This commit is contained in:
2026-08-15 10:30:53 +08:00
parent 2cb52cd849
commit 0573cd8f40
11 changed files with 7910 additions and 435 deletions
+49 -25
View File
@@ -1,35 +1,47 @@
<template> <template>
<el-container class="layout"> <el-container class="layout">
<el-aside width="200px"> <el-header class="header">
<div class="logo">三十六计·后台</div> <div class="logo">三十六计·后台</div>
<el-menu :default-active="$route.path" :default-openeds="['content', 'ops', 'users']" router background-color="#1f2d3d" text-color="#c0c4cc" active-text-color="#409eff"> <el-menu
mode="horizontal"
:default-active="$route.path"
router
background-color="#1f2d3d"
text-color="#c0c4cc"
active-text-color="#409eff"
class="top-menu"
>
<el-sub-menu index="content"> <el-sub-menu index="content">
<template #title><span>内容管理</span></template> <template #title>内容管理</template>
<el-menu-item index="/content"><span>内容管理</span></el-menu-item> <el-menu-item index="/content">内容管理</el-menu-item>
<el-menu-item index="/element"><span>元素库</span></el-menu-item> <el-menu-item index="/element">元素库</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="ops"> <el-sub-menu index="ops">
<template #title><span>运营中心</span></template> <template #title>运营中心</template>
<el-menu-item index="/prize"><span>奖品管理</span></el-menu-item> <el-menu-item index="/prize">奖品管理</el-menu-item>
<el-menu-item index="/badge"><span>徽章管理</span></el-menu-item> <el-menu-item index="/badge">徽章管理</el-menu-item>
<el-menu-item index="/life-task"><span>生活任务</span></el-menu-item> <el-menu-item index="/life-task">生活任务</el-menu-item>
<el-menu-item index="/redemption"><span>兑换管理</span></el-menu-item> <el-menu-item index="/redemption">兑换管理</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="users"> <el-sub-menu index="users">
<template #title><span>用户管理</span></template> <template #title>用户管理</template>
<el-menu-item index="/parent"><span>家长管理</span></el-menu-item> <el-menu-item index="/parent">家长管理</el-menu-item>
<el-menu-item index="/child"><span>孩子管理</span></el-menu-item> <el-menu-item index="/child">孩子管理</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-menu-item index="/stats"><span>关卡统计</span></el-menu-item> <el-menu-item index="/stats">关卡统计</el-menu-item>
</el-menu> </el-menu>
</el-aside> <div class="right">
<el-container>
<el-header class="header">
<span>{{ $route.meta.title }}</span>
<el-button link type="primary" @click="logout">退出登录</el-button> <el-button link type="primary" @click="logout">退出登录</el-button>
</el-header> </div>
<el-main><router-view /></el-main> </el-header>
</el-container> <div class="breadcrumb-bar">
<el-breadcrumb separator="/">
<el-breadcrumb-item>三十六计·后台</el-breadcrumb-item>
<el-breadcrumb-item v-if="$route.meta.group">{{ $route.meta.group }}</el-breadcrumb-item>
<el-breadcrumb-item>{{ $route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<el-main><router-view /></el-main>
</el-container> </el-container>
</template> </template>
@@ -45,8 +57,20 @@ const logout = () => {
<style scoped> <style scoped>
.layout { height: 100vh; } .layout { height: 100vh; }
.logo { height: 60px; line-height: 60px; text-align: center; color: #fff; font-weight: 600; background: #1f2d3d; } .header {
.el-aside { background: #1f2d3d; } display: flex;
.el-menu { border-right: none; } align-items: center;
.header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e4e7ed; } padding: 0 16px;
background: #1f2d3d;
border-bottom: none;
}
.logo { color: #fff; font-weight: 600; font-size: 16px; padding: 0 16px 0 0; white-space: nowrap; }
.el-menu--horizontal { border-bottom: none; }
.top-menu { flex: 1; min-width: 0; overflow-x: auto; }
.right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.breadcrumb-bar {
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
}
</style> </style>
+1
View File
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import './styles.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
+8 -8
View File
@@ -10,14 +10,14 @@ const router = createRouter({
component: Layout, component: Layout,
redirect: '/content', redirect: '/content',
children: [ children: [
{ path: 'content', component: () => import('../views/Content.vue'), meta: { title: '内容管理' } }, { path: 'content', component: () => import('../views/Content.vue'), meta: { title: '内容管理', group: '内容管理' } },
{ path: 'element', component: () => import('../views/Element.vue'), meta: { title: '元素库' } }, { path: 'element', component: () => import('../views/Element.vue'), meta: { title: '元素库', group: '内容管理' } },
{ path: 'prize', component: () => import('../views/Prize.vue'), meta: { title: '奖品管理' } }, { path: 'prize', component: () => import('../views/Prize.vue'), meta: { title: '奖品管理', group: '运营中心' } },
{ path: 'badge', component: () => import('../views/Badge.vue'), meta: { title: '徽章管理' } }, { path: 'badge', component: () => import('../views/Badge.vue'), meta: { title: '徽章管理', group: '运营中心' } },
{ path: 'life-task', component: () => import('../views/LifeTask.vue'), meta: { title: '生活任务' } }, { path: 'life-task', component: () => import('../views/LifeTask.vue'), meta: { title: '生活任务', group: '运营中心' } },
{ path: 'parent', component: () => import('../views/Parent.vue'), meta: { title: '家长管理' } }, { path: 'parent', component: () => import('../views/Parent.vue'), meta: { title: '家长管理', group: '用户管理' } },
{ path: 'child', component: () => import('../views/Child.vue'), meta: { title: '孩子管理' } }, { path: 'child', component: () => import('../views/Child.vue'), meta: { title: '孩子管理', group: '用户管理' } },
{ path: 'redemption', component: () => import('../views/Redemption.vue'), meta: { title: '兑换管理' } }, { path: 'redemption', component: () => import('../views/Redemption.vue'), meta: { title: '兑换管理', group: '运营中心' } },
{ path: 'stats', component: () => import('../views/Stats.vue'), meta: { title: '关卡统计' } }, { path: 'stats', component: () => import('../views/Stats.vue'), meta: { title: '关卡统计' } },
], ],
}, },
+5
View File
@@ -0,0 +1,5 @@
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
}
+41 -6
View File
@@ -3,7 +3,6 @@
<el-aside width="330px" class="tree-panel"> <el-aside width="330px" class="tree-panel">
<div class="tree-head"> <div class="tree-head">
<span>内容结构</span> <span>内容结构</span>
<el-button size="small" type="primary" plain @click="strategyCrud.openCreate({ group_no: 1, sort_order: 1 })">新增计策</el-button>
</div> </div>
<el-tree <el-tree
ref="treeRef" ref="treeRef"
@@ -18,6 +17,7 @@
<template #default="{ data }"> <template #default="{ data }">
<span class="tree-label"> <span class="tree-label">
<span>{{ data.label }}</span> <span>{{ data.label }}</span>
<el-tag v-if="data.type === 'node' && data.node_type === 2" type="warning" size="small">终局</el-tag>
<el-tag v-if="data.status === 0" type="info" size="small">禁用</el-tag> <el-tag v-if="data.status === 0" type="info" size="small">禁用</el-tag>
</span> </span>
</template> </template>
@@ -73,7 +73,24 @@
</el-table> </el-table>
<!-- 节点列表选中关卡 --> <!-- 节点列表选中关卡 -->
<el-table v-else-if="current && current.type === 'level'" :data="list" v-loading="loading" border stripe> <el-table v-else-if="current && current.type === 'level'" :data="list" v-loading="loading" border stripe @expand-change="onNodeExpand">
<el-table-column type="expand">
<template #default="{ row }">
<div v-if="row.node_type === 1" class="node-opts">
<div v-if="row._optionsLoading" class="opt-hint">加载中</div>
<template v-else>
<div v-for="o in row._options || []" :key="o.id" class="opt-row">
<el-tag size="small" :type="o.status === 1 ? 'success' : 'info'">{{ o.status === 1 ? '上架' : '下架' }}</el-tag>
<span class="opt-text">{{ o.text }}</span>
<el-tag v-if="o.prop_name" size="small" type="warning">{{ o.prop_name }}</el-tag>
<span class="opt-next"> {{ o.next_node_title }}</span>
</div>
<div v-if="!row._options || !row._options.length" class="opt-hint">该节点暂无选项</div>
</template>
</div>
<div v-else class="opt-hint">终局节点无分支选项</div>
</template>
</el-table-column>
<el-table-column prop="id" label="ID" width="70" /> <el-table-column prop="id" label="ID" width="70" />
<el-table-column label="类型" width="70"> <el-table-column label="类型" width="70">
<template #default="{ row }"><el-tag :type="row.node_type === 1 ? 'primary' : 'warning'" size="small">{{ nodeTypeMap[row.node_type] }}</el-tag></template> <template #default="{ row }"><el-tag :type="row.node_type === 1 ? 'primary' : 'warning'" size="small">{{ nodeTypeMap[row.node_type] }}</el-tag></template>
@@ -125,7 +142,7 @@
<el-empty v-else-if="current && current.type === 'node'" description="终局节点,无分支选项" /> <el-empty v-else-if="current && current.type === 'node'" description="终局节点,无分支选项" />
<!-- 计策对话框 --> <!-- 计策对话框 -->
<el-dialog v-model="strategyCrud.state.dialogVisible" :title="strategyCrud.state.isEdit ? '编辑计策' : '新增计策'" width="680px"> <el-dialog v-model="strategyCrud.state.dialogVisible" title="编辑计策" width="680px">
<el-form :model="strategyCrud.state.form" label-width="100px"> <el-form :model="strategyCrud.state.form" label-width="100px">
<el-form-item label="名称" required><el-input v-model="strategyCrud.state.form.name" /></el-form-item> <el-form-item label="名称" required><el-input v-model="strategyCrud.state.form.name" /></el-form-item>
<el-form-item label="分组编号" required><el-input-number v-model="strategyCrud.state.form.group_no" :min="1" :max="6" /></el-form-item> <el-form-item label="分组编号" required><el-input-number v-model="strategyCrud.state.form.group_no" :min="1" :max="6" /></el-form-item>
@@ -290,14 +307,15 @@ const levelCrud = makeCrud('/level')
const nodeCrud = makeCrud('/scene-node') const nodeCrud = makeCrud('/scene-node')
const optionCrud = makeCrud('/node-option') const optionCrud = makeCrud('/node-option')
// 强制重载某个树节点的子节点(lazy 树),加载完成后回调 // 强制重载某个树节点的子节点(lazy 树):置 loaded=false 后走 Node.expand 触发 loadData
// (expanded 是普通属性,直接赋值不会触发懒加载;loadNode 也不在树组件公开方法中)
const reloadTree = (node, after) => { const reloadTree = (node, after) => {
if (!treeRef.value || !node) { if (!treeRef.value || !node) {
after && after() after && after()
return return
} }
node.loaded = false node.loaded = false
treeRef.value.loadNode(node, () => { node.expand(() => {
after && after() after && after()
}) })
} }
@@ -313,7 +331,7 @@ const loadNode = async (node, resolve) => {
nextTick(() => { nextTick(() => {
const treeNode = treeRef.value.getNode(items[0].id) const treeNode = treeRef.value.getNode(items[0].id)
if (treeNode) { if (treeNode) {
treeNode.expanded = true treeNode.expand()
onNodeClick(treeNode.data, treeNode) onNodeClick(treeNode.data, treeNode)
} }
}) })
@@ -343,6 +361,18 @@ const loadList = async (api, params) => {
} }
} }
// 展开节点行时懒加载该节点的分支选项
const onNodeExpand = async (row, expanded) => {
if (!expanded || row.node_type !== 1 || row._options) return
row._optionsLoading = true
try {
const d = await request.get('/node-option/list', { params: { node_id: row.id } })
row._options = (d && d.list) || []
} finally {
row._optionsLoading = false
}
}
// 从树节点向上收集祖先链,得到「计策 / 关卡 / 节点」路径 // 从树节点向上收集祖先链,得到「计策 / 关卡 / 节点」路径
const buildPath = (data, treeNode) => { const buildPath = (data, treeNode) => {
const path = [] const path = []
@@ -443,6 +473,11 @@ request.get('/strategy/list').then((d) => {
.tree-label { display: inline-flex; align-items: center; gap: 6px; } .tree-label { display: inline-flex; align-items: center; gap: 6px; }
.detail-panel { padding: 10px 16px; } .detail-panel { padding: 10px 16px; }
.detail-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; } .detail-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
.node-opts { padding: 8px 24px; display: flex; flex-direction: column; gap: 8px; }
.opt-row { display: flex; align-items: center; gap: 10px; }
.opt-text { font-weight: 600; color: #5b4636; }
.opt-next { color: #909399; }
.opt-hint { color: #a08c74; padding: 8px 24px; }
.crumb { display: flex; align-items: center; gap: 8px; } .crumb { display: flex; align-items: center; gap: 8px; }
.crumb-label { color: #909399; } .crumb-label { color: #909399; }
</style> </style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff