feat(admin): polish badge, OAuth and payment gateway settings UX

- Badge page: rewrite as collapsible sortable category/item cards with
  preset medal icon picker, dirty-tracked save bar and duplicate slug check;
  drop user_badge_* dual-read and legacy name/icon_url fallbacks
- OAuth: provider config groups collapsible, expanded when provider is on;
  clogin visibility moved to group-level dependsOn
- Gateway: expandWhen accepts dependsOn-style rules; select-gated groups no
  longer always expand ('null' was truthy); mark five gateway secrets as
  secret/password in JS and PHP; class-based notice list styling
- Remove unused UserRuntime::parse_badge_dictionary

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-28 15:33:49 +08:00
1 parent 116b6b2223
commit 3179754670
13 files changed
+502 -239

No files matched your search

@@ -948,6 +948,12 @@ function updateRepeaterItem(index, fieldKey, value) {
line-height: 1.6;
}
.aksrt-field__content :deep(ul) {
margin: 0;
padding-left: 18px;
line-height: 1.9;
}
/* Mobile */
@media (max-width: 768px) {
.aksrt-field__link,
@@ -248,16 +248,21 @@ function isGroupVisible(group) {
return matchDependsOn(group.dependsOn)
}
/**
* expandWhen 与 dependsOn 同语法(表单 key 字符串或规则对象),规则成立时分组默认展开。
* 下拉值如 'null' 也是真值,所以按选项判断的分组要写规则对象,不能只写 key。
*/
function isGroupExpanded(group) {
return matchDependsOn(group.expandWhen)
}
/**
* Collapse preference for a group.
* - expandWhen: form key; when truthy, start expanded (channel just enabled → open form)
* - expandWhen: rule; when matched, start expanded (channel just enabled → open form)
* - defaultCollapsed: static default
*/
function resolveGroupCollapsed(group) {
if (group.expandWhen) {
const on = !!props.formData?.[group.expandWhen]
return !on
}
if (group.expandWhen) return !isGroupExpanded(group)
return !!group.defaultCollapsed
}
@@ -265,7 +270,7 @@ function resolveGroupCollapsed(group) {
function groupKey(group) {
const base = group.key || group.title || 'group'
if (group.expandWhen) {
return `${base}:${props.formData?.[group.expandWhen] ? 'on' : 'off'}`
return `${base}:${isGroupExpanded(group) ? 'on' : 'off'}`
}
return base
}
+95 -168
View File
@@ -1,9 +1,6 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="用户徽章"
desc="配置勋章分类、自动授予条件(get_type / get_val)与公开展示。路由 /users/badge,存储键 user_medal_*。"
>
<PageHeader title="用户徽章" desc="配置勋章分类、自动授予条件与前台公开展示。">
<template #actions>
<n-space>
<n-button quaternary @click="go('/users/notifications')">消息通知</n-button>
@@ -13,23 +10,6 @@
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>当前摘要</h3>
</div>
<n-space>
<n-tag round :type="form.user_medal_s ? 'success' : 'default'">
徽章系统:{{ form.user_medal_s ? '已启用' : '已关闭' }}
</n-tag>
<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>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>徽章展示</h3>
@@ -38,81 +18,57 @@
<n-form-item label="启用徽章/勋章系统">
<n-switch v-model:value="form.user_medal_s" />
</n-form-item>
<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 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>
<template v-if="form.user_medal_s">
<n-form-item label="公开页显示徽章">
<n-switch v-model:value="form.user_medal_public_display" />
</n-form-item>
<n-form-item label="最多显示数量">
<n-input-number v-model:value="form.user_medal_limit" :min="1" :max="8" style="width: 180px;">
<template #suffix>枚</template>
</n-input-number>
</n-form-item>
</template>
</n-form>
</n-card>
<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 class="aksrt-section-header users-badge__header">
<h3>徽章明细</h3>
<span class="users-badge__stats">{{ form.user_medal_args.length }} 个分类 · {{ itemCount }} 枚徽章</span>
</div>
<p class="users-badge__hint">
拖动手柄调整分类与条目顺序,点击行头展开编辑。获取条件选择「仅手动授予」时不自动发放。
</p>
<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 v-if="!form.user_medal_args.length" class="aksrt-sortable__empty">
<i class="fa fas fa-trophy" aria-hidden="true" />
<span>暂无分类,请添加</span>
</div>
</n-card>
<draggable
v-else
v-model="form.user_medal_args"
v-bind="SORTABLE_LIST_ATTRS"
:item-key="rowKey"
class="aksrt-sortable"
>
<template #item="{ element, index }">
<MedalCategoryCard
:category="element"
:index="index"
:open="cats.isOpen(rowKey(element))"
:head-attrs="cats.headAttrs(rowKey(element))"
@remove="form.user_medal_args.splice(index, 1)"
/>
</template>
</draggable>
<n-card class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
前台已授予徽章保存在用户 meta aksrt_user_badges;佩戴为 aksrt_wear_badge(slug)。
字典来自 user_medal_args;运行时 AKSRT_User_Badges 读取 identity badge.dictionary 派生列表。
</n-alert>
<n-button class="users-badge__add" dashed block @click="addCategory">
<i class="fa fas fa-plus" aria-hidden="true" />&nbsp;添加分类
</n-button>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
<AksSaveBar :dirty="dirty" :saving="saving" @save="handleSave" @reload="load" />
</div>
</template>
@@ -120,16 +76,22 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import draggable from 'vuedraggable'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import { MEDAL_GET_TYPES, defaultMedalArgs } from '@/schema/sections/users/badge.js'
import AksSaveBar from '@/components/form/AksSaveBar.vue'
import { SORTABLE_LIST_ATTRS } from '@/components/form/sortable-options.js'
import { useCollapsibleItems } from '@/components/form/useCollapsibleItems.js'
import { defaultMedalArgs } from '@/schema/sections/users/badge.js'
import { emptyCategory, findInvalidItem, normalizeArgs, rowKey } from './partials/badge/badge-model.js'
import MedalCategoryCard from './partials/badge/MedalCategoryCard.vue'
const router = useRouter()
const message = useMessage()
const cats = useCollapsibleItems()
const loading = ref(true)
const saving = ref(false)
const getTypeOptions = MEDAL_GET_TYPES
const form = reactive({
user_medal_s: false,
@@ -138,82 +100,38 @@ const form = reactive({
user_medal_args: defaultMedalArgs(),
})
const itemCount = computed(() =>
form.user_medal_args.reduce((n, c) => n + (Array.isArray(c.items) ? c.items.length : 0), 0),
)
// 与全局保存栏一致:仅在有未保存更改时显示。以最近一次加载/保存的快照为基准。
const snapshot = ref('')
const serialize = () => JSON.stringify(form)
const dirty = computed(() => !loading.value && snapshot.value !== '' && serialize() !== snapshot.value)
function markClean() {
snapshot.value = serialize()
}
const itemCount = computed(() => form.user_medal_args.reduce((n, c) => n + c.items.length, 0))
function go(path) {
router.push(path)
}
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()] })
form.user_medal_args.push(emptyCategory())
cats.toggle(rowKey(form.user_medal_args[form.user_medal_args.length - 1]))
}
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)
function applySettings(data) {
form.user_medal_s = !!data.user_medal_s
form.user_medal_public_display = data.user_medal_public_display !== false
form.user_medal_limit = Number(data.user_medal_limit ?? 3)
form.user_medal_args = normalizeArgs(data.user_medal_args ?? null)
}
async function load() {
loading.value = true
try {
const schemaRes = await api.getSchemaPage('users', 'badge')
const schemaData = extractData(schemaRes)
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)
const res = await api.getSchemaPage('users', 'badge')
applySettings(res?.data ?? res ?? {})
markClean()
} catch (error) {
message.error(error.message || '加载用户徽章配置失败')
} finally {
@@ -222,18 +140,14 @@ async function load() {
}
async function handleSave() {
const args = normalizeArgs(form.user_medal_args)
const invalid = form.user_medal_s ? findInvalidItem(args) : ''
if (invalid) {
message.error(invalid)
return
}
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_medal_s: !!form.user_medal_s,
user_medal_public_display: !!form.user_medal_public_display,
@@ -241,6 +155,7 @@ async function handleSave() {
user_medal_args: args,
})
form.user_medal_args = args
markClean()
message.success('用户徽章配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
@@ -253,13 +168,25 @@ onMounted(load)
</script>
<style scoped>
.medal-cat-block {
margin-bottom: 16px;
.users-badge__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.medal-item-row {
padding: 12px;
border: 1px solid var(--n-border-color, #efeff5);
border-radius: 8px;
margin-bottom: 8px;
.users-badge__stats {
color: var(--aksrt-color-muted, #6F7A72);
font-size: 12px;
}
.users-badge__hint {
margin: 0 0 12px;
color: var(--aksrt-color-muted, #6F7A72);
font-size: 13px;
}
.users-badge__add {
margin-top: 12px;
}
</style>
@@ -0,0 +1,97 @@
<template>
<div class="aksrt-sortable__item">
<div class="aksrt-sortable__head" v-bind="headAttrs">
<AksSortHandle @click.stop />
<span class="aksrt-sortable__index">{{ index + 1 }}</span>
<div class="aksrt-sortable__main">
<div class="aksrt-sortable__title-row">
<span class="aksrt-sortable__title">{{ category.cat_name || '未命名分类' }}</span>
<n-tag size="small" :bordered="false">{{ category.items.length }} 枚</n-tag>
</div>
<span class="aksrt-sortable__desc">{{ previewNames || '该分类暂无条目' }}</span>
</div>
<div class="aksrt-sortable__actions">
<n-popconfirm @positive-click="emit('remove')">
<template #trigger>
<n-button size="tiny" quaternary type="error" title="删除分类" @click.stop>
<i class="fa fas fa-trash-alt" aria-hidden="true" />
</n-button>
</template>
删除分类「{{ category.cat_name || '未命名分类' }}」及其全部条目?
</n-popconfirm>
</div>
<i class="fa fas aksrt-sortable__toggle" :class="open ? 'fa-chevron-down' : 'fa-chevron-right'" aria-hidden="true" />
</div>
<div v-if="open" class="aksrt-sortable__body">
<n-form label-placement="left" label-width="96" class="aksrt-form">
<n-form-item label="分类名称">
<n-input v-model:value="category.cat_name" placeholder="如:成长" style="max-width: 320px;" />
</n-form-item>
</n-form>
<div v-if="!category.items.length" class="aksrt-sortable__empty">
<i class="fa fas fa-medal" aria-hidden="true" />
<span>该分类暂无条目</span>
</div>
<draggable
v-else
v-model="category.items"
v-bind="SORTABLE_LIST_ATTRS"
:item-key="rowKey"
class="aksrt-sortable aksrt-sortable--compact"
>
<template #item="{ element, index: ii }">
<MedalItemCard
:item="element"
:open="items.isOpen(rowKey(element))"
:head-attrs="items.headAttrs(rowKey(element))"
@remove="category.items.splice(ii, 1)"
/>
</template>
</draggable>
<n-button class="medal-category-card__add" size="small" dashed block @click="addItem">
<i class="fa fas fa-plus" aria-hidden="true" />&nbsp;添加条目
</n-button>
</div>
</div>
</template>
<script setup>
/** 徽章分类卡片:行头显示条目数与名称预览,展开后编辑分类名并拖拽排序条目。 */
import { computed } from 'vue'
import draggable from 'vuedraggable'
import AksSortHandle from '@/components/form/AksSortHandle.vue'
import { SORTABLE_LIST_ATTRS } from '@/components/form/sortable-options.js'
import { useCollapsibleItems } from '@/components/form/useCollapsibleItems.js'
import { emptyItem, rowKey } from './badge-model.js'
import MedalItemCard from './MedalItemCard.vue'
const props = defineProps({
category: { type: Object, required: true },
index: { type: Number, required: true },
open: { type: Boolean, default: false },
headAttrs: { type: Object, required: true },
})
const emit = defineEmits(['remove'])
const items = useCollapsibleItems()
const previewNames = computed(() =>
props.category.items.map((it) => it.name || it.slug).filter(Boolean).slice(0, 4).join('、'),
)
function addItem() {
const item = emptyItem()
props.category.items.push(item)
// 新条目直接展开,方便立即填写。
items.toggle(rowKey(props.category.items[props.category.items.length - 1]))
}
</script>
<style scoped>
.medal-category-card__add {
margin-top: 10px;
}
</style>
@@ -0,0 +1,82 @@
<template>
<div class="medal-icon-picker">
<div class="medal-icon-picker__presets" role="listbox" aria-label="预设勋章图标">
<button
v-for="(src, i) in MEDAL_PRESET_ICONS"
:key="src"
type="button"
role="option"
class="medal-icon-picker__preset"
:class="{ 'is-active': src === modelValue }"
:aria-selected="src === modelValue ? 'true' : 'false'"
:title="`预设图标 ${i + 1}`"
@click="emit('update:modelValue', src)"
>
<img :src="src" :alt="`预设图标 ${i + 1}`" loading="lazy" />
</button>
</div>
<AksMediaField
:model-value="modelValue"
placeholder="或从媒体库选择自定义图标"
@update:model-value="emit('update:modelValue', $event)"
/>
</div>
</template>
<script setup>
/** 勋章图标选择:主题预设 SVG 一键选用,或走媒体库 / 直接填 URL。 */
import AksMediaField from '@/components/form/AksMediaField.vue'
import { MEDAL_PRESET_ICONS } from '@/schema/sections/users/badge.js'
defineProps({
modelValue: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
</script>
<style scoped>
.medal-icon-picker {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.medal-icon-picker__presets {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
gap: 6px;
max-width: 520px;
}
.medal-icon-picker__preset {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0;
border: 1px solid var(--aksrt-color-border, #E2E8E3);
border-radius: 8px;
background: var(--aksrt-color-surface, #fff);
cursor: pointer;
transition: border-color .15s ease, background-color .15s ease;
}
.medal-icon-picker__preset img {
width: 22px;
height: 22px;
object-fit: contain;
}
.medal-icon-picker__preset:hover,
.medal-icon-picker__preset:focus-visible {
border-color: var(--aksrt-color-primary, #18A058);
outline: none;
}
.medal-icon-picker__preset.is-active {
border-color: var(--aksrt-color-primary, #18A058);
background: var(--aksrt-color-primary-soft, #EAF7EF);
box-shadow: 0 0 0 1px var(--aksrt-color-primary, #18A058);
}
</style>
@@ -0,0 +1,79 @@
<template>
<div class="aksrt-sortable__item">
<div class="aksrt-sortable__head" v-bind="headAttrs">
<AksSortHandle @click.stop />
<span class="aksrt-sortable__icon">
<img v-if="item.icon" :src="item.icon" alt="" />
<i v-else class="fa fas fa-medal" aria-hidden="true" />
</span>
<div class="aksrt-sortable__main">
<div class="aksrt-sortable__title-row">
<span class="aksrt-sortable__title">{{ item.name || '未命名徽章' }}</span>
<n-tag size="small" :bordered="false" :type="item.get_type === 'manually_add' ? 'default' : 'info'">
{{ conditionSummary(item) }}
</n-tag>
<n-tag v-if="!item.slug" size="small" type="warning" :bordered="false">缺少 slug</n-tag>
</div>
<span class="aksrt-sortable__desc">{{ item.slug || '—' }}{{ item.desc ? ` · ${item.desc}` : '' }}</span>
</div>
<div class="aksrt-sortable__actions">
<n-button size="tiny" quaternary type="error" title="删除条目" @click.stop="emit('remove')">
<i class="fa fas fa-trash-alt" aria-hidden="true" />
</n-button>
</div>
<i class="fa fas aksrt-sortable__toggle" :class="open ? 'fa-chevron-down' : 'fa-chevron-right'" aria-hidden="true" />
</div>
<div v-if="open" class="aksrt-sortable__body">
<n-form label-placement="left" label-width="96" class="aksrt-form medal-item-card__form">
<n-grid cols="1 m:2" :x-gap="16" responsive="screen">
<n-form-item-gi label="名称">
<n-input v-model:value="item.name" placeholder="如:签到达人" />
</n-form-item-gi>
<n-form-item-gi label="slug">
<n-input v-model:value="item.slug" placeholder="唯一标识,如 checkin-30" />
</n-form-item-gi>
<n-form-item-gi label="获取条件">
<n-select v-model:value="item.get_type" :options="MEDAL_GET_TYPES" />
</n-form-item-gi>
<n-form-item-gi label="达成阈值">
<n-input-number
v-model:value="item.get_val"
:min="0"
:disabled="item.get_type === 'manually_add'"
:placeholder="item.get_type === 'manually_add' ? '手动授予无需阈值' : '达到该数值自动授予'"
style="width: 100%;"
/>
</n-form-item-gi>
<n-form-item-gi :span="2" label="描述">
<n-input v-model:value="item.desc" placeholder="前台展示的获取说明" />
</n-form-item-gi>
<n-form-item-gi :span="2" label="图标">
<MedalIconPicker v-model="item.icon" />
</n-form-item-gi>
</n-grid>
</n-form>
</div>
</div>
</template>
<script setup>
/** 徽章条目卡片:行头摘要(图标 / 名称 / 条件),展开后编辑字段。条目对象由父级直接持有并原地修改。 */
import AksSortHandle from '@/components/form/AksSortHandle.vue'
import { MEDAL_GET_TYPES } from '@/schema/sections/users/badge.js'
import { conditionSummary } from './badge-model.js'
import MedalIconPicker from './MedalIconPicker.vue'
defineProps({
item: { type: Object, required: true },
open: { type: Boolean, default: false },
headAttrs: { type: Object, required: true },
})
const emit = defineEmits(['remove'])
</script>
<style scoped>
.medal-item-card__form :deep(.n-form-item) {
margin-bottom: 4px;
}
</style>
@@ -0,0 +1,63 @@
/**
* 用户徽章编辑页的数据模型:空条目、规范化与拖拽用的稳定行 key。
*/
import { MEDAL_GET_TYPES, defaultMedalArgs } from '@/schema/sections/users/badge.js'
const GET_TYPE_LABELS = Object.fromEntries(MEDAL_GET_TYPES.map((t) => [t.value, t.label]))
export function getTypeLabel(type) {
return GET_TYPE_LABELS[type] || '未设置条件'
}
export function conditionSummary(item) {
if (!item.get_type) return '未设置条件'
if (item.get_type === 'manually_add') return getTypeLabel(item.get_type)
return `${getTypeLabel(item.get_type)} ≥ ${Number(item.get_val || 0)}`
}
export function emptyItem() {
return { slug: '', name: '', desc: '', icon: '', get_type: 'manually_add', get_val: 0 }
}
export function emptyCategory() {
return { cat_name: '新分类', items: [emptyItem()] }
}
export function normalizeArgs(raw) {
if (!Array.isArray(raw)) return defaultMedalArgs()
return raw.map((cat) => ({
cat_name: String(cat?.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 || '').trim(),
get_type: String(it?.get_type || 'manually_add').trim() || 'manually_add',
get_val: Math.max(0, Number(it?.get_val ?? 0) || 0),
}))
: [],
}))
}
/** 首个不完整条目的位置描述;全部完整时返回空字符串。 */
export function findInvalidItem(args) {
const slugs = new Set()
for (const cat of args) {
for (const it of cat.items) {
const where = `「${cat.cat_name}」中的条目「${it.name || it.slug || '未命名'}」`
if (!it.slug || !it.name || !it.get_type) return `${where}缺少 slug、名称或获取条件`
if (slugs.has(it.slug)) return `${where}的 slug「${it.slug}」重复`
slugs.add(it.slug)
}
}
return ''
}
// 对象 → 稳定 key:拖拽换位后展开状态与 DOM 跟随对象本身。
const rowKeys = new WeakMap()
let rowSeq = 0
export function rowKey(obj) {
if (!rowKeys.has(obj)) rowKeys.set(obj, `row-${++rowSeq}`)
return rowKeys.get(obj)
}
@@ -91,7 +91,7 @@ export const gatewaySchema = {
label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>以下收款接口,主题仅提供API接入服务,收款平台的可靠性请自行斟酌!</li><li>涉及到资金及信息安全,有相关执照的商家推荐使用官方接口</li><li>微信 / 支付宝接口可在「官方」与「易支付」之间选择;PayPal 与 Stripe 独立开关</li><li>异步回调由各网关验签,无需额外口令;回调地址固定为 /wp-json/aksrt/v1/payment/{gateway}/{notify|return}</li></ul>',
content: '<ul><li>以下收款接口,主题仅提供API接入服务,收款平台的可靠性请自行斟酌!</li><li>涉及到资金及信息安全,有相关执照的商家推荐使用官方接口</li><li>微信 / 支付宝接口可在「官方」与「易支付」之间选择;PayPal 与 Stripe 独立开关</li><li>异步回调由各网关验签,无需额外口令;回调地址固定为 /wp-json/aksrt/v1/payment/{gateway}/{notify|return}</li></ul>',
},
],
},
@@ -198,7 +198,6 @@ export const gatewaySchema = {
icon: 'fa fas fa-credit-card',
description: '支付宝官方接口(当面付 / 网站应用)。',
collapsible: true,
expandWhen: 'pay_alipay_sdk_options',
dependsOn: { key: 'pay_alipay_sdk_options', value: 'official_alipay' },
fields: [
{
@@ -206,7 +205,7 @@ export const gatewaySchema = {
label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>支付宝公钥为必填项目(公钥错误可以成功付款,但会回调失败)</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/alipay/{notify|return}</li><li>同时填写了企业支付以及当面付参数,则优先使用当面付</li></ul>',
content: '<ul><li>支付宝公钥为必填项目(公钥错误可以成功付款,但会回调失败)</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/alipay/{notify|return}</li><li>同时填写了企业支付以及当面付参数,则优先使用当面付</li></ul>',
},
{ key: 'alipay_appid', label: '当面付:APPID', type: 'input', default: '' },
{ key: 'alipay_privatekey', label: '当面付:应用私钥', type: 'textarea', sanitize_type: 'code', default: '', rows: 4 },
@@ -231,7 +230,6 @@ export const gatewaySchema = {
icon: 'fa fab fa-weixin',
description: '微信企业支付官方接口。',
collapsible: true,
expandWhen: 'pay_wechat_sdk_options',
dependsOn: { key: 'pay_wechat_sdk_options', value: 'official_wechat' },
fields: [
{
@@ -239,7 +237,7 @@ export const gatewaySchema = {
label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>微信企业支付:官方接口,商家可申请,有年费,申请较为复杂</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/wechatpay/{notify|return},请配置 Native 支付回调链接</li></ul>',
content: '<ul><li>微信企业支付:官方接口,商家可申请,有年费,申请较为复杂</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/wechatpay/{notify|return},请配置 Native 支付回调链接</li></ul>',
},
{ key: 'wechat_merchantid', label: '商户号 PartnerID', type: 'input', default: '' },
{ key: 'wechat_appid', label: '授权绑定的AppID', type: 'input', default: '' },
@@ -253,7 +251,7 @@ export const gatewaySchema = {
{ label: 'H5支付', value: 'h5' },
],
},
{ key: 'wechat_api_v3_key', label: '支付APIv3密钥', type: 'input', default: '', help: '微信商户平台设置的 APIv3 密钥(32位)。' },
{ key: 'wechat_api_v3_key', label: '支付APIv3密钥', type: 'input', inputType: 'password', secret: true, default: '', help: '微信商户平台设置的 APIv3 密钥(32位)。' },
{ key: 'wechat_private_key_path', label: '商户私钥路径', type: 'input', default: '', help: 'apiclient_key.pem 绝对路径,用于签名。' },
{ key: 'wechat_cert_path', label: '商户API证书路径', type: 'input', default: '', help: 'apiclient_cert.pem 绝对路径。' },
],
@@ -264,7 +262,6 @@ export const gatewaySchema = {
icon: 'fa fab fa-paypal',
description: '常见的海外支付接口。',
collapsible: true,
expandWhen: 'pay_paypal_sdk_s',
dependsOn: { key: 'pay_paypal_sdk_s', value: true },
fields: [
{
@@ -272,10 +269,10 @@ export const gatewaySchema = {
label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>PayPal(贝宝)是常见的海外支付接口</li><li>如果您的收款账户为中国大陆地区,则账户类型必须是企业,且只能收取非中国大陆区的PayPal付款</li><li>REST 应用申请:https://developer.paypal.com/dashboard/applications</li></ul>',
content: '<ul><li>PayPal(贝宝)是常见的海外支付接口</li><li>如果您的收款账户为中国大陆地区,则账户类型必须是企业,且只能收取非中国大陆区的PayPal付款</li><li>REST 应用申请:https://developer.paypal.com/dashboard/applications</li></ul>',
},
{ key: 'paypal_client_id', label: 'Client ID', type: 'input', default: '' },
{ key: 'paypal_client_secret', label: 'Client Secret', type: 'input', default: '' },
{ key: 'paypal_client_secret', label: 'Client Secret', type: 'input', inputType: 'password', secret: true, default: '' },
{ key: 'paypal_currency', label: '结算货币', type: 'select', case_insensitive: true, default: 'USD', options: currencyOptions },
{ key: 'paypal_rates', label: '货币汇率', type: 'input', default: '0.14', help: '根据你选择的货币类型与您定价货币的汇率进行填写,例如1元人民币兑换0.137美元(USD),则此处填0.137' },
{ key: 'paypal_sandbox', label: '沙盒调试模式', type: 'switch', default: false, help: '沙箱模式(Sandbox)是PayPal支付的测试环境。开启后请使用沙箱环境生成的收款账户和付款账户进行测试。' },
@@ -287,7 +284,6 @@ export const gatewaySchema = {
icon: 'fa fab fa-cc-stripe',
description: '常见的海外信用卡收款接口。',
collapsible: true,
expandWhen: 'pay_stripe_sdk_s',
dependsOn: { key: 'pay_stripe_sdk_s', value: true },
fields: [
{
@@ -295,11 +291,11 @@ export const gatewaySchema = {
label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>Stripe 是常见的海外信用卡收款接口</li><li>API 密钥申请:https://dashboard.stripe.com/apikeys</li><li>Webhook 事件:checkout.session.completed</li><li>测试环境请使用 sk_test_ 开头的 Secret Key</li></ul>',
content: '<ul><li>Stripe 是常见的海外信用卡收款接口</li><li>API 密钥申请:https://dashboard.stripe.com/apikeys</li><li>Webhook 事件:checkout.session.completed</li><li>测试环境请使用 sk_test_ 开头的 Secret Key</li></ul>',
},
{ key: 'stripe_secret_key', label: '私钥Secret Key', type: 'input', default: '', help: 'Stripe Dashboard → Developers → API keys → Secret key' },
{ key: 'stripe_secret_key', label: '私钥Secret Key', type: 'input', inputType: 'password', secret: true, default: '', help: 'Stripe Dashboard → Developers → API keys → Secret key' },
{ key: 'stripe_publishable_key', label: 'Publishable Key', type: 'input', default: '' },
{ key: 'stripe_webhook_secret', label: '签名密钥Webhook Secret', type: 'input', default: '', help: 'Developers → Webhooks → 端点 → Signing secret(whsec_ 开头)' },
{ key: 'stripe_webhook_secret', label: '签名密钥Webhook Secret', type: 'input', inputType: 'password', secret: true, default: '', help: 'Developers → Webhooks → 端点 → Signing secret(whsec_ 开头)' },
{ key: 'stripe_currency', label: '结算货币', type: 'select', case_insensitive: true, default: 'USD', options: currencyOptions },
{ key: 'stripe_rates', label: '货币汇率', type: 'input', default: '0.14', help: '根据你选择的货币类型与您定价货币的汇率进行填写,例如1元人民币兑换0.137美元(USD),则此处填0.137' },
{ key: 'stripe_test_mode', label: '测试模式', type: 'switch', default: false, help: '如果使用的测试开放模式,则请使用 sk_test_ 测试密钥与 Stripe 测试卡进行配置' },
@@ -311,7 +307,6 @@ export const gatewaySchema = {
icon: 'fa fas fa-university',
description: '易支付(彩虹协议)聚合收银台;通道由上方微信/支付宝接口选择控制。',
collapsible: true,
expandWhen: 'pay_wechat_sdk_options',
dependsOn: {
any: [
{ key: 'pay_wechat_sdk_options', value: 'epay' },
@@ -324,11 +319,11 @@ export const gatewaySchema = {
label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>易支付是一个常见的支付系统源码,任何人都可以下载并搭建</li><li>同时市面上多数的小规模支付平台都是由易支付修改而来,例如部分码支付、源支付等等</li><li>由于此接口服务商众多,主题只负责技术接入,平台可靠性请自行斟酌</li></ul>',
content: '<ul><li>易支付是一个常见的支付系统源码,任何人都可以下载并搭建</li><li>同时市面上多数的小规模支付平台都是由易支付修改而来,例如部分码支付、源支付等等</li><li>由于此接口服务商众多,主题只负责技术接入,平台可靠性请自行斟酌</li></ul>',
},
{ key: 'epay_apiurl', label: 'API接口网址', type: 'input', default: '', help: '请填写接口地址,例如:https://pay.xxxxxxx.cn/' },
{ key: 'epay_pid', label: '商户号', type: 'input', default: '' },
{ key: 'epay_key', label: '商户秘钥', type: 'input', default: '', help: 'MD5 通信密钥。' },
{ key: 'epay_key', label: '商户秘钥', type: 'input', inputType: 'password', secret: true, default: '', help: 'MD5 通信密钥。' },
{
key: 'epay_device',
label: '下单终端',
@@ -1,6 +1,6 @@
/**
* User medal catalog (SPA route remains /users/badge).
* Storage keys: user_medal_* (legacy user_medal_s / user_medal_args parity).
* 用户徽章字典(路由 /users/badge,存储键 user_medal_*)。
* 自定义页 pages/users/UsersBadge.vue 负责编辑,本文件提供获取条件、预设图标与默认字典。
*/
export const MEDAL_GET_TYPES = [
@@ -23,7 +23,10 @@ const MEDAL_BASE = (() => {
return root ? String(root).replace(/\/$/, '') + '/assets/img/medal' : '/assets/img/medal'
})()
const medalIcon = (n) => `${MEDAL_BASE}/medal-${n}.svg`
export const medalIcon = (n) => `${MEDAL_BASE}/medal-${n}.svg`
/** 主题自带的 30 枚预设勋章图标(assets/img/medal/medal-1..30.svg)。 */
export const MEDAL_PRESET_ICONS = Array.from({ length: 30 }, (_, i) => medalIcon(i + 1))
export const defaultMedalArgs = () => [
{
@@ -90,7 +93,7 @@ export const badgeSchema = {
key: 'users.badge',
title: '用户徽章',
icon: 'fa fas fa-trophy',
description: '配置勋章分类、自动授予条件与前台展示。路由仍为 /users/badge;存储键为 user_medal_*。',
description: '配置勋章分类、自动授予条件与前台展示。',
defaults: {
user_medal_s: false,
user_medal_public_display: true,
@@ -83,6 +83,8 @@ export const oauthSchema = {
key: 'github',
title: 'GitHub配置',
icon: 'fa fab fa-github',
collapsible: true,
expandWhen: 'oauth_github_s',
fields: [
{ key: 'github_appid', label: 'Client ID', type: 'input', default: '' },
{ key: 'github_appkey', label: 'Client Secret', type: 'input', inputType: 'password', secret: true, default: '' },
@@ -92,6 +94,8 @@ export const oauthSchema = {
key: 'gitee',
title: 'Gitee配置',
icon: 'fa fas fa-code-branch',
collapsible: true,
expandWhen: 'oauth_gitee_s',
fields: [
{ key: 'gitee_appid', label: 'Client ID', type: 'input', default: '' },
{ key: 'gitee_appkey', label: 'Client Secret', type: 'input', inputType: 'password', secret: true, default: '' },
@@ -101,6 +105,8 @@ export const oauthSchema = {
key: 'qq',
title: 'QQ配置',
icon: 'fa fab fa-qq',
collapsible: true,
expandWhen: 'oauth_qq_s',
fields: [
{ key: 'qq_appid', label: 'App ID', type: 'input', default: '' },
{ key: 'qq_appkey', label: 'App Key', type: 'input', inputType: 'password', secret: true, default: '' },
@@ -110,6 +116,8 @@ export const oauthSchema = {
key: 'google',
title: 'Google配置',
icon: 'fa fab fa-google',
collapsible: true,
expandWhen: 'oauth_google_s',
fields: [
{ key: 'google_client_id', label: 'Client ID', type: 'input', default: '' },
{ key: 'google_client_secret', label: 'Client Secret', type: 'input', inputType: 'password', secret: true, default: '' },
@@ -119,6 +127,8 @@ export const oauthSchema = {
key: 'weibo',
title: '微博配置',
icon: 'fa fab fa-weibo',
collapsible: true,
expandWhen: 'oauth_weibo_s',
fields: [
{ key: 'weibo_appid', label: 'App Key', type: 'input', default: '' },
{ key: 'weibo_appkey', label: 'App Secret', type: 'input', inputType: 'password', secret: true, default: '' },
@@ -128,6 +138,8 @@ export const oauthSchema = {
key: 'weixin',
title: '微信开放平台配置',
icon: 'fa fab fa-weixin',
collapsible: true,
expandWhen: 'oauth_weixin_s',
fields: [
{ key: 'weixin_appid', label: 'AppID', type: 'input', default: '' },
{ key: 'weixin_appsecret', label: 'AppSecret', type: 'input', inputType: 'password', secret: true, default: '' },
@@ -137,6 +149,8 @@ export const oauthSchema = {
key: 'weixingzh',
title: '微信公众号配置',
icon: 'fa fab fa-weixin',
collapsible: true,
expandWhen: 'oauth_weixingzh_s',
fields: [
{ key: 'weixingzh_gzh_type', label: '公众号类型', type: 'radio', default: 'service', options: [{ label: '服务号', value: 'service' }, { label: '订阅号', value: 'subscribe' }] },
{ key: 'weixingzh_token', label: 'Token', type: 'input', inputType: 'password', secret: true, default: '' },
@@ -153,15 +167,17 @@ export const oauthSchema = {
key: 'clogin',
title: '彩虹聚合登录',
icon: 'fa fas fa-cloud',
collapsible: true,
dependsOn: { key: 'clogin_s', value: true },
fields: [
{ key: 'clogin_url', label: '接口地址', type: 'input', default: '', help: '如 https://xxx.com,不要带 connect.php', dependsOn: { key: 'clogin_s', value: true } },
{ key: 'clogin_id', label: 'AppID', type: 'input', default: '', dependsOn: { key: 'clogin_s', value: true } },
{ key: 'clogin_key', label: 'AppKey', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'clogin_s', value: true } },
{ key: 'clogin_url', label: '接口地址', type: 'input', default: '', help: '如 https://xxx.com,不要带 connect.php' },
{ key: 'clogin_id', label: 'AppID', type: 'input', default: '' },
{ key: 'clogin_key', label: 'AppKey', type: 'input', inputType: 'password', secret: true, default: '' },
{ key: 'clogin_oauth_s', label: '启用平台', type: 'checkbox', default: ['qq', 'weixin', 'weibo'], options: [
{ label: 'QQ', value: 'qq' },
{ label: '微信', value: 'weixin' },
{ label: '微博', value: 'weibo' },
], dependsOn: { key: 'clogin_s', value: true } },
] },
],
},
],
+16 -16
View File
@@ -439,7 +439,7 @@ class SchemaPayment {
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'notice', 'title' => '说明', 'fields' => [
['key' => 'gateway_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul style="margin:0 0 0 18px;line-height:1.9"><li>以下收款接口,主题仅提供API接入服务,收款平台的可靠性请自行斟酌!</li><li>涉及到资金及信息安全,有相关执照的商家推荐使用官方接口</li><li>微信 / 支付宝接口可在「官方」与「易支付」之间选择;PayPal 与 Stripe 独立开关</li><li>异步回调由各网关验签,无需额外口令;回调地址固定为 /wp-json/aksrt/v1/payment/{gateway}/{notify|return}</li></ul>'],
['key' => 'gateway_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul><li>以下收款接口,主题仅提供API接入服务,收款平台的可靠性请自行斟酌!</li><li>涉及到资金及信息安全,有相关执照的商家推荐使用官方接口</li><li>微信 / 支付宝接口可在「官方」与「易支付」之间选择;PayPal 与 Stripe 独立开关</li><li>异步回调由各网关验签,无需额外口令;回调地址固定为 /wp-json/aksrt/v1/payment/{gateway}/{notify|return}</li></ul>'],
]],
['key' => 'channels', 'title' => '收款接口选择', 'fields' => [
['key' => 'pay_wechat_sdk_options', 'label' => '微信收款接口', 'type' => 'select', 'default' => 'null', 'options' => [
@@ -485,8 +485,8 @@ class SchemaPayment {
['key' => 'card_pass', 'label' => '卡密'],
], 'help' => '收银台支付方式显示顺序;仅显示已开启的接口'],
]],
['key' => 'alipay', 'title' => '支付宝官方', 'collapsible' => true, 'expandWhen' => 'pay_alipay_sdk_options', 'dependsOn' => ['key' => 'pay_alipay_sdk_options', 'value' => 'official_alipay'], 'fields' => [
['key' => 'alipay_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul style="margin:0 0 0 18px;line-height:1.9"><li>支付宝公钥为必填项目(公钥错误可以成功付款,但会回调失败)</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/alipay/{notify|return}</li><li>同时填写了企业支付以及当面付参数,则优先使用当面付</li></ul>'],
['key' => 'alipay', 'title' => '支付宝官方', 'collapsible' => true, 'dependsOn' => ['key' => 'pay_alipay_sdk_options', 'value' => 'official_alipay'], 'fields' => [
['key' => 'alipay_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul><li>支付宝公钥为必填项目(公钥错误可以成功付款,但会回调失败)</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/alipay/{notify|return}</li><li>同时填写了企业支付以及当面付参数,则优先使用当面付</li></ul>'],
['key' => 'alipay_appid', 'label' => '当面付:APPID', 'type' => 'input', 'default' => ''],
['key' => 'alipay_privatekey', 'label' => '当面付:应用私钥', 'type' => 'textarea', 'sanitize_type' => 'code', 'default' => '', 'rows' => 4],
['key' => 'alipay_payment_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'page', 'options' => [
@@ -497,43 +497,43 @@ class SchemaPayment {
['key' => 'alipay_public_cert_path', 'label' => '支付宝公钥证书路径', 'type' => 'input', 'default' => '', 'help' => 'alipayCertPublicKey_RSA2.crt 绝对路径。'],
['key' => 'alipay_root_cert_path', 'label' => '支付宝根证书路径', 'type' => 'input', 'default' => '', 'help' => 'alipayRootCert.crt 绝对路径。'],
]],
['key' => 'wechat', 'title' => '微信官方', 'collapsible' => true, 'expandWhen' => 'pay_wechat_sdk_options', 'dependsOn' => ['key' => 'pay_wechat_sdk_options', 'value' => 'official_wechat'], 'fields' => [
['key' => 'wechat_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul style="margin:0 0 0 18px;line-height:1.9"><li>微信企业支付:官方接口,商家可申请,有年费,申请较为复杂</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/wechatpay/{notify|return},请配置 Native 支付回调链接</li></ul>'],
['key' => 'wechat', 'title' => '微信官方', 'collapsible' => true, 'dependsOn' => ['key' => 'pay_wechat_sdk_options', 'value' => 'official_wechat'], 'fields' => [
['key' => 'wechat_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul><li>微信企业支付:官方接口,商家可申请,有年费,申请较为复杂</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/wechatpay/{notify|return},请配置 Native 支付回调链接</li></ul>'],
['key' => 'wechat_merchantid', 'label' => '商户号 PartnerID', 'type' => 'input', 'default' => ''],
['key' => 'wechat_appid', 'label' => '授权绑定的AppID', 'type' => 'input', 'default' => ''],
['key' => 'wechat_payment_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'native', 'options' => [
['label' => '扫码支付(Native)', 'value' => 'native'],
['label' => 'H5支付', 'value' => 'h5'],
]],
['key' => 'wechat_api_v3_key', 'label' => '支付APIv3密钥', 'type' => 'input', 'default' => '', 'help' => '微信商户平台设置的 APIv3 密钥(32位)。'],
['key' => 'wechat_api_v3_key', 'label' => '支付APIv3密钥', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'help' => '微信商户平台设置的 APIv3 密钥(32位)。'],
['key' => 'wechat_private_key_path', 'label' => '商户私钥路径', 'type' => 'input', 'default' => '', 'help' => 'apiclient_key.pem 绝对路径,用于签名。'],
['key' => 'wechat_cert_path', 'label' => '商户API证书路径', 'type' => 'input', 'default' => '', 'help' => 'apiclient_cert.pem 绝对路径。'],
]],
['key' => 'paypal', 'title' => 'PayPal(贝宝)', 'collapsible' => true, 'expandWhen' => 'pay_paypal_sdk_s', 'dependsOn' => ['key' => 'pay_paypal_sdk_s', 'value' => true], 'fields' => [
['key' => 'paypal_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul style="margin:0 0 0 18px;line-height:1.9"><li>PayPal(贝宝)是常见的海外支付接口</li><li>如果您的收款账户为中国大陆地区,则账户类型必须是企业,且只能收取非中国大陆区的PayPal付款</li><li>REST 应用申请:https://developer.paypal.com/dashboard/applications</li></ul>'],
['key' => 'paypal', 'title' => 'PayPal(贝宝)', 'collapsible' => true, 'dependsOn' => ['key' => 'pay_paypal_sdk_s', 'value' => true], 'fields' => [
['key' => 'paypal_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul><li>PayPal(贝宝)是常见的海外支付接口</li><li>如果您的收款账户为中国大陆地区,则账户类型必须是企业,且只能收取非中国大陆区的PayPal付款</li><li>REST 应用申请:https://developer.paypal.com/dashboard/applications</li></ul>'],
['key' => 'paypal_client_id', 'label' => 'Client ID', 'type' => 'input', 'default' => ''],
['key' => 'paypal_client_secret', 'label' => 'Client Secret', 'type' => 'input', 'default' => ''],
['key' => 'paypal_client_secret', 'label' => 'Client Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
['key' => 'paypal_currency', 'label' => '结算货币', 'type' => 'select', 'case_insensitive' => true, 'default' => 'USD', 'options' => self::currency_options()],
['key' => 'paypal_rates', 'label' => '货币汇率', 'type' => 'input', 'default' => '0.14', 'help' => '根据你选择的货币类型与您定价货币的汇率进行填写,例如1元人民币兑换0.137美元(USD),则此处填0.137'],
['key' => 'paypal_sandbox', 'label' => '沙盒调试模式', 'type' => 'switch', 'default' => false, 'help' => '沙箱模式(Sandbox)是PayPal支付的测试环境。开启后请使用沙箱环境生成的收款账户和付款账户进行测试。'],
]],
['key' => 'stripe', 'title' => 'Stripe', 'collapsible' => true, 'expandWhen' => 'pay_stripe_sdk_s', 'dependsOn' => ['key' => 'pay_stripe_sdk_s', 'value' => true], 'fields' => [
['key' => 'stripe_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul style="margin:0 0 0 18px;line-height:1.9"><li>Stripe 是常见的海外信用卡收款接口</li><li>API 密钥申请:https://dashboard.stripe.com/apikeys</li><li>Webhook 事件:checkout.session.completed</li><li>测试环境请使用 sk_test_ 开头的 Secret Key</li></ul>'],
['key' => 'stripe_secret_key', 'label' => '私钥Secret Key', 'type' => 'input', 'default' => '', 'help' => 'Stripe Dashboard → Developers → API keys → Secret key'],
['key' => 'stripe', 'title' => 'Stripe', 'collapsible' => true, 'dependsOn' => ['key' => 'pay_stripe_sdk_s', 'value' => true], 'fields' => [
['key' => 'stripe_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul><li>Stripe 是常见的海外信用卡收款接口</li><li>API 密钥申请:https://dashboard.stripe.com/apikeys</li><li>Webhook 事件:checkout.session.completed</li><li>测试环境请使用 sk_test_ 开头的 Secret Key</li></ul>'],
['key' => 'stripe_secret_key', 'label' => '私钥Secret Key', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'help' => 'Stripe Dashboard → Developers → API keys → Secret key'],
['key' => 'stripe_publishable_key', 'label' => 'Publishable Key', 'type' => 'input', 'default' => ''],
['key' => 'stripe_webhook_secret', 'label' => '签名密钥Webhook Secret', 'type' => 'input', 'default' => '', 'help' => 'Developers → Webhooks → 端点 → Signing secret(whsec_ 开头)'],
['key' => 'stripe_webhook_secret', 'label' => '签名密钥Webhook Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'help' => 'Developers → Webhooks → 端点 → Signing secret(whsec_ 开头)'],
['key' => 'stripe_currency', 'label' => '结算货币', 'type' => 'select', 'case_insensitive' => true, 'default' => 'USD', 'options' => self::currency_options()],
['key' => 'stripe_rates', 'label' => '货币汇率', 'type' => 'input', 'default' => '0.14', 'help' => '根据你选择的货币类型与您定价货币的汇率进行填写,例如1元人民币兑换0.137美元(USD),则此处填0.137'],
['key' => 'stripe_test_mode', 'label' => '测试模式', 'type' => 'switch', 'default' => false, 'help' => '如果使用的测试开放模式,则请使用 sk_test_ 测试密钥与 Stripe 测试卡进行配置'],
]],
['key' => 'epay', 'title' => '易支付', 'collapsible' => true, 'expandWhen' => 'pay_wechat_sdk_options', 'dependsOn' => ['any' => [
['key' => 'epay', 'title' => '易支付', 'collapsible' => true, 'dependsOn' => ['any' => [
['key' => 'pay_wechat_sdk_options', 'value' => 'epay'],
['key' => 'pay_alipay_sdk_options', 'value' => 'epay'],
]], 'fields' => [
['key' => 'epay_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul style="margin:0 0 0 18px;line-height:1.9"><li>易支付是一个常见的支付系统源码,任何人都可以下载并搭建</li><li>同时市面上多数的小规模支付平台都是由易支付修改而来,例如部分码支付、源支付等等</li><li>由于此接口服务商众多,主题只负责技术接入,平台可靠性请自行斟酌</li></ul>'],
['key' => 'epay_notice', 'label' => '', 'type' => 'content', 'ui_only' => true, 'content' => '<ul><li>易支付是一个常见的支付系统源码,任何人都可以下载并搭建</li><li>同时市面上多数的小规模支付平台都是由易支付修改而来,例如部分码支付、源支付等等</li><li>由于此接口服务商众多,主题只负责技术接入,平台可靠性请自行斟酌</li></ul>'],
['key' => 'epay_apiurl', 'label' => 'API接口网址', 'type' => 'input', 'default' => '', 'help' => '请填写接口地址,例如:https://pay.xxxxxxx.cn/'],
['key' => 'epay_pid', 'label' => '商户号', 'type' => 'input', 'default' => ''],
['key' => 'epay_key', 'label' => '商户秘钥', 'type' => 'input', 'default' => '', 'help' => 'MD5 通信密钥。'],
['key' => 'epay_key', 'label' => '商户秘钥', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'help' => 'MD5 通信密钥。'],
['key' => 'epay_device', 'label' => '下单终端', 'type' => 'select', 'default' => 'auto', 'options' => [
['label' => '自动(按 UA)', 'value' => 'auto'],
['label' => '电脑端 submit', 'value' => 'pc'],
+13 -13
View File
@@ -334,33 +334,33 @@ class SchemaUsers {
['key' => 'oauth_weibo_s', 'label' => '微博登录', 'type' => 'switch', 'default' => false],
['key' => 'oauth_weixin_s', 'label' => '微信开放平台登录', 'type' => 'switch', 'default' => false],
['key' => 'oauth_weixingzh_s', 'label' => '微信公众号登录', 'type' => 'switch', 'default' => false],
['key' => 'clogin_s', 'label' => '彩虹聚合登录', 'type' => 'switch', 'default' => false],
['key' => 'clogin_s', 'label' => '彩虹聚合登录', 'type' => 'switch', 'default' => false, 'help' => '通过第三方聚合登录平台统一接入 QQ、微信和微博'],
]],
['key' => 'github', 'title' => 'GitHub配置', 'fields' => [
['key' => 'github', 'title' => 'GitHub配置', 'collapsible' => true, 'expandWhen' => 'oauth_github_s', 'fields' => [
['key' => 'github_appid', 'label' => 'Client ID', 'type' => 'input', 'default' => ''],
['key' => 'github_appkey', 'label' => 'Client Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'gitee', 'title' => 'Gitee配置', 'fields' => [
['key' => 'gitee', 'title' => 'Gitee配置', 'collapsible' => true, 'expandWhen' => 'oauth_gitee_s', 'fields' => [
['key' => 'gitee_appid', 'label' => 'Client ID', 'type' => 'input', 'default' => ''],
['key' => 'gitee_appkey', 'label' => 'Client Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'qq', 'title' => 'QQ配置', 'fields' => [
['key' => 'qq', 'title' => 'QQ配置', 'collapsible' => true, 'expandWhen' => 'oauth_qq_s', 'fields' => [
['key' => 'qq_appid', 'label' => 'App ID', 'type' => 'input', 'default' => ''],
['key' => 'qq_appkey', 'label' => 'App Key', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'google', 'title' => 'Google配置', 'fields' => [
['key' => 'google', 'title' => 'Google配置', 'collapsible' => true, 'expandWhen' => 'oauth_google_s', 'fields' => [
['key' => 'google_client_id', 'label' => 'Client ID', 'type' => 'input', 'default' => ''],
['key' => 'google_client_secret', 'label' => 'Client Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'weibo', 'title' => '微博配置', 'fields' => [
['key' => 'weibo', 'title' => '微博配置', 'collapsible' => true, 'expandWhen' => 'oauth_weibo_s', 'fields' => [
['key' => 'weibo_appid', 'label' => 'App Key', 'type' => 'input', 'default' => ''],
['key' => 'weibo_appkey', 'label' => 'App Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'weixin', 'title' => '微信开放平台配置', 'fields' => [
['key' => 'weixin', 'title' => '微信开放平台配置', 'collapsible' => true, 'expandWhen' => 'oauth_weixin_s', 'fields' => [
['key' => 'weixin_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''],
['key' => 'weixin_appsecret', 'label' => 'AppSecret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'weixingzh', 'title' => '微信公众号配置', 'fields' => [
['key' => 'weixingzh', 'title' => '微信公众号配置', 'collapsible' => true, 'expandWhen' => 'oauth_weixingzh_s', 'fields' => [
['key' => 'weixingzh_gzh_type', 'label' => '公众号类型', 'type' => 'radio', 'default' => 'service', 'options' => [
['label' => '服务号', 'value' => 'service'],
['label' => '订阅号', 'value' => 'subscribe'],
@@ -374,15 +374,15 @@ class SchemaUsers {
['key' => 'weixingzh_subscribe_msg', 'label' => '关注后回复', 'type' => 'textarea', 'default' => '', 'help' => '用户关注公众号后的自动回复文案'],
['key' => 'weixingzh_scan_msg', 'label' => '扫码回复', 'type' => 'textarea', 'default' => '', 'help' => '用户扫码关注公众号后的回复文案'],
]],
['key' => 'clogin', 'title' => '彩虹聚合登录', 'fields' => [
['key' => 'clogin_url', 'label' => '接口地址', 'type' => 'input', 'sanitize_type' => 'url', 'default' => '', 'dependsOn' => ['key' => 'clogin_s', 'value' => true], 'help' => '如 https://xxx.com,不要带 connect.php'],
['key' => 'clogin_id', 'label' => 'AppID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'clogin_s', 'value' => true]],
['key' => 'clogin_key', 'label' => 'AppKey', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'dependsOn' => ['key' => 'clogin_s', 'value' => true]],
['key' => 'clogin', 'title' => '彩虹聚合登录', 'collapsible' => true, 'dependsOn' => ['key' => 'clogin_s', 'value' => true], 'fields' => [
['key' => 'clogin_url', 'label' => '接口地址', 'type' => 'input', 'sanitize_type' => 'url', 'default' => '', 'help' => '如 https://xxx.com,不要带 connect.php'],
['key' => 'clogin_id', 'label' => 'AppID', 'type' => 'input', 'default' => ''],
['key' => 'clogin_key', 'label' => 'AppKey', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
['key' => 'clogin_oauth_s', 'label' => '启用平台', 'type' => 'checkbox', 'default' => ['qq', 'weixin', 'weibo'], 'options' => [
['label' => 'QQ', 'value' => 'qq'],
['label' => '微信', 'value' => 'weixin'],
['label' => '微博', 'value' => 'weibo'],
], 'dependsOn' => ['key' => 'clogin_s', 'value' => true]],
]],
]],
],
],
+2 -12
View File
@@ -282,7 +282,7 @@ class UserRuntime {
if (!is_array($cat)) {
continue;
}
$cat_name = sanitize_text_field((string) ($cat['cat_name'] ?? $cat['name'] ?? ''));
$cat_name = sanitize_text_field((string) ($cat['cat_name'] ?? ''));
if ($cat_name === '') {
$cat_name = __('未命名', 'aksrt');
}
@@ -301,7 +301,7 @@ class UserRuntime {
if (!in_array($get_type, $allowed_types, true)) {
$get_type = 'manually_add';
}
$icon = esc_url_raw((string) ($item['icon'] ?? $item['icon_url'] ?? ''));
$icon = esc_url_raw((string) ($item['icon'] ?? ''));
$row = [
'slug' => $slug,
'name' => $name,
@@ -358,14 +358,4 @@ class UserRuntime {
ksort($thresholds, SORT_NUMERIC);
return $thresholds;
}
/**
* @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 {
// Rewrite policy: no dual-read of pipe dictionary. Callers must use parse_medal_catalog / get_user_identity_settings()['medal'].
unset($raw);
return [];
}
}