Expand publishing and virtual product workflows

This commit is contained in:
王鹏
2026-09-30 11:00:01 +08:00
parent 4ed8743934
commit b904dcf52e
54 changed files with 2185 additions and 108 deletions

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

View File

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

View 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>

View File

@@ -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="字号">

View File

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

View File

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