Add AKSRT_User_Badges::auto_evaluate on login for get_type metrics, introduce aksrt_get_user_name (auth→wear medal→level→follow order), wire single author bar/card, and document transfer/withdraw default-closed semantics on capabilities page. audit errors=0.
252 lines
6.4 KiB
Vue
252 lines
6.4 KiB
Vue
<template>
|
||
<div class="aksrt-page-container">
|
||
<PageHeader
|
||
title="基本权限 / 能力矩阵"
|
||
desc="统一管理用户治理、投稿和评论的角色能力矩阵。"
|
||
>
|
||
<template #actions>
|
||
<n-space>
|
||
<n-button quaternary @click="resetDefaults">恢复默认</n-button>
|
||
</n-space>
|
||
</template>
|
||
</PageHeader>
|
||
|
||
<n-alert type="warning" :show-icon="true" style="margin: 12px 0 16px;">
|
||
转账/提现(balance_transfer、points_transfer、balance_withdraw)默认 <b>exclude all</b>,与 Zibll 一致:未在矩阵中为角色/等级/VIP/认证打开前,前台入口会隐藏或拒绝。
|
||
medal_manually_set 控制谁可授予徽章;管理员 manage_options 始终可通过。
|
||
</n-alert>
|
||
|
||
|
||
<n-spin :show="loading" style="width: 100%; min-height: 240px;">
|
||
<n-card class="aksrt-section-card" v-if="summary.cap_count">
|
||
<div class="aksrt-section-header">
|
||
<h3>矩阵概览</h3>
|
||
</div>
|
||
<n-space>
|
||
<n-tag round type="info">能力项:{{ summary.cap_count }}</n-tag>
|
||
<n-tag round type="info">分组:{{ summary.group_count }}</n-tag>
|
||
<n-tag round type="info">角色列:{{ summary.column_count }}</n-tag>
|
||
</n-space>
|
||
</n-card>
|
||
|
||
<n-card v-for="group in groups" :key="group.key" class="aksrt-section-card">
|
||
<template #header>
|
||
<div class="aksrt-cap-matrix__header">
|
||
<div>
|
||
<h3 class="aksrt-cap-matrix__title">{{ group.title }}</h3>
|
||
<p class="aksrt-cap-matrix__desc">共 {{ group.items.length }} 项能力</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<n-data-table
|
||
:columns="buildColumns(group)"
|
||
:data="group.items"
|
||
:bordered="true"
|
||
size="small"
|
||
:single-line="false"
|
||
:scroll-x="1200"
|
||
/>
|
||
</n-card>
|
||
|
||
<div class="aksrt-form-actions">
|
||
<n-button type="primary" :loading="saving" @click="handleSave">保存能力矩阵</n-button>
|
||
</div>
|
||
</n-spin>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { h, onMounted, reactive, ref } from 'vue'
|
||
import { NInputNumber, NSwitch, NTag } from 'naive-ui'
|
||
import { useMessage } from 'naive-ui'
|
||
import api from '@/api'
|
||
import PageHeader from '@/components/PageHeader.vue'
|
||
|
||
const message = useMessage()
|
||
const loading = ref(true)
|
||
const saving = ref(false)
|
||
const groups = ref([])
|
||
const columnsMeta = ref({})
|
||
const summary = reactive({
|
||
cap_count: 0,
|
||
group_count: 0,
|
||
column_count: 0,
|
||
})
|
||
const userCaps = reactive({})
|
||
const defaultsSnapshot = ref({})
|
||
|
||
onMounted(load)
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const res = await api.getCapabilities()
|
||
const data = res?.data || {}
|
||
|
||
columnsMeta.value = data.columns || {}
|
||
groups.value = data.groups || []
|
||
Object.assign(summary, data.summary || { cap_count: 0, group_count: 0, column_count: 0 })
|
||
|
||
const snapshot = {}
|
||
for (const group of groups.value) {
|
||
for (const item of group.items || []) {
|
||
snapshot[item.cap_id] = { ...(item.values || {}) }
|
||
}
|
||
}
|
||
|
||
replaceReactive(userCaps, snapshot)
|
||
defaultsSnapshot.value = JSON.parse(JSON.stringify(snapshot))
|
||
} catch (error) {
|
||
message.error(error?.message || '加载能力矩阵失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function replaceReactive(target, source) {
|
||
Object.keys(target).forEach((key) => delete target[key])
|
||
Object.entries(source).forEach(([key, value]) => {
|
||
target[key] = { ...value }
|
||
})
|
||
}
|
||
|
||
function buildColumns(group) {
|
||
const cols = [
|
||
{
|
||
title: '能力项',
|
||
key: 'label',
|
||
fixed: 'left',
|
||
width: 280,
|
||
render: (row) => h('div', { class: 'aksrt-cap-matrix__cap' }, [
|
||
h('strong', row.label),
|
||
h('code', row.cap_id),
|
||
]),
|
||
},
|
||
]
|
||
|
||
const visibleColumns = new Set()
|
||
for (const item of group.items || []) {
|
||
for (const col of item.visible_columns || []) {
|
||
visibleColumns.add(col)
|
||
}
|
||
}
|
||
|
||
for (const col of Object.keys(columnsMeta.value || {})) {
|
||
if (!visibleColumns.has(col)) continue
|
||
|
||
cols.push({
|
||
title: columnsMeta.value[col],
|
||
key: col,
|
||
width: col === 'level' || col === 'vip' ? 110 : 100,
|
||
render: (row) => renderCapCell(row, col),
|
||
})
|
||
}
|
||
|
||
return cols
|
||
}
|
||
|
||
function renderCapCell(row, col) {
|
||
const hidden = !(row.visible_columns || []).includes(col)
|
||
if (hidden) {
|
||
return h('span', { class: 'aksrt-cap-matrix__empty' }, '—')
|
||
}
|
||
|
||
if (!userCaps[row.cap_id]) {
|
||
userCaps[row.cap_id] = { ...(row.values || {}) }
|
||
}
|
||
|
||
if (col === 'level' || col === 'vip') {
|
||
return h(NInputNumber, {
|
||
value: userCaps[row.cap_id]?.[col] ?? -1,
|
||
min: -1,
|
||
max: col === 'level' ? 10 : 2,
|
||
step: 1,
|
||
size: 'small',
|
||
style: 'width: 90px',
|
||
onUpdateValue: (value) => updateCapValue(row.cap_id, col, value ?? -1),
|
||
})
|
||
}
|
||
|
||
return h(NSwitch, {
|
||
value: !!userCaps[row.cap_id]?.[col],
|
||
size: 'small',
|
||
onUpdateValue: (value) => updateCapValue(row.cap_id, col, value),
|
||
})
|
||
}
|
||
|
||
function updateCapValue(capId, col, value) {
|
||
if (!userCaps[capId]) {
|
||
userCaps[capId] = {}
|
||
}
|
||
userCaps[capId][col] = value
|
||
}
|
||
|
||
function resetDefaults() {
|
||
replaceReactive(userCaps, JSON.parse(JSON.stringify(defaultsSnapshot.value || {})))
|
||
message.info('已恢复默认矩阵')
|
||
}
|
||
|
||
function expandAll() {
|
||
message.info('当前矩阵为平铺展示,无需展开')
|
||
}
|
||
|
||
function collapseAll() {
|
||
message.info('当前矩阵为平铺展示,无需折叠')
|
||
}
|
||
|
||
async function handleSave() {
|
||
saving.value = true
|
||
try {
|
||
await api.saveCapabilities(JSON.parse(JSON.stringify(userCaps)))
|
||
defaultsSnapshot.value = JSON.parse(JSON.stringify(userCaps))
|
||
message.success('能力矩阵已保存')
|
||
} catch (error) {
|
||
message.error(error?.message || '保存能力矩阵失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.aksrt-cap-matrix__header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.aksrt-cap-matrix__title {
|
||
margin: 0;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.aksrt-cap-matrix__desc {
|
||
margin: 4px 0 0;
|
||
font-size: 12px;
|
||
color: #8a909c;
|
||
}
|
||
|
||
.aksrt-cap-matrix__cap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.aksrt-cap-matrix__cap code {
|
||
font-size: 11px;
|
||
color: #667085;
|
||
}
|
||
|
||
.aksrt-cap-matrix__empty {
|
||
color: #c0c4cc;
|
||
}
|
||
|
||
.aksrt-form-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
</style>
|