增加租户官网模块

This commit is contained in:
2026-08-19 23:34:28 +08:00
parent ec5cf96d0b
commit 14f2e8b5c1
28 changed files with 3812 additions and 7 deletions
+82
View File
@@ -0,0 +1,82 @@
import request from '@/utils/request'
// ==================== 官网模板管理(平台端) ====================
// 模板列表
export function getTemplateList(params) {
return request({
url: '/platform/template/index',
method: 'get',
params
})
}
// 扫描 themes/ 目录,自动登记新模板
export function scanTemplates() {
return request({
url: '/platform/template/scan',
method: 'post'
})
}
// 上传模板 zip
export function uploadTemplate(formData) {
return request({
url: '/platform/template/upload',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 模板目录下可编辑文件列表
export function getTemplateFiles(code) {
return request({
url: `/platform/template/files/${code}`,
method: 'get'
})
}
// 读取模板文件内容
export function getTemplateFile(code, path) {
return request({
url: '/platform/template/file',
method: 'get',
params: { code, path }
})
}
// 保存模板文件
export function saveTemplateFile(data) {
return request({
url: '/platform/template/file/save',
method: 'post',
data
})
}
// 启用/停用模板
export function setTemplateStatus(id, status) {
return request({
url: '/platform/template/status',
method: 'post',
data: { id, status }
})
}
// 删除模板登记(可选删除磁盘目录)
export function deleteTemplate(id, removeFiles = false) {
return request({
url: `/platform/template/delete/${id}`,
method: 'post',
data: { remove_files: removeFiles }
})
}
// 标签调用说明数据
export function getTagDocs() {
return request({
url: '/platform/template/tags',
method: 'get'
})
}
+12
View File
@@ -21,6 +21,18 @@ const staticMainChildren = [
component: () => import("@/views/system/email/index.vue"),
meta: { requiresAuth: true, title: "邮箱管理" }
},
{
path: "/template/index",
name: "PlatformTemplateManage",
component: () => import("@/views/template/index.vue"),
meta: { requiresAuth: true, title: "官网模板管理" }
},
{
path: "/template/tags",
name: "PlatformTemplateTags",
component: () => import("@/views/template/tags.vue"),
meta: { requiresAuth: true, title: "标签调用说明" }
},
// 兼容拼写错误的路径重定向
{
path: "/apps/erp/dashborad",
+409 -6
View File
@@ -1,11 +1,414 @@
<script setup>
</script>
<template>
<div class="template-manage">
<div class="header-bar">
<h2>官网模板管理</h2>
<div class="header-actions">
<el-button type="primary" @click="uploadVisible = true">
<el-icon><Upload /></el-icon>上传模板
</el-button>
<el-button @click="handleScan" :loading="scanning">
<el-icon><Refresh /></el-icon>扫描目录
</el-button>
<el-button @click="fetchList" :loading="loading">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
模板文件存放于服务器 themes/{编码}/ 目录;上传 zip 或扫描目录后自动登记。
租户端(backend)只能选用已启用的模板,模板编辑仅平台端可操作。
</el-alert>
<el-table :data="list" v-loading="loading" border style="width: 100%">
<el-table-column label="预览" width="140" align="center">
<template #default="scope">
<el-image
v-if="scope.row.preview"
:src="getPreviewUrl(scope.row.preview)"
fit="cover"
style="width: 110px; height: 66px; border-radius: 4px"
@error="handleImageError"
/>
<span v-else style="color: #ccc">-</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="code" label="编码" width="130" />
<el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
<el-table-column label="目录" width="80" align="center">
<template #default="scope">
<el-tag v-if="scope.row.dir_exists" type="success" size="small">存在</el-tag>
<el-tag v-else type="danger" size="small">缺失</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="scope">
<el-switch
:model-value="scope.row.status === 1"
@change="handleStatusChange(scope.row, $event)"
/>
</template>
</el-table-column>
<el-table-column prop="create_time" label="登记时间" width="170" />
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="scope">
<el-button size="small" text type="primary" @click="openEditor(scope.row)">
编辑文件
</el-button>
<el-button size="small" text type="danger" @click="handleDelete(scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 上传模板 -->
<el-dialog v-model="uploadVisible" title="上传模板 zip" width="460px">
<el-form label-width="90px">
<el-form-item label="模板编码">
<el-input v-model="uploadCode" placeholder="留空则取 zip 文件名(仅字母数字下划线中划线)" />
</el-form-item>
<el-form-item label="zip 文件">
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
accept=".zip"
:on-change="handleFileChange"
:on-exceed="() => ElMessage.warning('只能上传一个 zip,请先移除')"
>
<el-button type="primary">选择 zip</el-button>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="handleUpload">上传并登记</el-button>
</template>
</el-dialog>
<!-- 在线编辑 -->
<el-dialog v-model="editorVisible" :title="`模板文件编辑 - ${editorCode}`" width="80%" top="5vh">
<div class="editor-layout">
<div class="editor-files">
<div
v-for="f in editorFiles"
:key="f.path"
class="file-item"
:class="{ active: f.path === currentFile }"
@click="openFile(f.path)"
>
{{ f.path }}
</div>
<el-empty v-if="editorFiles.length === 0" description="无可编辑文件" :image-size="60" />
</div>
<div class="editor-main">
<div class="editor-toolbar">
<span class="editor-path">{{ currentFile || '请选择左侧文件' }}</span>
<el-button type="primary" size="small" :loading="saving" :disabled="!currentFile" @click="handleSave">
保存
</el-button>
</div>
<textarea
v-model="fileContent"
class="editor-textarea"
spellcheck="false"
placeholder="选择左侧 html/css/js 文件进行编辑"
></textarea>
</div>
</div>
</el-dialog>
</div>
</template>
<style scoped>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, Refresh } from '@element-plus/icons-vue'
import {
getTemplateList,
scanTemplates,
uploadTemplate,
getTemplateFiles,
getTemplateFile,
saveTemplateFile,
setTemplateStatus,
deleteTemplate
} from '@/api/templateManage'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
const list = ref([])
const loading = ref(false)
const scanning = ref(false)
// 上传相关
const uploadVisible = ref(false)
const uploading = ref(false)
const uploadCode = ref('')
const uploadRef = ref(null)
const uploadFile = ref(null)
// 编辑器相关
const editorVisible = ref(false)
const editorCode = ref('')
const editorFiles = ref([])
const currentFile = ref('')
const fileContent = ref('')
const saving = ref(false)
const getPreviewUrl = (path) => {
if (!path) return ''
if (path.startsWith('http')) return path
return API_BASE_URL + path
}
const handleImageError = (event) => {
event.target.style.display = 'none'
}
const fetchList = async () => {
loading.value = true
try {
const res = await getTemplateList()
if (res.code === 200) {
list.value = res.data.list || []
} else {
ElMessage.error(res.msg || '获取模板列表失败')
}
} catch (e) {
ElMessage.error('获取模板列表失败')
} finally {
loading.value = false
}
}
const handleScan = async () => {
scanning.value = true
try {
const res = await scanTemplates()
if (res.code === 200) {
ElMessage.success(`扫描完成,新登记 ${res.data.added} 个模板`)
fetchList()
} else {
ElMessage.error(res.msg || '扫描失败')
}
} catch (e) {
ElMessage.error('扫描失败')
} finally {
scanning.value = false
}
}
const handleFileChange = (file) => {
uploadFile.value = file.raw
}
const handleUpload = async () => {
if (!uploadFile.value) {
ElMessage.warning('请先选择 zip 文件')
return
}
uploading.value = true
try {
const fd = new FormData()
fd.append('file', uploadFile.value)
if (uploadCode.value.trim()) {
fd.append('code', uploadCode.value.trim())
}
const res = await uploadTemplate(fd)
if (res.code === 200) {
ElMessage.success('上传成功,模板编码:' + res.data.code)
uploadVisible.value = false
uploadCode.value = ''
uploadFile.value = null
uploadRef.value?.clearFiles()
fetchList()
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch (e) {
ElMessage.error('上传失败')
} finally {
uploading.value = false
}
}
const handleStatusChange = async (row, val) => {
try {
const res = await setTemplateStatus(row.id, val ? 1 : 0)
if (res.code === 200) {
row.status = val ? 1 : 0
ElMessage.success(val ? '已启用' : '已停用(已选用租户将回退默认模板)')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
ElMessage.error('操作失败')
}
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(
`确定删除模板「${row.name}」的登记吗?`,
'删除确认',
{
confirmButtonText: '删除登记',
cancelButtonText: '取消',
type: 'warning',
distinguishCancelAndClose: true,
showClose: true
}
)
const res = await deleteTemplate(row.id, false)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (e) {
// 用户取消或接口异常均不提示
}
}
const openEditor = async (row) => {
if (!row.dir_exists) {
ElMessage.warning('模板目录不存在,无法编辑')
return
}
editorCode.value = row.code
currentFile.value = ''
fileContent.value = ''
editorVisible.value = true
try {
const res = await getTemplateFiles(row.code)
if (res.code === 200) {
editorFiles.value = res.data.files || []
} else {
ElMessage.error(res.msg || '获取文件列表失败')
}
} catch (e) {
ElMessage.error('获取文件列表失败')
}
}
const openFile = async (path) => {
currentFile.value = path
fileContent.value = ''
try {
const res = await getTemplateFile(editorCode.value, path)
if (res.code === 200) {
fileContent.value = res.data.content || ''
} else {
ElMessage.error(res.msg || '读取失败')
}
} catch (e) {
ElMessage.error('读取失败')
}
}
const handleSave = async () => {
saving.value = true
try {
const res = await saveTemplateFile({
code: editorCode.value,
path: currentFile.value,
content: fileContent.value
})
if (res.code === 200) {
ElMessage.success('保存成功')
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.template-manage {
padding: 16px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.header-bar h2 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.header-actions {
display: flex;
gap: 8px;
}
.editor-layout {
display: flex;
gap: 12px;
height: 62vh;
}
.editor-files {
width: 220px;
border: 1px solid #e5e5e5;
border-radius: 4px;
overflow-y: auto;
padding: 6px;
}
.file-item {
padding: 6px 10px;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
word-break: break-all;
}
.file-item:hover {
background: #f5f7fa;
}
.file-item.active {
background: #ecf5ff;
color: #1890ff;
}
.editor-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.editor-path {
font-size: 13px;
color: #666;
}
.editor-textarea {
flex: 1;
width: 100%;
border: 1px solid #e5e5e5;
border-radius: 4px;
padding: 10px;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.6;
resize: none;
outline: none;
}
.editor-textarea:focus {
border-color: #1890ff;
}
</style>
+235
View File
@@ -0,0 +1,235 @@
<template>
<div class="tag-docs">
<div class="header-bar">
<h2>标签调用说明</h2>
<el-button @click="fetchDocs" :loading="loading">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
模板中使用 <b>{yz:标签}</b> 调用数据,循环标签内用 <b>[field:字段/]</b> 输出字段。
本页由引擎注册表自动生成,新增标签后无需改文档。
</el-alert>
<div v-loading="loading">
<template v-for="cat in categories" :key="cat.key">
<div v-if="grouped[cat.key]?.length" class="cat-block">
<h3 class="cat-title">{{ cat.label }}</h3>
<el-card v-for="tag in grouped[cat.key]" :key="tag.name" class="tag-card" shadow="never">
<template #header>
<div class="tag-head">
<span class="tag-name">{{ tag.name }}</span>
<code class="tag-syntax">{{ tag.syntax }}</code>
<el-button size="small" text type="primary" @click="copy(tag.syntax)">复制语法</el-button>
</div>
</template>
<p class="tag-desc">{{ tag.desc }}</p>
<div v-if="tag.params?.length" class="tag-section">
<div class="sec-label">参数</div>
<ul>
<li v-for="p in tag.params" :key="p"><code>{{ paramName(p) }}</code>{{ paramDesc(p) }}</li>
</ul>
</div>
<div v-if="tag.fields?.length" class="tag-section">
<div class="sec-label">可用字段</div>
<div class="field-tags">
<el-tag
v-for="f in tag.fields"
:key="f"
size="small"
effect="plain"
class="field-tag"
@click="copy('[field:' + fieldName(f) + '/]')"
>
[field:{{ fieldName(f) }}/]
</el-tag>
</div>
<ul class="field-desc">
<li v-for="f in tag.fields" :key="f"><code>[field:{{ fieldName(f) }}/]</code>{{ fieldDesc(f) }}</li>
</ul>
</div>
<div v-if="tag.example" class="tag-section">
<div class="sec-label">
示例
<el-button size="small" text type="primary" @click="copy(tag.example)">复制</el-button>
</div>
<pre class="tag-example">{{ tag.example }}</pre>
</div>
</el-card>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { getTagDocs } from '@/api/templateManage'
const docs = ref([])
const loading = ref(false)
const categories = [
{ key: 'global', label: '全局标签' },
{ key: 'loop', label: '循环标签' },
{ key: 'single', label: '单条内容标签' },
{ key: 'other', label: '其他标签' }
]
const grouped = computed(() => {
const out = {}
for (const d of docs.value) {
if (!out[d.category]) out[d.category] = []
out[d.category].push(d)
}
return out
})
// "row 输出条数,默认 10" -> 名称与说明拆分
const paramName = (p) => (p || '').split(' ')[0]
const paramDesc = (p) => {
const i = (p || '').indexOf(' ')
return i > 0 ? ' — ' + p.slice(i + 1) : ''
}
const fieldName = (f) => (f || '').split(' ')[0]
const fieldDesc = (f) => {
const i = (f || '').indexOf(' ')
return i > 0 ? ' — ' + f.slice(i + 1) : ''
}
const copy = async (text) => {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch (e) {
ElMessage.error('复制失败,请手动选择复制')
}
}
const fetchDocs = async () => {
loading.value = true
try {
const res = await getTagDocs()
if (res.code === 200) {
docs.value = res.data || []
} else {
ElMessage.error(res.msg || '获取标签说明失败')
}
} catch (e) {
ElMessage.error('获取标签说明失败')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDocs()
})
</script>
<style scoped>
.tag-docs {
padding: 16px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.header-bar h2 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.cat-block {
margin-bottom: 24px;
}
.cat-title {
font-size: 16px;
border-left: 3px solid #1890ff;
padding-left: 10px;
margin-bottom: 12px;
}
.tag-card {
margin-bottom: 12px;
}
.tag-head {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.tag-name {
font-weight: 600;
font-size: 15px;
}
.tag-syntax {
background: #f5f7fa;
padding: 2px 8px;
border-radius: 4px;
font-size: 13px;
color: #d63384;
}
.tag-desc {
margin: 0 0 12px;
color: #555;
font-size: 14px;
}
.tag-section {
margin-top: 10px;
}
.sec-label {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.tag-section ul {
margin: 0;
padding-left: 20px;
font-size: 13px;
color: #666;
}
.tag-section li {
margin: 3px 0;
}
.field-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.field-tag {
cursor: pointer;
font-family: Consolas, Monaco, monospace;
}
.field-desc code,
.tag-section code {
background: #f5f7fa;
padding: 1px 6px;
border-radius: 3px;
font-size: 12px;
color: #1890ff;
}
.tag-example {
background: #282c34;
color: #abb2bf;
padding: 12px 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.7;
overflow-x: auto;
white-space: pre;
}
</style>