Merge pull request 'Worktree identity zibll p1' (#181) from worktree-identity-zibll-p1 into main

Reviewed-on: CYL/wordpress-theme-aksrt#181
This commit is contained in:
2026-07-17 01:25:01 +08:00
26 changed files with 1902 additions and 458 deletions
+2 -2
View File
@@ -71,7 +71,7 @@ export const usersSection = {
component: 'UsersVerify',
status: 'available',
title: '身份认证',
desc: '身份认证字段与展示配置(审核队列在 用户 → 身份认证)。',
desc: '认证展示、申请门槛与审核策略(审核队列在 用户 → 身份认证)。',
canonicalDomain: 'users',
section: 'verify',
}),
@@ -95,7 +95,7 @@ export const usersSection = {
component: 'UsersBadge',
status: 'available',
title: '用户徽章',
desc: '徽章规则、授予条件和前台展示。',
desc: '勋章分类、自动授予条件(get_type)与前台佩戴展示。存储键 user_medal_*。',
canonicalDomain: 'users',
section: 'badge',
}),
@@ -11,6 +11,12 @@
</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">
+164 -72
View File
@@ -2,7 +2,7 @@
<div class="aksrt-page-container">
<PageHeader
title="用户徽章"
desc="维护徽章字典(含可选 icon_url)、展示数量。授予/收回在 WP 用户资料页;用户前台可佩戴徽章。"
desc="配置勋章分类、自动授予条件(get_type / get_val)与公开展示。路由 /users/badge,存储键 user_medal_*。"
>
<template #actions>
<n-space>
@@ -15,24 +15,18 @@
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>当前联动摘要</h3>
<h3>当前摘要</h3>
</div>
<n-space>
<n-tag round :type="form.user_badge_enabled ? 'success' : 'default'">
徽章系统:{{ form.user_badge_enabled ? '已启用' : '已关闭' }}
<n-tag round :type="form.user_medal_s ? 'success' : 'default'">
徽章系统:{{ form.user_medal_s ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.user_badge_public_display ? 'info' : 'default'">
公开展示:{{ form.user_badge_public_display ? '开启' : '关闭' }}
</n-tag>
<n-tag round type="info">
最多显示:{{ form.user_badge_limit }} 枚
</n-tag>
<n-tag round :type="notificationSummary.dynamicBadge ? 'success' : 'default'">
通知红点联动:{{ notificationSummary.dynamicBadge ? '启用' : '关闭' }}
</n-tag>
<n-tag round type="info">
字典条目:{{ badgeCount }}
<n-tag round :type="form.user_medal_public_display ? 'info' : 'default'">
公开展示:{{ form.user_medal_public_display ? '开启' : '关闭' }}
</n-tag>
<n-tag round type="info">最多显示:{{ form.user_medal_limit }} 枚</n-tag>
<n-tag round type="info">分类:{{ form.user_medal_args.length }}</n-tag>
<n-tag round type="info">条目:{{ itemCount }}</n-tag>
</n-space>
</n-card>
@@ -41,43 +35,77 @@
<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_badge_enabled" />
<n-form-item label="启用徽章/勋章系统">
<n-switch v-model:value="form.user_medal_s" />
</n-form-item>
<n-form-item label="公开页显示徽章" v-if="form.user_badge_enabled">
<n-switch v-model:value="form.user_badge_public_display" />
<n-form-item v-if="form.user_medal_s" label="公开页显示徽章">
<n-switch v-model:value="form.user_medal_public_display" />
</n-form-item>
<n-form-item label="最多显示数量" v-if="form.user_badge_enabled">
<n-input-number v-model:value="form.user_badge_limit" :min="1" :max="8" style="width: 180px;" />
<n-form-item v-if="form.user_medal_s" label="最多显示数量">
<n-input-number v-model:value="form.user_medal_limit" :min="1" :max="8" style="width: 180px;" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>徽章字典</h3>
<n-card v-if="form.user_medal_s" class="aksrt-section-card">
<div class="aksrt-section-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3>徽章明细(分类 → 条目)</h3>
<n-button size="small" @click="addCategory">添加分类</n-button>
</div>
<n-empty v-if="!form.user_medal_args.length" description="暂无分类,请添加" />
<div v-for="(cat, ci) in form.user_medal_args" :key="'cat-' + ci" class="medal-cat-block">
<n-card size="small" embedded>
<n-space vertical :size="12">
<n-space align="center">
<n-input v-model:value="cat.cat_name" placeholder="分类名称" style="width: 220px;" />
<n-button size="small" @click="addItem(ci)">添加条目</n-button>
<n-button size="small" quaternary type="error" @click="removeCategory(ci)">删除分类</n-button>
</n-space>
<n-empty v-if="!(cat.items && cat.items.length)" description="该分类暂无条目" />
<div v-for="(item, ii) in cat.items || []" :key="'item-' + ci + '-' + ii" class="medal-item-row">
<n-grid cols="1 s:2 m:3" :x-gap="12" :y-gap="8" responsive="screen">
<n-grid-item>
<n-input v-model:value="item.slug" placeholder="slug" />
</n-grid-item>
<n-grid-item>
<n-input v-model:value="item.name" placeholder="名称" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="item.get_type" :options="getTypeOptions" placeholder="获取条件" />
</n-grid-item>
<n-grid-item>
<n-input-number
v-model:value="item.get_val"
:min="0"
:disabled="item.get_type === 'manually_add'"
placeholder="阈值"
style="width: 100%;"
/>
</n-grid-item>
<n-grid-item>
<n-input v-model:value="item.icon" placeholder="图标 URL" />
</n-grid-item>
<n-grid-item>
<n-input v-model:value="item.desc" placeholder="描述" />
</n-grid-item>
</n-grid>
<div style="margin-top: 8px; text-align: right;">
<n-button size="tiny" quaternary type="error" @click="removeItem(ci, ii)">删除条目</n-button>
</div>
</div>
</n-space>
</n-card>
</div>
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="徽章列表">
<n-input
v-model:value="form.user_badge_dictionary"
type="textarea"
:autosize="{ minRows: 5, maxRows: 10 }"
placeholder="founder|创始成员|warning&#10;staff|站务团队|info|https://example.com/medal.png"
:disabled="!form.user_badge_enabled"
/>
<template #feedback>
<span class="aksrt-hint-text">每行:`slug|显示文案|色调|图标URL(可选)`。色调:neutral/info/success/warning/brand。授予在用户资料页;前台「我的徽章」可佩戴。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
字典定义合法徽章;`aksrt_user_badges` 存已授予 slug;`aksrt_wear_badge` 为当前佩戴。公开页/评论通过 identity chips 展示(有 icon_url 时为勋章图)。
前台已授予徽章保存在用户 meta aksrt_user_badges;佩戴为 aksrt_wear_badge(slug)。
字典来自 user_medal_args;运行时 AKSRT_User_Badges 读取 identity badge.dictionary 派生列表。
</n-alert>
</n-card>
@@ -94,31 +122,25 @@ import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import { MEDAL_GET_TYPES, defaultMedalArgs } from '@/schema/sections/users/badge.js'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const getTypeOptions = MEDAL_GET_TYPES
const form = reactive({
user_badge_enabled: false,
user_badge_public_display: true,
user_badge_limit: 3,
user_badge_dictionary: 'founder|创始成员|warning\nstaff|站务团队|info',
user_medal_s: false,
user_medal_public_display: true,
user_medal_limit: 3,
user_medal_args: defaultMedalArgs(),
})
const notificationSummary = reactive({
dynamicBadge: false,
})
const badgeCount = computed(() => {
return String(form.user_badge_dictionary || '')
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
.length
})
const itemCount = computed(() =>
form.user_medal_args.reduce((n, c) => n + (Array.isArray(c.items) ? c.items.length : 0), 0),
)
function go(path) {
router.push(path)
@@ -128,23 +150,70 @@ function extractData(response) {
return response?.data ?? response ?? {}
}
function emptyItem() {
return {
slug: '',
name: '',
desc: '',
icon: '',
get_type: 'manually_add',
get_val: 0,
}
}
function normalizeArgs(raw) {
if (!Array.isArray(raw)) {
return defaultMedalArgs()
}
return raw.map((cat) => ({
cat_name: String(cat?.cat_name || cat?.name || '未命名').trim() || '未命名',
items: Array.isArray(cat?.items)
? cat.items.map((it) => ({
slug: String(it?.slug || '').trim(),
name: String(it?.name || '').trim(),
desc: String(it?.desc || '').trim(),
icon: String(it?.icon || it?.icon_url || '').trim(),
get_type: String(it?.get_type || 'manually_add').trim() || 'manually_add',
get_val: Math.max(0, Number(it?.get_val ?? 0) || 0),
}))
: [],
}))
}
function addCategory() {
form.user_medal_args.push({ cat_name: '新分类', items: [emptyItem()] })
}
function removeCategory(ci) {
form.user_medal_args.splice(ci, 1)
}
function addItem(ci) {
if (!form.user_medal_args[ci].items) {
form.user_medal_args[ci].items = []
}
form.user_medal_args[ci].items.push(emptyItem())
}
function removeItem(ci, ii) {
form.user_medal_args[ci].items.splice(ii, 1)
}
async function load() {
loading.value = true
try {
const [schemaRes, notificationRes] = await Promise.all([
api.getSchemaPage('users', 'badge'),
api.getNotificationSettings(),
])
const schemaRes = await api.getSchemaPage('users', 'badge')
const schemaData = extractData(schemaRes)
const notificationData = extractData(notificationRes)
form.user_badge_enabled = !!schemaData.user_badge_enabled
form.user_badge_public_display = schemaData.user_badge_public_display !== false
form.user_badge_limit = Number(schemaData.user_badge_limit || 3)
form.user_badge_dictionary = schemaData.user_badge_dictionary || 'founder|创始成员|warning\nstaff|站务团队|info'
notificationSummary.dynamicBadge = !!notificationData.user_badge_enabled
form.user_medal_s = !!(schemaData.user_medal_s ?? schemaData.user_badge_enabled)
form.user_medal_public_display =
schemaData.user_medal_public_display !== undefined
? schemaData.user_medal_public_display !== false
: schemaData.user_badge_public_display !== false
form.user_medal_limit = Number(
schemaData.user_medal_limit ?? schemaData.user_badge_limit ?? 3,
)
form.user_medal_args = normalizeArgs(schemaData.user_medal_args ?? null)
} catch (error) {
message.error(error.message || '加载用户徽章配置失败')
} finally {
@@ -155,12 +224,23 @@ async function load() {
async function handleSave() {
saving.value = true
try {
const args = normalizeArgs(form.user_medal_args)
for (const cat of args) {
for (const it of cat.items) {
if (!it.slug || !it.name || !it.get_type) {
message.error('每个条目需要 slug、名称和获取条件')
saving.value = false
return
}
}
}
await api.saveSchemaPage('users', 'badge', {
user_badge_enabled: form.user_badge_enabled,
user_badge_public_display: form.user_badge_public_display,
user_badge_limit: Number(form.user_badge_limit || 3),
user_badge_dictionary: form.user_badge_dictionary,
user_medal_s: !!form.user_medal_s,
user_medal_public_display: !!form.user_medal_public_display,
user_medal_limit: Number(form.user_medal_limit || 3),
user_medal_args: args,
})
form.user_medal_args = args
message.success('用户徽章配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
@@ -171,3 +251,15 @@ async function handleSave() {
onMounted(load)
</script>
<style scoped>
.medal-cat-block {
margin-bottom: 16px;
}
.medal-item-row {
padding: 12px;
border: 1px solid var(--n-border-color, #efeff5);
border-radius: 8px;
margin-bottom: 8px;
}
</style>
+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>
@@ -54,6 +54,43 @@
<n-form-item label="启用人工审核台">
<n-switch v-model:value="form.user_verify_require_manual_review" :disabled="!form.user_verify_enabled" />
</n-form-item>
<n-form-item label="申请类型选项">
<n-input
v-model:value="form.user_verify_apply_option"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
:disabled="!form.user_verify_enabled"
placeholder="slug|显示名称,每行一条"
/>
</n-form-item>
<n-form-item label="允许用户前台申请">
<n-switch v-model:value="form.user_verify_allow_apply" :disabled="!form.user_verify_enabled" />
</n-form-item>
<n-form-item v-if="!form.user_verify_allow_apply" label="停用申请说明">
<n-input
v-model:value="form.user_verify_disable_desc"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
:disabled="!form.user_verify_enabled"
/>
</n-form-item>
<n-form-item v-if="form.user_verify_allow_apply" label="申请要求等级">
<n-input-number
v-model:value="form.user_verify_limit_level"
:min="0"
:max="99"
:disabled="!form.user_verify_enabled"
style="width: 180px;"
/>
</n-form-item>
<n-form-item v-if="form.user_verify_allow_apply" label="申请介绍">
<n-input
v-model:value="form.user_verify_desc"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
:disabled="!form.user_verify_enabled"
/>
</n-form-item>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
@@ -118,6 +155,11 @@ const form = reactive({
user_verify_badge_text: '已认证',
user_verify_accept_email_verified: true,
user_verify_require_manual_review: false,
user_verify_allow_apply: true,
user_verify_disable_desc: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
user_verify_limit_level: 2,
user_verify_desc: '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。',
user_verify_apply_option: 'real_name|实名认证\norganization|企业认证',
})
const authSummary = reactive({
@@ -276,6 +318,14 @@ async function load() {
form.user_verify_badge_text = schemaData.user_verify_badge_text || '已认证'
form.user_verify_accept_email_verified = schemaData.user_verify_accept_email_verified !== false
form.user_verify_require_manual_review = !!schemaData.user_verify_require_manual_review
form.user_verify_allow_apply = schemaData.user_verify_allow_apply !== false
form.user_verify_disable_desc = schemaData.user_verify_disable_desc
|| '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员'
form.user_verify_limit_level = Number(schemaData.user_verify_limit_level ?? 2)
form.user_verify_desc = schemaData.user_verify_desc
|| '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。'
form.user_verify_apply_option = schemaData.user_verify_apply_option
|| 'real_name|实名认证\norganization|企业认证'
authSummary.emailVerificationEnabled = !!authData.registration_email_verification_enabled
await loadRequests()
@@ -311,6 +361,11 @@ async function handleSave() {
user_verify_badge_text: form.user_verify_badge_text,
user_verify_accept_email_verified: form.user_verify_accept_email_verified,
user_verify_require_manual_review: form.user_verify_require_manual_review,
user_verify_allow_apply: form.user_verify_allow_apply,
user_verify_disable_desc: form.user_verify_disable_desc,
user_verify_limit_level: Number(form.user_verify_limit_level || 0),
user_verify_desc: form.user_verify_desc,
user_verify_apply_option: form.user_verify_apply_option,
})
message.success('身份认证配置已保存')
} catch (error) {
@@ -49,10 +49,11 @@ export const pageSchema = {
post_article_video_cover_scale: 56,
item_heading_bold: false,
post_button_toptext: '喜欢就支持一下吧',
post_rewards_enabled: false,
post_rewards_text: '',
post_rewards_image: '',
post_rewards_btn_text: '赞赏',
post_rewards_s: false,
post_rewards_text: '赞赏',
post_author_card_limit: 6,
post_author_card_orderby: 'views',
post_author_card_style: 'card',
article_maxheight_kg: false,
article_maxheight: 1000,
},
@@ -71,6 +72,17 @@ export const pageSchema = {
{ key: 'breadcrumbs_single_text', label: '末项显示「正文」', type: 'switch', default: true, dependsOn: { key: 'breadcrumbs_single_s', value: true }, help: '关闭后末项显示文章标题' },
{ key: 'post_prevnext_s', label: '上一篇/下一篇', type: 'switch', default: true },
{ key: 'post_authordesc_s', label: '作者简介', type: 'switch', default: true },
{ key: 'post_author_card_limit', label: '作者卡文章数', type: 'number', default: 6, min: 0, max: 12, dependsOn: { key: 'post_authordesc_s', value: true } },
{ key: 'post_author_card_orderby', label: '作者卡文章排序', type: 'select', default: 'views', options: [
{ label: '浏览量', value: 'views' },
{ label: '发布时间', value: 'date' },
{ label: '更新时间', value: 'modified' },
{ label: '评论数', value: 'comment_count' },
], dependsOn: { key: 'post_authordesc_s', value: true } },
{ key: 'post_author_card_style', label: '作者卡文章样式', type: 'radio', default: 'card', options: [
{ label: '卡片', value: 'card' },
{ label: '列表', value: 'list' },
], dependsOn: { key: 'post_authordesc_s', value: true } },
{ key: 'post_single_hide_time_s', label: '隐藏文章时间', type: 'switch', default: false },
],
},
@@ -137,10 +149,8 @@ export const pageSchema = {
{ key: 'post_copyright_enabled', label: '版权信息', type: 'switch', default: true },
{ key: 'post_copyright_text', label: '版权默认文案', type: 'textarea', default: '', help: '留空则自动生成"本文由 XX 原创发布"' },
{ key: 'post_button_toptext', label: '按钮上方文案', type: 'input', default: '喜欢就支持一下吧', help: '显示在点赞/打赏/分享按钮上方' },
{ key: 'post_rewards_enabled', label: '打赏', type: 'switch', default: false },
{ key: 'post_rewards_btn_text', label: '打赏按钮文案', type: 'input', default: '赞赏', dependsOn: { key: 'post_rewards_enabled', value: true } },
{ key: 'post_rewards_text', label: '打赏说明', type: 'input', default: '', dependsOn: { key: 'post_rewards_enabled', value: true } },
{ key: 'post_rewards_image', label: '打赏二维码 URL', type: 'input', default: '', dependsOn: { key: 'post_rewards_enabled', value: true } },
{ key: 'post_rewards_s', label: '用户打赏', type: 'switch', default: false, help: '开启后用户可配置微信/支付宝收款码;无码则不显示按钮。' },
{ key: 'post_rewards_text', label: '打赏按钮文案', type: 'input', default: '赞赏', dependsOn: { key: 'post_rewards_s', value: true } },
{ key: 'article_maxheight_kg', label: '正文限高', type: 'switch', default: false, help: '开启后长文默认折叠,可展开全文' },
{ key: 'article_maxheight', label: '限高像素', type: 'number', default: 1000, min: 200, max: 5000, dependsOn: { key: 'article_maxheight_kg', value: true } },
],
+93 -17
View File
@@ -1,13 +1,75 @@
/**
* User medal catalog (SPA route remains /users/badge).
* Storage keys: user_medal_* (Zibll user_medal_s / user_medal_args parity).
*/
export const MEDAL_GET_TYPES = [
{ label: '注册天数', value: 'registration_time' },
{ label: '累计签到天数', value: 'checkin_all_day' },
{ label: '发布文章数', value: 'new_post' },
{ label: '论坛发帖数', value: 'bbs_post' },
{ label: '评论数', value: 'comment' },
{ label: '粉丝数', value: 'followed' },
{ label: '文章获赞', value: 'like' },
{ label: '评论获赞', value: 'comment_like' },
{ label: '回复被采纳', value: 'bbs_adopt' },
{ label: '神评次数', value: 'bbs_comment_hot' },
{ label: '热帖次数', value: 'bbs_post_hot' },
{ label: '精华帖数', value: 'bbs_essence' },
{ label: '帖子评分累计', value: 'bbs_score' },
{ label: '内容阅读量', value: 'views' },
{ label: '创作分成累计(元)', value: 'pay_income' },
{ label: '推广佣金累计(元)', value: 'pay_rebate' },
{ label: '仅手动授予', value: 'manually_add' },
]
export const defaultMedalArgs = () => [
{
cat_name: '成长',
items: [
{
slug: 'rookie',
name: '初出茅庐',
desc: '注册时间超过30天',
icon: '',
get_type: 'registration_time',
get_val: 30,
},
{
slug: 'checkin-30',
name: '签到达人',
desc: '累计签到30天',
icon: '',
get_type: 'checkin_all_day',
get_val: 30,
},
],
},
{
cat_name: '限定',
items: [
{
slug: 'annual-star',
name: '年度人气达人',
desc: '活动限定徽章',
icon: '',
get_type: 'manually_add',
get_val: 0,
},
],
},
]
export const badgeSchema = {
key: 'users.badge',
title: '用户徽章',
icon: 'fas fa-award',
description: '配置徽章字典、公开展示数量和前台挂点,具体授予逻辑后续扩展。',
description: '配置勋章分类、自动授予条件与前台展示。路由仍为 /users/badge;存储键为 user_medal_*。',
defaults: {
user_badge_enabled: false,
user_badge_public_display: true,
user_badge_limit: 3,
user_badge_dictionary: 'founder|创始成员|warning\nstaff|站务团队|info',
user_medal_s: false,
user_medal_public_display: true,
user_medal_limit: 3,
user_medal_args: defaultMedalArgs(),
},
groups: [
{
@@ -15,23 +77,37 @@ export const badgeSchema = {
title: '徽章展示',
icon: 'fas fa-medal',
fields: [
{ key: 'user_badge_enabled', label: '启用徽章系统', type: 'switch', default: false },
{ key: 'user_badge_public_display', label: '公开页显示徽章', type: 'switch', default: true, dependsOn: { key: 'user_badge_enabled', value: true } },
{ key: 'user_badge_limit', label: '最多显示数量', type: 'number', default: 3, min: 1, max: 8, dependsOn: { key: 'user_badge_enabled', value: true } },
{ key: 'user_medal_s', label: '启用徽章/勋章系统', type: 'switch', default: false },
{
key: 'user_medal_public_display',
label: '公开页显示徽章',
type: 'switch',
default: true,
dependsOn: { key: 'user_medal_s', value: true },
},
{
key: 'user_medal_limit',
label: '最多显示数量',
type: 'number',
default: 3,
min: 1,
max: 8,
dependsOn: { key: 'user_medal_s', value: true },
},
],
},
{
key: 'dictionary',
title: '徽章字典',
icon: 'fas fa-bookmark',
key: 'catalog',
title: '徽章明细',
icon: 'fas fa-trophy',
fields: [
{
key: 'user_badge_dictionary',
label: '徽章列表',
type: 'textarea',
default: 'founder|创始成员|warning\nstaff|站务团队|info',
help: '每行一个徽章:slug|显示文案|色调(neutral/info/success/warning/brand)|图标URL(可选)。有 icon_url 时前台显示勋章图;用户 meta aksrt_wear_badge 指定当前佩戴一枚。',
dependsOn: { key: 'user_badge_enabled', value: true },
key: 'user_medal_args',
label: '分类与条目',
type: 'json',
default: defaultMedalArgs(),
help: '由自定义页编辑:分类 → 条目(slug/名称/描述/图标/get_type/get_val)。仅手动授予用 manually_add。',
dependsOn: { key: 'user_medal_s', value: true },
},
],
},
+62 -13
View File
@@ -1,33 +1,82 @@
/**
* User level + integral XP (Zibll user_level_s / user_level_opt / user_integral_opt).
*/
export const INTEGRAL_CATALOG = [
{ key: 'sign_up', label: '首次注册', default: 20, group: '用户' },
{ key: 'sign_in', label: '每日登录', default: 5, group: '用户' },
{ key: 'followed', label: '被关注', default: 5, group: '用户' },
{ key: 'post_new', label: '发布文章', default: 5, group: '文章' },
{ key: 'post_like', label: '文章获赞', default: 1, group: '文章' },
{ key: 'post_favorite', label: '文章被收藏', default: 2, group: '文章' },
{ key: 'comment_new', label: '发表评论', default: 2, group: '文章' },
{ key: 'comment_like', label: '评论获赞', default: 1, group: '文章' },
{ key: 'bbs_posts_new', label: '发布帖子', default: 3, group: '论坛' },
{ key: 'bbs_score_extra', label: '帖子被加分', default: 1, group: '论坛' },
{ key: 'bbs_essence', label: '帖子评为精华', default: 2, group: '论坛' },
{ key: 'bbs_posts_hot', label: '帖子成为热门', default: 2, group: '论坛' },
{ key: 'bbs_plate_new', label: '创建版块', default: 2, group: '论坛' },
{ key: 'bbs_plate_hot', label: '版块成为热门', default: 2, group: '论坛' },
{ key: 'bbs_adopt', label: '回答被采纳', default: 2, group: '论坛' },
{ key: 'bbs_comment_hot', label: '评论成为神评', default: 2, group: '论坛' },
]
export function defaultLevelOpt(max = 10) {
const opt = {}
for (let i = 1; i <= max; i++) {
// Rough Zibll-like curve: LV1=0, then grows
opt['upgrade_integral_' + i] = i === 1 ? 0 : Math.round(20 * Math.pow(i - 1, 1.6))
opt['name_' + i] = 'Lv.' + i
}
return opt
}
export function defaultIntegralOpt() {
const opt = { day_max: 100 }
for (const row of INTEGRAL_CATALOG) {
opt[row.key] = row.default
}
return opt
}
export const levelSchema = {
key: 'users.level',
title: '用户等级',
icon: 'fas fa-level-up-alt',
description: '配置等级展示前缀和等级阈值,当前可基于积分余额自动计算。',
description: '等级与经验值:最高等级、升级阈值、经验获取矩阵(对齐 Zibll user_level / user_integral)。',
defaults: {
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,
},
groups: [
{
key: 'display',
title: '等级显示',
icon: 'fas fa-signal',
fields: [
{ key: 'user_level_enabled', label: '启用等级系统', type: 'switch', default: false },
{ key: 'user_level_public_display', label: '公开页显示等级', type: 'switch', default: true, dependsOn: { key: 'user_level_enabled', value: true } },
{ key: 'user_level_prefix', label: '等级前缀', type: 'input', default: 'Lv.', dependsOn: { key: 'user_level_enabled', value: true } },
{ key: 'user_level_s', label: '启用用户等级', type: 'switch', default: true },
{ key: 'user_level_public_display', label: '公开页显示等级', type: 'switch', default: true, dependsOn: { key: 'user_level_s', value: true } },
{ key: 'user_level_prefix', label: '等级前缀', type: 'input', default: 'Lv.', dependsOn: { key: 'user_level_s', value: true } },
{ key: 'user_level_max', label: '最高等级', type: 'number', default: 10, min: 3, max: 30, dependsOn: { key: 'user_level_s', value: true } },
{ key: 'user_level_notify', label: '升级通知', type: 'switch', default: true, dependsOn: { key: 'user_level_s', value: true } },
],
},
{
key: 'rules',
title: '等级规则',
icon: 'fas fa-list-ol',
key: 'level_opt',
title: '等级升级阈值',
fields: [
{ key: 'user_level_source', label: '等级来源', type: 'radio', default: 'credits_balance', options: [{ label: '积分余额', value: 'credits_balance' }, { label: '手动 Meta', value: 'manual_meta' }], dependsOn: { key: 'user_level_enabled', value: true } },
{ key: 'user_level_thresholds', label: '等级阈值', type: 'textarea', default: '1|0\n2|30\n3|100\n4|300', help: '每行一个等级,格式:等级|所需积分,例如 3|100', dependsOn: { key: 'user_level_enabled', value: true } },
{ key: 'user_level_opt', label: '升级经验配置', type: 'json', default: defaultLevelOpt(10), help: 'upgrade_integral_N 为升到 N 级所需累计经验' },
],
},
{
key: 'integral',
title: '经验值得分',
fields: [
{ key: 'user_integral_opt', label: '经验值获取', type: 'json', default: defaultIntegralOpt(), help: 'day_max + 各行为得分;对应功能未开则将分值设 0' },
],
},
],
@@ -2,13 +2,20 @@ export const verifySchema = {
key: 'users.verify',
title: '身份认证',
icon: 'fas fa-id-card',
description: '配置公开认证徽标、审核策略,以及是否复用现有邮箱验证状态。',
description: '配置公开认证徽标、申请门槛与审核策略(审核队列在 用户 → 身份认证 原生菜单)。',
defaults: {
user_verify_enabled: true,
user_verify_public_display: true,
user_verify_badge_text: '已认证',
user_verify_accept_email_verified: true,
user_verify_require_manual_review: false,
user_verify_allow_apply: true,
user_verify_disable_desc:
'由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
user_verify_limit_level: 2,
user_verify_desc:
'认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。',
user_verify_apply_option: 'real_name|实名认证\norganization|企业认证',
},
groups: [
{
@@ -16,9 +23,21 @@ export const verifySchema = {
title: '公开展示',
icon: 'fas fa-eye',
fields: [
{ key: 'user_verify_enabled', label: '启用身份认证结构', type: 'switch', default: true },
{ key: 'user_verify_public_display', label: '公开页显示认证徽标', type: 'switch', default: true, dependsOn: { key: 'user_verify_enabled', value: true } },
{ key: 'user_verify_badge_text', label: '认证徽标文案', type: 'input', default: '已认证', dependsOn: { key: 'user_verify_public_display', value: true } },
{ key: 'user_verify_enabled', label: '启用身份认证', type: 'switch', default: true },
{
key: 'user_verify_public_display',
label: '公开页显示认证徽标',
type: 'switch',
default: true,
dependsOn: { key: 'user_verify_enabled', value: true },
},
{
key: 'user_verify_badge_text',
label: '认证徽标文案',
type: 'input',
default: '已认证',
dependsOn: { key: 'user_verify_public_display', value: true },
},
],
},
{
@@ -26,8 +45,66 @@ export const verifySchema = {
title: '审核流程',
icon: 'fas fa-tasks',
fields: [
{ key: 'user_verify_accept_email_verified', label: '邮箱验证通过视为已认证', type: 'switch', default: true, dependsOn: { key: 'user_verify_enabled', value: true } },
{ key: 'user_verify_require_manual_review', label: '保留人工审核位', type: 'switch', default: false, dependsOn: { key: 'user_verify_enabled', value: true } },
{
key: 'user_verify_accept_email_verified',
label: '邮箱验证通过视为已认证',
type: 'switch',
default: true,
dependsOn: { key: 'user_verify_enabled', value: true },
},
{
key: 'user_verify_require_manual_review',
label: '保留人工审核位',
type: 'switch',
default: false,
dependsOn: { key: 'user_verify_enabled', value: true },
},
{
key: 'user_verify_apply_option',
label: '申请类型选项',
type: 'textarea',
default: 'real_name|实名认证\norganization|企业认证',
help: '每行:slug|显示名称',
dependsOn: { key: 'user_verify_enabled', value: true },
},
],
},
{
key: 'apply',
title: '申请设置',
icon: 'fas fa-file-signature',
fields: [
{
key: 'user_verify_allow_apply',
label: '允许用户前台申请',
type: 'switch',
default: true,
dependsOn: { key: 'user_verify_enabled', value: true },
},
{
key: 'user_verify_disable_desc',
label: '停用申请说明',
type: 'textarea',
default: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
dependsOn: { key: 'user_verify_allow_apply', value: false },
},
{
key: 'user_verify_limit_level',
label: '申请要求等级',
type: 'number',
default: 2,
min: 0,
max: 99,
help: '0 表示不限制;依赖用户等级功能',
dependsOn: { key: 'user_verify_allow_apply', value: true },
},
{
key: 'user_verify_desc',
label: '申请介绍',
type: 'textarea',
default: '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。',
dependsOn: { key: 'user_verify_allow_apply', value: true },
},
],
},
],
+28 -74
View File
@@ -1,8 +1,5 @@
/**
* Reward (打赏) — dedicated module.
*
* Handles the reward modal: amount selection, custom amount input,
* QR code display, and open/close interaction.
* Reward (打赏) — per-user QR modal openers.
*
* @package AKSRT
*/
@@ -10,95 +7,52 @@
'use strict';
function initReward() {
var btn = document.getElementById('action-reward');
if (!btn) return;
function closeAll() {
document.querySelectorAll('.reward-modal.is-open').forEach(function (m) {
m.classList.remove('is-open');
m.setAttribute('aria-hidden', 'true');
});
document.body.style.overflow = '';
}
var modal = document.getElementById('reward-modal');
if (!modal) return;
var closeBtn = modal.querySelector('.reward-modal__close');
var overlay = modal.querySelector('.reward-modal__overlay');
var amountBtns = modal.querySelectorAll('.reward-amounts__btn');
var customWrap = modal.querySelector('.reward-custom');
var customInput = modal ? modal.querySelector('.reward-custom__input') : null;
var customConfirm = modal ? modal.querySelector('.reward-custom__confirm') : null;
var selectedAmount = 0;
/* ---- open / close ---- */
function openReward() {
function openForUser(userId) {
var id = String(userId || '');
var modal = id
? document.getElementById('reward-modal-' + id)
: document.querySelector('.reward-modal');
if (!modal) return;
closeAll();
modal.classList.add('is-open');
modal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
}
function closeReward() {
modal.classList.remove('is-open');
document.body.style.overflow = '';
// reset selection
selectedAmount = 0;
amountBtns.forEach(function (b) { b.classList.remove('is-active'); });
if (customInput) customInput.value = '';
}
btn.addEventListener('click', openReward);
document.querySelectorAll('[data-aksrt-reward-open]').forEach(function (el) {
if (el === btn) return;
el.addEventListener('click', function (e) {
e.preventDefault();
openReward();
openForUser(el.getAttribute('data-user-id') || '');
});
});
document.querySelectorAll('[data-aksrt-reward-close]').forEach(function (el) {
el.addEventListener('click', function (e) {
e.preventDefault();
closeAll();
});
});
if (closeBtn) closeBtn.addEventListener('click', closeReward);
if (overlay) overlay.addEventListener('click', closeReward);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && modal.classList.contains('is-open')) closeReward();
if (e.key === 'Escape') closeAll();
});
/* ---- amount selection ---- */
amountBtns.forEach(function (b) {
b.addEventListener('click', function () {
amountBtns.forEach(function (x) { x.classList.remove('is-active'); });
b.classList.add('is-active');
selectedAmount = parseInt(b.dataset.amount || '0', 10);
// clear custom input when preset is chosen
if (customInput) customInput.value = '';
});
});
/* ---- custom amount ---- */
if (customInput) {
customInput.addEventListener('focus', function () {
amountBtns.forEach(function (x) { x.classList.remove('is-active'); });
selectedAmount = 0;
});
customInput.addEventListener('input', function () {
// strip non-digits
customInput.value = customInput.value.replace(/[^\d]/g, '');
});
}
if (customConfirm) {
customConfirm.addEventListener('click', function () {
var val = customInput ? parseInt(customInput.value, 10) : 0;
if (val > 0) {
selectedAmount = val;
amountBtns.forEach(function (x) { x.classList.remove('is-active'); });
}
});
}
}
/* ---- register with AksrtFrontend lifecycle ---- */
if (window.AksrtFrontend && typeof window.AksrtFrontend.register === 'function') {
window.AksrtFrontend.register('aksrt-reward', function () {
initReward();
});
} else if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initReward);
} else {
// fallback: run on DOMContentLoaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initReward);
} else {
initReward();
}
initReward();
}
})();
+3
View File
@@ -370,6 +370,9 @@ if (class_exists('AKSRT_User_Badges')) {
if (class_exists('AKSRT_User_Badges_Admin')) {
AKSRT_User_Badges_Admin::get_instance()->init();
}
if (class_exists('AKSRT_User_Level')) {
AKSRT_User_Level::get_instance()->init();
}
// WordPress 原生侧栏:支付/订单运营中心(zibll zibpay 对齐)
if (is_admin() && class_exists('AKSRT_Admin_Pay_Menus')) {
+2
View File
@@ -45,6 +45,7 @@ require_once AKSRT_DIR . '/inc/helpers/aksrt-coupon-order.php';
require_once AKSRT_DIR . '/inc/helpers/aksrt-credits.php';
require_once AKSRT_DIR . '/inc/helpers/aksrt-null.php';
require_once AKSRT_DIR . '/inc/helpers/aksrt-identity.php';
require_once AKSRT_DIR . '/inc/helpers/aksrt-rewards.php';
require_once AKSRT_DIR . '/inc/helpers/menu-decoration.php';
require_once AKSRT_DIR . '/inc/helpers/class-aksrt-nav-walker.php';
require_once AKSRT_DIR . '/inc/helpers/aksrt-transactions.php';
@@ -221,6 +222,7 @@ require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-credits-admin.php';
require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-balance-admin.php';
require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-badges.php';
require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-badges-admin.php';
require_once AKSRT_DIR . '/inc/classes/class-aksrt-user-level.php';
// 加载数据表迁移器(CMS / 积分 / 下载 token)
require_once AKSRT_DIR . '/inc/classes/db-traits/trait-aksrt-db-migrator-part-2.php';
@@ -75,6 +75,17 @@ class AKSRT_Admin_Schema_Post_List {
['key' => 'breadcrumbs_single_text', 'label' => '末项显示「正文」', 'type' => 'switch', 'default' => true, 'depends_on' => 'breadcrumbs_single_s', 'description' => '关闭后末项显示文章标题'],
['key' => 'post_prevnext_s', 'label' => '上一篇/下一篇', 'type' => 'switch', 'default' => true],
['key' => 'post_authordesc_s', 'label' => '作者简介', 'type' => 'switch', 'default' => true],
['key' => 'post_author_card_limit', 'label' => '作者卡文章数', 'type' => 'number', 'default' => 6, 'min' => 0, 'max' => 12],
['key' => 'post_author_card_orderby', 'label' => '作者卡文章排序', 'type' => 'select', 'default' => 'views', 'options' => [
['label' => '浏览量', 'value' => 'views'],
['label' => '发布时间', 'value' => 'date'],
['label' => '更新时间', 'value' => 'modified'],
['label' => '评论数', 'value' => 'comment_count'],
]],
['key' => 'post_author_card_style', 'label' => '作者卡文章样式', 'type' => 'radio', 'default' => 'card', 'options' => [
['label' => '卡片', 'value' => 'card'],
['label' => '列表', 'value' => 'list'],
]],
['key' => 'post_single_hide_time_s', 'label' => '隐藏文章时间', 'type' => 'switch', 'default' => false],
]],
['key' => 'cover', 'title' => '封面展示', 'fields' => [
@@ -127,8 +138,8 @@ class AKSRT_Admin_Schema_Post_List {
['key' => 'post_copyright_enabled', 'label' => '版权信息', 'type' => 'switch', 'default' => true],
['key' => 'post_copyright_text', 'label' => '版权默认文案', 'type' => 'textarea', 'default' => '', 'description' => '留空则自动生成"本文由 XX 原创发布"'],
['key' => 'post_wechat_share', 'label' => '微信分享', 'type' => 'switch', 'default' => false],
['key' => 'post_rewards_enabled', 'label' => '打赏功能', 'type' => 'switch', 'default' => false],
['key' => 'post_rewards_text', 'label' => '打赏文案', 'type' => 'input', 'default' => ''],
['key' => 'post_rewards_s', 'label' => '用户打赏', 'type' => 'switch', 'default' => false],
['key' => 'post_rewards_text', 'label' => '打赏按钮文案', 'type' => 'input', 'default' => '赞赏'],
['key' => 'post_button_toptext', 'label' => '按钮上方文案', 'type' => 'input', 'default' => '喜欢就支持一下吧'],
]],
['key' => 'content', 'title' => '内容处理', 'fields' => [
@@ -580,7 +580,7 @@ class AKSRT_Admin_Schema_Users {
'verify' => [
'key' => 'verify',
'title' => '身份认证',
'version' => '1.0.0',
'version' => '1.1.0',
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'display', 'title' => '公开展示', 'fields' => [
@@ -591,69 +591,95 @@ class AKSRT_Admin_Schema_Users {
['key' => 'workflow', 'title' => '审核流程', 'fields' => [
['key' => 'user_verify_accept_email_verified', 'label' => '邮箱验证通过视为已认证', 'type' => 'switch', 'default' => true],
['key' => 'user_verify_require_manual_review', 'label' => '保留人工审核位', 'type' => 'switch', 'default' => false],
['key' => 'user_verify_apply_option', 'label' => '申请选项', 'type' => 'textarea', 'default' => "real_name|实名认证\norganization|企业认证"],
['key' => 'user_verify_apply_option', 'label' => '申请类型选项', 'type' => 'textarea', 'default' => "real_name|实名认证\norganization|企业认证", 'desc' => '每行:slug|显示名称'],
]],
['key' => 'restrict', 'title' => '限制设置', 'fields' => [
['key' => 'user_verify_disable_desc', 'label' => '未认证限制说明', 'type' => 'textarea', 'default' => ''],
['key' => 'user_verify_limit_level', 'label' => '限制等级', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100],
['key' => 'user_verify_desc', 'label' => '认证说明', 'type' => 'textarea', 'default' => ''],
['key' => 'apply', 'title' => '申请设置', 'fields' => [
['key' => 'user_verify_allow_apply', 'label' => '允许用户前台申请', 'type' => 'switch', 'default' => true],
['key' => 'user_verify_disable_desc', 'label' => '停用申请说明', 'type' => 'textarea', 'default' => "由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员"],
['key' => 'user_verify_limit_level', 'label' => '申请要求等级', 'type' => 'number', 'default' => 2, 'min' => 0, 'max' => 99, 'desc' => '0 表示不限制;依赖用户等级'],
['key' => 'user_verify_desc', 'label' => '申请介绍', 'type' => 'textarea', 'default' => '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。'],
]],
],
],
'level' => [
'key' => 'level',
'title' => '用户等级',
'version' => '1.0.0',
'version' => '2.0.0',
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'display', 'title' => '等级显示', 'fields' => [
['key' => 'user_level_enabled', 'label' => '启用等级系统', 'type' => 'switch', 'default' => false],
['key' => 'user_level_s', 'label' => '启用用户等级', 'type' => 'switch', 'default' => true],
['key' => 'user_level_enabled', 'label' => '启用等级系统(别名)', 'type' => 'switch', 'default' => true, 'help' => '与 user_level_s 同步写入'],
['key' => 'user_level_public_display', 'label' => '公开页显示等级', 'type' => 'switch', 'default' => true],
['key' => 'user_level_prefix', 'label' => '等级前缀', 'type' => 'input', 'default' => 'Lv.'],
['key' => 'user_level_color', 'label' => '等级颜色', 'type' => 'color', 'default' => '#f04494'],
['key' => 'user_level_icon', 'label' => '等级图标', 'type' => 'icon', 'default' => 'fas fa-star'],
]],
['key' => 'rules', 'title' => '等级规则', 'fields' => [
['key' => 'user_level_source', 'label' => '等级来源', 'type' => 'radio', 'default' => 'credits_balance'],
['key' => 'user_level_thresholds', 'label' => '等级阈值', 'type' => 'textarea', 'default' => "1|0\n2|30\n3|100\n4|300"],
['key' => 'user_level_max', 'label' => '最高等级', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 99],
['key' => 'user_level_decay', 'label' => '等级衰减', 'type' => 'switch', 'default' => false, 'help' => '长期不活跃自动降级'],
['key' => 'user_level_decay_days', 'label' => '衰减周期(天)', 'type' => 'number', 'default' => 90, 'min' => 7, 'max' => 365],
]],
['key' => 'privilege', 'title' => '等级特权', 'fields' => [
['key' => 'user_level_download_limit', 'label' => '下载限制加成', 'type' => 'switch', 'default' => false, 'help' => '高等级用户每日下载次数增加'],
['key' => 'user_level_upload_limit', 'label' => '上传限制加成', 'type' => 'switch', 'default' => false, 'help' => '高等级用户可上传更大文件'],
['key' => 'user_level_comment_cooldown', 'label' => '评论冷却缩短', 'type' => 'switch', 'default' => false, 'help' => '高等级用户评论间隔更短'],
['key' => 'user_level_custom_title', 'label' => '自定义头衔', 'type' => 'switch', 'default' => false, 'help' => '允许高等级用户自定义头衔'],
]],
['key' => 'notify', 'title' => '升级通知', 'fields' => [
['key' => 'user_level_max', 'label' => '最高等级', 'type' => 'number', 'default' => 10, 'min' => 3, 'max' => 30],
['key' => 'user_level_notify', 'label' => '升级通知', 'type' => 'switch', 'default' => true],
['key' => 'user_level_notify_style', 'label' => '通知方式', 'type' => 'select', 'default' => 'toast', 'options' => [
['label' => '弹窗提示', 'value' => 'toast'],
['label' => '站内信', 'value' => 'message'],
['label' => '两者都有', 'value' => 'both'],
['key' => 'user_level_source', 'label' => '等级来源', 'type' => 'radio', 'default' => 'integral', 'options' => [
['label' => '经验值', 'value' => 'integral'],
['label' => '手动 Meta', 'value' => 'manual_meta'],
]],
]],
['key' => 'level_opt', 'title' => '升级阈值', 'fields' => [
['key' => 'user_level_opt', 'label' => '等级参数', 'type' => 'json', 'default' => [], 'help' => 'upgrade_integral_N / name_N'],
]],
['key' => 'integral', 'title' => '经验值得分', 'fields' => [
['key' => 'user_integral_opt', 'label' => '经验值配置', 'type' => 'json', 'default' => [], 'help' => 'day_max + 行为得分键'],
]],
],
],
'badge' => [
'key' => 'badge',
'title' => '用户徽章',
'version' => '1.0.0',
'version' => '2.0.0',
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'display', 'title' => '徽章展示', 'fields' => [
['key' => 'user_badge_enabled', 'label' => '启用徽章系统', 'type' => 'switch', 'default' => false],
['key' => 'user_badge_public_display', 'label' => '公开页显示徽章', 'type' => 'switch', 'default' => true],
['key' => 'user_badge_limit', 'label' => '最多显示数量', 'type' => 'number', 'default' => 3, 'min' => 1, 'max' => 8],
['key' => 'user_medal_s', 'label' => '启用徽章/勋章系统', 'type' => 'switch', 'default' => false],
['key' => 'user_medal_public_display', 'label' => '公开页显示徽章', 'type' => 'switch', 'default' => true],
['key' => 'user_medal_limit', 'label' => '最多显示数量', 'type' => 'number', 'default' => 3, 'min' => 1, 'max' => 8],
]],
['key' => 'dictionary', 'title' => '徽章字典', 'fields' => [
['key' => 'catalog', 'title' => '徽章明细', 'fields' => [
[
'key' => 'user_badge_dictionary',
'label' => '徽章列表',
'type' => 'textarea',
'default' => "founder|创始成员|warning\nstaff|站务团队|info",
'help' => '每行:slug|显示文案|色调(neutral/info/success/warning/brand)|图标URL(可选)。配置 icon_url 后前台显示勋章图;用户 meta aksrt_wear_badge 指定当前佩戴。',
'key' => 'user_medal_args',
'label' => '分类与条目',
'type' => 'json',
'default' => [
[
'cat_name' => '成长',
'items' => [
[
'slug' => 'rookie',
'name' => '初出茅庐',
'desc' => '注册时间超过30天',
'icon' => '',
'get_type' => 'registration_time',
'get_val' => 30,
],
[
'slug' => 'checkin-30',
'name' => '签到达人',
'desc' => '累计签到30天',
'icon' => '',
'get_type' => 'checkin_all_day',
'get_val' => 30,
],
],
],
[
'cat_name' => '限定',
'items' => [
[
'slug' => 'annual-star',
'name' => '年度人气达人',
'desc' => '活动限定徽章',
'icon' => '',
'get_type' => 'manually_add',
'get_val' => 0,
],
],
],
],
'help' => '结构化目录:分类 → 条目(slug/name/desc/icon/get_type/get_val)。由 SPA UsersBadge 编辑。',
],
]],
],
+18 -6
View File
@@ -180,9 +180,12 @@ class AKSRT_Points_Free {
* @return list<array{key:string,title:string,desc:string,points:int,enabled:bool}>
*/
public static function task_list_for_user(int $user_id = 0): array {
unset($user_id); // reserved for per-user progress later
$cfg = self::settings();
$list = [];
$login_done = false;
if ($user_id > 0) {
$login_done = (string) get_user_meta($user_id, self::META_LOGIN, true) === self::today_key();
}
foreach (self::catalog() as $key => $meta) {
$pts = (int) ($cfg['tasks'][$key] ?? 0);
// Hide sign_up from daily task list (one-time); still grant on register.
@@ -192,12 +195,21 @@ class AKSRT_Points_Free {
if ($pts <= 0) {
continue;
}
$status = 'todo';
if (in_array($key, ['login', 'sign_in'], true) && $login_done) {
$status = 'done_today';
} elseif (in_array($key, ['post_like', 'post_favorite', 'followed', 'comment_like', 'bbs_essence', 'bbs_adopt', 'bbs_posts_hot', 'bbs_comment_hot', 'bbs_score_extra'], true)) {
$status = 'passive';
}
$list[] = [
'key' => $key,
'title' => $meta[0],
'desc' => $meta[1],
'points' => $pts,
'enabled' => true,
'key' => $key,
'title' => $meta[0],
'desc' => $meta[1],
'points' => $pts,
'enabled' => true,
'status' => $status,
'progress' => null,
'target' => null,
];
}
return $list;
+132
View File
@@ -404,4 +404,136 @@ class AKSRT_User_Badges {
'message' => __('已收回徽章', 'aksrt'),
]);
}
/**
* Auto-grant medals whose get_type metrics meet get_val (skip manually_add).
*/
public static function auto_evaluate(int $user_id): void {
if ($user_id <= 0 || !self::enabled() || !class_exists('AKSRT_Settings')) {
return;
}
$settings = AKSRT_Settings::get_user_identity_settings();
$medal = is_array($settings['medal'] ?? null) ? $settings['medal'] : [];
$items = is_array($medal['items_by_slug'] ?? null) ? $medal['items_by_slug'] : [];
if (!$items) {
// Fall back to dictionary only — no get_type → nothing to auto-grant.
return;
}
$owned = self::get_owned($user_id);
$owned_map = array_fill_keys($owned, true);
foreach ($items as $slug => $item) {
if (!is_array($item)) {
continue;
}
$slug = sanitize_key((string) $slug);
if ($slug === '' || !empty($owned_map[$slug])) {
continue;
}
$type = sanitize_key((string) ($item['get_type'] ?? 'manually_add'));
if ($type === '' || $type === 'manually_add') {
continue;
}
$need = max(0, (float) ($item['get_val'] ?? 0));
$have = (float) self::metric_value($user_id, $type);
if ($have >= $need) {
self::grant($user_id, $slug, 0);
$owned_map[$slug] = true;
}
}
}
/**
* Numeric metric for a medal get_type.
*/
public static function metric_value(int $user_id, string $type): float {
switch ($type) {
case 'registration_time':
$user = get_userdata($user_id);
if (!$user) {
return 0.0;
}
$reg = get_date_from_gmt($user->user_registered, 'Y-m-d H:i:s');
$now = current_time('Y-m-d H:i:s');
return max(0.0, (strtotime($now) - strtotime($reg)) / 86400);
case 'checkin_all_day':
// Prefer dedicated check-in total meta if present.
$v = get_user_meta($user_id, 'aksrt_checkin_all_day', true);
if ($v === '' || $v === false) {
$v = get_user_meta($user_id, 'checkin_all_day', true);
}
return max(0.0, (float) $v);
case 'new_post':
return (float) count_user_posts($user_id, 'post', true);
case 'bbs_post':
$types = post_type_exists('forum_topic') ? 'forum_topic' : (post_type_exists('topic') ? 'topic' : '');
return $types !== '' ? (float) count_user_posts($user_id, $types, true) : 0.0;
case 'comment':
$counts = get_comments(['user_id' => $user_id, 'count' => true, 'status' => 'approve']);
return (float) $counts;
case 'followed':
$meta = get_user_meta($user_id, 'followed-user', true);
if (!is_array($meta)) {
$meta = get_user_meta($user_id, 'aksrt_followed_users', true);
}
return is_array($meta) ? (float) count($meta) : 0.0;
case 'like':
return (float) get_user_meta($user_id, 'aksrt_posts_like_sum', true);
case 'comment_like':
return (float) get_user_meta($user_id, 'aksrt_comment_like_sum', true);
case 'bbs_adopt':
return (float) get_user_meta($user_id, 'aksrt_bbs_adopt_count', true);
case 'bbs_comment_hot':
return (float) get_user_meta($user_id, 'aksrt_bbs_comment_hot_count', true);
case 'bbs_post_hot':
return (float) get_user_meta($user_id, 'aksrt_bbs_post_hot_count', true);
case 'bbs_essence':
return (float) get_user_meta($user_id, 'aksrt_bbs_essence_count', true);
case 'bbs_score':
return (float) get_user_meta($user_id, 'aksrt_bbs_score_sum', true);
case 'views':
return (float) get_user_meta($user_id, 'aksrt_posts_views_sum', true);
case 'pay_income':
return (float) get_user_meta($user_id, 'aksrt_pay_income_sum', true);
case 'pay_rebate':
return (float) get_user_meta($user_id, 'aksrt_pay_rebate_sum', true);
default:
return 0.0;
}
}
/**
* @param string $user_login
* @param WP_User $user
*/
public function on_login($user_login, $user): void {
$user_id = $user instanceof WP_User ? (int) $user->ID : 0;
if ($user_id > 0) {
self::auto_evaluate($user_id);
}
}
public function on_current_user_probe(): void {
$uid = get_current_user_id();
if ($uid > 0) {
self::auto_evaluate($uid);
}
}
}
+7 -5
View File
@@ -279,22 +279,24 @@ class AKSRT_User_Home {
}
/**
* @param array{source:string,thresholds:array<int,int>} $level_settings
* @param array{source?:string,thresholds?:array<int,int>} $level_settings
*/
private function get_user_level(int $user_id, array $level_settings): int {
if (($level_settings['source'] ?? 'credits_balance') === 'manual_meta') {
$source = (string) ($level_settings['source'] ?? 'integral');
if ($source === 'manual_meta') {
return max(0, (int) get_user_meta($user_id, 'aksrt_user_level', true));
}
if (class_exists('AKSRT_User_Level')) {
return AKSRT_User_Level::get_level($user_id);
}
// Fallback: credits_balance thresholds (legacy) if integral class missing.
$score = function_exists('aksrt_user_credits') ? (int) aksrt_user_credits($user_id) : 0;
$level = 0;
foreach ((array) ($level_settings['thresholds'] ?? []) as $candidate => $threshold) {
if ($score >= (int) $threshold) {
$level = (int) $candidate;
}
}
return $level;
}
+379
View File
@@ -0,0 +1,379 @@
<?php
/**
* User level + integral XP — Zibll user_level / user_integral parity.
*
* Meta:
* aksrt_level_integral int total XP
* aksrt_user_level int resolved level
* aksrt_level_integral_detail array reason => [value, integral]
* aksrt_level_integral_date_detail array Y-m-d => int
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_User_Level {
public const META_INTEGRAL = 'aksrt_level_integral';
public const META_LEVEL = 'aksrt_user_level';
public const META_DETAIL = 'aksrt_level_integral_detail';
public const META_DATE_DETAIL = 'aksrt_level_integral_date_detail';
public const META_TODAY_FOLLOW = 'aksrt_level_integral_followed';
/** @var self|null */
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('user_register', [$this, 'on_register'], 20, 1);
add_action('wp_login', [$this, 'on_login'], 20, 2);
add_action('aksrt_user_signed_in', [$this, 'on_sign_in'], 20, 1);
add_action('publish_post', [$this, 'on_publish_post'], 20, 2);
add_action('comment_post', [$this, 'on_comment_post'], 20, 3);
add_action('aksrt_user_followed', [$this, 'on_followed'], 20, 2);
add_action('aksrt_post_liked', [$this, 'on_post_like'], 20, 2);
add_action('aksrt_comment_liked', [$this, 'on_comment_like'], 20, 2);
add_action('aksrt_post_favorited', [$this, 'on_post_favorite'], 20, 2);
}
/**
* @return array{enabled:bool,public_display:bool,prefix:string,max:int,source:string,level_opt:array<string,mixed>,integral_opt:array<string,mixed>,notify:bool}
*/
public static function settings(): array {
$all = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_all() : [];
$enabled = !empty($all['user_level_s']) || !empty($all['user_level_enabled']);
$max = max(3, min(30, (int) ($all['user_level_max'] ?? 10)));
$source = (string) ($all['user_level_source'] ?? 'integral');
if (!in_array($source, ['integral', 'manual_meta'], true)) {
$source = 'integral';
}
$level_opt = $all['user_level_opt'] ?? [];
if (is_string($level_opt)) {
$decoded = json_decode($level_opt, true);
$level_opt = is_array($decoded) ? $decoded : [];
}
if (!is_array($level_opt)) {
$level_opt = [];
}
$integral_opt = $all['user_integral_opt'] ?? [];
if (is_string($integral_opt)) {
$decoded = json_decode($integral_opt, true);
$integral_opt = is_array($decoded) ? $decoded : [];
}
if (!is_array($integral_opt)) {
$integral_opt = [];
}
$prefix = trim((string) ($all['user_level_prefix'] ?? 'Lv.'));
return [
'enabled' => $enabled,
'public_display' => !isset($all['user_level_public_display']) || !empty($all['user_level_public_display']),
'prefix' => $prefix !== '' ? $prefix : 'Lv.',
'max' => $max,
'source' => $source,
'level_opt' => $level_opt,
'integral_opt' => $integral_opt,
'notify' => !isset($all['user_level_notify']) || !empty($all['user_level_notify']),
];
}
/**
* Catalog of integral earn keys (defaults).
*
* @return array<string,int>
*/
public static function integral_defaults(): array {
return [
'day_max' => 100,
'sign_up' => 20,
'sign_in' => 5,
'followed' => 5,
'post_new' => 5,
'post_like' => 1,
'post_favorite' => 2,
'comment_new' => 2,
'comment_like' => 1,
'bbs_posts_new' => 3,
'bbs_score_extra' => 1,
'bbs_essence' => 2,
'bbs_posts_hot' => 2,
'bbs_plate_new' => 2,
'bbs_plate_hot' => 2,
'bbs_adopt' => 2,
'bbs_comment_hot' => 2,
];
}
public static function task_points(string $key): int {
$cfg = self::settings();
$opt = $cfg['integral_opt'];
$def = self::integral_defaults();
if (array_key_exists($key, $opt)) {
return max(0, (int) $opt[$key]);
}
return max(0, (int) ($def[$key] ?? 0));
}
public static function day_max(): int {
$cfg = self::settings();
$opt = $cfg['integral_opt'];
if (array_key_exists('day_max', $opt)) {
return max(0, (int) $opt['day_max']);
}
return 100;
}
public static function get_integral(int $user_id): int {
if ($user_id <= 0) {
return 0;
}
return max(0, (int) get_user_meta($user_id, self::META_INTEGRAL, true));
}
public static function get_level(int $user_id): int {
if ($user_id <= 0) {
return 0;
}
$cfg = self::settings();
if (!$cfg['enabled']) {
return 0;
}
if ($cfg['source'] === 'manual_meta') {
return max(0, (int) get_user_meta($user_id, self::META_LEVEL, true));
}
return self::resolve_level(self::get_integral($user_id), $cfg);
}
/**
* @param array{max:int,level_opt:array} $cfg
*/
public static function resolve_level(int $integral, ?array $cfg = null): int {
$cfg = $cfg ?? self::settings();
$max = (int) $cfg['max'];
$opt = $cfg['level_opt'];
$level = 1;
for ($i = 1; $i <= $max; $i++) {
$need = (int) ($opt['upgrade_integral_' . $i] ?? ($i === 1 ? 0 : PHP_INT_MAX));
if ($integral >= $need) {
$level = $i;
} else {
break;
}
}
return max(0, min($max, $level));
}
/**
* Grant integral for a task key. Respects day_max.
*
* @return int|false points granted or false
*/
public static function add(int $user_id, string $key, int $points = 0) {
if ($user_id <= 0) {
return false;
}
$cfg = self::settings();
if (!$cfg['enabled'] || $cfg['source'] !== 'integral') {
return false;
}
$key = sanitize_key($key);
if ($key === '' || $key === 'day_max') {
return false;
}
if ($points <= 0) {
$points = self::task_points($key);
}
if ($points <= 0) {
return false;
}
$day_max = self::day_max();
$today = gmdate('Y-m-d', current_time('timestamp'));
$date_detail = get_user_meta($user_id, self::META_DATE_DETAIL, true);
$date_detail = is_array($date_detail) ? $date_detail : [];
$today_sum = max(0, (int) ($date_detail[$today] ?? 0));
if ($day_max > 0 && $today_sum >= $day_max) {
return false;
}
if ($day_max > 0) {
$points = min($points, $day_max - $today_sum);
}
if ($points <= 0) {
return false;
}
$before = self::get_integral($user_id);
$after = $before + $points;
update_user_meta($user_id, self::META_INTEGRAL, $after);
$date_detail[$today] = $today_sum + $points;
// Keep last 90 days.
if (count($date_detail) > 90) {
krsort($date_detail);
$date_detail = array_slice($date_detail, 0, 90, true);
}
update_user_meta($user_id, self::META_DATE_DETAIL, $date_detail);
$detail = get_user_meta($user_id, self::META_DETAIL, true);
$detail = is_array($detail) ? $detail : [];
$prev = is_array($detail[$key] ?? null) ? $detail[$key] : ['value' => 0, 'integral' => 0];
$detail[$key] = [
'value' => (int) ($prev['value'] ?? 0) + $points,
'integral' => (int) ($prev['integral'] ?? 0) + $points,
];
update_user_meta($user_id, self::META_DETAIL, $detail);
$old_level = (int) get_user_meta($user_id, self::META_LEVEL, true);
$new_level = self::resolve_level($after, $cfg);
if ($new_level !== $old_level) {
update_user_meta($user_id, self::META_LEVEL, $new_level);
/**
* @param int $user_id
* @param int $new_level
* @param int $old_level
*/
do_action('aksrt_user_level_changed', $user_id, $new_level, $old_level);
} else {
update_user_meta($user_id, self::META_LEVEL, $new_level);
}
return $points;
}
public function on_register(int $user_id): void {
self::add($user_id, 'sign_up');
}
/**
* @param string $user_login
* @param WP_User $user
*/
public function on_login($user_login, $user): void {
$user_id = $user instanceof WP_User ? (int) $user->ID : 0;
if ($user_id <= 0) {
return;
}
// Once per calendar day for login XP.
$today = gmdate('Y-m-d', current_time('timestamp'));
$flag = (string) get_user_meta($user_id, '_aksrt_integral_sign_in_day', true);
if ($flag === $today) {
return;
}
$granted = self::add($user_id, 'sign_in');
if ($granted) {
update_user_meta($user_id, '_aksrt_integral_sign_in_day', $today);
}
}
public function on_sign_in(int $user_id): void {
// Dedicated check-in hook can also grant if separate from login; reuse sign_in key once/day.
$this->on_login('', get_userdata($user_id) ?: new WP_User($user_id));
}
/**
* @param int $post_id
* @param WP_Post $post
*/
public function on_publish_post($post_id, $post): void {
if (!$post instanceof WP_Post || $post->post_type !== 'post' || $post->post_status !== 'publish') {
return;
}
// Only when transitioning to publish first time.
if ((string) get_post_meta($post_id, '_aksrt_integral_post_new', true) === '1') {
return;
}
$author = (int) $post->post_author;
if ($author <= 0) {
return;
}
if (self::add($author, 'post_new')) {
update_post_meta($post_id, '_aksrt_integral_post_new', '1');
}
}
/**
* @param int $comment_id
* @param int|string $approved
* @param array $commentdata
*/
public function on_comment_post($comment_id, $approved, $commentdata): void {
if ((string) $approved !== '1' && $approved !== 1) {
return;
}
$user_id = (int) ($commentdata['user_id'] ?? 0);
if ($user_id <= 0) {
$user_id = (int) get_comment($comment_id)->user_id;
}
if ($user_id <= 0) {
return;
}
self::add($user_id, 'comment_new');
}
/**
* @param int $follower_id
* @param int $followed_id
*/
public function on_followed($follower_id, $followed_id): void {
$followed_id = (int) $followed_id;
if ($followed_id <= 0) {
return;
}
self::add($followed_id, 'followed');
}
/**
* @param int $post_id
* @param int $user_id liker
*/
public function on_post_like($post_id, $user_id): void {
$post = get_post((int) $post_id);
if (!$post instanceof WP_Post) {
return;
}
$author = (int) $post->post_author;
if ($author <= 0 || $author === (int) $user_id) {
return;
}
self::add($author, 'post_like');
}
/**
* @param int $comment_id
* @param int $user_id
*/
public function on_comment_like($comment_id, $user_id): void {
$c = get_comment((int) $comment_id);
if (!$c) {
return;
}
$author = (int) $c->user_id;
if ($author <= 0 || $author === (int) $user_id) {
return;
}
self::add($author, 'comment_like');
}
/**
* @param int $post_id
* @param int $user_id
*/
public function on_post_favorite($post_id, $user_id): void {
$post = get_post((int) $post_id);
if (!$post instanceof WP_Post) {
return;
}
$author = (int) $post->post_author;
if ($author <= 0 || $author === (int) $user_id) {
return;
}
self::add($author, 'post_favorite');
}
}
@@ -237,10 +237,20 @@ class AKSRT_Settings_Post_Display {
$authordesc = !isset($settings['post_authordesc_s']) || !empty($settings['post_authordesc_s']);
$hide_time = !empty($settings['post_single_hide_time_s']);
$share_enabled = !isset($settings['share_s']) || !empty($settings['share_s']);
$rewards_enabled = !empty($settings['post_rewards_enabled']) || !empty($settings['post_rewards_s']);
$rewards_image = (string) ($settings['post_rewards_image'] ?? $settings['post_rewards_qrcode'] ?? $settings['post_rewards_img'] ?? '');
$rewards_enabled = !empty($settings['post_rewards_s']) || !empty($settings['post_rewards_enabled']);
$rewards_text = sanitize_text_field((string) ($settings['post_rewards_text'] ?? ''));
$rewards_btn_text = sanitize_text_field((string) ($settings['post_rewards_btn_text'] ?? '赞赏'));
if ($rewards_text === '') {
$rewards_text = '赞赏';
}
$author_card_limit = max(0, min(12, (int) ($settings['post_author_card_limit'] ?? 6)));
$author_card_orderby = sanitize_key((string) ($settings['post_author_card_orderby'] ?? 'views'));
if (!in_array($author_card_orderby, ['views', 'date', 'modified', 'comment_count'], true)) {
$author_card_orderby = 'views';
}
$author_card_style = sanitize_key((string) ($settings['post_author_card_style'] ?? 'card'));
if (!in_array($author_card_style, ['card', 'list'], true)) {
$author_card_style = 'card';
}
$maxheight_enabled = !empty($settings['article_maxheight_kg'])
|| !empty($settings['post_article_maxheight_kg'])
|| !empty($settings['post_maxheight_kg']);
@@ -318,11 +328,15 @@ class AKSRT_Settings_Post_Display {
// Canonical + legacy share.
'share_enabled' => $share_enabled,
'share_s' => $share_enabled,
// Rewards (Zibll post_rewards_s).
// Author card posts (Zibll user_card_box defaults).
'author_card_limit' => $author_card_limit,
'author_card_orderby' => $author_card_orderby,
'author_card_style' => $author_card_style,
// Rewards (Zibll post_rewards_s) — per-user QR only.
'rewards_enabled' => $rewards_enabled,
'rewards_image' => $rewards_image,
'post_rewards_s' => $rewards_enabled,
'rewards_text' => $rewards_text,
'rewards_btn_text' => $rewards_btn_text !== '' ? $rewards_btn_text : '赞赏',
'rewards_btn_text' => $rewards_text,
// Content max height (Zibll article_maxheight_kg / article_maxheight).
'maxheight_enabled' => $maxheight_enabled,
'maxheight' => $maxheight,
@@ -115,11 +115,12 @@ class AKSRT_Settings_User_Runtime {
}
/**
* User identity, level, badge, and report governance settings.
* User identity, level, medal, and report governance settings.
*
* @return array{
* verify:array{enabled:bool,public_display:bool,badge_text:string,accept_email_verified:bool,require_manual_review:bool},
* verify:array{enabled:bool,public_display:bool,badge_text:string,accept_email_verified:bool,require_manual_review:bool,allow_apply:bool,disable_desc:string,limit_level:int,desc:string,apply_option:string},
* level:array{enabled:bool,public_display:bool,source:string,label_prefix:string,thresholds:array<int,int>},
* medal:array{enabled:bool,public_display:bool,limit:int,categories:array<int,array{cat_name:string,items:array<int,array{slug:string,name:string,desc:string,icon:string,get_type:string,get_val:int}>}>,items_by_slug:array<string,array{slug:string,name:string,desc:string,icon:string,get_type:string,get_val:int,cat_name:string}>},
* badge:array{enabled:bool,public_display:bool,limit:int,dictionary:array<int,array{slug:string,label:string,tone:string,icon_url?:string}>},
* report:array{enabled:bool,public_entry:bool,public_notice:bool,ban_notice_text:string,categories:array<int,array{slug:string,label:string}>}
* }
@@ -129,6 +130,7 @@ class AKSRT_Settings_User_Runtime {
$verify_badge_text = trim((string) ($settings['user_verify_badge_text'] ?? ''));
$level_prefix = trim((string) ($settings['user_level_prefix'] ?? ''));
$ban_notice_text = trim((string) ($settings['user_report_ban_notice_text'] ?? ''));
$medal = self::parse_medal_catalog($settings);
return [
'verify' => [
@@ -137,22 +139,37 @@ class AKSRT_Settings_User_Runtime {
'badge_text' => $verify_badge_text !== '' ? $verify_badge_text : __('已认证', 'aksrt'),
'accept_email_verified' => !isset($settings['user_verify_accept_email_verified']) || !empty($settings['user_verify_accept_email_verified']),
'require_manual_review' => !empty($settings['user_verify_require_manual_review']),
'allow_apply' => !isset($settings['user_verify_allow_apply']) || !empty($settings['user_verify_allow_apply']),
'disable_desc' => (string) ($settings['user_verify_disable_desc'] ?? ''),
'limit_level' => max(0, (int) ($settings['user_verify_limit_level'] ?? 0)),
'desc' => (string) ($settings['user_verify_desc'] ?? ''),
'apply_option' => (string) ($settings['user_verify_apply_option'] ?? ''),
],
'level' => [
'enabled' => !empty($settings['user_level_enabled']),
'enabled' => !empty($settings['user_level_enabled']) || !empty($settings['user_level_s']),
'public_display' => !isset($settings['user_level_public_display']) || !empty($settings['user_level_public_display']),
'source' => (function() use ($settings) {
$v = $settings['user_level_source'] ?? 'credits_balance';
return in_array($v, ['credits_balance', 'manual_meta'], true) ? $v : 'credits_balance';
'source' => (static function () use ($settings) {
$v = $settings['user_level_source'] ?? 'integral';
return in_array($v, ['credits_balance', 'manual_meta', 'integral'], true) ? $v : 'integral';
})(),
'label_prefix' => $level_prefix !== '' ? $level_prefix : 'Lv.',
'thresholds' => self::parse_level_thresholds((string) ($settings['user_level_thresholds'] ?? "1|0\n2|30\n3|100\n4|300")),
'max' => max(3, min(30, (int) ($settings['user_level_max'] ?? 10))),
],
'medal' => $medal,
// Alias for existing call sites that still read badge.*; dictionary is derived from medal catalog.
'badge' => [
'enabled' => !empty($settings['user_badge_enabled']),
'public_display' => !isset($settings['user_badge_public_display']) || !empty($settings['user_badge_public_display']),
'limit' => max(1, min(8, (int) ($settings['user_badge_limit'] ?? 3))),
'dictionary' => self::parse_badge_dictionary((string) ($settings['user_badge_dictionary'] ?? "founder|创始成员|warning\nstaff|站务团队|info")),
'enabled' => $medal['enabled'],
'public_display' => $medal['public_display'],
'limit' => $medal['limit'],
'dictionary' => array_values(array_map(static function (array $item): array {
return [
'slug' => $item['slug'],
'label' => $item['name'],
'tone' => 'info',
'icon_url' => $item['icon'],
];
}, $medal['items_by_slug'])),
],
'report' => [
'enabled' => !empty($settings['user_report_enabled']),
@@ -207,6 +224,100 @@ class AKSRT_Settings_User_Runtime {
];
}
/**
* Parse structured user_medal_args catalog (rewrite; no badge dictionary dual-read).
*
* @param array<string,mixed> $settings
* @return array{enabled:bool,public_display:bool,limit:int,categories:array,items_by_slug:array}
*/
public static function parse_medal_catalog(array $settings): array {
$enabled = !empty($settings['user_medal_s']);
$public = !isset($settings['user_medal_public_display']) || !empty($settings['user_medal_public_display']);
$limit = max(1, min(8, (int) ($settings['user_medal_limit'] ?? 3)));
$allowed_types = [
'registration_time',
'checkin_all_day',
'new_post',
'bbs_post',
'comment',
'followed',
'like',
'comment_like',
'bbs_adopt',
'bbs_comment_hot',
'bbs_post_hot',
'bbs_essence',
'bbs_score',
'views',
'pay_income',
'pay_rebate',
'manually_add',
];
$raw = $settings['user_medal_args'] ?? null;
if (is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
$raw = is_array($decoded) ? $decoded : [];
}
if (!is_array($raw)) {
$raw = [];
}
$categories = [];
$items_by_slug = [];
foreach ($raw as $cat) {
if (!is_array($cat)) {
continue;
}
$cat_name = sanitize_text_field((string) ($cat['cat_name'] ?? $cat['name'] ?? ''));
if ($cat_name === '') {
$cat_name = __('未命名', 'aksrt');
}
$items_out = [];
$items = is_array($cat['items'] ?? null) ? $cat['items'] : [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$slug = sanitize_key((string) ($item['slug'] ?? ''));
$name = sanitize_text_field((string) ($item['name'] ?? ''));
if ($slug === '' || $name === '') {
continue;
}
$get_type = sanitize_key((string) ($item['get_type'] ?? 'manually_add'));
if (!in_array($get_type, $allowed_types, true)) {
$get_type = 'manually_add';
}
$icon = esc_url_raw((string) ($item['icon'] ?? $item['icon_url'] ?? ''));
$row = [
'slug' => $slug,
'name' => $name,
'desc' => sanitize_text_field((string) ($item['desc'] ?? '')),
'icon' => $icon,
'get_type' => $get_type,
'get_val' => max(0, (int) ($item['get_val'] ?? 0)),
'cat_name' => $cat_name,
];
$items_out[] = $row;
$items_by_slug[$slug] = $row;
}
$categories[] = [
'cat_name' => $cat_name,
'items' => $items_out,
];
}
return [
'enabled' => $enabled,
'public_display' => $public,
'limit' => $limit,
'categories' => $categories,
'items_by_slug' => $items_by_slug,
];
}
/**
* @return array<int,int>
*/
@@ -238,41 +349,13 @@ class AKSRT_Settings_User_Runtime {
}
/**
* @deprecated Removed — medal catalog is structured user_medal_args. Method kept empty stub intentionally deleted.
* @return array<int,array{slug:string,label:string,tone:string,icon_url:string}>
*/
public static function parse_badge_dictionary(string $raw): array {
$lines = preg_split('/[\r\n]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
$allowed_tones = ['neutral', 'info', 'success', 'warning', 'brand'];
$items = [];
foreach ((array) $lines as $line) {
$parts = array_map('trim', explode('|', (string) $line));
if (count($parts) < 2) {
continue;
}
$slug = sanitize_key((string) $parts[0]);
$label = sanitize_text_field((string) $parts[1]);
$tone = isset($parts[2]) ? sanitize_key((string) $parts[2]) : 'neutral';
$icon_url = isset($parts[3]) ? esc_url_raw((string) $parts[3]) : '';
if ($slug === '' || $label === '') {
continue;
}
if (!in_array($tone, $allowed_tones, true)) {
$tone = 'neutral';
}
$items[] = [
'slug' => $slug,
'label' => $label,
'tone' => $tone,
'icon_url' => $icon_url,
];
}
return $items;
// Rewrite policy: no dual-read of pipe dictionary. Callers must use parse_medal_catalog / get_user_identity_settings()['medal'].
unset($raw);
return [];
}
/**
+52 -21
View File
@@ -835,28 +835,59 @@ if (!function_exists('aksrt_comment_action_permissions')) :
* @return array{can_edit:bool,can_delete:bool,can_moderate:bool,can_pin:bool}
*/
function aksrt_comment_action_permissions($comment): array {
$comment = $comment instanceof WP_Comment ? $comment : get_comment($comment);
$none = ['can_edit' => false, 'can_delete' => false, 'can_moderate' => false, 'can_pin' => false];
if (!($comment instanceof WP_Comment) || !is_user_logged_in()) {
return $none;
$comment = $comment instanceof WP_Comment ? $comment : get_comment($comment);
$none = ['can_edit' => false, 'can_delete' => false, 'can_moderate' => false, 'can_pin' => false];
if (!($comment instanceof WP_Comment) || !is_user_logged_in()) {
return $none;
}
$uid = get_current_user_id();
$is_wp_mod = current_user_can('moderate_comments') || current_user_can('manage_options');
$is_owner = (int) $comment->user_id > 0 && (int) $comment->user_id === $uid;
$post_id = (int) $comment->comment_post_ID;
$post = $post_id > 0 ? get_post($post_id) : null;
$is_post_author = $post instanceof WP_Post && (int) $post->post_author === $uid;
$ctx = $post_id > 0 ? $post_id : null;
$can = static function (string $cap) use ($uid, $ctx): bool {
if (function_exists('aksrt_user_can')) {
return aksrt_user_can($cap, $uid, $ctx);
}
if (class_exists('AKSRT_Settings_Capabilities')) {
return AKSRT_Settings_Capabilities::user_can($cap, $uid, $ctx);
}
return false;
};
$edit_window = 10;
if (class_exists('AKSRT_Settings')) {
$display = AKSRT_Settings::get_comment_display_settings();
$edit_window = (int) ($display['edit_window'] ?? 10);
}
$within_window = $edit_window > 0
&& (time() - strtotime((string) $comment->comment_date_gmt)) < ($edit_window * MINUTE_IN_SECONDS);
$can_edit_own = $is_owner && $within_window && $can('comment_edit');
$can_edit_others = (!$is_owner) && (
($is_post_author && $can('comment_edit_my_post'))
|| $can('comment_edit_other')
);
$can_delete_own = $is_owner && $can('comment_delete');
$can_delete_others = (!$is_owner) && (
($is_post_author && $can('comment_delete_my_post'))
|| $can('comment_delete_other')
);
$can_pin = (int) $comment->comment_parent === 0 && (
($is_post_author && $can('comment_set_topping_my_post'))
|| $can('comment_set_topping_other')
);
return [
'can_edit' => $is_wp_mod || $can_edit_own || $can_edit_others,
'can_delete' => $is_wp_mod || $can_delete_own || $can_delete_others,
'can_moderate' => $is_wp_mod || $can('comment_audit_other') || ($is_post_author && $can('comment_audit_my_post')),
'can_pin' => $is_wp_mod || $can_pin,
];
}
$is_moderator = current_user_can('moderate_comments');
$is_owner = (int) $comment->user_id > 0 && (int) $comment->user_id === get_current_user_id();
// Owner self-edit window (minutes); moderators bypass the window.
$edit_window = 10;
if (class_exists('AKSRT_Settings')) {
$display = AKSRT_Settings::get_comment_display_settings();
$edit_window = (int) ($display['edit_window'] ?? 10);
}
$within_window = $edit_window > 0
&& (time() - strtotime((string) $comment->comment_date_gmt)) < ($edit_window * MINUTE_IN_SECONDS);
return [
'can_edit' => $is_moderator || ($is_owner && $within_window),
'can_delete' => $is_moderator || $is_owner,
'can_moderate' => $is_moderator,
'can_pin' => $is_moderator && (int) $comment->comment_parent === 0,
];
}
endif;
if (!function_exists('aksrt_is_comment_pinned')) :
/**
+113
View File
@@ -91,6 +91,119 @@ if (!function_exists('aksrt_render_user_identity_chips')) {
}
}
if (!function_exists('aksrt_get_user_name')) {
/**
* Zibll zib_get_user_name parity: name link + auth + wear medal + level + optional follow.
*
* @param array|int $args User id or args array.
*/
function aksrt_get_user_name($args = []): string {
$defaults = [
'id' => 0,
'class' => 'flex ac flex1',
'name_class' => '',
'vip' => false,
'medal' => true,
'auth' => true,
'level' => true,
'follow' => false,
];
if (is_numeric($args)) {
$defaults['id'] = (int) $args;
$args = $defaults;
} else {
$args = wp_parse_args(is_array($args) ? $args : [], $defaults);
}
$user_id = (int) $args['id'];
if ($user_id <= 0) {
$user_id = get_current_user_id();
}
if ($user_id <= 0) {
return '';
}
$user = get_userdata($user_id);
if (!$user) {
return '';
}
$home = function_exists('get_author_posts_url') ? get_author_posts_url($user_id) : '';
$display = $user->display_name;
$vip_html = '';
if (!empty($args['vip']) && function_exists('aksrt_render_vip_badge')) {
$vip_html = (string) aksrt_render_vip_badge($user_id, 'sm');
}
$name_class = trim('display-name text-ellipsis ' . (string) $args['name_class']);
$name_link = '<a class="' . esc_attr($name_class) . '" href="' . esc_url($home) . '">' . $vip_html . esc_html($display) . '</a>';
$auth_html = '';
$medal_html = '';
$level_html = '';
$signals = aksrt_get_user_identity_signals($user_id);
foreach ($signals as $signal) {
$type = (string) ($signal['type'] ?? '');
$label = trim((string) ($signal['label'] ?? ''));
if ($label === '') {
continue;
}
$icon_url = esc_url((string) ($signal['icon_url'] ?? ''));
$title = (string) ($signal['title'] ?? $label);
if ($type === 'verify' && !empty($args['auth']) && $auth_html === '') {
$auth_html = '<span class="badg badg-sm ml3 aksrt-identity-chip aksrt-identity-chip--success" title="' . esc_attr($title) . '"><i class="fa fa-check-circle" aria-hidden="true"></i> ' . esc_html($label) . '</span>';
} elseif ($type === 'level' && !empty($args['level']) && $level_html === '') {
$level_html = '<span class="badg badg-sm ml3 aksrt-identity-chip aksrt-identity-chip--info" title="' . esc_attr($title) . '"><i class="fa fa-layer-group" aria-hidden="true"></i> ' . esc_html($label) . '</span>';
} elseif ($type === 'badge' && !empty($args['medal']) && $medal_html === '' && !empty($signal['wear'])) {
if ($icon_url !== '') {
$medal_html = '<img class="medal-icon img-icon ml3 aksrt-identity-medal is-wear" src="' . $icon_url . '" alt="' . esc_attr($label) . '" title="' . esc_attr($title) . '" loading="lazy" decoding="async" />';
} else {
$medal_html = '<span class="badg badg-sm ml3 aksrt-identity-chip is-wear" title="' . esc_attr($title) . '"><i class="fa fa-award" aria-hidden="true"></i> ' . esc_html($label) . '</span>';
}
}
}
// If medal requested but no wear flag, use first badge with icon.
if (!empty($args['medal']) && $medal_html === '') {
foreach ($signals as $signal) {
if ((string) ($signal['type'] ?? '') !== 'badge') {
continue;
}
$label = trim((string) ($signal['label'] ?? ''));
$icon_url = esc_url((string) ($signal['icon_url'] ?? ''));
$title = (string) ($signal['title'] ?? $label);
if ($label === '') {
continue;
}
if ($icon_url !== '') {
$medal_html = '<img class="medal-icon img-icon ml3 aksrt-identity-medal" src="' . $icon_url . '" alt="' . esc_attr($label) . '" title="' . esc_attr($title) . '" loading="lazy" decoding="async" />';
} else {
$medal_html = '<span class="badg badg-sm ml3 aksrt-identity-chip" title="' . esc_attr($title) . '"><i class="fa fa-award" aria-hidden="true"></i> ' . esc_html($label) . '</span>';
}
break;
}
}
$follow_html = '';
if (!empty($args['follow']) && get_current_user_id() !== $user_id) {
if (is_user_logged_in()) {
$follow_html = '<button type="button" class="focus-color ml10 follow flex0 but" data-aksrt-follow="' . (int) $user_id . '"><i class="fa fa-heart em09" aria-hidden="true"></i> ' . esc_html__('关注', 'aksrt') . '</button>';
} else {
$login = function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url();
$follow_html = '<a class="focus-color ml10 follow flex0 but" href="' . esc_url($login) . '"><i class="fa fa-heart em09" aria-hidden="true"></i> ' . esc_html__('关注', 'aksrt') . '</a>';
}
}
// Order: name → auth → wear medal → level → follow (Zibll).
$html = '<name class="' . esc_attr((string) $args['class']) . '">' . $name_link . $auth_html . $medal_html . $level_html . $follow_html . '</name>';
/**
* Filter rendered user name chrome.
*
* @param string $html
* @param int $user_id
* @param array $args
*/
return (string) apply_filters('aksrt_user_show_name', $html, $user_id, $args);
}
}
if (!function_exists('aksrt_is_blog_comment_hot')) {
/**
* "神评" for ordinary post comments when likes cross threshold.
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* Per-user tip (打赏) helpers — Zibll post_rewards_s + user QR parity.
*
* Meta: aksrt_rewards_weixin, aksrt_rewards_alipay (attachment id or URL).
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
if (!function_exists('aksrt_rewards_enabled')) {
function aksrt_rewards_enabled(): bool {
if (!class_exists('AKSRT_Settings') || !method_exists('AKSRT_Settings', 'get_post_page_settings')) {
return false;
}
$s = AKSRT_Settings::get_post_page_settings();
return !empty($s['rewards_enabled']) || !empty($s['post_rewards_s']);
}
}
if (!function_exists('aksrt_get_user_rewards_img_urls')) {
/**
* @return array{weixin:string,alipay:string}
*/
function aksrt_get_user_rewards_img_urls(int $user_id): array {
$out = ['weixin' => '', 'alipay' => ''];
if ($user_id <= 0) {
return $out;
}
foreach (['weixin' => 'aksrt_rewards_weixin', 'alipay' => 'aksrt_rewards_alipay'] as $key => $meta) {
$raw = get_user_meta($user_id, $meta, true);
if (is_numeric($raw) && (int) $raw > 0) {
$url = wp_get_attachment_image_url((int) $raw, 'full');
$out[$key] = is_string($url) ? $url : '';
} else {
$out[$key] = esc_url_raw((string) $raw);
}
}
return $out;
}
}
if (!function_exists('aksrt_user_has_rewards_qr')) {
function aksrt_user_has_rewards_qr(int $user_id): bool {
$urls = aksrt_get_user_rewards_img_urls($user_id);
return $urls['weixin'] !== '' || $urls['alipay'] !== '';
}
}
if (!function_exists('aksrt_get_rewards_button')) {
function aksrt_get_rewards_button(int $user_id, string $class = 'but jb-blue ml10 pw-1em'): string {
if ($user_id <= 0 || !aksrt_rewards_enabled() || !aksrt_user_has_rewards_qr($user_id)) {
return '';
}
$label = '赞赏';
if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_post_page_settings')) {
$s = AKSRT_Settings::get_post_page_settings();
$label = (string) ($s['rewards_btn_text'] ?? $s['rewards_text'] ?? $label);
if ($label === '') {
$label = '赞赏';
}
}
return '<button type="button" class="' . esc_attr($class) . ' aksrt-reward-open" data-aksrt-reward-open data-user-id="' . (int) $user_id . '"><i class="fa fa-gift em09" aria-hidden="true"></i> ' . esc_html($label) . '</button>';
}
}
if (!function_exists('aksrt_render_rewards_modal_for_user')) {
function aksrt_render_rewards_modal_for_user(int $user_id): void {
static $done = [];
if ($user_id <= 0 || isset($done[$user_id])) {
return;
}
if (!aksrt_rewards_enabled() || !aksrt_user_has_rewards_qr($user_id)) {
return;
}
$done[$user_id] = true;
$urls = aksrt_get_user_rewards_img_urls($user_id);
$label = '赞赏';
if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_post_page_settings')) {
$s = AKSRT_Settings::get_post_page_settings();
$label = (string) ($s['rewards_text'] ?? $label);
if ($label === '') {
$label = __('赞赏', 'aksrt');
}
}
?>
<div class="reward-modal" id="reward-modal-<?php echo (int) $user_id; ?>" data-user-id="<?php echo (int) $user_id; ?>" aria-hidden="true">
<div class="reward-modal__overlay" data-aksrt-reward-close></div>
<div class="reward-modal__dialog" role="dialog" aria-modal="true">
<button type="button" class="reward-modal__close" data-aksrt-reward-close aria-label="<?php esc_attr_e('关闭', 'aksrt'); ?>"><i class="fas fa-times" aria-hidden="true"></i></button>
<div class="reward-modal__content">
<h3><?php echo esc_html($label); ?></h3>
<div class="reward-modal__qrcodes flex jc ac hh">
<?php if ($urls['weixin'] !== '') : ?>
<div class="reward-modal__qrcode text-center mr10">
<img src="<?php echo esc_url($urls['weixin']); ?>" alt="<?php esc_attr_e('微信收款码', 'aksrt'); ?>" loading="lazy" decoding="async" />
<div class="em09 muted-2-color mt6"><?php esc_html_e('微信', 'aksrt'); ?></div>
</div>
<?php endif; ?>
<?php if ($urls['alipay'] !== '') : ?>
<div class="reward-modal__qrcode text-center">
<img src="<?php echo esc_url($urls['alipay']); ?>" alt="<?php esc_attr_e('支付宝收款码', 'aksrt'); ?>" loading="lazy" decoding="async" />
<div class="em09 muted-2-color mt6"><?php esc_html_e('支付宝', 'aksrt'); ?></div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php
}
}
+129 -36
View File
@@ -240,7 +240,23 @@ if (!function_exists('aksrt_single_render_actions')) :
</button>
<?php endif; ?>
<?php if (!empty($settings['rewards_enabled']) && !empty($settings['rewards_image'])) : ?>
<?php
$reward_author = (int) get_the_author_meta('ID');
$reward_btn = ($reward_author > 0 && function_exists('aksrt_get_rewards_button'))
? aksrt_get_rewards_button($reward_author, 'action action-rewards')
: '';
if ($reward_btn === '' && !empty($settings['rewards_enabled']) && function_exists('aksrt_user_has_rewards_qr') && $reward_author > 0 && aksrt_user_has_rewards_qr($reward_author)) {
$label = esc_html((string) ($settings['rewards_btn_text'] ?? __('赞赏', 'aksrt')));
$reward_btn = '<button type="button" class="action action-rewards" data-aksrt-reward-open data-user-id="' . (int) $reward_author . '"><i class="fas fa-gift" aria-hidden="true"></i><span class="action-btn__label">' . $label . '</span></button>';
}
if ($reward_btn !== '') :
echo $reward_btn; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
if (function_exists('aksrt_render_rewards_modal_for_user')) {
aksrt_render_rewards_modal_for_user($reward_author);
}
endif;
// legacy placeholder to keep structure; actual button echoed above
if (false) : ?>
<button type="button" class="action action-rewards" id="action-reward">
<i class="fas fa-gift" aria-hidden="true"></i>
<span class="action-btn__label"><?php echo esc_html($settings['rewards_btn_text'] ?? __('赞赏', 'aksrt')); ?></span>
@@ -331,21 +347,59 @@ if (!function_exists('aksrt_single_render_author_card')) :
$author_url = get_author_posts_url($author_id);
$author_name = get_the_author();
$bio = trim((string) get_the_author_meta('description'));
$identity_chips = function_exists('aksrt_render_user_identity_chips')
$name_html = function_exists('aksrt_get_user_name')
? aksrt_get_user_name([
'id' => $author_id,
'class' => 'user-name flex jc ac',
'vip' => true,
'medal' => true,
'auth' => true,
'level' => true,
'follow' => false,
])
: '';
$identity_chips = ($name_html === '' && function_exists('aksrt_render_user_identity_chips'))
? aksrt_render_user_identity_chips($author_id, 'card')
: '';
$avatar = function_exists('aksrt_get_avatar_html')
? aksrt_get_avatar_html($author_id, 80, ['class' => 'avatar avatar-lg'])
: get_avatar($author_id, 80, '', esc_attr($author_name), ['class' => 'avatar avatar-lg']);
$recent = get_posts([
'author' => $author_id,
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 6,
'orderby' => 'date',
'order' => 'DESC',
'no_found_rows' => true,
]);
$page_s = class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_post_page_settings')
? AKSRT_Settings::get_post_page_settings()
: [];
$card_limit = max(0, min(12, (int) ($page_s['author_card_limit'] ?? 6)));
$card_orderby = sanitize_key((string) ($page_s['author_card_orderby'] ?? 'views'));
$card_style = sanitize_key((string) ($page_s['author_card_style'] ?? 'card'));
$recent = [];
if ($card_limit > 0) {
$q = [
'author' => $author_id,
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $card_limit,
'ignore_sticky_posts' => true,
'no_found_rows' => true,
];
$cur = get_the_ID();
if ($cur) {
$q['post__not_in'] = [(int) $cur];
}
if ($card_orderby === 'views') {
$q['meta_key'] = 'views';
$q['orderby'] = 'meta_value_num';
$q['order'] = 'DESC';
} elseif ($card_orderby === 'comment_count') {
$q['orderby'] = 'comment_count';
$q['order'] = 'DESC';
} elseif ($card_orderby === 'modified') {
$q['orderby'] = 'modified';
$q['order'] = 'DESC';
} else {
$q['orderby'] = 'date';
$q['order'] = 'DESC';
}
$recent = get_posts($q);
}
$is_self = is_user_logged_in() && get_current_user_id() === $author_id;
$login_url = function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(get_permalink());
$aksrt_pm_on = class_exists('AKSRT_Settings') && !empty(AKSRT_Settings::private_message_settings()['enabled']);
@@ -362,17 +416,34 @@ if (!function_exists('aksrt_single_render_author_card')) :
<div class="user-content">
<div class="user-avatar"><?php echo $avatar; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
<div class="user-info mt20 mb10">
<div class="user-name flex jc ac">
<a class="display-name" href="<?php echo esc_url($author_url); ?>"><?php echo esc_html($author_name); ?></a>
<?php if ($identity_chips !== '') : ?>
<?php echo $identity_chips; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php if ($name_html !== '') : ?>
<?php echo $name_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php else : ?>
<div class="user-name flex jc ac">
<a class="display-name" href="<?php echo esc_url($author_url); ?>"><?php echo esc_html($author_name); ?></a>
<?php if ($identity_chips !== '') : ?>
<?php echo $identity_chips; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php if ($bio !== '') : ?>
<div class="user-desc mt10 muted-2-color em09"><?php echo esc_html($bio); ?></div>
<?php endif; ?>
<div class="user-btns mt20">
<?php if (!$is_self) : ?>
<?php if ($is_self) : ?>
<?php
$submit = function_exists('aksrt_get_page_url_by_template')
? (string) aksrt_get_page_url_by_template('templates/pages/page-submit.php')
: '';
$center = function_exists('aksrt_get_user_center_url')
? (string) aksrt_get_user_center_url()
: $author_url;
if ($submit !== '') :
?>
<a class="but pw-1em mr6 jb-pink" href="<?php echo esc_url($submit); ?>"><?php esc_html_e('发布文章', 'aksrt'); ?></a>
<?php endif; ?>
<a class="but pw-1em mr6 jb-blue" href="<?php echo esc_url($center); ?>"><?php esc_html_e('用户中心', 'aksrt'); ?></a>
<?php else : ?>
<?php if (is_user_logged_in()) : ?>
<button type="button" class="but pw-1em mr6 c-red follow" data-aksrt-follow="<?php echo (int) $author_id; ?>">
<i class="fa fa-heart em09" aria-hidden="true"></i> <span><?php esc_html_e('关注', 'aksrt'); ?></span>
@@ -382,34 +453,56 @@ if (!function_exists('aksrt_single_render_author_card')) :
<i class="fa fa-heart em09" aria-hidden="true"></i> <span><?php esc_html_e('关注', 'aksrt'); ?></span>
</a>
<?php endif; ?>
<?php if (is_user_logged_in() && $aksrt_pm_on && $aksrt_msg_url !== '') : ?>
<a class="but pw-1em mr6 c-blue" href="<?php echo esc_url(add_query_arg('msg_type', 'conversation', $aksrt_msg_url)); ?>">
<i class="fa fa-comment em09" aria-hidden="true"></i> <?php esc_html_e('私信', 'aksrt'); ?>
</a>
<?php endif; ?>
<?php
$tip = function_exists('aksrt_get_rewards_button') ? aksrt_get_rewards_button($author_id, 'but pw-1em mr6 c-yellow') : '';
if ($tip !== '') {
echo $tip; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
if (function_exists('aksrt_render_rewards_modal_for_user')) {
aksrt_render_rewards_modal_for_user($author_id);
}
}
?>
<a class="but pw-1em mr6 jb-blue" href="<?php echo esc_url($author_url); ?>"><?php esc_html_e('个人主页', 'aksrt'); ?></a>
<?php endif; ?>
<?php if (!$is_self && is_user_logged_in() && $aksrt_pm_on && $aksrt_msg_url !== '') : ?>
<a class="but pw-1em mr6 c-blue" href="<?php echo esc_url(add_query_arg('msg_type', 'conversation', $aksrt_msg_url)); ?>">
<i class="fa fa-comment em09" aria-hidden="true"></i> <?php esc_html_e('私信', 'aksrt'); ?>
</a>
<?php elseif (!$is_self && $rewards_on) : ?>
<button type="button" class="but pw-1em mr6 c-yellow" data-aksrt-reward-open>
<i class="fa fa-gift em09" aria-hidden="true"></i> <?php esc_html_e('赞赏', 'aksrt'); ?>
</button>
<?php endif; ?>
<a class="but pw-1em mr6 jb-blue" href="<?php echo esc_url($author_url); ?>"><?php esc_html_e('个人主页', 'aksrt'); ?></a>
</div>
</div>
</div>
<?php if (!empty($recent)) : ?>
<div class="user-card-posts mt10">
<ul class="no-thumb">
<?php foreach ($recent as $rp) : ?>
<?php if ($rp instanceof WP_Post) : ?>
<li><a class="icon-circle" href="<?php echo esc_url(get_permalink($rp)); ?>"><?php echo esc_html(get_the_title($rp)); ?></a></li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php if ($card_style === 'card') : ?>
<div class="more-posts-mini flex hh">
<?php foreach ($recent as $rp) : ?>
<?php if ($rp instanceof WP_Post) :
$thumb = get_the_post_thumbnail_url($rp, 'medium');
?>
<a class="item mr10 mb10" href="<?php echo esc_url(get_permalink($rp)); ?>" style="display:block;width:46%;">
<?php if ($thumb) : ?>
<img class="fit-cover radius8" src="<?php echo esc_url($thumb); ?>" alt="" loading="lazy" decoding="async" style="width:100%;aspect-ratio:16/10;object-fit:cover;" />
<?php endif; ?>
<div class="em09 mt6 text-ellipsis"><?php echo esc_html(get_the_title($rp)); ?></div>
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php else : ?>
<ul class="no-thumb">
<?php foreach ($recent as $rp) : ?>
<?php if ($rp instanceof WP_Post) : ?>
<li><a class="icon-circle" href="<?php echo esc_url(get_permalink($rp)); ?>"><?php echo esc_html(get_the_title($rp)); ?></a></li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php else : ?>
<div class="user-card-posts mt10 muted-2-color em09 text-center"><?php esc_html_e('暂无更多文章', 'aksrt'); ?></div>
<?php endif; ?>
</div>
<?php endif;
?> </div>
</div>
<?php
}
@@ -167,8 +167,10 @@ $pane = isset($points_tasks_pane) ? sanitize_key((string) $points_tasks_pane)
<?php
$task_key = sanitize_key((string) ($task['key'] ?? ''));
$action = $aksrt_points_task_action($task_key);
$status = sanitize_key((string) ($task['status'] ?? 'todo'));
$done = in_array($status, ['done_today', 'done'], true);
?>
<div class="border-bottom padding-h10 uc-points-tasks__row" data-task-key="<?php echo esc_attr($task_key); ?>">
<div class="border-bottom padding-h10 uc-points-tasks__row<?php echo $done ? ' is-done' : ''; ?>" data-task-key="<?php echo esc_attr($task_key); ?>" data-status="<?php echo esc_attr($status); ?>">
<div class="flex jsb ac">
<div class="flex1 mr20">
<div class="font-bold mb6"><?php echo esc_html((string) $task['title']); ?></div>
+27 -10
View File
@@ -48,10 +48,23 @@ $aksrt_msg_url = function_exists('aksrt_get_messages_page_url') ? aksrt_get_mess
$login_url = function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(get_permalink());
$is_self = is_user_logged_in() && get_current_user_id() === $author_id;
// Identity chips: VIP + verify/level/badges (shared helper).
$identity_chips = function_exists('aksrt_render_user_identity_chips')
? aksrt_render_user_identity_chips($author_id, 'author')
: '';
// Identity: prefer aksrt_get_user_name (Zibll order); fall back to chips.
$identity_chips = '';
$name_html = '';
if (function_exists('aksrt_get_user_name')) {
$name_html = aksrt_get_user_name([
'id' => $author_id,
'class' => 'name flex ac flex1',
'name_class' => '',
'vip' => true,
'medal' => true,
'auth' => true,
'level' => true,
'follow' => false,
]);
} elseif (function_exists('aksrt_render_user_identity_chips')) {
$identity_chips = aksrt_render_user_identity_chips($author_id, 'author');
}
?>
<div class="<?php echo esc_attr($classes); ?>">
@@ -67,12 +80,16 @@ $identity_chips = function_exists('aksrt_render_user_identity_chips')
</a>
<div class="user-right flex flex1 ac jsb ml10">
<div class="flex1">
<span class="name flex ac flex1">
<a class="display-name text-ellipsis" href="<?php echo esc_url($author_url); ?>"><?php echo esc_html($author_name); ?></a>
<?php if ($identity_chips !== '') : ?>
<?php echo $identity_chips; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endif; ?>
</span>
<?php if ($name_html !== '') : ?>
<?php echo $name_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php else : ?>
<span class="name flex ac flex1">
<a class="display-name text-ellipsis" href="<?php echo esc_url($author_url); ?>"><?php echo esc_html($author_name); ?></a>
<?php if ($identity_chips !== '') : ?>
<?php echo $identity_chips; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endif; ?>
</span>
<?php endif; ?>
<div class="px12-sm muted-2-color text-ellipsis">
<?php if ($show_time) : ?>
<span class="icon-circle" title="<?php echo esc_attr($abs_fmt); ?>" data-toggle="tooltip"><?php echo esc_html($time_text); ?></span>