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:
1 parent
e80d8ea45d
commit
1b0eed6ef7
8 files changed
+658
-141
No files matched your search
@@ -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 2|30 3|100 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>
|
||||
Reference in new issue
Block a user