Add multi-platform publishing workflow

This commit is contained in:
王鹏
2026-09-14 17:40:52 +08:00
parent a466caa793
commit 0e24c9e235
41 changed files with 4980 additions and 58 deletions

Binary file not shown.

View File

@@ -0,0 +1,58 @@
# 易代码多平台发布助手
该扩展与后台“发布管理”页面配合,将视频、图片、封面、标题、文案、标签和商品信息填写到小红书、抖音、视频号、B站或闲鱼卖家工作台。
## 安装
1. 在 Chrome 地址栏打开 `chrome://extensions/`。
2. 打开右上角“开发者模式”。
3. 点击“加载已解压的扩展程序”。
4. 选择本目录 `browser-extension/multi-platform-publisher`。
5. 登录易代码后台以及需要使用的创作者平台。
6. 重新打开后台“发布管理”页面,确认页面显示“发布扩展已连接”。
修改扩展代码后,需要在扩展管理页点击该扩展的“重新加载”,并刷新后台和平台页面。
## 工作范围
- 后台页面创建带短期凭证的发布任务。
- 多平台发布会一次创建多个任务,扩展按小红书、抖音、视频号、B站、闲鱼的顺序逐个平台处理,避免同时上传多个大文件。
- 批次中的平台达到待手动发布、失败或需要登录后会继续下一个;页面状态异常时最多等待4分钟,避免整个批次永久卡住。
- 扩展打开目标平台、读取任务资料、上传素材并填写文本。
- 填写完成后任务状态为 `READY_TO_PUBLISH`(待手动发布)。
- 扩展不会查找或点击最终发布按钮。
- 用户手动发布后,扩展只观察平台新增的明确成功或失败提示并回写结果。
- 扩展不会处理扫码、验证码、安全校验或平台密码。
- B站视频会打开创作中心投稿页,上传视频并填写标题、简介和标签文案;分区等投稿选项由用户检查或手动选择。
- 小红书文案会按模板原有段落写入富文本编辑器,保留换行和空行。
- 抖音文案使用原生选区替换并兼容平台的话题节点,重复校验时不会再次追加全文。
- 抖音文案一次性写入带换行节点的富文本,保留模板换行且避免编辑器重绘造成内容错乱。
- 视频号只填写视频描述,不再填写短标题。
- 多平台批次在平台启动失败或状态回写短暂异常时仍会继续处理后续平台。
- 闲鱼商品只在闲管家网页中上传图片、选择分类并填写标题和文案;有历史分类时优先使用,否则固定选择“家居/服务/IT技术/软件/程序/网站开发”。
- 扩展沿用页面当前选中的闲鱼店铺,不自动切换店铺;遇到“高级版已过期”阻断提示时只关闭提示并继续填写。
- 扩展不填写闲鱼商品售价、原价和库存,不调用闲管家开放接口,也不读取或保存闲管家 Cookie。
## 权限说明
- `tabs`:打开对应平台创作者中心,并将任务绑定到该标签页。
- `storage`:在浏览器重载或平台登录跳转期间保存任务上下文。
- 后台及五个发布平台的站点权限:建立任务桥接、填写表单和上传素材。
扩展只允许从以下后台来源接收任务:
- `https://feast.yidaima.cn/office/publish*`
- 本地开发环境的 `localhost` 或 `127.0.0.1` 发布管理页面
## 测试清单
1. 页面能识别扩展已连接。
2. 创建任务后只打开所选平台。
3. 未登录时状态变为“需要登录”,不伪装成功。
4. 视频或图片能够进入平台上传控件。
5. 标题、文案和标签填写正确;B站还需确认投稿分区,闲鱼商品还需确认图片、分类、标题和描述正确。
6. 页面停在最终发布按钮前,扩展不点击该按钮。
7. 关闭未完成的平台标签页后,后台状态变为“已取消”。
8. 找不到关键控件或平台提示上传失败时,后台状态变为“发布失败”。
平台页面会持续更新。如果某个平台出现“未找到控件”,应根据当时页面 DOM 调整该平台单独的内容脚本,不能放宽到点击任意包含“发布”的按钮。

View File

