From 0e24c9e23560249303631ec4eea3bd92df9089cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Mon, 14 Sep 2026 17:40:52 +0800 Subject: [PATCH] Add multi-platform publishing workflow --- .../multi-platform-publisher-1.1.15.zip | Bin 0 -> 26085 bytes .../multi-platform-publisher-1.1.15/README.md | 58 + .../background/service-worker.js | 556 ++++++++ .../content/admin-bridge.js | 84 ++ .../content/bilibili.js | 69 + .../content/douyin.js | 45 + .../content/platform-common.js | 907 +++++++++++++ .../content/wechat-channels.js | 67 + .../content/xianyu.js | 80 ++ .../content/xiaohongshu.js | 44 + .../manifest.json | 69 + .../popup/popup.html | 31 + .../popup/popup.js | 1 + docs/platform-publish.md | 68 + .../controller/TtCopyTemplateController.java | 6 +- .../controller/TtPublishTaskController.java | 105 ++ .../office/domain/PublishBridgeTask.java | 36 + .../ruoyi/office/domain/PublishMaterial.java | 45 + .../office/domain/PublishProjectRow.java | 56 + .../office/domain/PublishStatusUpdate.java | 16 + .../ruoyi/office/domain/TtCopyTemplate.java | 13 + .../ruoyi/office/domain/TtPublishTask.java | 81 ++ .../office/mapper/TtCopyTemplateMapper.java | 5 +- .../office/mapper/TtPublishTaskMapper.java | 20 + .../service/ITtCopyTemplateService.java | 7 +- .../office/service/ITtPublishTaskService.java | 28 + .../impl/TtCopyTemplateServiceImpl.java | 9 +- .../impl/TtPublishTaskServiceImpl.java | 524 ++++++++ .../mapper/office/TtCopyTemplateMapper.xml | 8 +- .../mapper/office/TtPublishTaskMapper.xml | 147 +++ .../impl/TtPublishTaskServiceImplTest.java | 363 ++++++ ruoyi-ui/src/api/office/copyTemplate.js | 9 +- ruoyi-ui/src/api/office/publish.js | 33 + ruoyi-ui/src/components/ImageUpload/index.vue | 62 +- ruoyi-ui/src/views/office/code/index.vue | 2 +- .../src/views/office/copyTemplate/index.vue | 81 +- ruoyi-ui/src/views/office/project/index.vue | 52 +- ruoyi-ui/src/views/office/publish/index.vue | 1130 +++++++++++++++++ sql/copy_template.sql | 10 + sql/copy_template_scene.sql | 31 + sql/platform_publish.sql | 80 ++ 41 files changed, 4980 insertions(+), 58 deletions(-) create mode 100644 browser-extension/multi-platform-publisher-1.1.15.zip create mode 100644 browser-extension/multi-platform-publisher-1.1.15/README.md create mode 100644 browser-extension/multi-platform-publisher-1.1.15/background/service-worker.js create mode 100644 browser-extension/multi-platform-publisher-1.1.15/content/admin-bridge.js create mode 100644 browser-extension/multi-platform-publisher-1.1.15/content/bilibili.js create mode 100644 browser-extension/multi-platform-publisher-1.1.15/content/douyin.js create mode 100644 browser-extension/multi-platform-publisher-1.1.15/content/platform-common.js create mode 100644 browser-extension/multi-platform-publisher-1.1.15/content/wechat-channels.js create mode 100644 browser-extension/multi-platform-publisher-1.1.15/content/xianyu.js create mode 100644 browser-extension/multi-platform-publisher-1.1.15/content/xiaohongshu.js create mode 100644 browser-extension/multi-platform-publisher-1.1.15/manifest.json create mode 100644 browser-extension/multi-platform-publisher-1.1.15/popup/popup.html create mode 100644 browser-extension/multi-platform-publisher-1.1.15/popup/popup.js create mode 100644 docs/platform-publish.md create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/controller/TtPublishTaskController.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishBridgeTask.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishMaterial.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishProjectRow.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishStatusUpdate.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/domain/TtPublishTask.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtPublishTaskMapper.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/ITtPublishTaskService.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java create mode 100644 ruoyi-office/src/main/resources/mapper/office/TtPublishTaskMapper.xml create mode 100644 ruoyi-office/src/test/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImplTest.java create mode 100644 ruoyi-ui/src/api/office/publish.js create mode 100644 ruoyi-ui/src/views/office/publish/index.vue create mode 100644 sql/copy_template_scene.sql create mode 100644 sql/platform_publish.sql diff --git a/browser-extension/multi-platform-publisher-1.1.15.zip b/browser-extension/multi-platform-publisher-1.1.15.zip new file mode 100644 index 0000000000000000000000000000000000000000..5a37c93782121e92926be333c5a36592ef115f59 GIT binary patch literal 26085 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
kKwf?TE-D&z| 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 { + chrome.storage.local.set({ publisherExtensionVersion: VERSION }) + recoverStoredBatches().catch(() => {}) +}) + +chrome.runtime.onStartup.addListener(() => { + recoverStoredBatches().catch(() => {}) +}) + +chrome.alarms.onAlarm.addListener(alarm => { + if (!alarm || !alarm.name.startsWith(BATCH_WATCHDOG_PREFIX)) return + const batchId = alarm.name.slice(BATCH_WATCHDOG_PREFIX.length) + continueTimedOutBatch(batchId).catch(() => {}) +}) + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || typeof message.type !== 'string') return false + + if (message.type === 'ADMIN_PING') { + sendResponse({ ok: isAllowedAdmin(sender.url), version: VERSION }) + return false + } + + if (message.type === 'ADMIN_START_PUBLISH') { + startPublish(message.payload, sender) + .then(result => sendResponse({ ok: true, ...result })) + .catch(error => sendResponse({ ok: false, message: readableError(error) })) + return true + } + + if (message.type === 'ADMIN_START_PUBLISH_BATCH') { + startPublishBatch(message.payload, sender) + .then(result => sendResponse({ ok: true, ...result })) + .catch(error => sendResponse({ ok: false, message: readableError(error) })) + return true + } + + if (message.type === 'PLATFORM_READY') { + resumeTaskForTab(sender.tab, message.platform) + .then(result => sendResponse(result)) + .catch(error => sendResponse({ ok: false, message: readableError(error) })) + return true + } + + if (message.type === 'PLATFORM_FILL_CONTENT') { + fillPlatformContent(message.payload, sender) + .then(result => sendResponse({ ok: true, ...result })) + .catch(error => sendResponse({ ok: false, message: readableError(error) })) + return true + } + + if (message.type === 'PUBLISH_STATUS') { + receivePlatformStatus(message.payload, sender) + .then(() => sendResponse({ ok: true })) + .catch(error => sendResponse({ ok: false, message: readableError(error) })) + return true + } + + return false +}) + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status !== 'complete') return + getContextForTab(tabId).then(context => { + if (!context || !isExpectedPlatformUrl(tab.url, context.platform)) return + sendTaskMessage(tabId, context) + }).catch(() => {}) +}) + +chrome.tabs.onRemoved.addListener(tabId => { + getContextForTab(tabId).then(async context => { + if (!context) return + if (!TERMINAL_STATUSES.has(context.status)) { + await updateStatus(context, 'CANCELLED', '平台页面已关闭', '') + await advanceBatch(context) + } + await chrome.storage.local.remove([tabKey(tabId), taskKey(context.taskId)]) + }).catch(() => {}) +}) + +async function startPublish(payload, sender) { + if (!isAllowedAdmin(sender.url) || !sender.tab) { + throw new Error('发布请求不是来自允许的后台页面') + } + validatePayload(payload) + const apiBaseUrl = normalizeApiBase(payload.apiBaseUrl, sender.url) + const task = await fetchBridgeTask(apiBaseUrl, payload.taskId, payload.token) + if (task.platform !== payload.platform || !PLATFORM_URLS[task.platform]) { + throw new Error('任务平台与页面请求不一致') + } + + const context = { + taskId: payload.taskId, + token: payload.token, + apiBaseUrl, + platform: task.platform, + task, + adminTabId: sender.tab.id, + batchId: payload.batchId || '', + status: 'OPENING', + createdAt: Date.now() + } + + await updateStatus(context, 'OPENING', '正在打开' + platformName(task.platform) + '创作者中心', '') + const tab = await chrome.tabs.create({ url: PLATFORM_URLS[task.platform], active: true }) + context.platformTabId = tab.id + await chrome.storage.local.set({ + [taskKey(context.taskId)]: context, + [tabKey(tab.id)]: context.taskId + }) + await sendTaskMessageWithRetry(tab.id, context, 15000) + return { taskId: context.taskId, tabId: tab.id } +} + +async function startPublishBatch(payload, sender) { + if (!isAllowedAdmin(sender.url) || !sender.tab) { + throw new Error('批量发布请求不是来自允许的后台页面') + } + if (!payload || typeof payload.batchId !== 'string' + || !/^publish_batch_[a-zA-Z0-9_]+$/.test(payload.batchId) + || !Array.isArray(payload.tasks) || !payload.tasks.length || payload.tasks.length > 5) { + throw new Error('多平台发布批次参数不正确') + } + const platforms = new Set() + const tasks = payload.tasks.map(item => { + const task = { + taskId: item.taskId, + token: item.token, + platform: item.platform, + apiBaseUrl: payload.apiBaseUrl + } + validatePayload(task) + if (platforms.has(task.platform)) throw new Error('多平台发布中存在重复平台') + platforms.add(task.platform) + return task + }) + const batch = { + batchId: payload.batchId, + tasks, + nextIndex: 0, + currentTaskId: '', + adminTabId: sender.tab.id, + adminUrl: sender.url, + createdAt: Date.now() + } + await chrome.storage.local.set({ [batchKey(batch.batchId)]: batch }) + await processNextBatchTask(batch.batchId) + return { batchId: batch.batchId, taskCount: tasks.length } +} + +async function processNextBatchTask(batchId) { + const stored = await chrome.storage.local.get(batchKey(batchId)) + const batch = stored[batchKey(batchId)] + if (!batch || batch.currentTaskId) return + if (batch.nextIndex >= batch.tasks.length) { + await chrome.storage.local.remove(batchKey(batchId)) + return + } + + const task = batch.tasks[batch.nextIndex] + batch.nextIndex += 1 + batch.currentTaskId = task.taskId + await chrome.storage.local.set({ [batchKey(batchId)]: batch }) + await scheduleBatchWatchdog(batchId) + try { + await startPublish( + { ...task, batchId }, + { url: batch.adminUrl, tab: { id: batch.adminTabId } } + ) + } catch (error) { + const context = { + taskId: task.taskId, + token: task.token, + apiBaseUrl: task.apiBaseUrl, + platform: task.platform, + adminTabId: batch.adminTabId, + batchId + } + try { + await updateStatus(context, 'FAILED', '平台任务启动失败', readableError(error)) + } catch (ignored) {} + await advanceBatch(context) + } +} + +async function advanceBatch(context) { + if (!context || !context.batchId) return + if (advancingBatches.has(context.batchId)) return + advancingBatches.add(context.batchId) + let shouldProcessNext = false + try { + const key = batchKey(context.batchId) + const stored = await chrome.storage.local.get(key) + const batch = stored[key] + if (!batch || batch.currentTaskId !== context.taskId) return + await chrome.alarms.clear(batchWatchdogName(context.batchId)) + batch.currentTaskId = '' + await chrome.storage.local.set({ [key]: batch }) + shouldProcessNext = true + } finally { + advancingBatches.delete(context.batchId) + } + // 启动下一项前必须先释放批次锁。否则下一项在启动阶段失败时, + // 它的补偿推进会因同一把锁仍被占用而直接返回,整个批次就会停住。 + if (shouldProcessNext) await processNextBatchTask(context.batchId) +} + +async function scheduleBatchWatchdog(batchId) { + await chrome.alarms.create(batchWatchdogName(batchId), { delayInMinutes: 4 }) +} + +async function continueTimedOutBatch(batchId) { + const stored = await chrome.storage.local.get(batchKey(batchId)) + const batch = stored[batchKey(batchId)] + if (!batch || !batch.currentTaskId) return + const taskId = batch.currentTaskId + const taskStored = await chrome.storage.local.get(taskKey(taskId)) + const context = taskStored[taskKey(taskId)] || { + taskId, + batchId, + token: '', + apiBaseUrl: '', + platform: '' + } + context.batchId = batchId + if (context.token && context.apiBaseUrl && !TERMINAL_STATUSES.has(context.status)) { + try { + await updateStatus( + context, + 'FAILED', + '当前平台处理超时,批次已自动继续', + '扩展超过4分钟未收到完成信号,请检查该平台页面' + ) + } catch (ignored) {} + } + await advanceBatch({ batchId, taskId }) +} + +async function recoverStoredBatches() { + const stored = await chrome.storage.local.get(null) + const batches = Object.keys(stored) + .filter(key => key.startsWith('publisher_batch_')) + .map(key => stored[key]) + .filter(Boolean) + for (const batch of batches) { + if (!batch.currentTaskId) { + await processNextBatchTask(batch.batchId) + continue + } + const context = stored[taskKey(batch.currentTaskId)] + if (context && BATCH_ADVANCE_STATUSES.has(context.status)) { + await advanceBatch(context) + } else { + await scheduleBatchWatchdog(batch.batchId) + } + } +} + +async function resumeTaskForTab(tab, platform) { + if (!tab || !tab.id) return { ok: false, message: '无法识别平台标签页' } + const context = await getContextForTab(tab.id) + if (!context) return { ok: false, message: '当前标签页没有待执行任务' } + if (context.platform !== platform || !isExpectedPlatformUrl(tab.url, platform)) { + return { ok: false, message: '平台标签页与发布任务不匹配' } + } + if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(context.status)) { + return { ok: false, message: '发布任务已经结束' } + } + await sendTaskMessage(tab.id, context) + return { ok: true, taskId: context.taskId } +} + +async function sendTaskMessage(tabId, context) { + try { + await chrome.tabs.sendMessage(tabId, { + type: context.status === 'READY_TO_PUBLISH' ? 'WATCH_PUBLISH_OUTCOME' : 'RUN_PUBLISH', + payload: { + task: context.task, + taskId: context.taskId, + token: context.token, + apiBaseUrl: context.apiBaseUrl, + batchId: context.batchId || '' + } + }) + return true + } catch (error) { + // 页面脚本可能仍在加载,PLATFORM_READY 会再次触发发送。 + return false + } +} + +async function sendTaskMessageWithRetry(tabId, context, timeout) { + const started = Date.now() + while (Date.now() - started < timeout) { + if (await sendTaskMessage(tabId, context)) return true + await sleep(500) + } + // 登录页或较慢的页面仍可在加载完成后通过 PLATFORM_READY 恢复任务。 + return false +} + +async function receivePlatformStatus(payload, sender) { + if (!payload || !payload.taskId || !payload.status || !sender.tab) { + throw new Error('平台状态参数不完整') + } + const context = await getContextForTab(sender.tab.id) + if (!context || context.taskId !== payload.taskId) { + throw new Error('未找到当前平台标签页对应的发布任务') + } + if (!isExpectedPlatformUrl(sender.url, context.platform)) { + throw new Error('平台状态来源不可信') + } + let statusError = null + try { + await updateStatus(context, payload.status, payload.message || '', payload.error || '') + } catch (error) { + statusError = error + } + // 批次推进以平台页面的完成信号为准。后台状态接口偶发失败时, + // 仍继续打开下一平台,避免一个回写请求把整个多平台批次卡住。 + if (BATCH_ADVANCE_STATUSES.has(payload.status)) { + await advanceBatch(context) + } + if (statusError) throw statusError +} + +async function fillPlatformContent(payload, sender) { + if (!sender.tab || !sender.tab.id || !payload || payload.platform !== 'wechat_channels' + || !isExpectedPlatformUrl(sender.url, payload.platform)) { + throw new Error('文案填写请求来源不可信') + } + const value = String(payload.value || '') + if (!value || value.length > 10000) throw new Error('视频号发布文案为空或过长') + const results = await chrome.scripting.executeScript({ + target: { tabId: sender.tab.id, allFrames: true }, + world: 'MAIN', + func: fillWechatChannelsContent, + args: [value] + }) + const frameResults = results.map(item => item.result).filter(Boolean) + const hit = frameResults.find(item => item.ok) + if (!hit) { + const details = frameResults.map(item => (item.url || 'unknown') + ': ' + (item.error || '未找到编辑器')) + throw new Error('视频号文案写入失败;' + details.join(' | ').slice(0, 800)) + } + return { frameUrl: hit.url } +} + +function fillWechatChannelsContent(value) { + const normalize = text => String(text || '').replace(/\s+/g, ' ').trim() + const isVisible = element => { + if (!element || !element.isConnected) return false + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + return rect.width > 0 && rect.height > 0 + && style.display !== 'none' && style.visibility !== 'hidden' + } + const queryAllDeep = (root, selector) => { + const matches = Array.from(root.querySelectorAll(selector)) + for (const element of root.querySelectorAll('*')) { + if (element.shadowRoot) matches.push(...queryAllDeep(element.shadowRoot, selector)) + } + return matches + } + const selectors = [ + 'div.input-editor[contenteditable][data-placeholder="添加描述"]', + '[contenteditable][data-placeholder*="描述"]', + '.post-desc-box [contenteditable]' + ] + let editor = null + for (const selector of selectors) { + const candidates = queryAllDeep(document, selector).filter(element => + isVisible(element.closest && element.closest('.post-desc-box') || element) + ) + if (candidates.length === 1) { + editor = candidates[0] + break + } + } + if (!editor) { + return { ok: false, url: location.href, error: '可见描述编辑器数量不为 1' } + } + + editor.focus() + const selection = getSelection() + const range = document.createRange() + range.selectNodeContents(editor) + selection.removeAllRanges() + selection.addRange(range) + let inserted = false + try { + inserted = document.execCommand('insertText', false, value) + if (!inserted) inserted = document.execCommand('insertHTML', false, value) + } catch (error) { + return { ok: false, url: location.href, error: error.message } + } + editor.dispatchEvent(new InputEvent('input', { + bubbles: true, inputType: 'insertText', data: value + })) + editor.dispatchEvent(new Event('change', { bubbles: true })) + editor.blur() + const current = normalize(editor.textContent) + return { + ok: inserted && current === normalize(value), + url: location.href, + error: inserted ? '写入后内容校验不一致' : '富文本写入命令未生效' + } +} + +async function updateStatus(context, status, message, error) { + const url = bridgeUrl(context, '/status') + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status, message, error }) + }) + const body = await readJson(response) + if (!response.ok || body.code !== 200) { + throw new Error(body.msg || '发布状态回写失败') + } + context.status = status + if (context.platformTabId) { + await chrome.storage.local.set({ [taskKey(context.taskId)]: context }) + } + await broadcastStatus(context, status, message, error) +} + +async function broadcastStatus(context, status, message, error) { + const event = { + type: 'ADMIN_PUBLISH_STATUS', + payload: { + taskId: context.taskId, + platform: context.platform, + status, + message, + error + } + } + if (context.adminTabId) { + try { + await chrome.tabs.sendMessage(context.adminTabId, event) + return + } catch (ignored) {} + } + const tabs = await chrome.tabs.query({}) + await Promise.all(tabs.filter(tab => isAllowedAdmin(tab.url)).map(tab => + chrome.tabs.sendMessage(tab.id, event).catch(() => {}) + )) +} + +async function fetchBridgeTask(apiBaseUrl, taskId, token) { + const url = apiBaseUrl + '/office/publish/bridge/task/' + encodeURIComponent(taskId) + + '?token=' + encodeURIComponent(token) + const response = await fetch(url, { method: 'GET', cache: 'no-store' }) + const body = await readJson(response) + if (!response.ok || body.code !== 200 || !body.data) { + throw new Error(body.msg || '读取发布任务失败') + } + return body.data +} + +async function readJson(response) { + try { + return await response.json() + } catch (error) { + return { code: response.status, msg: '服务返回了无法识别的内容' } + } +} + +async function getContextForTab(tabId) { + const tabEntry = await chrome.storage.local.get(tabKey(tabId)) + const taskId = tabEntry[tabKey(tabId)] + if (!taskId) return null + const taskEntry = await chrome.storage.local.get(taskKey(taskId)) + return taskEntry[taskKey(taskId)] || null +} + +function bridgeUrl(context, suffix) { + return context.apiBaseUrl + '/office/publish/bridge/task/' + encodeURIComponent(context.taskId) + + suffix + '?token=' + encodeURIComponent(context.token) +} + +function validatePayload(payload) { + if (!payload || typeof payload.taskId !== 'string' || typeof payload.token !== 'string' + || typeof payload.platform !== 'string' || typeof payload.apiBaseUrl !== 'string') { + throw new Error('发布任务参数不完整') + } + if (!PLATFORM_URLS[payload.platform]) throw new Error('不支持的发布平台') + if (!/^publish_[a-f0-9]{32}$/.test(payload.taskId)) throw new Error('任务编号格式不正确') + if (!/^[a-f0-9]{32}$/.test(payload.token)) throw new Error('任务凭证格式不正确') +} + +function normalizeApiBase(value, senderUrl) { + const api = new URL(value) + const sender = new URL(senderUrl) + if (api.origin !== sender.origin) throw new Error('任务接口必须与后台页面同源') + return api.origin + api.pathname.replace(/\/$/, '') +} + +function isAllowedAdmin(value) { + if (!value) return false + try { + const url = new URL(value) + const allowedHost = url.hostname === 'feast.yidaima.cn' + || url.hostname === 'localhost' + || url.hostname === '127.0.0.1' + return allowedHost && url.pathname.indexOf('/office/publish') >= 0 + } catch (error) { + return false + } +} + +function isExpectedPlatformUrl(value, platform) { + if (!value || !PLATFORM_HOSTS[platform]) return false + try { + const hostname = new URL(value).hostname + return hostname === PLATFORM_HOSTS[platform] + || (platform === 'xianyu' && hostname === 'www.goofish.pro') + } catch (error) { + return false + } +} + +function platformName(platform) { + return { xiaohongshu: '小红书', douyin: '抖音', wechat_channels: '视频号', bilibili: 'B站', xianyu: '闲鱼' }[platform] || platform +} + +function taskKey(taskId) { return 'publisher_task_' + taskId } +function tabKey(tabId) { return 'publisher_tab_' + tabId } +function batchKey(batchId) { return 'publisher_batch_' + batchId } +function batchWatchdogName(batchId) { return BATCH_WATCHDOG_PREFIX + batchId } +function readableError(error) { return error && error.message ? error.message : String(error || '未知错误') } +function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/admin-bridge.js b/browser-extension/multi-platform-publisher-1.1.15/content/admin-bridge.js new file mode 100644 index 0000000..c1d4002 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.15/content/admin-bridge.js @@ -0,0 +1,84 @@ +(() => { + const SOURCE_ADMIN = 'yidaima-admin' + const SOURCE_EXTENSION = 'yidaima-publisher-extension' + const VERSION = chrome.runtime.getManifest().version + + function isPublishPage() { + return location.pathname.indexOf('/office/publish') >= 0 + } + + function announceReady() { + if (!isPublishPage()) return + window.dispatchEvent(new CustomEvent('YIDAIMA_EXTENSION_READY', { + detail: { source: SOURCE_EXTENSION, version: VERSION } + })) + } + + window.addEventListener('YIDAIMA_EXTENSION_PING', event => { + if (!isPublishPage() || !event.detail || event.detail.source !== SOURCE_ADMIN) return + chrome.runtime.sendMessage({ type: 'ADMIN_PING' }, response => { + if (chrome.runtime.lastError || !response || !response.ok) return + announceReady() + }) + }) + + window.addEventListener('YIDAIMA_START_PUBLISH', event => { + const detail = event.detail + if (!isPublishPage() || !detail || detail.source !== SOURCE_ADMIN) return + const payload = { + taskId: detail.taskId, + token: detail.token, + platform: detail.platform, + apiBaseUrl: detail.apiBaseUrl + } + chrome.runtime.sendMessage({ type: 'ADMIN_START_PUBLISH', payload }, response => { + const error = chrome.runtime.lastError + window.dispatchEvent(new CustomEvent('YIDAIMA_PUBLISH_ACCEPTED', { + detail: { + source: SOURCE_EXTENSION, + taskId: payload.taskId, + ok: !error && Boolean(response && response.ok), + message: error ? error.message : (response && response.message) + } + })) + }) + }) + + window.addEventListener('YIDAIMA_START_PUBLISH_BATCH', event => { + const detail = event.detail + if (!isPublishPage() || !detail || detail.source !== SOURCE_ADMIN || !Array.isArray(detail.tasks)) return + const payload = { + batchId: detail.batchId, + apiBaseUrl: detail.apiBaseUrl, + tasks: detail.tasks.map(task => ({ + taskId: task.taskId, + token: task.token, + platform: task.platform + })) + } + chrome.runtime.sendMessage({ type: 'ADMIN_START_PUBLISH_BATCH', payload }, response => { + const error = chrome.runtime.lastError + window.dispatchEvent(new CustomEvent('YIDAIMA_PUBLISH_ACCEPTED', { + detail: { + source: SOURCE_EXTENSION, + batchId: payload.batchId, + ok: !error && Boolean(response && response.ok), + message: error ? error.message : (response && response.message) + } + })) + }) + }) + + chrome.runtime.onMessage.addListener(message => { + if (!message || message.type !== 'ADMIN_PUBLISH_STATUS' || !isPublishPage()) return + window.dispatchEvent(new CustomEvent('YIDAIMA_PUBLISH_STATUS', { + detail: { source: SOURCE_EXTENSION, ...message.payload } + })) + }) + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', announceReady, { once: true }) + } else { + announceReady() + } +})() diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/bilibili.js b/browser-extension/multi-platform-publisher-1.1.15/content/bilibili.js new file mode 100644 index 0000000..0509df6 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.15/content/bilibili.js @@ -0,0 +1,69 @@ +(() => { + const config = { + platform: 'bilibili', + platformName: 'B站', + fillAfterUploadSettled: true, + uploadSettleTimeout: 30 * 60 * 1000, + fillDelay: 1000, + videoModeLabels: [], + imageModeLabels: [], + uploadSelectors: [ + '.upload-btn input[type="file"]', + '.upload-wrapper input[type="file"]', + 'input[type="file"][accept*="video"]', + 'input[type="file"][accept*="mp4"]', + 'input[type="file"]' + ], + uploadDropZoneSelectors: [ + '.upload-btn', + '.upload-wrapper', + '[class*="upload"] [class*="drag"]' + ], + videoInputSelectors: [ + '.upload-btn input[type="file"]', + '.upload-wrapper input[type="file"]', + 'input[type="file"][accept*="video"]', + 'input[type="file"][accept*="mp4"]' + ], + imageInputSelectors: [], + coverInputSelectors: [ + '[class*="cover"] input[type="file"][accept*="image"]', + '[class*="cover"] input[type="file"]', + '[class*="poster"] input[type="file"][accept*="image"]' + ], + titleSelectors: [ + 'input[placeholder*="稿件标题"]', + 'input[placeholder*="视频标题"]', + 'input[placeholder*="标题"]', + 'input[maxlength="80"]', + '[class*="title"] input[type="text"]' + ], + contentSelectors: [ + 'textarea[placeholder*="视频简介"]', + 'textarea[placeholder*="稿件简介"]', + 'textarea[placeholder*="简介"]', + '[contenteditable="true"][data-placeholder*="简介"]', + '[contenteditable="true"][data-placeholder*="描述"]', + '.ql-editor[contenteditable="true"]', + '[class*="desc"] [contenteditable="true"]', + '[class*="desc"] textarea' + ], + progressSelectors: [ + '.upload-progress', + '.bcc-progress', + '[class*="upload"] [class*="progress"]', + '[role="progressbar"]' + ], + successTexts: [/^投稿成功[!!。]?$/, /^发布成功[!!。]?$/, /^稿件投递成功[!!。]?$/], + failureTexts: [/^投稿失败/, /^发布失败/, /^稿件投递失败/] + } + + chrome.runtime.onMessage.addListener(message => { + if (message && message.type === 'RUN_PUBLISH') { + globalThis.YidaimaPublisher.run(config, message.payload) + } else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') { + globalThis.YidaimaPublisher.watch(config, message.payload) + } + }) + chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform }) +})() diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/douyin.js b/browser-extension/multi-platform-publisher-1.1.15/content/douyin.js new file mode 100644 index 0000000..97fdec3 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.15/content/douyin.js @@ -0,0 +1,45 @@ +(() => { + const config = { + platform: 'douyin', + platformName: '抖音', + preserveContentLineBreaks: true, + useAtomicHtmlLineBreaks: true, + looseContentVerification: true, + videoModeLabels: [], + imageModeLabels: [], + uploadSelectors: [ + 'input[type="file"][accept*="video"]', + 'input[type="file"]' + ], + videoInputSelectors: ['input[type="file"][accept*="video"]'], + imageInputSelectors: [], + coverInputSelectors: [ + '[class*="cover"] input[type="file"][accept*="image"]', + '[class*="poster"] input[type="file"][accept*="image"]' + ], + titleSelectors: [ + 'input[placeholder*="标题"]', + 'textarea[placeholder*="标题"]', + '[class*="title"] input' + ], + contentSelectors: [ + '[contenteditable="true"][data-placeholder*="作品描述"]', + '[contenteditable="true"][data-placeholder*="描述"]', + 'textarea[placeholder*="作品描述"]', + 'textarea[placeholder*="描述"]', + '[class*="editor"] [contenteditable="true"]' + ], + progressSelectors: ['[class*="progress"]', '[role="progressbar"]'], + successTexts: [/^发布成功[!!。]?$/, /^作品发布成功[!!。]?$/], + failureTexts: [/^发布失败/, /^作品发布失败/] + } + + chrome.runtime.onMessage.addListener(message => { + if (message && message.type === 'RUN_PUBLISH') { + globalThis.YidaimaPublisher.run(config, message.payload) + } else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') { + globalThis.YidaimaPublisher.watch(config, message.payload) + } + }) + chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform }) +})() diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/platform-common.js b/browser-extension/multi-platform-publisher-1.1.15/content/platform-common.js new file mode 100644 index 0000000..c387613 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.15/content/platform-common.js @@ -0,0 +1,907 @@ +(() => { + const runningTasks = new Set() + + async function run(config, payload) { + const { task, taskId, token, apiBaseUrl } = payload || {} + if (!task || !taskId || !token || !apiBaseUrl || runningTasks.has(taskId)) return + runningTasks.add(taskId) + const context = { config, task, taskId, token, apiBaseUrl, batchId: payload.batchId || '' } + + try { + await report(context, 'DOWNLOADING', '正在准备发布素材') + await activatePublishMode(config, task) + const preparationWarnings = [] + const preparationWarning = await prepareForm(config) + if (preparationWarning) preparationWarnings.push(preparationWarning) + if (config.fillBeforeUpload) { + await fillPublishText(context) + } + const uploadTarget = await waitForUploadTarget(config, 60000) + const uploadInputs = uploadTarget.inputs + const uploadDropZone = uploadTarget.dropZone + if (!uploadInputs.length && !uploadDropZone) { + await report( + context, + 'LOGIN_REQUIRED', + '未找到发布表单,请登录平台后进入内容发布页面', + createPageDiagnostics() + ) + return + } + + const uploaded = await uploadAssets(context, uploadInputs, uploadDropZone) + if (!uploaded && !config.allowNoAssets) throw new Error('没有找到与发布素材匹配的上传控件') + + if (uploaded && config.fillAfterUploadSettled) { + await waitForUploadSettled(config, config.uploadSettleTimeout || 10 * 60 * 1000) + } + + if (!config.fillBeforeUpload || config.refillAfterUpload) { + await fillPublishText(context) + } + + await tryUploadCover(context) + if (uploaded && !config.fillAfterUploadSettled) { + try { + const settleTimeout = context.batchId + ? (config.batchUploadSettleTimeout || 60000) + : (config.uploadSettleTimeout || 10 * 60 * 1000) + await waitForUploadSettled(config, settleTimeout) + } catch (error) { + if (!context.batchId) throw error + preparationWarnings.push('平台仍显示上传处理中,批次已继续,请稍后检查本页面') + } + } + const combinedWarning = preparationWarnings.join(';') + const readyMessage = combinedWarning + ? '标题、文案和素材已处理;部分商品选项未能自动确认,请检查后再发布' + : '资料已填写完成,请检查后手动点击发布按钮' + await report(context, 'READY_TO_PUBLISH', readyMessage, combinedWarning) + showReadyNotice(config.platformName, combinedWarning) + watch(config, payload) + } catch (error) { + await report(context, 'FAILED', '发布资料填写失败', readableError(error)) + } finally { + runningTasks.delete(taskId) + } + } + + function watch(config, payload) { + const { task, taskId, token, apiBaseUrl } = payload || {} + if (!task || !taskId || !token || !apiBaseUrl) return + const watcherKey = 'watch:' + taskId + if (runningTasks.has(watcherKey)) return + runningTasks.add(watcherKey) + const context = { config, task, taskId, token, apiBaseUrl } + const observer = new MutationObserver(mutations => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + const text = String(node.innerText || node.textContent || '').trim() + if (!text || text.length > 500) continue + if (config.successTexts.some(pattern => pattern.test(text))) { + observer.disconnect() + runningTasks.delete(watcherKey) + report(context, 'SUCCEEDED', '检测到用户已完成平台发布').catch(() => {}) + return + } + if (config.failureTexts.some(pattern => pattern.test(text))) { + observer.disconnect() + runningTasks.delete(watcherKey) + report(context, 'FAILED', '平台提示发布失败', text.slice(0, 300)).catch(() => {}) + return + } + } + } + }) + observer.observe(document.documentElement, { childList: true, subtree: true }) + setTimeout(() => { + observer.disconnect() + runningTasks.delete(watcherKey) + }, 30 * 60 * 1000) + } + + async function uploadAssets(context, initialInputs, uploadDropZone) { + const { task, config } = context + let uploaded = false + + if (task.hasVideo) { + await report(context, 'DOWNLOADING', '正在读取发布视频') + const video = await fetchAssetFile(context, 'video', null, 'publish-video.mp4') + const input = findInput(initialInputs, 'video', config.videoInputSelectors) + if (input) { + setInputFiles(input, [video]) + } else if (uploadDropZone) { + dropFiles(uploadDropZone, [video]) + } else { + throw new Error('未找到视频上传控件') + } + uploaded = true + await report(context, 'UPLOADING', '视频已交给平台上传') + } else if (task.imageCount > 0) { + await report(context, 'DOWNLOADING', '正在读取发布图片') + const images = [] + for (let index = 0; index < task.imageCount; index++) { + images.push(await fetchAssetFile(context, 'image', index, 'publish-image-' + (index + 1) + '.jpg')) + } + const latestInputs = queryVisibleInputs(context.config.uploadSelectors) + const input = findInput(latestInputs.length ? latestInputs : initialInputs, 'image', config.imageInputSelectors) + if (!input) throw new Error('未找到图片上传控件') + setInputFiles(input, input.multiple ? images : [images[0]]) + uploaded = true + await report(context, 'UPLOADING', '图片已交给平台上传') + } + return uploaded + } + + async function tryUploadCover(context) { + if (!context.task.hasCover || !context.config.coverInputSelectors.length) return + const coverInput = queryFirstVisible(context.config.coverInputSelectors) + if (!coverInput) return + const cover = await fetchAssetFile(context, 'cover', null, 'publish-cover.jpg') + setInputFiles(coverInput, [cover]) + } + + async function fetchAssetFile(context, kind, index, fallbackName) { + const suffix = index === null ? '' : '/' + index + const url = context.apiBaseUrl + '/office/publish/bridge/task/' + + encodeURIComponent(context.taskId) + '/asset/' + kind + suffix + + '?token=' + encodeURIComponent(context.token) + const response = await fetch(url, { method: 'GET', cache: 'no-store' }) + if (!response.ok) throw new Error('读取' + assetName(kind) + '失败,HTTP ' + response.status) + const blob = await response.blob() + if (!blob.size) throw new Error(assetName(kind) + '文件为空') + const disposition = response.headers.get('Content-Disposition') || '' + const fileName = parseFileName(disposition) || fallbackName + return new File([blob], fileName, { type: blob.type || mimeForName(fileName), lastModified: Date.now() }) + } + + async function activatePublishMode(config, task) { + const labels = task.hasVideo ? config.videoModeLabels : config.imageModeLabels + for (const label of labels) { + const element = findClickableByText(label) + if (element) { + element.click() + await sleep(1000) + return + } + } + } + + async function prepareForm(config) { + await dismissBlockingNotice(config.blockingNotice) + const warnings = [] + try { + await prepareCategory(config.category) + } catch (error) { + if (!config.continueAfterCategoryFailure) throw error + warnings.push(readableError(error)) + } + for (const choice of config.prepareTextChoices || []) { + try { + await prepareTextChoice(choice) + } catch (error) { + if (!config.continueAfterCategoryFailure) throw error + warnings.push(readableError(error)) + } + } + for (const step of config.prepareClickSteps || []) { + const element = queryFirstVisible(step.selectors || []) + if (!element) continue + element.click() + await sleep(step.wait || 600) + } + return warnings.join(';') + } + + async function fillPublishText(context) { + const { config, task } = context + await report(context, 'FILLING', '正在填写标题、文案和标签') + await sleep(config.fillDelay || 500) + const titleElement = config.skipTitle + ? null + : await waitForAny( + config.titleSelectors, + config.titleOptional ? (config.optionalTitleTimeout || 3000) : 180000 + ) + if (!config.skipTitle && !titleElement && !config.titleOptional) { + throw new Error('未找到标题输入框,平台页面可能已经更新') + } + if (titleElement) { + const title = transformTitle(config, task.title || '') + await fillAndVerify(config.titleSelectors, title, '标题') + } + + let content = !titleElement && config.mergeTitleIntoContent + ? mergeTitleAndContent(task.title, task.content) + : task.content + if (!content && config.fallbackContentToTitle) content = task.title + const fullContent = buildContent(content, task.tags) + if (!fullContent) return + const contentElement = await waitForAny(config.contentSelectors, 180000) + if (!contentElement) throw new Error('未找到文案输入框,平台页面可能已经更新') + if (config.useMainWorldContent) { + const fillResult = await chrome.runtime.sendMessage({ + type: 'PLATFORM_FILL_CONTENT', + payload: { platform: config.platform, value: fullContent } + }) + if (!fillResult || !fillResult.ok) { + throw new Error(fillResult && fillResult.message ? fillResult.message : '平台文案写入失败') + } + } else { + await fillAndVerify(config.contentSelectors, fullContent, '文案', { + preserveLineBreaks: Boolean(config.preserveContentLineBreaks), + useAtomicHtmlLineBreaks: Boolean(config.useAtomicHtmlLineBreaks), + looseVerification: Boolean(config.looseContentVerification) + }) + } + } + + async function prepareTextChoice(choice) { + const label = typeof choice === 'string' ? choice : choice.label + const timeout = typeof choice === 'string' ? 5000 : (choice.timeout || 5000) + const started = Date.now() + let element = null + while (Date.now() - started < timeout) { + element = findExactCategoryText(['button, label, [role="button"], body *'], label) + if (element) break + await sleep(250) + } + if (!element) throw new Error('未找到商品选项:' + label) + if (!isTextChoiceSelected(element)) { + dispatchPointerClick(element) + await sleep(typeof choice === 'string' ? 350 : (choice.wait || 350)) + element = findExactCategoryText(['button, label, [role="button"], body *'], label) || element + if (!isTextChoiceSelected(element) && element.parentElement) { + dispatchPointerClick(element.parentElement) + await sleep(250) + } + } + } + + function isTextChoiceSelected(element) { + let current = element + for (let depth = 0; current && depth < 4; depth++, current = current.parentElement) { + const className = String(current.className || '') + if (/(^|[-_\s])(active|selected|checked|current)([-_\s]|$)/i.test(className)) return true + if (current.getAttribute('aria-selected') === 'true' || current.getAttribute('aria-checked') === 'true') return true + if ('checked' in current && current.checked) return true + } + return false + } + + async function dismissBlockingNotice(notice) { + if (!notice) return + for (const currentDocument of collectAccessibleDocuments()) { + const dialogs = queryAllDeep(currentDocument, '[role="dialog"], .el-dialog__wrapper, .el-message-box__wrapper') + .filter(isVisible) + for (const dialog of dialogs) { + const text = String(dialog.textContent || '').replace(/\s+/g, '') + if (!text.includes(notice.messageText)) continue + const dismiss = queryAllDeep(dialog, 'button, [role="button"]').find(element => + isVisible(element) && String(element.textContent || '').trim() === notice.dismissText + ) + if (dismiss) { + dismiss.click() + await sleep(500) + return + } + } + } + } + + async function prepareCategory(category) { + if (!category) return + const input = await waitForAny(category.inputSelectors || [], 60000) + if (!input) throw new Error('未找到商品分类选择框,闲鱼页面可能已经更新') + const targetPath = category.path || [] + const targetLeaf = targetPath[targetPath.length - 1] || '' + const targetFullPath = category.resultText || targetPath.join('/') + if (isCategorySelected(input, targetFullPath, targetLeaf)) return + + const history = queryFirstVisible(category.historySelectors || []) + if (history) { + history.click() + await sleep(800) + if (!targetLeaf || isCategorySelected(input, targetFullPath, targetLeaf)) return + } + + input.click() + await sleep(500) + for (const segment of targetPath) { + const option = await waitForCategoryOption(category, segment, category.optionTimeout || 1800) + if (!option) break + clickCategoryResult(option) + await sleep(500) + if (isCategorySelected(input, targetFullPath, targetLeaf)) return + } + + // 分级菜单未命中时,使用分类框的关键词搜索固定末级分类。 + input.click() + setElementValue(input, targetLeaf, true) + await sleep(800) + const searchResult = await waitForCategorySearchResult(category, targetFullPath, targetLeaf, 6000) + if (!searchResult) throw new Error('分类搜索结果已打开,但未找到可点击的完整分类:' + targetFullPath) + clickCategoryResult(searchResult) + let selected = await waitForCategorySelection(input, category, targetFullPath, targetLeaf, 2500) + if (!selected) { + await selectCategoryWithKeyboard(input) + selected = await waitForCategorySelection(input, category, targetFullPath, targetLeaf, 2500) + } + if (!selected) throw new Error('分类搜索结果未能选中:' + targetFullPath) + } + + async function waitForCategorySearchResult(category, fullPath, leaf, timeout) { + const selectors = (category.searchResultSelectors || []) + .concat(category.optionSelectors || []) + .concat(['[role="option"]', '[class*="suggest"]', '[class*="dropdown"] li']) + const started = Date.now() + while (Date.now() - started < timeout) { + const exact = findExactCategoryText(selectors, fullPath) + if (exact) return exact + + // 闲管家会把搜索结果渲染成普通 div/span,不一定带下拉选项的固定类名。 + const fallback = findExactCategoryText(['body *'], fullPath) + if (fallback) return fallback + + const leafOption = await waitForCategoryOption(category, leaf, 300) + if (leafOption) return leafOption + await sleep(250) + } + return null + } + + function findExactCategoryText(selectors, expectedText) { + const normalizedExpected = normalizeCategoryText(expectedText) + if (!normalizedExpected) return null + const candidates = [] + for (const selector of selectors) { + for (const element of queryAllAccessible(selector)) { + if (!isVisible(element)) continue + if (normalizeCategoryText(element.textContent) !== normalizedExpected) continue + if (!candidates.includes(element)) candidates.push(element) + } + } + return smallestVisible(candidates) + } + + function clickCategoryResult(element) { + element.scrollIntoView({ block: 'center', inline: 'nearest' }) + const ancestor = element.closest( + '[role="option"], [role="menuitem"], li, button, [class*="suggest"], [class*="option"], [class*="item"]' + ) || element + const rect = element.getBoundingClientRect() + const documentAtPoint = element.ownerDocument + const hitTarget = documentAtPoint.elementFromPoint( + Math.max(0, rect.left + Math.min(rect.width / 2, 24)), + Math.max(0, rect.top + rect.height / 2) + ) + const clickable = hitTarget && element.contains(hitTarget) ? hitTarget : ancestor + dispatchPointerClick(clickable) + if (clickable !== ancestor) ancestor.click() + } + + function dispatchPointerClick(element) { + const view = element.ownerDocument.defaultView || window + const common = { bubbles: true, cancelable: true, composed: true, view } + for (const type of ['pointerover', 'mouseover', 'pointermove', 'mousemove']) { + const EventConstructor = type.startsWith('pointer') && view.PointerEvent + ? view.PointerEvent + : view.MouseEvent + element.dispatchEvent(new EventConstructor(type, common)) + } + if (view.PointerEvent) element.dispatchEvent(new view.PointerEvent('pointerdown', common)) + element.dispatchEvent(new view.MouseEvent('mousedown', common)) + if (view.PointerEvent) element.dispatchEvent(new view.PointerEvent('pointerup', common)) + element.dispatchEvent(new view.MouseEvent('mouseup', common)) + element.click() + } + + async function selectCategoryWithKeyboard(input) { + input.focus() + dispatchKeyboardEvent(input, 'keydown', 'ArrowDown', 40) + dispatchKeyboardEvent(input, 'keyup', 'ArrowDown', 40) + await sleep(150) + dispatchKeyboardEvent(input, 'keydown', 'Enter', 13) + dispatchKeyboardEvent(input, 'keyup', 'Enter', 13) + } + + function dispatchKeyboardEvent(element, type, key, keyCode) { + const view = element.ownerDocument.defaultView || window + element.dispatchEvent(new view.KeyboardEvent(type, { + key, + code: key === 'Enter' ? 'Enter' : 'ArrowDown', + keyCode, + which: keyCode, + bubbles: true, + cancelable: true, + composed: true + })) + } + + async function waitForCategorySelection(input, category, fullPath, leaf, timeout) { + const started = Date.now() + while (Date.now() - started < timeout) { + if (isCategorySelected(input, fullPath, leaf)) return true + const resultStillOpen = findExactCategoryText( + (category.searchResultSelectors || []) + .concat(category.optionSelectors || []) + .concat(['[role="option"]', 'body *']), + fullPath + ) + const inputValue = normalizeCategoryText(input.value) + if (!resultStillOpen && inputValue === normalizeCategoryText(leaf)) return true + await sleep(250) + } + return false + } + + function isCategorySelected(input, fullPath, leaf) { + const value = normalizeCategoryText(input && input.value) + const normalizedPath = normalizeCategoryText(fullPath) + const normalizedLeaf = normalizeCategoryText(leaf) + return Boolean(value && ( + value === normalizedPath + || value.endsWith('/' + normalizedLeaf) + || value.endsWith('>' + normalizedLeaf) + )) + } + + function normalizeCategoryText(value) { + return String(value || '') + .replace(/[>>]/g, '/') + .replace(/\s+/g, '') + .replace(/\/{2,}/g, '/') + .replace(/^\/|\/$/g, '') + } + + async function waitForCategoryOption(category, label, timeout) { + const started = Date.now() + while (Date.now() - started < timeout) { + for (const selector of category.optionSelectors || []) { + const candidates = queryAllAccessible(selector).filter(element => { + if (!isVisible(element)) return false + const text = String(element.textContent || '').replace(/\s+/g, '') + return text === label || text.endsWith('/' + label) || text.endsWith(label) + }) + if (candidates.length) { + const candidate = smallestVisible(candidates) + return candidate.closest('li, [role="menuitem"], button') || candidate + } + } + if (category.allowGlobalTextOption) { + const candidate = findExactCategoryText(['body *'], label) + if (candidate) return candidate + } + await sleep(250) + } + return null + } + + function findClickableByText(label) { + // 这里只允许切换发布类型标签,不扫描或点击普通按钮,避免误触最终发布。 + const candidates = document.querySelectorAll('[role="tab"], [class*="tab-item"], [class*="tabItem"]') + for (const candidate of candidates) { + if (!isVisible(candidate)) continue + const text = (candidate.innerText || candidate.textContent || '').trim() + if (text === label || text.indexOf(label) >= 0) return candidate + } + return null + } + + async function waitForUploadTarget(config, timeout) { + const started = Date.now() + while (Date.now() - started < timeout) { + const inputs = queryVisibleInputs(config.uploadSelectors) + if (inputs.length) return { inputs, dropZone: null } + const dropZone = findUploadDropZone(config) + if (dropZone) return { inputs: [], dropZone } + await sleep(500) + } + return { inputs: [], dropZone: null } + } + + async function waitForAny(selectors, timeout) { + const started = Date.now() + while (Date.now() - started < timeout) { + const element = queryFirstVisible(selectors) + if (element) return element + await sleep(400) + } + return null + } + + async function fillAndVerify(selectors, value, label, options = {}) { + const normalize = options.preserveLineBreaks ? normalizeMultilineValue : normalizeFieldValue + const expected = normalize(value) + for (let attempt = 0; attempt < 3; attempt++) { + const element = await waitForAny(selectors, 10000) + if (!element) break + setElementValue(element, value, false, options) + await sleep(500 + attempt * 500) + const current = queryFirstVisible(selectors) + if (current && valuesMatch(readElementValue(current), expected, normalize, options)) return + } + throw new Error(label + '写入后校验失败,平台页面可能已经更新') + } + + function valuesMatch(actualValue, expected, normalize, options) { + const actual = normalize(actualValue) + if (actual === expected) return true + if (!options.looseVerification) return false + return normalizeLooseEditorValue(actual) === normalizeLooseEditorValue(expected) + } + + function readElementValue(element) { + if (element.isContentEditable || element.getAttribute('contenteditable') === 'true') { + return element.innerText || element.textContent || '' + } + return element.value || '' + } + + function normalizeFieldValue(value) { + return String(value || '').replace(/\r\n/g, '\n').trim() + } + + function normalizeMultilineValue(value) { + return String(value || '') + .replace(/\r\n?/g, '\n') + .replace(/\u00a0/g, ' ') + .replace(/[\u200b-\u200d\ufeff]/g, '') + .split('\n') + .map(line => line.replace(/[ \t]+$/g, '')) + .join('\n') + .trim() + } + + function normalizeLooseEditorValue(value) { + return String(value || '') + .replace(/\u00a0/g, ' ') + .replace(/[\u200b-\u200d\ufeff]/g, '') + .replace(/\s+/g, '') + } + + function transformTitle(config, value) { + let title = String(value || '') + if (typeof config.titleTransform === 'function') title = config.titleTransform(title) + if (config.titleMaxLength) title = Array.from(title).slice(0, config.titleMaxLength).join('') + return title.trim() + } + + async function waitForUploadSettled(config, timeout) { + const started = Date.now() + let calmChecks = 0 + await sleep(5000) + while (Date.now() - started < timeout) { + const pageText = collectAccessibleDocuments() + .map(doc => (doc.body && doc.body.innerText) || '') + .join('\n') + if (/上传失败|文件损坏|格式不支持|重新上传/.test(pageText)) { + throw new Error('平台提示素材上传失败或格式不支持') + } + const progressTexts = queryAllAccessible(config.progressSelectors.join(',')) + .filter(isVisible) + .map(element => (element.innerText || element.textContent || '').trim()) + const uploading = progressTexts.some(text => /(^|\D)([1-9]|[1-9]\d)%/.test(text) && !/100%/.test(text)) + if (uploading) { + calmChecks = 0 + } else { + calmChecks++ + if (calmChecks >= 3) return + } + await sleep(1500) + } + throw new Error('等待平台上传完成超时') + } + + function findInput(inputs, kind, preferredSelectors) { + const preferred = queryFirstVisible(preferredSelectors) + if (preferred) return preferred + const matcher = kind === 'video' ? /video/i : /image|png|jpe?g|webp/i + return inputs.find(input => matcher.test(input.accept || '')) || inputs[0] || null + } + + function queryVisibleInputs(selectors) { + const result = [] + for (const selector of selectors) { + queryAllAccessible(selector).forEach(element => { + if (isFileInput(element) && !result.includes(element)) { + result.push(element) + } + }) + } + return result + } + + function queryFirstVisible(selectors) { + for (const selector of selectors) { + const elements = queryAllAccessible(selector) + for (const element of elements) { + if (isVisible(element) || isFileInput(element)) return element + } + } + return null + } + + function setInputFiles(input, files) { + const view = input.ownerDocument.defaultView || window + const transfer = new view.DataTransfer() + files.forEach(file => { + const transferable = file instanceof view.File + ? file + : new view.File([file], file.name, { type: file.type, lastModified: file.lastModified }) + transfer.items.add(transferable) + }) + input.files = transfer.files + input.dispatchEvent(new view.Event('input', { bubbles: true, composed: true })) + input.dispatchEvent(new view.Event('change', { bubbles: true, composed: true })) + if (!input.files || input.files.length === 0) throw new Error('浏览器未能把素材交给上传控件') + } + + function dropFiles(target, files) { + const view = target.ownerDocument.defaultView || window + const transfer = new view.DataTransfer() + files.forEach(file => { + const transferable = file instanceof view.File + ? file + : new view.File([file], file.name, { type: file.type, lastModified: file.lastModified }) + transfer.items.add(transferable) + }) + for (const type of ['dragenter', 'dragover', 'drop']) { + target.dispatchEvent(new view.DragEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + dataTransfer: transfer + })) + } + } + + function setElementValue(element, value, keepFocus, options = {}) { + const view = element.ownerDocument.defaultView || window + element.focus() + if (element.isContentEditable || element.getAttribute('contenteditable') === 'true') { + let nativeInputDispatched = false + if (options.preserveLineBreaks) { + nativeInputDispatched = options.useAtomicHtmlLineBreaks + ? setRichTextWithAtomicLineBreaks(element, value) + : setRichTextWithLineBreaks(element, value) + } else { + element.innerHTML = '' + element.textContent = value + } + if (!nativeInputDispatched) { + element.dispatchEvent(new view.InputEvent('input', { + bubbles: true, + composed: true, + inputType: 'insertReplacementText', + data: null + })) + } + } else { + const prototype = element instanceof view.HTMLTextAreaElement + ? view.HTMLTextAreaElement.prototype + : view.HTMLInputElement.prototype + const setter = Object.getOwnPropertyDescriptor(prototype, 'value').set + setter.call(element, value) + const inputEvent = view.InputEvent + ? new view.InputEvent('input', { + bubbles: true, + composed: true, + inputType: 'insertText', + data: value + }) + : new view.Event('input', { bubbles: true, composed: true }) + element.dispatchEvent(inputEvent) + element.dispatchEvent(new view.Event('change', { bubbles: true, composed: true })) + } + if (!keepFocus) element.blur() + } + + function setRichTextWithLineBreaks(element, value) { + const document = element.ownerDocument + const selection = document.getSelection ? document.getSelection() : null + let inserted = false + if (selection) { + const range = document.createRange() + range.selectNodeContents(element) + selection.removeAllRanges() + selection.addRange(range) + try { + inserted = document.execCommand('insertText', false, String(value || '')) + } catch (ignored) {} + } + // execCommand 已经触发编辑器自身的输入事件。成功后不要再派发携带全文的 + // input 事件,否则部分受控富文本编辑器会把同一份内容再次追加。 + if (inserted) return true + + element.innerHTML = '' + const lines = String(value || '').replace(/\r\n?/g, '\n').split('\n') + lines.forEach(line => { + const block = document.createElement('div') + if (line) { + block.appendChild(document.createTextNode(line)) + } else { + block.appendChild(document.createElement('br')) + } + element.appendChild(block) + }) + return false + } + + function setRichTextWithAtomicLineBreaks(element, value) { + const document = element.ownerDocument + const selection = document.getSelection ? document.getSelection() : null + if (!selection) return false + + const container = document.createElement('div') + const lines = String(value || '').replace(/\r\n?/g, '\n').split('\n') + lines.forEach((line, index) => { + if (index > 0) container.appendChild(document.createElement('br')) + container.appendChild(document.createTextNode(line)) + }) + + const range = document.createRange() + range.selectNodeContents(element) + selection.removeAllRanges() + selection.addRange(range) + + try { + // 一次命令同时替换全文和换行,避免受控编辑器在逐行写入期间重绘, + // 导致光标复位并反复插入文案开头。 + if (document.execCommand('insertHTML', false, container.innerHTML)) return true + } catch (ignored) {} + + element.replaceChildren(...Array.from(container.childNodes).map(node => node.cloneNode(true))) + return false + } + + async function report(context, status, message, error) { + await chrome.runtime.sendMessage({ + type: 'PUBLISH_STATUS', + payload: { taskId: context.taskId, status, message, error: error || '' } + }) + } + + function showReadyNotice(platformName, preparationWarning) { + const existing = document.getElementById('yidaima-publisher-ready-notice') + if (existing) existing.remove() + const notice = document.createElement('div') + notice.id = 'yidaima-publisher-ready-notice' + notice.textContent = preparationWarning + ? platformName + '资料已填写;部分商品选项请手动检查后发布' + : platformName + '资料已填写完成,请检查后手动点击发布按钮' + Object.assign(notice.style, { + position: 'fixed', top: '20px', left: '50%', transform: 'translateX(-50%)', + zIndex: '2147483647', padding: '14px 22px', color: '#fff', background: '#1677ff', + borderRadius: '8px', boxShadow: '0 8px 28px rgba(0,0,0,.24)', fontSize: '15px' + }) + document.documentElement.appendChild(notice) + setTimeout(() => notice.remove(), 12000) + } + + function buildContent(content, tags) { + const tagText = (tags || []).map(tag => '#' + String(tag).replace(/^#/, '')).join(' ') + return [content || '', tagText].filter(Boolean).join('\n\n') + } + + function mergeTitleAndContent(title, content) { + const normalizedTitle = String(title || '').trim() + const normalizedContent = String(content || '').trim() + if (!normalizedTitle || normalizedContent.indexOf(normalizedTitle) >= 0) return normalizedContent + return [normalizedTitle, normalizedContent].filter(Boolean).join('\n\n') + } + + function parseFileName(disposition) { + const utf = disposition.match(/filename\*=UTF-8''([^;]+)/i) + if (utf) { + try { return decodeURIComponent(utf[1]) } catch (ignored) {} + } + const plain = disposition.match(/filename="?([^";]+)"?/i) + return plain ? plain[1] : '' + } + + function mimeForName(name) { + const extension = String(name).toLowerCase().split('.').pop() + return { + mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/x-m4v', webm: 'video/webm', + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp' + }[extension] || 'application/octet-stream' + } + + function assetName(kind) { + return { video: '视频', image: '图片', cover: '封面' }[kind] || '素材' + } + + function isVisible(element) { + if (!element || !element.isConnected) return false + const view = element.ownerDocument.defaultView || window + const style = view.getComputedStyle(element) + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + && style.display !== 'none' && style.visibility !== 'hidden' + } + + function isFileInput(element) { + if (!element || !element.ownerDocument) return false + const view = element.ownerDocument.defaultView || window + return element instanceof view.HTMLInputElement && element.type === 'file' + } + + function queryAllDeep(root, selector) { + const matches = Array.from(root.querySelectorAll(selector)) + for (const element of root.querySelectorAll('*')) { + if (element.shadowRoot) matches.push(...queryAllDeep(element.shadowRoot, selector)) + } + return matches + } + + function collectAccessibleDocuments(rootWindow = window) { + const documents = [] + const seen = new Set() + const visit = currentWindow => { + try { + const currentDocument = currentWindow.document + if (!currentDocument || seen.has(currentDocument)) return + seen.add(currentDocument) + documents.push(currentDocument) + for (const frame of queryAllDeep(currentDocument, 'iframe, frame')) { + if (frame.contentWindow) visit(frame.contentWindow) + } + } catch (ignored) {} + } + visit(rootWindow) + return documents + } + + function queryAllAccessible(selector) { + if (!selector) return [] + const matches = [] + const seen = new Set() + for (const currentDocument of collectAccessibleDocuments()) { + for (const element of queryAllDeep(currentDocument, selector)) { + if (!seen.has(element)) { + seen.add(element) + matches.push(element) + } + } + } + return matches + } + + function findUploadDropZone(config) { + for (const selector of config.uploadDropZoneSelectors || []) { + const candidates = queryAllAccessible(selector).filter(isVisible) + if (candidates.length) return smallestVisible(candidates) + } + const candidates = queryAllAccessible('div, label, section').filter(element => { + if (!isVisible(element)) return false + const text = String(element.textContent || '').replace(/\s+/g, ' ').trim() + return text.indexOf('上传时长') >= 0 + && (text.indexOf('20GB') >= 0 || text.indexOf('MP4/H.264') >= 0) + }) + return smallestVisible(candidates) + } + + function smallestVisible(elements) { + return elements.slice().sort((left, right) => { + const leftRect = left.getBoundingClientRect() + const rightRect = right.getBoundingClientRect() + return leftRect.width * leftRect.height - rightRect.width * rightRect.height + })[0] || null + } + + function createPageDiagnostics() { + const documents = collectAccessibleDocuments().map(currentDocument => ({ + url: currentDocument.defaultView && currentDocument.defaultView.location.href, + fileInputs: queryAllDeep(currentDocument, 'input[type="file"]').length, + editables: queryAllDeep(currentDocument, '[contenteditable]').length, + hasUploadText: String(currentDocument.body && currentDocument.body.innerText || '').indexOf('上传时长') >= 0 + })) + return '页面结构诊断:' + JSON.stringify(documents).slice(0, 900) + } + + function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } + function readableError(error) { return error && error.message ? error.message : String(error || '未知错误') } + + globalThis.YidaimaPublisher = Object.freeze({ run, watch }) +})() diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/wechat-channels.js b/browser-extension/multi-platform-publisher-1.1.15/content/wechat-channels.js new file mode 100644 index 0000000..94b8230 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.15/content/wechat-channels.js @@ -0,0 +1,67 @@ +(() => { + const config = { + platform: 'wechat_channels', + platformName: '视频号', + skipTitle: true, + mergeTitleIntoContent: false, + useMainWorldContent: true, + titleMaxLength: 30, + titleTransform: value => String(value || '') + .replace(/[【】\[\]()(){}<>]/g, ' ') + .replace(/[^\u3400-\u9fffA-Za-z0-9《》“”‘’"'::++??%%℃,,、。!!\s]/g, ' ') + .replace(/\s+/g, ' '), + batchUploadSettleTimeout: 30000, + videoModeLabels: [], + imageModeLabels: [], + uploadSelectors: [ + 'input[type="file"][accept*="video"]', + 'input[type="file"]' + ], + uploadDropZoneSelectors: [ + 'div.upload-content', + '[class*="upload-content"]', + '[class*="upload-wrapper"]' + ], + videoInputSelectors: ['input[type="file"][accept*="video"]'], + imageInputSelectors: [], + coverInputSelectors: [ + '[class*="cover"] input[type="file"][accept*="image"]', + '[class*="poster"] input[type="file"][accept*="image"]' + ], + titleSelectors: [ + 'input[type="text"][placeholder="填写短标题有机会获得更多流量"]', + 'input[type="text"][placeholder*="短标题"]', + 'input[placeholder*="标题"]', + 'textarea[placeholder*="标题"]', + '[class*="title"] input' + ], + contentSelectors: [ + 'div.input-editor[contenteditable][data-placeholder="添加描述"]', + '[contenteditable="true"][data-placeholder*="添加描述"]', + '[contenteditable="true"][data-placeholder*="描述"]', + '[contenteditable="true"][placeholder*="描述"]', + '[contenteditable="true"][aria-label*="描述"]', + 'textarea[placeholder*="描述"]', + 'textarea[placeholder*="文案"]', + '[class*="post-desc"] [contenteditable="true"]', + '[class*="post_desc"] [contenteditable="true"]', + '[class*="desc"] [contenteditable="true"]', + '.post-desc-box [contenteditable]', + '[class*="editor"][contenteditable="true"]', + '[class*="editor"] [contenteditable="true"]', + '.ProseMirror[contenteditable="true"]' + ], + progressSelectors: ['[class*="progress"]', '[role="progressbar"]'], + successTexts: [/^发表成功[!!。]?$/, /^发布成功[!!。]?$/, /^动态发表成功[!!。]?$/], + failureTexts: [/^发表失败/, /^发布失败/] + } + + chrome.runtime.onMessage.addListener(message => { + if (message && message.type === 'RUN_PUBLISH') { + globalThis.YidaimaPublisher.run(config, message.payload) + } else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') { + globalThis.YidaimaPublisher.watch(config, message.payload) + } + }) + chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform }) +})() diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/xianyu.js b/browser-extension/multi-platform-publisher-1.1.15/content/xianyu.js new file mode 100644 index 0000000..34daefb --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.15/content/xianyu.js @@ -0,0 +1,80 @@ +(() => { + const config = { + platform: 'xianyu', + platformName: '闲鱼', + fillAfterUploadSettled: true, + fillBeforeUpload: true, + refillAfterUpload: true, + fillDelay: 500, + uploadSettleTimeout: 30000, + fallbackContentToTitle: true, + allowNoAssets: true, + continueAfterCategoryFailure: true, + videoModeLabels: [], + imageModeLabels: [], + prepareClickSteps: [], + prepareTextChoices: ['Bug修改次数', '1-5天', '元/次'], + blockingNotice: { + messageText: '高级版已过期', + dismissText: '再想想' + }, + category: { + historySelectors: ['.release-history'], + inputSelectors: [ + '.channelCat input[placeholder="请输入关键词"]', + '.channelCat input', + 'input[placeholder="请输入关键词"]' + ], + optionSelectors: [ + '.el-cascader-node__label', + '.el-popper [role="menuitem"]', + '.el-popper li', + '.custom-cascader [class*="item"]' + ], + searchResultSelectors: [ + '[role="option"]', + '.el-select-dropdown__item', + '.el-cascader__suggestion-item', + '[class*="cascader"] [class*="suggest"]', + '[class*="category"] [class*="item"]' + ], + resultText: '家居/服务/IT技术/软件/程序/网站开发', + allowGlobalTextOption: true, + optionTimeout: 1800, + path: ['家居', '服务', 'IT技术', '软件', '程序', '网站开发'] + }, + uploadSelectors: [ + 'input[type="file"][accept*="image"]', + 'input[type="file"][multiple]', + 'input[type="file"]' + ], + videoInputSelectors: [], + imageInputSelectors: [ + 'input[type="file"][accept*="image"]', + 'input[type="file"][multiple]' + ], + coverInputSelectors: [], + titleSelectors: [ + 'input[placeholder="请输入商品标题,最多允许输入30个汉字"]', + 'input[placeholder*="商品标题"]', + 'input[maxlength="30"]' + ], + contentSelectors: [ + 'textarea[placeholder="请输入商品描述"]', + 'textarea[placeholder*="商品描述"]', + '[contenteditable="true"][data-placeholder*="商品描述"]' + ], + progressSelectors: ['[class*="progress"]', '.el-progress', '[role="progressbar"]'], + successTexts: [/^发布成功[!!。]?$/, /^商品发布成功[!!。]?$/, /^提交成功[!!。]?$/], + failureTexts: [/^发布失败/, /^提交失败/, /^商品发布失败/] + } + + chrome.runtime.onMessage.addListener(message => { + if (message && message.type === 'RUN_PUBLISH') { + globalThis.YidaimaPublisher.run(config, message.payload) + } else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') { + globalThis.YidaimaPublisher.watch(config, message.payload) + } + }) + chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform }) +})() diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/xiaohongshu.js b/browser-extension/multi-platform-publisher-1.1.15/content/xiaohongshu.js new file mode 100644 index 0000000..226f2d5 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.15/content/xiaohongshu.js @@ -0,0 +1,44 @@ +(() => { + const config = { + platform: 'xiaohongshu', + platformName: '小红书', + preserveContentLineBreaks: true, + videoModeLabels: ['上传视频', '视频笔记'], + imageModeLabels: ['上传图文', '图文笔记'], + uploadSelectors: [ + 'input[type="file"][accept*="video"]', + 'input[type="file"][accept*="image"]', + 'input[type="file"]' + ], + videoInputSelectors: ['input[type="file"][accept*="video"]'], + imageInputSelectors: ['input[type="file"][accept*="image"]'], + coverInputSelectors: [ + '[class*="cover"] input[type="file"][accept*="image"]', + '[class*="poster"] input[type="file"][accept*="image"]' + ], + titleSelectors: [ + 'input[placeholder*="标题"]', + 'textarea[placeholder*="标题"]', + '[class*="title"] input' + ], + contentSelectors: [ + '[contenteditable="true"][data-placeholder*="正文"]', + '[contenteditable="true"][data-placeholder*="描述"]', + 'textarea[placeholder*="正文"]', + 'textarea[placeholder*="描述"]', + '[class*="editor"] [contenteditable="true"]' + ], + progressSelectors: ['[class*="progress"]', '[role="progressbar"]'], + successTexts: [/^发布成功[!!。]?$/, /^笔记发布成功[!!。]?$/], + failureTexts: [/^发布失败/, /^笔记发布失败/] + } + + chrome.runtime.onMessage.addListener(message => { + if (message && message.type === 'RUN_PUBLISH') { + globalThis.YidaimaPublisher.run(config, message.payload) + } else if (message && message.type === 'WATCH_PUBLISH_OUTCOME') { + globalThis.YidaimaPublisher.watch(config, message.payload) + } + }) + chrome.runtime.sendMessage({ type: 'PLATFORM_READY', platform: config.platform }) +})() diff --git a/browser-extension/multi-platform-publisher-1.1.15/manifest.json b/browser-extension/multi-platform-publisher-1.1.15/manifest.json new file mode 100644 index 0000000..5524458 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.15/manifest.json @@ -0,0 +1,69 @@ +{ + "manifest_version": 3, + "name": "易代码多平台发布助手", + "version": "1.1.15", + "description": "从易代码后台读取发布任务,为小红书、抖音、视频号、B站和闲鱼填写发布资料,最终发布由用户手动确认。", + "permissions": [ + "storage", + "tabs", + "scripting", + "alarms" + ], + "host_permissions": [ + "https://feast.yidaima.cn/*", + "http://localhost/*", + "http://127.0.0.1/*", + "https://creator.xiaohongshu.com/*", + "https://creator.douyin.com/*", + "https://channels.weixin.qq.com/*", + "https://member.bilibili.com/*", + "https://goofish.pro/*", + "https://www.goofish.pro/*" + ], + "background": { + "service_worker": "background/service-worker.js" + }, + "action": { + "default_title": "易代码多平台发布助手", + "default_popup": "popup/popup.html" + }, + "content_scripts": [ + { + "matches": [ + "https://feast.yidaima.cn/*", + "http://localhost/*", + "http://127.0.0.1/*" + ], + "js": ["content/admin-bridge.js"], + "run_at": "document_start" + }, + { + "matches": ["https://creator.xiaohongshu.com/*"], + "js": ["content/platform-common.js", "content/xiaohongshu.js"], + "run_at": "document_idle" + }, + { + "matches": ["https://creator.douyin.com/*"], + "js": ["content/platform-common.js", "content/douyin.js"], + "run_at": "document_idle" + }, + { + "matches": ["https://channels.weixin.qq.com/*"], + "js": ["content/platform-common.js", "content/wechat-channels.js"], + "run_at": "document_idle" + }, + { + "matches": ["https://member.bilibili.com/*"], + "js": ["content/platform-common.js", "content/bilibili.js"], + "run_at": "document_idle" + }, + { + "matches": [ + "https://goofish.pro/*", + "https://www.goofish.pro/*" + ], + "js": ["content/platform-common.js", "content/xianyu.js"], + "run_at": "document_idle" + } + ] +} diff --git a/browser-extension/multi-platform-publisher-1.1.15/popup/popup.html b/browser-extension/multi-platform-publisher-1.1.15/popup/popup.html new file mode 100644 index 0000000..090bdca --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.15/popup/popup.html @@ -0,0 +1,31 @@ + + + + + + 易代码多平台发布助手 + + + +
+

多平台发布助手

+

小红书 · 抖音 · 视频号 · B站 · 闲鱼

+
+
+
扩展已启用
+

请在易代码后台打开“发布管理”。扩展会上传并填写资料,但不会点击平台的最终发布按钮。

+
+
+ + + diff --git a/browser-extension/multi-platform-publisher-1.1.15/popup/popup.js b/browser-extension/multi-platform-publisher-1.1.15/popup/popup.js new file mode 100644 index 0000000..d9a1610 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.15/popup/popup.js @@ -0,0 +1 @@ +document.getElementById('version').textContent = '版本 ' + chrome.runtime.getManifest().version diff --git a/docs/platform-publish.md b/docs/platform-publish.md new file mode 100644 index 0000000..6c3ea90 --- /dev/null +++ b/docs/platform-publish.md @@ -0,0 +1,68 @@ +# 多平台发布管理部署与验证 + +## 部署顺序 + +1. 在业务数据库执行 `sql/platform_publish.sql`,创建发布任务表和“发布管理”菜单。 +2. 重新构建并部署后端与 `ruoyi-ui`。 +3. 在 Chrome 中加载 `browser-extension/multi-platform-publisher` 目录。 +4. 分别登录小红书、抖音、视频号、B站创作者中心和闲管家卖家工作台。 +5. 重新进入后台“工作室运营 → 发布管理”,确认右上角显示“发布扩展已连接”。 + +非管理员角色还需要在角色管理中勾选新菜单及“发布资料查询”“创建发布任务”权限。 + +## 使用流程 + +1. 在项目所在行点击“发布小红书”“发布抖音”“发布视频号”“发布B站视频”或“发布闲鱼商品”。 +2. 检查后台自动带出的标题和模板文案,并手动选择本次发布使用的图片、视频或封面。 +3. 点击“交给扩展填写”。 +4. 扩展打开对应平台并依次上传、填写。 +5. 后台状态显示“待手动发布”后,在平台页面检查全部资料。 +6. 用户手动点击平台最终发布按钮。 +7. 扩展观察到平台新增的明确成功提示后,将后台状态更新为“发布成功”。 + +### 多平台发布 + +1. 在项目名称下点击“多平台发布”,勾选本次需要的平台。 +2. 视频由抖音、视频号和B站共用;图片由小红书和闲鱼共用;各平台标题、文案和标签可分别修改。 +3. 后台通过一个事务批量创建任务,不会出现只创建部分平台任务的情况。 +4. 扩展按小红书、抖音、视频号、B站、闲鱼的顺序执行;当前平台到达“待手动发布”、失败、需要登录或页面被关闭后,再继续下一个平台。 +5. 所有平台的最终发布按钮仍由用户手动确认。 +6. 视频号批次等待上传状态最多30秒;任一平台超过4分钟没有返回状态时会标记超时并继续后续平台,避免整批任务停住。 + +### 发布文案模板规则 + +- 发布小红书时匹配“小红书”分组,发布抖音时匹配“抖音”分组,发布视频号时匹配“视频号”分组,发布B站时匹配“B站”分组,发布闲鱼时匹配“闲鱼”分组。 +- 也支持“抖音、视频号、小红书”这类包含平台名称的共享分组。 +- 平台专用的精确分组优先于共享分组。 +- 匹配分组只有一个启用模板时直接使用;有多个启用模板时随机选择一个。 +- 模板中的项目变量由后端渲染后自动写入发布文案,弹窗会显示本次选择的分组和模板名称。 + +## 安全边界 + +- 后台页面不会向扩展提供后台登录 Token、Cookie 或平台密码。 +- 扩展用随机短期凭证读取单个发布任务,凭证默认 4 小时失效。 +- 素材下载地址由后端根据已保存任务生成,网页不能要求扩展访问任意脚本或选择器。 +- 扩展只切换“图文/视频”类型标签、设置上传控件和填写文本,不扫描或点击普通发布按钮。 +- B站扩展会上传视频并填写标题、简介和标签文案,投稿分区等选项保留给用户检查或手动选择。 +- 闲鱼发布仅通过扩展操作闲管家网页表单,不调用闲管家开放接口,也不读取或保存闲管家 Cookie。 +- 闲鱼有历史分类快捷项时优先使用;没有时固定选择“家居/服务/IT技术/软件/程序/网站开发”,且不填写售价、原价和库存。 +- 扫码、验证码和安全校验必须由用户处理。 + +## 状态说明 + +- `PENDING`:等待扩展接收。 +- `OPENING`:正在打开创作者中心。 +- `LOGIN_REQUIRED`:平台尚未登录或发布表单不可见。 +- `DOWNLOADING`:读取后台素材。 +- `UPLOADING`:素材已交给平台上传。 +- `FILLING`:填写标题、文案和标签。 +- `READY_TO_PUBLISH`:填写完成,等待用户手动发布。 +- `SUCCEEDED`:观察到平台明确的发布成功提示。 +- `FAILED`:关键控件缺失、素材读取失败或平台明确提示失败。 +- `CANCELLED`:任务未完成时平台标签页被关闭。 + +## 平台适配验证 + +五个平台的发布页面会不定期改版。每次正式部署前,都应使用测试账号真实验证上传控件、标题控件、文案控件、商品字段和上传进度。未经过登录账号实测的平台不能标记为“已完成适配”。 + +扩展没有最终发布动作。检索扩展内容脚本中的 `.click()` 时,只应看到发布类型标签切换逻辑。 diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java index e7fca4d..c2596be 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java @@ -57,11 +57,11 @@ public class TtCopyTemplateController extends BaseController { } /** - * 查询所有启用的文案模板(供源码明细页动态按钮使用,无需分页权限) + * 按场景查询所有启用的文案模板(无需分页权限) */ @GetMapping("/enabled") - public AjaxResult listEnabled() { - return success(ttCopyTemplateService.selectEnabledTemplates()); + public AjaxResult listEnabled(@RequestParam(value = "templateScene", required = false) String templateScene) { + return success(ttCopyTemplateService.selectEnabledTemplates(templateScene)); } /** diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtPublishTaskController.java b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtPublishTaskController.java new file mode 100644 index 0000000..30b7483 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtPublishTaskController.java @@ -0,0 +1,105 @@ +package com.ruoyi.office.controller; + +import java.io.IOException; +import java.util.List; +import javax.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.office.domain.PublishBridgeTask; +import com.ruoyi.office.domain.PublishMaterial; +import com.ruoyi.office.domain.PublishProjectRow; +import com.ruoyi.office.domain.PublishStatusUpdate; +import com.ruoyi.office.domain.TtPublishTask; +import com.ruoyi.office.service.ITtPublishTaskService; + +/** + * 后台发布管理与浏览器扩展桥接接口。 + */ +@RestController +@RequestMapping("/office/publish") +public class TtPublishTaskController extends BaseController +{ + @Autowired + private ITtPublishTaskService publishTaskService; + + @PreAuthorize("@ss.hasPermi('office:publish:list')") + @GetMapping("/list") + public TableDataInfo list(PublishProjectRow query) + { + startPage(); + List list = publishTaskService.selectPublishProjectList(query); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('office:publish:query')") + @GetMapping("/material/{codeId}") + public AjaxResult material(@PathVariable Long codeId, @RequestParam String platform) + { + PublishMaterial material = publishTaskService.selectProjectMaterial(codeId, platform); + return AjaxResult.success(material); + } + + @PreAuthorize("@ss.hasPermi('office:publish:add')") + @Log(title = "创建平台发布任务", businessType = BusinessType.INSERT) + @PostMapping("/task") + public AjaxResult createTask(@RequestBody TtPublishTask task) + { + return AjaxResult.success("发布任务已创建", publishTaskService.createPublishTask(task)); + } + + @PreAuthorize("@ss.hasPermi('office:publish:add')") + @Log(title = "创建多平台发布任务", businessType = BusinessType.INSERT) + @PostMapping("/tasks") + public AjaxResult createTasks(@RequestBody List tasks) + { + return AjaxResult.success("多平台发布任务已创建", publishTaskService.createPublishTasks(tasks)); + } + + @Anonymous + @CrossOrigin(origins = "*") + @GetMapping("/bridge/task/{taskId}") + public AjaxResult bridgeTask(@PathVariable String taskId, @RequestParam String token) + { + PublishBridgeTask task = publishTaskService.selectBridgeTask(taskId, token); + return AjaxResult.success(task); + } + + @Anonymous + @CrossOrigin(origins = "*") + @PostMapping("/bridge/task/{taskId}/status") + public AjaxResult bridgeStatus(@PathVariable String taskId, + @RequestParam String token, + @RequestBody PublishStatusUpdate update) + { + return AjaxResult.success(publishTaskService.updateBridgeStatus(taskId, token, update)); + } + + @Anonymous + @CrossOrigin(origins = "*") + @GetMapping({ + "/bridge/task/{taskId}/asset/{kind}", + "/bridge/task/{taskId}/asset/{kind}/{index}" + }) + public void bridgeAsset(@PathVariable String taskId, + @PathVariable String kind, + @PathVariable(required = false) Integer index, + @RequestParam String token, + HttpServletResponse response) throws IOException + { + publishTaskService.writeBridgeAsset(taskId, token, kind, index, response); + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishBridgeTask.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishBridgeTask.java new file mode 100644 index 0000000..b4731f6 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishBridgeTask.java @@ -0,0 +1,36 @@ +package com.ruoyi.office.domain; + +import java.util.ArrayList; +import java.util.List; + +/** + * 浏览器扩展可读取的最小发布任务数据,不包含后台身份凭证。 + */ +public class PublishBridgeTask +{ + private String taskId; + private String platform; + private String title; + private String content; + private List tags = new ArrayList<>(); + private boolean hasVideo; + private boolean hasCover; + private int imageCount; + + public String getTaskId() { return taskId; } + public void setTaskId(String taskId) { this.taskId = taskId; } + public String getPlatform() { return platform; } + public void setPlatform(String platform) { this.platform = platform; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public List getTags() { return tags; } + public void setTags(List tags) { this.tags = tags; } + public boolean isHasVideo() { return hasVideo; } + public void setHasVideo(boolean hasVideo) { this.hasVideo = hasVideo; } + public boolean isHasCover() { return hasCover; } + public void setHasCover(boolean hasCover) { this.hasCover = hasCover; } + public int getImageCount() { return imageCount; } + public void setImageCount(int imageCount) { this.imageCount = imageCount; } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishMaterial.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishMaterial.java new file mode 100644 index 0000000..8716958 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishMaterial.java @@ -0,0 +1,45 @@ +package com.ruoyi.office.domain; + +import java.util.ArrayList; +import java.util.List; + +/** + * 创建发布任务前返回给管理页面的项目素材。 + */ +public class PublishMaterial +{ + private Long codeId; + private String codeName; + private String title; + private String content; + private Long templateId; + private String templateName; + private String templateGroup; + private String tags; + private String videoUrl; + private String coverUrl; + private List imageUrls = new ArrayList<>(); + + public Long getCodeId() { return codeId; } + public void setCodeId(Long codeId) { this.codeId = codeId; } + public String getCodeName() { return codeName; } + public void setCodeName(String codeName) { this.codeName = codeName; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public Long getTemplateId() { return templateId; } + public void setTemplateId(Long templateId) { this.templateId = templateId; } + public String getTemplateName() { return templateName; } + public void setTemplateName(String templateName) { this.templateName = templateName; } + public String getTemplateGroup() { return templateGroup; } + public void setTemplateGroup(String templateGroup) { this.templateGroup = templateGroup; } + public String getTags() { return tags; } + public void setTags(String tags) { this.tags = tags; } + public String getVideoUrl() { return videoUrl; } + public void setVideoUrl(String videoUrl) { this.videoUrl = videoUrl; } + public String getCoverUrl() { return coverUrl; } + public void setCoverUrl(String coverUrl) { this.coverUrl = coverUrl; } + public List getImageUrls() { return imageUrls; } + public void setImageUrls(List imageUrls) { this.imageUrls = imageUrls; } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishProjectRow.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishProjectRow.java new file mode 100644 index 0000000..a015a73 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishProjectRow.java @@ -0,0 +1,56 @@ +package com.ruoyi.office.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 发布管理页面中的项目聚合行。 + */ +public class PublishProjectRow extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long codeId; + private String codeName; + private String xiaohongshuTaskId; + private String xiaohongshuStatus; + private String douyinTaskId; + private String douyinStatus; + private String wechatChannelsTaskId; + private String wechatChannelsStatus; + private String bilibiliTaskId; + private String bilibiliStatus; + private String xianyuTaskId; + private String xianyuStatus; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date lastPublishTime; + + public Long getCodeId() { return codeId; } + public void setCodeId(Long codeId) { this.codeId = codeId; } + public String getCodeName() { return codeName; } + public void setCodeName(String codeName) { this.codeName = codeName; } + public String getXiaohongshuTaskId() { return xiaohongshuTaskId; } + public void setXiaohongshuTaskId(String xiaohongshuTaskId) { this.xiaohongshuTaskId = xiaohongshuTaskId; } + public String getXiaohongshuStatus() { return xiaohongshuStatus; } + public void setXiaohongshuStatus(String xiaohongshuStatus) { this.xiaohongshuStatus = xiaohongshuStatus; } + public String getDouyinTaskId() { return douyinTaskId; } + public void setDouyinTaskId(String douyinTaskId) { this.douyinTaskId = douyinTaskId; } + public String getDouyinStatus() { return douyinStatus; } + public void setDouyinStatus(String douyinStatus) { this.douyinStatus = douyinStatus; } + public String getWechatChannelsTaskId() { return wechatChannelsTaskId; } + public void setWechatChannelsTaskId(String wechatChannelsTaskId) { this.wechatChannelsTaskId = wechatChannelsTaskId; } + public String getWechatChannelsStatus() { return wechatChannelsStatus; } + public void setWechatChannelsStatus(String wechatChannelsStatus) { this.wechatChannelsStatus = wechatChannelsStatus; } + public String getBilibiliTaskId() { return bilibiliTaskId; } + public void setBilibiliTaskId(String bilibiliTaskId) { this.bilibiliTaskId = bilibiliTaskId; } + public String getBilibiliStatus() { return bilibiliStatus; } + public void setBilibiliStatus(String bilibiliStatus) { this.bilibiliStatus = bilibiliStatus; } + public String getXianyuTaskId() { return xianyuTaskId; } + public void setXianyuTaskId(String xianyuTaskId) { this.xianyuTaskId = xianyuTaskId; } + public String getXianyuStatus() { return xianyuStatus; } + public void setXianyuStatus(String xianyuStatus) { this.xianyuStatus = xianyuStatus; } + public Date getLastPublishTime() { return lastPublishTime; } + public void setLastPublishTime(Date lastPublishTime) { this.lastPublishTime = lastPublishTime; } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishStatusUpdate.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishStatusUpdate.java new file mode 100644 index 0000000..b2da47b --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/PublishStatusUpdate.java @@ -0,0 +1,16 @@ +package com.ruoyi.office.domain; + +/** 浏览器扩展发布状态回传。 */ +public class PublishStatusUpdate +{ + private String status; + private String message; + private String error; + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public String getError() { return error; } + public void setError(String error) { this.error = error; } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtCopyTemplate.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtCopyTemplate.java index c316e45..49555ca 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtCopyTemplate.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtCopyTemplate.java @@ -25,6 +25,10 @@ public class TtCopyTemplate extends BaseEntity { @Excel(name = "模板分组") private String templateGroup; + /** 模板场景(CODE源码文案 DELIVERY发货文案) */ + @Excel(name = "模板场景") + private String templateScene; + /** 文案模板内容,支持 {变量} 占位符 */ @Excel(name = "模板内容") private String templateBody; @@ -61,6 +65,14 @@ public class TtCopyTemplate extends BaseEntity { return templateGroup; } + public void setTemplateScene(String templateScene) { + this.templateScene = templateScene; + } + + public String getTemplateScene() { + return templateScene; + } + public void setTemplateBody(String templateBody) { this.templateBody = templateBody; } @@ -91,6 +103,7 @@ public class TtCopyTemplate extends BaseEntity { .append("templateId", getTemplateId()) .append("templateName", getTemplateName()) .append("templateGroup", getTemplateGroup()) + .append("templateScene", getTemplateScene()) .append("templateBody", getTemplateBody()) .append("sortNum", getSortNum()) .append("status", getStatus()) diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtPublishTask.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtPublishTask.java new file mode 100644 index 0000000..1819e7b --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtPublishTask.java @@ -0,0 +1,81 @@ +package com.ruoyi.office.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +/** + * 多平台发布任务对象 tt_publish_task。 + */ +public class TtPublishTask extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long publishId; + private String taskId; + private Long codeId; + private String codeName; + private String platform; + private String title; + private String content; + private String tags; + private String videoUrl; + private String imageUrls; + private String coverUrl; + private String status; + private String statusMessage; + private String lastError; + + private String bridgeToken; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date bridgeExpiresAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startedAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date readyAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date finishedAt; + + public Long getPublishId() { return publishId; } + public void setPublishId(Long publishId) { this.publishId = publishId; } + public String getTaskId() { return taskId; } + public void setTaskId(String taskId) { this.taskId = taskId; } + public Long getCodeId() { return codeId; } + public void setCodeId(Long codeId) { this.codeId = codeId; } + public String getCodeName() { return codeName; } + public void setCodeName(String codeName) { this.codeName = codeName; } + public String getPlatform() { return platform; } + public void setPlatform(String platform) { this.platform = platform; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public String getTags() { return tags; } + public void setTags(String tags) { this.tags = tags; } + public String getVideoUrl() { return videoUrl; } + public void setVideoUrl(String videoUrl) { this.videoUrl = videoUrl; } + public String getImageUrls() { return imageUrls; } + public void setImageUrls(String imageUrls) { this.imageUrls = imageUrls; } + public String getCoverUrl() { return coverUrl; } + public void setCoverUrl(String coverUrl) { this.coverUrl = coverUrl; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getStatusMessage() { return statusMessage; } + public void setStatusMessage(String statusMessage) { this.statusMessage = statusMessage; } + public String getLastError() { return lastError; } + public void setLastError(String lastError) { this.lastError = lastError; } + public String getBridgeToken() { return bridgeToken; } + public void setBridgeToken(String bridgeToken) { this.bridgeToken = bridgeToken; } + public Date getBridgeExpiresAt() { return bridgeExpiresAt; } + public void setBridgeExpiresAt(Date bridgeExpiresAt) { this.bridgeExpiresAt = bridgeExpiresAt; } + public Date getStartedAt() { return startedAt; } + public void setStartedAt(Date startedAt) { this.startedAt = startedAt; } + public Date getReadyAt() { return readyAt; } + public void setReadyAt(Date readyAt) { this.readyAt = readyAt; } + public Date getFinishedAt() { return finishedAt; } + public void setFinishedAt(Date finishedAt) { this.finishedAt = finishedAt; } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java index 57c0c5e..b9f8d79 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.office.mapper; import java.util.List; import com.ruoyi.office.domain.TtCopyTemplate; +import org.apache.ibatis.annotations.Param; /** * 文案模板Mapper接口 @@ -24,7 +25,7 @@ public interface TtCopyTemplateMapper { /** * 查询所有启用的文案模板(按排序号升序) */ - public List selectEnabledTemplates(); + public List selectEnabledTemplates(@Param("templateScene") String templateScene); /** * 新增文案模板 @@ -45,4 +46,4 @@ public interface TtCopyTemplateMapper { * 删除文案模板信息 */ public int deleteTtCopyTemplateByTemplateId(Long templateId); -} \ No newline at end of file +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtPublishTaskMapper.java b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtPublishTaskMapper.java new file mode 100644 index 0000000..e9a1115 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtPublishTaskMapper.java @@ -0,0 +1,20 @@ +package com.ruoyi.office.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.ruoyi.office.domain.PublishProjectRow; +import com.ruoyi.office.domain.TtPublishTask; + +public interface TtPublishTaskMapper +{ + List selectPublishProjectList(PublishProjectRow query); + + TtPublishTask selectPublishTaskByTaskId(String taskId); + + TtPublishTask selectPublishTaskByBridge(@Param("taskId") String taskId, + @Param("bridgeToken") String bridgeToken); + + int insertPublishTask(TtPublishTask task); + + int updatePublishTaskStatus(TtPublishTask task); +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java index fd60d16..ef08177 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java @@ -26,6 +26,11 @@ public interface ITtCopyTemplateService { */ public List selectEnabledTemplates(); + /** + * 按场景查询所有启用的文案模板 + */ + public List selectEnabledTemplates(String templateScene); + /** * 新增文案模板 */ @@ -45,4 +50,4 @@ public interface ITtCopyTemplateService { * 删除文案模板信息 */ public int deleteTtCopyTemplateByTemplateId(Long templateId); -} \ No newline at end of file +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtPublishTaskService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtPublishTaskService.java new file mode 100644 index 0000000..c398eea --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtPublishTaskService.java @@ -0,0 +1,28 @@ +package com.ruoyi.office.service; + +import java.io.IOException; +import java.util.List; +import javax.servlet.http.HttpServletResponse; +import com.ruoyi.office.domain.PublishBridgeTask; +import com.ruoyi.office.domain.PublishMaterial; +import com.ruoyi.office.domain.PublishProjectRow; +import com.ruoyi.office.domain.PublishStatusUpdate; +import com.ruoyi.office.domain.TtPublishTask; + +public interface ITtPublishTaskService +{ + List selectPublishProjectList(PublishProjectRow query); + + PublishMaterial selectProjectMaterial(Long codeId, String platform); + + TtPublishTask createPublishTask(TtPublishTask task); + + List createPublishTasks(List tasks); + + PublishBridgeTask selectBridgeTask(String taskId, String token); + + TtPublishTask updateBridgeStatus(String taskId, String token, PublishStatusUpdate update); + + void writeBridgeAsset(String taskId, String token, String kind, Integer index, + HttpServletResponse response) throws IOException; +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java index 514429d..81b9d77 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java @@ -32,7 +32,12 @@ public class TtCopyTemplateServiceImpl implements ITtCopyTemplateService { @Override public List selectEnabledTemplates() { - return ttCopyTemplateMapper.selectEnabledTemplates(); + return selectEnabledTemplates(null); + } + + @Override + public List selectEnabledTemplates(String templateScene) { + return ttCopyTemplateMapper.selectEnabledTemplates(templateScene); } @Override @@ -56,4 +61,4 @@ public class TtCopyTemplateServiceImpl implements ITtCopyTemplateService { public int deleteTtCopyTemplateByTemplateId(Long templateId) { return ttCopyTemplateMapper.deleteTtCopyTemplateByTemplateId(templateId); } -} \ No newline at end of file +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java new file mode 100644 index 0000000..f0f5652 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java @@ -0,0 +1,524 @@ +package com.ruoyi.office.service.impl; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import javax.servlet.http.HttpServletResponse; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.constant.Constants; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.office.domain.PublishBridgeTask; +import com.ruoyi.office.domain.PublishMaterial; +import com.ruoyi.office.domain.PublishProjectRow; +import com.ruoyi.office.domain.PublishStatusUpdate; +import com.ruoyi.office.domain.TtCode; +import com.ruoyi.office.domain.TtCopyTemplate; +import com.ruoyi.office.domain.TtPublishTask; +import com.ruoyi.office.mapper.TtPublishTaskMapper; +import com.ruoyi.office.service.CopyTemplateRenderer; +import com.ruoyi.office.service.ITtCodeService; +import com.ruoyi.office.service.ITtCopyTemplateService; +import com.ruoyi.office.service.ITtPublishTaskService; + +@Service +public class TtPublishTaskServiceImpl implements ITtPublishTaskService +{ + private static final Set PLATFORMS = Collections.unmodifiableSet( + new LinkedHashSet<>(Arrays.asList("xiaohongshu", "douyin", "wechat_channels", "bilibili", "xianyu"))); + + private static final Set EXTENSION_STATUSES = Collections.unmodifiableSet( + new LinkedHashSet<>(Arrays.asList( + "OPENING", "LOGIN_REQUIRED", "DOWNLOADING", "UPLOADING", + "FILLING", "READY_TO_PUBLISH", "SUCCEEDED", "FAILED", "CANCELLED" + ))); + + @Autowired + private TtPublishTaskMapper publishTaskMapper; + + @Autowired + private ITtCodeService codeService; + + @Autowired + private ITtCopyTemplateService copyTemplateService; + + @Autowired + private CopyTemplateRenderer copyTemplateRenderer; + + @Autowired + private ObjectMapper objectMapper; + + @Override + public List selectPublishProjectList(PublishProjectRow query) + { + return publishTaskMapper.selectPublishProjectList(query); + } + + @Override + public PublishMaterial selectProjectMaterial(Long codeId, String platform) + { + String templateGroup = platformTemplateGroup(platform); + TtCode code = requireCode(codeId); + PublishMaterial material = new PublishMaterial(); + material.setCodeId(code.getCodeId()); + material.setCodeName(code.getCodeName()); + material.setTitle(code.getCodeName()); + TtCopyTemplate selectedTemplate = selectPublishTemplate( + copyTemplateService.selectEnabledTemplates("CODE"), templateGroup); + if (selectedTemplate != null) + { + material.setTemplateId(selectedTemplate.getTemplateId()); + material.setTemplateName(selectedTemplate.getTemplateName()); + material.setTemplateGroup(selectedTemplate.getTemplateGroup()); + material.setContent(copyTemplateRenderer.render(selectedTemplate, code)); + } + return material; + } + + private TtCopyTemplate selectPublishTemplate(List templates, String targetGroup) + { + if (templates == null || templates.isEmpty()) + { + return null; + } + List exactMatches = new ArrayList<>(); + List sharedMatches = new ArrayList<>(); + for (TtCopyTemplate template : templates) + { + if (template == null || StringUtils.isEmpty(template.getTemplateGroup())) + { + continue; + } + String group = template.getTemplateGroup().trim(); + if (targetGroup.equals(group)) + { + exactMatches.add(template); + } + else if (group.contains(targetGroup)) + { + sharedMatches.add(template); + } + } + List candidates = exactMatches.isEmpty() ? sharedMatches : exactMatches; + if (candidates.isEmpty()) + { + return null; + } + return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size())); + } + + private String platformTemplateGroup(String platform) + { + if ("xiaohongshu".equals(platform)) return "小红书"; + if ("douyin".equals(platform)) return "抖音"; + if ("wechat_channels".equals(platform)) return "视频号"; + if ("bilibili".equals(platform)) return "B站"; + if ("xianyu".equals(platform)) return "闲鱼"; + throw new ServiceException("不支持的发布平台"); + } + + @Override + public TtPublishTask createPublishTask(TtPublishTask task) + { + if (task == null || task.getCodeId() == null) + { + throw new ServiceException("请选择需要发布的项目"); + } + if (!PLATFORMS.contains(task.getPlatform())) + { + throw new ServiceException("不支持的发布平台"); + } + TtCode code = requireCode(task.getCodeId()); + task.setTitle(normalizePlatformTitle(task.getPlatform(), task.getTitle())); + task.setContent(trimToNull(task.getContent())); + task.setTags(trimToNull(task.getTags())); + task.setVideoUrl(trimToNull(task.getVideoUrl())); + task.setCoverUrl(trimToNull(task.getCoverUrl())); + List images = parseImageUrls(task.getImageUrls()); + task.setImageUrls(writeJson(images)); + + if (StringUtils.isEmpty(task.getTitle())) + { + throw new ServiceException("发布标题不能为空"); + } + int titleLimit = "xianyu".equals(task.getPlatform()) || "wechat_channels".equals(task.getPlatform()) ? 30 + : ("bilibili".equals(task.getPlatform()) ? 80 : 100); + if (task.getTitle().length() > titleLimit) + { + throw new ServiceException("发布标题不能超过" + titleLimit + "个字符"); + } + if (task.getContent() != null && task.getContent().length() > 5000) + { + throw new ServiceException("发布文案不能超过5000个字符"); + } + if ("xiaohongshu".equals(task.getPlatform()) + && StringUtils.isEmpty(task.getVideoUrl()) && images.isEmpty()) + { + throw new ServiceException("发布小红书至少需要一个视频或一张图片"); + } + if (("douyin".equals(task.getPlatform()) || "wechat_channels".equals(task.getPlatform()) + || "bilibili".equals(task.getPlatform())) + && StringUtils.isEmpty(task.getVideoUrl())) + { + throw new ServiceException("发布抖音、视频号或B站需要选择视频"); + } + if ("xianyu".equals(task.getPlatform())) + { + if (images.isEmpty()) + { + throw new ServiceException("发布闲鱼商品至少需要一张商品图片"); + } + } + + String username = SecurityUtils.getUsername(); + task.setCodeName(code.getCodeName()); + task.setTaskId("publish_" + UUID.randomUUID().toString().replace("-", "")); + task.setBridgeToken(UUID.randomUUID().toString().replace("-", "")); + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.HOUR, 4); + task.setBridgeExpiresAt(calendar.getTime()); + task.setStatus("PENDING"); + task.setStatusMessage("等待浏览器扩展接收任务"); + task.setCreateBy(username); + task.setUpdateBy(username); + publishTaskMapper.insertPublishTask(task); + return task; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List createPublishTasks(List tasks) + { + if (tasks == null || tasks.isEmpty()) + { + throw new ServiceException("请至少选择一个发布平台"); + } + if (tasks.size() > PLATFORMS.size()) + { + throw new ServiceException("一次最多创建" + PLATFORMS.size() + "个平台任务"); + } + Set selectedPlatforms = new LinkedHashSet<>(); + List created = new ArrayList<>(); + for (TtPublishTask task : tasks) + { + if (task == null || !selectedPlatforms.add(task.getPlatform())) + { + throw new ServiceException("多平台发布中存在重复或无效的平台"); + } + created.add(createPublishTask(task)); + } + return created; + } + + @Override + public PublishBridgeTask selectBridgeTask(String taskId, String token) + { + TtPublishTask task = requireBridgeTask(taskId, token); + List images = parseImageUrls(task.getImageUrls()); + PublishBridgeTask bridgeTask = new PublishBridgeTask(); + bridgeTask.setTaskId(task.getTaskId()); + bridgeTask.setPlatform(task.getPlatform()); + bridgeTask.setTitle(task.getTitle()); + bridgeTask.setContent(task.getContent()); + bridgeTask.setTags(parseTags(task.getTags())); + bridgeTask.setHasVideo(StringUtils.isNotEmpty(task.getVideoUrl())); + bridgeTask.setHasCover(StringUtils.isNotEmpty(task.getCoverUrl())); + bridgeTask.setImageCount(images.size()); + return bridgeTask; + } + + @Override + public TtPublishTask updateBridgeStatus(String taskId, String token, PublishStatusUpdate update) + { + TtPublishTask existing = requireBridgeTask(taskId, token); + if (update == null || !EXTENSION_STATUSES.contains(update.getStatus())) + { + throw new ServiceException("扩展回传的发布状态无效"); + } + TtPublishTask task = new TtPublishTask(); + task.setTaskId(existing.getTaskId()); + task.setStatus(update.getStatus()); + task.setStatusMessage(limit(update.getMessage(), 255)); + task.setLastError(limit(update.getError(), 1000)); + task.setUpdateBy("browser-extension"); + Date now = new Date(); + if ("OPENING".equals(update.getStatus())) + { + task.setStartedAt(now); + } + if ("READY_TO_PUBLISH".equals(update.getStatus())) + { + task.setReadyAt(now); + } + if ("SUCCEEDED".equals(update.getStatus()) || "FAILED".equals(update.getStatus()) + || "CANCELLED".equals(update.getStatus())) + { + task.setFinishedAt(now); + } + publishTaskMapper.updatePublishTaskStatus(task); + return publishTaskMapper.selectPublishTaskByTaskId(taskId); + } + + @Override + public void writeBridgeAsset(String taskId, String token, String kind, Integer index, + HttpServletResponse response) throws IOException + { + TtPublishTask task = requireBridgeTask(taskId, token); + String source = selectAssetSource(task, kind, index); + if (StringUtils.isEmpty(source)) + { + throw new ServiceException("发布素材不存在"); + } + + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + response.setHeader("Cache-Control", "private, no-store"); + + int profileIndex = source.indexOf(Constants.RESOURCE_PREFIX + "/"); + if (profileIndex >= 0) + { + String relative = source.substring(profileIndex + Constants.RESOURCE_PREFIX.length()); + Path profileRoot = Paths.get(RuoYiConfig.getProfile()).toAbsolutePath().normalize(); + Path assetPath = profileRoot.resolve(relative.replaceFirst("^[\\\\/]+", "")).normalize(); + if (!assetPath.startsWith(profileRoot) || !Files.isRegularFile(assetPath)) + { + throw new ServiceException("发布素材文件不存在"); + } + writeLocalAsset(assetPath.toFile(), response); + return; + } + writeRemoteAsset(source, response); + } + + private TtCode requireCode(Long codeId) + { + TtCode code = codeService.selectTtCodeByCodeId(codeId); + if (code == null) + { + throw new ServiceException("项目不存在或已被删除"); + } + return code; + } + + private TtPublishTask requireBridgeTask(String taskId, String token) + { + if (StringUtils.isEmpty(taskId) || StringUtils.isEmpty(token)) + { + throw new ServiceException("发布任务凭证不能为空"); + } + TtPublishTask task = publishTaskMapper.selectPublishTaskByBridge(taskId, token); + if (task == null) + { + throw new ServiceException("发布任务不存在或任务凭证已过期"); + } + return task; + } + + private String selectAssetSource(TtPublishTask task, String kind, Integer index) + { + if ("video".equals(kind)) + { + return task.getVideoUrl(); + } + if ("cover".equals(kind)) + { + return task.getCoverUrl(); + } + if ("image".equals(kind)) + { + List images = parseImageUrls(task.getImageUrls()); + int selected = index == null ? 0 : index; + if (selected >= 0 && selected < images.size()) + { + return images.get(selected); + } + } + return null; + } + + private void writeLocalAsset(File file, HttpServletResponse response) throws IOException + { + String contentType = Files.probeContentType(file.toPath()); + response.setContentType(contentType == null ? "application/octet-stream" : contentType); + response.setContentLengthLong(file.length()); + response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + encode(file.getName())); + try (InputStream input = new FileInputStream(file)) + { + copy(input, response.getOutputStream()); + } + } + + private void writeRemoteAsset(String source, HttpServletResponse response) throws IOException + { + URL url = new URL(source); + if (!"http".equalsIgnoreCase(url.getProtocol()) && !"https".equalsIgnoreCase(url.getProtocol())) + { + throw new ServiceException("不支持的发布素材地址"); + } + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(10000); + connection.setReadTimeout(120000); + connection.setInstanceFollowRedirects(true); + connection.setRequestProperty("User-Agent", "RuoYi-Publish-Bridge/1.0"); + int status = connection.getResponseCode(); + if (status < 200 || status >= 300) + { + connection.disconnect(); + throw new ServiceException("读取远程发布素材失败,HTTP " + status); + } + String contentType = connection.getContentType(); + response.setContentType(contentType == null ? "application/octet-stream" : contentType); + long length = connection.getContentLengthLong(); + if (length >= 0) + { + response.setContentLengthLong(length); + } + String fileName = new File(url.getPath()).getName(); + response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + encode(fileName)); + try (InputStream input = connection.getInputStream()) + { + copy(input, response.getOutputStream()); + } + finally + { + connection.disconnect(); + } + } + + private void copy(InputStream input, OutputStream output) throws IOException + { + byte[] buffer = new byte[8192]; + int length; + while ((length = input.read(buffer)) >= 0) + { + output.write(buffer, 0, length); + } + output.flush(); + } + + private String encode(String value) throws IOException + { + return URLEncoder.encode(StringUtils.defaultIfEmpty(value, "publish-asset"), "UTF-8") + .replace("+", "%20"); + } + + private List parseImageUrls(String value) + { + if (StringUtils.isEmpty(value)) + { + return new ArrayList<>(); + } + try + { + if (value.trim().startsWith("[")) + { + List parsed = objectMapper.readValue(value, new TypeReference>() { }); + return cleanUrls(parsed); + } + } + catch (Exception ignored) + { + // 兼容历史逗号分隔形式。 + } + return cleanUrls(Arrays.asList(value.split(","))); + } + + private List cleanUrls(List urls) + { + LinkedHashSet result = new LinkedHashSet<>(); + if (urls != null) + { + for (String url : urls) + { + String clean = trimToNull(url); + if (clean != null) + { + result.add(clean); + } + } + } + return new ArrayList<>(result); + } + + private String writeJson(List urls) + { + try + { + return objectMapper.writeValueAsString(urls == null ? Collections.emptyList() : urls); + } + catch (Exception e) + { + throw new ServiceException("发布图片数据格式不正确"); + } + } + + private List parseTags(String value) + { + if (StringUtils.isEmpty(value)) + { + return new ArrayList<>(); + } + LinkedHashSet tags = new LinkedHashSet<>(); + for (String part : value.split("[,,#\\s]+")) + { + String tag = trimToNull(part); + if (tag != null) + { + tags.add(tag); + } + } + return new ArrayList<>(tags); + } + + private String trimToNull(String value) + { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String normalizePlatformTitle(String platform, String value) + { + String title = trimToNull(value); + if (title == null || !"wechat_channels".equals(platform)) + { + return title; + } + title = title.replaceAll("[【】\\[\\]()(){}<>]", " ") + .replaceAll("[^\\p{IsHan}A-Za-z0-9《》“”‘’\\\"'::++??%%℃,,、。!!\\s]", " ") + .replaceAll("\\s+", " ") + .trim(); + return StringUtils.isEmpty(title) ? null : title; + } + + private String limit(String value, int maxLength) + { + String clean = trimToNull(value); + if (clean == null || clean.length() <= maxLength) return clean; + return clean.substring(0, maxLength); + } +} diff --git a/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml b/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml index f2cbf1d..9767438 100644 --- a/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml +++ b/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml @@ -8,6 +8,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -16,7 +17,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select template_id, template_name, template_group, template_body, sort_num, status, create_time, update_time + select template_id, template_name, template_group, template_scene, template_body, sort_num, status, create_time, update_time from tt_copy_template @@ -25,6 +26,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and template_name like concat('%', #{templateName}, '%') and template_group like concat('%', #{templateGroup}, '%') + and template_scene = #{templateScene} and status = #{status} order by sort_num asc, create_time asc @@ -33,6 +35,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" @@ -46,6 +49,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" template_name, template_group, + template_scene, template_body, sort_num, status, @@ -55,6 +59,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{templateName}, #{templateGroup}, + #{templateScene}, #{templateBody}, #{sortNum}, #{status}, @@ -68,6 +73,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" template_name = #{templateName}, template_group = #{templateGroup}, + template_scene = #{templateScene}, template_body = #{templateBody}, sort_num = #{sortNum}, status = #{status}, diff --git a/ruoyi-office/src/main/resources/mapper/office/TtPublishTaskMapper.xml b/ruoyi-office/src/main/resources/mapper/office/TtPublishTaskMapper.xml new file mode 100644 index 0000000..28dea79 --- /dev/null +++ b/ruoyi-office/src/main/resources/mapper/office/TtPublishTaskMapper.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select t.publish_id, t.task_id, t.code_id, c.code_name, t.platform, + t.title, t.content, t.tags, t.video_url, t.image_urls, t.cover_url, + t.status, t.status_message, t.last_error, t.bridge_token, + t.bridge_expires_at, t.started_at, t.ready_at, t.finished_at, + t.create_by, t.create_time, t.update_by, t.update_time + from tt_publish_task t + inner join tt_code c on c.code_id = t.code_id + + + + + + + + + + insert into tt_publish_task + (task_id, code_id, platform, title, content, tags, video_url, + image_urls, cover_url, status, status_message, bridge_token, + bridge_expires_at, create_by, create_time, update_by, update_time) + values + (#{taskId}, #{codeId}, #{platform}, #{title}, #{content}, #{tags}, + #{videoUrl}, #{imageUrls}, #{coverUrl}, #{status}, #{statusMessage}, + #{bridgeToken}, #{bridgeExpiresAt}, #{createBy}, now(), #{updateBy}, now()) + + + + update tt_publish_task + + status = #{status}, + status_message = #{statusMessage}, + last_error = #{lastError}, + started_at = #{startedAt}, + ready_at = #{readyAt}, + finished_at = #{finishedAt}, + update_by = #{updateBy}, + update_time = now() + + where task_id = #{taskId} + + diff --git a/ruoyi-office/src/test/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImplTest.java b/ruoyi-office/src/test/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImplTest.java new file mode 100644 index 0000000..61e6057 --- /dev/null +++ b/ruoyi-office/src/test/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImplTest.java @@ -0,0 +1,363 @@ +package com.ruoyi.office.service.impl; + +import java.util.Arrays; +import java.util.Collections; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.office.domain.PublishMaterial; +import com.ruoyi.office.domain.TtCode; +import com.ruoyi.office.domain.TtCopyTemplate; +import com.ruoyi.office.domain.TtPublishTask; +import com.ruoyi.office.mapper.TtPublishTaskMapper; +import com.ruoyi.office.service.CopyTemplateRenderer; +import com.ruoyi.office.service.ITtCodeService; +import com.ruoyi.office.service.ITtCopyTemplateService; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class TtPublishTaskServiceImplTest +{ + @InjectMocks + private TtPublishTaskServiceImpl service; + + @Mock + private TtPublishTaskMapper publishTaskMapper; + + @Mock + private ITtCodeService codeService; + + @Mock + private ITtCopyTemplateService copyTemplateService; + + @Mock + private CopyTemplateRenderer copyTemplateRenderer; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + + private TtCode code; + + @Before + public void setUp() + { + code = new TtCode(); + code.setCodeId(40L); + code.setCodeName("【K040】团购优惠商城管理系统"); + code.setPictureFile("/profile/a.jpg,/profile/b.png"); + code.setVideoFile("/profile/demo.mp4"); + when(codeService.selectTtCodeByCodeId(40L)).thenReturn(code); + when(copyTemplateService.selectEnabledTemplates("CODE")).thenReturn(Collections.emptyList()); + + SysUser user = new SysUser(); + user.setUserName("publisher"); + LoginUser loginUser = new LoginUser(user, Collections.emptySet()); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(loginUser, null)); + } + + @After + public void tearDown() + { + SecurityContextHolder.clearContext(); + } + + @Test + public void leavesMediaEmptyForManualSelection() + { + PublishMaterial material = service.selectProjectMaterial(40L, "xiaohongshu"); + + Assert.assertEquals(code.getCodeName(), material.getTitle()); + Assert.assertNull(material.getVideoUrl()); + Assert.assertTrue(material.getImageUrls().isEmpty()); + Assert.assertNull(material.getCoverUrl()); + } + + @Test + public void fillsContentFromTheOnlyTemplateInPlatformGroup() + { + TtCopyTemplate template = template(11L, "小红书种草文案", "小红书"); + when(copyTemplateService.selectEnabledTemplates("CODE")) + .thenReturn(Collections.singletonList(template)); + when(copyTemplateRenderer.render(template, code)).thenReturn("已渲染的小红书文案"); + + PublishMaterial material = service.selectProjectMaterial(40L, "xiaohongshu"); + + Assert.assertEquals(Long.valueOf(11L), material.getTemplateId()); + Assert.assertEquals("小红书种草文案", material.getTemplateName()); + Assert.assertEquals("小红书", material.getTemplateGroup()); + Assert.assertEquals("已渲染的小红书文案", material.getContent()); + } + + @Test + public void randomlyChoosesOneTemplateWhenPlatformGroupHasMultipleTemplates() + { + TtCopyTemplate first = template(21L, "抖音文案一", "抖音"); + TtCopyTemplate second = template(22L, "抖音文案二", "抖音"); + when(copyTemplateService.selectEnabledTemplates("CODE")) + .thenReturn(Arrays.asList(first, second)); + when(copyTemplateRenderer.render(any(TtCopyTemplate.class), any(TtCode.class))) + .thenAnswer(invocation -> ((TtCopyTemplate) invocation.getArgument(0)).getTemplateName()); + + PublishMaterial material = service.selectProjectMaterial(40L, "douyin"); + + Assert.assertTrue(Arrays.asList(21L, 22L).contains(material.getTemplateId())); + Assert.assertEquals(material.getTemplateName(), material.getContent()); + } + + @Test + public void prefersExactPlatformGroupOverSharedGroup() + { + TtCopyTemplate shared = template(31L, "三平台通用", "抖音、视频号、小红书"); + TtCopyTemplate exact = template(32L, "视频号专用", "视频号"); + when(copyTemplateService.selectEnabledTemplates("CODE")) + .thenReturn(Arrays.asList(shared, exact)); + when(copyTemplateRenderer.render(exact, code)).thenReturn("视频号专用内容"); + + PublishMaterial material = service.selectProjectMaterial(40L, "wechat_channels"); + + Assert.assertEquals(Long.valueOf(32L), material.getTemplateId()); + Assert.assertEquals("视频号专用内容", material.getContent()); + } + + @Test + public void fillsContentFromXianyuTemplateGroup() + { + TtCopyTemplate template = template(41L, "闲鱼商品文案", "闲鱼"); + String rendered = "闲鱼商品完整介绍"; + when(copyTemplateService.selectEnabledTemplates("CODE")) + .thenReturn(Collections.singletonList(template)); + when(copyTemplateRenderer.render(template, code)).thenReturn(rendered); + + PublishMaterial material = service.selectProjectMaterial(40L, "xianyu"); + + Assert.assertEquals(Long.valueOf(41L), material.getTemplateId()); + Assert.assertEquals(code.getCodeName(), material.getTitle()); + Assert.assertEquals(rendered, material.getContent()); + } + + @Test + public void fillsContentFromBilibiliTemplateGroup() + { + TtCopyTemplate template = template(44L, "B站视频文案", "B站"); + when(copyTemplateService.selectEnabledTemplates("CODE")) + .thenReturn(Collections.singletonList(template)); + when(copyTemplateRenderer.render(template, code)).thenReturn("B站视频简介"); + + PublishMaterial material = service.selectProjectMaterial(40L, "bilibili"); + + Assert.assertEquals(Long.valueOf(44L), material.getTemplateId()); + Assert.assertEquals("B站", material.getTemplateGroup()); + Assert.assertEquals("B站视频简介", material.getContent()); + } + + @Test + public void randomlyChoosesOneTemplateFromXianyuGroup() + { + TtCopyTemplate first = template(42L, "闲鱼文案一", "闲鱼"); + TtCopyTemplate second = template(43L, "闲鱼文案二", "闲鱼"); + when(copyTemplateService.selectEnabledTemplates("CODE")) + .thenReturn(Arrays.asList(first, second)); + when(copyTemplateRenderer.render(any(TtCopyTemplate.class), any(TtCode.class))) + .thenAnswer(invocation -> ((TtCopyTemplate) invocation.getArgument(0)).getTemplateName()); + + PublishMaterial material = service.selectProjectMaterial(40L, "xianyu"); + + Assert.assertTrue(Arrays.asList(42L, 43L).contains(material.getTemplateId())); + Assert.assertEquals(code.getCodeName(), material.getTitle()); + Assert.assertEquals(material.getTemplateName(), material.getContent()); + } + + @Test(expected = ServiceException.class) + public void rejectsDouyinTaskWithoutVideo() + { + TtPublishTask task = new TtPublishTask(); + task.setCodeId(40L); + task.setPlatform("douyin"); + task.setTitle("测试标题"); + task.setImageUrls("[\"/profile/a.jpg\"]"); + + service.createPublishTask(task); + } + + @Test(expected = ServiceException.class) + public void rejectsXianyuTaskWithoutImages() + { + TtPublishTask task = new TtPublishTask(); + task.setCodeId(40L); + task.setPlatform("xianyu"); + task.setTitle("闲鱼测试商品"); + service.createPublishTask(task); + } + + @Test(expected = ServiceException.class) + public void rejectsBilibiliTaskWithoutVideo() + { + TtPublishTask task = new TtPublishTask(); + task.setCodeId(40L); + task.setPlatform("bilibili"); + task.setTitle("B站测试视频"); + + service.createPublishTask(task); + } + + @Test + public void createsBilibiliTaskWithVideo() + { + TtPublishTask task = new TtPublishTask(); + task.setCodeId(40L); + task.setPlatform("bilibili"); + task.setTitle("B站测试视频"); + task.setContent("视频简介"); + task.setVideoUrl("/profile/demo.mp4"); + when(publishTaskMapper.insertPublishTask(any(TtPublishTask.class))).thenReturn(1); + + service.createPublishTask(task); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TtPublishTask.class); + verify(publishTaskMapper).insertPublishTask(captor.capture()); + Assert.assertEquals("bilibili", captor.getValue().getPlatform()); + Assert.assertEquals("/profile/demo.mp4", captor.getValue().getVideoUrl()); + } + + @Test(expected = ServiceException.class) + public void rejectsBilibiliTitleLongerThanEightyCharacters() + { + TtPublishTask task = new TtPublishTask(); + task.setCodeId(40L); + task.setPlatform("bilibili"); + task.setTitle("12345678901234567890123456789012345678901234567890123456789012345678901234567890X"); + task.setVideoUrl("/profile/demo.mp4"); + + service.createPublishTask(task); + } + + @Test + public void createsMultiplePlatformTasksAsOneBatch() + { + TtPublishTask xiaohongshu = new TtPublishTask(); + xiaohongshu.setCodeId(40L); + xiaohongshu.setPlatform("xiaohongshu"); + xiaohongshu.setTitle("小红书标题"); + xiaohongshu.setImageUrls("[\"/profile/a.jpg\"]"); + TtPublishTask bilibili = new TtPublishTask(); + bilibili.setCodeId(40L); + bilibili.setPlatform("bilibili"); + bilibili.setTitle("B站标题"); + bilibili.setVideoUrl("/profile/demo.mp4"); + when(publishTaskMapper.insertPublishTask(any(TtPublishTask.class))).thenReturn(1); + + java.util.List created = service.createPublishTasks( + Arrays.asList(xiaohongshu, bilibili)); + + Assert.assertEquals(2, created.size()); + verify(publishTaskMapper, times(2)).insertPublishTask(any(TtPublishTask.class)); + } + + @Test + public void removesUnsupportedCharactersFromWechatChannelsTitle() + { + TtPublishTask task = new TtPublishTask(); + task.setCodeId(40L); + task.setPlatform("wechat_channels"); + task.setTitle("【S072】 地方特产礼盒定制商城"); + task.setVideoUrl("/profile/demo.mp4"); + when(publishTaskMapper.insertPublishTask(any(TtPublishTask.class))).thenReturn(1); + + service.createPublishTask(task); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TtPublishTask.class); + verify(publishTaskMapper).insertPublishTask(captor.capture()); + Assert.assertEquals("S072 地方特产礼盒定制商城", captor.getValue().getTitle()); + } + + @Test(expected = ServiceException.class) + public void rejectsDuplicatePlatformsInBatch() + { + TtPublishTask first = new TtPublishTask(); + first.setCodeId(40L); + first.setPlatform("bilibili"); + first.setTitle("第一个B站标题"); + first.setVideoUrl("/profile/demo.mp4"); + TtPublishTask second = new TtPublishTask(); + second.setCodeId(40L); + second.setPlatform("bilibili"); + second.setTitle("第二个B站标题"); + second.setVideoUrl("/profile/demo.mp4"); + when(publishTaskMapper.insertPublishTask(any(TtPublishTask.class))).thenReturn(1); + + service.createPublishTasks(Arrays.asList(first, second)); + } + + @Test + public void createsXianyuTaskWithImagesAndText() + { + TtPublishTask task = new TtPublishTask(); + task.setCodeId(40L); + task.setPlatform("xianyu"); + task.setTitle("闲鱼测试商品"); + task.setContent("商品描述"); + task.setImageUrls("[\"/profile/a.jpg\"]"); + when(publishTaskMapper.insertPublishTask(any(TtPublishTask.class))).thenReturn(1); + + service.createPublishTask(task); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TtPublishTask.class); + verify(publishTaskMapper).insertPublishTask(captor.capture()); + Assert.assertEquals("xianyu", captor.getValue().getPlatform()); + Assert.assertEquals("商品描述", captor.getValue().getContent()); + Assert.assertEquals("[\"/profile/a.jpg\"]", captor.getValue().getImageUrls()); + } + + @Test + public void createsShortLivedTaskForExtension() + { + TtPublishTask task = new TtPublishTask(); + task.setCodeId(40L); + task.setPlatform("xiaohongshu"); + task.setTitle("测试标题"); + task.setContent("测试文案"); + task.setImageUrls("[\"/profile/a.jpg\"]"); + when(publishTaskMapper.insertPublishTask(any(TtPublishTask.class))).thenReturn(1); + + TtPublishTask created = service.createPublishTask(task); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TtPublishTask.class); + verify(publishTaskMapper).insertPublishTask(captor.capture()); + Assert.assertTrue(created.getTaskId().matches("publish_[a-f0-9]{32}")); + Assert.assertTrue(created.getBridgeToken().matches("[a-f0-9]{32}")); + Assert.assertEquals("PENDING", created.getStatus()); + Assert.assertEquals("publisher", created.getCreateBy()); + Assert.assertNotNull(created.getBridgeExpiresAt()); + Assert.assertTrue(created.getBridgeExpiresAt().after(created.getCreateTime() == null + ? new java.util.Date() : created.getCreateTime())); + } + + private TtCopyTemplate template(Long id, String name, String group) + { + TtCopyTemplate template = new TtCopyTemplate(); + template.setTemplateId(id); + template.setTemplateName(name); + template.setTemplateGroup(group); + template.setTemplateScene("CODE"); + template.setStatus("0"); + return template; + } +} diff --git a/ruoyi-ui/src/api/office/copyTemplate.js b/ruoyi-ui/src/api/office/copyTemplate.js index 5ea6400..7909572 100644 --- a/ruoyi-ui/src/api/office/copyTemplate.js +++ b/ruoyi-ui/src/api/office/copyTemplate.js @@ -9,11 +9,12 @@ export function listCopyTemplate(query) { }) } -// 查询所有启用的文案模板(源码明细页按钮用) -export function listEnabledTemplates() { +// 按场景查询所有启用的文案模板 +export function listEnabledTemplates(templateScene) { return request({ url: '/office/copyTemplate/enabled', - method: 'get' + method: 'get', + params: { templateScene } }) } @@ -49,4 +50,4 @@ export function delCopyTemplate(templateId) { url: '/office/copyTemplate/' + templateId, method: 'delete' }) -} \ No newline at end of file +} diff --git a/ruoyi-ui/src/api/office/publish.js b/ruoyi-ui/src/api/office/publish.js new file mode 100644 index 0000000..3b54823 --- /dev/null +++ b/ruoyi-ui/src/api/office/publish.js @@ -0,0 +1,33 @@ +import request from '@/utils/request' + +export function listPublishProjects(query) { + return request({ + url: '/office/publish/list', + method: 'get', + params: query + }) +} + +export function getPublishMaterial(codeId, platform) { + return request({ + url: '/office/publish/material/' + codeId, + method: 'get', + params: { platform } + }) +} + +export function createPublishTask(data) { + return request({ + url: '/office/publish/task', + method: 'post', + data: data + }) +} + +export function createPublishTasks(data) { + return request({ + url: '/office/publish/tasks', + method: 'post', + data: data + }) +} diff --git a/ruoyi-ui/src/components/ImageUpload/index.vue b/ruoyi-ui/src/components/ImageUpload/index.vue index 29690fe..08749c2 100644 --- a/ruoyi-ui/src/components/ImageUpload/index.vue +++ b/ruoyi-ui/src/components/ImageUpload/index.vue @@ -44,6 +44,7 @@ + + + + diff --git a/sql/copy_template.sql b/sql/copy_template.sql index d07ddc8..83a7aec 100644 --- a/sql/copy_template.sql +++ b/sql/copy_template.sql @@ -6,6 +6,7 @@ CREATE TABLE tt_copy_template ( template_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '模板ID', template_name VARCHAR(100) NOT NULL COMMENT '按钮显示名称', template_group VARCHAR(100) NOT NULL DEFAULT '默认分组' COMMENT '模板分组', + template_scene VARCHAR(20) NOT NULL DEFAULT 'CODE' COMMENT '模板场景(CODE源码文案 DELIVERY发货文案)', template_body LONGTEXT NOT NULL COMMENT '文案模板内容,支持 {变量} 占位符', sort_num INT DEFAULT 0 COMMENT '排序号', status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)', @@ -257,3 +258,12 @@ INSERT INTO tt_copy_template (template_name, template_body, sort_num, status, cr
长按小程序码,打开小程序搜索 "{projectCode}" 即可获取资源 ', 6, '0', NOW(), 'admin'); + +-- 项目清单“复制发货链接”使用的默认模板 +INSERT INTO tt_copy_template + (template_name, template_group, template_scene, template_body, sort_num, status, create_time, create_by) +VALUES + ('复制发货链接', '发货文案', 'DELIVERY', '{deliveryLinks} + +项目部署文档 +https://www.yuque.com/feastcoding/cg5w9h/rbvp8w80h8tultgy 密码:el4q', 1, '0', NOW(), 'admin'); diff --git a/sql/copy_template_scene.sql b/sql/copy_template_scene.sql new file mode 100644 index 0000000..34f8ef5 --- /dev/null +++ b/sql/copy_template_scene.sql @@ -0,0 +1,31 @@ +-- 文案模板增加使用场景,并初始化“复制发货链接”模板(已有数据库升级脚本) +ALTER TABLE `tt_copy_template` + ADD COLUMN `template_scene` VARCHAR(20) NOT NULL DEFAULT 'CODE' + COMMENT '模板场景(CODE源码文案 DELIVERY发货文案)' AFTER `template_group`; + +-- 历史模板均为源码文案 +UPDATE `tt_copy_template` +SET `template_scene` = 'CODE' +WHERE `template_scene` IS NULL OR TRIM(`template_scene`) = ''; + +-- 将原先写死在项目清单前端的发货文案迁入模板表 +INSERT INTO `tt_copy_template` + (`template_name`, `template_group`, `template_scene`, `template_body`, `sort_num`, `status`, `create_time`, `create_by`) +SELECT + '复制发货链接', + '发货文案', + 'DELIVERY', + '{deliveryLinks} + +项目部署文档 +https://www.yuque.com/feastcoding/cg5w9h/rbvp8w80h8tultgy 密码:el4q', + 1, + '0', + NOW(), + 'admin' +WHERE NOT EXISTS ( + SELECT 1 + FROM `tt_copy_template` + WHERE `template_scene` = 'DELIVERY' + AND `template_name` = '复制发货链接' +); diff --git a/sql/platform_publish.sql b/sql/platform_publish.sql new file mode 100644 index 0000000..b403f5e --- /dev/null +++ b/sql/platform_publish.sql @@ -0,0 +1,80 @@ +-- 发布管理与浏览器扩展任务表,可重复执行。 +CREATE TABLE IF NOT EXISTS `tt_publish_task` ( + `publish_id` bigint NOT NULL AUTO_INCREMENT COMMENT '发布记录ID', + `task_id` varchar(64) NOT NULL COMMENT '公开任务编号', + `code_id` bigint NOT NULL COMMENT '源码项目ID', + `platform` varchar(32) NOT NULL COMMENT 'xiaohongshu/douyin/wechat_channels/bilibili/xianyu', + `title` varchar(100) NOT NULL COMMENT '发布标题', + `content` text COMMENT '发布文案', + `tags` varchar(500) DEFAULT NULL COMMENT '标签,逗号分隔', + `video_url` varchar(1000) DEFAULT NULL COMMENT '视频地址', + `image_urls` text COMMENT '图片地址JSON数组', + `cover_url` varchar(1000) DEFAULT NULL COMMENT '封面地址', + `status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', + `status_message` varchar(255) DEFAULT NULL COMMENT '状态说明', + `last_error` varchar(1000) DEFAULT NULL COMMENT '最近错误', + `bridge_token` varchar(64) NOT NULL COMMENT '扩展短期访问凭证', + `bridge_expires_at` datetime NOT NULL COMMENT '扩展凭证失效时间', + `started_at` datetime DEFAULT NULL COMMENT '扩展开始执行时间', + `ready_at` datetime DEFAULT NULL COMMENT '等待人工发布时间', + `finished_at` datetime DEFAULT NULL COMMENT '任务结束时间', + `create_by` varchar(64) DEFAULT '' COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT '' COMMENT '更新者', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`publish_id`), + UNIQUE KEY `uk_publish_task_id` (`task_id`), + KEY `idx_publish_code_platform` (`code_id`, `platform`, `publish_id`), + KEY `idx_publish_status` (`status`), + KEY `idx_publish_update_time` (`update_time`), + KEY `idx_publish_bridge` (`task_id`, `bridge_token`, `bridge_expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='多平台发布任务'; + +-- 兼容已有的小红书、抖音、视频号通用模板,使首次部署即可自动生成发布文案。 +-- 闲鱼使用“闲鱼”分组中的启用模板;一个模板时直接使用,多个模板时随机选择。 +-- B站使用“B站”分组中的启用模板;用户可在文案模板管理中单独创建。 +-- 用户后续可在文案模板管理中分别创建“小红书”“抖音”“视频号”“B站”分组; +-- 平台专用分组存在时,会优先于这个共享分组。 +UPDATE `tt_copy_template` +SET `template_group` = '抖音、视频号、小红书' +WHERE `template_name` = '复制抖音、视频号、小红书文案' + AND (`template_group` IS NULL OR TRIM(`template_group`) = '' OR `template_group` = '默认分组'); + +-- 将发布管理放到源码管理所在目录。 +SET @publish_parent_id := COALESCE( + (SELECT parent_id FROM sys_menu WHERE perms = 'office:code:list' LIMIT 1), + 0 +); +SET @publish_order_num := COALESCE( + (SELECT MAX(order_num) + 1 FROM sys_menu WHERE parent_id = @publish_parent_id), + 1 +); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT '发布管理', @publish_parent_id, @publish_order_num, + 'publish', 'office/publish/index', '', 1, 0, + 'C', '0', '0', 'office:publish:list', 'documentation', 'admin', NOW(), '', NULL, + '浏览器扩展辅助发布管理' +WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'office:publish:list'); + +SET @publish_menu_id := ( + SELECT menu_id FROM sys_menu WHERE perms = 'office:publish:list' LIMIT 1 +); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT '发布资料查询', @publish_menu_id, 1, '', '', '', 1, 0, + 'F', '0', '0', 'office:publish:query', '#', 'admin', NOW(), '', NULL, '' +WHERE @publish_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'office:publish:query'); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT '创建发布任务', @publish_menu_id, 2, '', '', '', 1, 0, + 'F', '0', '0', 'office:publish:add', '#', 'admin', NOW(), '', NULL, '' +WHERE @publish_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'office:publish:add');