fix: 权限列表JSON字段与角色可编辑; 前台site_id与SPA; 首页积木扩展区

Made-with: Cursor
This commit is contained in:
whm
2026-03-19 17:31:18 +08:00
parent e1fc257435
commit 0360ee5261
10 changed files with 210 additions and 54 deletions

View File

@@ -10,27 +10,28 @@
</div>
</div>
</template>
<p class="tip">超级管理员(9527)拥有全部权限且不可修改为其他角色勾选其可用的后台权限可创建自定义角色并赋权</p>
<p class="tip">
超级管理员(9527)拥有全部权限且不可改权限勾选防误操作<strong>超级用户(0)普通用户(1)</strong>可修改权限与显示名称自定义角色可删除
</p>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="role_name" label="角色" width="160">
<el-table-column prop="role_name" label="角色" width="200">
<template #default="{ row }">
<el-input v-if="row.is_custom" v-model="row.role_name" size="small" placeholder="角色名" style="width: 120px" />
<el-input v-if="row.role_id !== 9527" v-model="row.role_name" size="small" placeholder="显示名称" style="width: 160px" />
<span v-else>{{ row.role_name }}</span>
</template>
</el-table-column>
<el-table-column prop="role_id" label="role_id" width="100" />
<el-table-column label="权限" min-width="400">
<el-table-column label="权限" min-width="480">
<template #default="{ row }">
<span v-if="row.role_id === 9527" class="perm-all">全部权限不可修改</span>
<div v-else class="perm-checkboxes">
<el-checkbox
v-for="p in allPermissions"
:key="p.key"
v-model="row._checked[p.key]"
style="margin-right: 16px; margin-bottom: 8px"
>
{{ p.name }}
</el-checkbox>
<div v-else class="perm-grid">
<label v-for="p in allPermissions" :key="permKey(p)" class="perm-item">
<el-checkbox v-model="row._checked[permKey(p)]" />
<span class="perm-text">
<span class="perm-name">{{ permLabel(p) }}</span>
<span class="perm-key">{{ permKey(p) }}</span>
</span>
</label>
</div>
</template>
</el-table-column>
@@ -43,16 +44,21 @@
</el-table>
</el-card>
<el-dialog v-model="showCreate" title="创建角色" width="500px">
<el-dialog v-model="showCreate" title="创建角色" width="560px">
<el-form label-width="90px">
<el-form-item label="角色名称" required>
<el-input v-model="createForm.role_name" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="权限">
<div class="perm-checkboxes">
<el-checkbox v-for="p in allPermissions" :key="p.key" v-model="createForm._checked[p.key]">
{{ p.name }}
</el-checkbox>
<p class="dialog-perm-hint">勾选该角色可访问的后台能力</p>
<div class="perm-grid dialog-perm-grid">
<label v-for="p in allPermissions" :key="permKey(p)" class="perm-item">
<el-checkbox v-model="createForm._checked[permKey(p)]" />
<span class="perm-text">
<span class="perm-name">{{ permLabel(p) }}</span>
<span class="perm-key">{{ permKey(p) }}</span>
</span>
</label>
</div>
</el-form-item>
</el-form>
@@ -77,10 +83,20 @@ const showCreate = ref(false)
const creating = ref(false)
const createForm = reactive({ role_name: '', _checked: {} })
/** 兼容旧接口大写字段 Key/Name */
function permKey(p) {
return p?.key || p?.Key || ''
}
function permLabel(p) {
const k = permKey(p)
return p?.name || p?.Name || k || '权限'
}
function buildChecked(permissions) {
const o = {}
allPermissions.value.forEach((p) => {
o[p.key] = permissions.includes(p.key)
const k = permKey(p)
if (k) o[k] = (permissions || []).includes(k)
})
return o
}
@@ -106,9 +122,10 @@ const handleSave = async () => {
try {
for (const row of list.value) {
if (row.role_id === 9527) continue
const permissions = allPermissions.value.filter((p) => row._checked[p.key]).map((p) => p.key)
const permissions = allPermissions.value.filter((p) => row._checked[permKey(p)]).map((p) => permKey(p))
const payload = { permissions }
if (row.is_custom && row.role_name) payload.role_name = row.role_name
const name = (row.role_name || '').trim()
if (name) payload.role_name = name
await updateRolePermissions(row.role_id, payload)
}
ElMessage.success('保存成功')
@@ -123,7 +140,8 @@ const resetCreateForm = () => {
createForm.role_name = ''
createForm._checked = {}
allPermissions.value.forEach((p) => {
createForm._checked[p.key] = false
const k = permKey(p)
if (k) createForm._checked[k] = false
})
}
@@ -135,7 +153,7 @@ const handleCreate = async () => {
}
creating.value = true
try {
const permissions = allPermissions.value.filter((p) => createForm._checked[p.key]).map((p) => p.key)
const permissions = allPermissions.value.filter((p) => createForm._checked[permKey(p)]).map((p) => permKey(p))
await createRole({ role_name: name, permissions })
ElMessage.success('创建成功')
showCreate.value = false
@@ -176,9 +194,50 @@ onMounted(fetchList)
color: #666;
font-size: 13px;
margin-bottom: 16px;
line-height: 1.6;
}
.perm-checkboxes {
.perm-all {
color: #909399;
font-size: 13px;
}
.perm-grid {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
align-items: flex-start;
}
.dialog-perm-grid {
max-height: 360px;
overflow-y: auto;
padding: 8px 0;
}
.perm-item {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
min-width: 200px;
max-width: 240px;
}
.perm-text {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.35;
}
.perm-name {
font-size: 13px;
color: #303133;
}
.perm-key {
font-size: 11px;
color: #909399;
font-family: ui-monospace, monospace;
word-break: break-all;
}
.dialog-perm-hint {
margin: 0 0 8px;
font-size: 12px;
color: #909399;
}
</style>

View File

@@ -14,7 +14,7 @@
</div>
</template>
<el-form v-if="siteId" ref="formRef" :model="form" label-width="120px" style="max-width: 720px">
<el-form v-if="siteId" ref="formRef" :model="form" label-width="120px" class="homepage-form">
<el-divider content-position="left">导航与标题</el-divider>
<el-form-item label="Logo 文案">
<el-input v-model="form.logo_text" placeholder="YUHENG ONE" />
@@ -110,6 +110,14 @@
<el-form-item label="页脚文案">
<el-input v-model="form.footer_text" placeholder="© 2024 YUHENG ONE" />
</el-form-item>
<el-divider content-position="left">首页下方扩展区可视化积木可拖拽排序</el-divider>
<p class="builder-tip">
网页管理 积木相同从左侧手柄拖拽调整模块顺序保存后内容显示在落地页主视觉与特性卡片<strong>之后</strong>页脚之前留空则不显示扩展区
</p>
<el-form-item label="扩展积木" class="builder-form-item homepage-builder-wrap">
<PageBuilderEditor v-model="form.body_builder" :site-id="siteId" />
</el-form-item>
</el-form>
<el-empty v-else description="请先选择站点" />
@@ -129,6 +137,7 @@ import { ElMessage } from 'element-plus'
import { getSites, getOfficialSite, getHomepage, updateHomepage } from '../../api/admin'
import { useAuthStore } from '../../stores/auth'
import LinkPickerDialog from '../../components/LinkPickerDialog.vue'
import PageBuilderEditor from '../../components/PageBuilderEditor.vue'
const siteId = ref('')
const sites = ref([])
@@ -162,7 +171,8 @@ const defaultForm = () => ({
{ title: '量子同步', desc: '跨维度数据同步技术,您的数据在多宇宙中保持一致' },
{ title: '星际防护', desc: '来自未来的安全加密协议,守护您的数字资产安全' }
],
footer_text: '© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE'
footer_text: '© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE',
body_builder: ''
})
const form = reactive(defaultForm())
@@ -284,4 +294,25 @@ onMounted(() => {
justify-content: space-between;
align-items: center;
}
.homepage-form {
max-width: 720px;
}
.builder-tip {
font-size: 13px;
color: #606266;
line-height: 1.6;
margin: 0 0 12px;
max-width: 900px;
}
.homepage-builder-wrap {
max-width: 1000px;
}
.homepage-builder-wrap :deep(.el-form-item__content) {
display: block;
margin-left: 0 !important;
}
.builder-form-item :deep(.el-form-item__content) {
display: block;
margin-left: 0 !important;
}
</style>

View File

@@ -53,10 +53,18 @@
<el-dialog
v-model="dialogVisible"
:title="editId ? '编辑网页' : '新增网页'"
:width="form.content_mode === 'builder' ? '960px' : '720px'"
:width="form.content_mode === 'builder' ? '1080px' : '720px'"
top="4vh"
@close="resetForm"
>
<el-alert
v-if="form.content_mode === 'builder'"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
title="积木模式:从模块左侧 ⋮⋮ 手柄拖拽排序;链接可点「选择链接」选站内页或文件。"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="Slug" prop="slug">
<el-input v-model="form.slug" placeholder="如 about、indexindex 为首页数据,一般不单独走路由)" :disabled="!!editId" />
@@ -74,10 +82,10 @@
</el-select>
</el-form-item>
<el-form-item label="内容模式">
<el-select v-model="form.content_mode" placeholder="模式" style="width: 200px">
<el-option label="HTML 富文本" value="html" />
<el-option label="积木组装(可视化)" value="builder" />
</el-select>
<el-radio-group v-model="form.content_mode">
<el-radio-button value="builder">积木可视化拖拽</el-radio-button>
<el-radio-button value="html">HTML 源码</el-radio-button>
</el-radio-group>
<el-button type="primary" link style="margin-left: 12px" @click="insertBuilderTemplate">插入积木模板</el-button>
</el-form-item>
<el-form-item label="发布到前台">
@@ -86,7 +94,7 @@
<el-form-item v-if="form.content_mode === 'html'" label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="14" placeholder="直接编写 HTML" />
</el-form-item>
<el-form-item v-else label="页面积木" class="builder-form-item">
<el-form-item v-else label="页面积木" class="builder-form-item page-builder-wrap">
<PageBuilderEditor v-model="form.content" :site-id="siteId" />
</el-form-item>
</el-form>
@@ -202,7 +210,7 @@ const form = reactive({
title: '',
type: 'page',
content: '',
content_mode: 'html',
content_mode: 'builder',
route_path: '',
published: true
})
@@ -231,7 +239,7 @@ const openDialog = (row) => {
form.title = row ? row.title : ''
form.type = row ? row.type || 'page' : 'page'
form.content = row ? row.content || '' : ''
form.content_mode = row?.content_mode || 'html'
form.content_mode = row?.content_mode || 'builder'
form.route_path = row?.route_path || ''
form.published = row?.published !== false
dialogVisible.value = true
@@ -242,7 +250,7 @@ const resetForm = () => {
form.title = ''
form.type = 'page'
form.content = ''
form.content_mode = 'html'
form.content_mode = 'builder'
form.route_path = ''
form.published = true
editId.value = ''
@@ -303,4 +311,7 @@ onMounted(() => {
display: block;
margin-left: 0 !important;
}
.page-builder-wrap {
max-width: 100%;
}
</style>

View File

@@ -52,6 +52,11 @@
1.`web/src/components/blocks/BlockRenderer.vue` 增加 `type` 分支与样式。
2. 后台仍通过 JSON 配置 `props`,无需改库表结构。
## 首页编辑:下方扩展积木
- 除原有导航、主视觉、特性等表单项外,可增加 **「首页下方扩展区」**:与网页积木相同 JSON保存后由前台 `Home.vue` 在特性区之后、页脚之前用 **同一套 BlockRenderer** 动态渲染。
- 下载的静态 `index.html` 目前**不包含**该积木区(仅在线 SPA 展示)。
## 首页编辑(导航 / 下载 / 平台链接)
- **管理后台 → 首页编辑与下载**:导航链接、下载按钮链接、各平台链接均可点 **「选择链接」**,与积木编辑器共用 `LinkPickerDialog`

View File

@@ -122,7 +122,8 @@ func UpdateRolePermissions(c *gin.Context) {
coll := config.GetDB(config.DBName).Collection("role_permissions")
filter := bson.M{"role_id": roleID}
set := bson.M{"role_id": roleID, "permissions": input.Permissions}
if input.RoleName != "" && roleID >= customRoleIDStart {
// 超级管理员(9527)已拦截;其余预定义(0/1)与自定义角色均可更新显示名称
if input.RoleName != "" {
set["role_name"] = input.RoleName
}
update := bson.M{"$set": set}

View File

@@ -14,21 +14,24 @@ const (
PermRolePermission = "role:permission" // 角色权限管理
)
// PermissionItem 单条权限定义JSON 须用小写 key/name供前端展示与勾选
type PermissionItem struct {
Key string `json:"key"`
Name string `json:"name"`
}
// AllPermissions 所有可配置权限(用于角色权限管理页)
var AllPermissions = []struct {
Key string
Name string
}{
{PermSiteManage, "站点管理"},
{PermHomepageEdit, "首页编辑"},
{PermPageManage, "网页管理"},
{PermModuleUpload, "功能模块上传"},
{PermUserManage, "用户管理"},
{PermWorkspaceManage, "工作空间"},
{PermConversationManage, "对话管理"},
{PermSMSConfig, "短信配置"},
{PermPaymentConfig, "支付配置"},
{PermRolePermission, "角色权限管理"},
var AllPermissions = []PermissionItem{
{Key: PermSiteManage, Name: "站点管理"},
{Key: PermHomepageEdit, Name: "首页编辑"},
{Key: PermPageManage, Name: "网页管理"},
{Key: PermModuleUpload, Name: "功能模块上传"},
{Key: PermUserManage, Name: "用户管理"},
{Key: PermWorkspaceManage, Name: "工作空间"},
{Key: PermConversationManage, Name: "对话管理"},
{Key: PermSMSConfig, Name: "短信配置"},
{Key: PermPaymentConfig, Name: "支付配置"},
{Key: PermRolePermission, Name: "角色权限管理"},
}
// RolePermissionsDoc MongoDB 文档:角色 ID -> 名称与权限列表(支持自定义角色)

View File

@@ -40,6 +40,8 @@ type HomepageData struct {
BadgeText string `json:"badge_text"` // FREE ACCESS
Features []FeatureItem `json:"features"` // 星际导航等
FooterText string `json:"footer_text"` // © 2024 YUHENG ONE
// BodyBuilder 首页下方扩展区:与网页积木相同 JSON 字符串 {"version":1,"blocks":[...]},空则仅展示上方模板
BodyBuilder string `json:"body_builder,omitempty"`
}
type NavLink struct {

View File

@@ -2,14 +2,32 @@ import { apiBase } from '../config'
const prefix = () => (apiBase ? `${apiBase}/api` : '/api')
/** 与 /web/routes 返回的 site_id 一致,拉取单页时附带,避免多站点下错站 */
let cachedWebSiteId = ''
export function getCachedWebSiteId() {
return cachedWebSiteId
}
export async function fetchWebRoutes() {
const res = await fetch(`${prefix()}/web/routes`)
if (!res.ok) return { site_id: '', routes: [] }
return res.json()
let url = `${prefix()}/web/routes`
const sid = new URLSearchParams(window.location.search).get('site_id')
if (sid) url += `?site_id=${encodeURIComponent(sid)}`
const res = await fetch(url)
if (!res.ok) {
cachedWebSiteId = ''
return { site_id: '', routes: [] }
}
const data = await res.json()
cachedWebSiteId = data.site_id || ''
return data
}
export async function fetchWebPageByPath(path) {
const q = new URLSearchParams({ path: path || '/' })
const fromQs = new URLSearchParams(window.location.search).get('site_id')
const sid = cachedWebSiteId || fromQs
if (sid) q.set('site_id', sid)
const res = await fetch(`${prefix()}/web/page?${q}`)
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || '加载失败')
return res.json()

View File

@@ -48,6 +48,10 @@
</div>
</section>
<section v-if="bodyBuilderBlocks.length" class="home-body-builder">
<BlockRenderer :blocks="bodyBuilderBlocks" />
</section>
<footer>
<p>{{ data.footer_text || '© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE' }}</p>
<p class="beian">成都宇惠达智能科技有限公司 <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2025134957号-1</a></p>
@@ -58,6 +62,7 @@
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { apiBase } from '../config'
import BlockRenderer from '../components/blocks/BlockRenderer.vue'
const starsEl = ref(null)
let cometTimer = null
@@ -85,7 +90,8 @@ const defaultData = () => ({
{ title: '量子同步', desc: '跨维度数据同步技术,您的数据在多宇宙中保持一致' },
{ title: '星际防护', desc: '来自未来的安全加密协议,守护您的数字资产安全' }
],
footer_text: '© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE'
footer_text: '© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE',
body_builder: ''
})
const data = reactive(defaultData())
@@ -100,6 +106,18 @@ const descriptionHtml = computed(() => {
return s.replace(/\n/g, '<br>')
})
/** 首页扩展区:与后台「页面积木」相同 JSON */
const bodyBuilderBlocks = computed(() => {
const raw = data.body_builder
if (!raw || typeof raw !== 'string') return []
try {
const j = JSON.parse(raw)
return Array.isArray(j.blocks) ? j.blocks : []
} catch {
return []
}
})
const featureIconPaths = [
'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z',
'M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM19 18H6c-2.21 0-4-1.79-4-4s1.79-4 4-4h.71C7.37 7.69 9.48 6 12 6c3.04 0 5.5 2.46 5.5 5.5v.5H19c1.66 0 3 1.34 3 3s-1.34 3-3 3z',
@@ -182,6 +200,13 @@ onUnmounted(() => {
overflow-x: hidden;
position: relative;
}
.home-body-builder {
position: relative;
z-index: 10;
max-width: 960px;
margin: 0 auto;
padding: 40px 24px 20px;
}
</style>
<style>

View File

@@ -2,6 +2,7 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
appType: 'spa',
plugins: [vue()],
server: {
port: 3001,