feat(admin): 订单管理重构、设置管理增强、短信签名模板管理及通知渠道优化
Build and Deploy Vue3 / build (push) Successful in 1m27s
Build and Deploy Vue3 / deploy (push) Successful in 36s

- 订单列表重构为卡片式布局并新增筛选功能

- 设置管理支持struct/struct_list类型配置

- 新增短信签名和模板独立管理页面

- 通知渠道新增短信渠道配置

- 产品参数管理优化

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shiran
2026-06-15 18:27:23 +08:00
parent 3227a50f9a
commit 4180f73c53
14 changed files with 3811 additions and 363 deletions
+259 -39
View File
@@ -193,43 +193,75 @@
</el-tabs>
<!-- ==================== 模板编辑弹窗 ==================== -->
<el-dialog v-model="tplDialogVisible" :title="tplForm.id ? '编辑模板' : '新增模板'" width="640px" append-to-body destroy-on-close>
<el-form :model="tplForm" :rules="tplRules" ref="tplFormRef" label-width="90px" label-position="right">
<el-form-item label="模板名称" prop="name">
<el-input v-model="tplForm.name" placeholder="例:用户注册通知" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="模板标识" prop="tag">
<el-input v-model="tplForm.tag" placeholder="例:user_register_notify" maxlength="100" :disabled="!!tplForm.id" />
</el-form-item>
<el-form-item label="模板类型" prop="type">
<el-radio-group v-model="tplForm.type">
<el-radio value="email">
<span class="radio-with-icon">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="#e6a23c"/></svg>
邮件
</span>
</el-radio>
<el-radio value="phone">
<span class="radio-with-icon">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="#67c23a"/></svg>
短信
</span>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="可用参数" v-if="tplFormArgs.length > 0">
<div class="args-btn-group">
<el-button v-for="arg in tplFormArgs" :key="arg" size="small" @click="insertArg(arg)" class="arg-insert-btn">
<svg viewBox="0 0 24 24" width="12" height="12" style="margin-right:3px"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor"/></svg>
{{ arg }}
<el-dialog v-model="tplDialogVisible" :title="tplForm.id ? '编辑模板' : '新增模板'" width="1060px" append-to-body destroy-on-close @opened="onTplDialogOpened">
<div class="tpl-dialog-body">
<!-- 左侧表单 -->
<div class="tpl-dialog-left">
<el-form :model="tplForm" :rules="tplRules" ref="tplFormRef" label-width="90px" label-position="right">
<el-form-item label="模板名称" prop="name">
<el-input v-model="tplForm.name" placeholder="例:用户注册通知" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="模板标识" prop="tag">
<el-input v-model="tplForm.tag" placeholder="例:user_register_notify" maxlength="100" :disabled="!!tplForm.id" />
</el-form-item>
<el-form-item label="模板类型" prop="type">
<el-radio-group v-model="tplForm.type">
<el-radio value="email">
<span class="radio-with-icon">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="#e6a23c"/></svg>
邮件
</span>
</el-radio>
<el-radio value="phone">
<span class="radio-with-icon">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="#67c23a"/></svg>
短信
</span>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="可用参数" v-if="tplFormArgs.length > 0">
<div class="args-btn-group">
<el-button v-for="arg in tplFormArgs" :key="arg" size="small" @click="insertArg(arg)" class="arg-insert-btn">
<svg viewBox="0 0 24 24" width="12" height="12" style="margin-right:3px"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor"/></svg>
{{ arg }}
</el-button>
</div>
<div class="form-hint">点击参数按钮可将其插入到模板内容光标处</div>
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-input ref="contentInputRef" v-model="tplForm.content" type="textarea" :rows="8" placeholder="模板内容,点击上方参数按钮插入变量" />
</el-form-item>
</el-form>
</div>
<!-- 右侧渲染预览 -->
<div class="tpl-dialog-right">
<div class="preview-header">
<span class="preview-title">
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" fill="currentColor"/></svg>
渲染预览
</span>
<el-button size="small" link type="primary" @click="fetchPreview" :loading="previewLoading">
<el-icon><Refresh /></el-icon> 刷新
</el-button>
</div>
<div class="form-hint">点击参数按钮可将其插入到模板内容光标处</div>
</el-form-item>
<el-form-item label="模板内容" prop="content">
<el-input ref="contentInputRef" v-model="tplForm.content" type="textarea" :rows="6" placeholder="模板内容,点击上方参数按钮插入变量" />
</el-form-item>
</el-form>
<div class="preview-body" v-loading="previewLoading">
<template v-if="previewData.rendered">
<div class="preview-rendered" :class="{ 'html-mode': tplForm.type === 'email' }" v-html="previewRenderedHtml"></div>
</template>
<div v-else-if="previewError" class="preview-empty">
<el-icon :size="32" color="#f56c6c"><WarningFilled /></el-icon>
<p>{{ previewError }}</p>
</div>
<div v-else class="preview-empty">
<el-icon :size="32" color="#c0c4cc"><View /></el-icon>
<p>填写模板标识和类型后自动加载预览</p>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="tplDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="tplSubmitting" @click="handleSubmitTpl">确定</el-button>
@@ -239,12 +271,13 @@
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { Plus, Refresh, View, WarningFilled } from '@element-plus/icons-vue'
import {
getNoticeChannelList, updateNoticeChannel,
getNoticeTemplateList, addNoticeTemplate, updateNoticeTemplate, deleteNoticeTemplate
getNoticeTemplateList, addNoticeTemplate, updateNoticeTemplate, deleteNoticeTemplate,
previewNoticeTemplate
} from '@/api/admin/noticeChannel'
// ==================== 渠道配置 ====================
@@ -410,6 +443,70 @@ const handleDeleteTpl = async (row) => {
} catch {}
}
// ==================== 渲染预览 ====================
const previewLoading = ref(false)
const previewError = ref('')
const previewData = ref({ rendered: '', default_args: null })
const previewRenderedHtml = computed(() => {
const text = previewData.value.rendered || ''
if (tplForm.value.type === 'email') return text
return text.replace(/\n/g, '<br>')
})
let previewTimer = null
const debouncedFetchPreview = () => {
clearTimeout(previewTimer)
previewTimer = setTimeout(() => fetchPreview(), 600)
}
const fetchPreview = async () => {
const tag = tplForm.value.tag?.trim()
const type = tplForm.value.type
const content = tplForm.value.content?.trim()
if (!content && (!tag || !type)) {
previewData.value = { rendered: '', default_args: null }
previewError.value = ''
return
}
previewLoading.value = true
previewError.value = ''
try {
const params = {}
if (content) {
params.content = content
} else {
params.tag = tag
params.type = type
}
const res = await previewNoticeTemplate(params)
const body = res?.data
if (body?.code === 200 && body.data) {
previewData.value = body.data
} else {
previewData.value = { rendered: '', default_args: null }
previewError.value = body?.message || '渲染失败'
}
} catch {
previewData.value = { rendered: '', default_args: null }
previewError.value = '请求失败'
} finally {
previewLoading.value = false
}
}
const onTplDialogOpened = () => {
if (tplForm.value.tag && tplForm.value.type) {
fetchPreview()
}
}
watch(() => tplForm.value.tag, debouncedFetchPreview)
watch(() => tplForm.value.type, debouncedFetchPreview)
watch(() => tplForm.value.content, debouncedFetchPreview)
// ==================== 工具函数 ====================
const formatTime = (t) => {
if (!t) return '-'
@@ -495,6 +592,129 @@ onMounted(() => {
.form-hint { margin-top: 6px; font-size: 12px; color: #a8abb2; line-height: 1.4; }
.form-hint code { background: #f5f7fa; padding: 1px 5px; border-radius: 3px; font-size: 11px; color: #606266; }
/* ===== 弹窗 ===== */
/* ===== 弹窗双栏布局 ===== */
.radio-with-icon { display: inline-flex; align-items: center; gap: 4px; }
.tpl-dialog-body {
display: flex;
gap: 20px;
min-height: 380px;
}
.tpl-dialog-left {
flex: 1;
min-width: 0;
}
.tpl-dialog-right {
width: 380px;
flex-shrink: 0;
border: 1px solid #e4e7ed;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fafbfc;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
}
.preview-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.preview-body {
flex: 1;
overflow-y: auto;
padding: 14px;
}
.preview-rendered {
font-size: 13px;
line-height: 1.7;
color: #303133;
word-break: break-all;
white-space: pre-wrap;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 12px;
max-height: 320px;
overflow-y: auto;
}
.preview-rendered.html-mode {
white-space: normal;
transform: scale(0.75);
transform-origin: top left;
width: 133.33%;
max-height: 426px;
}
.preview-args {
margin-top: 14px;
}
.preview-args-title {
font-size: 12px;
font-weight: 600;
color: #606266;
margin-bottom: 8px;
}
.preview-args-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-arg-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.preview-arg-key {
background: #ecf5ff;
color: #409eff;
padding: 2px 8px;
border-radius: 3px;
font-family: 'Consolas','Monaco',monospace;
flex-shrink: 0;
}
.preview-arg-val {
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: #909399;
}
.preview-empty p {
margin: 10px 0 0;
font-size: 13px;
}
</style>
File diff suppressed because it is too large Load Diff