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), /不能重试/)
|
||||
})
|
||||
@@ -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。
|
||||
|
||||
虚拟支付订单由微信回调和主动查单流程维护,后台页面只提供查询与导出,不允许人工修改或删除订单。
|
||||
|
||||
@@ -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<AppVirtualProduct> 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<AppVirtualProduct> list = virtualProductService.selectAppVirtualProductList(product);
|
||||
ExcelUtil<AppVirtualProduct> util = new ExcelUtil<>(AppVirtualProduct.class);
|
||||
util.exportExcel(response, list, "虚拟道具数据");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TtCopyTemplateGroupStat> groups = ttCopyTemplateService.selectCopyTemplateGroupStats();
|
||||
return success(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按场景查询所有启用的文案模板(无需分页权限)
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<TtCopyTemplate> selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate);
|
||||
|
||||
/**
|
||||
* 查询模板分组及模板数量
|
||||
*/
|
||||
public List<TtCopyTemplateGroupStat> selectCopyTemplateGroupStats();
|
||||
|
||||
/**
|
||||
* 查询所有启用的文案模板(按排序号升序)
|
||||
*/
|
||||
|
||||
@@ -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<TtCopyTemplate> selectTtCopyTemplateList(TtCopyTemplate ttCopyTemplate);
|
||||
|
||||
/**
|
||||
* 查询模板分组及模板数量
|
||||
*/
|
||||
public List<TtCopyTemplateGroupStat> selectCopyTemplateGroupStats();
|
||||
|
||||
/**
|
||||
* 查询所有启用的文案模板(供源码明细页按钮使用)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 字限制。
|
||||
|
||||
@@ -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<AppResourceList> resourceList = new ArrayList<AppResourceList>();
|
||||
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)
|
||||
|
||||
@@ -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<TtCopyTemplateGroupStat> selectCopyTemplateGroupStats() {
|
||||
return ttCopyTemplateMapper.selectCopyTemplateGroupStats();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TtCopyTemplate> selectEnabledTemplates() {
|
||||
return selectEnabledTemplates(null);
|
||||
|
||||
@@ -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<Integer, String> 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("_")){
|
||||
|
||||
@@ -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<ValidatedImportRow> validRows = validateRows(sourceRows, normalizedDiskType, result);
|
||||
Set<String> duplicateNames = findDuplicateNames(validRows);
|
||||
Map<String, List<TtProjectInfo>> projectsByName = loadProjectsByNormalizedName();
|
||||
List<TtProjectInfo> projects = new ArrayList<>(
|
||||
ttProjectInfoMapper.selectTtProjectInfoList(new TtProjectInfo()));
|
||||
|
||||
for (ValidatedImportRow row : validRows)
|
||||
{
|
||||
@@ -181,23 +180,22 @@ public class TtProjectInfoServiceImpl implements ITtProjectInfoService
|
||||
continue;
|
||||
}
|
||||
|
||||
List<TtProjectInfo> matches = projectsByName.get(row.normalizedName);
|
||||
if (matches != null && matches.size() > 1)
|
||||
List<TtProjectInfo> 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<TtProjectInfo> 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<String, List<TtProjectInfo>> loadProjectsByNormalizedName()
|
||||
private List<TtProjectInfo> findMatchingProjects(ValidatedImportRow row, List<TtProjectInfo> projects)
|
||||
{
|
||||
Map<String, List<TtProjectInfo>> projectsByName = new HashMap<>();
|
||||
List<TtProjectInfo> projects = ttProjectInfoMapper.selectTtProjectInfoList(new TtProjectInfo());
|
||||
List<TtProjectInfo> 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<String> findDuplicateNames(List<ValidatedImportRow> 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);
|
||||
|
||||
@@ -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<String> 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<String> selectProjectImageUrls(String codeName)
|
||||
{
|
||||
List<TtFile> files = fileService.selectTtFileByCodeName(codeName);
|
||||
List<String> urls = new ArrayList<>();
|
||||
if (files != null)
|
||||
{
|
||||
for (TtFile file : files)
|
||||
{
|
||||
if (file != null)
|
||||
{
|
||||
urls.add(file.getFileUrl());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> cleaned = cleanUrls(urls);
|
||||
return cleaned.size() <= MAX_PUBLISH_IMAGES
|
||||
? cleaned
|
||||
: new ArrayList<>(cleaned.subList(0, MAX_PUBLISH_IMAGES));
|
||||
}
|
||||
|
||||
private TtCopyTemplate selectPublishTemplate(List<TtCopyTemplate> 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];
|
||||
|
||||
@@ -16,6 +16,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<result property="updateTime" column="update_time" />
|
||||
</resultMap>
|
||||
|
||||
<resultMap type="TtCopyTemplateGroupStat" id="TtCopyTemplateGroupStatResult">
|
||||
<result property="templateGroup" column="template_group" />
|
||||
<result property="templateCount" column="template_count" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectTtCopyTemplateVo">
|
||||
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"
|
||||
<include refid="selectTtCopyTemplateVo"/>
|
||||
<where>
|
||||
<if test="templateName != null and templateName != ''"> and template_name like concat('%', #{templateName}, '%')</if>
|
||||
<if test="templateGroup != null and templateGroup != ''"> and template_group like concat('%', #{templateGroup}, '%')</if>
|
||||
<if test="templateGroup != null and templateGroup != ''">
|
||||
<choose>
|
||||
<when test="templateGroup == '__UNGROUPED__'">
|
||||
and (template_group is null or trim(template_group) = '')
|
||||
</when>
|
||||
<otherwise>
|
||||
and template_group = #{templateGroup}
|
||||
</otherwise>
|
||||
</choose>
|
||||
</if>
|
||||
<if test="templateScene != null and templateScene != ''"> and template_scene = #{templateScene}</if>
|
||||
<if test="status != null and status != ''"> and status = #{status}</if>
|
||||
</where>
|
||||
order by sort_num asc, create_time asc
|
||||
</select>
|
||||
|
||||
<select id="selectCopyTemplateGroupStats" resultMap="TtCopyTemplateGroupStatResult">
|
||||
select
|
||||
case
|
||||
when template_group is null or trim(template_group) = '' then '__UNGROUPED__'
|
||||
else template_group
|
||||
end as template_group,
|
||||
count(1) as template_count
|
||||
from tt_copy_template
|
||||
group by
|
||||
case
|
||||
when template_group is null or trim(template_group) = '' then '__UNGROUPED__'
|
||||
else template_group
|
||||
end
|
||||
order by min(sort_num) asc, template_group asc
|
||||
</select>
|
||||
|
||||
<select id="selectEnabledTemplates" resultMap="TtCopyTemplateResult">
|
||||
<include refid="selectTtCopyTemplateVo"/>
|
||||
where status = '0'
|
||||
|
||||
@@ -30,4 +30,17 @@ public class WebsiteArticleDocumentParserTest
|
||||
{
|
||||
parser.parse("只有标题");
|
||||
}
|
||||
|
||||
@Test(expected = ServiceException.class)
|
||||
public void keepsWebsiteTitleLimitAtFiftyCharacters()
|
||||
{
|
||||
parser.parse(repeat("题", 51) + "\n正文");
|
||||
}
|
||||
|
||||
private String repeat(String value, int count)
|
||||
{
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int i = 0; i < count; i++) result.append(value);
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import com.ruoyi.office.domain.TtCode;
|
||||
import com.ruoyi.office.domain.TtCopyTemplate;
|
||||
import com.ruoyi.office.domain.WechatDraftPreview;
|
||||
import com.ruoyi.office.domain.WechatDraftPushRequest;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class WechatDraftContentServiceTest
|
||||
{
|
||||
@Test
|
||||
public void acceptsSixtyCharacterTitleAndLimitsGeneratedTitle()
|
||||
{
|
||||
ITtCodeService codeService = mock(ITtCodeService.class);
|
||||
ITtCopyTemplateService templateService = mock(ITtCopyTemplateService.class);
|
||||
ITtWechatAccountService accountService = mock(ITtWechatAccountService.class);
|
||||
CopyTemplateRenderer templateRenderer = mock(CopyTemplateRenderer.class);
|
||||
WechatArticleRenderer articleRenderer = mock(WechatArticleRenderer.class);
|
||||
WechatDraftContentService service = new WechatDraftContentService(codeService, templateService,
|
||||
accountService, templateRenderer, new WebsiteArticleDocumentParser(), articleRenderer);
|
||||
|
||||
TtCode code = new TtCode();
|
||||
TtCopyTemplate template = new TtCopyTemplate();
|
||||
template.setStatus("0");
|
||||
when(codeService.selectTtCodeByCodeId(1L)).thenReturn(code);
|
||||
when(templateService.selectTtCopyTemplateByTemplateId(2L)).thenReturn(template);
|
||||
when(templateRenderer.render(template, code)).thenReturn(repeat("题", 61) + "\n正文");
|
||||
|
||||
WechatDraftPushRequest request = new WechatDraftPushRequest();
|
||||
request.setCodeId(1L);
|
||||
request.setTemplateId(2L);
|
||||
request.setTitle(repeat("字", 60));
|
||||
WechatDraftPreview preview = service.preview(request);
|
||||
Assert.assertEquals(repeat("字", 60), preview.getTitle());
|
||||
|
||||
request.setTitle("");
|
||||
preview = service.preview(request);
|
||||
Assert.assertEquals(repeat("题", 60), preview.getTitle());
|
||||
}
|
||||
|
||||
private String repeat(String value, int count)
|
||||
{
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int i = 0; i < count; i++) result.append(value);
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,36 @@ public class TtCodeServiceImplTest
|
||||
Assert.assertEquals(Long.valueOf(7L), articleCaptor.getValue().getArticleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createsFPremiumProjectWithSingleNineYuanSpec()
|
||||
{
|
||||
code.setCodeName("【F001】毕业设计项目");
|
||||
projectInfo.setProjectNum(" f001 ");
|
||||
when(resourceService.insertAppResource(any())).thenAnswer(invocation -> {
|
||||
AppResource resource = invocation.getArgument(0);
|
||||
resource.setId(103L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
service.transToArticle1(9L, 1L);
|
||||
|
||||
ArgumentCaptor<AppResource> resourceCaptor = ArgumentCaptor.forClass(AppResource.class);
|
||||
verify(resourceService).insertAppResource(resourceCaptor.capture());
|
||||
AppResource resource = resourceCaptor.getValue();
|
||||
Assert.assertEquals(Long.valueOf(6L), resource.getResourceType());
|
||||
Assert.assertEquals(Long.valueOf(3L), resource.getIsAd());
|
||||
Assert.assertEquals(Integer.valueOf(900), resource.getPriceFen());
|
||||
Assert.assertEquals(1, resource.getAppResourceListList().size());
|
||||
assertPremiumSpec(resource.getAppResourceListList().get(0),
|
||||
"源码 + 数据库 + 万字文档 + PPT",
|
||||
"https://pan.quark.cn/s/example", 900, 1);
|
||||
|
||||
ArgumentCaptor<AppBlogArticle> articleCaptor = ArgumentCaptor.forClass(AppBlogArticle.class);
|
||||
verify(articleMapper).insertAppBlogArticle(articleCaptor.capture());
|
||||
Assert.assertEquals(Long.valueOf(7L), articleCaptor.getValue().getArticleType());
|
||||
Assert.assertEquals("https://pan.quark.cn/s/example", code.getDiskLink());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repairsArticleLeftByPreviousFailedConversion()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ruoyi.office.service.impl;
|
||||
|
||||
import com.ruoyi.office.domain.TtFile;
|
||||
import com.ruoyi.office.mapper.TtFileMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@RunWith(MockitoJUnitRunner.class)
|
||||
public class TtFileServiceImplTest {
|
||||
@InjectMocks
|
||||
private TtFileServiceImpl service;
|
||||
|
||||
@Mock
|
||||
private TtFileMapper mapper;
|
||||
|
||||
@Test
|
||||
public void insertOrdersNumberedHttpsImagesAndKeepsNames() {
|
||||
TtFile request = new TtFile();
|
||||
request.setCodeName("测试项目");
|
||||
request.setPictureFile("https://img.yidaima.cn/2_管理端数据仪表盘-bbbb,"
|
||||
+ "https://img.yidaima.cn/1_统一登录入口-aaaa");
|
||||
|
||||
Assert.assertEquals(2, service.insertTtFile(request));
|
||||
|
||||
ArgumentCaptor<TtFile> saved = ArgumentCaptor.forClass(TtFile.class);
|
||||
verify(mapper, times(2)).insertTtFile(saved.capture());
|
||||
List<TtFile> files = saved.getAllValues();
|
||||
Assert.assertEquals("统一登录入口", files.get(0).getFileName());
|
||||
Assert.assertEquals("https://img.yidaima.cn/1_统一登录入口-aaaa", files.get(0).getFileUrl());
|
||||
Assert.assertEquals("管理端数据仪表盘", files.get(1).getFileName());
|
||||
Assert.assertEquals("https://img.yidaima.cn/2_管理端数据仪表盘-bbbb", files.get(1).getFileUrl());
|
||||
Assert.assertEquals("测试项目", files.get(0).getCodeName());
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,55 @@ public class TtProjectInfoServiceImplTest
|
||||
Assert.assertEquals(1, result.getUnchangedCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updatesExistingProjectWhenProjectNumberMatches()
|
||||
{
|
||||
TtProjectInfo existing = new TtProjectInfo();
|
||||
existing.setId(31);
|
||||
existing.setProjectNum("s031");
|
||||
existing.setProjectName("旧项目名称");
|
||||
existing.setProjectName1("旧源码名称");
|
||||
existing.setProjectUrl("https://pan.quark.cn/s/old");
|
||||
|
||||
when(parser.parse(file, "QUARK")).thenReturn(Collections.singletonList(quarkRow));
|
||||
when(projectInfoMapper.selectTtProjectInfoList(any(TtProjectInfo.class)))
|
||||
.thenReturn(Collections.singletonList(existing));
|
||||
when(projectInfoMapper.updateProjectQuarkUrl(31,
|
||||
"https://pan.quark.cn/s/741bf63df1bb")).thenReturn(1);
|
||||
|
||||
ProjectLinkImportResult result = service.importProjectLinks(file, "QUARK");
|
||||
|
||||
verify(projectInfoMapper).updateProjectQuarkUrl(31,
|
||||
"https://pan.quark.cn/s/741bf63df1bb");
|
||||
verify(projectInfoMapper, never()).insertTtProjectInfo(any(TtProjectInfo.class));
|
||||
Assert.assertEquals(1, result.getUpdatedCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updatesExistingProjectWhenSourceNameMatches()
|
||||
{
|
||||
quarkRow.setProjectName("婚纱摄影管理系统");
|
||||
TtProjectInfo existing = new TtProjectInfo();
|
||||
existing.setId(31);
|
||||
existing.setProjectNum("S999");
|
||||
existing.setProjectName("另一个项目名称");
|
||||
existing.setProjectName1("婚纱摄影管理系统");
|
||||
existing.setProjectUrl("https://pan.quark.cn/s/old");
|
||||
|
||||
when(parser.parse(file, "QUARK")).thenReturn(Collections.singletonList(quarkRow));
|
||||
when(projectInfoMapper.selectTtProjectInfoList(any(TtProjectInfo.class)))
|
||||
.thenReturn(Collections.singletonList(existing));
|
||||
when(projectInfoMapper.updateProjectQuarkUrl(31,
|
||||
"https://pan.quark.cn/s/741bf63df1bb")).thenReturn(1);
|
||||
|
||||
ProjectLinkImportResult result = service.importProjectLinks(file, "QUARK");
|
||||
|
||||
verify(projectInfoMapper).updateProjectQuarkUrl(31,
|
||||
"https://pan.quark.cn/s/741bf63df1bb");
|
||||
verify(projectInfoMapper, never()).insertTtProjectInfo(any(TtProjectInfo.class));
|
||||
Assert.assertEquals(1, result.getUpdatedCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updatesOnlyBaiduLinkForExistingProjectAndAddsPasscode()
|
||||
{
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.ruoyi.office.service.impl;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.core.domain.model.LoginUser;
|
||||
@@ -9,11 +12,13 @@ import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.office.domain.PublishMaterial;
|
||||
import com.ruoyi.office.domain.TtCode;
|
||||
import com.ruoyi.office.domain.TtCopyTemplate;
|
||||
import com.ruoyi.office.domain.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 org.junit.After;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
@@ -26,6 +31,7 @@ import org.mockito.Spy;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -47,6 +53,9 @@ public class TtPublishTaskServiceImplTest
|
||||
@Mock
|
||||
private ITtCopyTemplateService copyTemplateService;
|
||||
|
||||
@Mock
|
||||
private ITtFileService fileService;
|
||||
|
||||
@Mock
|
||||
private CopyTemplateRenderer copyTemplateRenderer;
|
||||
|
||||
@@ -65,6 +74,7 @@ public class TtPublishTaskServiceImplTest
|
||||
code.setVideoFile("/profile/demo.mp4");
|
||||
when(codeService.selectTtCodeByCodeId(40L)).thenReturn(code);
|
||||
when(copyTemplateService.selectEnabledTemplates("CODE")).thenReturn(Collections.emptyList());
|
||||
when(fileService.selectTtFileByCodeName(code.getCodeName())).thenReturn(Collections.emptyList());
|
||||
|
||||
SysUser user = new SysUser();
|
||||
user.setUserName("publisher");
|
||||
@@ -80,13 +90,21 @@ public class TtPublishTaskServiceImplTest
|
||||
}
|
||||
|
||||
@Test
|
||||
public void leavesMediaEmptyForManualSelection()
|
||||
public void loadsProjectImagesFromFileManagement()
|
||||
{
|
||||
TtFile first = projectFile(1L, "http://img.yidaima.cn/1-home.png");
|
||||
TtFile duplicate = projectFile(2L, "http://img.yidaima.cn/1-home.png");
|
||||
TtFile second = projectFile(3L, "http://img.yidaima.cn/2-detail.jpg");
|
||||
when(fileService.selectTtFileByCodeName(code.getCodeName()))
|
||||
.thenReturn(Arrays.asList(first, duplicate, second));
|
||||
|
||||
PublishMaterial material = service.selectProjectMaterial(40L, "xiaohongshu");
|
||||
|
||||
Assert.assertEquals(code.getCodeName(), material.getTitle());
|
||||
Assert.assertNull(material.getVideoUrl());
|
||||
Assert.assertTrue(material.getImageUrls().isEmpty());
|
||||
Assert.assertEquals(Arrays.asList(
|
||||
"http://img.yidaima.cn/1-home.png",
|
||||
"http://img.yidaima.cn/2-detail.jpg"), material.getImageUrls());
|
||||
Assert.assertNull(material.getCoverUrl());
|
||||
}
|
||||
|
||||
@@ -350,6 +368,85 @@ public class TtPublishTaskServiceImplTest
|
||||
? new java.util.Date() : created.getCreateTime()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void followsRedirectWhenReadingRemotePublishImage() throws Exception
|
||||
{
|
||||
byte[] image = "test-png-content".getBytes(StandardCharsets.UTF_8);
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
int port = server.getAddress().getPort();
|
||||
server.createContext("/redirect", exchange -> {
|
||||
exchange.getResponseHeaders().set("Location", "http://127.0.0.1:" + port + "/image");
|
||||
exchange.sendResponseHeaders(301, -1);
|
||||
exchange.close();
|
||||
});
|
||||
server.createContext("/image", exchange -> {
|
||||
exchange.getResponseHeaders().set("Content-Type", "image/png");
|
||||
exchange.sendResponseHeaders(200, image.length);
|
||||
exchange.getResponseBody().write(image);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
|
||||
try
|
||||
{
|
||||
TtPublishTask task = new TtPublishTask();
|
||||
task.setTaskId("redirect-task");
|
||||
task.setImageUrls("[\"http://127.0.0.1:" + port + "/redirect\"]");
|
||||
when(publishTaskMapper.selectPublishTaskByBridge("redirect-task", "bridge-token"))
|
||||
.thenReturn(task);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
service.writeBridgeAsset("redirect-task", "bridge-token", "image", 0, response);
|
||||
|
||||
Assert.assertEquals("image/png", response.getContentType());
|
||||
Assert.assertArrayEquals(image, response.getContentAsByteArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void encodesUnicodePathWhenReadingRemotePublishImage() throws Exception
|
||||
{
|
||||
byte[] image = "unicode-path-image".getBytes(StandardCharsets.UTF_8);
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
int port = server.getAddress().getPort();
|
||||
server.createContext("/", exchange -> {
|
||||
if (!"/%E5%9B%BE%E7%89%87.png".equals(exchange.getRequestURI().getRawPath()))
|
||||
{
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
exchange.close();
|
||||
return;
|
||||
}
|
||||
exchange.getResponseHeaders().set("Content-Type", "image/png");
|
||||
exchange.sendResponseHeaders(200, image.length);
|
||||
exchange.getResponseBody().write(image);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
|
||||
try
|
||||
{
|
||||
TtPublishTask task = new TtPublishTask();
|
||||
task.setTaskId("unicode-path-task");
|
||||
task.setImageUrls("[\"http://127.0.0.1:" + port + "/图片.png\"]");
|
||||
when(publishTaskMapper.selectPublishTaskByBridge("unicode-path-task", "bridge-token"))
|
||||
.thenReturn(task);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
service.writeBridgeAsset("unicode-path-task", "bridge-token", "image", 0, response);
|
||||
|
||||
Assert.assertEquals("image/png", response.getContentType());
|
||||
Assert.assertArrayEquals(image, response.getContentAsByteArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private TtCopyTemplate template(Long id, String name, String group)
|
||||
{
|
||||
TtCopyTemplate template = new TtCopyTemplate();
|
||||
@@ -360,4 +457,13 @@ public class TtPublishTaskServiceImplTest
|
||||
template.setStatus("0");
|
||||
return template;
|
||||
}
|
||||
|
||||
private TtFile projectFile(Long id, String url)
|
||||
{
|
||||
TtFile file = new TtFile();
|
||||
file.setFileId(id);
|
||||
file.setCodeName(code.getCodeName());
|
||||
file.setFileUrl(url);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ruoyi.app.domain;
|
||||
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
@@ -10,9 +11,17 @@ public class AppVirtualProduct extends BaseEntity
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
|
||||
@Excel(name = "微信道具ID")
|
||||
private String productId;
|
||||
|
||||
@Excel(name = "价格", suffix = "分")
|
||||
private Integer priceFen;
|
||||
|
||||
@Excel(name = "道具名称")
|
||||
private String productName;
|
||||
|
||||
@Excel(name = "状态", readConverterExp = "0=停用,1=启用")
|
||||
private Integer status;
|
||||
|
||||
public Long getId()
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
package com.ruoyi.app.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.app.domain.AppVirtualProduct;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
public interface AppVirtualProductMapper
|
||||
{
|
||||
AppVirtualProduct selectAppVirtualProductById(Long id);
|
||||
|
||||
List<AppVirtualProduct> selectAppVirtualProductList(AppVirtualProduct product);
|
||||
|
||||
AppVirtualProduct selectActiveByPrice(Integer priceFen);
|
||||
|
||||
AppVirtualProduct selectByProductId(String productId);
|
||||
|
||||
AppVirtualProduct selectByProductIdExcludeId(@Param("productId") String productId,
|
||||
@Param("excludeId") Long excludeId);
|
||||
|
||||
AppVirtualProduct selectByPriceExcludeId(@Param("priceFen") Integer priceFen,
|
||||
@Param("excludeId") Long excludeId);
|
||||
|
||||
int insertAppVirtualProduct(AppVirtualProduct product);
|
||||
|
||||
int updateAppVirtualProduct(AppVirtualProduct product);
|
||||
|
||||
int updateProductIdByPrice(AppVirtualProduct product);
|
||||
|
||||
int deleteAppVirtualProductByIds(Long[] ids);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ruoyi.app.service;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.app.domain.AppVirtualProduct;
|
||||
|
||||
/**
|
||||
* 虚拟支付道具管理服务。
|
||||
*/
|
||||
public interface IAppVirtualProductService
|
||||
{
|
||||
AppVirtualProduct selectAppVirtualProductById(Long id);
|
||||
|
||||
List<AppVirtualProduct> selectAppVirtualProductList(AppVirtualProduct product);
|
||||
|
||||
int insertAppVirtualProduct(AppVirtualProduct product);
|
||||
|
||||
int updateAppVirtualProduct(AppVirtualProduct product);
|
||||
|
||||
int deleteAppVirtualProductByIds(Long[] ids);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.ruoyi.app.service.impl;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.app.domain.AppVirtualProduct;
|
||||
import com.ruoyi.app.mapper.AppVirtualProductMapper;
|
||||
import com.ruoyi.app.service.IAppVirtualProductService;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.DateUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 虚拟支付道具管理服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class AppVirtualProductServiceImpl implements IAppVirtualProductService
|
||||
{
|
||||
@Autowired
|
||||
private AppVirtualProductMapper virtualProductMapper;
|
||||
|
||||
@Override
|
||||
public AppVirtualProduct selectAppVirtualProductById(Long id)
|
||||
{
|
||||
return virtualProductMapper.selectAppVirtualProductById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppVirtualProduct> selectAppVirtualProductList(AppVirtualProduct product)
|
||||
{
|
||||
return virtualProductMapper.selectAppVirtualProductList(product);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int insertAppVirtualProduct(AppVirtualProduct product)
|
||||
{
|
||||
normalizeAndValidate(product);
|
||||
product.setCreateTime(DateUtils.getNowDate());
|
||||
return virtualProductMapper.insertAppVirtualProduct(product);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateAppVirtualProduct(AppVirtualProduct product)
|
||||
{
|
||||
if (product.getId() == null || virtualProductMapper.selectAppVirtualProductById(product.getId()) == null)
|
||||
{
|
||||
throw new ServiceException("虚拟道具不存在");
|
||||
}
|
||||
normalizeAndValidate(product);
|
||||
product.setUpdateTime(DateUtils.getNowDate());
|
||||
return virtualProductMapper.updateAppVirtualProduct(product);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int deleteAppVirtualProductByIds(Long[] ids)
|
||||
{
|
||||
if (ids == null || ids.length == 0)
|
||||
{
|
||||
throw new ServiceException("请选择要删除的虚拟道具");
|
||||
}
|
||||
return virtualProductMapper.deleteAppVirtualProductByIds(ids);
|
||||
}
|
||||
|
||||
private void normalizeAndValidate(AppVirtualProduct product)
|
||||
{
|
||||
String productId = StringUtils.trim(product.getProductId());
|
||||
if (StringUtils.isEmpty(productId) || productId.length() > 64)
|
||||
{
|
||||
throw new ServiceException("微信道具ID需填写1至64个字符");
|
||||
}
|
||||
|
||||
String productName = StringUtils.trim(product.getProductName());
|
||||
if (StringUtils.isEmpty(productName) || productName.length() > 100)
|
||||
{
|
||||
throw new ServiceException("道具名称需填写1至100个字符");
|
||||
}
|
||||
if (product.getPriceFen() == null || product.getPriceFen() <= 0)
|
||||
{
|
||||
throw new ServiceException("价格必须大于0分");
|
||||
}
|
||||
if (virtualProductMapper.selectByProductIdExcludeId(productId, product.getId()) != null)
|
||||
{
|
||||
throw new ServiceException("微信道具ID已存在");
|
||||
}
|
||||
if (virtualProductMapper.selectByPriceExcludeId(product.getPriceFen(), product.getId()) != null)
|
||||
{
|
||||
throw new ServiceException("该价格档位已存在");
|
||||
}
|
||||
|
||||
product.setProductId(productId);
|
||||
product.setProductName(productName);
|
||||
if (product.getStatus() == null)
|
||||
{
|
||||
product.setStatus(1);
|
||||
}
|
||||
else if (product.getStatus() != 0 && product.getStatus() != 1)
|
||||
{
|
||||
throw new ServiceException("道具状态不正确");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,28 +13,80 @@
|
||||
<result property="updateTime" column="update_time"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="selectActiveByPrice" resultMap="AppVirtualProductResult">
|
||||
<sql id="selectAppVirtualProductVo">
|
||||
select id, product_id, price_fen, product_name, status, create_time, update_time
|
||||
from app_virtual_product
|
||||
</sql>
|
||||
|
||||
<select id="selectAppVirtualProductById" parameterType="Long" resultMap="AppVirtualProductResult">
|
||||
<include refid="selectAppVirtualProductVo" />
|
||||
where id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="selectAppVirtualProductList" parameterType="AppVirtualProduct" resultMap="AppVirtualProductResult">
|
||||
<include refid="selectAppVirtualProductVo" />
|
||||
<where>
|
||||
<if test="productId != null and productId != ''">
|
||||
and product_id like concat('%', #{productId}, '%')
|
||||
</if>
|
||||
<if test="productName != null and productName != ''">
|
||||
and product_name like concat('%', #{productName}, '%')
|
||||
</if>
|
||||
<if test="priceFen != null">and price_fen = #{priceFen}</if>
|
||||
<if test="status != null">and status = #{status}</if>
|
||||
</where>
|
||||
order by price_fen asc, id asc
|
||||
</select>
|
||||
|
||||
<select id="selectActiveByPrice" resultMap="AppVirtualProductResult">
|
||||
<include refid="selectAppVirtualProductVo" />
|
||||
where price_fen = #{priceFen} and status = 1
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectByProductId" resultMap="AppVirtualProductResult">
|
||||
select id, product_id, price_fen, product_name, status, create_time, update_time
|
||||
from app_virtual_product
|
||||
<include refid="selectAppVirtualProductVo" />
|
||||
where product_id = #{productId}
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectByProductIdExcludeId" resultMap="AppVirtualProductResult">
|
||||
<include refid="selectAppVirtualProductVo" />
|
||||
where product_id = #{productId}
|
||||
<if test="excludeId != null">and id != #{excludeId}</if>
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectByPriceExcludeId" resultMap="AppVirtualProductResult">
|
||||
<include refid="selectAppVirtualProductVo" />
|
||||
where price_fen = #{priceFen}
|
||||
<if test="excludeId != null">and id != #{excludeId}</if>
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<insert id="insertAppVirtualProduct" parameterType="AppVirtualProduct" useGeneratedKeys="true" keyProperty="id">
|
||||
insert into app_virtual_product(product_id, price_fen, product_name, status, create_time)
|
||||
values(#{productId}, #{priceFen}, #{productName}, #{status}, #{createTime})
|
||||
</insert>
|
||||
|
||||
<update id="updateAppVirtualProduct" parameterType="AppVirtualProduct">
|
||||
update app_virtual_product
|
||||
set product_id = #{productId},
|
||||
price_fen = #{priceFen},
|
||||
product_name = #{productName},
|
||||
status = #{status},
|
||||
update_time = #{updateTime}
|
||||
where id = #{id}
|
||||
</update>
|
||||
|
||||
<update id="updateProductIdByPrice" parameterType="AppVirtualProduct">
|
||||
update app_virtual_product
|
||||
set product_id = #{productId}, product_name = #{productName}, status = 1, update_time = #{updateTime}
|
||||
where price_fen = #{priceFen}
|
||||
</update>
|
||||
|
||||
<delete id="deleteAppVirtualProductByIds" parameterType="Long">
|
||||
delete from app_virtual_product where id in
|
||||
<foreach item="id" collection="array" open="(" separator="," close=")">#{id}</foreach>
|
||||
</delete>
|
||||
</mapper>
|
||||
|
||||
21
ruoyi-ui/src/api/app/virtualProduct.js
Normal file
21
ruoyi-ui/src/api/app/virtualProduct.js
Normal file
@@ -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' })
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
227
ruoyi-ui/src/views/app/virtualProduct/index.vue
Normal file
227
ruoyi-ui/src/views/app/virtualProduct/index.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-alert
|
||||
title="这里维护微信虚拟支付后台已发布的道具。资源规格价格和升级差价必须有对应的已启用道具。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb8"
|
||||
/>
|
||||
|
||||
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="86px">
|
||||
<el-form-item label="道具ID" prop="productId">
|
||||
<el-input v-model="queryParams.productId" placeholder="请输入微信道具ID" clearable @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="道具名称" prop="productName">
|
||||
<el-input v-model="queryParams.productName" placeholder="请输入道具名称" clearable @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格(分)" prop="priceFen">
|
||||
<el-input-number v-model="queryParams.priceFen" :min="1" :max="2147483647" :controls="false" placeholder="精确价格" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="queryParams.status" placeholder="全部状态" clearable>
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="停用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button v-hasPermi="['app:virtualProduct:add']" type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button v-hasPermi="['app:virtualProduct:edit']" type="success" plain icon="el-icon-edit" size="mini" :disabled="single" @click="handleUpdate()">修改</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button v-hasPermi="['app:virtualProduct:remove']" type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete()">删除</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button v-hasPermi="['app:virtualProduct:export']" type="warning" plain icon="el-icon-download" size="mini" @click="handleExport">导出</el-button>
|
||||
</el-col>
|
||||
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="virtualProductList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="道具名称" prop="productName" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="微信道具ID" prop="productId" min-width="190" show-overflow-tooltip />
|
||||
<el-table-column label="价格(分)" prop="priceFen" width="130" align="right" />
|
||||
<el-table-column label="价格(元)" width="130" align="right">
|
||||
<template slot-scope="scope">¥{{ formatYuan(scope.row.priceFen) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ scope.row.status === 1 ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" prop="updateTime" width="165">
|
||||
<template slot-scope="scope">{{ parseTime(scope.row.updateTime || scope.row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<el-button v-hasPermi="['app:virtualProduct:edit']" type="text" icon="el-icon-edit" size="mini" @click="handleUpdate(scope.row)">修改</el-button>
|
||||
<el-button v-hasPermi="['app:virtualProduct:remove']" type="text" icon="el-icon-delete" size="mini" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
|
||||
|
||||
<el-dialog :title="title" :visible.sync="open" width="540px" append-to-body>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="微信道具ID" prop="productId">
|
||||
<el-input v-model.trim="form.productId" maxlength="64" show-word-limit placeholder="须与微信后台完全一致" />
|
||||
</el-form-item>
|
||||
<el-form-item label="道具名称" prop="productName">
|
||||
<el-input v-model.trim="form.productName" maxlength="100" show-word-limit placeholder="例如:99元资源" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格(分)" prop="priceFen">
|
||||
<el-input-number v-model="form.priceFen" :min="1" :max="2147483647" :step="100" controls-position="right" />
|
||||
<span v-if="form.priceFen" class="price-hint">约 ¥{{ formatYuan(form.priceFen) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :label="1">启用</el-radio>
|
||||
<el-radio :label="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||
<el-button @click="cancel">取消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
addVirtualProduct,
|
||||
delVirtualProduct,
|
||||
getVirtualProduct,
|
||||
listVirtualProduct,
|
||||
updateVirtualProduct
|
||||
} from '@/api/app/virtualProduct'
|
||||
|
||||
export default {
|
||||
name: 'VirtualProduct',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
showSearch: true,
|
||||
ids: [],
|
||||
single: true,
|
||||
multiple: true,
|
||||
total: 0,
|
||||
virtualProductList: [],
|
||||
open: false,
|
||||
title: '',
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
productId: undefined,
|
||||
productName: undefined,
|
||||
priceFen: undefined,
|
||||
status: undefined
|
||||
},
|
||||
form: {},
|
||||
rules: {
|
||||
productId: [{ required: true, message: '请输入微信道具ID', trigger: 'blur' }],
|
||||
productName: [{ required: true, message: '请输入道具名称', trigger: 'blur' }],
|
||||
priceFen: [{ required: true, message: '请输入价格', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
this.loading = true
|
||||
listVirtualProduct(this.queryParams).then(response => {
|
||||
this.virtualProductList = response.rows || []
|
||||
this.total = response.total || 0
|
||||
}).finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
reset() {
|
||||
this.form = { id: undefined, productId: '', productName: '', priceFen: 100, status: 1 }
|
||||
this.resetForm('form')
|
||||
},
|
||||
cancel() {
|
||||
this.open = false
|
||||
this.reset()
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageNum = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.resetForm('queryForm')
|
||||
this.handleQuery()
|
||||
},
|
||||
handleSelectionChange(selection) {
|
||||
this.ids = selection.map(item => item.id)
|
||||
this.single = selection.length !== 1
|
||||
this.multiple = !selection.length
|
||||
},
|
||||
handleAdd() {
|
||||
this.reset()
|
||||
this.title = '新增虚拟道具'
|
||||
this.open = true
|
||||
},
|
||||
handleUpdate(row) {
|
||||
this.reset()
|
||||
const id = row && row.id ? row.id : this.ids[0]
|
||||
getVirtualProduct(id).then(response => {
|
||||
this.form = response.data
|
||||
this.title = '修改虚拟道具'
|
||||
this.open = true
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
this.$refs.form.validate(valid => {
|
||||
if (!valid) return
|
||||
const editing = Boolean(this.form.id)
|
||||
const request = editing ? updateVirtualProduct(this.form) : addVirtualProduct(this.form)
|
||||
request.then(() => {
|
||||
this.$modal.msgSuccess(editing ? '修改成功' : '新增成功')
|
||||
this.open = false
|
||||
this.getList()
|
||||
})
|
||||
})
|
||||
},
|
||||
handleDelete(row) {
|
||||
const ids = row && row.id ? row.id : this.ids
|
||||
this.$modal.confirm('确认删除选中的虚拟道具吗?删除后对应价格的资源将无法创建新订单。').then(() => {
|
||||
return delVirtualProduct(ids)
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('删除成功')
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
},
|
||||
handleExport() {
|
||||
this.download('app/virtualProduct/export', { ...this.queryParams }, `virtual_product_${new Date().getTime()}.xlsx`)
|
||||
},
|
||||
formatYuan(priceFen) {
|
||||
return (Number(priceFen || 0) / 100).toFixed(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.price-hint {
|
||||
margin-left: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -416,7 +416,7 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10"><el-form-item label="标题" required><el-input v-model="wechatDraftForm.title" maxlength="32" show-word-limit /></el-form-item></el-col>
|
||||
<el-col :span="10"><el-form-item label="标题" required><el-input v-model="wechatDraftForm.title" maxlength="60" show-word-limit /></el-form-item></el-col>
|
||||
<el-col :span="6"><el-form-item label="作者"><el-input v-model="wechatDraftForm.author" maxlength="16" show-word-limit /></el-form-item></el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="字号">
|
||||
|
||||
@@ -9,14 +9,6 @@
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板分组" prop="templateGroup">
|
||||
<el-input
|
||||
v-model="queryParams.templateGroup"
|
||||
placeholder="请输入模板分组"
|
||||
clearable
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板场景" prop="templateScene">
|
||||
<el-select v-model="queryParams.templateScene" placeholder="请选择模板场景" clearable>
|
||||
<el-option label="源码文案" value="CODE" />
|
||||
@@ -48,36 +40,76 @@
|
||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="templateList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="按钮名称" align="center" prop="templateName" width="160" />
|
||||
<el-table-column label="模板分组" align="center" prop="templateGroup" width="140" />
|
||||
<el-table-column label="模板场景" align="center" prop="templateScene" width="110">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" :type="scope.row.templateScene === 'DELIVERY' ? 'warning' : ''">
|
||||
{{ scope.row.templateScene === 'DELIVERY' ? '发货文案' : '源码文案' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" align="center" prop="sortNum" width="80" />
|
||||
<el-table-column label="状态" align="center" prop="status" width="100">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row.status === '0' ? '正常' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模板内容(预览)" align="left" prop="templateBody" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" align="center" prop="createTime" width="160" />
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="160">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['office:copyTemplate:edit']">修改</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['office:copyTemplate:remove']">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="template-content">
|
||||
<aside class="template-group-panel">
|
||||
<div class="template-group-header">
|
||||
<span>模板分组</span>
|
||||
<el-button
|
||||
type="text"
|
||||
icon="el-icon-plus"
|
||||
@click="handleAddGroup"
|
||||
v-hasPermi="['office:copyTemplate:add']"
|
||||
>新增分组</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
v-model.trim="groupKeyword"
|
||||
class="template-group-search"
|
||||
size="small"
|
||||
prefix-icon="el-icon-search"
|
||||
placeholder="搜索分组"
|
||||
clearable
|
||||
/>
|
||||
<div class="template-group-list">
|
||||
<button
|
||||
v-for="group in filteredGroupOptions"
|
||||
:key="group.key || '__ALL__'"
|
||||
type="button"
|
||||
class="template-group-item"
|
||||
:class="{ 'is-active': activeGroup === group.key }"
|
||||
@click="handleGroupSelect(group.key)"
|
||||
>
|
||||
<span class="template-group-name" :title="group.label">{{ group.label }}</span>
|
||||
<span class="template-group-count">{{ group.count }}</span>
|
||||
</button>
|
||||
<div v-if="filteredGroupOptions.length === 1 && groupKeyword" class="template-group-empty">
|
||||
未找到匹配分组
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
|
||||
<div class="template-table-panel">
|
||||
<el-table v-loading="loading" :data="templateList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="按钮名称" align="center" prop="templateName" width="160" />
|
||||
<el-table-column label="模板分组" align="center" prop="templateGroup" width="140" />
|
||||
<el-table-column label="模板场景" align="center" prop="templateScene" width="110">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" :type="scope.row.templateScene === 'DELIVERY' ? 'warning' : ''">
|
||||
{{ scope.row.templateScene === 'DELIVERY' ? '发货文案' : '源码文案' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" align="center" prop="sortNum" width="80" />
|
||||
<el-table-column label="状态" align="center" prop="status" width="100">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ scope.row.status === '0' ? '正常' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模板内容(预览)" align="left" prop="templateBody" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" align="center" prop="createTime" width="160" />
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="160">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['office:copyTemplate:edit']">修改</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['office:copyTemplate:remove']">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增或修改对话框 -->
|
||||
<el-dialog :title="title" :visible.sync="open" width="900px" append-to-body>
|
||||
@@ -157,7 +189,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listCopyTemplate, getCopyTemplate, addCopyTemplate, updateCopyTemplate, delCopyTemplate } from "@/api/office/copyTemplate";
|
||||
import { listCopyTemplate, listCopyTemplateGroups, getCopyTemplate, addCopyTemplate, updateCopyTemplate, delCopyTemplate } from "@/api/office/copyTemplate";
|
||||
|
||||
export default {
|
||||
name: "CopyTemplate",
|
||||
@@ -170,6 +202,9 @@ export default {
|
||||
showSearch: true,
|
||||
total: 0,
|
||||
templateList: [],
|
||||
templateGroups: [],
|
||||
activeGroup: null,
|
||||
groupKeyword: "",
|
||||
title: "",
|
||||
open: false,
|
||||
queryParams: {
|
||||
@@ -192,8 +227,29 @@ export default {
|
||||
},
|
||||
created() {
|
||||
this.getList();
|
||||
this.getGroups();
|
||||
},
|
||||
computed: {
|
||||
groupTotal() {
|
||||
return this.templateGroups.reduce((total, group) => total + Number(group.templateCount || 0), 0);
|
||||
},
|
||||
groupOptions() {
|
||||
return [
|
||||
{ key: null, label: "全部模板", count: this.groupTotal },
|
||||
...this.templateGroups.map(group => ({
|
||||
key: group.templateGroup,
|
||||
label: group.templateGroup === "__UNGROUPED__" ? "未分组" : group.templateGroup,
|
||||
count: Number(group.templateCount || 0)
|
||||
}))
|
||||
];
|
||||
},
|
||||
filteredGroupOptions() {
|
||||
const keyword = this.groupKeyword.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return this.groupOptions;
|
||||
}
|
||||
return this.groupOptions.filter((group, index) => index === 0 || group.label.toLowerCase().includes(keyword));
|
||||
},
|
||||
templateBodyPlaceholder() {
|
||||
if (this.form.templateScene === 'DELIVERY') {
|
||||
return '请输入发货文案模板内容,例如:\n{deliveryLinks}\n\n项目部署文档\n文档地址和密码'
|
||||
@@ -210,6 +266,14 @@ export default {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
getGroups() {
|
||||
listCopyTemplateGroups().then(response => {
|
||||
this.templateGroups = response.data || [];
|
||||
if (this.activeGroup && !this.templateGroups.some(group => group.templateGroup === this.activeGroup)) {
|
||||
this.handleGroupSelect(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
this.open = false;
|
||||
this.reset();
|
||||
@@ -234,16 +298,38 @@ export default {
|
||||
this.resetForm("queryForm");
|
||||
this.handleQuery();
|
||||
},
|
||||
handleGroupSelect(group) {
|
||||
this.activeGroup = group;
|
||||
this.queryParams.templateGroup = group;
|
||||
this.queryParams.pageNum = 1;
|
||||
this.getList();
|
||||
},
|
||||
handleSelectionChange(selection) {
|
||||
this.ids = selection.map(item => item.templateId);
|
||||
this.single = selection.length !== 1;
|
||||
this.multiple = !selection.length;
|
||||
},
|
||||
handleAdd() {
|
||||
handleAdd(groupName) {
|
||||
this.reset();
|
||||
if (typeof groupName === "string" && groupName) {
|
||||
this.form.templateGroup = groupName;
|
||||
} else if (this.activeGroup && this.activeGroup !== "__UNGROUPED__") {
|
||||
this.form.templateGroup = this.activeGroup;
|
||||
}
|
||||
this.open = true;
|
||||
this.title = "新增文案模板";
|
||||
},
|
||||
handleAddGroup() {
|
||||
this.$prompt("请输入模板分组名称", "新增分组", {
|
||||
confirmButtonText: "下一步",
|
||||
cancelButtonText: "取消",
|
||||
inputPattern: /\S+/,
|
||||
inputErrorMessage: "分组名称不能为空",
|
||||
inputPlaceholder: "例如:闲鱼、微信公众号"
|
||||
}).then(({ value }) => {
|
||||
this.handleAdd(value.trim());
|
||||
}).catch(() => {});
|
||||
},
|
||||
handleUpdate(row) {
|
||||
this.reset();
|
||||
const templateId = row.templateId || this.ids;
|
||||
@@ -261,12 +347,14 @@ export default {
|
||||
this.$modal.msgSuccess("修改成功");
|
||||
this.open = false;
|
||||
this.getList();
|
||||
this.getGroups();
|
||||
});
|
||||
} else {
|
||||
addCopyTemplate(this.form).then(() => {
|
||||
this.$modal.msgSuccess("新增成功");
|
||||
this.open = false;
|
||||
this.getList();
|
||||
this.getGroups();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -278,9 +366,136 @@ export default {
|
||||
return delCopyTemplate(templateIds);
|
||||
}).then(() => {
|
||||
this.getList();
|
||||
this.getGroups();
|
||||
this.$modal.msgSuccess("删除成功");
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.template-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-group-panel {
|
||||
flex: 0 0 210px;
|
||||
min-height: 510px;
|
||||
padding: 14px 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.template-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 28px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
|
||||
.el-button {
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.template-group-search {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.template-group-list {
|
||||
max-height: calc(100vh - 350px);
|
||||
min-height: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.template-group-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin: 0 0 3px;
|
||||
padding: 0 10px 0 12px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: #606266;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color .2s, color .2s;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: #409eff;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-group-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-group-count {
|
||||
flex: none;
|
||||
min-width: 26px;
|
||||
margin-left: 8px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
color: #909399;
|
||||
background: #f0f2f5;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.template-group-item.is-active .template-group-count {
|
||||
color: #409eff;
|
||||
background: #d9ecff;
|
||||
}
|
||||
|
||||
.template-group-empty {
|
||||
padding: 24px 0;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.template-table-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.template-group-panel {
|
||||
flex-basis: 180px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -180,6 +180,9 @@
|
||||
:file-size="activePlatform === 'xianyu' ? 10 : 20"
|
||||
:file-type="activePlatform === 'xianyu' ? ['png', 'jpg', 'jpeg'] : ['png', 'jpg', 'jpeg', 'webp']"
|
||||
/>
|
||||
<div v-if="publishForm.imageUrls" class="material-source-hint">
|
||||
已自动使用文件管理中与当前项目关联的图片,可删除、排序或继续添加
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="activePlatform !== 'xianyu'" label="视频封面">
|
||||
<image-upload
|
||||
@@ -290,7 +293,7 @@
|
||||
:is-show-tip="false"
|
||||
:enable-sort="true"
|
||||
/>
|
||||
<div class="multi-help">小红书和闲鱼共用,最多上传 9 张,可拖动调整顺序。</div>
|
||||
<div class="multi-help">自动使用文件管理中的项目图片;小红书和闲鱼共用,最多 9 张,可拖动调整顺序。</div>
|
||||
</div>
|
||||
|
||||
<div v-if="multiShowsCover" class="multi-material-block">
|
||||
@@ -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);
|
||||
|
||||
140
sql/source_code_technology_backfill.sql
Normal file
140
sql/source_code_technology_backfill.sql
Normal file
@@ -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`;
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 的现有拼写)。
|
||||
|
||||
69
sql/virtual_product_menu.sql
Normal file
69
sql/virtual_product_menu.sql
Normal file
@@ -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');
|
||||
Reference in New Issue
Block a user