feat(users): level integral XP schema + AKSRT_User_Level runtime

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.
This commit is contained in:
kate522 committed 2026-07-17 01:05:39 +08:00
1 parent e80d8ea45d
commit 1b0eed6ef7
8 files changed
+658 -141

No files matched your search

+190 -98
View File
@@ -2,7 +2,7 @@
<div class="aksrt-page-container">
<PageHeader
title="用户等级"
desc="配置等级来源、展示前缀和阈值规则。前台公开主页会按照这里的规则输出等级身份芯片。"
desc="配置等级开关、最高等级、升级经验阈值与经验获取矩阵(对齐 Zibll 用户等级 / 经验值)。"
>
<template #actions>
<n-space>
@@ -14,80 +14,64 @@
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>当前联动摘要</h3>
</div>
<div class="aksrt-section-header"><h3>当前摘要</h3></div>
<n-space>
<n-tag round :type="form.user_level_enabled ? 'success' : 'default'">
等级系统:{{ form.user_level_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="creditsSummary.enabled ? 'info' : 'warning'">
积分系统:{{ creditsSummary.enabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag round type="info">
当前货币:{{ creditsSummary.currencyName }}
</n-tag>
<n-tag round :type="form.user_level_source === 'credits_balance' ? 'success' : 'warning'">
来源:{{ form.user_level_source === 'credits_balance' ? '积分余额' : '手动 Meta' }}
</n-tag>
<n-tag round type="info">
阈值条目:{{ thresholdCount }}
<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>
<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_enabled" />
<n-form-item label="启用用户等级">
<n-switch v-model:value="form.user_level_s" />
</n-form-item>
<n-form-item label="公开页显示等级" v-if="form.user_level_enabled">
<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 label="等级前缀" v-if="form.user_level_enabled">
<n-input v-model:value="form.user_level_prefix" maxlength="12" placeholder="Lv." />
<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 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-radio-group v-model:value="form.user_level_source" :disabled="!form.user_level_enabled">
<n-space size="large">
<n-radio value="credits_balance">积分余额</n-radio>
<n-radio value="manual_meta">手动 Meta</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="等级阈值">
<n-input
v-model:value="form.user_level_thresholds"
type="textarea"
:autosize="{ minRows: 5, maxRows: 10 }"
placeholder="1|0&#10;2|30&#10;3|100&#10;4|300"
:disabled="!form.user_level_enabled"
/>
<template #feedback>
<span class="aksrt-hint-text">每行一个等级,格式 `等级|所需积分`。手动 Meta 模式下仍建议保留完整字典,方便后续切换回自动计算。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
手动模式读取 `aksrt_user_level` user meta;自动模式则根据当前积分余额和上方阈值表实时计算等级,不需要单独写库。
<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">
@@ -98,38 +82,78 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
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_enabled: false,
user_level_s: true,
user_level_public_display: true,
user_level_source: 'credits_balance',
user_level_prefix: 'Lv.',
user_level_thresholds: '1|0\n2|30\n3|100\n4|300',
user_level_max: 10,
user_level_opt: defaultLevelOpt(10),
user_integral_opt: defaultIntegralOpt(),
user_level_notify: true,
})
const creditsSummary = reactive({
enabled: true,
currencyName: '积分',
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 thresholdCount = computed(() => {
return String(form.user_level_thresholds || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
.length
})
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)
@@ -139,25 +163,74 @@ 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, creditRes] = await Promise.all([
api.getSchemaPage('users', 'level'),
api.getCreditSettings(),
])
const schemaData = extractData(schemaRes)
const creditData = extractData(creditRes)
form.user_level_enabled = !!schemaData.user_level_enabled
form.user_level_public_display = schemaData.user_level_public_display !== false
form.user_level_source = schemaData.user_level_source || 'credits_balance'
form.user_level_prefix = schemaData.user_level_prefix || 'Lv.'
form.user_level_thresholds = schemaData.user_level_thresholds || '1|0\n2|30\n3|100\n4|300'
creditsSummary.enabled = creditData.enabled !== false
creditsSummary.currencyName = creditData.currency_name || '积分'
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 {
@@ -168,12 +241,23 @@ async function load() {
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('users', 'level', {
user_level_enabled: form.user_level_enabled,
user_level_public_display: form.user_level_public_display,
user_level_source: form.user_level_source,
user_level_prefix: form.user_level_prefix,
user_level_thresholds: form.user_level_thresholds,
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) {
@@ -185,3 +269,11 @@ async function handleSave() {
onMounted(load)
</script>
<style scoped>
.integral-field__label {
font-size: 12px;
margin-bottom: 4px;
opacity: 0.8;
}
</style>