@@ -0,0 +1,556 @@
const VERSION = chrome.runtime.getManifest().version
const PLATFORM_URLS = Object.freeze({
xiaohongshu: 'https://creator.xiaohongshu.com/publish/publish?source=official',
douyin: 'https://creator.douyin.com/creator-micro/content/upload',
wechat_channels: 'https://channels.weixin.qq.com/platform/post/create',
bilibili: 'https://member.bilibili.com/platform/upload/video/frame',
xianyu: 'https://www.goofish.pro/sale/product/add?from=%2Fon-sale'
})
const PLATFORM_HOSTS = Object.freeze({
xiaohongshu: 'creator.xiaohongshu.com',
douyin: 'creator.douyin.com',
wechat_channels: 'channels.weixin.qq.com',
bilibili: 'member.bilibili.com',
xianyu: 'goofish.pro'
})
const TERMINAL_STATUSES = new Set(['READY_TO_PUBLISH', 'SUCCEEDED', 'FAILED', 'CANCELLED'])
const BATCH_ADVANCE_STATUSES = new Set(['READY_TO_PUBLISH', 'FAILED', 'LOGIN_REQUIRED', 'CANCELLED'])
const BATCH_WATCHDOG_PREFIX = 'publisher_batch_watchdog:'
const advancingBatches = new Set()
chrome.runtime.onInstalled.addListener(() => {
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)) }

View File

@@ -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()
}
})()

View File

@@ -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 })
})()

View File

@@ -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 })
})()

View File

@@ -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 })
})()

View File

@@ -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 })
})()

View File

@@ -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 })
})()

View File

@@ -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 })
})()

