Rewrite /users/level to Zibll-like user_level_s/max/opt and user_integral_opt. Runtime grants XP with day_max, resolves level, hooks login/register/post/comment/like/favorite/follow. Profile signals read integral-backed levels. audit errors=0.
280 lines
9.4 KiB
Vue
280 lines
9.4 KiB
Vue
<template>
|
||
<div class="aksrt-page-container">
|
||
<PageHeader
|
||
title="用户等级"
|
||
desc="配置等级开关、最高等级、升级经验阈值与经验获取矩阵(对齐 Zibll 用户等级 / 经验值)。"
|
||
>
|
||
<template #actions>
|
||
<n-space>
|
||
<n-button quaternary @click="go('/users/credits')">用户积分</n-button>
|
||
<n-button quaternary @click="go('/users/badge')">用户徽章</n-button>
|
||
</n-space>
|
||
</template>
|
||
</PageHeader>
|
||
|
||
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
||
<n-card class="aksrt-section-card">
|
||
<div class="aksrt-section-header"><h3>当前摘要</h3></div>
|
||
<n-space>
|
||
<n-tag round :type="form.user_level_s ? 'success' : 'default'">
|
||
等级系统:{{ form.user_level_s ? '已启用' : '已关闭' }}
|
||
</n-tag>
|
||
<n-tag round type="info">最高等级:{{ form.user_level_max }}</n-tag>
|
||
<n-tag round type="info">日经验上限:{{ form.user_integral_opt.day_max }}</n-tag>
|
||
</n-space>
|
||
</n-card>
|
||
|
||
<n-card class="aksrt-section-card">
|
||
<div class="aksrt-section-header"><h3>等级显示</h3></div>
|
||
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
|
||
<n-form-item label="启用用户等级">
|
||
<n-switch v-model:value="form.user_level_s" />
|
||
</n-form-item>
|
||
<n-form-item v-if="form.user_level_s" label="公开页显示等级">
|
||
<n-switch v-model:value="form.user_level_public_display" />
|
||
</n-form-item>
|
||
<n-form-item v-if="form.user_level_s" label="等级前缀">
|
||
<n-input v-model:value="form.user_level_prefix" maxlength="12" style="width: 180px;" />
|
||
</n-form-item>
|
||
<n-form-item v-if="form.user_level_s" label="最高等级">
|
||
<n-input-number v-model:value="form.user_level_max" :min="3" :max="30" style="width: 180px;" @update:value="onMaxChange" />
|
||
</n-form-item>
|
||
<n-form-item v-if="form.user_level_s" label="升级通知">
|
||
<n-switch v-model:value="form.user_level_notify" />
|
||
</n-form-item>
|
||
</n-form>
|
||
</n-card>
|
||
|
||
<n-card v-if="form.user_level_s" class="aksrt-section-card">
|
||
<div class="aksrt-section-header"><h3>升级所需经验(累计)</h3></div>
|
||
<n-alert type="info" :show-icon="false" style="margin-bottom: 12px;">
|
||
Lv.1 通常为 0。数值为升到该级所需的累计经验。修改最高等级后会自动补齐/截断行。
|
||
</n-alert>
|
||
<n-data-table :columns="levelColumns" :data="levelRows" :bordered="false" size="small" />
|
||
</n-card>
|
||
|
||
<n-card v-if="form.user_level_s" class="aksrt-section-card">
|
||
<div class="aksrt-section-header"><h3>经验值得分</h3></div>
|
||
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
|
||
<n-form-item label="每日上限">
|
||
<n-input-number v-model:value="form.user_integral_opt.day_max" :min="0" :max="10000" style="width: 180px;" />
|
||
</n-form-item>
|
||
</n-form>
|
||
<n-divider />
|
||
<div v-for="group in integralGroups" :key="group" style="margin-bottom: 16px;">
|
||
<h4 style="margin: 0 0 8px;">{{ group }}</h4>
|
||
<n-grid cols="1 s:2 m:3" :x-gap="12" :y-gap="8" responsive="screen">
|
||
<n-grid-item v-for="row in INTEGRAL_CATALOG.filter((r) => r.group === group)" :key="row.key">
|
||
<div class="integral-field">
|
||
<div class="integral-field__label">{{ row.label }}</div>
|
||
<n-input-number v-model:value="form.user_integral_opt[row.key]" :min="0" :max="1000" style="width: 100%;" />
|
||
</div>
|
||
</n-grid-item>
|
||
</n-grid>
|
||
</div>
|
||
</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 { computed, h, onMounted, reactive, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { NInput, NInputNumber, useMessage } from 'naive-ui'
|
||
import api from '@/api'
|
||
import PageHeader from '@/components/PageHeader.vue'
|
||
import {
|
||
INTEGRAL_CATALOG,
|
||
defaultIntegralOpt,
|
||
defaultLevelOpt,
|
||
} from '@/schema/sections/users/level.js'
|
||
|
||
const router = useRouter()
|
||
const message = useMessage()
|
||
const loading = ref(true)
|
||
const saving = ref(false)
|
||
|
||
const form = reactive({
|
||
user_level_s: true,
|
||
user_level_public_display: true,
|
||
user_level_prefix: 'Lv.',
|
||
user_level_max: 10,
|
||
user_level_opt: defaultLevelOpt(10),
|
||
user_integral_opt: defaultIntegralOpt(),
|
||
user_level_notify: true,
|
||
})
|
||
|
||
const integralGroups = computed(() => [...new Set(INTEGRAL_CATALOG.map((r) => r.group))])
|
||
|
||
const levelRows = computed(() => {
|
||
const max = Math.max(3, Math.min(30, Number(form.user_level_max) || 10))
|
||
const rows = []
|
||
for (let i = 1; i <= max; i++) {
|
||
rows.push({
|
||
level: i,
|
||
name: form.user_level_opt['name_' + i] ?? form.user_level_prefix + i,
|
||
upgrade: Number(form.user_level_opt['upgrade_integral_' + i] ?? 0),
|
||
})
|
||
}
|
||
return rows
|
||
})
|
||
|
||
const levelColumns = [
|
||
{ title: '等级', key: 'level', width: 80 },
|
||
{
|
||
title: '名称',
|
||
key: 'name',
|
||
render: (row) =>
|
||
h(NInput, {
|
||
value: form.user_level_opt['name_' + row.level] ?? '',
|
||
size: 'small',
|
||
onUpdateValue: (v) => {
|
||
form.user_level_opt['name_' + row.level] = v
|
||
},
|
||
}),
|
||
},
|
||
{
|
||
title: '升级所需累计经验',
|
||
key: 'upgrade',
|
||
width: 200,
|
||
render: (row) =>
|
||
h(NInputNumber, {
|
||
value: Number(form.user_level_opt['upgrade_integral_' + row.level] ?? 0),
|
||
min: 0,
|
||
max: 9999999,
|
||
size: 'small',
|
||
style: 'width: 160px;',
|
||
onUpdateValue: (v) => {
|
||
form.user_level_opt['upgrade_integral_' + row.level] = Number(v || 0)
|
||
},
|
||
}),
|
||
},
|
||
]
|
||
|
||
function go(path) {
|
||
router.push(path)
|
||
}
|
||
|
||
function extractData(response) {
|
||
return response?.data ?? response ?? {}
|
||
}
|
||
|
||
function onMaxChange(val) {
|
||
const max = Math.max(3, Math.min(30, Number(val) || 10))
|
||
form.user_level_max = max
|
||
const next = { ...form.user_level_opt }
|
||
const filled = defaultLevelOpt(max)
|
||
for (let i = 1; i <= max; i++) {
|
||
if (next['upgrade_integral_' + i] === undefined) {
|
||
next['upgrade_integral_' + i] = filled['upgrade_integral_' + i]
|
||
}
|
||
if (next['name_' + i] === undefined) {
|
||
next['name_' + i] = form.user_level_prefix + i
|
||
}
|
||
}
|
||
// drop extras
|
||
for (const k of Object.keys(next)) {
|
||
const m = k.match(/_(\d+)$/)
|
||
if (m && Number(m[1]) > max) {
|
||
delete next[k]
|
||
}
|
||
}
|
||
form.user_level_opt = next
|
||
}
|
||
|
||
function normalizeIntegral(raw) {
|
||
const base = defaultIntegralOpt()
|
||
if (!raw || typeof raw !== 'object') {
|
||
return base
|
||
}
|
||
for (const row of INTEGRAL_CATALOG) {
|
||
if (raw[row.key] !== undefined) {
|
||
base[row.key] = Math.max(0, Number(raw[row.key]) || 0)
|
||
}
|
||
}
|
||
if (raw.day_max !== undefined) {
|
||
base.day_max = Math.max(0, Number(raw.day_max) || 0)
|
||
}
|
||
return base
|
||
}
|
||
|
||
function normalizeLevelOpt(raw, max) {
|
||
const base = defaultLevelOpt(max)
|
||
if (!raw || typeof raw !== 'object') {
|
||
return base
|
||
}
|
||
for (let i = 1; i <= max; i++) {
|
||
if (raw['upgrade_integral_' + i] !== undefined) {
|
||
base['upgrade_integral_' + i] = Math.max(0, Number(raw['upgrade_integral_' + i]) || 0)
|
||
}
|
||
if (raw['name_' + i] !== undefined) {
|
||
base['name_' + i] = String(raw['name_' + i] || '')
|
||
}
|
||
}
|
||
return base
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const schemaRes = await api.getSchemaPage('users', 'level')
|
||
const d = extractData(schemaRes)
|
||
const enabled = d.user_level_s !== undefined ? !!d.user_level_s : !!d.user_level_enabled
|
||
form.user_level_s = enabled
|
||
form.user_level_public_display = d.user_level_public_display !== false
|
||
form.user_level_prefix = d.user_level_prefix || 'Lv.'
|
||
form.user_level_max = Math.max(3, Math.min(30, Number(d.user_level_max || 10)))
|
||
form.user_level_notify = d.user_level_notify !== false
|
||
form.user_level_opt = normalizeLevelOpt(d.user_level_opt, form.user_level_max)
|
||
form.user_integral_opt = normalizeIntegral(d.user_integral_opt)
|
||
} catch (error) {
|
||
message.error(error.message || '加载用户等级配置失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleSave() {
|
||
saving.value = true
|
||
try {
|
||
const max = Math.max(3, Math.min(30, Number(form.user_level_max) || 10))
|
||
const payload = {
|
||
user_level_s: !!form.user_level_s,
|
||
user_level_enabled: !!form.user_level_s,
|
||
user_level_public_display: !!form.user_level_public_display,
|
||
user_level_prefix: form.user_level_prefix || 'Lv.',
|
||
user_level_max: max,
|
||
user_level_notify: !!form.user_level_notify,
|
||
user_level_source: 'integral',
|
||
user_level_opt: normalizeLevelOpt(form.user_level_opt, max),
|
||
user_integral_opt: normalizeIntegral(form.user_integral_opt),
|
||
}
|
||
await api.saveSchemaPage('users', 'level', payload)
|
||
Object.assign(form, {
|
||
user_level_max: max,
|
||
user_level_opt: payload.user_level_opt,
|
||
user_integral_opt: payload.user_integral_opt,
|
||
})
|
||
message.success('用户等级配置已保存')
|
||
} catch (error) {
|
||
message.error(error.message || '保存失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.integral-field__label {
|
||
font-size: 12px;
|
||
margin-bottom: 4px;
|
||
opacity: 0.8;
|
||
}
|
||
</style>
|