Expand publishing and virtual product workflows
This commit is contained in:
Binary file not shown.
BIN
browser-extension/multi-platform-publisher-1.1.17.zip
Normal file
BIN
browser-extension/multi-platform-publisher-1.1.17.zip
Normal file
Binary file not shown.
@@ -7,12 +7,14 @@
|
||||
1. 在 Chrome 地址栏打开 `chrome://extensions/`。
|
||||
2. 打开右上角“开发者模式”。
|
||||
3. 点击“加载已解压的扩展程序”。
|
||||
4. 选择本目录 `browser-extension/multi-platform-publisher`。
|
||||
4. 选择解压后的目录 `browser-extension/multi-platform-publisher-1.1.17`。
|
||||
5. 登录易代码后台以及需要使用的创作者平台。
|
||||
6. 重新打开后台“发布管理”页面,确认页面显示“发布扩展已连接”。
|
||||
|
||||
修改扩展代码后,需要在扩展管理页点击该扩展的“重新加载”,并刷新后台和平台页面。
|
||||
|
||||
点击浏览器工具栏中的扩展图标会打开右侧“多平台发布助手”。侧边栏展示凭证有效期内各平台的最新发布任务;点击平台名称可定位对应标签页,点击刷新图标会重新打开干净的发布页面并重试填写。已发布成功的任务禁止重试,避免重复发布。
|
||||
|
||||
## 工作范围
|
||||
|
||||
- 后台页面创建带短期凭证的发布任务。
|
||||
@@ -23,13 +23,17 @@ const advancingBatches = new Set()
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.storage.local.set({ publisherExtensionVersion: VERSION })
|
||||
configureSidePanel().catch(() => {})
|
||||
recoverStoredBatches().catch(() => {})
|
||||
})
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
configureSidePanel().catch(() => {})
|
||||
recoverStoredBatches().catch(() => {})
|
||||
})
|
||||
|
||||
configureSidePanel().catch(() => {})
|
||||
|
||||
chrome.alarms.onAlarm.addListener(alarm => {
|
||||
if (!alarm || !alarm.name.startsWith(BATCH_WATCHDOG_PREFIX)) return
|
||||
const batchId = alarm.name.slice(BATCH_WATCHDOG_PREFIX.length)
|
||||
@@ -79,6 +83,27 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === 'SIDEPANEL_GET_TASKS') {
|
||||
listSidePanelTasks()
|
||||
.then(tasks => sendResponse({ ok: true, tasks }))
|
||||
.catch(error => sendResponse({ ok: false, message: readableError(error) }))
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === 'SIDEPANEL_RETRY_TASK') {
|
||||
retryTask(message.taskId)
|
||||
.then(result => sendResponse({ ok: true, ...result }))
|
||||
.catch(error => sendResponse({ ok: false, message: readableError(error) }))
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === 'SIDEPANEL_ACTIVATE_TASK') {
|
||||
activateTaskTab(message.taskId)
|
||||
.then(result => sendResponse({ ok: true, ...result }))
|
||||
.catch(error => sendResponse({ ok: false, message: readableError(error) }))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
@@ -97,10 +122,93 @@ chrome.tabs.onRemoved.addListener(tabId => {
|
||||
await updateStatus(context, 'CANCELLED', '平台页面已关闭', '')
|
||||
await advanceBatch(context)
|
||||
}
|
||||
await chrome.storage.local.remove([tabKey(tabId), taskKey(context.taskId)])
|
||||
context.platformTabId = null
|
||||
context.closedAt = Date.now()
|
||||
await chrome.storage.local.set({ [taskKey(context.taskId)]: context })
|
||||
await chrome.storage.local.remove(tabKey(tabId))
|
||||
}).catch(() => {})
|
||||
})
|
||||
|
||||
async function configureSidePanel() {
|
||||
if (!chrome.sidePanel || !chrome.sidePanel.setPanelBehavior) return
|
||||
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })
|
||||
}
|
||||
|
||||
async function listSidePanelTasks() {
|
||||
const stored = await chrome.storage.local.get(null)
|
||||
const expiry = Date.now() - 4 * 60 * 60 * 1000
|
||||
const contexts = Object.keys(stored)
|
||||
.filter(key => key.startsWith('publisher_task_'))
|
||||
.map(key => stored[key])
|
||||
.filter(context => context && context.taskId && context.platform && context.createdAt >= expiry)
|
||||
.sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0))
|
||||
const latestByPlatform = new Map()
|
||||
for (const context of contexts) {
|
||||
if (!latestByPlatform.has(context.platform)) latestByPlatform.set(context.platform, context)
|
||||
}
|
||||
return Array.from(latestByPlatform.values()).map(context => ({
|
||||
taskId: context.taskId,
|
||||
platform: context.platform,
|
||||
platformName: platformName(context.platform),
|
||||
title: context.task && context.task.title || '',
|
||||
status: context.status || 'PENDING',
|
||||
statusMessage: context.statusMessage || '',
|
||||
platformTabId: context.platformTabId || null,
|
||||
createdAt: context.createdAt || 0,
|
||||
canRetry: context.status !== 'SUCCEEDED'
|
||||
}))
|
||||
}
|
||||
|
||||
async function activateTaskTab(taskId) {
|
||||
const context = await requireStoredTask(taskId)
|
||||
if (!context.platformTabId) throw new Error('平台页面已关闭,可点击刷新按钮重新打开')
|
||||
const tab = await chrome.tabs.get(context.platformTabId).catch(() => null)
|
||||
if (!tab) throw new Error('平台页面已关闭,可点击刷新按钮重新打开')
|
||||
await focusTab(tab)
|
||||
return { taskId: context.taskId, tabId: tab.id }
|
||||
}
|
||||
|
||||
async function retryTask(taskId) {
|
||||
const context = await requireStoredTask(taskId)
|
||||
if (context.status === 'SUCCEEDED') throw new Error('已发布成功的任务不能重试,以免重复发布')
|
||||
context.status = 'OPENING'
|
||||
context.statusMessage = '正在从侧边栏重试'
|
||||
context.closedAt = null
|
||||
await updateStatus(context, 'OPENING', '正在重试' + platformName(context.platform) + '发布任务', '')
|
||||
|
||||
let tab = context.platformTabId
|
||||
? await chrome.tabs.get(context.platformTabId).catch(() => null)
|
||||
: null
|
||||
if (tab) {
|
||||
await chrome.storage.local.set({ [tabKey(tab.id)]: context.taskId })
|
||||
tab = await chrome.tabs.update(tab.id, { url: PLATFORM_URLS[context.platform], active: true })
|
||||
} else {
|
||||
tab = await chrome.tabs.create({ url: PLATFORM_URLS[context.platform], active: true })
|
||||
}
|
||||
context.platformTabId = tab.id
|
||||
await chrome.storage.local.set({
|
||||
[taskKey(context.taskId)]: context,
|
||||
[tabKey(tab.id)]: context.taskId
|
||||
})
|
||||
await focusTab(tab)
|
||||
return { taskId: context.taskId, tabId: tab.id }
|
||||
}
|
||||
|
||||
async function requireStoredTask(taskId) {
|
||||
if (typeof taskId !== 'string' || !/^publish_[a-f0-9]{32}$/.test(taskId)) {
|
||||
throw new Error('发布任务编号无效')
|
||||
}
|
||||
const stored = await chrome.storage.local.get(taskKey(taskId))
|
||||
const context = stored[taskKey(taskId)]
|
||||
if (!context) throw new Error('发布任务已过期,请从后台重新创建')
|
||||
return context
|
||||
}
|
||||
|
||||
async function focusTab(tab) {
|
||||
if (tab.windowId) await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
|
||||
await chrome.tabs.update(tab.id, { active: true })
|
||||
}
|
||||
|
||||
async function startPublish(payload, sender) {
|
||||
if (!isAllowedAdmin(sender.url) || !sender.tab) {
|
||||
throw new Error('发布请求不是来自允许的后台页面')
|
||||
@@ -442,6 +550,8 @@ async function updateStatus(context, status, message, error) {
|
||||
throw new Error(body.msg || '发布状态回写失败')
|
||||
}
|
||||
context.status = status
|
||||
context.statusMessage = message || ''
|
||||
context.lastError = error || ''
|
||||
if (context.platformTabId) {
|
||||
await chrome.storage.local.set({ [taskKey(context.taskId)]: context })
|
||||
}
|
||||
@@ -151,8 +151,11 @@
|
||||
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() })
|
||||
const sourceName = parseFileName(disposition) || fallbackName
|
||||
const mimeType = normalizedAssetMimeType(blob.type, sourceName)
|
||||
validateAssetMimeType(kind, mimeType)
|
||||
const fileName = fileNameForMimeType(sourceName, mimeType)
|
||||
return new File([blob], fileName, { type: mimeType, lastModified: Date.now() })
|
||||
}
|
||||
|
||||
async function activatePublishMode(config, task) {
|
||||
@@ -808,6 +811,45 @@
|
||||
}[extension] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
function normalizedAssetMimeType(responseType, fileName) {
|
||||
const normalized = String(responseType || '').split(';')[0].trim().toLowerCase()
|
||||
return normalized && normalized !== 'application/octet-stream'
|
||||
? normalized
|
||||
: mimeForName(fileName)
|
||||
}
|
||||
|
||||
function validateAssetMimeType(kind, mimeType) {
|
||||
if ((kind === 'image' || kind === 'cover')
|
||||
&& !['image/png', 'image/jpeg', 'image/webp'].includes(mimeType)) {
|
||||
throw new Error(assetName(kind) + '返回的不是支持的图片格式(' + mimeType + ')')
|
||||
}
|
||||
if (kind === 'video' && ![
|
||||
'video/mp4', 'video/quicktime', 'video/x-m4v', 'video/webm'
|
||||
].includes(mimeType)) {
|
||||
throw new Error('视频返回的不是支持的视频格式(' + mimeType + ')')
|
||||
}
|
||||
}
|
||||
|
||||
function fileNameForMimeType(fileName, mimeType) {
|
||||
const expectedExtension = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
'video/mp4': 'mp4',
|
||||
'video/quicktime': 'mov',
|
||||
'video/x-m4v': 'm4v',
|
||||
'video/webm': 'webm'
|
||||
}[mimeType]
|
||||
const normalizedName = String(fileName || 'publish-asset').trim() || 'publish-asset'
|
||||
if (!expectedExtension) return normalizedName
|
||||
const currentMimeType = mimeForName(normalizedName)
|
||||
if (currentMimeType === mimeType) return normalizedName
|
||||
const lastSlash = Math.max(normalizedName.lastIndexOf('/'), normalizedName.lastIndexOf('\\'))
|
||||
const lastDot = normalizedName.lastIndexOf('.')
|
||||
const baseName = lastDot > lastSlash ? normalizedName.slice(0, lastDot) : normalizedName
|
||||
return baseName + '.' + expectedExtension
|
||||
}
|
||||
|
||||
function assetName(kind) {
|
||||
return { video: '视频', image: '图片', cover: '封面' }[kind] || '素材'
|
||||
}
|
||||
@@ -903,5 +945,13 @@
|
||||
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 })
|
||||
const publisherApi = { run, watch }
|
||||
if (globalThis.__YIDAIMA_PUBLISHER_TEST__) {
|
||||
publisherApi.test = Object.freeze({
|
||||
normalizedAssetMimeType,
|
||||
validateAssetMimeType,
|
||||
fileNameForMimeType
|
||||
})
|
||||
}
|
||||
globalThis.YidaimaPublisher = Object.freeze(publisherApi)
|
||||
})()
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "易代码多平台发布助手",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.17",
|
||||
"description": "从易代码后台读取发布任务,为小红书、抖音、视频号、B站和闲鱼填写发布资料,最终发布由用户手动确认。",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
"scripting",
|
||||
"alarms"
|
||||
"alarms",
|
||||
"sidePanel"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://feast.yidaima.cn/*",
|
||||
@@ -24,8 +25,10 @@
|
||||
"service_worker": "background/service-worker.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "易代码多平台发布助手",
|
||||
"default_popup": "popup/popup.html"
|
||||
"default_title": "打开多平台发布助手"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel/panel.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
@@ -0,0 +1,151 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: Arial, "Microsoft YaHei", sans-serif;
|
||||
color: #273142;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { min-width: 300px; margin: 0; background: #f6f8fb; }
|
||||
button { font: inherit; }
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 18px 16px 14px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e7ebf1;
|
||||
}
|
||||
.panel-header h1 { margin: 0; font-size: 17px; line-height: 24px; }
|
||||
.panel-header p { margin: 6px 0 0; color: #7a8495; font-size: 12px; line-height: 1.5; }
|
||||
.header-mark {
|
||||
display: inline-grid;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 4px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background: #28b66f;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.header-action,
|
||||
.retry-button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: background .15s ease, color .15s ease, transform .15s ease;
|
||||
}
|
||||
.header-action {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: #657084;
|
||||
background: #f1f4f8;
|
||||
border-radius: 8px;
|
||||
font-size: 19px;
|
||||
}
|
||||
.header-action:hover { color: #1677ff; background: #eaf3ff; }
|
||||
|
||||
main { padding: 12px 12px 54px; }
|
||||
.notice {
|
||||
margin-bottom: 10px;
|
||||
padding: 9px 11px;
|
||||
color: #9b5b00;
|
||||
background: #fff7e8;
|
||||
border: 1px solid #ffe0a3;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.notice.is-success { color: #237849; background: #edf9f2; border-color: #bce8cd; }
|
||||
.task-list { display: grid; gap: 9px; }
|
||||
.task-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 66px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e9f0;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(35, 47, 70, .04);
|
||||
overflow: hidden;
|
||||
}
|
||||
.task-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 11px 8px 11px 12px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.task-main:hover { background: #fafcff; }
|
||||
.platform-icon {
|
||||
display: inline-grid;
|
||||
flex: 0 0 34px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-right: 10px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
border-radius: 9px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.platform-xiaohongshu { background: #ff2442; }
|
||||
.platform-douyin { background: #15171d; }
|
||||
.platform-wechat_channels { background: #19b875; }
|
||||
.platform-bilibili { background: #00aeec; }
|
||||
.platform-xianyu { color: #322d00; background: #ffe500; }
|
||||
.task-copy { display: flex; flex: 1; min-width: 0; flex-direction: column; }
|
||||
.platform-name { color: #273142; font-size: 14px; line-height: 20px; }
|
||||
.task-title {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
color: #8a93a3;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-line { display: flex; align-items: center; margin-top: 4px; color: #697386; font-size: 11px; }
|
||||
.status-line i { width: 6px; height: 6px; margin-right: 5px; background: #9aa3b2; border-radius: 50%; }
|
||||
.status-line.is-running i { background: #1677ff; box-shadow: 0 0 0 3px #e8f2ff; }
|
||||
.status-line.is-ready i { background: #28b66f; box-shadow: 0 0 0 3px #e8f8ef; }
|
||||
.status-line.is-failed i { background: #f04438; box-shadow: 0 0 0 3px #ffedeb; }
|
||||
.status-line.is-login i { background: #f59f00; box-shadow: 0 0 0 3px #fff3d6; }
|
||||
.retry-button {
|
||||
flex: 0 0 42px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-right: 8px;
|
||||
color: #667085;
|
||||
background: #f5f7fa;
|
||||
border-radius: 9px;
|
||||
font-size: 21px;
|
||||
}
|
||||
.retry-button:hover { color: #1677ff; background: #eaf3ff; transform: rotate(-20deg); }
|
||||
.retry-button:disabled { color: #b7bec9; background: #f5f6f8; cursor: not-allowed; transform: none; }
|
||||
.retry-button.is-loading { animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.empty-state { padding: 54px 24px; color: #7b8595; text-align: center; }
|
||||
.empty-state .empty-icon { margin-bottom: 12px; color: #a9b2c0; font-size: 34px; }
|
||||
.empty-state strong { display: block; color: #4b5567; font-size: 14px; }
|
||||
.empty-state p { margin: 8px 0 0; font-size: 12px; line-height: 1.6; }
|
||||
|
||||
footer {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
color: #7f8998;
|
||||
background: rgba(255, 255, 255, .96);
|
||||
border-top: 1px solid #e7ebf1;
|
||||
font-size: 11px;
|
||||
}
|
||||
.connection-dot { width: 7px; height: 7px; margin-right: 6px; background: #28b66f; border-radius: 50%; }
|
||||
.version { margin-left: auto; }
|
||||
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>多平台发布助手</title>
|
||||
<link rel="stylesheet" href="panel.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h1><span class="header-mark">✓</span> 多平台发布助手</h1>
|
||||
<p>点击平台名称定位页面,点击刷新图标重新填写</p>
|
||||
</div>
|
||||
<button id="refresh-list" class="header-action" type="button" title="刷新任务列表" aria-label="刷新任务列表">↻</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div id="notice" class="notice" hidden></div>
|
||||
<div id="task-list" class="task-list"></div>
|
||||
<section id="empty-state" class="empty-state" hidden>
|
||||
<div class="empty-icon">⇧</div>
|
||||
<strong>暂无发布任务</strong>
|
||||
<p>请先在南音工作室系统的“发布管理”中创建任务。</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span id="connection-dot" class="connection-dot"></span>
|
||||
<span>扩展已连接</span>
|
||||
<span id="version" class="version"></span>
|
||||
</footer>
|
||||
|
||||
<template id="task-template">
|
||||
<article class="task-card">
|
||||
<button class="task-main" type="button">
|
||||
<span class="platform-icon"></span>
|
||||
<span class="task-copy">
|
||||
<strong class="platform-name"></strong>
|
||||
<small class="task-title"></small>
|
||||
<span class="status-line"><i></i><span></span></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="retry-button" type="button" title="重新打开并重试" aria-label="重新打开并重试">↻</button>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script src="panel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,103 @@
|
||||
const PLATFORM_META = Object.freeze({
|
||||
xiaohongshu: { short: '红', className: 'platform-xiaohongshu' },
|
||||
douyin: { short: '抖', className: 'platform-douyin' },
|
||||
wechat_channels: { short: '视', className: 'platform-wechat_channels' },
|
||||
bilibili: { short: 'B', className: 'platform-bilibili' },
|
||||
xianyu: { short: '闲', className: 'platform-xianyu' }
|
||||
})
|
||||
|
||||
const STATUS_META = Object.freeze({
|
||||
PENDING: { text: '等待扩展接收', className: '' },
|
||||
OPENING: { text: '正在打开平台', className: 'is-running' },
|
||||
LOGIN_REQUIRED: { text: '需要登录', className: 'is-login' },
|
||||
DOWNLOADING: { text: '正在读取素材', className: 'is-running' },
|
||||
UPLOADING: { text: '正在上传素材', className: 'is-running' },
|
||||
FILLING: { text: '正在填写资料', className: 'is-running' },
|
||||
READY_TO_PUBLISH: { text: '等待手动发布', className: 'is-ready' },
|
||||
SUCCEEDED: { text: '发布成功', className: 'is-ready' },
|
||||
FAILED: { text: '处理失败,可重试', className: 'is-failed' },
|
||||
CANCELLED: { text: '页面已关闭,可重试', className: 'is-failed' }
|
||||
})
|
||||
|
||||
const taskList = document.getElementById('task-list')
|
||||
const emptyState = document.getElementById('empty-state')
|
||||
const notice = document.getElementById('notice')
|
||||
const template = document.getElementById('task-template')
|
||||
document.getElementById('version').textContent = 'v' + chrome.runtime.getManifest().version
|
||||
document.getElementById('refresh-list').addEventListener('click', loadTasks)
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName !== 'local') return
|
||||
if (Object.keys(changes).some(key => key.startsWith('publisher_task_'))) loadTasks()
|
||||
})
|
||||
|
||||
async function loadTasks() {
|
||||
const response = await sendMessage({ type: 'SIDEPANEL_GET_TASKS' })
|
||||
if (!response || !response.ok) {
|
||||
showNotice(response && response.message || '读取发布任务失败')
|
||||
return
|
||||
}
|
||||
renderTasks(response.tasks || [])
|
||||
}
|
||||
|
||||
function renderTasks(tasks) {
|
||||
taskList.replaceChildren()
|
||||
emptyState.hidden = tasks.length > 0
|
||||
for (const task of tasks) {
|
||||
const card = template.content.firstElementChild.cloneNode(true)
|
||||
const meta = PLATFORM_META[task.platform] || { short: '?', className: '' }
|
||||
const status = STATUS_META[task.status] || { text: task.status || '未知状态', className: '' }
|
||||
const icon = card.querySelector('.platform-icon')
|
||||
icon.textContent = meta.short
|
||||
icon.classList.add(meta.className)
|
||||
card.querySelector('.platform-name').textContent = task.platformName || task.platform
|
||||
card.querySelector('.task-title').textContent = task.title || '当前发布任务'
|
||||
const statusLine = card.querySelector('.status-line')
|
||||
statusLine.classList.add(status.className)
|
||||
statusLine.querySelector('span').textContent = task.statusMessage || status.text
|
||||
|
||||
card.querySelector('.task-main').addEventListener('click', async () => {
|
||||
const response = await sendMessage({ type: 'SIDEPANEL_ACTIVATE_TASK', taskId: task.taskId })
|
||||
if (!response || !response.ok) showNotice(response && response.message || '无法定位平台页面')
|
||||
})
|
||||
|
||||
const retryButton = card.querySelector('.retry-button')
|
||||
retryButton.disabled = !task.canRetry
|
||||
retryButton.title = task.canRetry ? '重新打开并重试' : '已发布成功,不能重复执行'
|
||||
retryButton.addEventListener('click', async () => {
|
||||
retryButton.disabled = true
|
||||
retryButton.classList.add('is-loading')
|
||||
const response = await sendMessage({ type: 'SIDEPANEL_RETRY_TASK', taskId: task.taskId })
|
||||
retryButton.classList.remove('is-loading')
|
||||
if (!response || !response.ok) {
|
||||
retryButton.disabled = !task.canRetry
|
||||
showNotice(response && response.message || '重试失败')
|
||||
return
|
||||
}
|
||||
showNotice('已重新打开' + (task.platformName || task.platform) + '并开始填写', true)
|
||||
await loadTasks()
|
||||
})
|
||||
taskList.appendChild(card)
|
||||
}
|
||||
}
|
||||
|
||||
function showNotice(message, success = false) {
|
||||
notice.textContent = message
|
||||
notice.classList.toggle('is-success', success)
|
||||
notice.hidden = false
|
||||
window.setTimeout(() => { notice.hidden = true }, 4000)
|
||||
}
|
||||
|
||||
function sendMessage(message) {
|
||||
return new Promise(resolve => {
|
||||
chrome.runtime.sendMessage(message, response => {
|
||||
if (chrome.runtime.lastError) {
|
||||
resolve({ ok: false, message: chrome.runtime.lastError.message })
|
||||
return
|
||||
}
|
||||
resolve(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
loadTasks()
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import vm from 'node:vm'
|
||||
import test from 'node:test'
|
||||
|
||||
const testDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
const script = readFileSync(resolve(testDirectory, '../background/service-worker.js'), 'utf8')
|
||||
|
||||
function createRuntime(initialStorage = {}) {
|
||||
const storage = { ...initialStorage }
|
||||
const createdTabs = []
|
||||
let nextTabId = 900
|
||||
const event = () => ({ addListener() {} })
|
||||
const chrome = {
|
||||
runtime: {
|
||||
getManifest: () => ({ version: '1.1.17' }),
|
||||
onInstalled: event(),
|
||||
onStartup: event(),
|
||||
onMessage: event(),
|
||||
sendMessage: async () => undefined
|
||||
},
|
||||
alarms: {
|
||||
onAlarm: event(),
|
||||
create: async () => undefined,
|
||||
clear: async () => true
|
||||
},
|
||||
sidePanel: { setPanelBehavior: async () => undefined },
|
||||
storage: {
|
||||
local: {
|
||||
get: async key => {
|
||||
if (key == null) return { ...storage }
|
||||
if (Array.isArray(key)) return Object.fromEntries(key.filter(item => item in storage).map(item => [item, storage[item]]))
|
||||
return key in storage ? { [key]: storage[key] } : {}
|
||||
},
|
||||
set: async values => Object.assign(storage, values),
|
||||
remove: async keys => {
|
||||
for (const key of Array.isArray(keys) ? keys : [keys]) delete storage[key]
|
||||
}
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
onUpdated: event(),
|
||||
onRemoved: event(),
|
||||
create: async options => {
|
||||
const tab = { id: nextTabId++, windowId: 12, ...options }
|
||||
createdTabs.push(tab)
|
||||
return tab
|
||||
},
|
||||
get: async () => { throw new Error('tab not found') },
|
||||
update: async (id, options) => ({ id, windowId: 12, ...options }),
|
||||
query: async () => [],
|
||||
sendMessage: async () => undefined
|
||||
},
|
||||
windows: { update: async () => undefined },
|
||||
scripting: { executeScript: async () => [] }
|
||||
}
|
||||
const context = {
|
||||
chrome,
|
||||
console,
|
||||
URL,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
fetch: async () => ({ ok: true, json: async () => ({ code: 200 }) })
|
||||
}
|
||||
vm.createContext(context)
|
||||
vm.runInContext(script, context)
|
||||
return { context, storage, createdTabs }
|
||||
}
|
||||
|
||||
function task(taskId, platform, createdAt, status = 'FAILED') {
|
||||
return {
|
||||
taskId,
|
||||
token: 'a'.repeat(32),
|
||||
apiBaseUrl: 'https://feast.yidaima.cn/prod-api',
|
||||
platform,
|
||||
task: { title: platform + ' title' },
|
||||
status,
|
||||
statusMessage: '测试状态',
|
||||
createdAt,
|
||||
platformTabId: null
|
||||
}
|
||||
}
|
||||
|
||||
test('side panel lists only the latest task for each platform', async () => {
|
||||
const now = Date.now()
|
||||
const firstId = 'publish_' + '1'.repeat(32)
|
||||
const latestId = 'publish_' + '2'.repeat(32)
|
||||
const douyinId = 'publish_' + '3'.repeat(32)
|
||||
const runtime = createRuntime({
|
||||
['publisher_task_' + firstId]: task(firstId, 'xiaohongshu', now - 2000),
|
||||
['publisher_task_' + latestId]: task(latestId, 'xiaohongshu', now - 1000),
|
||||
['publisher_task_' + douyinId]: task(douyinId, 'douyin', now)
|
||||
})
|
||||
|
||||
const tasks = await runtime.context.listSidePanelTasks()
|
||||
|
||||
assert.equal(tasks.length, 2)
|
||||
assert.equal(tasks.find(item => item.platform === 'xiaohongshu').taskId, latestId)
|
||||
assert.equal(tasks.find(item => item.platform === 'douyin').taskId, douyinId)
|
||||
})
|
||||
|
||||
test('retry reopens a closed platform tab and stores the new mapping', async () => {
|
||||
const taskId = 'publish_' + '4'.repeat(32)
|
||||
const runtime = createRuntime({
|
||||
['publisher_task_' + taskId]: task(taskId, 'xianyu', Date.now())
|
||||
})
|
||||
|
||||
const result = await runtime.context.retryTask(taskId)
|
||||
|
||||
assert.equal(result.tabId, 900)
|
||||
assert.equal(runtime.createdTabs[0].url, 'https://www.goofish.pro/sale/product/add?from=%2Fon-sale')
|
||||
assert.equal(runtime.storage['publisher_task_' + taskId].status, 'OPENING')
|
||||
assert.equal(runtime.storage.publisher_tab_900, taskId)
|
||||
})
|
||||
|
||||
test('retry rejects a successfully published task', async () => {
|
||||
const taskId = 'publish_' + '5'.repeat(32)
|
||||
const runtime = createRuntime({
|
||||
['publisher_task_' + taskId]: task(taskId, 'bilibili', Date.now(), 'SUCCEEDED')
|
||||
})
|
||||
|
||||
await assert.rejects(() => runtime.context.retryTask(taskId), /不能重试/)
|
||||
})
|
||||
Reference in New Issue
Block a user