View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,31 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>易代码多平台发布助手</title>
<style>
body { width: 300px; margin: 0; color: #253044; font-family: Arial, "Microsoft YaHei", sans-serif; background: #f6f8fb; }
.header { padding: 18px 18px 14px; color: #fff; background: linear-gradient(135deg, #1677ff, #6558f5); }
.header h1 { margin: 0 0 5px; font-size: 17px; }
.header p { margin: 0; opacity: .85; font-size: 12px; }
.content { padding: 15px 18px 18px; }
.status { display: flex; align-items: center; padding: 11px; background: #fff; border: 1px solid #e8ebf0; border-radius: 8px; }
.dot { width: 9px; height: 9px; margin-right: 9px; background: #38b26d; border-radius: 50%; box-shadow: 0 0 0 4px #e8f8ef; }
.hint { margin: 13px 0 0; color: #697386; font-size: 12px; line-height: 1.65; }
.version { margin-top: 12px; color: #a0a7b2; font-size: 11px; text-align: right; }
</style>
</head>
<body>
<div class="header">
<h1>多平台发布助手</h1>
<p>小红书 · 抖音 · 视频号 · B站 · 闲鱼</p>
</div>
<div class="content">
<div class="status"><span class="dot"></span><span>扩展已启用</span></div>
<p class="hint">请在易代码后台打开“发布管理”。扩展会上传并填写资料,但不会点击平台的最终发布按钮。</p>
<div id="version" class="version"></div>
</div>
<script src="popup.js"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
document.getElementById('version').textContent = '版本 ' + chrome.runtime.getManifest().version

68
docs/platform-publish.md Normal file
View File

@@ -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()` 时,只应看到发布类型标签切换逻辑。

View File

@@ -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));
}
/**

View File

@@ -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<PublishProjectRow> 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<TtPublishTask> 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);
}
}

View File

@@ -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<String> 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<String> getTags() { return tags; }
public void setTags(List<String> 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; }
}

View File

@@ -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<String> 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<String> getImageUrls() { return imageUrls; }
public void setImageUrls(List<String> imageUrls) { this.imageUrls = imageUrls; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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())

View File

@@ -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; }
}

View File

@@ -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<TtCopyTemplate> selectEnabledTemplates();
public List<TtCopyTemplate> selectEnabledTemplates(@Param("templateScene") String templateScene);
/**
* 新增文案模板
@@ -45,4 +46,4 @@ public interface TtCopyTemplateMapper {
* 删除文案模板信息
*/
public int deleteTtCopyTemplateByTemplateId(Long templateId);
}
}

View File

@@ -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<PublishProjectRow> 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);
}

View File

@@ -26,6 +26,11 @@ public interface ITtCopyTemplateService {
*/
public List<TtCopyTemplate> selectEnabledTemplates();
/**
* 按场景查询所有启用的文案模板
*/
public List<TtCopyTemplate> selectEnabledTemplates(String templateScene);
/**
* 新增文案模板
*/
@@ -45,4 +50,4 @@ public interface ITtCopyTemplateService {
* 删除文案模板信息
*/
public int deleteTtCopyTemplateByTemplateId(Long templateId);
}
}

View File

@@ -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<PublishProjectRow> selectPublishProjectList(PublishProjectRow query);
PublishMaterial selectProjectMaterial(Long codeId, String platform);
TtPublishTask createPublishTask(TtPublishTask task);
List<TtPublishTask> createPublishTasks(List<TtPublishTask> 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;
}

View File

@@ -32,7 +32,12 @@ public class TtCopyTemplateServiceImpl implements ITtCopyTemplateService {
@Override
public List<TtCopyTemplate> selectEnabledTemplates() {
return ttCopyTemplateMapper.selectEnabledTemplates();
return selectEnabledTemplates(null);
}
@Override
public List<TtCopyTemplate> 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);
}
}
}

View File

@@ -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<String> PLATFORMS = Collections.unmodifiableSet(
new LinkedHashSet<>(Arrays.asList("xiaohongshu", "douyin", "wechat_channels", "bilibili", "xianyu")));
private static final Set<String> 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<PublishProjectRow> 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<TtCopyTemplate> templates, String targetGroup)
{
if (templates == null || templates.isEmpty())
{
return null;
}
List<TtCopyTemplate> exactMatches = new ArrayList<>();
List<TtCopyTemplate> 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<TtCopyTemplate> 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<String> 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<TtPublishTask> createPublishTasks(List<TtPublishTask> tasks)
{
if (tasks == null || tasks.isEmpty())
{
throw new ServiceException("请至少选择一个发布平台");
}
if (tasks.size() > PLATFORMS.size())
{
throw new ServiceException("一次最多创建" + PLATFORMS.size() + "个平台任务");
}
Set<String> selectedPlatforms = new LinkedHashSet<>();
List<TtPublishTask> 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<String> 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<String> 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<String> parseImageUrls(String value)
{
if (StringUtils.isEmpty(value))
{
return new ArrayList<>();
}
try
{
if (value.trim().startsWith("["))
{
List<String> parsed = objectMapper.readValue(value, new TypeReference<List<String>>() { });
return cleanUrls(parsed);
}
}
catch (Exception ignored)
{
// 兼容历史逗号分隔形式。
}
return cleanUrls(Arrays.asList(value.split(",")));
}
private List<String> cleanUrls(List<String> urls)
{
LinkedHashSet<String> 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<String> urls)
{
try
{
return objectMapper.writeValueAsString(urls == null ? Collections.emptyList() : urls);
}
catch (Exception e)
{
throw new ServiceException("发布图片数据格式不正确");
}
}
private List<String> parseTags(String value)
{
if (StringUtils.isEmpty(value))
{
return new ArrayList<>();
}
LinkedHashSet<String> 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);
}
}

View File

@@ -8,6 +8,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="templateId" column="template_id" />
<result property="templateName" column="template_name" />
<result property="templateGroup" column="template_group" />
<result property="templateScene" column="template_scene" />
<result property="templateBody" column="template_body" />
<result property="sortNum" column="sort_num" />
<result property="status" column="status" />
@@ -16,7 +17,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectTtCopyTemplateVo">
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
</sql>
@@ -25,6 +26,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<where>
<if test="templateName != null and templateName != ''"> and template_name like concat('%', #{templateName}, '%')</if>
<if test="templateGroup != null and templateGroup != ''"> and template_group like concat('%', #{templateGroup}, '%')</if>
<if test="templateScene != null and templateScene != ''"> and template_scene = #{templateScene}</if>
<if test="status != null and status != ''"> and status = #{status}</if>
</where>
order by sort_num asc, create_time asc
@@ -33,6 +35,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectEnabledTemplates" resultMap="TtCopyTemplateResult">
<include refid="selectTtCopyTemplateVo"/>
where status = '0'
<if test="templateScene != null and templateScene != ''"> and template_scene = #{templateScene}</if>
order by sort_num asc, create_time asc
</select>
@@ -46,6 +49,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="templateName != null and templateName != ''">template_name,</if>
<if test="templateGroup != null and templateGroup != ''">template_group,</if>
<if test="templateScene != null and templateScene != ''">template_scene,</if>
<if test="templateBody != null">template_body,</if>
<if test="sortNum != null">sort_num,</if>
<if test="status != null and status != ''">status,</if>
@@ -55,6 +59,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="templateName != null and templateName != ''">#{templateName},</if>
<if test="templateGroup != null and templateGroup != ''">#{templateGroup},</if>
<if test="templateScene != null and templateScene != ''">#{templateScene},</if>
<if test="templateBody != null">#{templateBody},</if>
<if test="sortNum != null">#{sortNum},</if>
<if test="status != null and status != ''">#{status},</if>
@@ -68,6 +73,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<trim prefix="SET" suffixOverrides=",">
<if test="templateName != null and templateName != ''">template_name = #{templateName},</if>
<if test="templateGroup != null and templateGroup != ''">template_group = #{templateGroup},</if>
<if test="templateScene != null and templateScene != ''">template_scene = #{templateScene},</if>
<if test="templateBody != null">template_body = #{templateBody},</if>
<if test="sortNum != null">sort_num = #{sortNum},</if>
<if test="status != null and status != ''">status = #{status},</if>

View File

@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.office.mapper.TtPublishTaskMapper">
<resultMap type="TtPublishTask" id="PublishTaskResult">
<id property="publishId" column="publish_id" />
<result property="taskId" column="task_id" />
<result property="codeId" column="code_id" />
<result property="codeName" column="code_name" />
<result property="platform" column="platform" />
<result property="title" column="title" />
<result property="content" column="content" />
<result property="tags" column="tags" />
<result property="videoUrl" column="video_url" />
<result property="imageUrls" column="image_urls" />
<result property="coverUrl" column="cover_url" />
<result property="status" column="status" />
<result property="statusMessage" column="status_message" />
<result property="lastError" column="last_error" />
<result property="bridgeToken" column="bridge_token" />
<result property="bridgeExpiresAt" column="bridge_expires_at" />
<result property="startedAt" column="started_at" />
<result property="readyAt" column="ready_at" />
<result property="finishedAt" column="finished_at" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
</resultMap>
<resultMap type="PublishProjectRow" id="PublishProjectResult">
<id property="codeId" column="code_id" />
<result property="codeName" column="code_name" />
<result property="xiaohongshuTaskId" column="xiaohongshu_task_id" />
<result property="xiaohongshuStatus" column="xiaohongshu_status" />
<result property="douyinTaskId" column="douyin_task_id" />
<result property="douyinStatus" column="douyin_status" />
<result property="wechatChannelsTaskId" column="wechat_channels_task_id" />
<result property="wechatChannelsStatus" column="wechat_channels_status" />
<result property="bilibiliTaskId" column="bilibili_task_id" />
<result property="bilibiliStatus" column="bilibili_status" />
<result property="xianyuTaskId" column="xianyu_task_id" />
<result property="xianyuStatus" column="xianyu_status" />
<result property="lastPublishTime" column="last_publish_time" />
</resultMap>
<sql id="selectPublishTaskVo">
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
</sql>
<select id="selectPublishProjectList" parameterType="PublishProjectRow" resultMap="PublishProjectResult">
select c.code_id,
c.code_name,
x.task_id as xiaohongshu_task_id,
x.status as xiaohongshu_status,
d.task_id as douyin_task_id,
d.status as douyin_status,
w.task_id as wechat_channels_task_id,
w.status as wechat_channels_status,
b.task_id as bilibili_task_id,
b.status as bilibili_status,
y.task_id as xianyu_task_id,
y.status as xianyu_status,
greatest(
ifnull(x.update_time, '1970-01-01 00:00:00'),
ifnull(d.update_time, '1970-01-01 00:00:00'),
ifnull(w.update_time, '1970-01-01 00:00:00'),
ifnull(b.update_time, '1970-01-01 00:00:00'),
ifnull(y.update_time, '1970-01-01 00:00:00')
) as last_publish_time
from tt_code c
left join tt_publish_task x on x.publish_id = (
select max(tx.publish_id) from tt_publish_task tx
where tx.code_id = c.code_id and tx.platform = 'xiaohongshu'
)
left join tt_publish_task d on d.publish_id = (
select max(td.publish_id) from tt_publish_task td
where td.code_id = c.code_id and td.platform = 'douyin'
)
left join tt_publish_task w on w.publish_id = (
select max(tw.publish_id) from tt_publish_task tw
where tw.code_id = c.code_id and tw.platform = 'wechat_channels'
)
left join tt_publish_task b on b.publish_id = (
select max(tb.publish_id) from tt_publish_task tb
where tb.code_id = c.code_id and tb.platform = 'bilibili'
)
left join tt_publish_task y on y.publish_id = (
select max(ty.publish_id) from tt_publish_task ty
where ty.code_id = c.code_id and ty.platform = 'xianyu'
)
<where>
<if test="codeName != null and codeName != ''">
and c.code_name like concat('%', #{codeName}, '%')
</if>
</where>
order by c.code_id desc
</select>
<select id="selectPublishTaskByTaskId" parameterType="String" resultMap="PublishTaskResult">
<include refid="selectPublishTaskVo" />
where t.task_id = #{taskId}
limit 1
</select>
<select id="selectPublishTaskByBridge" resultMap="PublishTaskResult">
<include refid="selectPublishTaskVo" />
where t.task_id = #{taskId}
and t.bridge_token = #{bridgeToken}
and t.bridge_expires_at &gt; now()
limit 1
</select>
<insert id="insertPublishTask" parameterType="TtPublishTask" useGeneratedKeys="true" keyProperty="publishId">
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())
</insert>
<update id="updatePublishTaskStatus" parameterType="TtPublishTask">
update tt_publish_task
<set>
status = #{status},
status_message = #{statusMessage},
last_error = #{lastError},
<if test="startedAt != null">started_at = #{startedAt},</if>
<if test="readyAt != null">ready_at = #{readyAt},</if>
<if test="finishedAt != null">finished_at = #{finishedAt},</if>
update_by = #{updateBy},
update_time = now()
</set>
where task_id = #{taskId}
</update>
</mapper>

View File

@@ -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<TtPublishTask> 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<TtPublishTask> 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<TtPublishTask> 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<TtPublishTask> 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<TtPublishTask> 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;
}
}

View File

@@ -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'
})
}
}

View File

@@ -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
})
}

View File

@@ -44,6 +44,7 @@
<script>
import { getToken } from "@/utils/auth";
import Sortable from "sortablejs";
export default {
props: {
@@ -67,6 +68,11 @@ export default {
isShowTip: {
type: Boolean,
default: true
},
// 是否允许拖动图片调整顺序
enableSort: {
type: Boolean,
default: false
}
},
data() {
@@ -81,9 +87,16 @@ export default {
headers: {
Authorization: "Bearer " + getToken(),
},
fileList: []
fileList: [],
imageSortable: null
};
},
mounted() {
this.$nextTick(this.initImageSort);
},
beforeDestroy() {
if (this.imageSortable) this.imageSortable.destroy();
},
watch: {
value: {
handler(val) {
@@ -93,11 +106,7 @@ export default {
// 然后将数组转为对象数组
this.fileList = list.map(item => {
if (typeof item === "string") {
if (item.indexOf(this.baseUrl) === -1) {
item = { name: this.baseUrl + item, url: item };
} else {
item = { name: item, url: item };
}
item = { name: item, url: this.getPreviewUrl(item) };
}
return item;
});
@@ -105,6 +114,7 @@ export default {
this.fileList = [];
return [];
}
this.$nextTick(this.initImageSort);
},
deep: true,
immediate: true
@@ -117,6 +127,21 @@ export default {
},
},
methods: {
initImageSort() {
if (!this.enableSort || this.imageSortable || !this.$refs.imageUpload) return;
const list = this.$refs.imageUpload.$el.querySelector('.el-upload-list');
if (!list) return;
this.imageSortable = Sortable.create(list, {
animation: 150,
draggable: '.el-upload-list__item',
onEnd: ({ oldIndex, newIndex }) => {
if (oldIndex === newIndex || oldIndex == null || newIndex == null) return;
const moved = this.fileList.splice(oldIndex, 1)[0];
this.fileList.splice(newIndex, 0, moved);
this.$emit("input", this.listToString(this.fileList));
}
});
},
// 上传前loading加载
handleBeforeUpload(file) {
let isImg = false;
@@ -155,12 +180,12 @@ export default {
// 上传成功回调
handleUploadSuccess(res, file) {
if (res.code === 200) {
this.uploadList.push({ name: res.fileName, url: res.fileName });
this.uploadList.push({ name: res.fileName, url: this.getPreviewUrl(res.fileName) });
this.uploadedSuccessfully();
} else {
this.number--;
this.$modal.closeLoading();
this.$modal.msgError(res.msg);
this.$modal.msgError((res && res.msg) || "上传图片失败,请检查服务器上传目录配置");
this.$refs.imageUpload.handleRemove(file);
this.uploadedSuccessfully();
}
@@ -175,6 +200,7 @@ export default {
},
// 上传失败
handleUploadError() {
if (this.number > 0) this.number--;
this.$modal.msgError("上传图片失败,请重试");
this.$modal.closeLoading();
},
@@ -193,13 +219,30 @@ export default {
this.dialogImageUrl = file.url;
this.dialogVisible = true;
},
// 将后端返回的 /profile/... 相对路径转换为可访问的预览地址。
// 完整网络地址和 data/blob 本地预览地址保持不变。
getPreviewUrl(url) {
if (!url || typeof url !== "string") return "";
if (/^(https?:)?\/\//i.test(url) || /^(data|blob):/i.test(url)) return url;
if (url.indexOf(this.baseUrl + "/") === 0 || url === this.baseUrl) return url;
const path = url.indexOf("/") === 0 ? url : "/" + url;
return this.baseUrl + path;
},
// 提交表单时仍保存相对路径,避免把环境前缀或域名写入数据库。
getStorageUrl(url) {
if (!url || typeof url !== "string") return "";
if (url.indexOf(this.baseUrl + "/") === 0) {
return url.substring(this.baseUrl.length);
}
return url;
},
// 对象转成指定字符串分隔
listToString(list, separator) {
let strs = "";
separator = separator || ",";
for (let i in list) {
if (list[i].url) {
strs += list[i].url.replace(this.baseUrl, "") + separator;
strs += this.getStorageUrl(list[i].url) + separator;
}
}
return strs != '' ? strs.substr(0, strs.length - 1) : '';
@@ -223,4 +266,3 @@ export default {
transform: translateY(0);
}
</style>

View File

@@ -615,7 +615,7 @@
});
},
loadCopyTemplates() {
listEnabledTemplates().then(response => {
listEnabledTemplates('CODE').then(response => {
this.copyTemplates = response.data || [];
this.selectDefaultArticleTemplate();
this.selectDefaultCopywritingTemplate();

View File

@@ -17,6 +17,12 @@
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="模板场景" prop="templateScene">
<el-select v-model="queryParams.templateScene" placeholder="请选择模板场景" clearable>
<el-option label="源码文案" value="CODE" />
<el-option label="发货文案" value="DELIVERY" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
<el-option label="正常" value="0" />
@@ -46,6 +52,13 @@
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="按钮名称" align="center" prop="templateName" width="160" />
<el-table-column label="模板分组" align="center" prop="templateGroup" width="140" />
<el-table-column label="模板场景" align="center" prop="templateScene" width="110">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.templateScene === 'DELIVERY' ? 'warning' : ''">
{{ scope.row.templateScene === 'DELIVERY' ? '发货文案' : '源码文案' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="排序" align="center" prop="sortNum" width="80" />
<el-table-column label="状态" align="center" prop="status" width="100">
<template slot-scope="scope">
@@ -75,6 +88,12 @@
<el-form-item label="模板分组" prop="templateGroup">
<el-input v-model="form.templateGroup" placeholder="请输入模板分组,例如:闲鱼、公众号" />
</el-form-item>
<el-form-item label="模板场景" prop="templateScene">
<el-radio-group v-model="form.templateScene">
<el-radio label="CODE">源码文案</el-radio>
<el-radio label="DELIVERY">发货文案</el-radio>
</el-radio-group>
</el-form-item>
<el-row>
<el-col :span="12">
<el-form-item label="排序" prop="sortNum">
@@ -95,34 +114,37 @@
v-model="form.templateBody"
type="textarea"
:rows="20"
placeholder="请输入文案模板内容,支持以下占位符:
{codeName} 项目名称(完整)
{projectCode} 项目编号(如 A075)
{projectName} 纯项目名(去掉编号和基于...实现的前缀)
{codeDesc} 项目描述(纯文本)
{codeEnvironment} 运行环境
{frontendTechnology} 前端
{backendTechnology} 后端
{databaseTechnology} 数据库
{codeTechnology} 其他技术
{diskLink} 网盘地址
{screenshots} 截图列表(自动生成 markdown 格式)"
:placeholder="templateBodyPlaceholder"
/>
</el-form-item>
<el-alert type="info" :closable="false" style="margin-bottom:0">
<template slot="title">
<strong>支持的占位符:</strong>
<code>{codeName}</code> 项目名称 &nbsp;
<code>{projectCode}</code> 项目编号 &nbsp;
<code>{projectName}</code> 纯项目名 &nbsp;
<code>{codeDesc}</code> 描述(纯文本) &nbsp;
<code>{codeEnvironment}</code> 运行环境 &nbsp;
<code>{frontendTechnology}</code> 前端 &nbsp;
<code>{backendTechnology}</code> 后端 &nbsp;
<code>{databaseTechnology}</code> 数据库 &nbsp;
<code>{codeTechnology}</code> 其他技术 &nbsp;
<code>{diskLink}</code> 网盘地址 &nbsp;
<code>{screenshots}</code> 截图列表
<template v-if="form.templateScene === 'DELIVERY'">
<strong>发货文案占位符:</strong>
<code>{deliveryLinks}</code> 完整网盘文案 &nbsp;
<code>{quarkLink}</code> 夸克链接 &nbsp;
<code>{baiduLink}</code> 百度链接 &nbsp;
<code>{projectNum}</code> 项目编号 &nbsp;
<code>{projectName}</code> 项目名称 &nbsp;
<code>{sourceName}</code> 源码名称 &nbsp;
<code>{projectTechnology}</code> 项目技术 &nbsp;
<code>{projectDesc}</code> 项目描述 &nbsp;
<code>{videoLink}</code> 演示视频
</template>
<template v-else>
<strong>源码文案占位符:</strong>
<code>{codeName}</code> 项目名称 &nbsp;
<code>{projectCode}</code> 项目编号 &nbsp;
<code>{projectName}</code> 纯项目名 &nbsp;
<code>{codeDesc}</code> 描述(纯文本) &nbsp;
<code>{codeEnvironment}</code> 运行环境 &nbsp;
<code>{frontendTechnology}</code> 前端 &nbsp;
<code>{backendTechnology}</code> 后端 &nbsp;
<code>{databaseTechnology}</code> 数据库 &nbsp;
<code>{codeTechnology}</code> 其他技术 &nbsp;
<code>{diskLink}</code> 网盘地址 &nbsp;
<code>{screenshots}</code> 截图列表
</template>
</template>
</el-alert>
</el-form>
@@ -155,12 +177,14 @@ export default {
pageSize: 10,
templateName: null,
templateGroup: null,
templateScene: null,
status: null
},
form: {},
rules: {
templateName: [{ required: true, message: "按钮名称不能为空", trigger: "blur" }],
templateGroup: [{ required: true, message: "模板分组不能为空", trigger: "blur" }],
templateScene: [{ required: true, message: "模板场景不能为空", trigger: "change" }],
templateBody: [{ required: true, message: "模板内容不能为空", trigger: "blur" }],
sortNum: [{ required: true, message: "排序不能为空", trigger: "blur" }]
}
@@ -169,6 +193,14 @@ export default {
created() {
this.getList();
},
computed: {
templateBodyPlaceholder() {
if (this.form.templateScene === 'DELIVERY') {
return '请输入发货文案模板内容,例如:\n{deliveryLinks}\n\n项目部署文档\n文档地址和密码'
}
return '请输入源码文案模板内容,支持 {codeName}、{projectCode}、{projectName}、{codeDesc}、{diskLink}、{screenshots} 等占位符'
}
},
methods: {
getList() {
this.loading = true;
@@ -187,6 +219,7 @@ export default {
templateId: null,
templateName: null,
templateGroup: "默认分组",
templateScene: "CODE",
templateBody: null,
sortNum: 0,
status: "0"

View File

@@ -380,6 +380,7 @@ import {
checkProjectLinks,
checkSelectedProjectLinks
} from '@/api/office/project'
import { listEnabledTemplates } from '@/api/office/copyTemplate'
import { getToken } from '@/utils/auth'
export default {
@@ -404,6 +405,8 @@ export default {
total: 0,
// 项目清单表格数据
projectList: [],
// 启用的发货文案模板
deliveryTemplates: [],
// 弹出层标题
title: '',
// 是否显示弹出层
@@ -450,6 +453,7 @@ export default {
},
created() {
this.getList()
this.loadDeliveryTemplates()
},
methods: {
/** 查询项目清单列表 */
@@ -461,6 +465,12 @@ export default {
this.loading = false
})
},
/** 查询启用的发货文案模板 */
loadDeliveryTemplates() {
listEnabledTemplates('DELIVERY').then(response => {
this.deliveryTemplates = response.data || []
})
},
// 取消按钮
cancel() {
this.open = false
@@ -761,20 +771,42 @@ export default {
})
})
},
copyUrlToClipboard1(row) {
let content = ''
renderDeliveryTemplate(template, row) {
const linkSections = []
if (row.projectUrl) {
content += '夸克网盘\n'
content += '链接:' + row.projectUrl
}
if (content.length > 0) {
content += '\n\n'
linkSections.push('夸克网盘\n链接:' + row.projectUrl)
}
if (row.projectBaiduUrl) {
content += '百度网盘\n'
content += '链接:' + row.projectBaiduUrl
linkSections.push('百度网盘\n链接:' + row.projectBaiduUrl)
}
content += '\n\n项目部署文档\nhttps://www.yuque.com/feastcoding/cg5w9h/rbvp8w80h8tultgy 密码:el4q'
const variables = {
deliveryLinks: linkSections.join('\n\n'),
projectNum: row.projectNum || '',
projectName: row.projectName || '',
sourceName: row.projectName1 || '',
projectName1: row.projectName1 || '',
projectTechnology: row.projectNum1 || '',
projectDesc: row.projectDesc || '',
quarkLink: row.projectUrl || '',
projectUrl: row.projectUrl || '',
baiduLink: row.projectBaiduUrl || '',
projectBaiduUrl: row.projectBaiduUrl || '',
videoLink: row.projectVurl || '',
projectVurl: row.projectVurl || ''
}
let content = template.templateBody || ''
Object.keys(variables).forEach(key => {
content = content.split('{' + key + '}').join(variables[key])
})
return content
},
copyUrlToClipboard1(row, template) {
const selectedTemplate = template || this.deliveryTemplates[0]
if (!selectedTemplate) {
this.$message({ message: '请先在文案模板管理中配置并启用发货文案模板', type: 'warning' })
return
}
const content = this.renderDeliveryTemplate(selectedTemplate, row)
navigator.clipboard.writeText(content).then(() => {
this.$message({
message: '复制成功',

File diff suppressed because it is too large Load Diff

View File

@@ -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
<br>
<span>长按小程序码,打开小程序搜索 "<Strong style="color:var(--md-primary-color);">{projectCode}</Strong>" 即可获取资源</span>
</center>', 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');

View File

@@ -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` = '复制发货链接'
);

80
sql/platform_publish.sql Normal file
View File

@@ -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');