From b904dcf52eab818ec1c320001ce55053433ddf1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Wed, 30 Sep 2026 11:00:01 +0800 Subject: [PATCH] Expand publishing and virtual product workflows --- ...ip => multi-platform-publisher-1.1.16.zip} | Bin 26085 -> 26592 bytes .../multi-platform-publisher-1.1.17.zip | Bin 0 -> 31755 bytes .../README.md | 4 +- .../background/service-worker.js | 112 ++++++- .../content/admin-bridge.js | 0 .../content/bilibili.js | 0 .../content/douyin.js | 0 .../content/platform-common.js | 56 +++- .../content/wechat-channels.js | 0 .../content/xianyu.js | 0 .../content/xiaohongshu.js | 0 .../manifest.json | 11 +- .../popup/popup.html | 0 .../popup/popup.js | 0 .../sidepanel/panel.css | 151 +++++++++ .../sidepanel/panel.html | 50 +++ .../sidepanel/panel.js | 103 ++++++ .../tests/sidepanel-service-worker.test.mjs | 125 ++++++++ docs/virtual-pay-setup.md | 8 +- .../app/AppVirtualProductController.java | 83 +++++ .../controller/common/CommonController.java | 9 +- .../controller/TtCopyTemplateController.java | 11 + .../domain/TtCopyTemplateGroupStat.java | 37 +++ .../office/mapper/TtCopyTemplateMapper.java | 6 + .../service/ITtCopyTemplateService.java | 6 + .../service/WebsiteArticleDocumentParser.java | 12 +- .../service/WechatDraftContentService.java | 4 +- .../service/impl/TtCodeServiceImpl.java | 23 +- .../impl/TtCopyTemplateServiceImpl.java | 6 + .../service/impl/TtFileServiceImpl.java | 4 +- .../impl/TtProjectInfoServiceImpl.java | 41 +-- .../impl/TtPublishTaskServiceImpl.java | 123 +++++++- .../mapper/office/TtCopyTemplateMapper.xml | 32 +- .../WebsiteArticleDocumentParserTest.java | 13 + .../WechatDraftContentServiceTest.java | 51 +++ .../service/impl/TtCodeServiceImplTest.java | 30 ++ .../service/impl/TtFileServiceImplTest.java | 44 +++ .../impl/TtProjectInfoServiceImplTest.java | 49 +++ .../impl/TtPublishTaskServiceImplTest.java | 110 ++++++- .../ruoyi/app/domain/AppVirtualProduct.java | 9 + .../app/mapper/AppVirtualProductMapper.java | 16 + .../service/IAppVirtualProductService.java | 20 ++ .../impl/AppVirtualProductServiceImpl.java | 101 ++++++ .../mapper/app/AppVirtualProductMapper.xml | 58 +++- ruoyi-ui/src/api/app/virtualProduct.js | 21 ++ ruoyi-ui/src/api/office/copyTemplate.js | 8 + .../src/views/app/virtualProduct/index.vue | 227 ++++++++++++++ ruoyi-ui/src/views/office/code/index.vue | 2 +- .../src/views/office/copyTemplate/index.vue | 293 +++++++++++++++--- ruoyi-ui/src/views/office/publish/index.vue | 10 +- sql/source_code_technology_backfill.sql | 140 +++++++++ sql/source_code_technology_fields.sql | 1 + sql/virtual_pay_resource.sql | 4 +- sql/virtual_product_menu.sql | 69 +++++ 54 files changed, 2185 insertions(+), 108 deletions(-) rename browser-extension/{multi-platform-publisher-1.1.15.zip => multi-platform-publisher-1.1.16.zip} (58%) create mode 100644 browser-extension/multi-platform-publisher-1.1.17.zip rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/README.md (90%) rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/background/service-worker.js (81%) rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/content/admin-bridge.js (100%) rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/content/bilibili.js (100%) rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/content/douyin.js (100%) rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/content/platform-common.js (94%) rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/content/wechat-channels.js (100%) rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/content/xianyu.js (100%) rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/content/xiaohongshu.js (100%) rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/manifest.json (91%) rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/popup/popup.html (100%) rename browser-extension/{multi-platform-publisher-1.1.15 => multi-platform-publisher-1.1.17}/popup/popup.js (100%) create mode 100644 browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.css create mode 100644 browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.html create mode 100644 browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.js create mode 100644 browser-extension/multi-platform-publisher-1.1.17/tests/sidepanel-service-worker.test.mjs create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualProductController.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/domain/TtCopyTemplateGroupStat.java create mode 100644 ruoyi-office/src/test/java/com/ruoyi/office/service/WechatDraftContentServiceTest.java create mode 100644 ruoyi-office/src/test/java/com/ruoyi/office/service/impl/TtFileServiceImplTest.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/service/IAppVirtualProductService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppVirtualProductServiceImpl.java create mode 100644 ruoyi-ui/src/api/app/virtualProduct.js create mode 100644 ruoyi-ui/src/views/app/virtualProduct/index.vue create mode 100644 sql/source_code_technology_backfill.sql create mode 100644 sql/virtual_product_menu.sql diff --git a/browser-extension/multi-platform-publisher-1.1.15.zip b/browser-extension/multi-platform-publisher-1.1.16.zip similarity index 58% rename from browser-extension/multi-platform-publisher-1.1.15.zip rename to browser-extension/multi-platform-publisher-1.1.16.zip index 5a37c93782121e92926be333c5a36592ef115f59..39e4ec6f0f929aa7b1d13306f04ff45695a86803 100644 GIT binary patch delta 10543 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 delta 10019 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 diff --git a/browser-extension/multi-platform-publisher-1.1.17.zip b/browser-extension/multi-platform-publisher-1.1.17.zip new file mode 100644 index 0000000000000000000000000000000000000000..92b3aa5574b34c5eef8a5527f7aac62ad4e0bfea GIT binary patch literal 31755 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 V{i?G80Kk7er@zY9#BTq5`ajM;G>!lO literal 0 HcmV?d00001 diff --git a/browser-extension/multi-platform-publisher-1.1.15/README.md b/browser-extension/multi-platform-publisher-1.1.17/README.md similarity index 90% rename from browser-extension/multi-platform-publisher-1.1.15/README.md rename to browser-extension/multi-platform-publisher-1.1.17/README.md index a88a99a..c9b210d 100644 --- a/browser-extension/multi-platform-publisher-1.1.15/README.md +++ b/browser-extension/multi-platform-publisher-1.1.17/README.md @@ -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. 重新打开后台“发布管理”页面,确认页面显示“发布扩展已连接”。 修改扩展代码后,需要在扩展管理页点击该扩展的“重新加载”,并刷新后台和平台页面。 +点击浏览器工具栏中的扩展图标会打开右侧“多平台发布助手”。侧边栏展示凭证有效期内各平台的最新发布任务;点击平台名称可定位对应标签页,点击刷新图标会重新打开干净的发布页面并重试填写。已发布成功的任务禁止重试,避免重复发布。 + ## 工作范围 - 后台页面创建带短期凭证的发布任务。 diff --git a/browser-extension/multi-platform-publisher-1.1.15/background/service-worker.js b/browser-extension/multi-platform-publisher-1.1.17/background/service-worker.js similarity index 81% rename from browser-extension/multi-platform-publisher-1.1.15/background/service-worker.js rename to browser-extension/multi-platform-publisher-1.1.17/background/service-worker.js index 10bea31..cf9bc46 100644 --- a/browser-extension/multi-platform-publisher-1.1.15/background/service-worker.js +++ b/browser-extension/multi-platform-publisher-1.1.17/background/service-worker.js @@ -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 }) } diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/admin-bridge.js b/browser-extension/multi-platform-publisher-1.1.17/content/admin-bridge.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/admin-bridge.js rename to browser-extension/multi-platform-publisher-1.1.17/content/admin-bridge.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/bilibili.js b/browser-extension/multi-platform-publisher-1.1.17/content/bilibili.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/bilibili.js rename to browser-extension/multi-platform-publisher-1.1.17/content/bilibili.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/douyin.js b/browser-extension/multi-platform-publisher-1.1.17/content/douyin.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/douyin.js rename to browser-extension/multi-platform-publisher-1.1.17/content/douyin.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/platform-common.js b/browser-extension/multi-platform-publisher-1.1.17/content/platform-common.js similarity index 94% rename from browser-extension/multi-platform-publisher-1.1.15/content/platform-common.js rename to browser-extension/multi-platform-publisher-1.1.17/content/platform-common.js index c387613..13406f7 100644 --- a/browser-extension/multi-platform-publisher-1.1.15/content/platform-common.js +++ b/browser-extension/multi-platform-publisher-1.1.17/content/platform-common.js @@ -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) })() diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/wechat-channels.js b/browser-extension/multi-platform-publisher-1.1.17/content/wechat-channels.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/wechat-channels.js rename to browser-extension/multi-platform-publisher-1.1.17/content/wechat-channels.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/xianyu.js b/browser-extension/multi-platform-publisher-1.1.17/content/xianyu.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/xianyu.js rename to browser-extension/multi-platform-publisher-1.1.17/content/xianyu.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/content/xiaohongshu.js b/browser-extension/multi-platform-publisher-1.1.17/content/xiaohongshu.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/content/xiaohongshu.js rename to browser-extension/multi-platform-publisher-1.1.17/content/xiaohongshu.js diff --git a/browser-extension/multi-platform-publisher-1.1.15/manifest.json b/browser-extension/multi-platform-publisher-1.1.17/manifest.json similarity index 91% rename from browser-extension/multi-platform-publisher-1.1.15/manifest.json rename to browser-extension/multi-platform-publisher-1.1.17/manifest.json index 5524458..ad372e8 100644 --- a/browser-extension/multi-platform-publisher-1.1.15/manifest.json +++ b/browser-extension/multi-platform-publisher-1.1.17/manifest.json @@ -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": [ { diff --git a/browser-extension/multi-platform-publisher-1.1.15/popup/popup.html b/browser-extension/multi-platform-publisher-1.1.17/popup/popup.html similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/popup/popup.html rename to browser-extension/multi-platform-publisher-1.1.17/popup/popup.html diff --git a/browser-extension/multi-platform-publisher-1.1.15/popup/popup.js b/browser-extension/multi-platform-publisher-1.1.17/popup/popup.js similarity index 100% rename from browser-extension/multi-platform-publisher-1.1.15/popup/popup.js rename to browser-extension/multi-platform-publisher-1.1.17/popup/popup.js diff --git a/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.css b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.css new file mode 100644 index 0000000..b3f649e --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.css @@ -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; } diff --git a/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.html b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.html new file mode 100644 index 0000000..ba5ddd0 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.html @@ -0,0 +1,50 @@ + + + + + + 多平台发布助手 + + + +
+
+

✓ 多平台发布助手

+

点击平台名称定位页面,点击刷新图标重新填写

+
+ +
+ +
+ +
+ +
+ +
+ + 扩展已连接 + +
+ + + + + + diff --git a/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.js b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.js new file mode 100644 index 0000000..d0232a8 --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.17/sidepanel/panel.js @@ -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() diff --git a/browser-extension/multi-platform-publisher-1.1.17/tests/sidepanel-service-worker.test.mjs b/browser-extension/multi-platform-publisher-1.1.17/tests/sidepanel-service-worker.test.mjs new file mode 100644 index 0000000..1ceb87a --- /dev/null +++ b/browser-extension/multi-platform-publisher-1.1.17/tests/sidepanel-service-worker.test.mjs @@ -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), /不能重试/) +}) diff --git a/docs/virtual-pay-setup.md b/docs/virtual-pay-setup.md index 1f86d6e..be44d23 100644 --- a/docs/virtual-pay-setup.md +++ b/docs/virtual-pay-setup.md @@ -46,6 +46,8 @@ AppKey 和小程序 Secret 不应提交到 Git,生产环境应由部署平台 | 5 元 | `resource_500` | | 10 元 | `resource_1000` | +执行 `sql/virtual_product_menu.sql` 并重新登录若依后台,然后在“虚拟道具管理”中新增或维护对应记录。微信道具 ID 必须与微信公众平台保持完全一致,价格以“分”为单位。 + 然后在若依后台编辑付费资源: - 获取方式选择“付费”; @@ -55,7 +57,7 @@ AppKey 和小程序 Secret 不应提交到 Git,生产环境应由部署平台 - 规格价格必须存在于 `app_virtual_product` 的已启用价格档位中,系统会自动匹配对应的 `productId`。 - 所有可能产生的升级差价也必须配置价格档位。例如版本价格分别为 `9900`、`19900`、`29900` 分,还需配置 `10000`、`20000` 分两个差价档位。 -后台会自动维护“价格 -> productId”唯一映射;一个 productId 不能绑定多个价格。 +“虚拟道具管理”会校验“价格 -> productId”唯一映射;一个价格只能配置一个道具,一个 productId 也不能绑定多个价格。 用户升级时,服务端按“目标版本当前价格 - 已拥有最高版本当前价格”计算实付金额。前端展示金额仅供参考,签名和订单金额始终由服务端重新计算。高版本退款后,只撤销该高版本订单对应的权益,用户更早单独购买的低版本权益仍然保留。 @@ -81,8 +83,8 @@ Token: 与 WX_VIRTUAL_PAY_CALLBACK_TOKEN 相同 4. 检查 `app_virtual_order.status=1` 和 `app_resource_entitlement.status=1`。 5. 重新进入资源详情,确认下载链接只对购买用户返回。 -## 6. 启用后台订单管理 +## 6. 启用后台管理 -执行 `sql/virtual_pay_order_menu.sql`,然后重新登录若依后台。在原“支付订单”菜单的同级位置会出现“虚拟支付订单”,支持按订单号、微信交易号、用户、资源、状态和创建时间查询,并可查看详情或导出 Excel。 +执行 `sql/virtual_product_menu.sql` 和 `sql/virtual_pay_order_menu.sql`,然后重新登录若依后台。在原“支付订单”菜单的同级位置会出现“虚拟道具管理”和“虚拟支付订单”。道具页面支持价格档位的新增、修改、停用、删除和导出;订单页面支持按订单号、微信交易号、用户、资源、状态和创建时间查询,并可查看详情或导出 Excel。 虚拟支付订单由微信回调和主动查单流程维护,后台页面只提供查询与导出,不允许人工修改或删除订单。 diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualProductController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualProductController.java new file mode 100644 index 0000000..971b79b --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppVirtualProductController.java @@ -0,0 +1,83 @@ +package com.ruoyi.web.controller.app; + +import java.util.List; +import javax.servlet.http.HttpServletResponse; +import com.ruoyi.app.domain.AppVirtualProduct; +import com.ruoyi.app.service.IAppVirtualProductService; +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.common.utils.poi.ExcelUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 微信虚拟支付道具管理接口。 + */ +@RestController +@RequestMapping("/app/virtualProduct") +public class AppVirtualProductController extends BaseController +{ + @Autowired + private IAppVirtualProductService virtualProductService; + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:list')") + @GetMapping("/list") + public TableDataInfo list(AppVirtualProduct product) + { + startPage(); + List list = virtualProductService.selectAppVirtualProductList(product); + return getDataTable(list); + } + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:query')") + @GetMapping("/{id}") + public AjaxResult getInfo(@PathVariable Long id) + { + return success(virtualProductService.selectAppVirtualProductById(id)); + } + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:add')") + @Log(title = "虚拟道具", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody AppVirtualProduct product) + { + return toAjax(virtualProductService.insertAppVirtualProduct(product)); + } + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:edit')") + @Log(title = "虚拟道具", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody AppVirtualProduct product) + { + return toAjax(virtualProductService.updateAppVirtualProduct(product)); + } + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:remove')") + @Log(title = "虚拟道具", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) + { + return toAjax(virtualProductService.deleteAppVirtualProductByIds(ids)); + } + + @PreAuthorize("@ss.hasPermi('app:virtualProduct:export')") + @Log(title = "虚拟道具", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(HttpServletResponse response, AppVirtualProduct product) + { + List list = virtualProductService.selectAppVirtualProductList(product); + ExcelUtil util = new ExcelUtil<>(AppVirtualProduct.class); + util.exportExcel(response, list, "虚拟道具数据"); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java index 351dc5d..301f4ea 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java @@ -20,7 +20,6 @@ import org.springframework.web.multipart.MultipartFile; import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.common.constant.Constants; import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.common.utils.DateUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.file.FileUploadUtils; import com.ruoyi.common.utils.file.FileUtils; @@ -107,10 +106,12 @@ public class CommonController { && !extension.equals(".png") && !extension.equals(".webp")) { return AjaxResult.error("仅支持JPG、PNG和WebP图片"); } - String fname = "admin-images/" + DateUtils.datePath() + "/" - + UUID.randomUUID().toString().replace("-", "") + extension; + String fname = originalFilename.substring(0, originalFilename.lastIndexOf('.')) + + "-" + UUID.randomUUID().toString().replace("-", ""); String domain = QiNiuConfig.getInstance().getDomainOfBucket(); - if (!domain.startsWith("http://") && !domain.startsWith("https://")) { + if (domain.startsWith("http://")) { + domain = "https://" + domain.substring("http://".length()); + } else if (!domain.startsWith("https://")) { domain = "https://" + domain; } String fkey = domain.replaceAll("/$", "") + "/" + fname; diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java index c2596be..b149b7a 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtCopyTemplateController.java @@ -20,6 +20,7 @@ import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.enums.BusinessType; import com.ruoyi.office.domain.TtCode; import com.ruoyi.office.domain.TtCopyTemplate; +import com.ruoyi.office.domain.TtCopyTemplateGroupStat; import com.ruoyi.office.service.CopyTemplateRenderer; import com.ruoyi.office.service.ITtCodeService; import com.ruoyi.office.service.ITtCopyTemplateService; @@ -56,6 +57,16 @@ public class TtCopyTemplateController extends BaseController { return getDataTable(list); } + /** + * 查询模板分组及各分组模板数量 + */ + @PreAuthorize("@ss.hasPermi('office:copyTemplate:list')") + @GetMapping("/groups") + public AjaxResult groups() { + List groups = ttCopyTemplateService.selectCopyTemplateGroupStats(); + return success(groups); + } + /** * 按场景查询所有启用的文案模板(无需分页权限) */ diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtCopyTemplateGroupStat.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtCopyTemplateGroupStat.java new file mode 100644 index 0000000..b34880f --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtCopyTemplateGroupStat.java @@ -0,0 +1,37 @@ +package com.ruoyi.office.domain; + +import java.io.Serializable; + +/** + * 文案模板分组统计。 + */ +public class TtCopyTemplateGroupStat implements Serializable +{ + private static final long serialVersionUID = 1L; + + /** 分组名称;__UNGROUPED__ 表示未分组。 */ + private String templateGroup; + + /** 分组下的模板数量。 */ + private Long templateCount; + + public String getTemplateGroup() + { + return templateGroup; + } + + public void setTemplateGroup(String templateGroup) + { + this.templateGroup = templateGroup; + } + + public Long getTemplateCount() + { + return templateCount; + } + + public void setTemplateCount(Long templateCount) + { + this.templateCount = templateCount; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java index b9f8d79..731c426 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtCopyTemplateMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.office.mapper; import java.util.List; import com.ruoyi.office.domain.TtCopyTemplate; +import com.ruoyi.office.domain.TtCopyTemplateGroupStat; import org.apache.ibatis.annotations.Param; /** @@ -22,6 +23,11 @@ public interface TtCopyTemplateMapper { */ public List selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate); + /** + * 查询模板分组及模板数量 + */ + public List selectCopyTemplateGroupStats(); + /** * 查询所有启用的文案模板(按排序号升序) */ diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java index ef08177..d9bd401 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtCopyTemplateService.java @@ -2,6 +2,7 @@ package com.ruoyi.office.service; import java.util.List; import com.ruoyi.office.domain.TtCopyTemplate; +import com.ruoyi.office.domain.TtCopyTemplateGroupStat; /** * 文案模板Service接口 @@ -21,6 +22,11 @@ public interface ITtCopyTemplateService { */ public List selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate); + /** + * 查询模板分组及模板数量 + */ + public List selectCopyTemplateGroupStats(); + /** * 查询所有启用的文案模板(供源码明细页按钮使用) */ diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleDocumentParser.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleDocumentParser.java index 358d08c..7fd5562 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleDocumentParser.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WebsiteArticleDocumentParser.java @@ -19,6 +19,16 @@ public class WebsiteArticleDocumentParser private static final Pattern MARKDOWN_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)"); public WebsiteArticleDocument parse(String renderedContent) + { + return parse(renderedContent, true); + } + + public WebsiteArticleDocument parseForWechat(String renderedContent) + { + return parse(renderedContent, false); + } + + private WebsiteArticleDocument parse(String renderedContent, boolean enforceWebsiteTitleLimit) { if (StringUtils.isBlank(renderedContent)) { @@ -44,7 +54,7 @@ public class WebsiteArticleDocumentParser { throw new ServiceException("复制网站文案第一行标题为空"); } - if (title.length() > 50) + if (enforceWebsiteTitleLimit && title.length() > 50) { throw new ServiceException("网站文章标题不能超过50个字符:" + title); } diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java index d48a350..33db974 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java @@ -50,7 +50,7 @@ public class WechatDraftContentService if (code == null) throw new ServiceException("源码项目不存在"); if (template == null || !"0".equals(template.getStatus())) throw new ServiceException("文案模板不存在或已停用"); - WebsiteArticleDocument generated = documentParser.parse(templateRenderer.render(template, code)); + WebsiteArticleDocument generated = documentParser.parseForWechat(templateRenderer.render(template, code)); String markdown = StringUtils.isNotBlank(request.getContentMarkdown()) ? request.getContentMarkdown() : generated.getContentMarkdown(); String title = StringUtils.isNotBlank(request.getTitle()) ? request.getTitle() : generated.getTitle(); @@ -59,7 +59,7 @@ public class WechatDraftContentService WechatDraftPreview preview = new WechatDraftPreview(); preview.setRequestNo(StringUtils.isNotBlank(request.getRequestNo()) ? request.getRequestNo() : UUID.randomUUID().toString().replace("-", "")); - preview.setTitle(limit(title, 32)); + preview.setTitle(limit(title, 60)); preview.setAuthor(limit(firstNonBlank(request.getAuthor(), account == null ? null : account.getDefaultAuthor()), 16)); String html = articleRenderer.render(markdown, request.getThemeColor(), request.getThemeName(), request.getFontSize()); // 摘要默认留空,交由微信使用正文内容生成展示摘要;手动摘要遵循微信 120 字限制。 diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCodeServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCodeServiceImpl.java index ae23a81..e2cf29a 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCodeServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCodeServiceImpl.java @@ -38,13 +38,14 @@ import com.ruoyi.office.service.ITtCodeService; @Service public class TtCodeServiceImpl implements ITtCodeService { private static final Pattern PREMIUM_PROJECT_CODE_PATTERN = - Pattern.compile("(?i)(?:^|【)\\s*([SK])\\s*\\d+"); + Pattern.compile("(?i)(?:^|【)\\s*([SKF])\\s*\\d+"); private static final long SOURCE_SHARE_RESOURCE_TYPE = 5L; private static final long PREMIUM_PROJECT_RESOURCE_TYPE = 6L; private static final long SOURCE_SHARE_ARTICLE_TYPE = 4L; private static final long PREMIUM_SOURCE_ARTICLE_TYPE = 7L; private static final long POINTS_ACCESS_TYPE = 2L; private static final long PAID_ACCESS_TYPE = 3L; + private static final String F_BASIC_SPEC_NAME = "源码 + 数据库 + 万字文档 + PPT"; private static final String BASIC_SPEC_NAME = "源码 + 数据库 + 论文 + 答辩PPT"; private static final String DEPLOYMENT_SPEC_NAME = "源码 + 数据库 + 论文 + 答辩PPT + 项目部署"; private static final String DEPLOYMENT_CONTACT = "调试部署加微信:forfeastcoding"; @@ -286,6 +287,12 @@ public class TtCodeServiceImpl implements ITtCodeService { String projectSeries) { List resourceList = new ArrayList(); + if ("F".equals(projectSeries)) + { + addResourceList(resourceList, resourceId, F_BASIC_SPEC_NAME, + projectInfo.getProjectUrl(), 900, 1); + return resourceList; + } if ("K".equals(projectSeries)) { addResourceList(resourceList, resourceId, BASIC_SPEC_NAME, @@ -346,7 +353,7 @@ public class TtCodeServiceImpl implements ITtCodeService { resource.setResourceType(PREMIUM_PROJECT_RESOURCE_TYPE); resource.setIsAd(PAID_ACCESS_TYPE); resource.setAdNumber(0L); - resource.setPriceFen("K".equals(projectSeries) ? 1900 : 5900); + resource.setPriceFen(resolvePremiumBasePrice(projectSeries)); return; } resource.setResourceType(SOURCE_SHARE_RESOURCE_TYPE); @@ -357,7 +364,17 @@ public class TtCodeServiceImpl implements ITtCodeService { private boolean isPremiumProject(String projectSeries) { - return "S".equals(projectSeries) || "K".equals(projectSeries); + return "S".equals(projectSeries) || "K".equals(projectSeries) + || "F".equals(projectSeries); + } + + private int resolvePremiumBasePrice(String projectSeries) + { + if ("F".equals(projectSeries)) + { + return 900; + } + return "K".equals(projectSeries) ? 1900 : 5900; } private String resolveProjectSeries(TtCode code, TtProjectInfo projectInfo) diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java index 81b9d77..d45f85b 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtCopyTemplateServiceImpl.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ruoyi.common.utils.DateUtils; import com.ruoyi.office.domain.TtCopyTemplate; +import com.ruoyi.office.domain.TtCopyTemplateGroupStat; import com.ruoyi.office.mapper.TtCopyTemplateMapper; import com.ruoyi.office.service.ITtCopyTemplateService; @@ -30,6 +31,11 @@ public class TtCopyTemplateServiceImpl implements ITtCopyTemplateService { return ttCopyTemplateMapper.selectTtCopyTemplateList(ttCopyTemplate); } + @Override + public List selectCopyTemplateGroupStats() { + return ttCopyTemplateMapper.selectCopyTemplateGroupStats(); + } + @Override public List selectEnabledTemplates() { return selectEnabledTemplates(null); diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtFileServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtFileServiceImpl.java index fdd076f..77d8c34 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtFileServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtFileServiceImpl.java @@ -21,7 +21,7 @@ public class TtFileServiceImpl implements ITtFileService { @Autowired private TtFileMapper ttFileMapper; - private final String SITE_URL = "http://img.yidaima.cn/"; + private final String SITE_URL = "https://img.yidaima.cn/"; /** * 查询文件管理 @@ -58,7 +58,7 @@ public class TtFileServiceImpl implements ITtFileService { String[] pictures = ttFile.getPictureFile().split(","); TreeMap treeMap = new TreeMap(); for (String url : pictures) { - url = url.replaceAll(SITE_URL, ""); + url = url.replace(SITE_URL, ""); String[] urls = url.split("-"); String picName = urls[0]; if(picName.contains("_")){ diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java index cd09694..5e8653f 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtProjectInfoServiceImpl.java @@ -3,11 +3,9 @@ package com.ruoyi.office.service.impl; import java.net.URI; import java.net.URLEncoder; import java.util.ArrayList; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -171,7 +169,8 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService List validRows = validateRows(sourceRows, normalizedDiskType, result); Set duplicateNames = findDuplicateNames(validRows); - Map> projectsByName = loadProjectsByNormalizedName(); + List projects = new ArrayList<>( + ttProjectInfoMapper.selectTtProjectInfoList(new TtProjectInfo())); for (ValidatedImportRow row : validRows) { @@ -181,23 +180,22 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService continue; } - List matches = projectsByName.get(row.normalizedName); - if (matches != null && matches.size() > 1) + List matches = findMatchingProjects(row, projects); + if (matches.size() > 1) { - result.addFailed(row.rowNumber, row.projectName, "数据库中存在多个同名项目,无法确定更新目标"); + result.addFailed(row.rowNumber, row.projectName, + "项目编号、项目名称或源码名称匹配到多条数据,无法确定更新目标"); continue; } - if (matches == null || matches.isEmpty()) + if (matches.isEmpty()) { TtProjectInfo project = createProject(row, normalizedDiskType); if (ttProjectInfoMapper.insertTtProjectInfo(project) != 1) { throw new ServiceException("新增项目失败:" + row.projectName); } - List inserted = new ArrayList<>(); - inserted.add(project); - projectsByName.put(row.normalizedName, inserted); + projects.add(project); result.incrementAddedCount(); continue; } @@ -344,20 +342,22 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService } } - private Map> loadProjectsByNormalizedName() + private List findMatchingProjects(ValidatedImportRow row, List projects) { - Map> projectsByName = new HashMap<>(); - List projects = ttProjectInfoMapper.selectTtProjectInfoList(new TtProjectInfo()); + List matches = new ArrayList<>(); + String projectNum = normalizeProjectNum(extractProjectNum(row.projectName)); for (TtProjectInfo project : projects) { - String normalizedName = normalizeProjectName(project.getProjectName()); - if (StringUtils.isBlank(normalizedName)) + boolean projectNumMatches = StringUtils.isNotBlank(projectNum) + && projectNum.equals(normalizeProjectNum(project.getProjectNum())); + boolean projectNameMatches = row.normalizedName.equals(normalizeProjectName(project.getProjectName())); + boolean sourceNameMatches = row.normalizedName.equals(normalizeProjectName(project.getProjectName1())); + if (projectNumMatches || projectNameMatches || sourceNameMatches) { - continue; + matches.add(project); } - projectsByName.computeIfAbsent(normalizedName, key -> new ArrayList<>()).add(project); } - return projectsByName; + return matches; } private Set findDuplicateNames(List rows) @@ -419,6 +419,11 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService .trim(); } + private String normalizeProjectNum(String projectNum) + { + return normalizeProjectName(projectNum).toUpperCase(Locale.ROOT); + } + private String normalizeDiskType(String diskType) { String value = StringUtils.trimToEmpty(diskType).toUpperCase(Locale.ROOT); diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java index f0f5652..2376dd5 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtPublishTaskServiceImpl.java @@ -6,6 +6,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.net.URLEncoder; import java.nio.file.Files; @@ -38,16 +40,21 @@ 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.TtFile; 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.ITtFileService; import com.ruoyi.office.service.ITtPublishTaskService; @Service public class TtPublishTaskServiceImpl implements ITtPublishTaskService { + private static final int MAX_PUBLISH_IMAGES = 9; + private static final int MAX_ASSET_REDIRECTS = 5; + private static final Set PLATFORMS = Collections.unmodifiableSet( new LinkedHashSet<>(Arrays.asList("xiaohongshu", "douyin", "wechat_channels", "bilibili", "xianyu"))); @@ -66,6 +73,9 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService @Autowired private ITtCopyTemplateService copyTemplateService; + @Autowired + private ITtFileService fileService; + @Autowired private CopyTemplateRenderer copyTemplateRenderer; @@ -87,6 +97,7 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService material.setCodeId(code.getCodeId()); material.setCodeName(code.getCodeName()); material.setTitle(code.getCodeName()); + material.setImageUrls(selectProjectImageUrls(code.getCodeName())); TtCopyTemplate selectedTemplate = selectPublishTemplate( copyTemplateService.selectEnabledTemplates("CODE"), templateGroup); if (selectedTemplate != null) @@ -99,6 +110,29 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService return material; } + /** + * 直接复用文件管理中与项目关联的图片,避免发布时再次上传同一批素材。 + */ + private List selectProjectImageUrls(String codeName) + { + List files = fileService.selectTtFileByCodeName(codeName); + List urls = new ArrayList<>(); + if (files != null) + { + for (TtFile file : files) + { + if (file != null) + { + urls.add(file.getFileUrl()); + } + } + } + List cleaned = cleanUrls(urls); + return cleaned.size() <= MAX_PUBLISH_IMAGES + ? cleaned + : new ArrayList<>(cleaned.subList(0, MAX_PUBLISH_IMAGES)); + } + private TtCopyTemplate selectPublishTemplate(List templates, String targetGroup) { if (templates == null || templates.isEmpty()) @@ -295,6 +329,7 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService } response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Expose-Headers", "Content-Disposition, Content-Type, Content-Length"); response.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); response.setHeader("Cache-Control", "private, no-store"); @@ -374,22 +409,8 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService 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); - } + HttpURLConnection connection = openRemoteAssetConnection(source); + URL finalUrl = connection.getURL(); String contentType = connection.getContentType(); response.setContentType(contentType == null ? "application/octet-stream" : contentType); long length = connection.getContentLengthLong(); @@ -397,7 +418,7 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService { response.setContentLengthLong(length); } - String fileName = new File(url.getPath()).getName(); + String fileName = new File(finalUrl.getPath()).getName(); response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + encode(fileName)); try (InputStream input = connection.getInputStream()) { @@ -409,6 +430,74 @@ public class TtPublishTaskServiceImpl implements ITtPublishTaskService } } + /** + * HttpURLConnection 不会自动跟随 HTTP 到 HTTPS 的跨协议重定向,图床地址需要在这里显式处理。 + */ + private HttpURLConnection openRemoteAssetConnection(String source) throws IOException + { + URL currentUrl = requireHttpAssetUrl(source); + for (int redirectCount = 0; redirectCount <= MAX_ASSET_REDIRECTS; redirectCount++) + { + HttpURLConnection connection = (HttpURLConnection) currentUrl.openConnection(); + connection.setConnectTimeout(10000); + connection.setReadTimeout(120000); + connection.setInstanceFollowRedirects(false); + connection.setRequestProperty("User-Agent", "RuoYi-Publish-Bridge/1.0"); + int status = connection.getResponseCode(); + if (status >= 200 && status < 300) + { + return connection; + } + if (!isRedirectStatus(status)) + { + connection.disconnect(); + throw new ServiceException("读取远程发布素材失败,HTTP " + status); + } + String location = connection.getHeaderField("Location"); + connection.disconnect(); + if (StringUtils.isEmpty(location)) + { + throw new ServiceException("远程发布素材重定向地址为空"); + } + if (redirectCount == MAX_ASSET_REDIRECTS) + { + throw new ServiceException("远程发布素材重定向次数过多"); + } + currentUrl = requireHttpAssetUrl(new URL(currentUrl, location).toString()); + } + throw new ServiceException("读取远程发布素材失败"); + } + + private URL requireHttpAssetUrl(String source) throws IOException + { + final URI uri; + try + { + uri = new URI(source); + } + catch (URISyntaxException ex) + { + throw new IOException("发布素材地址格式错误", ex); + } + String scheme = uri.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) + { + throw new ServiceException("不支持的发布素材地址"); + } + // URL/HttpURLConnection 不会自动把中文路径转换为 UTF-8 百分号编码。 + // 图床会原样重定向乱码路径,最终导致本来存在的图片返回 404。 + return new URL(uri.toASCIIString()); + } + + private boolean isRedirectStatus(int status) + { + return status == HttpURLConnection.HTTP_MOVED_PERM + || status == HttpURLConnection.HTTP_MOVED_TEMP + || status == HttpURLConnection.HTTP_SEE_OTHER + || status == 307 + || status == 308; + } + private void copy(InputStream input, OutputStream output) throws IOException { byte[] buffer = new byte[8192]; diff --git a/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml b/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml index 9767438..31022ae 100644 --- a/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml +++ b/ruoyi-office/src/main/resources/mapper/office/TtCopyTemplateMapper.xml @@ -16,6 +16,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + select template_id, template_name, template_group, template_scene, template_body, sort_num, status, create_time, update_time from tt_copy_template @@ -25,13 +30,38 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and template_name like concat('%', #{templateName}, '%') - and template_group like concat('%', #{templateGroup}, '%') + + + + and (template_group is null or trim(template_group) = '') + + + and template_group = #{templateGroup} + + + and template_scene = #{templateScene} and status = #{status} order by sort_num asc, create_time asc + + + select id, product_id, price_fen, product_name, status, create_time, update_time from app_virtual_product + + + + + + + + + + + insert into app_virtual_product(product_id, price_fen, product_name, status, create_time) values(#{productId}, #{priceFen}, #{productName}, #{status}, #{createTime}) + + update app_virtual_product + set product_id = #{productId}, + price_fen = #{priceFen}, + product_name = #{productName}, + status = #{status}, + update_time = #{updateTime} + where id = #{id} + + update app_virtual_product set product_id = #{productId}, product_name = #{productName}, status = 1, update_time = #{updateTime} where price_fen = #{priceFen} + + + delete from app_virtual_product where id in + #{id} + diff --git a/ruoyi-ui/src/api/app/virtualProduct.js b/ruoyi-ui/src/api/app/virtualProduct.js new file mode 100644 index 0000000..341411c --- /dev/null +++ b/ruoyi-ui/src/api/app/virtualProduct.js @@ -0,0 +1,21 @@ +import request from '@/utils/request' + +export function listVirtualProduct(query) { + return request({ url: '/app/virtualProduct/list', method: 'get', params: query }) +} + +export function getVirtualProduct(id) { + return request({ url: '/app/virtualProduct/' + id, method: 'get' }) +} + +export function addVirtualProduct(data) { + return request({ url: '/app/virtualProduct', method: 'post', data }) +} + +export function updateVirtualProduct(data) { + return request({ url: '/app/virtualProduct', method: 'put', data }) +} + +export function delVirtualProduct(ids) { + return request({ url: '/app/virtualProduct/' + ids, method: 'delete' }) +} diff --git a/ruoyi-ui/src/api/office/copyTemplate.js b/ruoyi-ui/src/api/office/copyTemplate.js index 7909572..b9454ea 100644 --- a/ruoyi-ui/src/api/office/copyTemplate.js +++ b/ruoyi-ui/src/api/office/copyTemplate.js @@ -9,6 +9,14 @@ export function listCopyTemplate(query) { }) } +// 查询模板分组及各分组模板数量 +export function listCopyTemplateGroups() { + return request({ + url: '/office/copyTemplate/groups', + method: 'get' + }) +} + // 按场景查询所有启用的文案模板 export function listEnabledTemplates(templateScene) { return request({ diff --git a/ruoyi-ui/src/views/app/virtualProduct/index.vue b/ruoyi-ui/src/views/app/virtualProduct/index.vue new file mode 100644 index 0000000..92165a2 --- /dev/null +++ b/ruoyi-ui/src/views/app/virtualProduct/index.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/ruoyi-ui/src/views/office/code/index.vue b/ruoyi-ui/src/views/office/code/index.vue index ca5e5c9..5d971f0 100644 --- a/ruoyi-ui/src/views/office/code/index.vue +++ b/ruoyi-ui/src/views/office/code/index.vue @@ -416,7 +416,7 @@ - + diff --git a/ruoyi-ui/src/views/office/copyTemplate/index.vue b/ruoyi-ui/src/views/office/copyTemplate/index.vue index 3d08702..4fa991c 100644 --- a/ruoyi-ui/src/views/office/copyTemplate/index.vue +++ b/ruoyi-ui/src/views/office/copyTemplate/index.vue @@ -9,14 +9,6 @@ @keyup.enter.native="handleQuery" /> - - - @@ -48,36 +40,76 @@ - - - - - - - - - - - - - - - - - +
+ - +
+ + + + + + + + + + + + + + + + + + + +
+
@@ -157,7 +189,7 @@ + + diff --git a/ruoyi-ui/src/views/office/publish/index.vue b/ruoyi-ui/src/views/office/publish/index.vue index f063b9f..5d80f06 100644 --- a/ruoyi-ui/src/views/office/publish/index.vue +++ b/ruoyi-ui/src/views/office/publish/index.vue @@ -180,6 +180,9 @@ :file-size="activePlatform === 'xianyu' ? 10 : 20" :file-type="activePlatform === 'xianyu' ? ['png', 'jpg', 'jpeg'] : ['png', 'jpg', 'jpeg', 'webp']" /> +
+ 已自动使用文件管理中与当前项目关联的图片,可删除、排序或继续添加 +
-
小红书和闲鱼共用,最多上传 9 张,可拖动调整顺序。
+
自动使用文件管理中的项目图片;小红书和闲鱼共用,最多 9 张,可拖动调整顺序。
@@ -671,6 +674,10 @@ export default { templateGroup: material.templateGroup || '' }) }) + const sharedMaterial = results.find(result => (result.material.imageUrls || []).length) + this.multiPublishForm.imageUrls = sharedMaterial + ? sharedMaterial.material.imageUrls.join(',') + : '' }).finally(() => { this.multiMaterialLoading = false }) @@ -943,6 +950,7 @@ export default { .last-publish-time.is-empty { color: #a0a4aa; } .template-source { margin-top: 7px; color: #67c23a; font-size: 12px; line-height: 1.5; } .template-source.is-warning { color: #e6a23c; } +.material-source-hint { margin-top: 7px; color: #67c23a; font-size: 12px; line-height: 1.5; } .multi-publish-dialog { min-height: 460px; max-height: calc(88vh - 176px); diff --git a/sql/source_code_technology_backfill.sql b/sql/source_code_technology_backfill.sql new file mode 100644 index 0000000..ceb25ca --- /dev/null +++ b/sql/source_code_technology_backfill.sql @@ -0,0 +1,140 @@ +-- 将旧版 tt_code.code_technology 中的混合技术栈拆分到前端、后端和数据库字段。 +-- +-- 安全性: +-- 1. 只填充 NULL 或空字符串,不覆盖已经维护的新字段; +-- 2. 保留 code_technology 原值,便于核对和回退; +-- 3. 可重复执行,第二次执行不会再次修改已经填充的数据。 + +START TRANSACTION; + +UPDATE `tt_code` +SET + `frontend_technology` = CASE + WHEN NULLIF(TRIM(`frontend_technology`), '') IS NOT NULL + THEN `frontend_technology` + WHEN LOWER(`code_technology`) LIKE '%vue3%' + THEN CONCAT( + 'Vue 3', + IF(LOWER(`code_technology`) LIKE '%elementui%', ' + Element UI', ''), + IF(LOWER(`code_technology`) LIKE '%nodejs14%', ' + Node.js 14', + IF(LOWER(`code_technology`) LIKE '%nodejs%', ' + Node.js', '')), + IF(`code_technology` LIKE '%微信小程序%', ' + 微信小程序', ''), + IF(`code_technology` LIKE '%原生小程序%', ' + 原生小程序', '') + ) + WHEN LOWER(`code_technology`) LIKE '%vue2%' + THEN CONCAT( + 'Vue 2', + IF(LOWER(`code_technology`) LIKE '%elementui%', ' + Element UI', ''), + IF(LOWER(`code_technology`) LIKE '%nodejs14%', ' + Node.js 14', + IF(LOWER(`code_technology`) LIKE '%nodejs%', ' + Node.js', '')), + IF(`code_technology` LIKE '%微信小程序%', ' + 微信小程序', ''), + IF(`code_technology` LIKE '%原生小程序%', ' + 原生小程序', '') + ) + WHEN LOWER(`code_technology`) LIKE '%vue%' + THEN CONCAT( + 'Vue', + IF(LOWER(`code_technology`) LIKE '%elementui%', ' + Element UI', ''), + IF(LOWER(`code_technology`) LIKE '%nodejs14%', ' + Node.js 14', + IF(LOWER(`code_technology`) LIKE '%nodejs%', ' + Node.js', '')) + ) + WHEN LOWER(`code_technology`) LIKE '%thymeleaf%' + THEN CONCAT( + 'Thymeleaf', + IF(LOWER(`code_technology`) LIKE '%bootstrap%', ' + Bootstrap', ''), + IF(LOWER(`code_technology`) LIKE '%jquery%', ' + jQuery', '') + ) + WHEN LOWER(`code_technology`) LIKE '%jsp%' + THEN CONCAT( + 'JSP', + IF(LOWER(`code_technology`) LIKE '%jstl%', ' + JSTL', ''), + IF(LOWER(`code_technology`) LIKE '%bootstrap%', ' + Bootstrap', ''), + IF(LOWER(`code_technology`) LIKE '%layui%', ' + Layui', '') + ) + WHEN LOWER(`code_technology`) LIKE '%html%' + THEN 'HTML' + WHEN LOWER(`code_technology`) LIKE '%bootstrap%' + THEN CONCAT( + 'Bootstrap', + IF(LOWER(`code_technology`) LIKE '%highcharts%', ' + Highcharts', '') + ) + ELSE '未单独记录' + END, + `backend_technology` = CASE + WHEN NULLIF(TRIM(`backend_technology`), '') IS NOT NULL + THEN `backend_technology` + WHEN LOWER(`code_technology`) LIKE '%php%' + THEN 'PHP' + WHEN LOWER(`code_technology`) LIKE '%servlet%' + THEN CONCAT( + 'Java 8 + Servlet', + IF(LOWER(`code_technology`) LIKE '%jdbc%', ' + JDBC', '') + ) + WHEN LOWER(`code_technology`) LIKE '%springboot2%' + THEN CONCAT( + 'Java 8 + Spring Boot 2', + IF(LOWER(`code_technology`) LIKE '%mybatis-plus%' + OR LOWER(`code_technology`) LIKE '%mybatisplus%', ' + MyBatis-Plus', + IF(LOWER(`code_technology`) LIKE '%mybatis%', ' + MyBatis', '')) + ) + WHEN LOWER(`code_technology`) LIKE '%springboot%' + THEN CONCAT( + 'Java 8 + Spring Boot', + IF(LOWER(`code_technology`) LIKE '%mybatis-plus%' + OR LOWER(`code_technology`) LIKE '%mybatisplus%', ' + MyBatis-Plus', + IF(LOWER(`code_technology`) LIKE '%mybatis%', ' + MyBatis', '')) + ) + WHEN LOWER(`code_technology`) LIKE '%springmvc%' + OR LOWER(`code_technology`) LIKE '%springmmvc%' + OR LOWER(`code_technology`) LIKE '%ssm%' + THEN CONCAT( + 'Java 8 + Spring + Spring MVC', + IF(LOWER(`code_technology`) LIKE '%mybatis-plus%' + OR LOWER(`code_technology`) LIKE '%mybatisplus%', ' + MyBatis-Plus', + IF(LOWER(`code_technology`) LIKE '%mybatis%' + OR LOWER(`code_technology`) LIKE '%mabtis%', ' + MyBatis', '')) + ) + ELSE '未单独记录' + END, + `database_technology` = CASE + WHEN NULLIF(TRIM(`database_technology`), '') IS NOT NULL + THEN `database_technology` + WHEN LOWER(`code_environment`) LIKE '%mysql8.0%' + OR LOWER(`code_environment`) LIKE '%mysql 8.0%' + OR LOWER(`code_environment`) LIKE '%mysql8%' + OR LOWER(`code_environment`) LIKE '%mysql 8%' + THEN CONCAT( + 'MySQL 8.0', + IF(LOWER(`code_environment`) LIKE '%redis%', ' + Redis', '') + ) + WHEN LOWER(`code_environment`) LIKE '%mysql5.7%' + OR LOWER(`code_environment`) LIKE '%mysql 5.7%' + THEN CONCAT( + 'MySQL 5.7', + IF(LOWER(`code_environment`) LIKE '%redis%', ' + Redis', '') + ) + WHEN LOWER(`code_environment`) LIKE '%mysql%' + OR LOWER(`code_technology`) LIKE '%mysql%' + THEN CONCAT( + 'MySQL', + IF(LOWER(`code_environment`) LIKE '%redis%', ' + Redis', '') + ) + ELSE '未单独记录' + END +WHERE NULLIF(TRIM(`code_technology`), '') IS NOT NULL + AND ( + NULLIF(TRIM(`frontend_technology`), '') IS NULL + OR NULLIF(TRIM(`backend_technology`), '') IS NULL + OR NULLIF(TRIM(`database_technology`), '') IS NULL + ); + +SELECT ROW_COUNT() AS `backfilled_rows`; + +COMMIT; + +-- 执行结果检查:三个 blank_* 都应为 0。 +SELECT + COUNT(*) AS `total`, + SUM(NULLIF(TRIM(`frontend_technology`), '') IS NULL) AS `frontend_blank`, + SUM(NULLIF(TRIM(`backend_technology`), '') IS NULL) AS `backend_blank`, + SUM(NULLIF(TRIM(`database_technology`), '') IS NULL) AS `database_blank` +FROM `tt_code`; diff --git a/sql/source_code_technology_fields.sql b/sql/source_code_technology_fields.sql index cfa7878..b1bcae0 100644 --- a/sql/source_code_technology_fields.sql +++ b/sql/source_code_technology_fields.sql @@ -1,5 +1,6 @@ -- 源码信息新增前端、后端、数据库技术字段。 -- 执行前请先备份数据库。适用于当前项目的 MySQL 数据库。 +-- 新增字段后,可执行 source_code_technology_backfill.sql 补齐旧数据。 ALTER TABLE `tt_code` ADD COLUMN `frontend_technology` VARCHAR(255) NULL COMMENT '前端' AFTER `code_technology`, diff --git a/sql/virtual_pay_resource.sql b/sql/virtual_pay_resource.sql index dcf6245..018b1a9 100644 --- a/sql/virtual_pay_resource.sql +++ b/sql/virtual_pay_resource.sql @@ -60,8 +60,8 @@ CREATE TABLE app_resource_entitlement ( -- 执行迁移后: -- 1. 在微信公众平台创建各价格档位的道具并发布; --- 2. 在若依后台编辑付费资源,填写“价格(分)”和对应的 productId; --- 3. 同一价格填写同一个 productId,后台会自动维护 app_virtual_product 映射。 +-- 2. 执行 sql/virtual_product_menu.sql,重新登录后在“虚拟道具管理”中维护相同的 productId 和价格; +-- 3. 在若依后台编辑付费资源,规格只需填写“价格(分)”,系统按价格自动匹配已启用道具。 -- 当前小程序已发布的微信虚拟道具价格映射。 -- product_id 必须与微信后台保持完全一致(包括 resourse 的现有拼写)。 diff --git a/sql/virtual_product_menu.sql b/sql/virtual_product_menu.sql new file mode 100644 index 0000000..c2d213a --- /dev/null +++ b/sql/virtual_product_menu.sql @@ -0,0 +1,69 @@ +-- 虚拟道具管理菜单(可重复执行) +-- 默认与“虚拟支付订单”或“支付订单”同级;均未找到时作为一级菜单显示。 + +SET @virtual_product_parent_id := COALESCE( + (SELECT parent_id FROM sys_menu WHERE perms = 'app:virtualOrder:list' LIMIT 1), + (SELECT parent_id FROM sys_menu WHERE perms = 'app:order:list' LIMIT 1), + 0 +); + +SET @virtual_product_order_num := COALESCE( + (SELECT MAX(order_num) + 1 FROM sys_menu WHERE parent_id = @virtual_product_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 + '虚拟道具管理', @virtual_product_parent_id, @virtual_product_order_num, + 'virtualProduct', 'app/virtualProduct/index', '', 1, 0, + 'C', '0', '0', 'app:virtualProduct:list', 'shopping', 'admin', NOW(), '', NULL, + '微信虚拟支付价格档位与道具映射管理' +WHERE NOT EXISTS ( + SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct:list' +); + +SET @virtual_product_menu_id := ( + SELECT menu_id FROM sys_menu WHERE perms = 'app:virtualProduct: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 '虚拟道具查询', @virtual_product_menu_id, 1, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualProduct:query', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_product_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct: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 '虚拟道具新增', @virtual_product_menu_id, 2, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualProduct:add', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_product_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct:add'); + +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 '虚拟道具修改', @virtual_product_menu_id, 3, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualProduct:edit', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_product_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct:edit'); + +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 '虚拟道具删除', @virtual_product_menu_id, 4, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualProduct:remove', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_product_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct:remove'); + +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 '虚拟道具导出', @virtual_product_menu_id, 5, '', '', '', 1, 0, + 'F', '0', '0', 'app:virtualProduct:export', '#', 'admin', NOW(), '', NULL, '' +WHERE @virtual_product_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'app:virtualProduct:export');