Compare commits
22
Commits
6dc9265260
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a120f61e13 | ||
|
|
0d51beeaad | ||
|
|
573040381d | ||
|
|
7a0445fc4a | ||
|
|
0bc72616e5 | ||
|
|
064f4e0955 | ||
|
|
4b5098a753 | ||
|
|
05f16d5fd7 | ||
|
|
af8dccdc45 | ||
|
|
dcb80cff6e | ||
|
|
2e2558e5ce | ||
|
|
817ac9c647 | ||
|
|
e94ecdd7b1 | ||
|
|
659b77719c | ||
|
|
7f556892ad | ||
|
|
cb76b95b01 | ||
|
|
319ec2f47f | ||
|
|
4b18f7ef2c | ||
|
|
1461c9fe27 | ||
|
|
5641c40958 | ||
|
|
0bee2c605e | ||
|
|
c472045ce4 |
No files matched your search
@@ -476,14 +476,43 @@
|
||||
|
||||
<!-- Fieldset (single nested object) -->
|
||||
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
|
||||
<AksField
|
||||
v-for="field in visibleNestedFields"
|
||||
:key="field.key"
|
||||
:schema="field"
|
||||
:model-value="fieldsetValue(field)"
|
||||
:form-data="formData"
|
||||
@update:model-value="updateFieldsetValue(field.key, $event)"
|
||||
/>
|
||||
<template v-if="fieldsetSections.length">
|
||||
<div
|
||||
v-for="(section, sIndex) in fieldsetSections"
|
||||
:key="section.title"
|
||||
class="aksrt-field__accordion-section"
|
||||
:class="{ 'is-collapsed': !isFieldsetSectionOpen(sIndex) }"
|
||||
>
|
||||
<div class="aksrt-field__accordion-section-head" @click="toggleFieldsetSection(sIndex)">
|
||||
<span class="aksrt-field__accordion-section-title">{{ section.title }}</span>
|
||||
<FaIcon
|
||||
:icon="isFieldsetSectionOpen(sIndex) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
|
||||
:size="12"
|
||||
class="aksrt-field__accordion-section-toggle"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="isFieldsetSectionOpen(sIndex)" class="aksrt-field__accordion-section-body">
|
||||
<AksField
|
||||
v-for="field in sectionFields(section)"
|
||||
:key="field.key"
|
||||
:schema="field"
|
||||
:model-value="fieldsetValue(field)"
|
||||
:form-data="formData"
|
||||
@update:model-value="updateFieldsetValue(field.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="!fieldsetSections.length">
|
||||
<AksField
|
||||
v-for="field in visibleNestedFields"
|
||||
:key="field.key"
|
||||
:schema="field"
|
||||
:model-value="fieldsetValue(field)"
|
||||
:form-data="formData"
|
||||
@update:model-value="updateFieldsetValue(field.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else-if="schema.type === 'link'" class="aksrt-field__link">
|
||||
@@ -517,23 +546,38 @@
|
||||
animation="180"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="aksrt-field__repeater-item">
|
||||
<div class="aksrt-field__repeater-header">
|
||||
<div class="aksrt-field__repeater-item" :class="{ 'is-collapsible': repeaterCollapsible, 'is-collapsed': repeaterCollapsible && !isRepeaterItemOpen(index) }">
|
||||
<div
|
||||
class="aksrt-field__repeater-header"
|
||||
:class="{ 'is-clickable': repeaterCollapsible }"
|
||||
@click="repeaterCollapsible ? toggleRepeaterItem(index) : undefined"
|
||||
>
|
||||
<div class="aksrt-field__repeater-title">
|
||||
<i class="fa fas fa-grip-vertical aksrt-field__repeater-handle" aria-hidden="true"></i>
|
||||
<i class="fa fas fa-grip-vertical aksrt-field__repeater-handle" aria-hidden="true" @click.stop></i>
|
||||
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
|
||||
<span v-if="repeaterCollapsible && repeaterItemSummary(element)" class="aksrt-field__repeater-summary">
|
||||
{{ repeaterItemSummary(element) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="aksrt-field__repeater-header-actions" @click.stop>
|
||||
<FaIcon
|
||||
v-if="repeaterCollapsible"
|
||||
:icon="isRepeaterItemOpen(index) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
|
||||
:size="12"
|
||||
class="aksrt-field__repeater-toggle"
|
||||
/>
|
||||
<n-button
|
||||
size="tiny"
|
||||
type="error"
|
||||
text
|
||||
:disabled="schema.disabled || reachedRepeaterMinimum"
|
||||
@click="removeRepeaterItem(index)"
|
||||
>
|
||||
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
|
||||
</n-button>
|
||||
</div>
|
||||
<n-button
|
||||
size="tiny"
|
||||
type="error"
|
||||
text
|
||||
:disabled="schema.disabled || reachedRepeaterMinimum"
|
||||
@click="removeRepeaterItem(index)"
|
||||
>
|
||||
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="aksrt-field__repeater-fields">
|
||||
<div v-show="!repeaterCollapsible || isRepeaterItemOpen(index)" class="aksrt-field__repeater-fields">
|
||||
<template v-for="field in (schema.fields || [])" :key="field.key">
|
||||
<AksField
|
||||
v-if="matchDependency(field.dependsOn, element)"
|
||||
@@ -697,6 +741,7 @@ import FaIconPicker from './FaIconPicker.vue'
|
||||
import AksMediaField from './AksMediaField.vue'
|
||||
import AksGalleryField from './AksGalleryField.vue'
|
||||
import AksCodeEditor from './AksCodeEditor.vue'
|
||||
import { buildFieldsetSections } from './fieldset-sections.js'
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
|
||||
@@ -730,6 +775,60 @@ const repeaterItems = computed(() => Array.isArray(props.modelValue) ? props.mod
|
||||
const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0))
|
||||
const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max))
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 手风琴模式:schema.collapsible 开启后,每一项默认收起,仅显示标题行,
|
||||
* 点击标题行展开/收起。defaultCollapsed 为 false 时改为默认全部展开。
|
||||
* ------------------------------------------------------------------ */
|
||||
const repeaterCollapsible = computed(() => !!props.schema.collapsible)
|
||||
const repeaterDefaultOpen = computed(() => props.schema.defaultCollapsed === false)
|
||||
// 只记录「与默认状态相反」的下标,新增/删除项时其余项的开合状态不受影响。
|
||||
const toggledRepeaterItems = ref(new Set())
|
||||
|
||||
function isRepeaterItemOpen(index) {
|
||||
if (!repeaterCollapsible.value) return true
|
||||
return repeaterDefaultOpen.value !== toggledRepeaterItems.value.has(index)
|
||||
}
|
||||
|
||||
function toggleRepeaterItem(index) {
|
||||
const next = new Set(toggledRepeaterItems.value)
|
||||
if (next.has(index)) {
|
||||
next.delete(index)
|
||||
} else {
|
||||
next.add(index)
|
||||
}
|
||||
toggledRepeaterItems.value = next
|
||||
}
|
||||
|
||||
/** 收起态标题行上的一行摘要,取自 listPreview 配置。 */
|
||||
function repeaterItemSummary(item) {
|
||||
if (!repeaterCollapsible.value || !item) return ''
|
||||
const cfg = getRepeaterPreviewConfig()
|
||||
const titleKey = cfg.titleKey || 'title'
|
||||
const title = String(item[titleKey] ?? '').trim()
|
||||
const prefix = String(cfg.prefix || '')
|
||||
if (title === '') return prefix
|
||||
const desc = repeaterItemDuration(item)
|
||||
return desc ? `${prefix}${title} · ${desc}` : `${prefix}${title}`
|
||||
}
|
||||
|
||||
/** time/unit 形态的时长摘要:0 视为永久,其余按 day/month 补单位。 */
|
||||
function repeaterItemDuration(item) {
|
||||
const cfg = getRepeaterPreviewConfig()
|
||||
const descKey = cfg.descKey
|
||||
if (!descKey) return ''
|
||||
const raw = item[descKey]
|
||||
if (raw === '' || raw == null) return ''
|
||||
const amount = Number(raw)
|
||||
if (!Number.isFinite(amount)) return String(raw)
|
||||
if (amount <= 0) return '永久'
|
||||
const unitKey = cfg.descUnitKey
|
||||
if (!unitKey) return String(raw)
|
||||
const unit = String(item[unitKey] || '')
|
||||
if (unit === 'month') return `${amount} 个月`
|
||||
if (unit === 'day') return `${amount} 天`
|
||||
return String(raw)
|
||||
}
|
||||
|
||||
/** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */
|
||||
const repeaterDragList = computed({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
|
||||
@@ -762,6 +861,35 @@ function fieldsetValue(field) {
|
||||
return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* accordion/fieldset 的分块手风琴:schema.sections 声明每块的标题与字段
|
||||
* key,默认全部收起,点击块标题展开。未声明 sections 时保持平铺渲染。
|
||||
* ------------------------------------------------------------------ */
|
||||
const fieldsetSections = computed(() => {
|
||||
return buildFieldsetSections(props.schema)
|
||||
})
|
||||
|
||||
const openFieldsetSections = ref(new Set())
|
||||
|
||||
function isFieldsetSectionOpen(index) {
|
||||
return openFieldsetSections.value.has(index)
|
||||
}
|
||||
|
||||
/** 分块内字段同样遵循 dependsOn,数据源是本 fieldset 的值对象。 */
|
||||
function sectionFields(section) {
|
||||
return (section.fields || []).filter((field) => matchDependency(field.dependsOn, props.modelValue))
|
||||
}
|
||||
|
||||
function toggleFieldsetSection(index) {
|
||||
const next = new Set(openFieldsetSections.value)
|
||||
if (next.has(index)) {
|
||||
next.delete(index)
|
||||
} else {
|
||||
next.add(index)
|
||||
}
|
||||
openFieldsetSections.value = next
|
||||
}
|
||||
|
||||
function updateFieldsetValue(key, value) {
|
||||
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
||||
? props.modelValue
|
||||
@@ -1053,6 +1181,8 @@ const repeaterDraft = ref({})
|
||||
function createRepeaterItem() {
|
||||
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
|
||||
for (const field of (props.schema.fields || [])) {
|
||||
// UI-only chrome rows (notice / alert) are presentation only.
|
||||
if (field.ui_only) continue
|
||||
if (field.default !== undefined) {
|
||||
newItem[field.key] = field.default
|
||||
} else if (field.type === 'switch') {
|
||||
@@ -1082,7 +1212,16 @@ function removeRepeaterItem(index) {
|
||||
|
||||
function updateRepeaterItem(index, fieldKey, value) {
|
||||
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
current[index] = { ...current[index], [fieldKey]: value }
|
||||
const row = current[index]
|
||||
if (row && typeof row === 'object') {
|
||||
// 必须原地写,不能展开成新对象:drag key 存在以「对象身份」为键的 WeakMap
|
||||
// 里,而行数据是 reactive 的——换一个新对象后,渲染时读到的是它的一个新
|
||||
// reactive 代理,WeakMap 认不出它便生成新 key,整行重挂载、输入框失焦,
|
||||
// 表现为打一个字就退出编辑。经代理原地写既保持身份,也照样触发响应式。
|
||||
row[fieldKey] = value
|
||||
} else {
|
||||
current[index] = { [fieldKey]: value }
|
||||
}
|
||||
emit('update:modelValue', current)
|
||||
}
|
||||
|
||||
@@ -1184,6 +1323,15 @@ function confirmRepeaterModal() {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 结构化分组(会员商品/续费商品/积分兑换/升级价格)与相邻字段之间留出更明确的间隔,
|
||||
* 避免两个分组上下贴在一起,和参考主题的留白一致。 */
|
||||
.aksrt-field--group,
|
||||
.aksrt-field--repeater,
|
||||
.aksrt-field--accordion,
|
||||
.aksrt-field--fieldset {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.aksrt-field__input-wrap {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -1449,6 +1597,48 @@ function confirmRepeaterModal() {
|
||||
background: var(--aksrt-color-canvas);
|
||||
}
|
||||
|
||||
.aksrt-field__accordion-section + .aksrt-field__accordion-section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.aksrt-field__accordion-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--aksrt-color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--aksrt-color-surface);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--aksrt-color-text);
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-field__accordion-section-head:hover {
|
||||
border-color: var(--aksrt-color-primary);
|
||||
color: var(--aksrt-color-primary);
|
||||
}
|
||||
|
||||
.aksrt-field__accordion-section-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-field__accordion-section-toggle {
|
||||
flex-shrink: 0;
|
||||
color: var(--aksrt-color-text-secondary);
|
||||
}
|
||||
|
||||
.aksrt-field__accordion-section-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 2px 2px;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1477,6 +1667,47 @@ function confirmRepeaterModal() {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-header.is-clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 6px;
|
||||
margin: -4px -4px 0;
|
||||
padding: 4px 4px 0;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-header.is-clickable:hover .aksrt-field__repeater-index {
|
||||
color: var(--aksrt-color-primary-hover);
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-toggle {
|
||||
color: var(--aksrt-color-text-secondary);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--aksrt-color-text-secondary);
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-item.is-collapsible {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-item.is-collapsed .aksrt-field__repeater-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-handle {
|
||||
cursor: grab;
|
||||
color: var(--aksrt-color-text-tertiary);
|
||||
|
||||
@@ -64,9 +64,9 @@
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane
|
||||
v-for="tab in field.tabs"
|
||||
:key="tab.title || tab.key"
|
||||
:name="tab.title || tab.key"
|
||||
:tab="tab.title || tab.key"
|
||||
:key="tab.key || tab.title"
|
||||
:name="tabName(tab)"
|
||||
:tab="tabName(tab)"
|
||||
>
|
||||
<template v-for="sub in (tab.fields || [])" :key="sub.key">
|
||||
<AksField
|
||||
@@ -231,6 +231,18 @@ function isFieldVisible(field) {
|
||||
return matchDependsOn(field.dependsOn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab label. `titleKey` binds the label to a live form value (e.g. the VIP level
|
||||
* display name) so renaming a level renames its tab; `titleFallback` is used
|
||||
* while that value is still empty.
|
||||
*/
|
||||
function tabName(tab) {
|
||||
if (!tab) return ''
|
||||
const dynamic = tab.titleKey ? props.formData?.[tab.titleKey] : ''
|
||||
const label = typeof dynamic === 'string' ? dynamic.trim() : ''
|
||||
return label || tab.titleFallback || tab.title || tab.key || ''
|
||||
}
|
||||
|
||||
/** Whole group can be hidden (e.g. optional advanced blocks). */
|
||||
function isGroupVisible(group) {
|
||||
return matchDependsOn(group.dependsOn)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 把 accordion / fieldset 的 `schema.sections` 声明展开成「块标题 + 字段定义」。
|
||||
*
|
||||
* 参考主题把「升级价格」这类复合配置拆成若干可折叠块,每块用 keys 声明自己
|
||||
* 包含哪些字段。这里是纯函数,方便在 Node 契约测试里直接用真实 schema 校验,
|
||||
* 避免渲染层的一个小疏忽让整块手风琴静默退化成平铺。
|
||||
*/
|
||||
export function buildFieldsetSections(schema) {
|
||||
const declared = schema && Array.isArray(schema.sections) ? schema.sections : []
|
||||
if (!declared.length) return []
|
||||
|
||||
const byKey = {}
|
||||
const nested = Array.isArray(schema.fields) ? schema.fields : []
|
||||
for (const field of nested) {
|
||||
if (field && field.key) byKey[field.key] = field
|
||||
}
|
||||
|
||||
return declared.map((section) => ({
|
||||
title: String((section && section.title) || ''),
|
||||
fields: (Array.isArray(section && section.keys) ? section.keys : [])
|
||||
.map((key) => byKey[key])
|
||||
.filter(Boolean),
|
||||
}))
|
||||
}
|
||||
@@ -111,7 +111,7 @@ export const rechargeSchema = {
|
||||
{ key: 'points_pass_exchange_desc', label: '卡密兑换积分说明', type: 'textarea', default: '', rows: 2, dependsOn: { key: 'points_pass_exchange_s', value: true }, help: '用户使用卡密兑换积分时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
|
||||
{ key: 'points_pass_exchange_only_password', label: '积分卡密单密码模式', type: 'switch', default: false, dependsOn: { key: 'points_pass_exchange_s', value: true }, help: '积分卡密仅需输入密码,不需要卡号' },
|
||||
{ key: 'pay_vip_pass_charge_s', label: '启用卡密兑换会员功能', type: 'switch', default: false, help: '开启后用户可在会员购买/续费弹窗选择「卡密」渠道,使用会员卡密直接兑换会员' },
|
||||
{ key: 'pay_vip_pass_charge_desc', label: '卡密兑换会员说明', type: 'textarea', default: '', rows: 2, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '用户使用卡密兑换会员时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
|
||||
{ key: 'pay_vip_pass_charge_desc', label: '卡密兑换会员说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 2, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '用户使用卡密兑换会员时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
|
||||
{ key: 'pay_vip_pass_charge_only_password', label: '会员卡密单密码模式', type: 'switch', default: false, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '会员卡密仅需输入密码,不需要卡号;默认关闭,会员卡需输入卡号和密码(与其他卡密兑换一致)' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,27 +1,127 @@
|
||||
/**
|
||||
* VIP会员 — 参考主题结构:会员等级、会员权益(普通/一级/二级 tab)、
|
||||
* 会员购买选项(一级/二级/续费/升级 tab)、卡密兑换与积分兑换。
|
||||
* VIP会员 — 结构 1:1 对照参考主题 Zibll:会员等级(开关)、会员权益
|
||||
* (普通/一级/二级 tab)、会员购买选项(一级/二级/续费/升级 tab)、
|
||||
* 积分兑换会员与界面文案。
|
||||
*
|
||||
* 会员商品 / 续费商品 / 升级价格 / 积分兑换明细均为结构化 group 或
|
||||
* accordion,不再使用 `|` 分隔的 textarea。等级 Tab 标题通过 titleKey
|
||||
* 绑定「会员显示名称」,改名即改 Tab 名。
|
||||
*/
|
||||
|
||||
const VIP_EQUITY_DEFAULT = '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>';
|
||||
|
||||
const vipTagPalette = [
|
||||
{ label: '#f74b3d', value: 'b-red' },
|
||||
{ label: '#f3920a', value: 'b-yellow' },
|
||||
{ label: '#08c4c1', value: 'b-cyan' },
|
||||
{ label: '#0a8cf3', value: 'b-blue' },
|
||||
{ label: '#1fd05a', value: 'b-green' },
|
||||
{ label: '#c133f5', value: 'b-purple' },
|
||||
{ label: '#121517', value: 'b-black' },
|
||||
{ label: 'linear-gradient(135deg, #ffbeb4 10%, #f61a1a 100%)', value: 'jb-red' },
|
||||
{ label: 'linear-gradient(135deg, #ff5e7f 30%, #ff967e 100%)', value: 'jb-pink' },
|
||||
{ label: 'linear-gradient(135deg, #ffd6b2 10%, #ff651c 100%)', value: 'jb-yellow' },
|
||||
{ label: 'linear-gradient(140deg, #039ab3 10%, #58dbcf 90%)', value: 'jb-cyan' },
|
||||
{ label: 'linear-gradient(135deg, #b6e6ff 10%, #198aff 100%)', value: 'jb-blue' },
|
||||
{ label: 'linear-gradient(135deg, #ccffcd 10%, #52bb51 100%)', value: 'jb-green' },
|
||||
{ label: 'linear-gradient(135deg, #fec2ff 10%, #d000de 100%)', value: 'jb-purple' },
|
||||
{ label: 'linear-gradient(25deg, #eab869 10%, #fbecd4 60%, #ffe0ae 100%)', value: 'jb-vip1' },
|
||||
{ label: 'linear-gradient(317deg, #4d4c4c 30%, #878787 70%, #5f5c5c 100%)', value: 'jb-vip2' },
|
||||
];
|
||||
|
||||
const vipUnitOptions = [
|
||||
{ label: '天', value: 'day' },
|
||||
{ label: '月', value: 'month' },
|
||||
];
|
||||
|
||||
// 会员商品/续费商品的手风琴配置:默认收起,标题行显示价格与时长摘要。
|
||||
const vipProductGroupOptions = {
|
||||
collapsible: true,
|
||||
defaultCollapsed: true,
|
||||
listPreview: { prefix: '价格:', titleKey: 'price', descKey: 'time', descUnitKey: 'unit' },
|
||||
};
|
||||
|
||||
// 积分兑换明细的手风琴配置:默认收起,标题行显示积分与时长摘要。
|
||||
const vipPointsGroupOptions = {
|
||||
collapsible: true,
|
||||
defaultCollapsed: true,
|
||||
listPreview: { prefix: '积分:', titleKey: 'points', descKey: 'time', descUnitKey: 'unit' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 会员商品字段集(参考主题 CFS_Module::vip_product)。
|
||||
*/
|
||||
function vipProductFields() {
|
||||
return [
|
||||
{ key: 'price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '699.00', min: 0, suffix: '元', help: '会员的实际购买价格' },
|
||||
{ key: 'show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '999.00', min: 0, suffix: '元', help: '显示在执行价格前面,并划掉' },
|
||||
{ key: 'tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '', rows: 1, help: '支持HTML,请注意控制长度' },
|
||||
{ key: 'tag_class', label: '标签颜色', type: 'palette', default: 'jb-yellow', dependsOn: { key: 'tag', operator: 'not_empty' }, options: vipTagPalette },
|
||||
{ key: 'time', label: '会员有效时间', type: 'spinner', default: 3, min: 0, max: 3600, step: 1, help: '开通会员的时长。填0则为永久会员' },
|
||||
{ key: 'unit', label: '时长单位', type: 'radio', default: 'month', inline: true, dependsOn: { key: 'time', operator: 'truthy' }, options: vipUnitOptions },
|
||||
{ key: '_time_permanent_hint', label: '', type: 'alert', alertType: 'success', ui_only: true, dependsOn: { key: 'time', operator: 'eq', value: 0 }, content: '会员有效时间已设置为:永久会员' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员等级 Tab 字段集(参考主题 CFS_Module::vip_tab,显示名称并入 Tab)。
|
||||
*/
|
||||
function vipLevelTabFields(level) {
|
||||
const dep = { key: `pay_user_vip_${level}_s`, value: true };
|
||||
return [
|
||||
{ key: `pay_user_vip_${level}_name`, label: '会员显示名称', type: 'input', sanitize_type: 'html', default: level === 1 ? '黄金会员' : '钻石会员', dependsOn: dep, help: '支持HTML,请注意控制长度' },
|
||||
{ key: `pay_user_vip_${level}_desc`, label: '一句话简介', type: 'input', sanitize_type: 'html', default: '尊享专属特权', dependsOn: dep, help: '支持HTML' },
|
||||
{ key: `pay_user_vip_${level}_equity`, label: '会员权益 HTML', type: 'textarea', sanitize_type: 'html', default: VIP_EQUITY_DEFAULT, rows: 4, dependsOn: dep, help: '支持安全HTML,建议使用 <li> 列表项' },
|
||||
{ key: `vip_${level}_product_s`, label: '购买会员', type: 'switch', default: true, dependsOn: dep, help: '关闭后则不能付费购买会员,但可通过积分兑换会员,请确保已开启积分以及积分兑换会员功能' },
|
||||
{
|
||||
key: `vip_${level}_product`,
|
||||
label: '会员商品',
|
||||
type: 'group',
|
||||
max: 8,
|
||||
button_title: '添加会员商品',
|
||||
default: [
|
||||
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||
],
|
||||
...vipProductGroupOptions,
|
||||
dependsOn: { all: [dep, { key: `vip_${level}_product_s`, value: true }] },
|
||||
help: '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
|
||||
fields: vipProductFields(),
|
||||
},
|
||||
{ key: `vip_${level}_img_icon`, label: '会员图标', type: 'upload', default: '', dependsOn: dep, help: `留空使用内置 vip-${level}.svg 图标;建议尺寸300x300` },
|
||||
];
|
||||
}
|
||||
|
||||
export const vipSchema = {
|
||||
key: 'payment.vip',
|
||||
title: 'VIP会员',
|
||||
icon: 'fa fas fa-gem',
|
||||
description: '双级会员:等级名称、权益(下载次数与限速)、套餐、续费/升级与积分兑换。',
|
||||
description: '双级会员:等级开关、权益(下载次数与限速)、会员商品、续费/升级与积分兑换。',
|
||||
defaults: {
|
||||
nav_pay_vip: true,
|
||||
nav_user_pay_vip: true,
|
||||
pay_user_vip_1_s: true,
|
||||
pay_user_vip_1_name: '一级会员',
|
||||
pay_user_vip_1_desc: '尊享一级会员特权',
|
||||
pay_user_vip_1_equity: '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>',
|
||||
pay_user_vip_1_name: '黄金会员',
|
||||
pay_user_vip_1_desc: '尊享专属特权',
|
||||
pay_user_vip_1_equity: VIP_EQUITY_DEFAULT,
|
||||
vip_1_product_s: true,
|
||||
vip_1_product: [
|
||||
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||
],
|
||||
vip_1_img_icon: '',
|
||||
pay_user_vip_2_s: true,
|
||||
pay_user_vip_2_name: '二级会员',
|
||||
pay_user_vip_2_desc: '尊享二级会员特权',
|
||||
pay_user_vip_2_equity: '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>',
|
||||
pay_user_vip_2_name: '钻石会员',
|
||||
pay_user_vip_2_desc: '尊享专属特权',
|
||||
pay_user_vip_2_equity: VIP_EQUITY_DEFAULT,
|
||||
vip_2_product_s: true,
|
||||
vip_2_product: [
|
||||
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||
],
|
||||
vip_2_img_icon: '',
|
||||
pay_user_vip_desc: '',
|
||||
pay_user_vip_more: '',
|
||||
pay_user_vip_desc: '开通VIP会员,享受会员专属折扣以及多项特权',
|
||||
pay_user_vip_more: '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>',
|
||||
pay_download_limit: 0,
|
||||
pay_download_speed: 0,
|
||||
pay_download_limit_vip_1: 0,
|
||||
@@ -29,19 +129,45 @@ export const vipSchema = {
|
||||
pay_download_limit_vip_2: 0,
|
||||
pay_download_speed_vip_2: 0,
|
||||
vip_1_product_s: true,
|
||||
payment_vip_1_products: '30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡',
|
||||
vip_2_product_s: true,
|
||||
payment_vip_2_products: '58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡',
|
||||
vip_renew: true,
|
||||
vip_renew_desc: '',
|
||||
vip_renew_desc: '立即续费会员,畅享VIP权益',
|
||||
vip_renew_price_type: 'discount',
|
||||
vip_renew_discount: 8,
|
||||
vip_renew_reduce: '20.00',
|
||||
vip_1_renew_product: [
|
||||
{ price: 69, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||
{ price: 169, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||
],
|
||||
vip_2_renew_product: [
|
||||
{ price: 269, show_price: 599, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||
{ price: 369, show_price: 899, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||
],
|
||||
vip_2_product_s: true,
|
||||
vip_renew: true,
|
||||
vip_renew_discount: 8,
|
||||
vip_renew_reduce: '20.00',
|
||||
vip_upgrade: true,
|
||||
vip_upgrade_desc: '',
|
||||
vip_upgrade_product: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级',
|
||||
vip_upgrade_desc: '升级VIP会员,享更多会员权益',
|
||||
vip_upgrade_product: {
|
||||
month_to_month_s: true,
|
||||
unit_price: '10.00',
|
||||
unit_tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐',
|
||||
unit_tag_class: 'jb-yellow',
|
||||
jump_s: false,
|
||||
jump_price: '199.00',
|
||||
jump_show_price: '299.00',
|
||||
jump_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
|
||||
jump_tag_class: 'jb-red',
|
||||
permanent_price: '199.00',
|
||||
permanent_show_price: '299.00',
|
||||
permanent_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
|
||||
permanent_tag_class: 'jb-red',
|
||||
},
|
||||
pay_vip_points_exchange_s: false,
|
||||
payment_vip_points_packages: '1|500|30|积分月卡\n1|5000|0|积分永久',
|
||||
pay_vip_points_exchange_product: [
|
||||
{ points: 500, level: 1, time: 30, unit: 'day' },
|
||||
{ points: 5000, level: 1, time: 'Permanent' },
|
||||
],
|
||||
pay_vip_points_exchange_desc: '',
|
||||
},
|
||||
groups: [
|
||||
@@ -67,17 +193,16 @@ export const vipSchema = {
|
||||
{ key: 'nav_pay_vip', label: '导航栏购买按钮', type: 'switch', default: true, help: '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。' },
|
||||
{ key: 'nav_user_pay_vip', label: '用户框购买按钮', type: 'switch', default: true, help: '在导航栏用户框内显示开通会员按钮。' },
|
||||
{ key: 'pay_user_vip_1_s', label: '启用一级会员', type: 'switch', default: true },
|
||||
{ key: 'pay_user_vip_1_name', label: '一级会员显示名称', type: 'input', default: '一级会员', dependsOn: { key: 'pay_user_vip_1_s', value: true } },
|
||||
{ key: 'pay_user_vip_1_desc', label: '一级会员一句话简介', type: 'input', default: '尊享一级会员特权', dependsOn: { key: 'pay_user_vip_1_s', value: true } },
|
||||
{ key: 'pay_user_vip_1_equity', label: '一级会员权益 HTML', type: 'textarea', default: '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>', dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '建议使用 <li> 列表项' },
|
||||
{ key: 'vip_1_img_icon', label: '一级会员图标 URL', type: 'input', default: '', dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '留空使用内置 vip-1.svg 图标' },
|
||||
{
|
||||
key: 'vip_level_order_notice',
|
||||
label: '',
|
||||
type: 'notice',
|
||||
ui_only: true,
|
||||
alertType: 'danger',
|
||||
dependsOn: { all: [{ key: 'pay_user_vip_2_s', value: true }, { key: 'pay_user_vip_1_s', operator: 'falsy' }] },
|
||||
content: '<div style="text-align:center"><b>必须先开启一级会员后才能开启二级会员!</b></div>',
|
||||
},
|
||||
{ key: 'pay_user_vip_2_s', label: '启用二级会员', type: 'switch', default: true },
|
||||
{ key: 'pay_user_vip_2_name', label: '二级会员显示名称', type: 'input', default: '二级会员', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
|
||||
{ key: 'pay_user_vip_2_desc', label: '二级会员一句话简介', type: 'input', default: '尊享二级会员特权', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
|
||||
{ key: 'pay_user_vip_2_equity', label: '二级会员权益 HTML', type: 'textarea', default: '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
|
||||
{ key: 'vip_2_img_icon', label: '二级会员图标 URL', type: 'input', default: '', dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '留空使用内置 vip-2.svg 图标' },
|
||||
{ key: 'pay_user_vip_desc', label: '弹窗顶部会员说明', type: 'textarea', default: '' },
|
||||
{ key: 'pay_user_vip_more', label: '弹窗底部补充说明 HTML', type: 'textarea', default: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -101,6 +226,8 @@ export const vipSchema = {
|
||||
{
|
||||
key: 'vip1',
|
||||
title: '一级会员',
|
||||
titleKey: 'pay_user_vip_1_name',
|
||||
titleFallback: '一级会员',
|
||||
fields: [
|
||||
{ key: 'pay_download_limit_vip_1', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '一级会员每日最多可下载几个免费资源(为0则不限制)' },
|
||||
{ key: 'pay_download_speed_vip_1', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' },
|
||||
@@ -109,6 +236,8 @@ export const vipSchema = {
|
||||
{
|
||||
key: 'vip2',
|
||||
title: '二级会员',
|
||||
titleKey: 'pay_user_vip_2_name',
|
||||
titleFallback: '二级会员',
|
||||
fields: [
|
||||
{ key: 'pay_download_limit_vip_2', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '二级会员每日最多可下载几个免费资源(为0则不限制)' },
|
||||
{ key: 'pay_download_speed_vip_2', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' },
|
||||
@@ -131,53 +260,111 @@ export const vipSchema = {
|
||||
{
|
||||
key: 'vip1',
|
||||
title: '一级会员',
|
||||
titleKey: 'pay_user_vip_1_name',
|
||||
titleFallback: '一级会员',
|
||||
fields: [
|
||||
{ key: 'vip_1_product_s', label: '允许购买一级会员', type: 'switch', default: true, dependsOn: { key: 'pay_user_vip_1_s', value: true } },
|
||||
{
|
||||
key: 'payment_vip_1_products',
|
||||
label: '一级会员套餐',
|
||||
type: 'textarea',
|
||||
default: '30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡',
|
||||
dependsOn: { key: 'vip_1_product_s', value: true },
|
||||
help: '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久',
|
||||
},
|
||||
...vipLevelTabFields(1),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'vip2',
|
||||
title: '二级会员',
|
||||
titleKey: 'pay_user_vip_2_name',
|
||||
titleFallback: '二级会员',
|
||||
fields: [
|
||||
{ key: 'vip_2_product_s', label: '允许购买二级会员', type: 'switch', default: true, dependsOn: { key: 'pay_user_vip_2_s', value: true } },
|
||||
{
|
||||
key: 'payment_vip_2_products',
|
||||
label: '二级会员套餐',
|
||||
type: 'textarea',
|
||||
default: '58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡',
|
||||
dependsOn: { key: 'vip_2_product_s', value: true },
|
||||
help: '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久',
|
||||
},
|
||||
...vipLevelTabFields(2),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'renew',
|
||||
title: '会员续费',
|
||||
fields: [
|
||||
{ key: 'vip_renew', label: '允许续费', type: 'switch', default: true, help: '关闭后则不能续费,只能会员到期之后再购买。永久会员不需要续费' },
|
||||
{ key: 'vip_renew_desc', label: '续费说明', type: 'textarea', default: '', dependsOn: { key: 'vip_renew', value: true } },
|
||||
{
|
||||
key: 'vip_renew',
|
||||
label: '允许续费',
|
||||
type: 'switch',
|
||||
default: true,
|
||||
help: '关闭后则不能续费,只能会员到期之后再购买。永久会员不需要续费',
|
||||
},
|
||||
{
|
||||
key: 'vip_renew_desc',
|
||||
label: '续费介绍',
|
||||
type: 'input',
|
||||
sanitize_type: 'html',
|
||||
default: '立即续费会员,畅享VIP权益',
|
||||
dependsOn: { key: 'vip_renew', value: true },
|
||||
help: '续费会员的一句话简介,支持HTML',
|
||||
},
|
||||
{
|
||||
key: 'vip_renew_price_type',
|
||||
label: '续费价格类型',
|
||||
type: 'radio',
|
||||
type: 'select',
|
||||
default: 'discount',
|
||||
dependsOn: { key: 'vip_renew', value: true },
|
||||
options: [
|
||||
{ label: '相同价格', value: 'same' },
|
||||
{ label: '折扣价格', value: 'discount' },
|
||||
{ label: '优惠立减', value: 'reduce' },
|
||||
{ label: '自定义商品', value: 'customize' },
|
||||
],
|
||||
help: '自定义商品时,按下方「一级会员续费」「二级会员续费」的商品选项计价',
|
||||
},
|
||||
{
|
||||
key: 'vip_renew_discount',
|
||||
label: '续费折扣',
|
||||
type: 'slider',
|
||||
sanitize_type: 'float',
|
||||
default: 8,
|
||||
min: 0.1,
|
||||
max: 9.9,
|
||||
step: 0.1,
|
||||
unit: '折',
|
||||
dependsOn: { key: 'vip_renew_price_type', value: 'discount' },
|
||||
help: '在会员商品基础上打几折,如 8 = 八折',
|
||||
},
|
||||
{
|
||||
key: 'vip_renew_reduce',
|
||||
label: '续费减免金额',
|
||||
type: 'spinner',
|
||||
sanitize_type: 'money_decimal',
|
||||
default: '20.00',
|
||||
min: 0,
|
||||
max: 500,
|
||||
step: 5,
|
||||
unit: '元',
|
||||
dependsOn: { key: 'vip_renew_price_type', value: 'reduce' },
|
||||
help: '在会员商品基础上优惠的金额,优惠后总金额不能为0元',
|
||||
},
|
||||
{
|
||||
key: 'vip_1_renew_product',
|
||||
label: '一级会员续费',
|
||||
type: 'group',
|
||||
max: 8,
|
||||
button_title: '添加续费商品',
|
||||
default: [
|
||||
{ price: 69, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||
{ price: 169, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||
],
|
||||
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
|
||||
help: '一级会员续费的商品选项',
|
||||
...vipProductGroupOptions,
|
||||
fields: vipProductFields(),
|
||||
},
|
||||
{
|
||||
key: 'vip_2_renew_product',
|
||||
label: '二级会员续费',
|
||||
type: 'group',
|
||||
max: 8,
|
||||
button_title: '添加续费商品',
|
||||
default: [
|
||||
{ price: 269, show_price: 599, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
||||
{ price: 369, show_price: 899, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
|
||||
],
|
||||
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
|
||||
help: '二级会员续费的商品选项',
|
||||
...vipProductGroupOptions,
|
||||
fields: vipProductFields(),
|
||||
},
|
||||
{ key: 'vip_renew_discount', label: '续费折扣(折,如 8=八折)', type: 'number', default: 8, min: 1, max: 100, dependsOn: { key: 'vip_renew_price_type', value: 'discount' } },
|
||||
{ key: 'vip_renew_reduce', label: '续费减免金额', type: 'input', sanitize_type: 'money_decimal', default: '20.00', min: 0, max: 9999, dependsOn: { key: 'vip_renew_price_type', value: 'reduce' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -185,14 +372,50 @@ export const vipSchema = {
|
||||
title: '会员升级',
|
||||
fields: [
|
||||
{ key: 'vip_upgrade', label: '允许升级', type: 'switch', default: true },
|
||||
{ key: 'vip_upgrade_desc', label: '升级说明', type: 'textarea', default: '', dependsOn: { key: 'vip_upgrade', value: true } },
|
||||
{ key: 'vip_upgrade_desc', label: '升级介绍', type: 'input', sanitize_type: 'html', default: '升级VIP会员,享更多会员权益', dependsOn: { key: 'vip_upgrade', value: true }, help: '升级会员的一句话简介,支持HTML' },
|
||||
{
|
||||
key: 'vip_upgrade_product',
|
||||
label: '升级产品',
|
||||
type: 'textarea',
|
||||
default: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级',
|
||||
label: '升级价格',
|
||||
type: 'accordion',
|
||||
default: {
|
||||
month_to_month_s: true,
|
||||
unit_price: '10.00',
|
||||
unit_tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐',
|
||||
unit_tag_class: 'jb-yellow',
|
||||
jump_s: false,
|
||||
jump_price: '199.00',
|
||||
jump_show_price: '299.00',
|
||||
jump_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
|
||||
jump_tag_class: 'jb-red',
|
||||
permanent_price: '199.00',
|
||||
permanent_show_price: '299.00',
|
||||
permanent_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
|
||||
permanent_tag_class: 'jb-red',
|
||||
},
|
||||
dependsOn: { key: 'vip_upgrade', value: true },
|
||||
help: '每行一条。永久VIP1升级:permanent|价格|标题;按剩余天:unit|每日单价|标题;限时跳转永久:jump|价格|标题',
|
||||
help: '会员升级的商品选项',
|
||||
sections: [
|
||||
{ title: '月费会员升级月费会员', keys: ['_upgrade_unit_notice', 'month_to_month_s', 'unit_price', 'unit_tag', 'unit_tag_class'] },
|
||||
{ title: '月费会员升级永久会员', keys: ['_upgrade_jump_notice', 'jump_s', 'jump_price', 'jump_show_price', 'jump_tag', 'jump_tag_class'] },
|
||||
{ title: '永久会员升级永久会员', keys: ['permanent_price', 'permanent_show_price', 'permanent_tag', 'permanent_tag_class'] },
|
||||
],
|
||||
fields: [
|
||||
{ key: '_upgrade_unit_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '月费会员升级为月费会员,用户会员有效期不会改变;价格按照会员剩余天数计算升级价格,请在下方设置每天的单价' },
|
||||
{ key: 'month_to_month_s', label: '月费会员升级月费会员', type: 'switch', default: true },
|
||||
{ key: 'unit_price', label: '天单价', type: 'number', sanitize_type: 'money_decimal', default: '10.00', min: 0, unit: '元', dependsOn: { key: 'month_to_month_s', value: true } },
|
||||
{ key: 'unit_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', rows: 1, dependsOn: { key: 'month_to_month_s', value: true }, help: '支持HTML,请注意控制长度' },
|
||||
{ key: 'unit_tag_class', label: '标签颜色', type: 'palette', default: 'jb-yellow', dependsOn: { all: [{ key: 'month_to_month_s', value: true }, { key: 'unit_tag', operator: 'not_empty' }] }, options: vipTagPalette },
|
||||
{ key: '_upgrade_jump_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '如果网站可以购买月费会员和永久会员,则可以开启此项;允许用户由月费会员直接升级到更高一级的永久会员,用户之前还未到期的部分将自动一起转为永久会员,可能会涉及到差价问题,请自行告知用户;如果网站没有永久会员的购买选项请务必关闭此项' },
|
||||
{ key: 'jump_s', label: '月费会员升级永久会员', type: 'switch', default: false },
|
||||
{ key: 'jump_price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '199.00', min: 0, unit: '元', dependsOn: { key: 'jump_s', value: true }, help: '月费会员升级永久会员的价格' },
|
||||
{ key: 'jump_show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '299.00', min: 0, unit: '元', dependsOn: { key: 'jump_s', value: true }, help: '显示在执行价格前面,并划掉' },
|
||||
{ key: 'jump_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', rows: 1, dependsOn: { key: 'jump_s', value: true }, help: '支持HTML,请注意控制长度' },
|
||||
{ key: 'jump_tag_class', label: '标签颜色', type: 'palette', default: 'jb-red', dependsOn: { all: [{ key: 'jump_s', value: true }, { key: 'jump_tag', operator: 'not_empty' }] }, options: vipTagPalette },
|
||||
{ key: 'permanent_price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '199.00', min: 0, unit: '元', help: '永久会员升级永久会员的价格' },
|
||||
{ key: 'permanent_show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '299.00', min: 0, unit: '元', help: '显示在执行价格前面,并划掉' },
|
||||
{ key: 'permanent_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', rows: 1, help: '支持HTML,请注意控制长度' },
|
||||
{ key: 'permanent_tag_class', label: '标签颜色', type: 'palette', default: 'jb-red', dependsOn: { key: 'permanent_tag', operator: 'not_empty' }, options: vipTagPalette },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -205,16 +428,72 @@ export const vipSchema = {
|
||||
title: '积分兑换会员',
|
||||
icon: 'fa fas fa-database',
|
||||
fields: [
|
||||
{ key: 'pay_vip_points_exchange_s', label: '启用积分兑换', type: 'switch', default: false },
|
||||
{ key: 'pay_vip_points_exchange_s', label: '启用积分兑换', type: 'switch', default: false, help: '开启后用户可使用积分兑换会员;由于积分可免费获取,所以积分兑换会员功能不支持升级及续费' },
|
||||
{
|
||||
key: 'payment_vip_points_packages',
|
||||
label: '兑换套餐',
|
||||
type: 'textarea',
|
||||
default: '1|500|30|积分月卡\n1|5000|0|积分永久',
|
||||
key: 'pay_vip_points_exchange_product',
|
||||
label: '积分兑换明细',
|
||||
type: 'group',
|
||||
max: 16,
|
||||
button_title: '添加积分兑换商品',
|
||||
default: [
|
||||
{ points: 500, level: 1, time: 30, unit: 'day' },
|
||||
{ points: 5000, level: 1, time: 'Permanent' },
|
||||
],
|
||||
dependsOn: { key: 'pay_vip_points_exchange_s', value: true },
|
||||
help: '每行:等级|积分|天数|标题(天数 0=永久;仅未开通会员可兑)',
|
||||
help: '每一行为一个可兑换的会员套餐',
|
||||
...vipPointsGroupOptions,
|
||||
fields: [
|
||||
{ key: 'points', label: '使用积分', type: 'number', default: 100, min: 0, unit: '积分' },
|
||||
{
|
||||
key: 'level',
|
||||
label: '兑换会员',
|
||||
type: 'radio',
|
||||
default: 1,
|
||||
inline: true,
|
||||
options: [
|
||||
{ label: '一级会员', value: 1 },
|
||||
{ label: '二级会员', value: 2 },
|
||||
],
|
||||
},
|
||||
{ key: 'time', label: '兑换时长', type: 'input', default: '', help: '填 Permanent 为永久' },
|
||||
{
|
||||
key: 'unit',
|
||||
label: '时长单位',
|
||||
type: 'radio',
|
||||
default: 'day',
|
||||
inline: true,
|
||||
dependsOn: { key: 'time', operator: 'neq', value: 'Permanent' },
|
||||
options: vipUnitOptions,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 2, dependsOn: { key: 'pay_vip_points_exchange_s', value: true }, help: '显示在积分兑换弹窗底部,可以为提醒事项、兑换协议等,支持HTML代码' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'modal-copy',
|
||||
title: '界面文案',
|
||||
icon: 'fa fas fa-align-left',
|
||||
fields: [
|
||||
{
|
||||
key: 'pay_user_vip_desc',
|
||||
label: '会员介绍',
|
||||
type: 'input',
|
||||
sanitize_type: 'html',
|
||||
default: '开通VIP会员,享受会员专属折扣以及多项特权',
|
||||
dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] },
|
||||
help: '显示在开通界面顶部一句话简介,可以为会员权益简介或者活动介绍,支持HTML',
|
||||
},
|
||||
{
|
||||
key: 'pay_user_vip_more',
|
||||
label: '开通会员更多内容',
|
||||
type: 'textarea',
|
||||
sanitize_type: 'html',
|
||||
default: '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>',
|
||||
rows: 4,
|
||||
dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] },
|
||||
help: '显示在开通界面底部位置,可以为提醒事项、用户协议等,支持HTML代码',
|
||||
},
|
||||
{ key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_vip_points_exchange_s', value: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2781,7 +2781,13 @@ main.container,
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.navbar-top .navbar-form .toggle-radius {
|
||||
/* The account-row chips, the theme and the language toggles all want the 29px
|
||||
* midline circle. The popover's quick-login buttons are .toggle-radius too and
|
||||
* also live inside .navbar-form, but they must keep the .social_loginbar
|
||||
* geometry from main.css: in a flex container flex-basis outranks width, so
|
||||
* without this exemption the buttons collapse to 29px and lose the 5px side
|
||||
* margins that space them apart. */
|
||||
.navbar-top .navbar-form .toggle-radius:not(.social-login-item):not(.xh-social-item) {
|
||||
flex: 0 0 29px;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
:root{--color-primary: #f04494;--color-primary-hover: #e0307e;--color-primary-active: #c9206d;--color-primary-soft: rgba(240, 68, 148, .1);--color-primary-softer: rgba(240, 68, 148, .05);--color-on-primary: #FFFFFF;--color-ink: #333333;--color-ink-2: #4e5358;--color-ink-3: #777777;--color-ink-4: #999999;--surface-page: #f5f6f7;--surface-1: #FFFFFF;--surface-2: #eeeeee;--surface-3: #e9ecef;--surface-4: #f8f8f8;--color-line: rgba(50, 50, 50, .06);--color-line-soft: rgba(0, 0, 0, .03);--color-line-stronger: rgba(50, 50, 50, .12);--color-success: #10B981;--color-warning: #F59E0B;--color-error: #EF4444;--color-info: #3B82F6;--color-accent: #18a058;--color-accent-soft: rgba(24, 160, 88, .12);--color-accent-shadow: rgba(24, 160, 88, .28);--font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;--text-xs: .75rem;--text-sm: .875rem;--text-base: .875rem;--text-md: 1.125rem;--text-lg: 1.25rem;--text-xl: 1.5rem;--text-2xl: 1.875rem;--text-3xl: 2.25rem;--text-4xl: 3rem;--text-5xl: 3.75rem;--leading-tight: 1.2;--leading-snug: 1.4;--leading-normal: 1.6;--leading-relaxed: 1.6;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-7: 32px;--space-8: 40px;--space-9: 48px;--space-10: 64px;--space-11: 80px;--space-12: 96px;--radius-xs: 3px;--radius-sm: 4px;--radius: 8px;--radius-lg: 8px;--radius-xl: 12px;--radius-2xl: 16px;--radius-pill: 100px;--radius-avatar: 50%;--shadow-xs: 0 1px 2px rgba(0, 0, 0, .03);--shadow-sm: 0 0 10px rgba(116, 116, 116, .08);--shadow: 0 0 10px rgba(116, 116, 116, .12);--shadow-lg: 0 12px 32px rgba(18, 18, 18, .1);--shadow-drawer: 0 18px 60px rgba(0, 0, 0, .2);--nav-height: 60px;--container: 1200px;--container-wide: 1440px;--container-read: 760px;--container-page: 880px;--sidebar-width: 311px;--content-gap: 24px;--section-gap: 24px;--sidebar-bp: 992px;--ease: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-spring: cubic-bezier(.34, 1.56, .64, 1);--dur-fast: .15s;--dur: .25s;--dur-slow: .4s;--focus-ring: 0 0 2px 3px rgba(253, 83, 161, .4);--z-dropdown: 100;--z-sticky: 200;--z-fixed: 300;--z-overlay: 400;--z-modal: 500;--z-popover: 600;--z-toast: 700;--z-tooltip: 800;--radius-btn: 4px;--btn-sm-h: 28px;--btn-md-h: 34px;--btn-lg-h: 44px;--card-padding: 15px;--card-padding-sm: 10px;--card-gap: 16px;--card-gap-lg: 20px;--color-primary-light: var(--color-primary-soft);--color-surface: var(--surface-1);--color-line-strong: var(--color-line);--color-bg-hover: var(--surface-2);--color-border-light: var(--color-line-soft);--color-fill: var(--surface-2);--color-warning-bg: #fffbe6;--color-warning-text: #ad6800;--text-secondary: var(--color-ink-3);--text-tertiary: var(--color-ink-4);--surface-active: var(--surface-2);--surface-card: var(--surface-1);--surface-hover: var(--surface-2);--surface-secondary: var(--surface-2);--radius-code: var(--radius);--radius-full: 100px;--shadow-xl: 0 0 10px rgba(116, 116, 116, .12);--space-16: 64px;--z-floating: 900;--transition-fast: .15s ease;--transition-base: .25s ease;--header-bg: var(--blur-bg);--header-color: var(--color-ink-2);--footer-bg: var(--surface-1);--footer-color: var(--color-ink-4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--font-heading: var(--font-ui);--radius-card: var(--radius);--radius-md: var(--radius);--color-primary-50: var(--color-primary-soft);--color-primary-dark: var(--color-primary-active);--shadow-md: var(--shadow);--mini-radius: calc(var(--radius) / 1.4);--aksrt-avatar-size-xs: 24px;--aksrt-avatar-size-sm: 32px;--aksrt-avatar-size: 40px;--aksrt-avatar-size-lg: 56px;--aksrt-avatar-size-xl: 80px;--aksrt-card-thumb-scale: 56.25%;--aksrt-rose: #f43f5e;--aksrt-rose-soft: rgba(244, 63, 94, .12);--aksrt-blue: #3b82f6;--aksrt-blue-soft: rgba(59, 130, 246, .12);--aksrt-green: #10b981;--aksrt-green-soft: rgba(16, 185, 129, .12);--aksrt-orange: #f97316;--aksrt-orange-soft:rgba(249, 115, 22, .12);--aksrt-purple: #8b5cf6;--aksrt-purple-soft:rgba(139, 92, 246, .12);--aksrt-cyan: #06b6d4;--aksrt-cyan-soft: rgba(6, 182, 212, .12);--aksrt-yellow: #eab308;--aksrt-yellow-soft:rgba(234, 179, 8, .12);--aksrt-red: #ef4444;--aksrt-red-soft: rgba(239, 68, 68, .12);--aksrt-teal: #14b8a6;--aksrt-teal-soft: rgba(20, 184, 166, .12);--aksrt-indigo: #6366f1;--aksrt-indigo-soft:rgba(99, 102, 241, .12);--aksrt-pink: #ec4899;--aksrt-pink-soft: rgba(236, 72, 153, .12);--aksrt-vip-gold-1: #f6d365;--aksrt-vip-gold-2: #fda085;--aksrt-grad-sunset-1: #f093fb;--aksrt-grad-sunset-2: #f5576c;--aksrt-graLine truncated
|
||||
:root{--color-primary: #f04494;--color-primary-hover: #e0307e;--color-primary-active: #c9206d;--color-primary-soft: rgba(240, 68, 148, .1);--color-primary-softer: rgba(240, 68, 148, .05);--color-on-primary: #FFFFFF;--color-ink: #333333;--color-ink-2: #4e5358;--color-ink-3: #777777;--color-ink-4: #999999;--surface-page: #f5f6f7;--surface-1: #FFFFFF;--surface-2: #eeeeee;--surface-3: #e9ecef;--surface-4: #f8f8f8;--color-line: rgba(50, 50, 50, .06);--color-line-soft: rgba(0, 0, 0, .03);--color-line-stronger: rgba(50, 50, 50, .12);--color-success: #10B981;--color-warning: #F59E0B;--color-error: #EF4444;--color-info: #3B82F6;--color-accent: #18a058;--color-accent-soft: rgba(24, 160, 88, .12);--color-accent-shadow: rgba(24, 160, 88, .28);--font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;--text-xs: .75rem;--text-sm: .875rem;--text-base: .875rem;--text-md: 1.125rem;--text-lg: 1.25rem;--text-xl: 1.5rem;--text-2xl: 1.875rem;--text-3xl: 2.25rem;--text-4xl: 3rem;--text-5xl: 3.75rem;--leading-tight: 1.2;--leading-snug: 1.4;--leading-normal: 1.6;--leading-relaxed: 1.6;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-7: 32px;--space-8: 40px;--space-9: 48px;--space-10: 64px;--space-11: 80px;--space-12: 96px;--radius-xs: 3px;--radius-sm: 4px;--radius: 8px;--radius-lg: 8px;--radius-xl: 12px;--radius-2xl: 16px;--radius-pill: 100px;--radius-avatar: 50%;--shadow-xs: 0 1px 2px rgba(0, 0, 0, .03);--shadow-sm: 0 0 10px rgba(116, 116, 116, .08);--shadow: 0 0 10px rgba(116, 116, 116, .12);--shadow-lg: 0 12px 32px rgba(18, 18, 18, .1);--shadow-drawer: 0 18px 60px rgba(0, 0, 0, .2);--nav-height: 60px;--container: 1200px;--container-wide: 1440px;--container-read: 760px;--container-page: 880px;--sidebar-width: 311px;--content-gap: 24px;--section-gap: 24px;--sidebar-bp: 992px;--ease: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-spring: cubic-bezier(.34, 1.56, .64, 1);--dur-fast: .15s;--dur: .25s;--dur-slow: .4s;--focus-ring: 0 0 2px 3px rgba(253, 83, 161, .4);--z-dropdown: 100;--z-sticky: 200;--z-fixed: 300;--z-overlay: 400;--z-modal: 500;--z-popover: 600;--z-toast: 700;--z-tooltip: 800;--radius-btn: 4px;--btn-sm-h: 28px;--btn-md-h: 34px;--btn-lg-h: 44px;--card-padding: 15px;--card-padding-sm: 10px;--card-gap: 16px;--card-gap-lg: 20px;--color-primary-light: var(--color-primary-soft);--color-surface: var(--surface-1);--color-line-strong: var(--color-line);--color-bg-hover: var(--surface-2);--color-border-light: var(--color-line-soft);--color-fill: var(--surface-2);--color-warning-bg: #fffbe6;--color-warning-text: #ad6800;--text-secondary: var(--color-ink-3);--text-tertiary: var(--color-ink-4);--surface-active: var(--surface-2);--surface-card: var(--surface-1);--surface-hover: var(--surface-2);--surface-secondary: var(--surface-2);--radius-code: var(--radius);--radius-full: 100px;--shadow-xl: 0 0 10px rgba(116, 116, 116, .12);--space-16: 64px;--z-floating: 900;--transition-fast: .15s ease;--transition-base: .25s ease;--header-bg: var(--blur-bg);--header-color: var(--color-ink-2);--footer-bg: var(--surface-1);--footer-color: var(--color-ink-4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--font-heading: var(--font-ui);--radius-card: var(--radius);--radius-md: var(--radius);--color-primary-50: var(--color-primary-soft);--color-primary-dark: var(--color-primary-active);--shadow-md: var(--shadow);--mini-radius: calc(var(--radius) / 1.4);--aksrt-avatar-size-xs: 24px;--aksrt-avatar-size-sm: 32px;--aksrt-avatar-size: 40px;--aksrt-avatar-size-lg: 56px;--aksrt-avatar-size-xl: 80px;--aksrt-card-thumb-scale: 56.25%;--aksrt-rose: #f43f5e;--aksrt-rose-soft: rgba(244, 63, 94, .12);--aksrt-blue: #3b82f6;--aksrt-blue-soft: rgba(59, 130, 246, .12);--aksrt-green: #10b981;--aksrt-green-soft: rgba(16, 185, 129, .12);--aksrt-orange: #f97316;--aksrt-orange-soft:rgba(249, 115, 22, .12);--aksrt-purple: #8b5cf6;--aksrt-purple-soft:rgba(139, 92, 246, .12);--aksrt-cyan: #06b6d4;--aksrt-cyan-soft: rgba(6, 182, 212, .12);--aksrt-yellow: #eab308;--aksrt-yellow-soft:rgba(234, 179, 8, .12);--aksrt-red: #ef4444;--aksrt-red-soft: rgba(239, 68, 68, .12);--aksrt-teal: #14b8a6;--aksrt-teal-soft: rgba(20, 184, 166, .12);--aksrt-indigo: #6366f1;--aksrt-indigo-soft:rgba(99, 102, 241, .12);--aksrt-pink: #ec4899;--aksrt-pink-soft: rgba(236, 72, 153, .12);--aksrt-vip-gold-1: #f6d365;--aksrt-vip-gold-2: #fda085;--aksrt-grad-sunset-1: #f093fb;--aksrt-grad-sunset-2: #f5576c;--aksrt-graLine truncated
|
||||
@@ -74,7 +74,13 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.navbar-top .navbar-form .toggle-radius {
|
||||
/* The account-row chips, the theme and the language toggles all want the 29px
|
||||
* midline circle. The popover's quick-login buttons are .toggle-radius too and
|
||||
* also live inside .navbar-form, but they must keep the .social_loginbar
|
||||
* geometry from main.css: in a flex container flex-basis outranks width, so
|
||||
* without this exemption the buttons collapse to 29px and lose the 5px side
|
||||
* margins that space them apart. */
|
||||
.navbar-top .navbar-form .toggle-radius:not(.social-login-item):not(.xh-social-item) {
|
||||
flex: 0 0 29px;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
|
||||
@@ -166,56 +166,6 @@ html.dark .archive-timeline__badge {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 社交登录分隔线 */
|
||||
.social-login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 24px 0 16px;
|
||||
color: var(--color-ink-3, #94a3b8);
|
||||
font-size: 13px;
|
||||
}
|
||||
.social-login-divider::before,
|
||||
.social-login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-line, #e2e8f0);
|
||||
}
|
||||
.social-login-divider span {
|
||||
padding: 0 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oauth-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.oauth-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-btn);
|
||||
border: 1px solid var(--color-line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-ink-3);
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
.oauth-btn i {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.oauth-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.auth-footer-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
|
||||
@@ -64,6 +64,27 @@
|
||||
z-index: 100055;
|
||||
}
|
||||
|
||||
/* public.css redeclares .muted-box after legacy utilities, overriding its
|
||||
background, radius, and padding utilities. Restore the reference box contract. */
|
||||
.modal[data-aksrt-vip-points-modal] .muted-box.padding-h10 {
|
||||
background-color: var(--this-bg, var(--muted-border-color));
|
||||
border-radius: var(--main-radius);
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
.modal[data-aksrt-vip-points-modal] .muted-box.padding-10,
|
||||
.modal[data-aksrt-vip-points-modal] .aksrt-vip-points-desc {
|
||||
background-color: var(--this-bg, var(--muted-border-color));
|
||||
border-radius: var(--main-radius);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal[data-aksrt-vip-points-modal] .aksrt-vip-points-desc {
|
||||
display: block;
|
||||
margin-top: 20px;
|
||||
color: var(--muted-2-color, #999);
|
||||
}
|
||||
|
||||
.pay-vip-form-row {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -101,3 +122,86 @@
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* 会员弹窗骨架屏(pay-vip.js vipSkeleton()):仿真实弹窗布局——
|
||||
用户框 + 等级卡行 + 双列套餐面板。线条/色块与 shimmer 动画复用全局
|
||||
.aksrt-modal-runtime__skeleton-line/-block 与 @keyframes
|
||||
aksrt-modal-skeleton(build/public.min.css 已全局入队);
|
||||
这里只定义 VIP 专属形状(头像圆、tab 卡、双列 grid、按钮条),
|
||||
与 .pay-vip-form-row 的 640px 双列阈值保持一致。 */
|
||||
.aksrt-vip-modal-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.aksrt-vip-modal-skeleton__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aksrt-vip-modal-skeleton__avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.aksrt-vip-modal-skeleton__lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-vip-modal-skeleton__tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aksrt-vip-modal-skeleton__tab {
|
||||
flex: 1;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.aksrt-vip-modal-skeleton__pane {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-vip-modal-skeleton__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.aksrt-vip-modal-skeleton__products {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aksrt-vip-modal-skeleton__button {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
/* 专属形状共用骨架底色与 shimmer(值与 modal-runtime.css 的线条/块一致)。 */
|
||||
.aksrt-vip-modal-skeleton__avatar,
|
||||
.aksrt-vip-modal-skeleton__tab,
|
||||
.aksrt-vip-modal-skeleton__button {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(90deg, rgba(148, 163, 184, 0.16), rgba(148, 163, 184, 0.3), rgba(148, 163, 184, 0.16));
|
||||
background-size: 200% 100%;
|
||||
animation: aksrt-modal-skeleton 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.aksrt-vip-modal-skeleton__pane {
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
}
|
||||
}
|
||||
@@ -478,18 +478,6 @@
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-softer);
|
||||
}
|
||||
.binding-item__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--color-ink);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.binding-item__icon i {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
.binding-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -264,8 +264,6 @@
|
||||
.aksrt-account-settings__section--social .binding-captcha { margin-bottom: 12px; }
|
||||
.aksrt-account-list--social .binding-item { border: 0; }
|
||||
.aksrt-account-list--social .aksrt-account-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto auto; text-decoration: none; }
|
||||
.aksrt-account-list--social .binding-item__icon { display: grid; place-items: center; width: 24px; height: 24px; color: var(--color-ink, #333); }
|
||||
.aksrt-account-list--social .binding-item__icon i { font-size: 22px; line-height: 1; }
|
||||
.aksrt-account-list--social .binding-status { display: inline-flex; align-items: center; gap: 4px; color: var(--color-success, #16a34a); font-size: 11px; white-space: nowrap; }
|
||||
.aksrt-account-list--social .binding-status--idle { color: var(--muted-2-color, var(--color-ink-4, #999)); }
|
||||
.aksrt-account-list--social .binding-unbind-btn { color: var(--this-color, #e4a11b); }
|
||||
|
||||
@@ -622,25 +622,6 @@ body.aksrt-user-center .uc-content,
|
||||
}
|
||||
}
|
||||
|
||||
.uc-service-grid__icon small {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -7px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
padding: 0 4px;
|
||||
border: 2px solid var(--surface-1);
|
||||
border-radius: 99px;
|
||||
background: var(--color-error, #ef4444);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.uc-sidebar__pair {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -684,7 +665,8 @@ body.aksrt-user-center .uc-content,
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.uc-summary-card__body > i {
|
||||
.uc-summary-card__body > i,
|
||||
.uc-summary-card__body > svg {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@@ -716,14 +698,26 @@ body.aksrt-user-center .uc-content,
|
||||
|
||||
.uc-center-card strong { font-size: 13px; }
|
||||
.uc-center-card small { margin-top: 3px; color: var(--color-ink-4); font-size: 10px; }
|
||||
.uc-center-card > i { color: var(--color-ink-4); font-size: 11px; }
|
||||
.uc-center-card > i,
|
||||
.uc-center-card > svg { color: var(--color-ink-4); font-size: 11px; }
|
||||
.uc-center-card--creation .uc-center-card__icon { color: #6650c7; background: rgba(124, 92, 220, 0.12); }
|
||||
.uc-center-card--revenue .uc-center-card__icon { color: #ce6b35; background: rgba(234, 112, 55, 0.12); }
|
||||
|
||||
.uc-service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
/* Services / settings grids, ported from the reference theme sidebar:
|
||||
four per row, no tinted tile behind the icon. */
|
||||
.user-icon-but-box > a {
|
||||
width: calc(25% - 10px);
|
||||
margin: 5px;
|
||||
min-width: 50px;
|
||||
max-width: 100px;
|
||||
color: var(--color-ink-3);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.user-icon-but-box > a:hover,
|
||||
.user-icon-but-box > a.is-active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.uc-follow-management__list { display: grid; gap: 10px; margin-top: 16px; }
|
||||
@@ -735,44 +729,3 @@ body.aksrt-user-center .uc-content,
|
||||
.uc-follow-management__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
|
||||
@media (max-width: 560px) { .uc-follow-management__item { align-items: flex-start; flex-wrap: wrap; } .uc-follow-management__actions { width: 100%; margin-left: 60px; } }
|
||||
|
||||
.uc-service-grid__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
padding: 4px 2px;
|
||||
color: var(--color-ink-3);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.uc-service-grid__icon {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 14px;
|
||||
background: var(--surface-2);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.uc-service-grid__item--cart .uc-service-grid__icon { color: #dd6941; background: rgba(238, 109, 61, 0.12); }
|
||||
.uc-service-grid__item--level .uc-service-grid__icon { color: #9b6b0a; background: rgba(234, 179, 8, 0.13); }
|
||||
.uc-service-grid__item--revenue .uc-service-grid__icon { color: #b8377d; background: rgba(219, 39, 119, 0.12); }
|
||||
.uc-service-grid__item--auth .uc-service-grid__icon { color: #287bc4; background: rgba(14, 165, 233, 0.12); }
|
||||
.uc-service-grid__item--orders .uc-service-grid__icon { color: #c05621; background: rgba(234, 88, 12, 0.12); }
|
||||
.uc-service-grid__item--notice .uc-service-grid__icon { color: #d04b65; background: rgba(244, 63, 94, 0.11); }
|
||||
.uc-service-grid__item--profile .uc-service-grid__icon { color: #6550c7; background: rgba(124, 92, 220, 0.12); }
|
||||
.uc-service-grid__item--security .uc-service-grid__icon { color: #16816c; background: rgba(16, 185, 129, 0.12); }
|
||||
|
||||
.uc-service-grid__item:hover,
|
||||
.uc-service-grid__item.is-active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.uc-service-grid__item.is-active .uc-service-grid__icon {
|
||||
box-shadow: 0 0 0 2px var(--color-primary-soft), 0 5px 14px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
+109
-17
@@ -17,6 +17,14 @@
|
||||
return el;
|
||||
}
|
||||
|
||||
// Server-side schema sanitization limits these configured rich-text fields to post-safe HTML.
|
||||
function htmlNode(tag, className, value) {
|
||||
var el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
if (value !== undefined && value !== null) el.innerHTML = text(value);
|
||||
return el;
|
||||
}
|
||||
|
||||
function imageNode(src, className) {
|
||||
if (!src) return null;
|
||||
var image = document.createElement("img");
|
||||
@@ -61,7 +69,7 @@
|
||||
if (levelInfo && levelInfo.img_icon) {
|
||||
var img = node("img", "img-icon");
|
||||
img.src = text(levelInfo.img_icon);
|
||||
img.alt = text(levelInfo.name || "");
|
||||
img.alt = text(levelInfo.name_text || levelInfo.name || "");
|
||||
img.decoding = "async";
|
||||
return img;
|
||||
}
|
||||
@@ -115,7 +123,8 @@
|
||||
// Zibll 套餐卡:zib-widget vip-product relative text-center product-box
|
||||
// (右上 tag + 价格行 + 时长 badge,无标题行)。
|
||||
var product = node("div", "zib-widget vip-product relative text-center product-box");
|
||||
if (item.discount_tag) append(product, node("div", "abs-right vip-tag badg jb-yellow", item.discount_tag));
|
||||
// 标签颜色取后台「标签颜色」配置,未配置时回退参考主题默认的 jb-yellow。
|
||||
if (item.discount_tag) append(product, htmlNode("div", "abs-right vip-tag badg " + (item.tag_class || "jb-yellow"), item.discount_tag));
|
||||
append(product, priceRow(mark, item.price_label || "", item.show_price_label || ""));
|
||||
if (item.days_label) append(product, node("div", "muted-2-color c-blue em09 p2-10 badg radius", item.days_label));
|
||||
append(label, product);
|
||||
@@ -179,7 +188,7 @@
|
||||
box.setAttribute("data-condition", "==");
|
||||
box.setAttribute("data-value", "card_pass");
|
||||
if (defaultMethod !== "card_pass") box.style.display = "none";
|
||||
if (cardPass.desc) append(box, node("div", "muted-box muted-2-color padding-10 mb10 em09", cardPass.desc));
|
||||
if (cardPass.desc) append(box, htmlNode("div", "muted-box muted-2-color padding-10 mb10 em09", cardPass.desc));
|
||||
append(box, node("div", "muted-2-color em09 mb6", cardPass.only_password ? "请输入卡密" : "请输入卡号和密码"));
|
||||
if (!cardPass.only_password) {
|
||||
var cardInput = node("input", "form-control mb6");
|
||||
@@ -398,7 +407,7 @@
|
||||
} else if (member.state === "expired") {
|
||||
append(userInfo, node("div", "muted-2-color em09", "已过期"));
|
||||
} else if (catalog.desc) {
|
||||
append(userInfo, node("div", "muted-2-color em09", catalog.desc));
|
||||
append(userInfo, htmlNode("div", "muted-2-color em09", catalog.desc));
|
||||
}
|
||||
append(userBox, userInfo);
|
||||
append(modal, userBox);
|
||||
@@ -434,8 +443,8 @@
|
||||
append(miniIcon, vipIcon(levelInfo));
|
||||
append(miniIconbox, miniIcon);
|
||||
var miniInfo = node("div", "v-info");
|
||||
append(miniInfo, node("div", "v-name", actionLabel(action) + (levelInfo.name || group.name || "VIP" + level)));
|
||||
append(miniInfo, node("div", "v-desc em09", levelInfo.desc || ""));
|
||||
append(miniInfo, htmlNode("div", "v-name", actionLabel(action) + (levelInfo.name || group.name || "VIP" + level)));
|
||||
append(miniInfo, htmlNode("div", "v-desc em09", levelInfo.desc || ""));
|
||||
append(miniIconbox, miniInfo);
|
||||
append(miniCard, miniIconbox);
|
||||
append(tab, miniCard);
|
||||
@@ -461,15 +470,12 @@
|
||||
append(vicon, vipIcon(levelInfo));
|
||||
append(iconbox, vicon);
|
||||
var vinfo = node("div", "v-info");
|
||||
append(vinfo, node("div", "v-name", levelInfo.name || group.name || "VIP" + level));
|
||||
append(vinfo, node("div", "v-desc em09", levelInfo.desc || ""));
|
||||
append(vinfo, htmlNode("div", "v-name", levelInfo.name || group.name || "VIP" + level));
|
||||
append(vinfo, htmlNode("div", "v-desc em09", levelInfo.desc || ""));
|
||||
append(iconbox, vinfo);
|
||||
append(info, iconbox);
|
||||
if (levelInfo.equity) {
|
||||
var equity = document.createElement("ul");
|
||||
equity.className = "v-equity";
|
||||
equity.innerHTML = text(levelInfo.equity);
|
||||
append(info, equity);
|
||||
append(info, htmlNode("div", "", levelInfo.equity));
|
||||
}
|
||||
var infoBa = node("div", "abs-center vip-baicon");
|
||||
append(infoBa, vipIcon(levelInfo));
|
||||
@@ -492,7 +498,7 @@
|
||||
+ '<i class="fa fas fa-angle-right em12 ml10" aria-hidden="true"></i>';
|
||||
append(products, pointsLink);
|
||||
}
|
||||
if (catalog.more) append(products, node("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more));
|
||||
if (catalog.more) append(products, htmlNode("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more));
|
||||
renderMethods(products, methods, cardPass, wallet);
|
||||
var actionName = actionKey(action);
|
||||
var actionAllowed = allowed[actionName] === true;
|
||||
@@ -519,8 +525,6 @@
|
||||
append(modal, tabs);
|
||||
append(modal, content);
|
||||
|
||||
if (catalog.help_text) append(modal, node("div", "muted-2-color em09 mb6", catalog.help_text));
|
||||
|
||||
// 卡密渠道初始显隐(选中 card_pass 时隐藏套餐、显示卡密输入盒)。
|
||||
modal.querySelectorAll(".aksrt-vip-pane").forEach(function (pane) {
|
||||
applyVipMethodVisibility(pane);
|
||||
@@ -698,6 +702,46 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 骨架屏:仿弹窗真实布局(用户框 + 等级卡 + 双列套餐面板)。
|
||||
// 线条/色块复用全局骨架类(build/public.min.css),VIP 专属形状见 vip-modal.css。
|
||||
function vipSkeleton() {
|
||||
var skeleton = node("div", "aksrt-vip-modal-skeleton");
|
||||
skeleton.setAttribute("aria-live", "polite");
|
||||
skeleton.setAttribute("aria-busy", "true");
|
||||
|
||||
// 用户框:头像圆 + 两行文字。
|
||||
var user = node("div", "aksrt-vip-modal-skeleton__user");
|
||||
append(user, node("span", "aksrt-vip-modal-skeleton__avatar"));
|
||||
var userLines = node("span", "aksrt-vip-modal-skeleton__lines");
|
||||
append(userLines, node("span", "aksrt-modal-runtime__skeleton-line is-wide"));
|
||||
append(userLines, node("span", "aksrt-modal-runtime__skeleton-line is-short"));
|
||||
append(user, userLines);
|
||||
append(skeleton, user);
|
||||
|
||||
// 等级卡行(vip-cardminis):两张 tab 占位。
|
||||
var tabs = node("div", "aksrt-vip-modal-skeleton__tabs");
|
||||
append(tabs, node("span", "aksrt-vip-modal-skeleton__tab"));
|
||||
append(tabs, node("span", "aksrt-vip-modal-skeleton__tab"));
|
||||
append(skeleton, tabs);
|
||||
|
||||
// 双列面板:左权益信息 + 右套餐区(套餐卡、支付方式行、提交按钮条)。
|
||||
var pane = node("div", "aksrt-vip-modal-skeleton__pane");
|
||||
var info = node("div", "aksrt-vip-modal-skeleton__info");
|
||||
append(info, node("span", "aksrt-modal-runtime__skeleton-line is-wide"));
|
||||
append(info, node("span", "aksrt-modal-runtime__skeleton-line"));
|
||||
append(info, node("span", "aksrt-modal-runtime__skeleton-line is-short"));
|
||||
append(pane, info);
|
||||
var products = node("div", "aksrt-vip-modal-skeleton__products");
|
||||
append(products, node("span", "aksrt-modal-runtime__skeleton-block"));
|
||||
append(products, node("span", "aksrt-modal-runtime__skeleton-block"));
|
||||
append(products, node("span", "aksrt-modal-runtime__skeleton-line is-short"));
|
||||
append(products, node("span", "aksrt-vip-modal-skeleton__button"));
|
||||
append(pane, products);
|
||||
append(skeleton, pane);
|
||||
|
||||
return skeleton.outerHTML;
|
||||
}
|
||||
|
||||
function vipPay(trigger) {
|
||||
var level = trigger
|
||||
? parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1
|
||||
@@ -706,11 +750,11 @@
|
||||
url: "/pay/vip/modal",
|
||||
query: { vip_level: level },
|
||||
className: "aksrt-vip-modal-shell",
|
||||
skeleton: '<div class="aksrt-vip-modal-panel"><div class="aksrt-vip-modal-content" data-aksrt-vip-modal-body><div class="text-center padding-20 muted-2-color">加载中…</div></div></div>',
|
||||
skeleton: vipSkeleton(),
|
||||
telemetry: "vip",
|
||||
kind: "vip",
|
||||
responseType: "json",
|
||||
cacheTtl: 15000,
|
||||
cacheTtl: 60000,
|
||||
cacheTags: ["vip"],
|
||||
renderData: function (data, session) {
|
||||
renderModalView(session.content, data);
|
||||
@@ -728,6 +772,53 @@
|
||||
});
|
||||
}
|
||||
|
||||
var prefetchedLevels = Object.create(null);
|
||||
|
||||
// 与 openRemote 同一 cacheKey(method + AksrtRequest.url),命中同一份缓存/flight。
|
||||
function vipModalCacheKey(level) {
|
||||
return "GET:" + window.AksrtRequest.url("/pay/vip/modal", { vip_level: level });
|
||||
}
|
||||
|
||||
// 意图预热:悬停/聚焦开通入口时按等级预热弹窗数据(不打开弹窗,失败静默)。
|
||||
// 点击打开时若预热请求在途,cache.acquire 的 flight 去重会自动合并。
|
||||
function prefetchVipModal(trigger) {
|
||||
if (!trigger || !window.AksrtModalCache || !window.AksrtRequest) return;
|
||||
var level = parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1;
|
||||
if (level < 1 || level > 2) level = 1;
|
||||
if (prefetchedLevels[level]) return;
|
||||
prefetchedLevels[level] = true;
|
||||
var cache = window.AksrtModalCache;
|
||||
if (cache.get && cache.get(vipModalCacheKey(level)).hit) return;
|
||||
try {
|
||||
cache.acquire(vipModalCacheKey(level), function () {
|
||||
return window.AksrtRequest.request("/pay/vip/modal", {
|
||||
method: "GET",
|
||||
query: { vip_level: level },
|
||||
});
|
||||
}, {
|
||||
ttl: 60000,
|
||||
tags: ["vip"],
|
||||
});
|
||||
} catch (error) {
|
||||
// 预热失败不影响点击后的正常打开路径。
|
||||
}
|
||||
}
|
||||
|
||||
function vipTriggerFrom(event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.closest) return null;
|
||||
return target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]");
|
||||
}
|
||||
|
||||
function bindPrefetch() {
|
||||
// pointerenter 不冒泡但走捕获阶段;focusin 冒泡,两者互补覆盖鼠标与键盘。
|
||||
["pointerenter", "focusin"].forEach(function (type) {
|
||||
document.addEventListener(type, function (event) {
|
||||
prefetchVipModal(vipTriggerFrom(event));
|
||||
}, true);
|
||||
});
|
||||
}
|
||||
|
||||
function onDocClick(event) {
|
||||
var trigger = event.target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]");
|
||||
if (!trigger) return;
|
||||
@@ -768,6 +859,7 @@
|
||||
if (window.__aksrtPayVipBound) return;
|
||||
window.__aksrtPayVipBound = true;
|
||||
document.addEventListener("click", onDocClick, true);
|
||||
bindPrefetch();
|
||||
bindPointsModal();
|
||||
bindPointsEntry();
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ if (!function_exists('aksrt_header_user_wallet_mini')) :
|
||||
|
||||
$center = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : '#';
|
||||
$box = '';
|
||||
$balance_icon = aksrt_icon_markup('zibsvg-money-color-2', 'em14');
|
||||
$points_icon = aksrt_icon_markup('zibsvg-points-color', 'em14');
|
||||
|
||||
if ($balance_on) {
|
||||
$box .= '<a rel="nofollow" style="flex: 1;" class="muted-box padding-6 flex1 radius4" href="' . esc_url($center) . '">'
|
||||
@@ -144,7 +146,7 @@ if (!function_exists('aksrt_header_user_wallet_mini')) :
|
||||
. '<i class="ml6 fa fas fa-angle-right em12" aria-hidden="true"></i></div>'
|
||||
. '<div class="flex jsb"><span class="font-bold c-blue-2" data-aksrt-wallet-value data-aksrt-count-format="cut-count">'
|
||||
. esc_html(aksrt_cut_count((float) aksrt_user_balance_decimal($user_id)))
|
||||
. '</span><i class="fa fas fa-money-bill-wave em14 c-blue-2" aria-hidden="true"></i></div></a>';
|
||||
. '</span>' . $balance_icon . '</div></a>';
|
||||
}
|
||||
|
||||
if ($points_on) {
|
||||
@@ -154,7 +156,7 @@ if (!function_exists('aksrt_header_user_wallet_mini')) :
|
||||
. '<i class="ml6 fa fas fa-angle-right em12" aria-hidden="true"></i></div>'
|
||||
. '<div class="flex jsb"><span class="font-bold c-yellow" data-aksrt-points-value data-aksrt-count-format="cut-count">'
|
||||
. esc_html(aksrt_cut_count(aksrt_user_points($user_id)))
|
||||
. '</span><i class="fa fas fa-coins em14 c-yellow" aria-hidden="true"></i></div></a>';
|
||||
. '</span>' . $points_icon . '</div></a>';
|
||||
}
|
||||
|
||||
return '<div class="flex ab jsb col-ml6">' . $box . '</div>';
|
||||
@@ -198,7 +200,7 @@ if (!function_exists('aksrt_header_payvip_bar')) :
|
||||
$level = function_exists('aksrt_get_user_vip_level') ? (int) aksrt_get_user_vip_level($user_id) : 0;
|
||||
if ($level > 0) {
|
||||
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
|
||||
$name = (string) ($levels[$level]['name'] ?? ($level === 2 ? __('二级会员', 'aksrt') : __('一级会员', 'aksrt')));
|
||||
$name = (string) ($levels[$level]['name_html'] ?? $levels[$level]['name'] ?? ($level === 2 ? __('二级会员', 'aksrt') : __('一级会员', 'aksrt')));
|
||||
// 到期徽章语义对齐参考主题 zib_get_user_vip_exp_date_text():只有真的拿到
|
||||
// 到期时间才渲染这枚徽章,已过期显示「会员已过期」否则显示裸日期;显式永久
|
||||
// 会员显示「永久会员」;拿不到到期数据时参考主题返回 false,整枚徽章不渲染。
|
||||
@@ -217,7 +219,7 @@ if (!function_exists('aksrt_header_payvip_bar')) :
|
||||
$html = '<div class="mt10 em09" style="padding:2px;">';
|
||||
$html .= '<span class="radius4 payvip-icon btn-block text-center">';
|
||||
$html .= function_exists('aksrt_get_vip_icon') ? aksrt_get_vip_icon($user_id, 'em12 mr6', false) : '';
|
||||
$html .= '<span>' . esc_html($name) . '</span>';
|
||||
$html .= '<span>' . wp_kses_post($name) . '</span>';
|
||||
if ($exp_label !== '') {
|
||||
$html .= '<span class="ml10 badg jb-yellow vip-expdate-tag">'
|
||||
. '<i class="fa fas fa-clock mr3" aria-hidden="true"></i>'
|
||||
|
||||
@@ -42,7 +42,7 @@ endif;
|
||||
|
||||
if (!function_exists('aksrt_brand_icon_map')) :
|
||||
/**
|
||||
* Short key → FA5 class string (allowlisted).
|
||||
* Short key → FA5 class string or built-in SVG sprite key (allowlisted).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@@ -52,10 +52,12 @@ function aksrt_brand_icon_map(): array {
|
||||
return $map;
|
||||
}
|
||||
|
||||
// FA5 Free has no bilibili/gitee brands — use solid stand-ins.
|
||||
// FA5 Free ships no Gitee brand glyph and no multi-colour Google mark, so
|
||||
// those two resolve to the theme's built-in SVG sprite (zibsvg-*) instead of
|
||||
// a solid stand-in; every other key stays on FA5.
|
||||
$map = [
|
||||
'github' => 'fa fab fa-github',
|
||||
'google' => 'fa fab fa-google',
|
||||
'google' => 'zibsvg-google-color',
|
||||
'x' => 'fa fab fa-twitter',
|
||||
'twitter' => 'fa fab fa-twitter',
|
||||
'email' => 'fa fas fa-envelope',
|
||||
@@ -73,7 +75,7 @@ function aksrt_brand_icon_map(): array {
|
||||
'instagram' => 'fa fab fa-instagram',
|
||||
'web' => 'fa fas fa-globe',
|
||||
'globe' => 'fa fas fa-globe',
|
||||
'gitee' => 'fa fas fa-code-branch',
|
||||
'gitee' => 'zibsvg-gitee',
|
||||
'baidu' => 'fa fas fa-paw',
|
||||
'alipay' => 'fa fab fa-alipay',
|
||||
];
|
||||
@@ -127,10 +129,10 @@ endif;
|
||||
|
||||
if (!function_exists('aksrt_brand_icon_class')) :
|
||||
/**
|
||||
* Resolve a short key or FA class string to an allowlisted FA5 class.
|
||||
* Resolve a short key, FA class string or sprite key to its icon value.
|
||||
*
|
||||
* @param string $name Short key or full FA class string.
|
||||
* @return string e.g. 'fa fab fa-github', or '' if unknown.
|
||||
* @param string $name Short key, full FA class string or zibsvg-/customsvg- value.
|
||||
* @return string e.g. 'fa fab fa-github' or 'zibsvg-gitee', or '' if unknown.
|
||||
*/
|
||||
function aksrt_brand_icon_class(string $name): string {
|
||||
$raw = trim($name);
|
||||
@@ -138,6 +140,12 @@ function aksrt_brand_icon_class(string $name): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Built-in SVG sprite and inline SVG values pass straight through; they are
|
||||
// not FA class strings and must survive to the markup renderer untouched.
|
||||
if (preg_match('/^(?:zibsvg|customsvg)-/i', $raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
// Pass through already-valid full FA class strings (footer contact_more).
|
||||
if (preg_match('/^fa\s+(?:fas|far|fab)\s+fa-[\w-]+(?:\s+fa-[\w-]+)*$/i', $raw)) {
|
||||
// Prefer mapped short key when strip matches our catalog (normalizes weixin etc.).
|
||||
@@ -164,14 +172,21 @@ endif;
|
||||
|
||||
if (!function_exists('aksrt_brand_icon')) :
|
||||
/**
|
||||
* Escaped FA <i> markup for a brand/contact icon.
|
||||
* Escaped markup for a brand/contact icon (FA5 <i> or built-in SVG sprite).
|
||||
*
|
||||
* @param string $name Short key or FA class string.
|
||||
* @param string $name Short key, FA class string or zibsvg-/customsvg- value.
|
||||
* @param array|string $extra_classes Optional extra classes.
|
||||
* @return string Safe HTML or empty string when unknown.
|
||||
*/
|
||||
function aksrt_brand_icon(string $name, $extra_classes = []): string {
|
||||
$class = aksrt_brand_icon_class($name);
|
||||
|
||||
// Sprite and inline-SVG values carry their own element, so the shared icon
|
||||
// renderer owns the markup (it emits <svg class="icon"><use …>).
|
||||
if (strpos($class, 'zibsvg-') === 0 || strpos($class, 'customsvg-') === 0) {
|
||||
return aksrt_icon_markup($class, $extra_classes);
|
||||
}
|
||||
|
||||
if ($class === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -428,10 +428,7 @@ if (!function_exists('aksrt_submit_links_modal')) :
|
||||
$body .= '<p><a href="' . esc_url($sign_url) . '" class="signin-loader but c-blue padding-lg">'
|
||||
. '<i class="fa fas fa-fw fa-sign-in-alt mr10" aria-hidden="true"></i>' . esc_html__('登录', 'aksrt') . '</a></p>';
|
||||
if (class_exists('\AKSRT\Auth\OAuth') && method_exists(\AKSRT\Auth\OAuth::get_instance(), 'render_social_buttons')) {
|
||||
$social = (string) \AKSRT\Auth\OAuth::get_instance()->render_social_buttons(__('使用以下方式快速登录', 'aksrt'));
|
||||
if ($social !== '') {
|
||||
$body .= '<div class="social_loginbar">' . $social . '</div>';
|
||||
}
|
||||
$body .= (string) \AKSRT\Auth\OAuth::get_instance()->render_social_buttons(__('使用以下方式快速登录', 'aksrt'));
|
||||
}
|
||||
$body .= '</div>';
|
||||
} else {
|
||||
|
||||
@@ -1309,6 +1309,7 @@ if (!function_exists('aksrt_get_posts_vip_price')) {
|
||||
}
|
||||
|
||||
$level_name = (string) ($level_settings['name'] ?? sprintf(__('VIP%d', 'aksrt'), $level));
|
||||
$level_name_html = (string) ($level_settings['name_html'] ?? $level_name);
|
||||
$price = $vip_minor === 0
|
||||
? esc_html__('免费', 'aksrt')
|
||||
: $mark . esc_html(aksrt_format_price($value));
|
||||
@@ -1319,7 +1320,7 @@ if (!function_exists('aksrt_get_posts_vip_price')) {
|
||||
? ' data-toggle="tooltip" title="' . esc_attr($title) . '"'
|
||||
: '';
|
||||
|
||||
$items[] = '<span class="but vip-price' . $action_class . '" vip-level="' . esc_attr((string) $level) . '" data-vip-level="' . esc_attr((string) $level) . '"' . $title_attr . '>' . $icon . esc_html($level_name) . '<span class="em12 ml3 vip-price-text">' . $price . '</span></span>';
|
||||
$items[] = '<span class="but vip-price' . $action_class . '" vip-level="' . esc_attr((string) $level) . '" data-vip-level="' . esc_attr((string) $level) . '"' . $title_attr . '>' . $icon . wp_kses_post($level_name_html) . '<span class="em12 ml3 vip-price-text">' . $price . '</span></span>';
|
||||
}
|
||||
|
||||
$html = implode('', $items);
|
||||
@@ -1384,10 +1385,13 @@ if (!function_exists('aksrt_get_paywall_html')) {
|
||||
$vip_action = $vip_level > 0
|
||||
? sprintf(__('升级%s', 'aksrt'), $level_name)
|
||||
: __('开通会员', 'aksrt');
|
||||
$vip_action_html = $vip_level > 0
|
||||
? __('升级', 'aksrt') . wp_kses_post((string) ($level_settings['name_html'] ?? $level_name))
|
||||
: esc_html__('开通会员', 'aksrt');
|
||||
$action_class = is_user_logged_in() ? 'pay-vip' : 'signin-loader';
|
||||
$icon = aksrt_get_pay_vip_icon_html($level_settings);
|
||||
$button = '<div class="badg c-yellow em09 mb6"><i class="fa fas fa-fw fa-info-circle mr6" aria-hidden="true"></i>' . esc_html(sprintf(__('您暂无购买权限,请先%s', 'aksrt'), $vip_action)) . '</div>';
|
||||
$button .= '<a href="javascript:;" vip-level="' . esc_attr((string) $pay_limit) . '" data-vip-level="' . esc_attr((string) $pay_limit) . '" class="but btn-block jb-vip' . esc_attr((string) $pay_limit) . ' ' . esc_attr($action_class) . ' padding-lg">' . $icon . esc_html($vip_action) . '</a>';
|
||||
$button .= '<a href="javascript:;" vip-level="' . esc_attr((string) $pay_limit) . '" data-vip-level="' . esc_attr((string) $pay_limit) . '" class="but btn-block jb-vip' . esc_attr((string) $pay_limit) . ' ' . esc_attr($action_class) . ' padding-lg">' . $icon . $vip_action_html . '</a>';
|
||||
} elseif (!is_user_logged_in()) {
|
||||
if ($amount_minor > 0 && $modo === '0' && aksrt_allow_guest_content_pay($post)) {
|
||||
$button = '<button type="button" class="but jb-red padding-lg cashier-link' . $block_class . '" data-aksrt-pay-content="' . esc_attr((string) $post->ID) . '"><i class="fa fas fa-shopping-cart" aria-hidden="true"></i> ' . esc_html__('立即购买', 'aksrt') . '</button>';
|
||||
|
||||
@@ -481,7 +481,7 @@ endif;
|
||||
* or Chinese keyword heuristics — that mangles intentional HTML.
|
||||
*
|
||||
* Rules:
|
||||
* - Sanitize with a small allowlist.
|
||||
* - Sanitize with WordPress post-safe HTML.
|
||||
* - If markup already has <li>, wrap the items in <ul class="v-equity …">.
|
||||
* - Otherwise output the sanitized HTML inside <div class="v-equity …">.
|
||||
* - Plain multi-line text: each non-empty line becomes one <li> (only \n split).
|
||||
@@ -497,21 +497,7 @@ if (!function_exists('aksrt_format_vip_equity_html')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$allowed = [
|
||||
'li' => ['class' => true],
|
||||
'ul' => ['class' => true],
|
||||
'ol' => ['class' => true],
|
||||
'strong' => [],
|
||||
'b' => [],
|
||||
'em' => [],
|
||||
'i' => ['class' => true],
|
||||
'span' => ['class' => true],
|
||||
'br' => [],
|
||||
'p' => ['class' => true],
|
||||
'div' => ['class' => true],
|
||||
'a' => ['href' => true, 'target' => true, 'rel' => true, 'class' => true],
|
||||
];
|
||||
$clean = trim(wp_kses($raw, $allowed));
|
||||
$clean = trim(wp_kses_post($raw));
|
||||
if ($clean === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -286,11 +286,7 @@ if (!function_exists('aksrt_header_user_box_html')) :
|
||||
|
||||
$con = '<div class="text-center">' . $href . '</div>';
|
||||
if (class_exists('\AKSRT\Auth\OAuth') && method_exists(\AKSRT\Auth\OAuth::get_instance(), 'render_social_buttons')) {
|
||||
$oauth_html = (string) \AKSRT\Auth\OAuth::get_instance()->render_social_buttons(__('快速登录', 'aksrt'));
|
||||
if ($oauth_html !== '') {
|
||||
$con .= '<p class="social-separator separator muted-3-color em09 mt10">' . esc_html__('快速登录', 'aksrt') . '</p>';
|
||||
$con .= '<div class="social_loginbar">' . $oauth_html . '</div>';
|
||||
}
|
||||
$con .= (string) \AKSRT\Auth\OAuth::get_instance()->render_social_buttons(__('快速登录', 'aksrt'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ require __DIR__ . '/sidebar-vip.php';
|
||||
require __DIR__ . '/sidebar-commerce.php';
|
||||
require __DIR__ . '/sidebar-creator.php';
|
||||
require __DIR__ . '/sidebar-nav.php';
|
||||
$drawer_trigger_icon = aksrt_icon_markup('zibsvg-menu');
|
||||
?>
|
||||
<button type="button" class="uc-sidebar__drawer-trigger" id="uc-drawer-trigger" aria-label="<?php esc_attr_e('打开导航', 'aksrt'); ?>">
|
||||
<i class="fa fas fa-th-large" aria-hidden="true"></i>
|
||||
<?php echo $drawer_trigger_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</button>
|
||||
@@ -26,10 +26,14 @@ foreach ((array) ($oauth_data['bindings'] ?? []) as $binding) {
|
||||
if (!$key) {
|
||||
continue;
|
||||
}
|
||||
// Reference parity: the provider type (clogin_ prefix stripped) drives the
|
||||
// .social-login-item brand colour, matching zibll's binding list.
|
||||
$icon_type = str_starts_with($key, 'clogin_') ? substr($key, 7) : $key;
|
||||
$binding_rows[$key] = [
|
||||
'key' => $key,
|
||||
'name' => (string) ($binding['label'] ?? $binding_name_map[$key] ?? $binding['name'] ?? ucfirst($key)),
|
||||
'icon' => $binding['icon'] ?? '',
|
||||
'icon_type' => $icon_type,
|
||||
'is_bound' => true,
|
||||
'url' => '',
|
||||
];
|
||||
@@ -39,10 +43,12 @@ foreach ((array) ($oauth_data['available'] ?? []) as $binding) {
|
||||
if (!$key) {
|
||||
continue;
|
||||
}
|
||||
$icon_type = str_starts_with($key, 'clogin_') ? substr($key, 7) : $key;
|
||||
$binding_rows[$key] = [
|
||||
'key' => $key,
|
||||
'name' => (string) ($binding['label'] ?? $binding_name_map[$key] ?? $binding['name'] ?? ucfirst($key)),
|
||||
'icon' => $binding['icon'] ?? '',
|
||||
'icon_type' => $icon_type,
|
||||
'is_bound' => false,
|
||||
'url' => $binding['start_url'] ?? $binding['url'] ?? '',
|
||||
];
|
||||
@@ -64,7 +70,7 @@ foreach ((array) ($oauth_data['available'] ?? []) as $binding) {
|
||||
<?php foreach ($binding_rows as $binding) : ?>
|
||||
<?php if (!empty($binding['is_bound'])) : ?>
|
||||
<div class="aksrt-account-row binding-item">
|
||||
<span class="binding-item__icon"><?php echo $binding['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
|
||||
<span class="social-login-item circular <?php echo esc_attr($binding['icon_type']); ?>"><?php echo $binding['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
|
||||
<span class="aksrt-account-row__social-name"><?php echo esc_html($binding['name']); ?></span>
|
||||
<span class="binding-status">
|
||||
<i class="fa fas fa-check-circle badge__icon" aria-hidden="true"></i>
|
||||
@@ -76,7 +82,7 @@ foreach ((array) ($oauth_data['available'] ?? []) as $binding) {
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<a href="<?php echo esc_url($binding['url']); ?>" class="aksrt-account-row binding-item binding-item--linkable" target="_self" data-provider="<?php echo esc_attr($binding['key']); ?>" data-bind-start-url="<?php echo esc_url($binding['url']); ?>">
|
||||
<span class="binding-item__icon"><?php echo $binding['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
|
||||
<span class="social-login-item circular <?php echo esc_attr($binding['icon_type']); ?>"><?php echo $binding['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
|
||||
<span class="aksrt-account-row__social-name"><?php echo esc_html($binding['name']); ?></span>
|
||||
<span class="binding-status binding-status--idle"><?php esc_html_e('未绑定', 'aksrt'); ?></span>
|
||||
<span class="but hollow c-blue aksrt-account-row__action"><?php esc_html_e('绑定', 'aksrt'); ?></span>
|
||||
|
||||
@@ -20,23 +20,26 @@ try {
|
||||
$wallet = '0.00';
|
||||
}
|
||||
$points = max(0, (int) ($credit_balance ?? 0));
|
||||
$wallet_icon = aksrt_icon_markup('zibsvg-money-color-2');
|
||||
$points_icon = aksrt_icon_markup('zibsvg-points-color');
|
||||
$arrow_icon = aksrt_icon_markup('zibsvg-right');
|
||||
?>
|
||||
<div class="uc-sidebar__pair uc-sidebar__pair--assets">
|
||||
<?php if ($show_wallet) : ?>
|
||||
<a class="uc-summary-card uc-summary-card--wallet" href="<?php echo esc_url($assets_url); ?>" data-tab="credits">
|
||||
<span class="uc-summary-card__head"><?php esc_html_e('账户余额', 'aksrt'); ?><i class="fa fas fa-angle-right" aria-hidden="true"></i></span>
|
||||
<span class="uc-summary-card__head"><?php esc_html_e('账户余额', 'aksrt'); ?><?php echo $arrow_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
|
||||
<span class="uc-summary-card__body">
|
||||
<strong data-aksrt-wallet-value><?php echo esc_html($wallet); ?></strong>
|
||||
<i class="fa fas fa-money-bill" aria-hidden="true"></i>
|
||||
<?php echo $wallet_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($show_credits) : ?>
|
||||
<a class="uc-summary-card uc-summary-card--points" href="<?php echo esc_url($assets_url); ?>" data-tab="credits">
|
||||
<span class="uc-summary-card__head"><?php echo esc_html((string) ($currency_name ?? __('积分', 'aksrt'))); ?><i class="fa fas fa-angle-right" aria-hidden="true"></i></span>
|
||||
<span class="uc-summary-card__head"><?php echo esc_html((string) ($currency_name ?? __('积分', 'aksrt'))); ?><?php echo $arrow_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
|
||||
<span class="uc-summary-card__body">
|
||||
<strong data-aksrt-points-value data-credits-value><?php echo esc_html(aksrt_format_points($points)); ?></strong>
|
||||
<i class="fa fas fa-money-bill" aria-hidden="true"></i>
|
||||
<?php echo $points_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -13,20 +13,23 @@ $revenue_url = isset($dashboard_tabs['revenue']) ? aksrt_get_user_center_tab_url
|
||||
if ($creation_url === '' && $revenue_url === '') {
|
||||
return;
|
||||
}
|
||||
$creation_icon = aksrt_icon_markup('zibsvg-merchant-color');
|
||||
$revenue_icon = aksrt_icon_markup('zibsvg-money-color');
|
||||
$arrow_icon = aksrt_icon_markup('zibsvg-right');
|
||||
?>
|
||||
<div class="uc-sidebar__pair uc-sidebar__pair--centers">
|
||||
<?php if ($creation_url !== '') : ?>
|
||||
<a class="uc-center-card uc-center-card--creation" href="<?php echo esc_url($creation_url); ?>" data-tab="creator">
|
||||
<span class="uc-center-card__icon"><i class="fa fas fa-pen" aria-hidden="true"></i></span>
|
||||
<span class="uc-center-card__icon"><?php echo $creation_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
|
||||
<span><strong><?php esc_html_e('创作收益', 'aksrt'); ?></strong><small><?php esc_html_e('作者收益明细', 'aksrt'); ?></small></span>
|
||||
<i class="fa fas fa-angle-right" aria-hidden="true"></i>
|
||||
<?php echo $arrow_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($revenue_url !== '') : ?>
|
||||
<a class="uc-center-card uc-center-card--revenue" href="<?php echo esc_url($revenue_url); ?>" data-tab="revenue">
|
||||
<span class="uc-center-card__icon"><i class="fa fas fa-chart-line" aria-hidden="true"></i></span>
|
||||
<span class="uc-center-card__icon"><?php echo $revenue_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
|
||||
<span><strong><?php esc_html_e('推广收益', 'aksrt'); ?></strong><small><?php esc_html_e('推广佣金明细', 'aksrt'); ?></small></span>
|
||||
<i class="fa fas fa-angle-right" aria-hidden="true"></i>
|
||||
<?php echo $arrow_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -2,6 +2,9 @@
|
||||
/**
|
||||
* User center sidebar: services and account settings.
|
||||
*
|
||||
* Icon set mirrors the reference theme user-center sidebar: every entry renders
|
||||
* an inline SVG from the theme sprite through aksrt_icon_markup().
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
@@ -17,22 +20,20 @@ $orders_url = $orders_available ? (string) ($orders_url ?? '') : '';
|
||||
$services = array_values(array_filter([
|
||||
[
|
||||
'label' => __('我的等级', 'aksrt'),
|
||||
'icon' => 'chart-line',
|
||||
'tone' => 'level',
|
||||
'icon' => 'trend-color',
|
||||
'url' => $level_url,
|
||||
'tab' => 'level',
|
||||
],
|
||||
|
||||
[
|
||||
'label' => __('官方认证', 'aksrt'),
|
||||
'icon' => 'id-badge',
|
||||
'tone' => 'auth',
|
||||
'icon' => 'user-auth',
|
||||
'url' => $auth_url,
|
||||
'tab' => 'auth',
|
||||
],
|
||||
[
|
||||
'label' => __('我的订单', 'aksrt'),
|
||||
'icon' => 'receipt',
|
||||
'tone' => 'orders',
|
||||
'icon' => 'order-color',
|
||||
'url' => $orders_url,
|
||||
'tab' => 'orders',
|
||||
],
|
||||
@@ -41,35 +42,38 @@ $services = array_values(array_filter([
|
||||
$settings = array_values(array_filter([
|
||||
[
|
||||
'label' => __('消息通知', 'aksrt'),
|
||||
'icon' => 'bell',
|
||||
'tone' => 'notice',
|
||||
'icon' => 'msg-color',
|
||||
'url' => isset($dashboard_tabs['msg']) ? aksrt_get_user_center_tab_url('msg') : '',
|
||||
'tab' => 'msg',
|
||||
],
|
||||
[
|
||||
'label' => __('个人资料', 'aksrt'),
|
||||
'icon' => 'user-edit',
|
||||
'tone' => 'profile',
|
||||
'icon' => 'user-color',
|
||||
'url' => isset($dashboard_tabs['profile']) ? aksrt_get_user_center_tab_url('profile') : '',
|
||||
'tab' => 'profile',
|
||||
],
|
||||
[
|
||||
'label' => __('账户安全', 'aksrt'),
|
||||
'icon' => 'shield-alt',
|
||||
'tone' => 'security',
|
||||
'icon' => 'security-color',
|
||||
'url' => isset($dashboard_tabs['settings']) ? aksrt_get_user_center_tab_url('settings') : '',
|
||||
'tab' => 'settings',
|
||||
],
|
||||
], static fn(array $item): bool => (string) $item['url'] !== ''));
|
||||
|
||||
$render_grid = static function (array $items): void {
|
||||
?>
|
||||
<div class="uc-service-grid">
|
||||
<?php foreach ($items as $item) : ?>
|
||||
<a class="uc-service-grid__item uc-service-grid__item--<?php echo esc_attr((string) $item['tone']); ?><?php echo !empty($item['tab']) && ($active_tab ?? '') === $item['tab'] ? ' is-active' : ''; ?>" href="<?php echo esc_url((string) $item['url']); ?>"<?php if (!empty($item['tab'])) : ?> data-tab="<?php echo esc_attr((string) $item['tab']); ?>"<?php endif; ?>>
|
||||
<span class="uc-service-grid__icon">
|
||||
<i class="fa fas fa-<?php echo esc_attr((string) $item['icon']); ?>" aria-hidden="true"></i>
|
||||
<?php if (!empty($item['count'])) : ?><small><?php echo esc_html((string) $item['count']); ?></small><?php endif; ?>
|
||||
</span>
|
||||
<span><?php echo esc_html((string) $item['label']); ?></span>
|
||||
<div class="flex ac hh text-center icon-but-box user-icon-but-box">
|
||||
<?php foreach ($items as $item) :
|
||||
$tab = (string) ($item['tab'] ?? '');
|
||||
$item_class = 'icon-but-' . ($tab !== '' ? $tab : 'item');
|
||||
if (($active_tab ?? '') === $tab) {
|
||||
$item_class .= ' is-active';
|
||||
}
|
||||
$icon_html = aksrt_icon_markup('zibsvg-' . (string) $item['icon']);
|
||||
?>
|
||||
<a class="<?php echo esc_attr($item_class); ?>" href="<?php echo esc_url((string) $item['url']); ?>"<?php if ($tab !== '') : ?> data-tab="<?php echo esc_attr($tab); ?>"<?php endif; ?>>
|
||||
<div class="em16"><?php echo $icon_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
|
||||
<div class="px12 muted-color mt3"><?php echo esc_html((string) $item['label']); ?></div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ if ($status === '') {
|
||||
}
|
||||
|
||||
if ($vip_level > 0) {
|
||||
$title = (string) ($vip_levels[$vip_level]['name'] ?? ('VIP' . $vip_level));
|
||||
$title = (string) ($vip_levels[$vip_level]['name_html'] ?? $vip_levels[$vip_level]['name'] ?? ('VIP' . $vip_level));
|
||||
if ($expires <= 0 && ($vip_state['source'] ?? 'none') !== 'order') {
|
||||
$status = __('永久会员', 'aksrt');
|
||||
} else {
|
||||
@@ -41,7 +41,7 @@ if ($vip_level > 0) {
|
||||
);
|
||||
}
|
||||
} elseif ($expired_level > 0) {
|
||||
$title = (string) ($vip_levels[$expired_level]['name'] ?? ('VIP' . $expired_level));
|
||||
$title = (string) ($vip_levels[$expired_level]['name_html'] ?? $vip_levels[$expired_level]['name'] ?? ('VIP' . $expired_level));
|
||||
$status = __('已过期', 'aksrt');
|
||||
}
|
||||
|
||||
@@ -49,13 +49,14 @@ $sidebar_level = $vip_level > 0 ? $vip_level : $expired_level;
|
||||
$sidebar_icon_url = esc_url((string) ($vip_levels[$sidebar_level]['img_icon'] ?? ''));
|
||||
$sidebar_icon = $sidebar_icon_url !== ''
|
||||
? '<img' . aksrt_get_lazy_attr('lazy_other', $sidebar_icon_url, 'aksrt-vip-level-icon') . ' alt="" decoding="async">'
|
||||
: '<i class="fa fas fa-certificate" aria-hidden="true"></i>';
|
||||
: aksrt_icon_markup('zibsvg-vip_1');
|
||||
$sidebar_arrow = aksrt_icon_markup('zibsvg-right', 'uc-sidebar-vip__arrow');
|
||||
?>
|
||||
<a class="uc-sidebar-vip" href="<?php echo esc_url($vip_url); ?>" data-tab="vip">
|
||||
<span class="uc-sidebar-vip__icon"><?php echo $sidebar_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
|
||||
<span class="uc-sidebar-vip__content">
|
||||
<strong><?php echo esc_html($title); ?></strong>
|
||||
<small><?php echo esc_html($status); ?></small>
|
||||
<strong><?php echo wp_kses_post($title); ?></strong>
|
||||
<small><?php echo wp_kses_post($status); ?></small>
|
||||
</span>
|
||||
<i class="fa fas fa-angle-right uc-sidebar-vip__arrow" aria-hidden="true"></i>
|
||||
<?php echo $sidebar_arrow; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</a>
|
||||
@@ -29,6 +29,8 @@ $vip_state_name = (string) ($vip_state['state'] ?? 'unopened');
|
||||
$vip_state_source = (string) ($vip_state['source'] ?? 'none');
|
||||
$levels = is_array($vip_settings['levels'] ?? null) ? $vip_settings['levels'] : [];
|
||||
$desc_top = (string) ($vip_settings['desc'] ?? '');
|
||||
$renew_desc = (string) ($vip_settings['renew']['desc'] ?? '');
|
||||
$upgrade_desc = (string) ($vip_settings['upgrade']['desc'] ?? '');
|
||||
|
||||
// Membership summary: title → level → intro → expiry.
|
||||
$vip_state = 'unopened';
|
||||
@@ -36,13 +38,15 @@ $vip_state_label = __('未开通', 'aksrt');
|
||||
$vip_level_number = 0;
|
||||
$vip_level_name = __('未开通会员', 'aksrt');
|
||||
$vip_description = $desc_top !== '' ? $desc_top : __('开通会员,尊享专属特权', 'aksrt');
|
||||
$vip_expiry_text = __('到期时间:未开通', 'aksrt');
|
||||
// 未开通时不留到期文案:状态标签已显示「未开通」,参考主题对从未开通
|
||||
// 用户同样不输出任何到期信息(仅生效中/永久/已过期才有)。
|
||||
$vip_expiry_text = '';
|
||||
$vip_is_permanent = false;
|
||||
$btn_type = 'pay';
|
||||
|
||||
if ($level > 0) {
|
||||
$vip_level_number = $level;
|
||||
$vip_level_name = (string) ($levels[$level]['name'] ?? ('VIP' . $level));
|
||||
$vip_level_name = (string) ($levels[$level]['name_html'] ?? $levels[$level]['name'] ?? ('VIP' . $level));
|
||||
$level_desc = (string) ($levels[$level]['desc'] ?? '');
|
||||
$vip_description = $level_desc !== '' ? $level_desc : __('尊享专属特权', 'aksrt');
|
||||
|
||||
@@ -59,7 +63,7 @@ if ($level > 0) {
|
||||
$vip_state_label = __('生效中', 'aksrt');
|
||||
$vip_expiry_text = sprintf(__('到期时间:%s', 'aksrt'), wp_date('Y年m月d日', $expires));
|
||||
if ($base_level > 0 && $base_level < $level) {
|
||||
$base_name = (string) ($levels[$base_level]['name'] ?? ('VIP' . $base_level));
|
||||
$base_name = wp_strip_all_tags((string) ($levels[$base_level]['name'] ?? ('VIP' . $base_level)));
|
||||
$base_expiry_text = $base_expires > 0
|
||||
? wp_date('Y年m月d日', $base_expires)
|
||||
: __('永久', 'aksrt');
|
||||
@@ -71,7 +75,7 @@ if ($level > 0) {
|
||||
$vip_state = 'expired';
|
||||
$vip_state_label = __('已过期', 'aksrt');
|
||||
$vip_level_number = $expired_level;
|
||||
$vip_level_name = (string) ($levels[$expired_level]['name'] ?? ('VIP' . $expired_level));
|
||||
$vip_level_name = (string) ($levels[$expired_level]['name_html'] ?? $levels[$expired_level]['name'] ?? ('VIP' . $expired_level));
|
||||
$expired_desc = (string) ($levels[$expired_level]['desc'] ?? '');
|
||||
$vip_description = $expired_desc !== ''
|
||||
? $expired_desc
|
||||
@@ -91,11 +95,12 @@ $status_html = '<div class="uc-vip-status" role="status">'
|
||||
. '<span class="uc-vip-status__state">' . esc_html($vip_state_label) . '</span>'
|
||||
. '</div>'
|
||||
. '<div class="uc-vip-status__main uc-vip-status__main--level-' . (int) $vip_level_number . '">'
|
||||
. '<span class="uc-vip-status__icon" aria-hidden="true">' . $status_icon . '</span>'
|
||||
. '<span class="uc-vip-status__level">' . esc_html($vip_level_name) . '</span>'
|
||||
// 未开通(level 0)不输出图标位:名称前不放通用证书图标。
|
||||
. ($vip_level_number > 0 ? '<span class="uc-vip-status__icon" aria-hidden="true">' . $status_icon . '</span>' : '')
|
||||
. '<span class="uc-vip-status__level">' . wp_kses_post($vip_level_name) . '</span>'
|
||||
. '</div>'
|
||||
. '<div class="uc-vip-status__desc">' . esc_html($vip_description) . '</div>'
|
||||
. '<div class="uc-vip-status__expiry"><i class="fa fas fa-clock" aria-hidden="true"></i><span>' . esc_html($vip_expiry_text) . '</span></div>'
|
||||
. '<div class="uc-vip-status__desc">' . wp_kses_post($vip_description) . '</div>'
|
||||
. ($vip_expiry_text !== '' ? '<div class="uc-vip-status__expiry"><i class="fa fas fa-clock" aria-hidden="true"></i><span>' . esc_html($vip_expiry_text) . '</span></div>' : '')
|
||||
. '</div>';
|
||||
|
||||
// Level cards.
|
||||
@@ -104,15 +109,18 @@ foreach ([1, 2] as $lv) {
|
||||
if (empty($levels[$lv]['enabled'])) {
|
||||
continue;
|
||||
}
|
||||
$lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
|
||||
$lv_name = (string) ($levels[$lv]['name_html'] ?? $levels[$lv]['name'] ?? ('VIP' . $lv));
|
||||
$lv_desc = (string) ($levels[$lv]['desc'] ?? '');
|
||||
// Preserve admin equity HTML as-is (sanitize/wrap only; never re-split into items).
|
||||
$equity_raw = (string) ($levels[$lv]['equity'] ?? '');
|
||||
$equity = function_exists('aksrt_format_vip_equity_html')
|
||||
? aksrt_format_vip_equity_html($equity_raw)
|
||||
: '';
|
||||
$cta = sprintf(__('开通%s', 'aksrt'), $lv_name);
|
||||
$cta = __('开通', 'aksrt') . wp_kses_post($lv_name);
|
||||
$vip_attr = $lv;
|
||||
// 参考主题 zibpay_get_vip_card:续费/升级档位卡展示各自的一句话简介。
|
||||
$card_action = 'pay';
|
||||
$card_desc = $lv_desc;
|
||||
$lv_icon_url = esc_url((string) ($levels[$lv]['img_icon'] ?? ''));
|
||||
$lv_icon = $lv_icon_url !== ''
|
||||
? '<img' . aksrt_get_lazy_attr('lazy_other', $lv_icon_url, 'aksrt-vip-level-icon') . ' alt="" decoding="async">'
|
||||
@@ -120,9 +128,11 @@ foreach ([1, 2] as $lv) {
|
||||
if (str_starts_with($btn_type, 'renew_')) {
|
||||
$renew_lv = (int) substr($btn_type, 6);
|
||||
if ($lv === $renew_lv) {
|
||||
$cta = sprintf(__('续费%s', 'aksrt'), $lv_name);
|
||||
$cta = __('续费', 'aksrt') . wp_kses_post($lv_name);
|
||||
$card_action = 'renew';
|
||||
} elseif ($lv > $renew_lv) {
|
||||
$cta = sprintf(__('升级%s', 'aksrt'), $lv_name);
|
||||
$cta = __('升级', 'aksrt') . wp_kses_post($lv_name);
|
||||
$card_action = 'upgrade';
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
@@ -130,25 +140,32 @@ foreach ([1, 2] as $lv) {
|
||||
if ($lv <= $level) {
|
||||
continue;
|
||||
}
|
||||
$cta = sprintf(__('升级%s', 'aksrt'), $lv_name);
|
||||
$cta = __('升级', 'aksrt') . wp_kses_post($lv_name);
|
||||
$card_action = 'upgrade';
|
||||
$vip_attr = 2;
|
||||
} elseif ($level > 0 && $lv < $level) {
|
||||
continue;
|
||||
} elseif ($level > 0 && $lv === $level) {
|
||||
$cta = sprintf(__('续费%s', 'aksrt'), $lv_name);
|
||||
$cta = __('续费', 'aksrt') . wp_kses_post($lv_name);
|
||||
$card_action = 'renew';
|
||||
}
|
||||
if ($card_action === 'renew' && $renew_desc !== '') {
|
||||
$card_desc = $renew_desc;
|
||||
} elseif ($card_action === 'upgrade' && $upgrade_desc !== '') {
|
||||
$card_desc = $upgrade_desc;
|
||||
}
|
||||
|
||||
$cards .= '<div class="aksrt-panel text-center pay-vip-info flex1 level-' . $lv . ' uc-vip-level-card">';
|
||||
$cards .= '<div class="relative uc-vip-level-card__inner">';
|
||||
$cards .= '<div class="v-iconbox mb10">';
|
||||
$cards .= '<div class="v-name font-bold"><span class="v-icon">' . $lv_icon . '</span>' . esc_html($lv_name) . '</div>';
|
||||
if ($lv_desc !== '') {
|
||||
$cards .= '<div class="v-desc em09 muted-2-color">' . esc_html($lv_desc) . '</div>';
|
||||
$cards .= '<div class="v-name font-bold"><span class="v-icon">' . $lv_icon . '</span>' . wp_kses_post($lv_name) . '</div>';
|
||||
if ($card_desc !== '') {
|
||||
$cards .= '<div class="v-desc em09 muted-2-color">' . wp_kses_post($card_desc) . '</div>';
|
||||
}
|
||||
$cards .= '</div>';
|
||||
$cards .= $equity;
|
||||
$cards .= '<a class="padding-lg but jb-vip' . $lv . ' radius pay-vip uc-vip-cta" href="javascript:;" vip-level="' . (int) $vip_attr . '">'
|
||||
. '<span>' . esc_html($cta) . '</span>'
|
||||
. '<span>' . $cta . '</span>'
|
||||
. ' <i class="fa fas fa-chevron-circle-right" aria-hidden="true"></i></a>';
|
||||
$cards .= '</div></div>';
|
||||
}
|
||||
@@ -169,10 +186,4 @@ foreach ([1, 2] as $lv) {
|
||||
// 下方的全宽横条;仅非会员 + 积分兑换开启 + 有可兑换套餐时输出)。
|
||||
echo aksrt_get_vip_points_exchange_entry_html($user_id, $level); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- 各字段已在生成时转义。
|
||||
?>
|
||||
|
||||
<?php if (!empty($vip_settings['more'])) : ?>
|
||||
<div class="muted-2-color em09 muted-box padding-10 mini-radius mt15">
|
||||
<?php echo $vip_settings['more']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@ class SchemaPayment {
|
||||
['key' => 'points_pass_exchange_desc', 'label' => '卡密兑换积分说明', 'type' => 'textarea', 'default' => '', 'rows' => 2, 'dependsOn' => ['key' => 'points_pass_exchange_s', 'value' => true], 'help' => '用户使用卡密兑换积分时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码'],
|
||||
['key' => 'points_pass_exchange_only_password', 'label' => '积分卡密单密码模式', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'points_pass_exchange_s', 'value' => true], 'help' => '积分卡密仅需输入密码,不需要卡号'],
|
||||
['key' => 'pay_vip_pass_charge_s', 'label' => '启用卡密兑换会员功能', 'type' => 'switch', 'default' => false, 'help' => '开启后用户可在会员购买/续费弹窗选择「卡密」渠道,使用会员卡密直接兑换会员'],
|
||||
['key' => 'pay_vip_pass_charge_desc', 'label' => '卡密兑换会员说明', 'type' => 'textarea', 'default' => '', 'rows' => 2, 'dependsOn' => ['key' => 'pay_vip_pass_charge_s', 'value' => true], 'help' => '用户使用卡密兑换会员时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码'],
|
||||
['key' => 'pay_vip_pass_charge_desc', 'label' => '卡密兑换会员说明', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '', 'rows' => 2, 'dependsOn' => ['key' => 'pay_vip_pass_charge_s', 'value' => true], 'help' => '用户使用卡密兑换会员时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码'],
|
||||
['key' => 'pay_vip_pass_charge_only_password', 'label' => '会员卡密单密码模式', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'pay_vip_pass_charge_s', 'value' => true], 'help' => '会员卡密仅需输入密码,不需要卡号;默认关闭,会员卡需输入卡号和密码(与其他卡密兑换一致)'],
|
||||
]],
|
||||
['key' => 'points-exchange', 'title' => '余额兑换积分', 'fields' => [
|
||||
@@ -178,7 +178,7 @@ class SchemaPayment {
|
||||
'vip' => [
|
||||
'key' => 'vip',
|
||||
'title' => 'VIP会员',
|
||||
'version' => '1.1.0',
|
||||
'version' => '1.2.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'notice', 'title' => '说明', 'fields' => [
|
||||
@@ -188,17 +188,8 @@ class SchemaPayment {
|
||||
['key' => 'nav_pay_vip', 'label' => '导航栏购买按钮', 'type' => 'switch', 'default' => true, 'help' => '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。'],
|
||||
['key' => 'nav_user_pay_vip', 'label' => '用户框购买按钮', 'type' => 'switch', 'default' => true, 'help' => '在导航栏用户框内显示开通会员按钮。'],
|
||||
['key' => 'pay_user_vip_1_s', 'label' => '启用一级会员', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'pay_user_vip_1_name', 'label' => '一级会员显示名称', 'type' => 'input', 'default' => '一级会员', 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true]],
|
||||
['key' => 'pay_user_vip_1_desc', 'label' => '一级会员一句话简介', 'type' => 'input', 'default' => '尊享一级会员特权', 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true]],
|
||||
['key' => 'pay_user_vip_1_equity', 'label' => '一级会员权益 HTML', 'type' => 'textarea', 'default' => '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>', 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true]],
|
||||
['key' => 'vip_1_img_icon', 'label' => '一级会员图标 URL', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true], 'help' => '留空使用内置 vip-1.svg 图标'],
|
||||
['key' => 'vip_level_order_notice', 'label' => '', 'type' => 'notice', 'ui_only' => true, 'alertType' => 'danger', 'dependsOn' => ['all' => [['key' => 'pay_user_vip_2_s', 'value' => true], ['key' => 'pay_user_vip_1_s', 'operator' => 'falsy']]], 'content' => '<div style="text-align:center"><b>必须先开启一级会员后才能开启二级会员!</b></div>'],
|
||||
['key' => 'pay_user_vip_2_s', 'label' => '启用二级会员', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'pay_user_vip_2_name', 'label' => '二级会员显示名称', 'type' => 'input', 'default' => '二级会员', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true]],
|
||||
['key' => 'pay_user_vip_2_desc', 'label' => '二级会员一句话简介', 'type' => 'input', 'default' => '尊享二级会员特权', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true]],
|
||||
['key' => 'pay_user_vip_2_equity', 'label' => '二级会员权益 HTML', 'type' => 'textarea', 'default' => '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true]],
|
||||
['key' => 'vip_2_img_icon', 'label' => '二级会员图标 URL', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true], 'help' => '留空使用内置 vip-2.svg 图标'],
|
||||
['key' => 'pay_user_vip_desc', 'label' => '弹窗顶部会员说明', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'pay_user_vip_more', 'label' => '弹窗底部补充说明 HTML', 'type' => 'textarea', 'default' => ''],
|
||||
]],
|
||||
['key' => 'benefit', 'title' => '会员权益', 'fields' => [
|
||||
['key' => 'vip_benefit', 'label' => '会员权益', 'type' => 'tabbed', 'tabs' => [
|
||||
@@ -206,11 +197,11 @@ class SchemaPayment {
|
||||
['key' => 'pay_download_limit', 'label' => '付费资源:免费资源每日可下载(个)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'help' => '普通用户每日最多可下载几个免费资源(为0则不限制)'],
|
||||
['key' => 'pay_download_speed', 'label' => '付费资源:下载限速(KB/s)', 'type' => 'number', 'default' => 0, 'min' => 0, 'help' => '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效'],
|
||||
]],
|
||||
['key' => 'vip1', 'title' => '一级会员', 'fields' => [
|
||||
['key' => 'vip1', 'title' => '一级会员', 'titleKey' => 'pay_user_vip_1_name', 'titleFallback' => '一级会员', 'fields' => [
|
||||
['key' => 'pay_download_limit_vip_1', 'label' => '付费资源:免费资源每日可下载(个)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true], 'help' => '一级会员每日最多可下载几个免费资源(为0则不限制)'],
|
||||
['key' => 'pay_download_speed_vip_1', 'label' => '付费资源:下载限速(KB/s)', 'type' => 'number', 'default' => 0, 'min' => 0, 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true], 'help' => '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效'],
|
||||
]],
|
||||
['key' => 'vip2', 'title' => '二级会员', 'fields' => [
|
||||
['key' => 'vip2', 'title' => '二级会员', 'titleKey' => 'pay_user_vip_2_name', 'titleFallback' => '二级会员', 'fields' => [
|
||||
['key' => 'pay_download_limit_vip_2', 'label' => '付费资源:免费资源每日可下载(个)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true], 'help' => '二级会员每日最多可下载几个免费资源(为0则不限制)'],
|
||||
['key' => 'pay_download_speed_vip_2', 'label' => '付费资源:下载限速(KB/s)', 'type' => 'number', 'default' => 0, 'min' => 0, 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true], 'help' => '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效'],
|
||||
]],
|
||||
@@ -218,36 +209,110 @@ class SchemaPayment {
|
||||
]],
|
||||
['key' => 'opt', 'title' => '会员购买选项', 'fields' => [
|
||||
['key' => 'vip_opt', 'label' => '会员购买选项', 'type' => 'tabbed', 'tabs' => [
|
||||
['key' => 'vip1', 'title' => '一级会员', 'fields' => [
|
||||
['key' => 'vip_1_product_s', 'label' => '允许购买一级会员', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true]],
|
||||
['key' => 'payment_vip_1_products', 'label' => '一级会员套餐', 'type' => 'textarea', 'default' => "30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡", 'dependsOn' => ['key' => 'vip_1_product_s', 'value' => true], 'help' => '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久'],
|
||||
]],
|
||||
['key' => 'vip2', 'title' => '二级会员', 'fields' => [
|
||||
['key' => 'vip_2_product_s', 'label' => '允许购买二级会员', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true]],
|
||||
['key' => 'payment_vip_2_products', 'label' => '二级会员套餐', 'type' => 'textarea', 'default' => "58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡", 'dependsOn' => ['key' => 'vip_2_product_s', 'value' => true], 'help' => '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久'],
|
||||
]],
|
||||
['key' => 'vip1', 'title' => '一级会员', 'titleKey' => 'pay_user_vip_1_name', 'titleFallback' => '一级会员', 'fields' => self::vip_level_tab_fields(1)],
|
||||
['key' => 'vip2', 'title' => '二级会员', 'titleKey' => 'pay_user_vip_2_name', 'titleFallback' => '二级会员', 'fields' => self::vip_level_tab_fields(2)],
|
||||
['key' => 'renew', 'title' => '会员续费', 'fields' => [
|
||||
['key' => 'vip_renew', 'label' => '允许续费', 'type' => 'switch', 'default' => true, 'help' => '关闭后则不能续费,只能会员到期之后再购买。永久会员不需要续费'],
|
||||
['key' => 'vip_renew_desc', 'label' => '续费说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'vip_renew', 'value' => true]],
|
||||
['key' => 'vip_renew_price_type', 'label' => '续费价格类型', 'type' => 'radio', 'default' => 'discount', 'dependsOn' => ['key' => 'vip_renew', 'value' => true], 'options' => [
|
||||
['key' => 'vip_renew_desc', 'label' => '续费介绍', 'type' => 'input', 'sanitize_type' => 'html', 'default' => '立即续费会员,畅享VIP权益', 'dependsOn' => ['key' => 'vip_renew', 'value' => true], 'help' => '续费会员的一句话简介,支持HTML'],
|
||||
['key' => 'vip_renew_price_type', 'label' => '续费价格类型', 'type' => 'select', 'default' => 'discount', 'dependsOn' => ['key' => 'vip_renew', 'value' => true], 'options' => [
|
||||
['label' => '相同价格', 'value' => 'same'],
|
||||
['label' => '折扣价格', 'value' => 'discount'],
|
||||
['label' => '优惠立减', 'value' => 'reduce'],
|
||||
]],
|
||||
['key' => 'vip_renew_discount', 'label' => '续费折扣(折,如 8=八折)', 'type' => 'number', 'default' => 8, 'min' => 1, 'max' => 100, 'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'discount']],
|
||||
['key' => 'vip_renew_reduce', 'label' => '续费减免金额', 'type' => 'input', 'sanitize_type' => 'money_decimal', 'default' => '20.00', 'min' => 0, 'max' => 9999, 'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'reduce']],
|
||||
['label' => '自定义商品', 'value' => 'customize'],
|
||||
], 'help' => '自定义商品时,按下方「一级会员续费」「二级会员续费」的商品选项计价'],
|
||||
['key' => 'vip_renew_discount', 'label' => '续费折扣', 'type' => 'slider', 'sanitize_type' => 'float', 'default' => 8, 'min' => 0.1, 'max' => 9.9, 'step' => 0.1, 'unit' => '折', 'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'discount'], 'help' => '在会员商品基础上打几折,如 8 = 八折'],
|
||||
['key' => 'vip_renew_reduce', 'label' => '续费减免金额', 'type' => 'spinner', 'sanitize_type' => 'money_decimal', 'default' => '20.00', 'min' => 0, 'max' => 500, 'step' => 5, 'unit' => '元', 'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'reduce'], 'help' => '在会员商品基础上优惠的金额,优惠后总金额不能为0元'],
|
||||
[
|
||||
'key' => 'vip_1_renew_product', 'label' => '一级会员续费', 'type' => 'group', 'max' => 8,
|
||||
'button_title' => '添加续费商品', 'default' => [
|
||||
['price' => '69.00', 'show_price' => '199.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
|
||||
['price' => '169.00', 'show_price' => '299.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
|
||||
],
|
||||
'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'customize'],
|
||||
'help' => '一级会员续费的商品选项',
|
||||
...self::vip_product_group_options(),
|
||||
'fields' => self::vip_product_fields(),
|
||||
],
|
||||
[
|
||||
'key' => 'vip_2_renew_product', 'label' => '二级会员续费', 'type' => 'group', 'max' => 8,
|
||||
'button_title' => '添加续费商品', 'default' => [
|
||||
['price' => '269.00', 'show_price' => '599.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
|
||||
['price' => '369.00', 'show_price' => '899.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
|
||||
],
|
||||
'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'customize'],
|
||||
'help' => '二级会员续费的商品选项',
|
||||
...self::vip_product_group_options(),
|
||||
'fields' => self::vip_product_fields(),
|
||||
],
|
||||
]],
|
||||
['key' => 'upgrade', 'title' => '会员升级', 'fields' => [
|
||||
['key' => 'vip_upgrade', 'label' => '允许升级', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'vip_upgrade_desc', 'label' => '升级说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'vip_upgrade', 'value' => true]],
|
||||
['key' => 'vip_upgrade_product', 'label' => '升级产品', 'type' => 'textarea', 'default' => "unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级", 'dependsOn' => ['key' => 'vip_upgrade', 'value' => true], 'help' => '每行一条。永久VIP1:permanent|价格|标题;剩余天:unit|每日单价|标题;跳转永久:jump|价格|标题'],
|
||||
['key' => 'vip_upgrade_desc', 'label' => '升级介绍', 'type' => 'input', 'sanitize_type' => 'html', 'default' => '升级VIP会员,享更多会员权益', 'dependsOn' => ['key' => 'vip_upgrade', 'value' => true], 'help' => '升级会员的一句话简介,支持HTML'],
|
||||
[
|
||||
'key' => 'vip_upgrade_product', 'label' => '升级价格', 'type' => 'accordion', 'default' => [
|
||||
'month_to_month_s' => true, 'unit_price' => '10.00', 'unit_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'unit_tag_class' => 'jb-yellow',
|
||||
'jump_s' => false, 'jump_price' => '199.00', 'jump_show_price' => '299.00', 'jump_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', 'jump_tag_class' => 'jb-red',
|
||||
'permanent_price' => '199.00', 'permanent_show_price' => '299.00', 'permanent_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', 'permanent_tag_class' => 'jb-red',
|
||||
],
|
||||
'dependsOn' => ['key' => 'vip_upgrade', 'value' => true],
|
||||
'help' => '会员升级的商品选项',
|
||||
'sections' => [
|
||||
['title' => '月费会员升级月费会员', 'keys' => ['_upgrade_unit_notice', 'month_to_month_s', 'unit_price', 'unit_tag', 'unit_tag_class']],
|
||||
['title' => '月费会员升级永久会员', 'keys' => ['_upgrade_jump_notice', 'jump_s', 'jump_price', 'jump_show_price', 'jump_tag', 'jump_tag_class']],
|
||||
['title' => '永久会员升级永久会员', 'keys' => ['permanent_price', 'permanent_show_price', 'permanent_tag', 'permanent_tag_class']],
|
||||
],
|
||||
'fields' => [
|
||||
['key' => '_upgrade_unit_notice', 'label' => '', 'type' => 'notice', 'ui_only' => true, 'alertType' => 'warning', 'content' => '月费会员升级为月费会员,用户会员有效期不会改变;价格按照会员剩余天数计算升级价格,请在下方设置每天的单价'],
|
||||
['key' => 'month_to_month_s', 'label' => '月费会员升级月费会员', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'unit_price', 'label' => '天单价', 'type' => 'number', 'sanitize_type' => 'money_decimal', 'default' => '10.00', 'min' => 0, 'unit' => '元', 'dependsOn' => ['key' => 'month_to_month_s', 'value' => true]],
|
||||
['key' => 'unit_tag', 'label' => '促销标签', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'rows' => 1, 'dependsOn' => ['key' => 'month_to_month_s', 'value' => true], 'help' => '支持HTML,请注意控制长度'],
|
||||
['key' => 'unit_tag_class', 'label' => '标签颜色', 'type' => 'palette', 'default' => 'jb-yellow', 'dependsOn' => ['all' => [['key' => 'month_to_month_s', 'value' => true], ['key' => 'unit_tag', 'operator' => 'not_empty']]], 'options' => self::vip_tag_palette()],
|
||||
['key' => '_upgrade_jump_notice', 'label' => '', 'type' => 'notice', 'ui_only' => true, 'alertType' => 'warning', 'content' => '如果网站可以购买月费会员和永久会员,则可以开启此项;允许用户由月费会员直接升级到更高一级的永久会员,用户之前还未到期的部分将自动一起转为永久会员,可能会涉及到差价问题,请自行告知用户;如果网站没有永久会员的购买选项请务必关闭此项'],
|
||||
['key' => 'jump_s', 'label' => '月费会员升级永久会员', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'jump_price', 'label' => '执行价', 'type' => 'number', 'sanitize_type' => 'money_decimal', 'default' => '199.00', 'min' => 0, 'unit' => '元', 'dependsOn' => ['key' => 'jump_s', 'value' => true], 'help' => '月费会员升级永久会员的价格'],
|
||||
['key' => 'jump_show_price', 'label' => '原价', 'type' => 'number', 'sanitize_type' => 'money_decimal', 'default' => '299.00', 'min' => 0, 'unit' => '元', 'dependsOn' => ['key' => 'jump_s', 'value' => true], 'help' => '显示在执行价格前面,并划掉'],
|
||||
['key' => 'jump_tag', 'label' => '促销标签', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', 'rows' => 1, 'dependsOn' => ['key' => 'jump_s', 'value' => true], 'help' => '支持HTML,请注意控制长度'],
|
||||
['key' => 'jump_tag_class', 'label' => '标签颜色', 'type' => 'palette', 'default' => 'jb-red', 'dependsOn' => ['all' => [['key' => 'jump_s', 'value' => true], ['key' => 'jump_tag', 'operator' => 'not_empty']]], 'options' => self::vip_tag_palette()],
|
||||
['key' => 'permanent_price', 'label' => '执行价', 'type' => 'number', 'sanitize_type' => 'money_decimal', 'default' => '199.00', 'min' => 0, 'unit' => '元', 'help' => '永久会员升级永久会员的价格'],
|
||||
['key' => 'permanent_show_price', 'label' => '原价', 'type' => 'number', 'sanitize_type' => 'money_decimal', 'default' => '299.00', 'min' => 0, 'unit' => '元', 'help' => '显示在执行价格前面,并划掉'],
|
||||
['key' => 'permanent_tag', 'label' => '促销标签', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', 'rows' => 1, 'help' => '支持HTML,请注意控制长度'],
|
||||
['key' => 'permanent_tag_class', 'label' => '标签颜色', 'type' => 'palette', 'default' => 'jb-red', 'dependsOn' => ['key' => 'permanent_tag', 'operator' => 'not_empty'], 'options' => self::vip_tag_palette()],
|
||||
],
|
||||
],
|
||||
]],
|
||||
]],
|
||||
]],
|
||||
['key' => 'points-exchange', 'title' => '积分兑换会员', 'fields' => [
|
||||
['key' => 'pay_vip_points_exchange_s', 'label' => '启用积分兑换', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'payment_vip_points_packages', 'label' => '兑换套餐', 'type' => 'textarea', 'default' => "1|500|30|积分月卡\n1|5000|0|积分永久", 'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true], 'help' => '每行:等级|积分|天数|标题(天数 0=永久;普通用户可开通,已有会员可续费同等级或升级更高等级)'],
|
||||
['key' => 'pay_vip_points_exchange_desc', 'label' => '兑换说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true]],
|
||||
['key' => 'pay_vip_points_exchange_s', 'label' => '启用积分兑换', 'type' => 'switch', 'default' => false, 'help' => '开启后用户可使用积分兑换会员;由于积分可免费获取,所以积分兑换会员功能不支持升级及续费'],
|
||||
[
|
||||
'key' => 'pay_vip_points_exchange_product', 'label' => '积分兑换明细', 'type' => 'group', 'max' => 16,
|
||||
'button_title' => '添加积分兑换商品', 'default' => [
|
||||
['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'day'],
|
||||
['points' => 5000, 'level' => 1, 'time' => 'Permanent'],
|
||||
],
|
||||
'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true],
|
||||
'help' => '每一行为一个可兑换的会员套餐',
|
||||
'collapsible' => true,
|
||||
'defaultCollapsed' => true,
|
||||
'listPreview' => ['prefix' => '积分:', 'titleKey' => 'points', 'descKey' => 'time', 'descUnitKey' => 'unit'],
|
||||
'fields' => [
|
||||
['key' => 'points', 'label' => '使用积分', 'type' => 'number', 'default' => 100, 'min' => 0, 'unit' => '积分'],
|
||||
['key' => 'level', 'label' => '兑换会员', 'type' => 'radio', 'default' => 1, 'inline' => true, 'options' => [
|
||||
['label' => '一级会员', 'value' => 1],
|
||||
['label' => '二级会员', 'value' => 2],
|
||||
]],
|
||||
['key' => 'time', 'label' => '兑换时长', 'type' => 'input', 'default' => '', 'help' => '填 Permanent 为永久'],
|
||||
['key' => 'unit', 'label' => '时长单位', 'type' => 'radio', 'default' => 'day', 'inline' => true, 'dependsOn' => ['key' => 'time', 'operator' => 'neq', 'value' => 'Permanent'], 'options' => [
|
||||
['label' => '天', 'value' => 'day'],
|
||||
['label' => '月', 'value' => 'month'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
['key' => 'pay_vip_points_exchange_desc', 'label' => '兑换说明', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '', 'rows' => 2, 'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true], 'help' => '用户使用积分兑换时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码'],
|
||||
]],
|
||||
['key' => 'modal-copy', 'title' => '界面文案', 'fields' => [
|
||||
['key' => 'pay_user_vip_desc', 'label' => '会员介绍', 'type' => 'input', 'sanitize_type' => 'html', 'default' => '开通VIP会员,享受会员专属折扣以及多项特权', 'dependsOn' => ['any' => [['key' => 'pay_user_vip_1_s', 'value' => true], ['key' => 'pay_user_vip_2_s', 'value' => true]]], 'help' => '显示在开通界面顶部的一句话简介,支持HTML'],
|
||||
['key' => 'pay_user_vip_more', 'label' => '开通会员更多内容', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>', 'rows' => 4, 'dependsOn' => ['any' => [['key' => 'pay_user_vip_1_s', 'value' => true], ['key' => 'pay_user_vip_2_s', 'value' => true]]]],
|
||||
]],
|
||||
],
|
||||
],
|
||||
@@ -536,6 +601,19 @@ class SchemaPayment {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员商品/续费商品的手风琴配置:默认收起,标题行显示价格与时长摘要。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function vip_product_group_options(): array {
|
||||
return [
|
||||
'collapsible' => true,
|
||||
'defaultCollapsed' => true,
|
||||
'listPreview' => ['prefix' => '价格:', 'titleKey' => 'price', 'descKey' => 'time', 'descUnitKey' => 'unit'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* PayPal / Stripe 结算货币选项(参考主题同款 26 种)。
|
||||
*
|
||||
@@ -576,4 +654,92 @@ class SchemaPayment {
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员商品/续费商品的统一字段集(参考主题 CFS_Module::vip_product 1:1)。
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private static function vip_product_fields(): array {
|
||||
return [
|
||||
['key' => 'price', 'label' => '执行价', 'type' => 'number', 'sanitize_type' => 'money_decimal', 'default' => '699.00', 'min' => 0, 'suffix' => '元', 'help' => '会员的实际购买价格'],
|
||||
['key' => 'show_price', 'label' => '原价', 'type' => 'number', 'sanitize_type' => 'money_decimal', 'default' => '999.00', 'min' => 0, 'suffix' => '元', 'help' => '显示在执行价格前面,并划掉'],
|
||||
['key' => 'tag', 'label' => '促销标签', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '', 'rows' => 1, 'help' => '支持HTML,请注意控制长度'],
|
||||
['key' => 'tag_class', 'label' => '标签颜色', 'type' => 'palette', 'default' => 'jb-yellow', 'dependsOn' => ['key' => 'tag', 'operator' => 'not_empty'], 'options' => self::vip_tag_palette()],
|
||||
['key' => 'time', 'label' => '会员有效时间', 'type' => 'spinner', 'default' => 3, 'min' => 0, 'max' => 3600, 'step' => 1, 'help' => '开通会员的时长。填0则为永久会员'],
|
||||
['key' => 'unit', 'label' => '时长单位', 'type' => 'radio', 'default' => 'month', 'inline' => true, 'dependsOn' => ['key' => 'time', 'operator' => 'truthy'], 'options' => [
|
||||
['label' => '天', 'value' => 'day'],
|
||||
['label' => '月', 'value' => 'month'],
|
||||
]],
|
||||
['key' => '_time_permanent_hint', 'label' => '', 'type' => 'alert', 'alertType' => 'success', 'ui_only' => true, 'dependsOn' => ['key' => 'time', 'operator' => 'eq', 'value' => 0], 'content' => '会员有效时间已设置为:永久会员'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员等级 Tab 的字段集(参考主题 CFS_Module::vip_tab 1:1,显示名称并入 Tab)。
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private static function vip_level_tab_fields(int $level): array {
|
||||
$dep = ['key' => 'pay_user_vip_' . $level . '_s', 'value' => true];
|
||||
$equity = '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>';
|
||||
return [
|
||||
['key' => 'pay_user_vip_' . $level . '_name', 'label' => '会员显示名称', 'type' => 'input', 'sanitize_type' => 'html', 'default' => $level === 1 ? '黄金会员' : '钻石会员', 'dependsOn' => $dep, 'help' => '会员名称,支持HTML'],
|
||||
['key' => 'pay_user_vip_' . $level . '_desc', 'label' => '一句话简介', 'type' => 'input', 'sanitize_type' => 'html', 'default' => '尊享专属特权', 'dependsOn' => $dep, 'help' => '支持HTML'],
|
||||
['key' => 'pay_user_vip_' . $level . '_equity', 'label' => '会员权益 HTML', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => $equity, 'rows' => 4, 'dependsOn' => $dep, 'help' => '支持安全HTML,建议使用 <li> 列表项'],
|
||||
['key' => 'vip_' . $level . '_product_s', 'label' => '购买会员', 'type' => 'switch', 'default' => true, 'dependsOn' => $dep, 'help' => '关闭后则不能付费购买会员,但可通过积分兑换会员,请确保已开启积分以及积分兑换会员功能'],
|
||||
[
|
||||
'key' => 'vip_' . $level . '_product', 'label' => '会员商品', 'type' => 'group', 'max' => 8,
|
||||
'button_title' => '添加会员商品', 'default' => self::vip_product_defaults(),
|
||||
...self::vip_product_group_options(),
|
||||
'dependsOn' => ['all' => [$dep, ['key' => 'vip_' . $level . '_product_s', 'value' => true]]],
|
||||
'help' => '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
|
||||
'fields' => self::vip_product_fields(),
|
||||
],
|
||||
['key' => 'vip_' . $level . '_img_icon', 'label' => '会员图标', 'type' => 'upload', 'default' => '', 'dependsOn' => $dep, 'help' => '留空使用内置 vip-' . $level . '.svg 图标;建议尺寸300x300'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员商品默认两行(参考主题 vip_tab 的 group 默认值)。
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private static function vip_product_defaults(): array {
|
||||
return [
|
||||
['price' => '99.00', 'show_price' => '199.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
|
||||
['price' => '199.00', 'show_price' => '299.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签颜色调色板(参考主题 zib_palette(array(), array('b','jb')))。
|
||||
*
|
||||
* @return array<int,array<string,string>>
|
||||
*/
|
||||
private static function vip_tag_palette(): array {
|
||||
$colors = [
|
||||
'b-red' => '#f74b3d',
|
||||
'b-yellow' => '#f3920a',
|
||||
'b-cyan' => '#08c4c1',
|
||||
'b-blue' => '#0a8cf3',
|
||||
'b-green' => '#1fd05a',
|
||||
'b-purple' => '#c133f5',
|
||||
'b-black' => '#121517',
|
||||
'jb-red' => 'linear-gradient(135deg, #ffbeb4 10%, #f61a1a 100%)',
|
||||
'jb-pink' => 'linear-gradient(135deg, #ff5e7f 30%, #ff967e 100%)',
|
||||
'jb-yellow' => 'linear-gradient(135deg, #ffd6b2 10%, #ff651c 100%)',
|
||||
'jb-cyan' => 'linear-gradient(140deg, #039ab3 10%, #58dbcf 90%)',
|
||||
'jb-blue' => 'linear-gradient(135deg, #b6e6ff 10%, #198aff 100%)',
|
||||
'jb-green' => 'linear-gradient(135deg, #ccffcd 10%, #52bb51 100%)',
|
||||
'jb-purple' => 'linear-gradient(135deg, #fec2ff 10%, #d000de 100%)',
|
||||
'jb-vip1' => 'linear-gradient(25deg, #eab869 10%, #fbecd4 60%, #ffe0ae 100%)',
|
||||
'jb-vip2' => 'linear-gradient(317deg, #4d4c4c 30%, #878787 70%, #5f5c5c 100%)',
|
||||
];
|
||||
$options = [];
|
||||
foreach ($colors as $value => $css) {
|
||||
$options[] = ['label' => $css, 'value' => $value];
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -866,6 +866,47 @@ class SchemaProvider {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a submitted enum value to the value declared by its option.
|
||||
*
|
||||
* Payloads reach the sanitizer as strings (or numbers), so a strict match
|
||||
* against the schema would reject a valid choice whenever the option value
|
||||
* is not itself a string -- e.g. 积分兑换明细.level declares 1 / 2 while the
|
||||
* SPA submits them as numbers and sanitize_text_field() then turns them into
|
||||
* "1" / "2". Returning the declared value keeps the stored type aligned with
|
||||
* the schema, which the radio/select controls compare with === when they
|
||||
* re-render a saved setting.
|
||||
*
|
||||
* Returns null when nothing matches, so validation still reports the
|
||||
* invalid value instead of silently rewriting the operator's input.
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param array<int,mixed> $options
|
||||
* @return mixed
|
||||
*/
|
||||
private function match_option_value($value, array $options) {
|
||||
foreach ($options as $opt) {
|
||||
$opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt;
|
||||
if ($opt_value === $value) {
|
||||
return $opt_value;
|
||||
}
|
||||
}
|
||||
if (is_bool($value) || !is_scalar($value)) {
|
||||
return null;
|
||||
}
|
||||
$needle = (string) $value;
|
||||
foreach ($options as $opt) {
|
||||
$opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt;
|
||||
if (is_bool($opt_value) || !is_scalar($opt_value)) {
|
||||
continue;
|
||||
}
|
||||
if ((string) $opt_value === $needle) {
|
||||
return $opt_value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function sanitize_field_value($value, array $field) {
|
||||
if (!empty($field['captcha_provider'])) {
|
||||
return function_exists('aksrt_captcha_sanitize_provider_config')
|
||||
@@ -898,6 +939,15 @@ class SchemaProvider {
|
||||
return $normalized !== null ? $normalized : (string) ($field['default'] ?? '');
|
||||
}
|
||||
|
||||
// Enum fields keep the option's declared value so the stored type always
|
||||
// matches what validate() compares against with in_array(..., true).
|
||||
if (in_array($type, ['select', 'radio', 'palette', 'image_select'], true) && empty($field['multiple']) && !empty($field['options'])) {
|
||||
$matched = $this->match_option_value($value, (array) $field['options']);
|
||||
if ($matched !== null) {
|
||||
return $matched;
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($type, ['fieldset', 'accordion'], true)) {
|
||||
return $this->sanitize_object_fields($value, (array) ($field['fields'] ?? []));
|
||||
}
|
||||
@@ -994,6 +1044,12 @@ class SchemaProvider {
|
||||
if (!is_array($field) || empty($field['key'])) {
|
||||
continue;
|
||||
}
|
||||
// UI-only chrome (notice / alert rows) is presentation only and must
|
||||
// never become a persisted settings key, matching the top-level
|
||||
// behaviour of collect_persisted_fields().
|
||||
if (!empty($field['ui_only']) || ($field['type'] ?? '') === 'notice') {
|
||||
continue;
|
||||
}
|
||||
$key = (string) $field['key'];
|
||||
if (array_key_exists($key, $value)) {
|
||||
$clean[$key] = $this->sanitize_field_value($value[$key], $field);
|
||||
|
||||
+41
-19
@@ -219,35 +219,57 @@ final class OAuth {
|
||||
'name' => $provider,
|
||||
'label' => OAuthProviderCatalog::label($provider),
|
||||
'icon' => function_exists('aksrt_brand_icon') ? aksrt_brand_icon(OAuthProviderCatalog::icon_key($provider)) : '',
|
||||
'url' => esc_url($url),
|
||||
// Raw, matching get_frontend_bindings_data(): every surface escapes
|
||||
// once on output. esc_url() is idempotent on its own output, so
|
||||
// escaping here too was redundant rather than broken.
|
||||
'url' => $url,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference-parity social login block (legacy zib_social_login()).
|
||||
*
|
||||
* Emits the muted separator plus a .social_loginbar of .social-login-item
|
||||
* buttons, one per enabled provider. Returns '' when nothing is enabled.
|
||||
*
|
||||
* @param string $title Separator label; an empty title drops the separator.
|
||||
*/
|
||||
public function render_social_buttons(string $title = ''): string {
|
||||
$items = $this->get_auth_providers_data();
|
||||
if ($items === []) {
|
||||
return '';
|
||||
}
|
||||
ob_start();
|
||||
?>
|
||||
<div class="social-login-divider">
|
||||
<span><?php echo esc_html($title) ?: esc_html__('or', 'aksrt'); ?></span>
|
||||
</div>
|
||||
<div class="oauth-row">
|
||||
<?php foreach ($items as $item) : ?>
|
||||
<a href="<?php echo esc_url((string) $item['url']); ?>"
|
||||
class="oauth-btn oauth-btn--<?php echo esc_attr((string) $item['name']); ?>"
|
||||
data-provider="<?php echo esc_attr((string) $item['name']); ?>"
|
||||
aria-label="<?php echo esc_attr((string) $item['label']); ?>"
|
||||
title="<?php echo esc_attr((string) $item['label']); ?>">
|
||||
<?php echo $item['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
return (string) ob_get_clean();
|
||||
|
||||
$large = (bool) \AKSRT\Settings\Settings::get_value('oauth_button_lg', false);
|
||||
$html = $title !== ''
|
||||
? '<p class="social-separator separator muted-3-color em09 mt10">' . esc_html($title) . '</p>'
|
||||
: '';
|
||||
$html .= '<div class="social_loginbar">';
|
||||
foreach ($items as $item) {
|
||||
$name = sanitize_html_class((string) ($item['name'] ?? ''));
|
||||
$type = str_starts_with($name, 'clogin_') ? substr($name, 7) : $name;
|
||||
$label = trim((string) ($item['label'] ?? ''));
|
||||
$url = (string) ($item['url'] ?? '');
|
||||
if ($name === '' || $type === '' || $label === '' || $url === '') {
|
||||
continue;
|
||||
}
|
||||
$class = 'social-login-item ' . $type . ($large ? ' button-lg' : ' toggle-radius');
|
||||
if ($name === 'weixingzh') {
|
||||
// Reference parity: the WeChat entry opens the QR panel instead of
|
||||
// navigating, so auth.js routes it to the modal's oauth tab.
|
||||
$class .= ' qrcode-signin';
|
||||
}
|
||||
$html .= '<a rel="nofollow" href="' . esc_url($url) . '" class="' . esc_attr($class) . '"'
|
||||
. ' data-provider="' . esc_attr($name) . '"'
|
||||
. ' aria-label="' . esc_attr(sprintf(__('%s登录', 'aksrt'), $label)) . '"'
|
||||
. ' title="' . esc_attr(sprintf(__('%s登录', 'aksrt'), $label)) . '">'
|
||||
. $item['icon'] // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
. ($large ? '<span>' . esc_html(sprintf(__('%s登录', 'aksrt'), $label)) . '</span>' : '')
|
||||
. '</a>';
|
||||
}
|
||||
return $html . '</div>';
|
||||
}
|
||||
|
||||
public function get_frontend_bindings_data(int $user_id): array {
|
||||
|
||||
@@ -38,8 +38,9 @@ trait VipServiceModal {
|
||||
: wp_kses_post($equity);
|
||||
$level_view[] = [
|
||||
'level' => $level,
|
||||
'name' => (string) ($levels[$level]['name'] ?? ('VIP' . $level)),
|
||||
'desc' => (string) ($levels[$level]['desc'] ?? ''),
|
||||
'name' => wp_kses_post((string) ($levels[$level]['name_html'] ?? $levels[$level]['name'] ?? ('VIP' . $level))),
|
||||
'name_text' => (string) ($levels[$level]['name'] ?? ('VIP' . $level)),
|
||||
'desc' => wp_kses_post((string) ($levels[$level]['desc'] ?? '')),
|
||||
'equity' => $equity,
|
||||
'img_icon' => esc_url_raw((string) ($levels[$level]['img_icon'] ?? '')),
|
||||
];
|
||||
@@ -115,7 +116,6 @@ trait VipServiceModal {
|
||||
'prefer_level' => $prefer_level,
|
||||
'catalog' => [
|
||||
'desc' => (string) ($catalog['desc'] ?? ''),
|
||||
'help_text' => (string) ($catalog['help_text'] ?? ''),
|
||||
'more' => (string) ($vip['more'] ?? ''),
|
||||
'currency_name' => (string) ($catalog['currency_name'] ?? ''),
|
||||
'level_packages' => $groups,
|
||||
@@ -215,15 +215,17 @@ trait VipServiceModal {
|
||||
}
|
||||
$level = max(1, min(2, (int) ($package['level'] ?? 1)));
|
||||
$points = max(0, (int) ($package['points'] ?? 0));
|
||||
$days = max(0, (int) ($package['days'] ?? 0));
|
||||
// 参考主题时长行:30<span class="px12">天</span> / 永久。
|
||||
$duration = $days > 0
|
||||
? (string) $days . '<span class="px12">' . esc_html__('天', 'aksrt') . '</span>'
|
||||
: esc_html__('永久', 'aksrt');
|
||||
$level_name = (string) ($levels[$level]['name'] ?? ('VIP' . $level));
|
||||
// 参考主题时长行:30<span class="px12">天</span> / 永久;time 为 Permanent 时按永久渲染。
|
||||
$is_permanent = !empty($package['is_permanent']);
|
||||
$pkg_time = max(0, (int) ($package['time'] ?? 0));
|
||||
$pkg_unit = ((string) ($package['unit'] ?? 'day')) === 'month' ? 'month' : 'day';
|
||||
$duration = ($is_permanent || $pkg_time <= 0)
|
||||
? esc_html__('永久', 'aksrt')
|
||||
: (string) $pkg_time . '<span class="px12">' . esc_html($pkg_unit === 'month' ? __('个月', 'aksrt') : __('天', 'aksrt')) . '</span>';
|
||||
$level_name = (string) ($levels[$level]['name_html'] ?? $levels[$level]['name'] ?? ('VIP' . $level));
|
||||
|
||||
$cards .= '<div class="vip-card vip-theme' . $level . ($index === 0 ? ' active' : '') . ' " data-for="product_id" data-value="' . $package_id . '">'
|
||||
. '<span class=" ">' . $this->points_exchange_card_icon($levels[$level] ?? []) . esc_html($level_name) . '</span>'
|
||||
. '<span class=" ">' . $this->points_exchange_card_icon($levels[$level] ?? []) . wp_kses_post($level_name) . '</span>'
|
||||
. '<div class="flex jsb ac hh em16 mt10">'
|
||||
. '<span class="shrink0 mr10">' . $duration . '</span>'
|
||||
. '<span class="shrink0">' . $points . '<span class="px12">' . esc_html($currency) . '</span></span>'
|
||||
@@ -237,16 +239,18 @@ trait VipServiceModal {
|
||||
return '';
|
||||
}
|
||||
|
||||
$desc_text = trim((string) ($points_ex['desc'] ?? ''));
|
||||
$desc_text = trim((string) ($vip['points_exchange']['desc'] ?? ''));
|
||||
$desc = $desc_text !== ''
|
||||
? '<div class="muted-box muted-2-color padding-10 em09 mt20">' . esc_html($desc_text) . '</div>'
|
||||
// 管理员编辑的富文本说明:保存端已 wp_kses_post,这里同级别输出
|
||||
// (参考主题 zibpay_get_vip_points_exchange_modal 原样拼接)。
|
||||
? '<div class="muted-box muted-2-color padding-10 em09 mt20 aksrt-vip-points-desc">' . wp_kses_post($desc_text) . '</div>'
|
||||
: '';
|
||||
|
||||
// 最低价套餐仍超出用户积分 → 参考主题的 badg c-red btn-block 提示。
|
||||
// (参考主题源码写作 `!$min_val > $user_points`,因运算符优先级恒假,
|
||||
// 此处按其原意取真实比较,保证提示分支可达。)
|
||||
if ($min_points > $user_points) {
|
||||
$form = '<div class="badg c-red btn-block">' . esc_html__('积分不足,暂时无法兑换', 'aksrt') . '</div>';
|
||||
$form = '<div class="mt20"><div class="badg c-red btn-block">' . esc_html__('积分不足,暂时无法兑换', 'aksrt') . '</div></div>';
|
||||
} else {
|
||||
$form = '<div class="mt20">'
|
||||
. '<input type="hidden" name="product_id" value="' . $first_package_id . '">'
|
||||
@@ -289,14 +293,14 @@ trait VipServiceModal {
|
||||
* @param array<string,mixed> $level_settings 等级设置(含 name / img_icon)。
|
||||
*/
|
||||
private function points_exchange_card_icon(array $level_settings): string {
|
||||
$name = (string) ($level_settings['name'] ?? '');
|
||||
$name_text = (string) ($level_settings['name'] ?? '');
|
||||
$icon_url = (string) ($level_settings['img_icon'] ?? '');
|
||||
if ($icon_url === '') {
|
||||
return '<i class="fa fas fa-certificate mr3" aria-hidden="true"></i>';
|
||||
}
|
||||
|
||||
return '<img' . aksrt_get_lazy_attr('lazy_other', $icon_url, 'img-icon mr3')
|
||||
. ' title="' . esc_attr($name) . '" alt="' . esc_attr($name) . '">';
|
||||
. ' title="' . esc_attr($name_text) . '" alt="' . esc_attr($name_text) . '">';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,26 +331,27 @@ trait VipServiceModal {
|
||||
if (empty($levels[$level]['enabled'])) {
|
||||
return '';
|
||||
}
|
||||
$name = (string) ($levels[$level]['name'] ?? ('VIP' . $level));
|
||||
$name = wp_kses_post((string) ($levels[$level]['name_html'] ?? $levels[$level]['name'] ?? ('VIP' . $level)));
|
||||
$name_text = (string) ($levels[$level]['name'] ?? ('VIP' . $level));
|
||||
$equity = (string) ($levels[$level]['equity'] ?? '');
|
||||
$cls = $user_id > 0 ? ' pay-vip' : ' signin-loader';
|
||||
|
||||
$equity_html = $equity !== ''
|
||||
? '<ul class="mb10 relative">' . wp_kses_post($equity) . '</ul>'
|
||||
: '';
|
||||
$equity_html = $equity !== '' && function_exists('aksrt_format_vip_equity_html')
|
||||
? aksrt_format_vip_equity_html($equity, 'mb10 relative')
|
||||
: ($equity !== '' ? '<div class="v-equity mb10 relative">' . wp_kses_post($equity) . '</div>' : '');
|
||||
|
||||
$icon_url = esc_url((string) ($levels[$level]['img_icon'] ?? ''));
|
||||
$icon = $icon_url !== ''
|
||||
? '<img' . aksrt_get_lazy_attr('lazy_other', $icon_url, 'img-icon') . ' title="' . esc_attr($name) . '" alt="' . esc_attr($name) . '" decoding="async">'
|
||||
? '<img' . aksrt_get_lazy_attr('lazy_other', $icon_url, 'img-icon') . ' title="' . esc_attr($name_text) . '" alt="' . esc_attr($name_text) . '" decoding="async">'
|
||||
: '<i class="fa fas fa-gem" aria-hidden="true"></i>';
|
||||
$html = '<div class="vip-card pointer level-' . $level . ' vip-theme' . $level . $cls . '" vip-level="' . $level . '">';
|
||||
$html .= '<div class="abs-center vip-baicon" aria-hidden="true">' . $icon . '</div>';
|
||||
$html .= '<div class="vip-img abs-right">' . $icon . '</div>';
|
||||
$html .= '<div class="relative">';
|
||||
$html .= '<div class="vip-name mb10"><span class="mr6">' . $icon . '</span>' . esc_html($name) . '</div>';
|
||||
$html .= '<div class="vip-name mb10"><span class="mr6">' . $icon . '</span>' . wp_kses_post($name) . '</div>';
|
||||
$html .= $equity_html;
|
||||
$html .= '<a class="but jb-blue radius payvip-button" href="javascript:;">'
|
||||
. esc_html(sprintf(__('开通%s', 'aksrt'), $name))
|
||||
. wp_kses_post(sprintf(__('开通%s', 'aksrt'), $name))
|
||||
. '<span class="ml6"><i class="fa fas fa-chevron-circle-right" aria-hidden="true"></i></span></a>';
|
||||
$html .= '</div></div>';
|
||||
return $html;
|
||||
|
||||
+120
-314
@@ -11,82 +11,6 @@ trait VipServicePackages {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Parse per-level product lines (legacy vip_product).
|
||||
*
|
||||
* Preferred: price|show_price|time|unit|tag|title
|
||||
* Legacy: price|days|title
|
||||
*
|
||||
* @return array<int, array{price:string,show_price:string,time:int,unit:string,tag:string,tag_class:string,title:string,days:int}>
|
||||
*/
|
||||
public function parse_level_products(string $raw): array {
|
||||
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
|
||||
$out = [];
|
||||
foreach ($lines as $line) {
|
||||
$line = trim((string) $line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
$parts = array_map('trim', explode('|', $line));
|
||||
$price_minor = $this->parse_vip_money_minor($parts[0] ?? '');
|
||||
if ($price_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$n = count($parts);
|
||||
// New format: price|show_price|time|unit|tag|title (6+)
|
||||
// Or: price|show_price|time|unit|title (5)
|
||||
if ($n >= 4 && (in_array(strtolower((string) ($parts[3] ?? '')), ['day', 'month', 'd', 'm'], true)
|
||||
|| (isset($parts[3]) && $parts[3] !== '' && !is_numeric($parts[3])))) {
|
||||
$show_minor = $this->parse_vip_money_minor($parts[1] ?? '0');
|
||||
$time = isset($parts[2]) ? (int) $parts[2] : 0;
|
||||
$unit_raw = strtolower((string) ($parts[3] ?? 'month'));
|
||||
$unit = ($unit_raw === 'day' || $unit_raw === 'd') ? 'day' : 'month';
|
||||
$tag = '';
|
||||
$title = '';
|
||||
if ($n >= 6) {
|
||||
$tag = sanitize_text_field((string) ($parts[4] ?? ''));
|
||||
$title = sanitize_text_field((string) ($parts[5] ?? ''));
|
||||
} elseif ($n === 5) {
|
||||
// price|show|time|unit|title
|
||||
$title = sanitize_text_field((string) ($parts[4] ?? ''));
|
||||
}
|
||||
} else {
|
||||
// Legacy: price|days|title
|
||||
$show_minor = 0;
|
||||
$days_legacy = isset($parts[1]) ? (int) $parts[1] : 0;
|
||||
$time = $days_legacy;
|
||||
$unit = 'day';
|
||||
$tag = '';
|
||||
$title = isset($parts[2]) ? sanitize_text_field($parts[2]) : '';
|
||||
}
|
||||
|
||||
$time = max(0, $time);
|
||||
$days = $this->duration_to_days($time, $unit);
|
||||
if ($title === '') {
|
||||
if ($time <= 0) {
|
||||
$title = __('永久会员', 'aksrt');
|
||||
} elseif ($unit === 'day') {
|
||||
$title = sprintf(__('%d天会员', 'aksrt'), $time);
|
||||
} else {
|
||||
$title = sprintf(__('%d个月会员', 'aksrt'), $time);
|
||||
}
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor),
|
||||
'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor),
|
||||
'time' => $time,
|
||||
'unit' => $unit,
|
||||
'tag' => $tag,
|
||||
'tag_class' => '',
|
||||
'title' => $title,
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate days for catalog labels / order meta; activate prefers time+unit calendar math.
|
||||
*/
|
||||
@@ -100,118 +24,59 @@ trait VipServicePackages {
|
||||
return $time;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use parse_level_products — kept as thin adapter for upgrade fixed lines.
|
||||
*
|
||||
* @return array<int, array{price:string,days:int,title:string}>
|
||||
*/
|
||||
public function parse_packages(string $raw): array {
|
||||
$parsed = $this->parse_level_products($raw);
|
||||
$out = [];
|
||||
foreach ($parsed as $p) {
|
||||
$out[] = [
|
||||
'price' => (string) $p['price'],
|
||||
'days' => (int) $p['days'],
|
||||
'title' => (string) $p['title'],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Build upgrade package items (legacy vip_upgrade_product parity).
|
||||
*
|
||||
* upgrade.product multi-line formats:
|
||||
* - permanent VIP1: permanent|price|title[|show_price]
|
||||
* - fixed (alias): price|days|title
|
||||
* - timed unit: unit|daily_price|title
|
||||
* - timed jump permanent: jump|price|title[|show_price]
|
||||
*
|
||||
* Rules:
|
||||
* - permanent user: only permanent/fixed (days=0 preferred)
|
||||
* - timed user: unit (keep expiry) + optional jump (to permanent)
|
||||
*
|
||||
* package index: 901 permanent/fixed, 902 unit, 903 jump
|
||||
*
|
||||
* @return array<int, array<string,mixed>>
|
||||
*/
|
||||
private function build_upgrade_items(int $user_id, int $expires, bool $is_permanent, string $raw): array {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
* Three blocks (reference theme vip_upgrade_product accordion):
|
||||
* - month_to_month_s: timed VIP1 → VIP2, priced by remaining days, expiry unchanged
|
||||
* - jump_s: timed VIP1 → permanent VIP2 at a fixed price
|
||||
* - permanent_*: permanent VIP1 → permanent VIP2 at a fixed price
|
||||
*
|
||||
* package index: 901 permanent, 902 unit, 903 jump
|
||||
*
|
||||
* @param array<string,mixed> $config
|
||||
* @return array<int, array<string,mixed>>
|
||||
*/
|
||||
private function build_upgrade_items(int $user_id, int $expires, bool $is_permanent, array $config): array {
|
||||
$items = [];
|
||||
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
|
||||
$has_permanent = false;
|
||||
$has_unit = false;
|
||||
$has_jump = false;
|
||||
$now = time();
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim((string) $line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
// 月费会员升级月费会员:按剩余天数计价,会员有效期不变(仅提升等级)。
|
||||
if (!$is_permanent && $expires > $now && !empty($config['month_to_month_s'])) {
|
||||
$daily_minor = $this->parse_vip_money_minor((string) ($config['unit_price'] ?? ''));
|
||||
if ($daily_minor > 0) {
|
||||
$remaining = (int) ceil(max(1, ($expires - $now) / DAY_IN_SECONDS));
|
||||
if ($daily_minor <= intdiv(PHP_INT_MAX, $remaining)) {
|
||||
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($remaining * $daily_minor);
|
||||
$items[] = [
|
||||
'package_id' => $this->encode_package_id(2, 902),
|
||||
'level' => 2,
|
||||
'index' => 902,
|
||||
'action' => 'upgrade',
|
||||
'price' => $price,
|
||||
'base_price' => $price,
|
||||
'show_price' => '0.00',
|
||||
'discount_tag' => (string) ($config['unit_tag'] ?? ''),
|
||||
'tag_class' => (string) ($config['unit_tag_class'] ?? ''),
|
||||
'days' => $remaining,
|
||||
'title' => sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining),
|
||||
'price_label' => $this->format_cash_label($price),
|
||||
'show_price_label' => '',
|
||||
'days_label' => sprintf(__('%d 天', 'aksrt'), $remaining),
|
||||
'cta_label' => __('立即升级', 'aksrt'),
|
||||
'is_upgrade_product' => true,
|
||||
'upgrade_mode' => 'unit',
|
||||
];
|
||||
}
|
||||
}
|
||||
$parts = array_map('trim', explode('|', $line));
|
||||
$mode = strtolower((string) ($parts[0] ?? ''));
|
||||
}
|
||||
|
||||
// unit|daily|title — timed VIP1 only.
|
||||
if ($mode === 'unit') {
|
||||
if ($is_permanent || $expires <= time() || $has_unit) {
|
||||
continue;
|
||||
}
|
||||
$daily_minor = $this->parse_vip_money_minor($parts[1] ?? '');
|
||||
if ($daily_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
$remaining = (int) ceil(max(1, ($expires - time()) / DAY_IN_SECONDS));
|
||||
if ($daily_minor > intdiv(PHP_INT_MAX, $remaining)) {
|
||||
continue;
|
||||
}
|
||||
$price_minor = $remaining * $daily_minor;
|
||||
// 月费会员升级永久会员:固定价格直接转为永久二级会员。
|
||||
if (!$is_permanent && !empty($config['jump_s'])) {
|
||||
$price_minor = $this->parse_vip_money_minor((string) ($config['jump_price'] ?? ''));
|
||||
if ($price_minor > 0) {
|
||||
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
|
||||
$title = sanitize_text_field((string) ($parts[2] ?? ''));
|
||||
if ($title === '') {
|
||||
$title = sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining);
|
||||
}
|
||||
$items[] = [
|
||||
'package_id' => $this->encode_package_id(2, 902),
|
||||
'level' => 2,
|
||||
'index' => 902,
|
||||
'action' => 'upgrade',
|
||||
'price' => $price,
|
||||
'base_price' => $price,
|
||||
'show_price' => '0.00',
|
||||
'discount_tag' => __('剩余天数', 'aksrt'),
|
||||
'days' => $remaining,
|
||||
'title' => $title,
|
||||
'price_label' => $this->format_cash_label($price),
|
||||
'show_price_label' => '',
|
||||
'days_label' => sprintf(__('%d 天', 'aksrt'), $remaining),
|
||||
'cta_label' => __('立即升级', 'aksrt'),
|
||||
'is_upgrade_product' => true,
|
||||
'upgrade_mode' => 'unit',
|
||||
];
|
||||
$has_unit = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// jump|price|title[|show_price] — timed VIP1 only → permanent VIP2.
|
||||
if ($mode === 'jump') {
|
||||
if ($is_permanent || $has_jump) {
|
||||
continue;
|
||||
}
|
||||
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
|
||||
if ($price_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
|
||||
$title = sanitize_text_field((string) ($parts[2] ?? ''));
|
||||
if ($title === '') {
|
||||
$title = __('升级为永久会员', 'aksrt');
|
||||
}
|
||||
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
|
||||
$show_minor = $this->parse_vip_money_minor((string) ($config['jump_show_price'] ?? ''));
|
||||
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
|
||||
$items[] = [
|
||||
'package_id' => $this->encode_package_id(2, 903),
|
||||
@@ -221,9 +86,10 @@ trait VipServicePackages {
|
||||
'price' => $price,
|
||||
'base_price' => $price,
|
||||
'show_price' => $show,
|
||||
'discount_tag' => __('永久升级', 'aksrt'),
|
||||
'discount_tag' => (string) ($config['jump_tag'] ?? ''),
|
||||
'tag_class' => (string) ($config['jump_tag_class'] ?? ''),
|
||||
'days' => 0,
|
||||
'title' => $title,
|
||||
'title' => __('升级为永久会员', 'aksrt'),
|
||||
'price_label' => $this->format_cash_label($price),
|
||||
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
|
||||
'days_label' => __('永久', 'aksrt'),
|
||||
@@ -231,25 +97,15 @@ trait VipServicePackages {
|
||||
'is_upgrade_product' => true,
|
||||
'upgrade_mode' => 'jump',
|
||||
];
|
||||
$has_jump = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// permanent|price|title[|show_price]
|
||||
if ($mode === 'permanent') {
|
||||
if ($has_permanent) {
|
||||
continue;
|
||||
}
|
||||
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
|
||||
if ($price_minor <= 0) {
|
||||
continue;
|
||||
}
|
||||
// 永久会员升级永久会员。
|
||||
if ($is_permanent) {
|
||||
$price_minor = $this->parse_vip_money_minor((string) ($config['permanent_price'] ?? ''));
|
||||
if ($price_minor > 0) {
|
||||
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
|
||||
$title = sanitize_text_field((string) ($parts[2] ?? ''));
|
||||
if ($title === '') {
|
||||
$title = __('永久会员升级', 'aksrt');
|
||||
}
|
||||
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
|
||||
$show_minor = $this->parse_vip_money_minor((string) ($config['permanent_show_price'] ?? ''));
|
||||
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
|
||||
$items[] = [
|
||||
'package_id' => $this->encode_package_id(2, 901),
|
||||
@@ -259,9 +115,10 @@ trait VipServicePackages {
|
||||
'price' => $price,
|
||||
'base_price' => $price,
|
||||
'show_price' => $show,
|
||||
'discount_tag' => __('升级专享', 'aksrt'),
|
||||
'discount_tag' => (string) ($config['permanent_tag'] ?? ''),
|
||||
'tag_class' => (string) ($config['permanent_tag_class'] ?? ''),
|
||||
'days' => 0,
|
||||
'title' => $title,
|
||||
'title' => __('永久会员升级', 'aksrt'),
|
||||
'price_label' => $this->format_cash_label($price),
|
||||
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
|
||||
'days_label' => __('永久', 'aksrt'),
|
||||
@@ -269,49 +126,7 @@ trait VipServicePackages {
|
||||
'is_upgrade_product' => true,
|
||||
'upgrade_mode' => 'permanent',
|
||||
];
|
||||
$has_permanent = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// fixed: price|days|title
|
||||
$parsed = $this->parse_packages($line);
|
||||
if (empty($parsed[0]) || $has_permanent) {
|
||||
continue;
|
||||
}
|
||||
// For permanent VIP1, only accept days=0 fixed lines as permanent upgrade.
|
||||
if ($is_permanent && (int) $parsed[0]['days'] > 0) {
|
||||
continue;
|
||||
}
|
||||
$pkg = $parsed[0];
|
||||
$items[] = [
|
||||
'package_id' => $this->encode_package_id(2, 901),
|
||||
'level' => 2,
|
||||
'index' => 901,
|
||||
'action' => 'upgrade',
|
||||
'price' => (string) $pkg['price'],
|
||||
'base_price' => (string) $pkg['price'],
|
||||
'show_price' => '0.00',
|
||||
'discount_tag' => __('升级专享', 'aksrt'),
|
||||
'days' => (int) $pkg['days'],
|
||||
'title' => (string) $pkg['title'],
|
||||
'price_label' => $this->format_cash_label((string) $pkg['price']),
|
||||
'show_price_label' => '',
|
||||
'days_label' => ((int) $pkg['days'] <= 0)
|
||||
? __('永久', 'aksrt')
|
||||
: sprintf(__('%d 天', 'aksrt'), (int) $pkg['days']),
|
||||
'cta_label' => __('立即升级', 'aksrt'),
|
||||
'is_upgrade_product' => true,
|
||||
'upgrade_mode' => ((int) $pkg['days'] <= 0) ? 'permanent' : 'fixed',
|
||||
];
|
||||
$has_permanent = true;
|
||||
}
|
||||
|
||||
// Permanent VIP1 should not see unit/jump.
|
||||
if ($is_permanent) {
|
||||
$items = array_values(array_filter($items, static function ($it) {
|
||||
$m = (string) ($it['upgrade_mode'] ?? '');
|
||||
return in_array($m, ['permanent', 'fixed'], true);
|
||||
}));
|
||||
}
|
||||
|
||||
return $items;
|
||||
@@ -320,7 +135,10 @@ trait VipServicePackages {
|
||||
|
||||
|
||||
/**
|
||||
* Points exchange packages: level|points|days|title
|
||||
* Points exchange packages from the structured 积分兑换明细 group.
|
||||
*
|
||||
* Each row is points / level / time / unit, where time is the literal
|
||||
* string "Permanent" for a permanent grant (reference theme contract).
|
||||
*
|
||||
* @param array<string,mixed> $vip
|
||||
* @param array<int,mixed> $levels
|
||||
@@ -328,47 +146,49 @@ trait VipServicePackages {
|
||||
*/
|
||||
public function get_points_exchange_packages(array $vip, array $levels): array {
|
||||
$points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : [];
|
||||
$raw = (string) ($points_ex['packages_raw'] ?? '');
|
||||
$rows = isset($points_ex['packages']) && is_array($points_ex['packages']) ? $points_ex['packages'] : [];
|
||||
$currency = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
|
||||
$out = [];
|
||||
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
|
||||
$i = 0;
|
||||
foreach ($lines as $line) {
|
||||
$line = trim((string) $line);
|
||||
if ($line === '') {
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$parts = array_map('trim', explode('|', $line));
|
||||
if (count($parts) < 3) {
|
||||
continue;
|
||||
}
|
||||
$lv = (int) ($parts[0] ?? 0);
|
||||
$pts = (int) ($parts[1] ?? 0);
|
||||
$days = (int) ($parts[2] ?? 0);
|
||||
$title = sanitize_text_field((string) ($parts[3] ?? ''));
|
||||
$lv = (int) ($row['level'] ?? 0);
|
||||
$pts = max(0, (int) ($row['points'] ?? 0));
|
||||
if ($lv < 1 || $lv > 2 || $pts <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (empty($levels[$lv]['enabled'])) {
|
||||
continue;
|
||||
}
|
||||
$i++;
|
||||
if ($title === '') {
|
||||
$lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
|
||||
$title = $days > 0
|
||||
? sprintf(__('%1$s · %2$d天', 'aksrt'), $lv_name, $days)
|
||||
: sprintf(__('%s · 永久', 'aksrt'), $lv_name);
|
||||
$is_permanent = !empty($row['is_permanent']);
|
||||
$time = max(0, (int) ($row['time'] ?? 0));
|
||||
$unit = ((string) ($row['unit'] ?? 'day')) === 'month' ? 'month' : 'day';
|
||||
$days = $is_permanent ? 0 : max(0, (int) ($row['days'] ?? 0));
|
||||
if (!$is_permanent && $time <= 0) {
|
||||
continue;
|
||||
}
|
||||
$i++;
|
||||
$lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
|
||||
$out[] = [
|
||||
'package_id' => 5000 + $i,
|
||||
'level' => $lv,
|
||||
'index' => $i,
|
||||
'points' => $pts,
|
||||
'days' => max(0, $days),
|
||||
'title' => $title,
|
||||
'price_label' => $pts . ' ' . $currency,
|
||||
'days_label' => $days > 0 ? sprintf(__('%d 天', 'aksrt'), $days) : __('永久', 'aksrt'),
|
||||
'cta_label' => __('立即兑换', 'aksrt'),
|
||||
'package_id' => 5000 + $i,
|
||||
'level' => $lv,
|
||||
'index' => $i,
|
||||
'points' => $pts,
|
||||
'days' => $days,
|
||||
'time' => $time,
|
||||
'unit' => $unit,
|
||||
'is_permanent' => $is_permanent,
|
||||
'title' => $is_permanent
|
||||
? sprintf(__('%s · 永久', 'aksrt'), $lv_name)
|
||||
: sprintf(__('%1$s · %2$d%3$s', 'aksrt'), $lv_name, $time, $unit === 'month' ? __('个月', 'aksrt') : __('天', 'aksrt')),
|
||||
'price_label' => $pts . ' ' . $currency,
|
||||
'days_label' => $is_permanent
|
||||
? __('永久', 'aksrt')
|
||||
: ($unit === 'month' ? sprintf(__('%d 个月', 'aksrt'), $time) : sprintf(__('%d 天', 'aksrt'), $time)),
|
||||
'duration_label' => $is_permanent ? __('永久', 'aksrt') : ($unit === 'month' ? sprintf(__('%d 个月', 'aksrt'), $time) : sprintf(__('%d 天', 'aksrt'), $time)),
|
||||
'cta_label' => __('立即兑换', 'aksrt'),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
@@ -376,41 +196,18 @@ trait VipServicePackages {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Single upgrade product line: price|days|title
|
||||
*
|
||||
* @return array{price:float,days:int,title:string}|null
|
||||
*/
|
||||
public function parse_upgrade_product_line(string $raw): ?array {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
|
||||
foreach ($lines as $line) {
|
||||
$line = trim((string) $line);
|
||||
if ($line === '' || str_starts_with(strtolower($line), 'unit|')) {
|
||||
continue;
|
||||
}
|
||||
$parsed = $this->parse_packages($line);
|
||||
if (!empty($parsed[0])) {
|
||||
return $parsed[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Apply renew discount/reduce to same-level packages.
|
||||
*
|
||||
* `customize` reuses the dedicated renew product's own price/show_price/tag,
|
||||
* so the caller passes that row's whole item in as $pkg.
|
||||
*
|
||||
* @param array<string,mixed> $renew
|
||||
* @param array<string,mixed> $upgrade
|
||||
* @return array{price:string,show_price:string,tag:string}|null
|
||||
* @return array{price:string,show_price:string,tag:string,tag_class:string}|null
|
||||
*/
|
||||
private function apply_action_price(
|
||||
string $base_price,
|
||||
array $pkg,
|
||||
string $action,
|
||||
array $renew,
|
||||
array $upgrade,
|
||||
@@ -420,6 +217,7 @@ trait VipServicePackages {
|
||||
bool $is_permanent,
|
||||
int $days
|
||||
): ?array {
|
||||
$base_price = (string) ($pkg['price'] ?? '');
|
||||
$base_minor = $this->parse_vip_money_minor($base_price);
|
||||
if ($base_minor <= 0) {
|
||||
return null;
|
||||
@@ -434,32 +232,31 @@ trait VipServicePackages {
|
||||
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($base_minor),
|
||||
'show_price' => '0.00',
|
||||
'tag' => '',
|
||||
'tag_class' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$type = sanitize_key((string) ($renew['price_type'] ?? 'discount'));
|
||||
// discount/reduce 的标签由优惠幅度计算得出,若实际没有优惠则属于虚假折扣。
|
||||
$computed_tag = in_array($type, ['discount', 'reduce'], true);
|
||||
$price_minor = $base_minor;
|
||||
$show_minor = $base_minor;
|
||||
$tag = '';
|
||||
$tag_class = '';
|
||||
|
||||
if ($type === 'discount') {
|
||||
$discount = (int) ($renew['discount'] ?? 8);
|
||||
$discount = (float) ($renew['discount'] ?? 8);
|
||||
if ($discount <= 0) {
|
||||
$discount = 8;
|
||||
}
|
||||
if ($discount <= 10) {
|
||||
$percent = $discount * 10;
|
||||
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent);
|
||||
$tag = sprintf(
|
||||
/* translators: %s: discount like 8 */
|
||||
__('%s折续费', 'aksrt'),
|
||||
(string) $discount
|
||||
);
|
||||
} else {
|
||||
$percent = min(100, $discount);
|
||||
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent);
|
||||
$tag = sprintf(__('%s%% 续费价', 'aksrt'), (string) (int) $discount);
|
||||
}
|
||||
// 折值:8 = 八折(参考主题 slider 0.1–9.9 语义)。
|
||||
$discount = max(0.1, min(9.9, $discount));
|
||||
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $discount * 10);
|
||||
$tag = sprintf(
|
||||
/* translators: %s: discount like 8 */
|
||||
__('%s折优惠', 'aksrt'),
|
||||
rtrim(rtrim(number_format($discount, 1, '.', ''), '0'), '.') ?: '0'
|
||||
);
|
||||
} elseif ($type === 'reduce') {
|
||||
$reduce_minor = $this->parse_vip_money_minor($renew['reduce'] ?? '20');
|
||||
$price_minor = max(1, $base_minor - $reduce_minor);
|
||||
@@ -468,6 +265,11 @@ trait VipServicePackages {
|
||||
__('续费立减%s', 'aksrt'),
|
||||
$this->format_cash_label(\AKSRT\Ledger\AssetAmount::decimal_from_minor($reduce_minor))
|
||||
);
|
||||
} elseif ($type === 'customize') {
|
||||
// 自定义商品:沿用商品自身的原价、促销标签与标签颜色。
|
||||
$show_minor = $this->parse_vip_money_minor((string) ($pkg['show_price'] ?? ''));
|
||||
$tag = (string) ($pkg['tag'] ?? '');
|
||||
$tag_class = (string) ($pkg['tag_class'] ?? '');
|
||||
} else {
|
||||
$show_minor = 0;
|
||||
$tag = __('续费同价', 'aksrt');
|
||||
@@ -477,14 +279,18 @@ trait VipServicePackages {
|
||||
$price_minor = 1;
|
||||
}
|
||||
if ($show_minor > 0 && $price_minor >= $show_minor) {
|
||||
// 参考主题契约:原价仅在低于执行价时展示。
|
||||
$show_minor = 0;
|
||||
$tag = __('续费', 'aksrt');
|
||||
if ($computed_tag) {
|
||||
$tag = __('续费', 'aksrt');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor),
|
||||
'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor > $price_minor ? $show_minor : 0),
|
||||
'tag' => $tag,
|
||||
'tag_class' => $tag_class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -137,22 +137,32 @@ trait VipServicePurchase {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
|
||||
$name = trim(wp_strip_all_tags((string) ($levels[$lv]['name'] ?? ('VIP' . $lv))));
|
||||
$items = [];
|
||||
$packages = $product_s
|
||||
? $this->parse_level_products((string) ($levels[$lv]['products_raw'] ?? ''))
|
||||
: [];
|
||||
// 自定义续费商品时按续费商品组计价,否则按会员商品组计价。
|
||||
$level_action = 'pay';
|
||||
if ($level > 0 && $lv === $level) {
|
||||
$level_action = 'renew';
|
||||
} elseif ($level > 0 && $lv > $level) {
|
||||
$level_action = 'upgrade';
|
||||
}
|
||||
$renew_custom = $level_action === 'renew'
|
||||
&& sanitize_key((string) ($renew['price_type'] ?? 'discount')) === 'customize';
|
||||
$packages = [];
|
||||
if ($product_s) {
|
||||
$packages = $renew_custom
|
||||
? (array) ($levels[$lv]['renew_products'] ?? [])
|
||||
: (array) ($levels[$lv]['products'] ?? []);
|
||||
}
|
||||
foreach ($packages as $idx => $pkg) {
|
||||
if (!is_array($pkg)) {
|
||||
continue;
|
||||
}
|
||||
$base_price = (string) $pkg['price'];
|
||||
$days = (int) $pkg['days'];
|
||||
$action = 'pay';
|
||||
if ($level > 0 && $lv === $level) {
|
||||
$action = 'renew';
|
||||
} elseif ($level > 0 && $lv > $level) {
|
||||
$action = 'upgrade';
|
||||
}
|
||||
$action = $level_action;
|
||||
|
||||
$priced = $this->apply_action_price($base_price, $action, $renew, $upgrade, $lv, $level, $expires, $is_permanent, $days);
|
||||
$priced = $this->apply_action_price($pkg, $action, $renew, $upgrade, $lv, $level, $expires, $is_permanent, $days);
|
||||
if ($priced === null) {
|
||||
continue;
|
||||
}
|
||||
@@ -167,6 +177,10 @@ trait VipServicePurchase {
|
||||
if ($discount_tag === '' && $action === 'pay' && !empty($pkg['tag'])) {
|
||||
$discount_tag = (string) $pkg['tag'];
|
||||
}
|
||||
$tag_class = (string) ($priced['tag_class'] ?? '');
|
||||
if ($tag_class === '' && !empty($pkg['tag_class'])) {
|
||||
$tag_class = (string) $pkg['tag_class'];
|
||||
}
|
||||
|
||||
$days_label = __('永久', 'aksrt');
|
||||
if ((int) $pkg['time'] > 0) {
|
||||
@@ -184,10 +198,10 @@ trait VipServicePurchase {
|
||||
'base_price' => $base_price,
|
||||
'show_price' => $show_price,
|
||||
'discount_tag' => $discount_tag,
|
||||
'tag_class' => $tag_class,
|
||||
'days' => $days,
|
||||
'time' => (int) ($pkg['time'] ?? 0),
|
||||
'unit' => (string) ($pkg['unit'] ?? 'day'),
|
||||
'title' => (string) $pkg['title'],
|
||||
'price_label' => $this->format_cash_label((string) $priced['price']),
|
||||
'show_price_label' => $show_minor > 0
|
||||
? $this->format_cash_label($show_price)
|
||||
@@ -201,7 +215,7 @@ trait VipServicePurchase {
|
||||
|
||||
// Dedicated upgrade product: fixed or remaining-days unit price.
|
||||
if ($level === 1 && $lv === 2 && $upgrade_enabled) {
|
||||
$upgrade_items = $this->build_upgrade_items($user_id, $expires, $is_permanent, (string) ($upgrade['product'] ?? ''));
|
||||
$upgrade_items = $this->build_upgrade_items($user_id, $expires, $is_permanent, $upgrade);
|
||||
foreach ($upgrade_items as $ui) {
|
||||
$items[] = $ui;
|
||||
}
|
||||
@@ -232,21 +246,19 @@ trait VipServicePurchase {
|
||||
}
|
||||
}
|
||||
|
||||
$points_balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS) : 0;
|
||||
$wallet_balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : '0.00';
|
||||
$balances = \AKSRT\Ledger\LedgerService::get_instance()->points_and_balance($user_id);
|
||||
$methods = $this->available_payment_methods();
|
||||
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
|
||||
|
||||
$catalog = [
|
||||
'enabled' => !empty($vip['enabled']),
|
||||
'desc' => (string) ($vip['desc'] ?? ''),
|
||||
'help_text' => (string) ($vip['help_text'] ?? ''),
|
||||
'user_level' => $level,
|
||||
'user_expires' => $expires,
|
||||
'user_is_permanent' => $is_permanent,
|
||||
'user_expires_label' => (string) ($member['expires_label'] ?? ''),
|
||||
'user_points' => $points_balance,
|
||||
'wallet_balance' => $wallet_balance,
|
||||
'user_points' => $balances['points'],
|
||||
'wallet_balance' => $balances['balance'],
|
||||
'currency_name' => $currency_name,
|
||||
'renew_enabled' => $renew_enabled,
|
||||
'upgrade_enabled' => $upgrade_enabled,
|
||||
@@ -443,11 +455,12 @@ trait VipServicePurchase {
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => $expires_at,
|
||||
'subject' => sprintf(
|
||||
/* translators: 1: action 2: vip name 3: package title */
|
||||
__('%1$s%2$s · %3$s', 'aksrt'),
|
||||
/* translators: 1: action 2: vip name 3: price label 4: duration label */
|
||||
__('%1$s%2$s · %3$s / %4$s', 'aksrt'),
|
||||
$action_label,
|
||||
$level_name,
|
||||
(string) ($pkg['title'] ?? '')
|
||||
(string) ($pkg['price_label'] ?? ''),
|
||||
(string) ($pkg['days_label'] ?? '')
|
||||
),
|
||||
];
|
||||
|
||||
@@ -759,6 +772,11 @@ trait VipServicePurchase {
|
||||
$cost = (int) ($match['points'] ?? 0);
|
||||
$level = (int) ($match['level'] ?? 1);
|
||||
$days = (int) ($match['days'] ?? 0);
|
||||
$pkg_time = max(0, (int) ($match['time'] ?? 0));
|
||||
$pkg_unit = sanitize_key((string) ($match['unit'] ?? 'day'));
|
||||
if (!in_array($pkg_unit, ['day', 'month'], true)) {
|
||||
$pkg_unit = 'day';
|
||||
}
|
||||
if ($cost <= 0) {
|
||||
return new \WP_Error('aksrt_invalid_price', __('兑换积分无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
@@ -781,6 +799,8 @@ trait VipServicePurchase {
|
||||
'vip_pay_type' => 'points_exchange',
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
'time' => $pkg_time,
|
||||
'unit' => $pkg_unit,
|
||||
'points' => $cost,
|
||||
'package_id' => $package_id,
|
||||
'package_title' => (string) ($match['title'] ?? ''),
|
||||
|
||||
@@ -63,7 +63,10 @@ trait ScriptsPart1b {
|
||||
};
|
||||
|
||||
// 顶部导航用户卡片(legacy header-userbox 模块样式)。
|
||||
$enqueue_module('header-userbox');
|
||||
// 仅登录用户渲染用户卡(aksrt-header-userbox.php 的渲染条件),访客页面不加载。
|
||||
if (is_user_logged_in()) {
|
||||
$enqueue_module('header-userbox');
|
||||
}
|
||||
|
||||
if (!is_user_logged_in() || (bool) get_query_var('aksrt_sign')) {
|
||||
$auth_css = get_template_directory() . '/assets/css/routes/sign.css';
|
||||
|
||||
@@ -30,6 +30,9 @@ class StatsTracker {
|
||||
|
||||
/**
|
||||
* 追踪访问
|
||||
*
|
||||
* 访客判定与 setcookie 必须留在 wp_head(响应头尚未发送);
|
||||
* 计数读写改挂 shutdown,避免每次前台请求都在渲染关键路径上写库。
|
||||
*/
|
||||
public function track_visit(): void {
|
||||
// 排除管理员、爬虫、AJAX请求
|
||||
@@ -41,6 +44,12 @@ class StatsTracker {
|
||||
return;
|
||||
}
|
||||
|
||||
static $scheduled = false;
|
||||
if ($scheduled) {
|
||||
return;
|
||||
}
|
||||
$scheduled = true;
|
||||
|
||||
$today = current_time('Y-m-d');
|
||||
$option_key = 'aksrt_daily_stats_' . $today;
|
||||
|
||||
@@ -48,6 +57,19 @@ class StatsTracker {
|
||||
$visitor_cookie = 'aksrt_visitor_' . $today;
|
||||
$is_unique = empty($_COOKIE[$visitor_cookie]);
|
||||
|
||||
if ($is_unique) {
|
||||
setcookie($visitor_cookie, '1', time() + DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN);
|
||||
}
|
||||
|
||||
add_action('shutdown', static function () use ($option_key, $is_unique): void {
|
||||
self::get_instance()->record_stats($option_key, $is_unique);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 计数落库(shutdown 阶段执行;请求中断时本次统计丢弃)。
|
||||
*/
|
||||
public function record_stats(string $option_key, bool $is_unique): void {
|
||||
// 获取或初始化今日统计
|
||||
$stats = get_option($option_key, ['visitors' => 0, 'pageviews' => 0]);
|
||||
|
||||
@@ -59,7 +81,6 @@ class StatsTracker {
|
||||
$stats['pageviews']++;
|
||||
if ($is_unique) {
|
||||
$stats['visitors']++;
|
||||
setcookie($visitor_cookie, '1', time() + DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN);
|
||||
}
|
||||
|
||||
update_option($option_key, $stats, false);
|
||||
|
||||
@@ -63,6 +63,46 @@ final class LedgerService {
|
||||
return AssetAmount::from_minor(AssetType::BALANCE, $this->balance($user_id, AssetType::BALANCE))->decimal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read points and cash balance together (VIP modal catalog).
|
||||
*
|
||||
* The meta adapter serves both assets from user meta; the asset-account
|
||||
* fallback answers both rows with a single query.
|
||||
*
|
||||
* @return array{points:int,balance:string}
|
||||
*/
|
||||
public function points_and_balance(int $user_id): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['points' => 0, 'balance' => '0.00'];
|
||||
}
|
||||
if (class_exists('\AKSRT\Credits\MetaAssetService')) {
|
||||
$meta = \AKSRT\Credits\MetaAssetService::get_instance();
|
||||
return [
|
||||
'points' => $meta->balance_points($user_id),
|
||||
'balance' => $meta->balance_decimal($user_id),
|
||||
];
|
||||
}
|
||||
global $wpdb;
|
||||
$table = $this->table('asset_account');
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT asset, balance_minor FROM {$table} WHERE user_id = %d AND asset IN (%s, %s)",
|
||||
$user_id,
|
||||
AssetType::POINTS->value,
|
||||
AssetType::BALANCE->value
|
||||
), ARRAY_A);
|
||||
$minor = ['points' => 0, 'balance' => 0];
|
||||
foreach ((array) $rows as $row) {
|
||||
$asset = (string) ($row['asset'] ?? '');
|
||||
if (isset($minor[$asset])) {
|
||||
$minor[$asset] = max(0, (int) ($row['balance_minor'] ?? 0));
|
||||
}
|
||||
}
|
||||
return [
|
||||
'points' => $minor['points'],
|
||||
'balance' => AssetAmount::from_minor(AssetType::BALANCE, $minor['balance'])->decimal(),
|
||||
];
|
||||
}
|
||||
|
||||
public function format_balance(int $minor): string {
|
||||
return AssetAmount::from_minor(AssetType::BALANCE, max(0, $minor))->formatted();
|
||||
}
|
||||
|
||||
@@ -210,42 +210,6 @@ class HomeCms {
|
||||
return $settings['home_exclude_cats'];
|
||||
}
|
||||
|
||||
public static function get_paid_content_settings(): array {
|
||||
return PaymentRuntime::get_paid_content_settings();
|
||||
}
|
||||
|
||||
public static function get_content_coupon_settings(): array {
|
||||
return PaymentRuntime::get_content_coupon_settings();
|
||||
}
|
||||
|
||||
public static function get_payment_recharge_settings(): array {
|
||||
return PaymentRuntime::get_payment_recharge_settings();
|
||||
}
|
||||
|
||||
public static function get_payment_vip_settings(): array {
|
||||
return PaymentRuntime::get_payment_vip_settings();
|
||||
}
|
||||
|
||||
public static function get_payment_revenue_settings(): array {
|
||||
return PaymentRuntime::get_payment_revenue_settings();
|
||||
}
|
||||
|
||||
public static function get_payment_affiliate_settings(): array {
|
||||
return PaymentRuntime::get_payment_affiliate_settings();
|
||||
}
|
||||
|
||||
public static function get_payment_gateway_settings(): array {
|
||||
return PaymentRuntime::get_payment_gateway_settings();
|
||||
}
|
||||
|
||||
public static function get_order_settings(): array {
|
||||
return PaymentRuntime::get_order_settings();
|
||||
}
|
||||
|
||||
public static function get_withdrawal_settings(): array {
|
||||
return PaymentRuntime::get_withdrawal_settings();
|
||||
}
|
||||
|
||||
private static function normalize_sort_options(array $raw): array {
|
||||
$allowed = self::homepage_allowed_sorts();
|
||||
$result = [];
|
||||
|
||||
+214
-51
@@ -121,7 +121,9 @@ class PaymentRuntime {
|
||||
],
|
||||
'vip' => [
|
||||
'enabled' => !empty($settings['pay_vip_pass_charge_s']),
|
||||
'desc' => sanitize_textarea_field((string) ($settings['pay_vip_pass_charge_desc'] ?? '')),
|
||||
// 保存端已按 post 级 HTML 放行(schema sanitize_type html),
|
||||
// 输出端不能再被 textarea 净化剥掉标签。
|
||||
'desc' => wp_kses_post((string) ($settings['pay_vip_pass_charge_desc'] ?? '')),
|
||||
// 默认非单密码(卡号+密码),对齐参考主题 pay_vip_pass_charge_only_password 默认 false。
|
||||
'only_password' => !empty($settings['pay_vip_pass_charge_only_password']),
|
||||
],
|
||||
@@ -168,48 +170,28 @@ class PaymentRuntime {
|
||||
$vip2_enabled = !empty($settings['pay_user_vip_2_s']);
|
||||
$enabled = $vip1_enabled || $vip2_enabled;
|
||||
|
||||
$name1 = trim((string) ($settings['pay_user_vip_1_name'] ?? ''));
|
||||
$name2 = trim((string) ($settings['pay_user_vip_2_name'] ?? ''));
|
||||
$name1_html = trim(wp_kses_post((string) ($settings['pay_user_vip_1_name'] ?? '')));
|
||||
$name2_html = trim(wp_kses_post((string) ($settings['pay_user_vip_2_name'] ?? '')));
|
||||
$name1 = sanitize_text_field(wp_strip_all_tags($name1_html));
|
||||
$name2 = sanitize_text_field(wp_strip_all_tags($name2_html));
|
||||
if ($name1 === '') {
|
||||
$name1 = __('一级会员', 'aksrt');
|
||||
$name1 = __('黄金会员', 'aksrt');
|
||||
$name1_html = $name1;
|
||||
}
|
||||
if ($name2 === '') {
|
||||
$name2 = __('二级会员', 'aksrt');
|
||||
$name2 = __('钻石会员', 'aksrt');
|
||||
$name2_html = $name2;
|
||||
}
|
||||
|
||||
$badge = $vip2_enabled ? $name2 : ($vip1_enabled ? $name1 : __('VIP', 'aksrt'));
|
||||
|
||||
$renew_price_type = sanitize_key((string) ($settings['vip_renew_price_type'] ?? 'discount'));
|
||||
if (!in_array($renew_price_type, ['same', 'discount', 'reduce'], true)) {
|
||||
if (!in_array($renew_price_type, ['same', 'discount', 'reduce', 'customize'], true)) {
|
||||
$renew_price_type = 'discount';
|
||||
}
|
||||
|
||||
$default_l1 = "30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡";
|
||||
$default_l2 = "58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡";
|
||||
$products_1 = sanitize_textarea_field((string) ($settings['payment_vip_1_products'] ?? $default_l1));
|
||||
$products_2 = sanitize_textarea_field((string) ($settings['payment_vip_2_products'] ?? $default_l2));
|
||||
// Restore newlines lost only if empty after sanitize — sanitize_textarea_field keeps \n.
|
||||
if (trim($products_1) === '') {
|
||||
$products_1 = $default_l1;
|
||||
}
|
||||
if (trim($products_2) === '') {
|
||||
$products_2 = $default_l2;
|
||||
}
|
||||
|
||||
$desc = sanitize_textarea_field((string) ($settings['pay_user_vip_desc'] ?? ''));
|
||||
$more = (string) ($settings['pay_user_vip_more'] ?? '');
|
||||
$allowed_equity = [
|
||||
'li' => [],
|
||||
'ul' => [],
|
||||
'ol' => [],
|
||||
'strong' => [],
|
||||
'b' => [],
|
||||
'em' => [],
|
||||
'i' => ['class' => true],
|
||||
'span' => ['class' => true],
|
||||
'br' => [],
|
||||
'a' => ['href' => true, 'target' => true, 'rel' => true, 'class' => true],
|
||||
];
|
||||
$desc = wp_kses_post((string) ($settings['pay_user_vip_desc'] ?? __('开通VIP会员,享受会员专属折扣以及多项特权', 'aksrt')));
|
||||
$more = (string) ($settings['pay_user_vip_more'] ?? '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>');
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
@@ -218,27 +200,32 @@ class PaymentRuntime {
|
||||
'name' => $vip1_enabled ? $name1 : $badge,
|
||||
'badge_text' => $badge,
|
||||
'desc' => $desc,
|
||||
'more' => wp_kses($more, $allowed_equity + ['div' => ['class' => true], 'p' => ['class' => true]]),
|
||||
'help_text' => $desc !== '' ? $desc : __('会员权益以免扣下载和身份展示为主,开通后立即生效。', 'aksrt'),
|
||||
// 管理员编辑的富文本说明:与保存端同为 post 级 HTML(wp_kses_post),
|
||||
// 不再用权益列表的窄白名单二次剥标签(h/img/hr/table 等)。
|
||||
'more' => wp_kses_post($more),
|
||||
'levels' => [
|
||||
1 => [
|
||||
'enabled' => $vip1_enabled,
|
||||
'name' => $name1,
|
||||
'desc' => sanitize_text_field((string) ($settings['pay_user_vip_1_desc'] ?? __('尊享一级会员特权', 'aksrt'))),
|
||||
'equity' => wp_kses((string) (($settings['pay_user_vip_1_equity'] ?? '') !== '' ? $settings['pay_user_vip_1_equity'] : '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>'), $allowed_equity),
|
||||
'name_html' => $name1_html,
|
||||
'desc' => wp_kses_post((string) ($settings['pay_user_vip_1_desc'] ?? __('尊享专属特权', 'aksrt'))),
|
||||
'equity' => wp_kses_post((string) (($settings['pay_user_vip_1_equity'] ?? '') !== '' ? $settings['pay_user_vip_1_equity'] : '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>')),
|
||||
'img_icon' => self::get_vip_icon_url(1, (string) ($settings['vip_1_img_icon'] ?? '')),
|
||||
'product_s' => !isset($settings['vip_1_product_s']) || !empty($settings['vip_1_product_s']),
|
||||
'products_raw' => $products_1,
|
||||
'products' => self::normalize_vip_product_items($settings['vip_1_product'] ?? null, self::vip_product_defaults()),
|
||||
'renew_products' => self::normalize_vip_product_items($settings['vip_1_renew_product'] ?? null, self::vip_renew_product_defaults(1)),
|
||||
'download_limit' => max(0, min(9999, (int) ($settings['pay_download_limit_vip_1'] ?? 0))),
|
||||
],
|
||||
2 => [
|
||||
'enabled' => $vip2_enabled,
|
||||
'name' => $name2,
|
||||
'desc' => sanitize_text_field((string) ($settings['pay_user_vip_2_desc'] ?? __('尊享二级会员特权', 'aksrt'))),
|
||||
'equity' => wp_kses((string) (($settings['pay_user_vip_2_equity'] ?? '') !== '' ? $settings['pay_user_vip_2_equity'] : '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>'), $allowed_equity),
|
||||
'name_html' => $name2_html,
|
||||
'desc' => wp_kses_post((string) ($settings['pay_user_vip_2_desc'] ?? __('尊享专属特权', 'aksrt'))),
|
||||
'equity' => wp_kses_post((string) (($settings['pay_user_vip_2_equity'] ?? '') !== '' ? $settings['pay_user_vip_2_equity'] : '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>')),
|
||||
'img_icon' => self::get_vip_icon_url(2, (string) ($settings['vip_2_img_icon'] ?? '')),
|
||||
'product_s' => !isset($settings['vip_2_product_s']) || !empty($settings['vip_2_product_s']),
|
||||
'products_raw' => $products_2,
|
||||
'products' => self::normalize_vip_product_items($settings['vip_2_product'] ?? null, self::vip_product_defaults()),
|
||||
'renew_products' => self::normalize_vip_product_items($settings['vip_2_renew_product'] ?? null, self::vip_renew_product_defaults(2)),
|
||||
'download_limit' => max(0, min(9999, (int) ($settings['pay_download_limit_vip_2'] ?? 0))),
|
||||
],
|
||||
],
|
||||
@@ -247,21 +234,16 @@ class PaymentRuntime {
|
||||
],
|
||||
'renew' => [
|
||||
'enabled' => !isset($settings['vip_renew']) || !empty($settings['vip_renew']),
|
||||
'desc' => sanitize_textarea_field((string) ($settings['vip_renew_desc'] ?? '')),
|
||||
'desc' => wp_kses_post((string) ($settings['vip_renew_desc'] ?? '立即续费会员,畅享VIP权益')),
|
||||
'price_type' => $renew_price_type,
|
||||
'discount' => max(1, min(100, (int) ($settings['vip_renew_discount'] ?? 8))),
|
||||
'reduce' => self::money_decimal($settings['vip_renew_reduce'] ?? '20.00', '20.00', 999900),
|
||||
],
|
||||
'upgrade' => [
|
||||
'enabled' => !isset($settings['vip_upgrade']) || !empty($settings['vip_upgrade']),
|
||||
'desc' => sanitize_textarea_field((string) ($settings['vip_upgrade_desc'] ?? '')),
|
||||
// Multi-line upgrade product rules — must not collapse newlines.
|
||||
'product' => sanitize_textarea_field((string) ($settings['vip_upgrade_product'] ?? '')),
|
||||
'discount' => max(0.1, min(9.9, (float) ($settings['vip_renew_discount'] ?? 8))),
|
||||
'reduce' => self::money_decimal($settings['vip_renew_reduce'] ?? '20.00', '20.00', 50000),
|
||||
],
|
||||
'upgrade' => self::normalize_vip_upgrade_settings($settings),
|
||||
'points_exchange' => [
|
||||
'enabled' => !empty($settings['pay_vip_points_exchange_s']),
|
||||
'desc' => sanitize_textarea_field((string) ($settings['pay_vip_points_exchange_desc'] ?? '')),
|
||||
'packages_raw' => sanitize_textarea_field((string) ($settings['payment_vip_points_packages'] ?? "1|500|30|积分月卡\n1|5000|0|积分永久")),
|
||||
'desc' => wp_kses_post((string) ($settings['pay_vip_points_exchange_desc'] ?? '')),
|
||||
'packages' => self::normalize_vip_points_packages($settings['pay_vip_points_exchange_product'] ?? null),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -728,4 +710,185 @@ class PaymentRuntime {
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate days for catalog labels / order meta; activation prefers
|
||||
* calendar time+unit math.
|
||||
*/
|
||||
private static function duration_to_days(int $time, string $unit = 'day'): int {
|
||||
if ($time <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return $unit === 'month' ? $time * 30 : $time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员商品默认两行(参考主题 vip_tab 的 group 默认值)。
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private static function vip_product_defaults(): array {
|
||||
return [
|
||||
['price' => '99.00', 'show_price' => '199.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
|
||||
['price' => '199.00', 'show_price' => '299.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private static function vip_renew_product_defaults(int $level): array {
|
||||
if ($level === 2) {
|
||||
return [
|
||||
['price' => '269.00', 'show_price' => '599.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
|
||||
['price' => '369.00', 'show_price' => '899.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
|
||||
];
|
||||
}
|
||||
return [
|
||||
['price' => '69.00', 'show_price' => '199.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
|
||||
['price' => '169.00', 'show_price' => '299.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签颜色可选值(参考主题 zib_palette(array(), array('b','jb')))。
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private static function vip_tag_palette_keys(): array {
|
||||
return ['b-red', 'b-yellow', 'b-cyan', 'b-blue', 'b-green', 'b-purple', 'b-black', 'jb-red', 'jb-pink', 'jb-yellow', 'jb-cyan', 'jb-blue', 'jb-green', 'jb-purple', 'jb-vip1', 'jb-vip2'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a structured 会员商品 group into purchasable rows.
|
||||
*
|
||||
* @param mixed $raw Stored group value.
|
||||
* @param array<int,array<string,mixed>> $fallback Default rows when unset.
|
||||
* @return array<int,array{price:string,show_price:string,tag:string,tag_class:string,time:int,unit:string,days:int}>
|
||||
*/
|
||||
private static function normalize_vip_product_items($raw, array $fallback): array {
|
||||
$rows = is_array($raw) ? array_values($raw) : [];
|
||||
if ($rows === []) {
|
||||
$rows = $fallback;
|
||||
}
|
||||
$palette = self::vip_tag_palette_keys();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$price = self::money_decimal($row['price'] ?? '', '0.00');
|
||||
if (bccomp($price, '0.00', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$time = max(0, (int) ($row['time'] ?? 0));
|
||||
$unit = ((string) ($row['unit'] ?? 'month')) === 'day' ? 'day' : 'month';
|
||||
$tag_class = (string) ($row['tag_class'] ?? '');
|
||||
if (!in_array($tag_class, $palette, true)) {
|
||||
$tag_class = '';
|
||||
}
|
||||
$out[] = [
|
||||
'price' => $price,
|
||||
'show_price' => self::money_decimal($row['show_price'] ?? '', '0.00'),
|
||||
'tag' => wp_kses_post((string) ($row['tag'] ?? '')),
|
||||
'tag_class' => $tag_class,
|
||||
'time' => $time,
|
||||
'unit' => $unit,
|
||||
'days' => self::duration_to_days($time, $unit),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the structured 升级价格 accordion.
|
||||
*
|
||||
* @param array<string,mixed> $settings
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function normalize_vip_upgrade_settings(array $settings): array {
|
||||
$raw = isset($settings['vip_upgrade_product']) && is_array($settings['vip_upgrade_product'])
|
||||
? $settings['vip_upgrade_product']
|
||||
: [];
|
||||
$palette = self::vip_tag_palette_keys();
|
||||
$money = static function ($value, string $default): string {
|
||||
return self::money_decimal(is_string($value) ? $value : (is_numeric($value) ? (string) $value : ''), $default);
|
||||
};
|
||||
$tag = static function ($value, string $default): string {
|
||||
$text = trim((string) $value);
|
||||
return $text !== '' ? wp_kses_post($text) : $default;
|
||||
};
|
||||
$tag_class = static function ($value) use ($palette): string {
|
||||
$key = (string) $value;
|
||||
return in_array($key, $palette, true) ? $key : '';
|
||||
};
|
||||
$switch = static function ($value, bool $default): bool {
|
||||
return isset($value) ? !empty($value) : $default;
|
||||
};
|
||||
|
||||
return [
|
||||
'enabled' => !isset($settings['vip_upgrade']) || !empty($settings['vip_upgrade']),
|
||||
'desc' => wp_kses_post((string) ($settings['vip_upgrade_desc'] ?? '升级VIP会员,享更多会员权益')),
|
||||
'month_to_month_s' => $switch($raw['month_to_month_s'] ?? null, true),
|
||||
'unit_price' => $money($raw['unit_price'] ?? null, '10.00'),
|
||||
'unit_tag' => $tag($raw['unit_tag'] ?? null, '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐'),
|
||||
'unit_tag_class' => $tag_class($raw['unit_tag_class'] ?? 'jb-yellow'),
|
||||
'jump_s' => $switch($raw['jump_s'] ?? null, false),
|
||||
'jump_price' => $money($raw['jump_price'] ?? null, '199.00'),
|
||||
'jump_show_price' => $money($raw['jump_show_price'] ?? null, '299.00'),
|
||||
'jump_tag' => $tag($raw['jump_tag'] ?? null, '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠'),
|
||||
'jump_tag_class' => $tag_class($raw['jump_tag_class'] ?? 'jb-red'),
|
||||
'permanent_price' => $money($raw['permanent_price'] ?? null, '199.00'),
|
||||
'permanent_show_price' => $money($raw['permanent_show_price'] ?? null, '299.00'),
|
||||
'permanent_tag' => $tag($raw['permanent_tag'] ?? null, '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠'),
|
||||
'permanent_tag_class' => $tag_class($raw['permanent_tag_class'] ?? 'jb-red'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分兑换明细默认行(对齐旧 textarea 的开箱配置)。
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private static function vip_points_package_defaults(): array {
|
||||
return [
|
||||
['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'day'],
|
||||
['points' => 5000, 'level' => 1, 'time' => 'Permanent'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the structured 积分兑换明细 group. `time` is the literal string
|
||||
* "Permanent" for a permanent grant, otherwise a day/month count.
|
||||
*
|
||||
* @param mixed $raw
|
||||
* @return array<int,array{points:int,level:int,time:int,unit:string,is_permanent:bool,days:int}>
|
||||
*/
|
||||
private static function normalize_vip_points_packages($raw): array {
|
||||
$rows = is_array($raw) ? array_values($raw) : self::vip_points_package_defaults();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$points = max(0, (int) ($row['points'] ?? 0));
|
||||
$level = (int) ($row['level'] ?? 1);
|
||||
$time_raw = trim((string) ($row['time'] ?? ''));
|
||||
if ($points <= 0 || $level < 1 || $level > 2 || $time_raw === '') {
|
||||
continue;
|
||||
}
|
||||
$is_permanent = strcasecmp($time_raw, 'Permanent') === 0;
|
||||
$time = $is_permanent ? 0 : max(0, (int) $time_raw);
|
||||
$unit = ((string) ($row['unit'] ?? 'day')) === 'month' ? 'month' : 'day';
|
||||
$out[] = [
|
||||
'points' => $points,
|
||||
'level' => $level,
|
||||
'time' => $time,
|
||||
'unit' => $unit,
|
||||
'is_permanent' => $is_permanent,
|
||||
'days' => $is_permanent ? 0 : self::duration_to_days($time, $unit),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ final class UserRenderer {
|
||||
if ($name === '' || $style_name === '' || $label === '' || $url === '') {
|
||||
continue;
|
||||
}
|
||||
$html .= '<a rel="nofollow" href="' . esc_url($url) . '" class="social-login-item ' . esc_attr($style_name) . '" data-provider="' . esc_attr($name) . '" aria-label="' . esc_attr(sprintf(__('%s登录', 'aksrt'), $label)) . '" title="' . esc_attr($label) . '">' . (string) ($item['icon'] ?? '') . '</a>';
|
||||
$html .= '<a rel="nofollow" href="' . esc_url($url) . '" class="social-login-item ' . esc_attr($style_name) . ' toggle-radius" data-provider="' . esc_attr($name) . '" aria-label="' . esc_attr(sprintf(__('%s登录', 'aksrt'), $label)) . '" title="' . esc_attr(sprintf(__('%s登录', 'aksrt'), $label)) . '">' . (string) ($item['icon'] ?? '') . '</a>';
|
||||
}
|
||||
return $html . '</div>';
|
||||
}
|
||||
|
||||
@@ -147,6 +147,5 @@ $oauth_bind_pass = $oauth_intent === 'bind'
|
||||
<?php foreach ($oauth_providers as $provider) : ?><?php if (($provider['name'] ?? '') === 'weixingzh') continue; ?><?php $provider_label = (string) ($provider['label'] ?? $provider['name'] ?? ''); ?><a class="social-login-item <?php echo esc_attr((string) ($provider['name'] ?? '')); ?>" href="<?php echo esc_url((string) ($provider['url'] ?? '#')); ?>" data-provider="<?php echo esc_attr((string) ($provider['name'] ?? '')); ?>" aria-label="<?php echo esc_attr(sprintf(__('%s登录', 'aksrt'), $provider_label)); ?>" title="<?php echo esc_attr($provider_label); ?>"><?php echo $provider['icon'] ?? ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><?php if ($oauth_large) : ?><span><?php printf(esc_html__('%s登录', 'aksrt'), esc_html($provider_label)); ?></span><?php endif; ?></a><?php endforeach; ?>
|
||||
<?php if ($wechat_qr) : ?><button type="button" class="social-login-item weixingzh" data-auth-tab="oauth" aria-label="<?php esc_attr_e('微信扫码登录', 'aksrt'); ?>" title="<?php esc_attr_e('微信扫码登录', 'aksrt'); ?>"><i class="fa fab fa-weixin" aria-hidden="true"></i><?php if ($oauth_large) : ?><span><?php esc_html_e('微信登录', 'aksrt'); ?></span><?php endif; ?></button><?php endif; ?>
|
||||
</div></div>
|
||||
<?php if (!empty($agreement['enabled'])) : ?><div class="sign-agreement sign-agreement-implicit"><?php esc_html_e('使用社交账号登录即表示', 'aksrt'); ?><?php echo $agreement['label_html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div><?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -180,6 +180,23 @@ test('guest account member VIP and publish contracts share reference header DOM'
|
||||
assert.match(userboxCss, /\.aksrt-header-userbox\s*>\s*\.mt6\.em09\s*>\s*\.but\.block\s*\{[^}]*display:\s*block;[^}]*box-sizing:\s*border-box;[^}]*width:\s*100%;/s);
|
||||
});
|
||||
|
||||
test('the top-nav midline chip rule leaves the popover social buttons alone', () => {
|
||||
const userboxCss = read('assets/css/frontend/header-userbox.css');
|
||||
const publicCss = read('assets/css/build/public.css');
|
||||
|
||||
// The account-row chips, the theme toggle and the language toggle all want a
|
||||
// 29px midline circle. The popover's quick-login buttons are .toggle-radius
|
||||
// too and also live inside .navbar-form, but they must keep the
|
||||
// .social_loginbar geometry from main.css. flex-basis outranks width in a
|
||||
// flex container and the bare rule is (0,3,0) against (0,2,0), so an
|
||||
// unscoped selector collapses the buttons to 29px and zeroes their margins.
|
||||
assert.match(userboxCss, /\.navbar-top \.navbar-form \.toggle-radius:not\(\.social-login-item\):not\(\.xh-social-item\)\s*\{[^}]*flex:\s*0 0 29px;[^}]*margin:\s*0;/s);
|
||||
assert.doesNotMatch(userboxCss, /\.navbar-top \.navbar-form \.toggle-radius\s*\{/);
|
||||
// header-userbox.css is also folded into the public bundle, so the exemption
|
||||
// has to survive the build.
|
||||
assert.match(publicCss, /\.navbar-top \.navbar-form \.toggle-radius:not\(\.social-login-item\):not\(\.xh-social-item\)/);
|
||||
});
|
||||
|
||||
test('route and feature modules do not restyle the legacy top-nav chips', () => {
|
||||
// Route and feature modules may not restyle the legacy .but colour modifiers
|
||||
// without an enclosing scope. The top-nav chips ride the legacy geometry
|
||||
@@ -229,13 +246,18 @@ test('the account popover rows match the reference user box structure', () => {
|
||||
assert.match(header, /admin\.php\?page=aksrt_pay/);
|
||||
assert.match(header, /支付中心/);
|
||||
|
||||
// Wallet tiles: balance first, muted-box geometry, FA5 icons.
|
||||
// Wallet tiles: balance first, muted-box geometry, reference sprite icons.
|
||||
assert.match(header, /class="flex ab jsb col-ml6"/);
|
||||
assert.match(header, /muted-box padding-6 flex1 radius4/);
|
||||
assert.match(header, /font-bold c-blue-2/);
|
||||
assert.match(header, /font-bold c-yellow/);
|
||||
assert.match(header, /fa fas fa-money-bill-wave em14 c-blue-2/);
|
||||
assert.match(header, /fa fas fa-coins em14 c-yellow/);
|
||||
assert.match(header, /aksrt_icon_markup\('zibsvg-money-color-2', 'em14'\)/);
|
||||
assert.match(header, /aksrt_icon_markup\('zibsvg-points-color', 'em14'\)/);
|
||||
// The value icons are the reference theme's multi-tone SVGs, not FA5 glyphs;
|
||||
// the tile arrow stays on fa-angle-right exactly as the reference renders it.
|
||||
assert.doesNotMatch(header, /fa fas fa-money-bill-wave/);
|
||||
assert.doesNotMatch(header, /fa fas fa-coins/);
|
||||
assert.match(header, /fa fas fa-angle-right em12/);
|
||||
|
||||
// Message icon uses the reference markup.
|
||||
assert.match(header, /msg-news-icon abs-right/);
|
||||
|
||||
@@ -239,3 +239,77 @@ test('OAuth frontend is accessible, redirect-aware, and removes retired provider
|
||||
assert.doesNotMatch(signCss + mainCss, /social-login-item[^\n{]*(?:baidu|alipay)|social-login-item\.(?:baidu|alipay)/i);
|
||||
assert.match(mainCss, /\.pay-box \.but\.baidu/);
|
||||
});
|
||||
|
||||
test('the social-login area drops the implicit agreement line', () => {
|
||||
const shared = read('templates/parts/auth-content.php');
|
||||
const userRenderer = read('src/WidgetSystem/Renderers/UserRenderer.php');
|
||||
|
||||
// The reference shows no agreement copy under the social buttons; only the
|
||||
// registration panel keeps its own, and it stays reachable.
|
||||
assert.doesNotMatch(shared, /使用社交账号登录即表示/);
|
||||
assert.match(shared, /注册即表示/);
|
||||
assert.match(shared, /name="agree_terms"[\s\S]*?required/);
|
||||
assert.doesNotMatch(userRenderer, /使用社交账号登录即表示|我已阅读并同意/);
|
||||
});
|
||||
|
||||
test('brand icons resolve Gitee and Google to the reference SVG sprite', () => {
|
||||
const icons = read('inc/helpers/aksrt-icons.php');
|
||||
const sprites = read('assets/js/runtime/svg-icon.js');
|
||||
|
||||
// FA5 Free ships no Gitee brand glyph and no multi-colour Google mark, so both
|
||||
// resolve to the theme's own symbol sheet exactly like the reference does.
|
||||
assert.match(icons, /'gitee'\s*=>\s*'zibsvg-gitee'/);
|
||||
assert.match(icons, /'google'\s*=>\s*'zibsvg-google-color'/);
|
||||
assert.match(icons, /'github'\s*=>\s*'fa fab fa-github'/);
|
||||
assert.match(icons, /'wechat'\s*=>\s*'fa fab fa-weixin'/);
|
||||
assert.match(sprites, /aksrt_svgs\['gitee'\]/);
|
||||
assert.match(sprites, /aksrt_svgs\['google-color'\]/);
|
||||
// Sprite values must reach the shared icon renderer instead of being wrapped
|
||||
// in a bogus <i class="zibsvg-…">.
|
||||
assert.match(icons, /strpos\(\$class, 'zibsvg-'\) === 0[\s\S]*?aksrt_icon_markup\(\$class, \$extra_classes\)/);
|
||||
});
|
||||
|
||||
test('the top-nav user popover reproduces the reference quick-login block', () => {
|
||||
const oauth = read('src/Auth/OAuth.php');
|
||||
const header = read('inc/helpers/header/aksrt-header-navbar.php');
|
||||
const links = read('inc/helpers/aksrt-links.php');
|
||||
const legacyCss = read('assets/css/frontend/pages/base-1.css');
|
||||
|
||||
assert.match(oauth, /social-separator separator muted-3-color em09 mt10/);
|
||||
assert.match(oauth, /'<div class="social_loginbar">'/);
|
||||
assert.match(oauth, /'social-login-item ' \. \$type/);
|
||||
assert.match(oauth, /' button-lg' : ' toggle-radius'/);
|
||||
assert.match(oauth, /rel="nofollow"/);
|
||||
assert.match(oauth, /qrcode-signin/);
|
||||
assert.match(oauth, /oauth_button_lg/);
|
||||
|
||||
// Callers append the block verbatim — no hand-rolled separator or wrapper.
|
||||
assert.match(header, /render_social_buttons\(__\('快速登录', 'aksrt'\)\)/);
|
||||
assert.doesNotMatch(header, /social-separator|social_loginbar/);
|
||||
assert.match(links, /render_social_buttons\(__\('使用以下方式快速登录', 'aksrt'\)\)/);
|
||||
assert.doesNotMatch(links, /social_loginbar/);
|
||||
|
||||
// The retired .social-login-divider / .oauth-row / .oauth-btn markup and its
|
||||
// stylesheet are gone.
|
||||
assert.doesNotMatch(oauth, /oauth-row|oauth-btn|social-login-divider/);
|
||||
assert.doesNotMatch(legacyCss, /\.oauth-row|\.oauth-btn|\.social-login-divider/);
|
||||
});
|
||||
|
||||
test('provider URLs stay raw in the data layer and are escaped once per surface', () => {
|
||||
const oauth = read('src/Auth/OAuth.php');
|
||||
const shared = read('templates/parts/auth-content.php');
|
||||
const userRenderer = read('src/WidgetSystem/Renderers/UserRenderer.php');
|
||||
const comments = read('inc/helpers/aksrt-comments.php');
|
||||
|
||||
// get_auth_providers_data() hands out raw URLs, exactly like its sibling
|
||||
// get_frontend_bindings_data() does. esc_url() is idempotent on its own
|
||||
// output, so escaping here as well was redundant rather than broken.
|
||||
assert.doesNotMatch(oauth, /'url'\s*=>\s*esc_url\(\$url\)/);
|
||||
assert.match(oauth, /'url'\s*=>\s*\$url,/);
|
||||
|
||||
// Every consumer still escapes on output, so the rendered href is unchanged.
|
||||
assert.match(shared, /esc_url\(\(string\) \(\$provider\['url'\] \?\? '#'\)\)/);
|
||||
assert.match(userRenderer, /href="' \. esc_url\(\$url\)/);
|
||||
assert.match(oauth, /'<a rel="nofollow" href="' \. esc_url\(\$url\)/);
|
||||
assert.match(comments, /<a href="<\?php echo esc_url\(\$url\); \?\>" class="but hollow social-login-item ml10">/);
|
||||
});
|
||||
@@ -156,9 +156,8 @@ test('header VIP hover cards match the legacy markup and style contract', () =>
|
||||
assert.match(vipCardRenderer, /<div class=\"abs-center vip-baicon\"[^>]*>/)
|
||||
assert.match(vipCardRenderer, /<div class=\"vip-img abs-right\">/)
|
||||
assert.match(vipCardRenderer, /<div class=\"vip-name mb10\"><span class=\"mr6\">/)
|
||||
assert.match(vipCardRenderer, /<ul class=\"mb10 relative\">/)
|
||||
assert.match(vipCardRenderer, /aksrt_format_vip_equity_html\(\$equity, 'mb10 relative'\)/)
|
||||
assert.doesNotMatch(vipCardRenderer, /vip-name__(?:icon|label)/)
|
||||
assert.doesNotMatch(vipCardRenderer, /aksrt_format_vip_equity_html\(\$equity, 'mb10 relative'\)/)
|
||||
|
||||
assert.doesNotMatch(vipModalCss, /Header hover VIP cards/)
|
||||
assert.doesNotMatch(vipModalCss, /\.navbar-vip-hover\.hover-show/)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* AksField 的 group / repeater 行身份稳定性契约。
|
||||
*
|
||||
* 背景:inline 展开模式用 vuedraggable 渲染行,vuedraggable 的 computeNodes 会把
|
||||
* `node.key = getKey(element)`,而行 key 由 AksField 的 repeaterDragKey 提供。服务端
|
||||
* 历史行没有 _id,key 来自以「对象身份」为键的 WeakMap。
|
||||
*
|
||||
* 关键坑:行数据是 reactive 的。updateRepeaterItem 一旦把行展开成一个新对象,渲染时
|
||||
* 读到的是那个新对象的又一个 reactive 代理——身份再次改变,WeakMap 认不出它便生成
|
||||
* 新 key,整行随之重挂载、输入框失焦,表现为「打一个字就退出编辑」。所以必须原地写,
|
||||
* 让行身份跨渲染保持不变。本测试直接跑这两个真实函数来锁定该不变量。
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const fieldVue = fs.readFileSync(
|
||||
path.join(root, 'assets', 'admin', 'src', 'components', 'form', 'AksField.vue'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
/**
|
||||
* 模拟 Vue 的 reactive():每个原始对象只包一次代理并缓存,之后读多少次都返回同一个
|
||||
* 代理。这正是让 WeakMap 身份在渲染与更新之间保持一致(或不一致)的原因。
|
||||
*/
|
||||
const proxyCache = new WeakMap();
|
||||
const proxySet = new WeakSet();
|
||||
function reactiveOf(value) {
|
||||
if (!value || typeof value !== 'object' || proxySet.has(value)) return value;
|
||||
let proxy = proxyCache.get(value);
|
||||
if (!proxy) {
|
||||
proxy = new Proxy(value, {
|
||||
get(target, key, receiver) { return Reflect.get(target, key, receiver); },
|
||||
set(target, key, next) { target[key] = next; return true; },
|
||||
});
|
||||
proxyCache.set(value, proxy);
|
||||
proxySet.add(proxy);
|
||||
}
|
||||
return proxy;
|
||||
}
|
||||
|
||||
/** 取一整行声明(WeakMap / 种子计数器都没有花括号体)。 */
|
||||
function declaration(source, marker) {
|
||||
const start = source.indexOf(marker);
|
||||
assert.notStrictEqual(start, -1, `AksField.vue 缺少声明: ${marker}`);
|
||||
const end = source.indexOf('\n', start);
|
||||
return source.slice(start, end === -1 ? undefined : end);
|
||||
}
|
||||
|
||||
/** 按花括号配平切出一个顶层函数(这些函数体内没有字符串/注释里的孤立花括号)。 */
|
||||
function functionBlock(source, marker) {
|
||||
const start = source.indexOf(marker);
|
||||
assert.notStrictEqual(start, -1, `AksField.vue 缺少函数: ${marker}`);
|
||||
const open = source.indexOf('{', start);
|
||||
assert.notStrictEqual(open, -1, `函数没有函数体: ${marker}`);
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i++) {
|
||||
if (source[i] === '{') depth++;
|
||||
else if (source[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return source.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
assert.fail(`花括号不配平: ${marker}`);
|
||||
}
|
||||
|
||||
const prelude = [
|
||||
declaration(fieldVue, 'const dragRowKeys = new WeakMap()'),
|
||||
declaration(fieldVue, 'let dragRowKeySeed = 0'),
|
||||
functionBlock(fieldVue, 'function repeaterDragKey(item)'),
|
||||
functionBlock(fieldVue, 'function updateRepeaterItem(index, fieldKey, value)'),
|
||||
].join('\n');
|
||||
|
||||
/** 用真实的 props / emit 形状驱动这两个函数。 */
|
||||
function makeField(rows) {
|
||||
const emitted = [];
|
||||
// props.modelValue 是 reactive 视图,正如真实组件收到的那样。
|
||||
const props = { modelValue: rows.map(reactiveOf) };
|
||||
const emit = (event, value) => emitted.push({ event, value });
|
||||
const factory = new Function('props', 'emit', `${prelude}\nreturn { repeaterDragKey, updateRepeaterItem };`);
|
||||
const api = factory(props, emit);
|
||||
// 父组件把 emit 出来的数组写回 reactive 树,读出来时行又被包成代理。
|
||||
const roundTrip = () => {
|
||||
props.modelValue = emitted[emitted.length - 1].value.map(reactiveOf);
|
||||
};
|
||||
return { api, props, emitted, roundTrip };
|
||||
}
|
||||
|
||||
test('连续输入期间行身份不变,drag key 保持稳定', () => {
|
||||
// 服务端历史行:没有 _id,key 只能由 WeakMap 派生。
|
||||
const serverRows = [
|
||||
{ points: 500, level: 1, time: 30, unit: 'day' },
|
||||
{ points: 5000, level: 1, time: 'Permanent' },
|
||||
];
|
||||
const { api, props, emitted, roundTrip } = makeField(serverRows);
|
||||
const firstKey = api.repeaterDragKey(props.modelValue[0]);
|
||||
const secondKey = api.repeaterDragKey(props.modelValue[1]);
|
||||
assert.notStrictEqual(firstKey, secondKey, '两行必须各有一个 key');
|
||||
const firstRow = props.modelValue[0];
|
||||
|
||||
// 模拟逐字输入:每次 emit 后父组件把新数组回填 props.modelValue。
|
||||
let rows = serverRows;
|
||||
for (const value of ['5', '50', '500']) {
|
||||
api.updateRepeaterItem(0, 'points', value);
|
||||
rows = emitted[emitted.length - 1].value;
|
||||
roundTrip();
|
||||
}
|
||||
|
||||
assert.strictEqual(rows[0].points, '500', '输入值应写进行内');
|
||||
// 核心不变量:行对象身份必须跨渲染保持不变,否则 WeakMap 会发新 key。
|
||||
assert.strictEqual(props.modelValue[0], firstRow, '行身份必须在编辑期间保持不变');
|
||||
assert.strictEqual(
|
||||
api.repeaterDragKey(props.modelValue[0]),
|
||||
firstKey,
|
||||
'行 key 必须在编辑期间保持稳定,否则整行重挂载、输入框失焦'
|
||||
);
|
||||
assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), secondKey, '未编辑的行 key 不应变');
|
||||
});
|
||||
|
||||
test('emit 出去的是新数组,父组件仍会重渲染', () => {
|
||||
const { api, props, emitted, roundTrip } = makeField([{ points: 500 }]);
|
||||
const before = props.modelValue;
|
||||
api.updateRepeaterItem(0, 'points', '9');
|
||||
const after = emitted[emitted.length - 1].value;
|
||||
assert.notStrictEqual(after, before, '必须发出新数组,否则父组件不会重渲染');
|
||||
roundTrip();
|
||||
assert.strictEqual(props.modelValue[0].points, '9');
|
||||
});
|
||||
|
||||
test('行不是对象时退化为新建,不抛错', () => {
|
||||
const { api, props, roundTrip } = makeField([]);
|
||||
api.updateRepeaterItem(0, 'points', '7');
|
||||
roundTrip();
|
||||
assert.strictEqual(props.modelValue.length, 1);
|
||||
assert.strictEqual(props.modelValue[0].points, '7');
|
||||
});
|
||||
|
||||
test('带 _id 的行仍以 _id 作为 key', () => {
|
||||
const rows = [{ _id: 'rpt_1', points: 100 }, { _id: 'rpt_2', points: 200 }];
|
||||
const { api, props } = makeField(rows);
|
||||
assert.strictEqual(api.repeaterDragKey(props.modelValue[0]), 'rpt_1');
|
||||
assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), 'rpt_2');
|
||||
});
|
||||
|
||||
test('新增行获得独立 key,不与既有行冲突', () => {
|
||||
const { api, props } = makeField([{ points: 500 }, { points: 5000 }]);
|
||||
const keys = new Set(props.modelValue.map((row) => api.repeaterDragKey(row)));
|
||||
assert.strictEqual(keys.size, 2);
|
||||
assert.ok(!keys.has(api.repeaterDragKey({ points: 0 })), '新行必须拿到全新 key');
|
||||
});
|
||||
@@ -23,9 +23,13 @@ test('user and author widgets render the reference state contract', () => {
|
||||
assert.match(data.guest, /signup-loader ml10 but jb-yellow padding-lg/)
|
||||
assert.match(data.guest, /social-separator separator muted-3-color em09 mt20 mb20/)
|
||||
assert.match(data.guest, /social_loginbar/)
|
||||
assert.match(data.guest, /class="social-login-item github"/)
|
||||
assert.match(data.guest, /class="social-login-item qq" data-provider="clogin_qq"/)
|
||||
assert.match(data.guest, /class="social-login-item github toggle-radius"/)
|
||||
assert.match(data.guest, /class="social-login-item qq toggle-radius" data-provider="clogin_qq"/)
|
||||
assert.match(data.guest, /title="GitHub登录"/)
|
||||
assert.doesNotMatch(data.guest, /aksrt-widget|aksrt-panel-wrap|user-card__auth-btns|oauth-row|oauth-btn/)
|
||||
// The social-login area carries no agreement copy; the reference guest card
|
||||
// only shows the separator, the .social_loginbar and the provider buttons.
|
||||
assert.doesNotMatch(data.guest, /我已阅读并同意|使用社交账号登录即表示|注册即表示/)
|
||||
|
||||
assert.match(data.guest_no_registration, /signin-loader but jb-blue padding-lg/)
|
||||
assert.doesNotMatch(data.guest_no_registration, /signup-loader/)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 用户中心左侧栏图标契约(参考主题 1:1 对齐)。
|
||||
*
|
||||
* 硬约束:
|
||||
* 1) 「我的服务 / 功能设置」两个网格使用参考主题的 icon-but-box / user-icon-but-box
|
||||
* 外壳,图标全部来自主题内置 SVG sprite(aksrt_icon_markup('zibsvg-*')),
|
||||
* 不再出现 Font Awesome 字形。
|
||||
* 2) 侧栏其余部件(余额/积分卡、创作/推广卡、VIP 入口、抽屉触发器)同样只使用
|
||||
* sprite 图标,箭头统一为 zibsvg-right,保证侧栏只剩一种图标族。
|
||||
* 3) 旧的 uc-service-grid 外壳、色调底色与计数角标必须已彻底移除,不得残留。
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
const sidebarDir = 'inc/helpers/user-center-renderers/';
|
||||
const sidebarFiles = [
|
||||
'sidebar-nav.php',
|
||||
'sidebar-commerce.php',
|
||||
'sidebar-creator.php',
|
||||
'sidebar-vip.php',
|
||||
'profile-sidebar.php',
|
||||
];
|
||||
|
||||
test('服务/设置网格使用参考主题 icon-but-box 外壳与 sprite 图标', () => {
|
||||
const nav = read(sidebarDir + 'sidebar-nav.php');
|
||||
|
||||
assert.match(nav, /class="flex ac hh text-center icon-but-box user-icon-but-box"/);
|
||||
assert.match(nav, /<div class="em16"><\?php echo \$icon_html;/);
|
||||
assert.match(nav, /<div class="px12 muted-color mt3">/);
|
||||
assert.match(nav, /aksrt_icon_markup\('zibsvg-' \. \(string\) \$item\['icon'\]\)/);
|
||||
|
||||
// 参考主题选型:我的服务 = trend-color / user-auth / order-color。
|
||||
assert.match(nav, /'icon'\s+=>\s+'trend-color'/);
|
||||
assert.match(nav, /'icon'\s+=>\s+'user-auth'/);
|
||||
assert.match(nav, /'icon'\s+=>\s+'order-color'/);
|
||||
// 功能设置 = msg-color / user-color / security-color。
|
||||
assert.match(nav, /'icon'\s+=>\s+'msg-color'/);
|
||||
assert.match(nav, /'icon'\s+=>\s+'user-color'/);
|
||||
assert.match(nav, /'icon'\s+=>\s+'security-color'/);
|
||||
|
||||
// 每项都带 tab,保证 icon-but-* 类名完整且选中态可同步。
|
||||
for (const tab of ['level', 'auth', 'orders', 'msg', 'profile', 'settings']) {
|
||||
assert.match(nav, new RegExp(`'tab'\\s+=>\\s+'${tab}'`), `missing tab key: ${tab}`);
|
||||
}
|
||||
|
||||
// AKSRT 的跳转契约保留 <a href> + data-tab,不搬参考主题的 <item data-onclick>。
|
||||
assert.match(nav, /<a class="<\?php echo esc_attr\(\$item_class\);/);
|
||||
assert.match(nav, /data-tab="<\?php echo esc_attr\(\$tab\); \?>/);
|
||||
assert.doesNotMatch(nav, /data-onclick/);
|
||||
});
|
||||
|
||||
test('侧栏五个部件均不再出现 Font Awesome 字形', () => {
|
||||
for (const file of sidebarFiles) {
|
||||
const source = read(sidebarDir + file);
|
||||
assert.doesNotMatch(source, /fa\s+fas\s+fa-/, `${file} still renders a Font Awesome glyph`);
|
||||
assert.doesNotMatch(source, /<i class="fa/, `${file} still renders a Font Awesome <i>`);
|
||||
}
|
||||
});
|
||||
|
||||
test('其余侧栏部件按参考主题选型替换为 sprite 图标', () => {
|
||||
const commerce = read(sidebarDir + 'sidebar-commerce.php');
|
||||
assert.match(commerce, /aksrt_icon_markup\('zibsvg-money-color-2'\)/);
|
||||
assert.match(commerce, /aksrt_icon_markup\('zibsvg-points-color'\)/);
|
||||
assert.match(commerce, /aksrt_icon_markup\('zibsvg-right'\)/);
|
||||
|
||||
const creator = read(sidebarDir + 'sidebar-creator.php');
|
||||
assert.match(creator, /aksrt_icon_markup\('zibsvg-merchant-color'\)/);
|
||||
assert.match(creator, /aksrt_icon_markup\('zibsvg-money-color'\)/);
|
||||
assert.match(creator, /aksrt_icon_markup\('zibsvg-right'\)/);
|
||||
|
||||
const vip = read(sidebarDir + 'sidebar-vip.php');
|
||||
assert.match(vip, /aksrt_icon_markup\('zibsvg-vip_1'\)/);
|
||||
assert.match(vip, /aksrt_icon_markup\('zibsvg-right', 'uc-sidebar-vip__arrow'\)/);
|
||||
|
||||
const profile = read(sidebarDir + 'profile-sidebar.php');
|
||||
assert.match(profile, /aksrt_icon_markup\('zibsvg-menu'\)/);
|
||||
});
|
||||
|
||||
test('旧 uc-service-grid 外壳与色调底色已彻底移除', () => {
|
||||
const shell = read('assets/css/routes/user-center/shell.css');
|
||||
|
||||
assert.doesNotMatch(shell, /uc-service-grid/);
|
||||
for (const file of sidebarFiles) {
|
||||
assert.doesNotMatch(read(sidebarDir + file), /uc-service-grid/);
|
||||
}
|
||||
// 计数角标与 tone 键随色调 CSS 一并成为死代码。
|
||||
assert.doesNotMatch(shell, /uc-service-grid__icon small/);
|
||||
assert.doesNotMatch(read(sidebarDir + 'sidebar-nav.php'), /\$item\['count'\]/);
|
||||
assert.doesNotMatch(read(sidebarDir + 'sidebar-nav.php'), /'tone'/);
|
||||
|
||||
// 参考主题网格尺寸落地。
|
||||
assert.match(shell, /\.user-icon-but-box > a \{[^}]*width: calc\(25% - 10px\)/s);
|
||||
assert.match(shell, /\.user-icon-but-box > a \{[^}]*min-width: 50px/s);
|
||||
assert.match(shell, /\.user-icon-but-box > a\.is-active/);
|
||||
|
||||
// 卡片上的 <i> 已换成 <svg>,元素选择器需覆盖两者。
|
||||
assert.match(shell, /\.uc-summary-card__body > svg/);
|
||||
assert.match(shell, /\.uc-center-card > svg/);
|
||||
});
|
||||
|
||||
test('所用 sprite key 均在前端 sprite 中定义', () => {
|
||||
const sprite = read('assets/js/runtime/svg-icon.js');
|
||||
const keys = [
|
||||
'trend-color', 'user-auth', 'order-color', 'msg-color', 'user-color', 'security-color',
|
||||
'money-color-2', 'points-color', 'merchant-color', 'money-color', 'vip_1', 'menu', 'right',
|
||||
];
|
||||
for (const key of keys) {
|
||||
assert.match(sprite, new RegExp(`aksrt_svgs\\['${key}'\\]\\s*=`), `sprite key missing: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('导航栏用户浮层的余额/积分磁贴与侧栏使用同一组 sprite key', () => {
|
||||
const nav = read(sidebarDir + 'sidebar-commerce.php');
|
||||
const wallet = read('inc/helpers/aksrt-header-userbox.php');
|
||||
|
||||
assert.match(wallet, /aksrt_icon_markup\('zibsvg-money-color-2', 'em14'\)/);
|
||||
assert.match(wallet, /aksrt_icon_markup\('zibsvg-points-color', 'em14'\)/);
|
||||
assert.match(nav, /aksrt_icon_markup\('zibsvg-money-color-2'\)/);
|
||||
assert.match(nav, /aksrt_icon_markup\('zibsvg-points-color'\)/);
|
||||
|
||||
// 磁贴箭头沿用参考主题的 fa-angle-right,不做多余替换。
|
||||
assert.match(wallet, /fa fas fa-angle-right em12/);
|
||||
assert.doesNotMatch(wallet, /fa fas fa-money-bill-wave/);
|
||||
assert.doesNotMatch(wallet, /fa fas fa-coins/);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* VIP 后台设置弹窗文案字段契约(对照参考主题 zibll 修正)。
|
||||
*
|
||||
* 硬约束:
|
||||
* 1) 弹窗文案字段更名为「会员介绍」(pay_user_vip_desc, input) 与
|
||||
* 「开通会员更多内容」(pay_user_vip_more, textarea rows 4 +
|
||||
* sanitize_type html),并从「会员等级」组移到分区末尾的
|
||||
* 「界面文案」(modal-copy) 独立分组;
|
||||
* 2) 文案默认值对齐 zibll(黄金会员/钻石会员、尊享专属特权、权益四项、
|
||||
* 会员介绍与开通会员更多内容默认值),SPA schema / PHP schema /
|
||||
* PaymentRuntime 回退三处一致——Settings::get_all() 不合并
|
||||
* schema 默认值,运行时回退即实际默认值;
|
||||
* 3) vip_renew_desc / vip_upgrade_desc 为参考主题的一句话简介字段,
|
||||
* 两份 schema 与 PaymentRuntime 均已定义,并由用户中心 /user/vip/
|
||||
* 档位卡渲染消费(vip-tab.php 按续费/升级动作取对应简介);
|
||||
* 4) 权益与卡密说明字段保存时保留 HTML(sanitize_type html),
|
||||
* 不再被 sanitize_textarea_field 剥掉标签。
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
|
||||
|
||||
const vipSchemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'vip.js');
|
||||
const schemaPhp = read('src', 'Admin', 'Schema', 'SchemaPayment.php');
|
||||
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
|
||||
const rechargeJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'recharge.js');
|
||||
const vipTabPhp = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
|
||||
const modalSource = read('src', 'Content', 'VipServiceModal.php');
|
||||
const payVipJs = read('assets', 'js', 'pay-vip.js');
|
||||
|
||||
const DESC_DEFAULT = '开通VIP会员,享受会员专属折扣以及多项特权';
|
||||
const MORE_DEFAULT = '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>';
|
||||
const EQUITY_DEFAULT = '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>';
|
||||
|
||||
// 取两个标记之间的源码段(分组切片,避免断言误伤其它功能的同名键)。
|
||||
function slice(source, from, to) {
|
||||
const start = source.indexOf(from);
|
||||
assert.notStrictEqual(start, -1, `missing ${from}`);
|
||||
const end = source.indexOf(to, start + from.length);
|
||||
return source.slice(start, end === -1 ? undefined : end);
|
||||
}
|
||||
|
||||
test('弹窗文案字段更名为会员介绍/开通会员更多内容并改为正确类型', () => {
|
||||
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp]]) {
|
||||
assert.ok(!src.includes('弹窗顶部会员说明') && !src.includes('弹窗底部补充说明 HTML'), `${name} 旧名称应已移除`);
|
||||
assert.ok(src.includes('会员介绍') && src.includes('开通会员更多内容'), `${name} 应含新名称`);
|
||||
}
|
||||
// 会员介绍为单行 input(zibll text「一句话简介」);开通会员更多内容为 rows 4 的 HTML textarea。
|
||||
assert.match(
|
||||
vipSchemaJs,
|
||||
/key: 'pay_user_vip_desc',\s*\n\s*label: '会员介绍',\s*\n\s*type: 'input',\s*\n\s*sanitize_type: 'html',\s*\n\s*default: '开通VIP会员,享受会员专属折扣以及多项特权'/
|
||||
);
|
||||
assert.match(
|
||||
vipSchemaJs,
|
||||
/key: 'pay_user_vip_more',\s*\n\s*label: '开通会员更多内容',\s*\n\s*type: 'textarea',\s*\n\s*sanitize_type: 'html'[\s\S]*?rows: 4/
|
||||
);
|
||||
assert.match(
|
||||
schemaPhp,
|
||||
/'key' => 'pay_user_vip_desc', 'label' => '会员介绍', 'type' => 'input', 'sanitize_type' => 'html', 'default' => '开通VIP会员,享受会员专属折扣以及多项特权'/
|
||||
);
|
||||
assert.match(
|
||||
schemaPhp,
|
||||
/'key' => 'pay_user_vip_more', 'label' => '开通会员更多内容', 'type' => 'textarea', 'sanitize_type' => 'html'[\s\S]*?'rows' => 4/
|
||||
);
|
||||
// 帮助文本对齐 zibll 的 desc。
|
||||
assert.ok(vipSchemaJs.includes('显示在开通界面顶部一句话简介,可以为会员权益简介或者活动介绍'));
|
||||
assert.ok(vipSchemaJs.includes('显示在开通界面底部位置,可以为提醒事项、用户协议等,支持HTML代码'));
|
||||
});
|
||||
|
||||
test('两个文案字段位于分区末尾的界面文案分组且不再属于会员等级组', () => {
|
||||
const modalJs = vipSchemaJs.indexOf("key: 'modal-copy'");
|
||||
const modalPhp = schemaPhp.indexOf("'key' => 'modal-copy'");
|
||||
assert.notStrictEqual(modalJs, -1, 'vip.js 应含 modal-copy 分组');
|
||||
assert.notStrictEqual(modalPhp, -1, 'SchemaPayment.php 应含 modal-copy 分组');
|
||||
assert.ok(modalJs > vipSchemaJs.indexOf("key: 'points-exchange'"), 'vip.js modal-copy 应在分区末尾');
|
||||
assert.ok(modalPhp > schemaPhp.indexOf("'key' => 'points-exchange'"), 'SchemaPayment.php modal-copy 应在分区末尾');
|
||||
// modal-copy 之后不再出现其它分组/字段 key。
|
||||
// modal-copy 之后(至分区末尾)不再出现其它分组/字段 key;dependsOn 引用的等级开关除外。
|
||||
assert.doesNotMatch(vipSchemaJs.slice(modalJs), /key: '(?!modal-copy|pay_user_vip_desc|pay_user_vip_more|pay_user_vip_1_s|pay_user_vip_2_s)/);
|
||||
const phpTail = slice(schemaPhp, "'key' => 'modal-copy'", "'revenue'");
|
||||
assert.doesNotMatch(phpTail, /'key' => '(?!modal-copy|pay_user_vip_desc|pay_user_vip_more|pay_user_vip_1_s|pay_user_vip_2_s)/);
|
||||
// 「会员等级」组不再包含这两个字段。
|
||||
assert.doesNotMatch(slice(vipSchemaJs, "key: 'basic'", "key: 'benefit'"), /pay_user_vip_desc|pay_user_vip_more/);
|
||||
assert.doesNotMatch(slice(schemaPhp, "'key' => 'basic'", "'key' => 'benefit'"), /pay_user_vip_desc|pay_user_vip_more/);
|
||||
// 「会员等级」组只留导航按钮与两个等级开关;等级身份字段全部搬进
|
||||
// 「会员购买选项」的等级 Tab(名称/简介/权益/图标)。
|
||||
const basicJs = slice(vipSchemaJs, "key: 'basic'", "key: 'benefit'");
|
||||
const basicPhp = slice(schemaPhp, "'key' => 'basic'", "'key' => 'benefit'");
|
||||
for (const key of ['pay_user_vip_1_name', 'pay_user_vip_2_name', 'pay_user_vip_1_desc', 'pay_user_vip_2_desc', 'vip_1_img_icon', 'vip_2_img_icon']) {
|
||||
assert.ok(!basicJs.includes(key), `vip.js 会员等级组不应再含 ${key}`);
|
||||
assert.ok(!basicPhp.includes(key), `SchemaPayment.php 会员等级组不应再含 ${key}`);
|
||||
}
|
||||
assert.ok(basicJs.includes('pay_user_vip_1_s') && basicJs.includes('pay_user_vip_2_s'), 'vip.js 会员等级组应保留两个等级开关');
|
||||
assert.ok(basicPhp.includes("'key' => 'pay_user_vip_1_s'") && basicPhp.includes("'key' => 'pay_user_vip_2_s'"), 'SchemaPayment.php 会员等级组应保留两个等级开关');
|
||||
// 二级会员先于一级会员开启时的危险提示(参考主题同款)。
|
||||
assert.ok(basicJs.includes('vip_level_order_notice') && basicPhp.includes('vip_level_order_notice'));
|
||||
// 依赖:任一等级启用时显示(zibll pay_user_vip_1_s 依赖的双等级适配)。
|
||||
assert.ok(
|
||||
vipSchemaJs.includes("dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] }"),
|
||||
'vip.js 应为 any 双等级依赖'
|
||||
);
|
||||
assert.ok(
|
||||
schemaPhp.includes("'dependsOn' => ['any' => [['key' => 'pay_user_vip_1_s', 'value' => true], ['key' => 'pay_user_vip_2_s', 'value' => true]]]"),
|
||||
'SchemaPayment.php 应为 any 双等级依赖'
|
||||
);
|
||||
});
|
||||
|
||||
test('续费/升级介绍字段存在且被档位卡消费', () => {
|
||||
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp]]) {
|
||||
assert.ok(src.includes('vip_renew_desc'), `${name} 应含 vip_renew_desc`);
|
||||
assert.ok(src.includes('vip_upgrade_desc'), `${name} 应含 vip_upgrade_desc`);
|
||||
}
|
||||
// 运行时回退默认值(get_all 不合并 schema 默认值,回退即实际默认值)。
|
||||
assert.ok(paymentRuntime.includes("$settings['vip_renew_desc'] ?? '立即续费会员,畅享VIP权益'"));
|
||||
assert.ok(paymentRuntime.includes("$settings['vip_upgrade_desc'] ?? '升级VIP会员,享更多会员权益'"));
|
||||
// 消费方:/user/vip/ 档位卡按动作取续费/升级简介,不得回退成死配置。
|
||||
assert.ok(vipTabPhp.includes("$vip_settings['renew']['desc']"));
|
||||
assert.ok(vipTabPhp.includes("$vip_settings['upgrade']['desc']"));
|
||||
assert.match(vipTabPhp, /\$card_action = 'renew'/);
|
||||
assert.match(vipTabPhp, /\$card_action = 'upgrade'/);
|
||||
});
|
||||
|
||||
test('文案默认值三处一致并对齐 zibll', () => {
|
||||
// SPA defaults 块与字段级 default。
|
||||
assert.ok(vipSchemaJs.includes("pay_user_vip_1_name: '黄金会员'"));
|
||||
assert.ok(vipSchemaJs.includes("pay_user_vip_2_name: '钻石会员'"));
|
||||
assert.ok(vipSchemaJs.includes("pay_user_vip_1_desc: '尊享专属特权'"));
|
||||
assert.ok(vipSchemaJs.includes("pay_user_vip_2_desc: '尊享专属特权'"));
|
||||
assert.ok(vipSchemaJs.includes(`pay_user_vip_desc: '${DESC_DEFAULT}'`));
|
||||
assert.ok(vipSchemaJs.includes(`pay_user_vip_more: '${MORE_DEFAULT}'`));
|
||||
// 权益默认值收敛为常量,两份 schema 与运行时共用同一份字面量。
|
||||
assert.ok(vipSchemaJs.includes(`const VIP_EQUITY_DEFAULT = '${EQUITY_DEFAULT}'`));
|
||||
assert.ok(vipSchemaJs.includes('default: VIP_EQUITY_DEFAULT'));
|
||||
// PHP schema 字段 default。
|
||||
assert.ok(schemaPhp.includes("'default' => $level === 1 ? '黄金会员' : '钻石会员'"));
|
||||
assert.ok(schemaPhp.includes(EQUITY_DEFAULT));
|
||||
assert.ok(schemaPhp.includes(`'default' => '${DESC_DEFAULT}'`));
|
||||
assert.ok(schemaPhp.includes(`'default' => '${MORE_DEFAULT}'`));
|
||||
// PaymentRuntime 运行时回退(get_all 不合并 schema 默认值,回退即实际默认值)。
|
||||
assert.ok(paymentRuntime.includes(`$desc = wp_kses_post((string) ($settings['pay_user_vip_desc'] ?? __('${DESC_DEFAULT}', 'aksrt')))`));
|
||||
assert.ok(paymentRuntime.includes(`$more = (string) ($settings['pay_user_vip_more'] ?? '${MORE_DEFAULT}')`));
|
||||
assert.ok(paymentRuntime.includes("$name1 = __('黄金会员', 'aksrt');"));
|
||||
assert.ok(paymentRuntime.includes("$name2 = __('钻石会员', 'aksrt');"));
|
||||
assert.ok(paymentRuntime.includes("$settings['pay_user_vip_1_desc'] ?? __('尊享专属特权', 'aksrt')"));
|
||||
assert.ok(paymentRuntime.includes("$settings['pay_user_vip_2_desc'] ?? __('尊享专属特权', 'aksrt')"));
|
||||
assert.ok(paymentRuntime.includes(EQUITY_DEFAULT));
|
||||
// 旧默认值不再出现在任何一处。
|
||||
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp], ['PaymentRuntime.php', paymentRuntime]]) {
|
||||
assert.ok(!/尊享一级会员特权|尊享二级会员特权|免费下载部分资源|更高免费下载额度/.test(src), `${name} 旧默认值应已移除`);
|
||||
}
|
||||
});
|
||||
|
||||
test('会员等级名称、简介、续费和升级介绍都声明 HTML 净化', () => {
|
||||
const levelFieldsJs = slice(vipSchemaJs, 'function vipLevelTabFields', 'export const vipSchema');
|
||||
const levelFieldsPhp = slice(schemaPhp, 'private static function vip_level_tab_fields', 'private static function vip_product_defaults');
|
||||
assert.match(levelFieldsJs, /pay_user_vip_\$\{level\}_name[^\n]*sanitize_type: 'html'/);
|
||||
assert.match(levelFieldsJs, /pay_user_vip_\$\{level\}_desc[^\n]*sanitize_type: 'html'/);
|
||||
assert.match(levelFieldsPhp, /pay_user_vip_' \. \$level \. '_name'[^\n]*'sanitize_type' => 'html'/);
|
||||
assert.match(levelFieldsPhp, /pay_user_vip_' \. \$level \. '_desc'[^\n]*'sanitize_type' => 'html'/);
|
||||
assert.match(vipSchemaJs, /key: 'vip_renew_desc',[\s\S]*?sanitize_type: 'html'/);
|
||||
assert.match(schemaPhp, /'key' => 'vip_renew_desc'[^\n]*'sanitize_type' => 'html'/);
|
||||
assert.match(vipSchemaJs, /key: 'vip_upgrade_desc',[^\n]*sanitize_type: 'html'/);
|
||||
assert.match(schemaPhp, /'key' => 'vip_upgrade_desc'[^\n]*'sanitize_type' => 'html'/);
|
||||
assert.match(vipSchemaJs, /key: 'pay_user_vip_desc',[\s\S]*?sanitize_type: 'html'/);
|
||||
assert.match(schemaPhp, /'key' => 'pay_user_vip_desc'[^\n]*'sanitize_type' => 'html'/);
|
||||
});
|
||||
|
||||
test('权益和促销标签 HTML 通过安全净化及 HTML 渲染保留', () => {
|
||||
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
|
||||
assert.match(vipSettings, /'equity'\s*=> wp_kses_post\(/);
|
||||
assert.doesNotMatch(vipSettings, /\$allowed_equity/);
|
||||
const equityHelper = read('inc', 'helpers', 'aksrt-vip.php');
|
||||
assert.match(equityHelper, /\$clean = trim\(wp_kses_post\(\$raw\)\)/);
|
||||
assert.match(payVipJs, /if \(item\.discount_tag\) append\(product, htmlNode\(/);
|
||||
assert.match(payVipJs, /append\(info, htmlNode\("div", "", levelInfo\.equity\)\)/);
|
||||
assert.match(payVipJs, /htmlNode\("div", "v-name", levelInfo\.name \|\| group\.name/);
|
||||
assert.match(payVipJs, /htmlNode\("div", "v-desc em09", levelInfo\.desc/);
|
||||
assert.match(vipTabPhp, /wp_kses_post\(\$card_desc\)/);
|
||||
});
|
||||
|
||||
test('会员介绍按 post 级 HTML 安全净化并在弹窗与用户中心渲染', () => {
|
||||
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
|
||||
assert.match(vipSettings, /\$desc = wp_kses_post\(\(string\) \(\$settings\['pay_user_vip_desc'\]/);
|
||||
assert.match(vipSettings, /'desc'\s+=> wp_kses_post\(\(string\) \(\$settings\['pay_user_vip_1_desc'\]/);
|
||||
assert.match(vipSettings, /'desc'\s+=> wp_kses_post\(\(string\) \(\$settings\['pay_user_vip_2_desc'\]/);
|
||||
assert.match(vipTabPhp, /wp_kses_post\(\$vip_description\)/);
|
||||
assert.match(payVipJs, /htmlNode\("div", "muted-2-color em09", catalog\.desc\)/);
|
||||
});
|
||||
|
||||
test('开通会员更多内容只在弹窗内渲染,不裸露在用户中心页面', () => {
|
||||
// 参考主题 zibpay_user_page_tab_content_vip 只渲染 pay_user_vip_desc;
|
||||
// pay_user_vip_more 仅出现在两个弹窗函数(购买/续费弹窗)中。
|
||||
assert.ok(!vipTabPhp.includes("vip_settings['more']"), '用户中心 VIP 页不应渲染 more');
|
||||
assert.ok(!vipTabPhp.includes('muted-box padding-10 mini-radius mt15'), '用户中心 VIP 页不应有 more 提示盒');
|
||||
// 弹窗侧保持:视图模型透传 more + pay-vip.js 右列渲染。
|
||||
assert.match(modalSource, /'more'\s+=>/);
|
||||
assert.match(payVipJs, /catalog\.more/);
|
||||
});
|
||||
|
||||
test('more 与卡密说明输出按 post 级 HTML 放行', () => {
|
||||
// 保存端已 wp_kses_post,输出端不能再窄白名单/textarea 净化二次剥标签。
|
||||
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
|
||||
assert.match(vipSettings, /'more'\s+=> wp_kses_post\(\$more\)/);
|
||||
assert.doesNotMatch(vipSettings, /\$allowed_equity \+ \['div'/);
|
||||
const rechargeSettings = slice(paymentRuntime, 'public static function get_payment_recharge_settings', 'public static function get_payment_vip_settings');
|
||||
assert.match(rechargeSettings, /'desc'\s+=> wp_kses_post\(\(string\) \(\$settings\['pay_vip_pass_charge_desc'\]/);
|
||||
});
|
||||
|
||||
test('弹窗内 more 与卡密说明按 HTML 渲染而非纯文本', () => {
|
||||
// node() 走 textContent 会把标签显示为字面文本;富文本字段必须走 innerHTML。
|
||||
assert.match(payVipJs, /function htmlNode\(tag, className, value\) \{[\s\S]*?el\.innerHTML = text\(value\);/);
|
||||
assert.ok(payVipJs.includes('htmlNode("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more)'));
|
||||
assert.ok(payVipJs.includes('htmlNode("div", "muted-box muted-2-color padding-10 mb10 em09", cardPass.desc)'));
|
||||
});
|
||||
|
||||
test('积分兑换说明按 post 级 HTML 放行并原样渲染', () => {
|
||||
// 参考主题该字段 sanitize=false 且弹窗内原样拼接;AKSRT 对齐为保存/输出
|
||||
// 双端 wp_kses_post + 弹窗 wp_kses_post 渲染。
|
||||
assert.match(vipSchemaJs, /key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', sanitize_type: 'html'/);
|
||||
assert.match(schemaPhp, /'key' => 'pay_vip_points_exchange_desc', 'label' => '兑换说明', 'type' => 'textarea', 'sanitize_type' => 'html'/);
|
||||
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
|
||||
assert.match(vipSettings, /'desc'\s+=> wp_kses_post\(\(string\) \(\$settings\['pay_vip_points_exchange_desc'\]/);
|
||||
// 弹窗从同一份 VIP 设置上下文读取说明,不经 catalog 投影遗漏;说明容器保留参考主题的 HTML 与定位类。
|
||||
assert.match(modalSource, /\$desc_text = trim\(\(string\) \(\$vip\['points_exchange'\]\['desc'\] \?\? ''\)\);/);
|
||||
assert.match(modalSource, /<div class="muted-box muted-2-color padding-10 em09 mt20 aksrt-vip-points-desc">' \. wp_kses_post\(\$desc_text\)/);
|
||||
assert.doesNotMatch(modalSource, /esc_html\(\$desc_text\)/);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 会员弹窗骨架屏 / 打开提速 / help_text 清理 / 启动优化契约。
|
||||
*
|
||||
* 硬约束:
|
||||
* 1) 会员弹窗加载占位为完整布局仿形骨架(vipSkeleton()),复用全局
|
||||
* aksrt-modal-runtime__skeleton-line/-block 与 shimmer 动画,
|
||||
* VIP 专属形状样式落在 assets/css/frontend/vip-modal.css;
|
||||
* 2) 弹窗数据缓存 TTL 60s,悬停/聚焦入口按等级意图预热,预热与
|
||||
* openRemote 共用 cacheKey 与 flight;
|
||||
* 3) 弹窗底部提示句与整条 help_text 管线已移除,顶部说明 desc 保留;
|
||||
* 4) 积分/钱包余额合并为一次 LedgerService::points_and_balance();
|
||||
* 5) 访问统计写库移到 shutdown,wp_head 只做访客判定与 setcookie;
|
||||
* 6) header-userbox 样式仅登录用户入队。
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
|
||||
|
||||
// 取某个方法体(到下一个同类方法声明为止),避免断言误伤其它功能的同名键。
|
||||
function sliceMethod(source, signature, nextSignature) {
|
||||
const start = source.indexOf(signature);
|
||||
assert.notStrictEqual(start, -1, `missing ${signature}`);
|
||||
const end = source.indexOf(nextSignature, start + signature.length);
|
||||
return source.slice(start, end === -1 ? undefined : end);
|
||||
}
|
||||
|
||||
const vipJs = read('assets', 'js', 'pay-vip.js');
|
||||
const vipCss = read('assets', 'css', 'frontend', 'vip-modal.css');
|
||||
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
|
||||
const modal = read('src', 'Content', 'VipServiceModal.php');
|
||||
const catalogSource = read('src', 'Content', 'VipServicePurchase.php');
|
||||
const ledger = read('src', 'Ledger', 'LedgerService.php');
|
||||
const stats = read('src', 'Core', 'StatsTracker.php');
|
||||
const scripts = read('src', 'Core', 'ScriptsPart1b.php');
|
||||
|
||||
test('会员弹窗使用完整布局仿形骨架屏', () => {
|
||||
assert.match(vipJs, /function vipSkeleton\(\) \{/);
|
||||
assert.match(vipJs, /node\("div", "aksrt-vip-modal-skeleton"\)/);
|
||||
assert.match(vipJs, /skeleton\.setAttribute\("aria-busy", "true"\)/);
|
||||
// 结构:用户框(头像+两行)、等级卡行、双列面板(权益+套餐区)。
|
||||
assert.match(vipJs, /aksrt-vip-modal-skeleton__user/);
|
||||
assert.match(vipJs, /aksrt-vip-modal-skeleton__avatar/);
|
||||
assert.match(vipJs, /aksrt-vip-modal-skeleton__lines/);
|
||||
assert.match(vipJs, /aksrt-vip-modal-skeleton__tabs/);
|
||||
assert.match(vipJs, /aksrt-vip-modal-skeleton__tab/);
|
||||
assert.match(vipJs, /aksrt-vip-modal-skeleton__pane/);
|
||||
assert.match(vipJs, /aksrt-vip-modal-skeleton__info/);
|
||||
assert.match(vipJs, /aksrt-vip-modal-skeleton__products/);
|
||||
assert.match(vipJs, /aksrt-vip-modal-skeleton__button/);
|
||||
// 线条/色块复用全局骨架类。
|
||||
assert.match(vipJs, /aksrt-modal-runtime__skeleton-line is-wide/);
|
||||
assert.match(vipJs, /aksrt-modal-runtime__skeleton-line is-short/);
|
||||
assert.match(vipJs, /aksrt-modal-runtime__skeleton-block/);
|
||||
// openRemote 传入自定义骨架,不再使用纯文本占位。
|
||||
assert.match(vipJs, /skeleton: vipSkeleton\(\),/);
|
||||
assert.doesNotMatch(vipJs, /加载中/);
|
||||
});
|
||||
|
||||
test('骨架样式落在 vip-modal.css 并对齐 640px 双列阈值', () => {
|
||||
assert.match(vipCss, /\.aksrt-vip-modal-skeleton \{/);
|
||||
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__avatar \{[\s\S]*?border-radius: 50%/);
|
||||
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__tab \{[\s\S]*?flex: 1/);
|
||||
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__button \{[\s\S]*?height: 38px/);
|
||||
assert.match(vipCss, /animation: aksrt-modal-skeleton/);
|
||||
assert.match(vipCss, /@media \(min-width: 640px\)[\s\S]*?\.aksrt-vip-modal-skeleton__pane \{[\s\S]*?grid-template-columns: 1fr 1\.2fr/);
|
||||
});
|
||||
|
||||
test('弹窗打开提速:缓存 TTL 提升与意图预热', () => {
|
||||
assert.match(vipJs, /cacheTtl: 60000,/);
|
||||
// 预热与 openRemote 同一 cacheKey(method + AksrtRequest.url)。
|
||||
assert.match(vipJs, /function vipModalCacheKey\(level\) \{/);
|
||||
assert.match(vipJs, /"GET:" \+ window\.AksrtRequest\.url\("\/pay\/vip\/modal", \{ vip_level: level \}\)/);
|
||||
assert.match(vipJs, /cache\.acquire\(vipModalCacheKey\(level\)/);
|
||||
assert.match(vipJs, /ttl: 60000,/);
|
||||
assert.match(vipJs, /tags: \["vip"\]/);
|
||||
// 悬停/聚焦触发,绑定进 bindOnce。
|
||||
assert.match(vipJs, /\["pointerenter", "focusin"\]/);
|
||||
assert.match(vipJs, /function bindPrefetch\(\) \{/);
|
||||
assert.match(vipJs, /document\.addEventListener\("click", onDocClick, true\);\s*bindPrefetch\(\);/);
|
||||
// 每等级每页只预热一次。
|
||||
assert.match(vipJs, /prefetchedLevels\[level\] = true;/);
|
||||
});
|
||||
|
||||
test('弹窗底部提示与 help_text 管线已彻底移除', () => {
|
||||
assert.doesNotMatch(vipJs, /help_text/);
|
||||
// 仅收敛 VIP 设置函数:充值/网关设置的 help_text 与本文无关,保持原样。
|
||||
const vipSettings = sliceMethod(
|
||||
paymentRuntime,
|
||||
'public static function get_payment_vip_settings',
|
||||
'public static function get_payment_revenue_settings'
|
||||
);
|
||||
assert.doesNotMatch(vipSettings, /help_text/);
|
||||
assert.doesNotMatch(vipSettings, /开通后立即生效/);
|
||||
assert.doesNotMatch(modal, /help_text/);
|
||||
const viewModel = sliceMethod(modal, 'public function get_modal_view_model', 'public function get_points_exchange_modal_html');
|
||||
assert.doesNotMatch(viewModel, /help_text/);
|
||||
assert.doesNotMatch(catalogSource, /help_text/);
|
||||
// 顶部说明 desc 仍保留(后台 pay_user_vip_desc 继续生效)。
|
||||
assert.match(vipJs, /catalog\.desc/);
|
||||
assert.match(paymentRuntime, /'desc'\s+=> \$desc,/);
|
||||
});
|
||||
|
||||
test('积分与钱包余额合并为一次 LedgerService 读取', () => {
|
||||
assert.match(ledger, /public function points_and_balance\(int \$user_id\): array/);
|
||||
// 仅收敛 get_catalog:购买/履约路径的余额读取与本文无关,保持原样。
|
||||
const getCatalog = sliceMethod(catalogSource, 'public function get_catalog', 'private function find_catalog_package');
|
||||
assert.match(getCatalog, /points_and_balance\(\$user_id\)/);
|
||||
assert.match(getCatalog, /'user_points'\s+=> \$balances\['points'\]/);
|
||||
assert.match(getCatalog, /'wallet_balance'\s+=> \$balances\['balance'\]/);
|
||||
assert.doesNotMatch(getCatalog, /LedgerService::get_instance\(\)->balance\(/);
|
||||
assert.doesNotMatch(getCatalog, /balance_decimal/);
|
||||
});
|
||||
|
||||
test('访问统计写库移出渲染关键路径', () => {
|
||||
assert.match(stats, /add_action\('wp_head', \[self::get_instance\(\), 'track_visit'\], 1\)/);
|
||||
assert.match(stats, /add_action\('shutdown'/);
|
||||
assert.match(stats, /public function record_stats\(string \$option_key, bool \$is_unique\): void/);
|
||||
// wp_head 阶段只做判定与 setcookie,不再读写 option。
|
||||
const trackVisit = stats.slice(stats.indexOf('public function track_visit'), stats.indexOf('public function record_stats'));
|
||||
assert.doesNotMatch(trackVisit, /update_option|get_option/);
|
||||
assert.match(trackVisit, /setcookie\(\$visitor_cookie/);
|
||||
// 落库与月初清理收敛到 record_stats。
|
||||
const recordStats = stats.slice(stats.indexOf('public function record_stats'));
|
||||
assert.match(recordStats, /update_option\(\$option_key, \$stats, false\)/);
|
||||
assert.match(recordStats, /cleanup_old_stats\(\)/);
|
||||
});
|
||||
|
||||
test('header-userbox 样式仅登录用户入队', () => {
|
||||
assert.match(scripts, /if \(is_user_logged_in\(\)\) \{\s*\$enqueue_module\('header-userbox'\);/);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
|
||||
function __(string $text, string $domain = ''): string { return $text; }
|
||||
function esc_html($value): string { return (string) $value; }
|
||||
function esc_html__($value, string $domain = ''): string { return (string) $value; }
|
||||
function esc_attr($value): string { return (string) $value; }
|
||||
function esc_attr__($value, string $domain = ''): string { return (string) $value; }
|
||||
function esc_url($value): string { return (string) $value; }
|
||||
function wp_kses_post($value): string { return (string) $value; }
|
||||
function aksrt_get_user_center_tab_url(string $tab): string { return '/user/' . $tab; }
|
||||
}
|
||||
|
||||
namespace AKSRT\Settings {
|
||||
final class Settings {
|
||||
public static function credits_enabled(): bool { return true; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require dirname(__DIR__, 2) . '/src/Content/VipServiceModal.php';
|
||||
|
||||
final class VipPointsDescriptionProbe {
|
||||
use \AKSRT\Content\VipServiceModal;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function get_vip_settings_context(): array {
|
||||
return [
|
||||
'enabled' => true,
|
||||
'levels' => [1 => ['enabled' => true, 'name' => 'VIP1']],
|
||||
'points_exchange' => [
|
||||
'enabled' => true,
|
||||
'desc' => '<p>后台配置的兑换说明</p>',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function get_catalog(int $user_id = 0): array {
|
||||
return [
|
||||
'points_exchange' => [
|
||||
'packages' => [[
|
||||
'package_id' => 6001,
|
||||
'level' => 1,
|
||||
'points' => 500,
|
||||
'time' => 30,
|
||||
'unit' => 'day',
|
||||
]],
|
||||
],
|
||||
'user_points' => 1000,
|
||||
'currency_name' => '积分',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo (new VipPointsDescriptionProbe())->get_points_exchange_modal_html(1);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
|
||||
@@ -66,7 +67,9 @@ test('弹窗内容保持参考主题结构:标题/我的积分/卡片选择/
|
||||
assert.match(modal, /name="action" value="points_initiate_pay"/);
|
||||
assert.match(modal, /mt6 but jb-yellow btn-block radius padding-lg/);
|
||||
assert.match(modal, /data-aksrt-vip-points-submit/);
|
||||
assert.match(modal, /badg c-red btn-block/);
|
||||
// CTA 与积分不足状态都从卡片选择区留出 20px 上间距,避免状态切换时布局贴紧。
|
||||
assert.match(modal, /\$form = '<div class="mt20">'/);
|
||||
assert.match(modal, /'<div class="mt20"><div class="badg c-red btn-block">'/);
|
||||
});
|
||||
|
||||
test('弹窗壳随 wp_footer 全局输出且不进入 catalog 载荷', () => {
|
||||
@@ -78,6 +81,28 @@ test('弹窗壳随 wp_footer 全局输出且不进入 catalog 载荷', () => {
|
||||
assert.doesNotMatch(modal, /'vip_url'/);
|
||||
});
|
||||
|
||||
test('我的积分盒恢复参考主题 muted-box 的背景、圆角与内边距', () => {
|
||||
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s+\.muted-box\.padding-h10\s*\{\s*background-color:\s*var\(--this-bg,\s*var\(--muted-border-color\)\);\s*border-radius:\s*var\(--main-radius\);\s*padding:\s*10px 15px;\s*\}/);
|
||||
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s+\.muted-box\.padding-10,\s*\.modal\[data-aksrt-vip-points-modal\]\s+\.aksrt-vip-points-desc\s*\{\s*background-color:\s*var\(--this-bg,\s*var\(--muted-border-color\)\);\s*border-radius:\s*var\(--main-radius\);\s*padding:\s*10px;\s*\}/);
|
||||
});
|
||||
|
||||
test('后台兑换说明直接从 VIP 设置读取并作为富文本置于套餐与提交动作之间', () => {
|
||||
assert.match(modal, /\$desc_text = trim\(\(string\) \(\$vip\['points_exchange'\]\['desc'\] \?\? ''\)\);/);
|
||||
assert.match(modal, /aksrt-vip-points-desc/);
|
||||
assert.match(modal, /wp_kses_post\(\$desc_text\)/);
|
||||
assert.match(modal, /\. \$header\s*\. '<form class="" data-aksrt-vip-points-form>' \. \$user_box \. \$lists \. \$desc \. \$form/);
|
||||
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s+\.aksrt-vip-points-desc\s*\{[^}]*display:\s*block;[^}]*margin-top:\s*20px;[^}]*color:\s*var\(--muted-2-color,\s*#999\);/s);
|
||||
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s+\.muted-box\.padding-10,\s*\.modal\[data-aksrt-vip-points-modal\]\s+\.aksrt-vip-points-desc\s*\{[^}]*padding:\s*10px;/s);
|
||||
});
|
||||
|
||||
test('兑换说明从后台设置实际输出,保留 HTML 并显示在卡片与 CTA 之间', () => {
|
||||
const rendered = execFileSync(process.env.PHP_BINARY || 'php', [
|
||||
path.join(__dirname, 'vip-points-exchange-description-harness.php'),
|
||||
], { encoding: 'utf8' });
|
||||
assert.match(rendered, /class="muted-box muted-2-color padding-10 em09 mt20 aksrt-vip-points-desc"><p>后台配置的兑换说明<\/p><\/div>/);
|
||||
assert.ok(rendered.indexOf('aksrt-vip-points-desc') < rendered.indexOf('data-aksrt-vip-points-submit'));
|
||||
});
|
||||
|
||||
test('弹窗壳静止态必须被 vip-modal.css 显式隐藏(.flex 覆盖回归护栏)', () => {
|
||||
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s*\{\s*display:\s*none;/);
|
||||
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\.in\s*\{\s*display:\s*flex;/);
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* VIP 后台设置结构化契约(对照参考主题 zibll 1:1)。
|
||||
*
|
||||
* 硬约束:
|
||||
* 1) 会员商品 / 续费商品 / 升级价格 / 积分兑换明细全部为结构化 group 或
|
||||
* accordion,`|` 分隔的 textarea 配置已彻底移除(不写双路/迁移);
|
||||
* 2) 「会员等级」组只留导航按钮与两个等级开关,等级身份字段(显示名称/
|
||||
* 简介/权益HTML/图标URL)与「会员商品」同处「会员购买选项」的等级 Tab;
|
||||
* 3) 会员商品字段集恰好 6 项:执行价/原价/促销标签/标签颜色/会员有效时间/
|
||||
* 时长单位;标签颜色为参考主题 b+jb 全量 16 色;
|
||||
* 4) 续费价格类型含「自定义商品」,升级价格为参考主题同款三块 accordion;
|
||||
* 5) tag_class 一路透传到前端套餐卡;等级 Tab 标题由显示名称动态驱动。
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
|
||||
|
||||
const vipSchemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'vip.js');
|
||||
const schemaPhp = read('src', 'Admin', 'Schema', 'SchemaPayment.php');
|
||||
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
|
||||
const packagesTrait = read('src', 'Content', 'VipServicePackages.php');
|
||||
const purchaseSource = read('src', 'Content', 'VipServicePurchase.php');
|
||||
const modalSource = read('src', 'Content', 'VipServiceModal.php');
|
||||
const rendererVue = read('assets', 'admin', 'src', 'components', 'form', 'AksFormRenderer.vue');
|
||||
const fieldVue = read('assets', 'admin', 'src', 'components', 'form', 'AksField.vue');
|
||||
const payVipJs = read('assets', 'js', 'pay-vip.js');
|
||||
const vipTabPhp = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
|
||||
const homeCms = read('src', 'Settings', 'HomeCms.php');
|
||||
|
||||
const TAG_PALETTE = [
|
||||
'b-red', 'b-yellow', 'b-cyan', 'b-blue', 'b-green', 'b-purple', 'b-black',
|
||||
'jb-red', 'jb-pink', 'jb-yellow', 'jb-cyan', 'jb-blue', 'jb-green', 'jb-purple',
|
||||
'jb-vip1', 'jb-vip2',
|
||||
];
|
||||
const PRODUCT_FIELDS = ['price', 'show_price', 'tag', 'tag_class', 'time', 'unit'];
|
||||
const UPGRADE_FIELDS = [
|
||||
'month_to_month_s', 'unit_price', 'unit_tag', 'unit_tag_class',
|
||||
'jump_s', 'jump_price', 'jump_show_price', 'jump_tag', 'jump_tag_class',
|
||||
'permanent_price', 'permanent_show_price', 'permanent_tag', 'permanent_tag_class',
|
||||
];
|
||||
const LEGACY_KEYS = [
|
||||
'payment_vip_1_products',
|
||||
'payment_vip_2_products',
|
||||
'payment_vip_points_packages',
|
||||
];
|
||||
|
||||
function runHarness() {
|
||||
const out = execFileSync(process.env.PHP_BINARY || 'php', [
|
||||
path.join(__dirname, 'vip-settings-structured-harness.php'),
|
||||
], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
||||
return JSON.parse(out);
|
||||
}
|
||||
|
||||
/** 在 SPA schema 里按 key 递归查找字段定义。 */
|
||||
function findGroup(schema, key) {
|
||||
const walk = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.key === key) return node
|
||||
if (Array.isArray(node.tabs)) {
|
||||
for (const tab of node.tabs) {
|
||||
const hit = walk(tab.fields || [])
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.fields)) {
|
||||
const hit = walk(node.fields)
|
||||
if (hit) return hit
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return walk(schema.groups)
|
||||
}
|
||||
|
||||
// 只切 VIP 设置函数:充值/积分设置里的 products_raw 与本文无关。
|
||||
function vipSettingsSlice() {
|
||||
const start = paymentRuntime.indexOf('public static function get_payment_vip_settings');
|
||||
assert.notStrictEqual(start, -1, 'missing get_payment_vip_settings');
|
||||
const end = paymentRuntime.indexOf('public static function get_payment_revenue_settings', start);
|
||||
return paymentRuntime.slice(start, end === -1 ? undefined : end);
|
||||
}
|
||||
|
||||
let harness;
|
||||
test('PHP harness 可执行并产出结构化设置', () => {
|
||||
harness = runHarness();
|
||||
assert.ok(harness && harness.schema && harness.runtime, 'harness 输出不完整');
|
||||
});
|
||||
|
||||
test('旧 `|` 分隔配置键已从两份 schema 与运行时彻底移除', () => {
|
||||
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp], ['PaymentRuntime.php', paymentRuntime]]) {
|
||||
for (const key of LEGACY_KEYS) {
|
||||
assert.ok(!src.includes(key), `${name} 不应再含 ${key}`);
|
||||
}
|
||||
}
|
||||
// 运行时不再暴露任何 *_raw 原始字符串。
|
||||
const vipRuntime = vipSettingsSlice();
|
||||
for (const key of ['products_raw', 'packages_raw']) {
|
||||
assert.ok(!vipRuntime.includes(`'${key}'`), `PaymentRuntime VIP 设置不应再产出 ${key}`);
|
||||
}
|
||||
assert.ok(!vipRuntime.includes("'product' => sanitize_textarea_field"), 'upgrade.product 管道配置应已移除');
|
||||
for (const key of LEGACY_KEYS) {
|
||||
assert.ok(!vipRuntime.includes(key), `PaymentRuntime VIP 设置不应再读 ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('会员等级组只留导航按钮与等级开关,身份字段搬进等级 Tab', () => {
|
||||
const basic = harness.schema.basic_field_keys;
|
||||
assert.deepStrictEqual(basic, [
|
||||
'nav_pay_vip', 'nav_user_pay_vip', 'pay_user_vip_1_s', 'vip_level_order_notice', 'pay_user_vip_2_s',
|
||||
]);
|
||||
const tabs = harness.schema.opt_tabs;
|
||||
assert.deepStrictEqual(Object.keys(tabs), ['vip1', 'vip2', 'renew', 'upgrade']);
|
||||
for (const level of [1, 2]) {
|
||||
const fields = Object.keys(tabs[`vip${level}`].fields);
|
||||
assert.deepStrictEqual(fields, [
|
||||
`pay_user_vip_${level}_name`,
|
||||
`pay_user_vip_${level}_desc`,
|
||||
`pay_user_vip_${level}_equity`,
|
||||
`vip_${level}_product_s`,
|
||||
`vip_${level}_product`,
|
||||
`vip_${level}_img_icon`,
|
||||
]);
|
||||
assert.strictEqual(tabs[`vip${level}`].fields[`vip_${level}_img_icon`].type, 'upload');
|
||||
}
|
||||
});
|
||||
|
||||
test('等级 Tab 标题由显示名称动态驱动', () => {
|
||||
for (const level of [1, 2]) {
|
||||
const tab = harness.schema.opt_tabs[`vip${level}`];
|
||||
assert.strictEqual(tab.titleKey, `pay_user_vip_${level}_name`);
|
||||
assert.strictEqual(tab.titleFallback, level === 1 ? '一级会员' : '二级会员');
|
||||
}
|
||||
// 会员权益的两个等级 Tab 同样动态。
|
||||
assert.strictEqual(harness.schema.benefit_tabs.vip1.titleKey, 'pay_user_vip_1_name');
|
||||
assert.strictEqual(harness.schema.benefit_tabs.vip2.titleKey, 'pay_user_vip_2_name');
|
||||
// 渲染器支持 titleKey / titleFallback。
|
||||
assert.match(rendererVue, /:name="tabName\(tab\)"/);
|
||||
assert.match(rendererVue, /:tab="tabName\(tab\)"/);
|
||||
assert.match(rendererVue, /function tabName\(tab\) \{[\s\S]*?tab\.titleKey[\s\S]*?tab\.titleFallback/);
|
||||
// 手风琴:默认收起,点击标题行展开。
|
||||
assert.match(fieldVue, /const repeaterCollapsible = computed\(\(\) => !!props\.schema\.collapsible\)/);
|
||||
assert.match(fieldVue, /const repeaterDefaultOpen = computed\(\(\) => props\.schema\.defaultCollapsed === false\)/);
|
||||
assert.match(fieldVue, /function toggleRepeaterItem\(index\)/);
|
||||
assert.match(fieldVue, /v-show="!repeaterCollapsible \|\| isRepeaterItemOpen\(index\)"/);
|
||||
assert.match(fieldVue, /aksrt-field__repeater-summary/);
|
||||
// accordion/fieldset 的 sections 分块手风琴。
|
||||
assert.match(fieldVue, /const fieldsetSections = computed\(/);
|
||||
assert.match(fieldVue, /function toggleFieldsetSection\(index\)/);
|
||||
assert.match(fieldVue, /aksrt-field__accordion-section-head/);
|
||||
});
|
||||
|
||||
test('结构化分组默认收起,标题行带摘要;升级价格按三块手风琴渲染', () => {
|
||||
const accordion = harness.accordion;
|
||||
// 会员商品 / 续费商品 / 积分兑换明细均默认收起。
|
||||
const expectedGroups = [
|
||||
'vip1/vip_1_product',
|
||||
'vip2/vip_2_product',
|
||||
'renew/vip_1_renew_product',
|
||||
'renew/vip_2_renew_product',
|
||||
'points-exchange/pay_vip_points_exchange_product',
|
||||
];
|
||||
assert.deepStrictEqual(Object.keys(accordion.groups).sort(), [...expectedGroups].sort());
|
||||
for (const key of expectedGroups) {
|
||||
const cfg = accordion.groups[key];
|
||||
assert.strictEqual(cfg.collapsible, true, `${key} 应开启手风琴`);
|
||||
assert.strictEqual(cfg.defaultCollapsed, true, `${key} 应默认收起`);
|
||||
assert.ok(cfg.listPreview && cfg.listPreview.titleKey, `${key} 应配置标题行摘要`);
|
||||
assert.ok(cfg.listPreview.descKey, `${key} 应配置标题行时长摘要`);
|
||||
assert.strictEqual(cfg.listPreview.descUnitKey, 'unit', `${key} 时长摘要应带单位`);
|
||||
}
|
||||
// 会员商品与续费商品摘要前缀为「价格:」,积分兑换为「积分:」。
|
||||
assert.strictEqual(accordion.groups['vip1/vip_1_product'].listPreview.prefix, '价格:');
|
||||
assert.strictEqual(accordion.groups['renew/vip_1_renew_product'].listPreview.prefix, '价格:');
|
||||
assert.strictEqual(accordion.groups['points-exchange/pay_vip_points_exchange_product'].listPreview.prefix, '积分:');
|
||||
assert.strictEqual(accordion.groups['points-exchange/pay_vip_points_exchange_product'].listPreview.titleKey, 'points');
|
||||
|
||||
// 升级价格拆成三块,且每块覆盖的字段并集恰好等于全部字段。
|
||||
const sections = accordion.sections['upgrade/vip_upgrade_product'];
|
||||
assert.strictEqual(sections.length, 3);
|
||||
assert.deepStrictEqual(sections.map((s) => s.title), [
|
||||
'月费会员升级月费会员',
|
||||
'月费会员升级永久会员',
|
||||
'永久会员升级永久会员',
|
||||
]);
|
||||
const covered = sections.flatMap((s) => s.keys).sort();
|
||||
const declared = harness.schema.opt_tabs.upgrade.fields.vip_upgrade_product.fields
|
||||
.map((f) => f.key)
|
||||
.sort();
|
||||
assert.deepStrictEqual(covered, declared, 'sections 必须覆盖升级价格的全部字段');
|
||||
});
|
||||
|
||||
test('会员商品字段集恰好 6 项且标签颜色为参考主题 16 色', () => {
|
||||
const group = harness.schema.opt_tabs.vip1.fields.vip_1_product;
|
||||
assert.strictEqual(group.type, 'group');
|
||||
assert.strictEqual(group.max, 8);
|
||||
assert.strictEqual(group.button_title, '添加会员商品');
|
||||
assert.deepStrictEqual(
|
||||
group.fields.filter((f) => !f.ui_only).map((f) => f.key),
|
||||
PRODUCT_FIELDS
|
||||
);
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'price').sanitize_type, 'money_decimal');
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'show_price').sanitize_type, 'money_decimal');
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'time').type, 'spinner');
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'time').max, 3600);
|
||||
assert.deepStrictEqual(
|
||||
group.fields.find((f) => f.key === 'unit').options.map((o) => o.value),
|
||||
['day', 'month']
|
||||
);
|
||||
const palette = group.fields.find((f) => f.key === 'tag_class');
|
||||
assert.strictEqual(palette.type, 'palette');
|
||||
assert.strictEqual(palette.default, 'jb-yellow');
|
||||
assert.deepStrictEqual(palette.options.map((o) => o.value), TAG_PALETTE);
|
||||
// 标签颜色仅在填写了促销标签时出现。
|
||||
assert.deepStrictEqual(palette.dependsOn, { key: 'tag', operator: 'not_empty' });
|
||||
// 时长单位在 time 为 0(永久)时隐藏。
|
||||
assert.deepStrictEqual(group.fields.find((f) => f.key === 'unit').dependsOn, { key: 'time', operator: 'truthy' });
|
||||
// 二级会员 Tab 复用同一字段集。
|
||||
assert.deepStrictEqual(
|
||||
harness.schema.opt_tabs.vip2.fields.vip_2_product.fields.filter((f) => !f.ui_only).map((f) => f.key),
|
||||
PRODUCT_FIELDS
|
||||
);
|
||||
});
|
||||
|
||||
test('续费价格类型含自定义商品,折扣为折值滑块', () => {
|
||||
const renew = harness.schema.opt_tabs.renew.fields;
|
||||
assert.deepStrictEqual(renew.vip_renew_price_type.options.map((o) => o.value), [
|
||||
'same', 'discount', 'reduce', 'customize',
|
||||
]);
|
||||
const discount = renew.vip_renew_discount;
|
||||
assert.strictEqual(discount.type, 'slider');
|
||||
assert.strictEqual(discount.sanitize_type, 'float');
|
||||
assert.strictEqual(discount.min, 0.1);
|
||||
assert.strictEqual(discount.max, 9.9);
|
||||
assert.strictEqual(discount.step, 0.1);
|
||||
assert.strictEqual(renew.vip_renew_reduce.type, 'spinner');
|
||||
assert.strictEqual(renew.vip_renew_reduce.max, 500);
|
||||
assert.strictEqual(renew.vip_renew_reduce.step, 5);
|
||||
// 自定义续费商品组仅在 customize 时出现。
|
||||
for (const level of [1, 2]) {
|
||||
const group = renew[`vip_${level}_renew_product`];
|
||||
assert.strictEqual(group.type, 'group');
|
||||
assert.strictEqual(group.max, 8);
|
||||
assert.strictEqual(group.button_title, '添加续费商品');
|
||||
assert.deepStrictEqual(group.dependsOn, { key: 'vip_renew_price_type', value: 'customize' });
|
||||
assert.deepStrictEqual(
|
||||
group.fields.filter((f) => !f.ui_only).map((f) => f.key),
|
||||
PRODUCT_FIELDS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('升级价格为参考主题同款三块 accordion', () => {
|
||||
const upgrade = harness.schema.opt_tabs.upgrade.fields.vip_upgrade_product;
|
||||
assert.strictEqual(upgrade.type, 'accordion');
|
||||
const keys = upgrade.fields.filter((f) => !f.ui_only).map((f) => f.key);
|
||||
assert.deepStrictEqual(keys, UPGRADE_FIELDS);
|
||||
assert.strictEqual(upgrade.fields.find((f) => f.key === 'month_to_month_s').default, true);
|
||||
assert.strictEqual(upgrade.fields.find((f) => f.key === 'jump_s').default, false);
|
||||
for (const key of ['unit_price', 'jump_price', 'jump_show_price', 'permanent_price', 'permanent_show_price']) {
|
||||
assert.strictEqual(upgrade.fields.find((f) => f.key === key).sanitize_type, 'money_decimal', `${key} 应为 money_decimal`);
|
||||
}
|
||||
for (const key of ['unit_tag_class', 'jump_tag_class', 'permanent_tag_class']) {
|
||||
const field = upgrade.fields.find((f) => f.key === key);
|
||||
assert.strictEqual(field.type, 'palette', `${key} 应为 palette`);
|
||||
assert.deepStrictEqual(field.options.map((o) => o.value), TAG_PALETTE);
|
||||
}
|
||||
});
|
||||
|
||||
test('积分兑换明细为结构化 group,时长为 time + Permanent 语义', () => {
|
||||
assert.deepStrictEqual(harness.schema.points_fields, [
|
||||
'pay_vip_points_exchange_s', 'pay_vip_points_exchange_product', 'pay_vip_points_exchange_desc',
|
||||
]);
|
||||
const group = harness.schema.points_group;
|
||||
assert.strictEqual(group.type, 'group');
|
||||
assert.strictEqual(group.max, 16);
|
||||
assert.strictEqual(group.button_title, '添加积分兑换商品');
|
||||
assert.deepStrictEqual(group.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']);
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'points').type, 'number');
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'level').type, 'radio');
|
||||
assert.strictEqual(group.fields.find((f) => f.key === 'time').type, 'input');
|
||||
// 永久时隐藏时长单位。
|
||||
assert.deepStrictEqual(group.fields.find((f) => f.key === 'unit').dependsOn, { key: 'time', operator: 'neq', value: 'Permanent' });
|
||||
});
|
||||
|
||||
test('积分兑换明细.level 枚举净化后仍能通过校验,非法值依旧报错', () => {
|
||||
const { sanitize } = harness;
|
||||
|
||||
// level 的选项值是整数 1 / 2。默认两行提交数字即可保存,且净化后必须保持
|
||||
// 整数类型:前端 n-radio 用 === 比较保存值,字符串化会让单选框失活。
|
||||
assert.strictEqual(sanitize.default_rows.valid, true);
|
||||
assert.deepStrictEqual(sanitize.default_rows.errors, []);
|
||||
assert.deepStrictEqual(sanitize.default_rows.points_rows.map((r) => r.level), [1, 1]);
|
||||
|
||||
// SPA 提交字符串等级时,同样收敛回选项声明的整数。
|
||||
assert.strictEqual(sanitize.string_level.valid, true);
|
||||
assert.deepStrictEqual(sanitize.string_level.points_rows.map((r) => r.level), [2]);
|
||||
|
||||
// 收敛不得掩盖真正的非法值:level 与 unit 仍各自报出精确错误。
|
||||
assert.strictEqual(sanitize.invalid_level.valid, false);
|
||||
assert.deepStrictEqual(sanitize.invalid_level.errors, ['积分兑换明细[0].level has an invalid value']);
|
||||
assert.strictEqual(sanitize.invalid_unit.valid, false);
|
||||
assert.deepStrictEqual(sanitize.invalid_unit.errors, ['积分兑换明细[0].unit has an invalid value']);
|
||||
|
||||
// VIP's HTML-capable fields pass the schema save sanitizer intact.
|
||||
assert.strictEqual(sanitize.vip_html.valid, true);
|
||||
assert.strictEqual(sanitize.vip_html.fields.pay_user_vip_1_name, '<b>会员</b>');
|
||||
assert.strictEqual(sanitize.vip_html.fields.pay_user_vip_1_desc, '<em>简介</em>');
|
||||
assert.strictEqual(sanitize.vip_html.fields.pay_user_vip_1_equity, '<p>权益</p>');
|
||||
assert.strictEqual(sanitize.vip_html.fields.pay_user_vip_desc, '<span>会员介绍</span>');
|
||||
assert.strictEqual(sanitize.vip_html.fields.vip_renew_desc, '<strong>续费</strong>');
|
||||
assert.strictEqual(sanitize.vip_html.fields.vip_upgrade_desc, '<i>升级</i>');
|
||||
assert.strictEqual(sanitize.vip_html.fields.vip_1_product[0].tag, '<b>推荐</b>');
|
||||
|
||||
// 字符串型枚举(palette)不受影响。
|
||||
assert.strictEqual(sanitize.invalid_tag_class.valid, false);
|
||||
assert.deepStrictEqual(sanitize.invalid_tag_class.errors, ['会员商品[0].tag_class has an invalid value']);
|
||||
|
||||
|
||||
});
|
||||
|
||||
test('运行时归一化:默认值、价格净化、零价与非法行剔除', () => {
|
||||
const defaults = harness.runtime.defaults;
|
||||
// 参考主题默认两行。
|
||||
assert.strictEqual(defaults.levels['1'].products.length, 2);
|
||||
assert.strictEqual(defaults.levels['1'].products[0].price, '99.00');
|
||||
assert.strictEqual(defaults.levels['1'].products[0].time, 3);
|
||||
assert.strictEqual(defaults.levels['1'].products[0].unit, 'month');
|
||||
assert.strictEqual(defaults.levels['1'].products[0].days, 90);
|
||||
assert.strictEqual(defaults.levels['1'].renew_products[0].price, '69.00');
|
||||
assert.strictEqual(defaults.levels['2'].renew_products[0].price, '269.00');
|
||||
// 积分兑换默认两行(含 Permanent)。
|
||||
assert.strictEqual(defaults.points_exchange.packages.length, 2);
|
||||
assert.strictEqual(defaults.points_exchange.packages[1].is_permanent, true);
|
||||
assert.strictEqual(defaults.points_exchange.packages[1].days, 0);
|
||||
// 续费/升级默认值。
|
||||
assert.strictEqual(defaults.renew.price_type, 'discount');
|
||||
assert.strictEqual(defaults.renew.discount, 8);
|
||||
assert.strictEqual(defaults.upgrade.month_to_month_s, true);
|
||||
assert.strictEqual(defaults.upgrade.jump_s, false);
|
||||
assert.strictEqual(defaults.upgrade.unit_price, '10.00');
|
||||
assert.strictEqual(defaults.upgrade.unit_tag_class, 'jb-yellow');
|
||||
assert.strictEqual(defaults.upgrade.jump_tag_class, 'jb-red');
|
||||
|
||||
const custom = harness.runtime.custom;
|
||||
// 零价行被剔除;价格规范化为两位小数。
|
||||
assert.strictEqual(custom.levels['1'].products.length, 2);
|
||||
assert.strictEqual(custom.levels['1'].products[0].price, '88.80');
|
||||
assert.strictEqual(custom.levels['1'].products[0].tag_class, 'jb-green');
|
||||
// 非法标签颜色回落为空串。
|
||||
assert.strictEqual(custom.levels['2'].products[0].tag_class, '');
|
||||
// 自定义续费价格类型与折扣。
|
||||
assert.strictEqual(custom.renew.price_type, 'customize');
|
||||
assert.strictEqual(custom.renew.discount, 7.5);
|
||||
assert.strictEqual(custom.renew.desc, '<b>续费有礼</b>');
|
||||
assert.strictEqual(custom.upgrade.desc, '<i>升级王者</i>');
|
||||
assert.strictEqual(custom.levels['1'].name, '青铜会员');
|
||||
assert.strictEqual(custom.levels['1'].name_html, '<strong>青铜会员</strong>');
|
||||
assert.strictEqual(custom.levels['1'].desc, '<em>专属权益</em>');
|
||||
assert.strictEqual(custom.levels['1'].equity, '<p>一对一支持</p>');
|
||||
assert.strictEqual(custom.levels['1'].renew_products[0].tag_class, 'b-red');
|
||||
// 升级三块参数透传。
|
||||
assert.strictEqual(custom.upgrade.unit_price, '12.50');
|
||||
assert.strictEqual(custom.upgrade.unit_tag_class, 'jb-blue');
|
||||
assert.strictEqual(custom.upgrade.jump_s, true);
|
||||
assert.strictEqual(custom.upgrade.permanent_tag_class, 'jb-vip2');
|
||||
// 积分兑换:非法行(积分为 0 / 等级越界 / 时长为空)被剔除。
|
||||
assert.strictEqual(custom.points_exchange.packages.length, 3);
|
||||
assert.strictEqual(custom.points_exchange.desc, '<p>积分兑换说明</p>');
|
||||
assert.strictEqual(custom.points_exchange.packages[0].days, 30);
|
||||
assert.strictEqual(custom.points_exchange.packages[1].is_permanent, true);
|
||||
assert.strictEqual(custom.points_exchange.packages[2].days, 90);
|
||||
});
|
||||
|
||||
test('旧 `|` 管道存量值按新默认值回退,不会渲染成假条目', () => {
|
||||
const legacy = harness.runtime.legacy;
|
||||
assert.strictEqual(legacy.levels['1'].products.length, 2);
|
||||
assert.strictEqual(legacy.levels['1'].products[0].price, '99.00');
|
||||
assert.strictEqual(legacy.points_exchange.packages.length, 2);
|
||||
assert.strictEqual(legacy.upgrade.unit_price, '10.00');
|
||||
});
|
||||
|
||||
test('续费计价:折值/立减/自定义商品三种语义', () => {
|
||||
const p = harness.pricing;
|
||||
// 相同价格:不改价、不展示原价。
|
||||
assert.strictEqual(p.same.price, '100.00');
|
||||
assert.strictEqual(p.same.show_price, '0.00');
|
||||
// 8 折:执行价 80,原价划线为 100。
|
||||
assert.strictEqual(p.discount_8.price, '80.00');
|
||||
assert.strictEqual(p.discount_8.show_price, '100.00');
|
||||
assert.strictEqual(p.discount_8.tag, '8折优惠');
|
||||
// 支持小数折。
|
||||
assert.strictEqual(p.discount_7_5.price, '75.00');
|
||||
assert.strictEqual(p.discount_7_5.tag, '7.5折优惠');
|
||||
// 超出滑块上限时按 9.9 折钳制(参考主题 slider max)。
|
||||
assert.strictEqual(p.discount_10_clamped.price, '99.00');
|
||||
assert.strictEqual(p.discount_10_clamped.tag, '9.9折优惠');
|
||||
// 立减 20:执行价 80。
|
||||
assert.strictEqual(p.reduce_20.price, '80.00');
|
||||
assert.strictEqual(p.reduce_20.show_price, '100.00');
|
||||
assert.strictEqual(p.reduce_20.tag, '续费立减¥20.00');
|
||||
// 自定义商品:沿用商品自身的原价、促销标签与标签颜色。
|
||||
assert.strictEqual(p.customize.price, '100.00');
|
||||
assert.strictEqual(p.customize.show_price, '200.00');
|
||||
assert.strictEqual(p.customize.tag, '限时');
|
||||
assert.strictEqual(p.customize.tag_class, 'jb-red');
|
||||
// 开通动作不套用续费规则。
|
||||
assert.strictEqual(p.pay.price, '100.00');
|
||||
assert.strictEqual(p.pay.show_price, '0.00');
|
||||
assert.strictEqual(p.pay.tag, '');
|
||||
});
|
||||
|
||||
test('tag_class 透传到前端套餐卡与积分兑换弹窗', () => {
|
||||
assert.match(payVipJs, /item\.tag_class \|\| "jb-yellow"/);
|
||||
// 弹窗时长行按 time/unit 渲染,Permanent 走永久。
|
||||
assert.match(modalSource, /\$is_permanent = !empty\(\$package\['is_permanent'\]\)/);
|
||||
assert.match(modalSource, /\$pkg_unit === 'month' \? __\('个月', 'aksrt'\) : __\('天', 'aksrt'\)/);
|
||||
// 用户中心档位卡消费续费/升级简介。
|
||||
assert.match(vipTabPhp, /\$renew_desc = \(string\) \(\$vip_settings\['renew'\]\['desc'\]/);
|
||||
assert.match(vipTabPhp, /\$upgrade_desc = \(string\) \(\$vip_settings\['upgrade'\]\['desc'\]/);
|
||||
});
|
||||
|
||||
test('管道解析死代码已清理', () => {
|
||||
for (const [name, src] of [['VipServicePackages.php', packagesTrait], ['VipServicePurchase.php', purchaseSource]]) {
|
||||
assert.ok(!/function parse_level_products/.test(src), `${name} 不应再定义 parse_level_products`);
|
||||
assert.ok(!/function parse_packages\(/.test(src), `${name} 不应再定义 parse_packages`);
|
||||
assert.ok(!/function parse_upgrade_product_line/.test(src), `${name} 不应再定义 parse_upgrade_product_line`);
|
||||
assert.ok(!/preg_split\('\/\\r\\n\|\\r\|\\n\/'/.test(src), `${name} 不应再按行拆分管道配置`);
|
||||
}
|
||||
// HomeCms 的零调用 PaymentRuntime 代理方法已删除。
|
||||
for (const method of ['get_payment_vip_settings', 'get_payment_recharge_settings', 'get_withdrawal_settings']) {
|
||||
assert.ok(!homeCms.includes(`function ${method}`), `HomeCms 不应再含死代理 ${method}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('SPA 镜像 schema 与 PHP schema 同构', async () => {
|
||||
const mod = await import(pathToFileURL(path.join(root, 'assets/admin/src/schema/sections/payment/vip.js')).href);
|
||||
const schema = mod.vipSchema;
|
||||
assert.deepStrictEqual(schema.groups.map((g) => g.key), [
|
||||
'notice', 'basic', 'benefit', 'opt', 'points-exchange', 'modal-copy',
|
||||
]);
|
||||
const opt = schema.groups.find((g) => g.key === 'opt').fields[0];
|
||||
assert.strictEqual(opt.type, 'tabbed');
|
||||
assert.deepStrictEqual(opt.tabs.map((t) => t.key), ['vip1', 'vip2', 'renew', 'upgrade']);
|
||||
for (const level of [1, 2]) {
|
||||
const tab = opt.tabs[level - 1];
|
||||
assert.strictEqual(tab.titleKey, `pay_user_vip_${level}_name`);
|
||||
assert.deepStrictEqual(tab.fields.map((f) => f.key), [
|
||||
`pay_user_vip_${level}_name`,
|
||||
`pay_user_vip_${level}_desc`,
|
||||
`pay_user_vip_${level}_equity`,
|
||||
`vip_${level}_product_s`,
|
||||
`vip_${level}_product`,
|
||||
`vip_${level}_img_icon`,
|
||||
]);
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
opt.tabs[2].fields.find((f) => f.key === 'vip_renew_price_type').options.map((o) => o.value),
|
||||
['same', 'discount', 'reduce', 'customize']
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
opt.tabs[3].fields.find((f) => f.key === 'vip_upgrade_product').fields.filter((f) => !f.ui_only).map((f) => f.key),
|
||||
UPGRADE_FIELDS
|
||||
);
|
||||
const points = schema.groups.find((g) => g.key === 'points-exchange').fields[1];
|
||||
assert.strictEqual(points.type, 'group');
|
||||
assert.deepStrictEqual(points.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']);
|
||||
// 手风琴配置与 PHP 侧一致。
|
||||
for (const key of ['vip_1_product', 'vip_2_product', 'vip_1_renew_product', 'vip_2_renew_product']) {
|
||||
const group = findGroup(schema, key);
|
||||
assert.strictEqual(group.collapsible, true, `${key} 应开启手风琴`);
|
||||
assert.strictEqual(group.defaultCollapsed, true, `${key} 应默认收起`);
|
||||
}
|
||||
assert.strictEqual(findGroup(schema, 'pay_vip_points_exchange_product').collapsible, true);
|
||||
const upgrade = findGroup(schema, 'vip_upgrade_product');
|
||||
assert.strictEqual(upgrade.sections.length, 3);
|
||||
// SPA 默认值与 PHP 侧一致。
|
||||
assert.strictEqual(schema.defaults.vip_1_product[0].price, 99);
|
||||
assert.strictEqual(schema.defaults.vip_1_renew_product[0].price, 69);
|
||||
assert.strictEqual(schema.defaults.vip_2_renew_product[0].price, 269);
|
||||
assert.strictEqual(schema.defaults.vip_renew_discount, 8);
|
||||
assert.strictEqual(schema.defaults.vip_upgrade_product.jump_s, false);
|
||||
// 旧键不出现在 SPA schema。
|
||||
for (const key of LEGACY_KEYS) {
|
||||
assert.ok(!vipSchemaJs.includes(key), `vip.js 不应再含 ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('升级价格 sections 能展开成三块手风琴,且块内字段遵循 dependsOn', async () => {
|
||||
const { buildFieldsetSections } = await import(
|
||||
pathToFileURL(path.join(root, 'assets/admin/src/components/form/fieldset-sections.js')).href
|
||||
);
|
||||
const mod = await import(pathToFileURL(path.join(root, 'assets/admin/src/schema/sections/payment/vip.js')).href);
|
||||
const upgrade = findGroup(mod.vipSchema, 'vip_upgrade_product');
|
||||
|
||||
const sections = buildFieldsetSections(upgrade);
|
||||
assert.strictEqual(sections.length, 3, '升级价格应展开成三块');
|
||||
assert.deepStrictEqual(sections.map((s) => s.title), [
|
||||
'月费会员升级月费会员',
|
||||
'月费会员升级永久会员',
|
||||
'永久会员升级永久会员',
|
||||
]);
|
||||
// 每块的字段并集恰好等于声明的全部字段,且顺序与 schema.fields 一致。
|
||||
const expanded = sections.flatMap((s) => s.fields.map((f) => f.key));
|
||||
assert.deepStrictEqual(expanded, upgrade.fields.map((f) => f.key));
|
||||
// 第一块带「已设置为永久会员」之外的提示与开关,字段定义必须是真实对象而非占位。
|
||||
assert.ok(sections[0].fields.every((f) => f && typeof f === 'object' && f.key));
|
||||
|
||||
// 没有 sections 声明的 fieldset 保持平铺,不产生假块。
|
||||
assert.deepStrictEqual(buildFieldsetSections({ type: 'fieldset', fields: [{ key: 'a' }] }), []);
|
||||
assert.deepStrictEqual(buildFieldsetSections(undefined), []);
|
||||
|
||||
// 渲染层把 sections 交给纯函数,并对块内字段套用 dependsOn。
|
||||
assert.match(fieldVue, /const fieldsetSections = computed\(\(\) => \{[\s\S]*?buildFieldsetSections\(props\.schema\)/);
|
||||
assert.match(fieldVue, /function sectionFields\(section\) \{[\s\S]*?matchDependency\(field\.dependsOn, props\.modelValue\)/);
|
||||
// 结构化分组之间留出间隔,两个分组不会上下贴在一起。
|
||||
assert.match(fieldVue, /\.aksrt-field--group,\s*\.aksrt-field--repeater,\s*\.aksrt-field--accordion,\s*\.aksrt-field--fieldset \{[\s\S]*?margin-bottom: 26px;/);
|
||||
assert.match(fieldVue, /\.aksrt-field__accordion-section \+ \.aksrt-field__accordion-section \{[\s\S]*?margin-top: 14px;/);
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
/**
|
||||
* Executable VIP structured-settings contract harness.
|
||||
*
|
||||
* Builds the payment schema with WordPress stubs, normalizes a stored
|
||||
* `aksrt_settings` snapshot through PaymentRuntime, and prints the result as
|
||||
* JSON so the Node contract test can assert on the real shapes.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
define('DAY_IN_SECONDS', 86400);
|
||||
|
||||
function __(string $text, string $domain = ''): string { return $text; }
|
||||
function _x(string $text, string $context, string $domain = ''): string { return $text; }
|
||||
function esc_html($value): string { return (string) $value; }
|
||||
function esc_attr($value): string { return (string) $value; }
|
||||
function esc_url($value): string { return (string) $value; }
|
||||
function esc_url_raw($value): string { return trim((string) $value); }
|
||||
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
|
||||
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
|
||||
function sanitize_textarea_field($value): string { return trim((string) $value); }
|
||||
function wp_kses_post($value): string { return (string) $value; }
|
||||
function wp_strip_all_tags($value): string { return trim(strip_tags((string) $value)); }
|
||||
function wp_kses($value, $allowed = null): string { return (string) $value; }
|
||||
function trailingslashit($value): string { return rtrim((string) $value, '/\\') . '/'; }
|
||||
function get_template_directory_uri(): string { return 'https://example.test/wp-content/themes/aksrt'; }
|
||||
function absint($value): int { return max(0, (int) $value); }
|
||||
function wp_json_encode($value, int $flags = 0): string { return json_encode($value, $flags | JSON_UNESCAPED_SLASHES); }
|
||||
function is_multisite(): bool { return false; }
|
||||
function get_option(string $key, $default = false) { return $default; }
|
||||
function did_action(string $hook): int { return 0; }
|
||||
function apply_filters(string $hook, $value, ...$args) { return $value; }
|
||||
function add_action(...$args): void {}
|
||||
function add_filter(...$args): void {}
|
||||
}
|
||||
|
||||
namespace AKSRT\Settings {
|
||||
/** Test double: PaymentRuntime reads every raw settings key through this. */
|
||||
final class Settings {
|
||||
/** @var array<string,mixed> */
|
||||
public static array $snapshot = [];
|
||||
|
||||
public static function get_all(): array {
|
||||
return self::$snapshot;
|
||||
}
|
||||
|
||||
public static function get_paid_content_settings(): array {
|
||||
return ['display' => ['currency_symbol' => '¥']];
|
||||
}
|
||||
|
||||
public static function reset(): void {
|
||||
self::$snapshot = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require dirname(__DIR__, 2) . '/src/Ledger/AssetType.php';
|
||||
require dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php';
|
||||
require dirname(__DIR__, 2) . '/src/Settings/PaymentRuntime.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPayment.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
|
||||
require dirname(__DIR__, 2) . '/src/Content/VipServicePackages.php';
|
||||
|
||||
/** Exposes the trait's private renew-pricing math for contract assertions. */
|
||||
final class VipPricingProbe {
|
||||
use \AKSRT\Content\VipServicePackages;
|
||||
|
||||
/** @param array<string,mixed> $pkg */
|
||||
public function price(array $pkg, string $action, array $renew, int $days): ?array {
|
||||
return $this->apply_action_price($pkg, $action, $renew, [], 1, 1, 0, false, $days);
|
||||
}
|
||||
}
|
||||
|
||||
use AKSRT\Admin\Schema\SchemaPayment;
|
||||
use AKSRT\Settings\PaymentRuntime;
|
||||
use AKSRT\Settings\Settings;
|
||||
|
||||
$vip = SchemaPayment::build()['sections']['vip'];
|
||||
$groups = [];
|
||||
foreach ($vip['groups'] as $group) {
|
||||
$groups[$group['key']] = $group;
|
||||
}
|
||||
|
||||
$opt_tabs = [];
|
||||
foreach ($groups['opt']['fields'][0]['tabs'] as $tab) {
|
||||
$fields = [];
|
||||
foreach ($tab['fields'] as $field) {
|
||||
$fields[$field['key']] = $field;
|
||||
}
|
||||
$opt_tabs[$tab['key']] = [
|
||||
'title' => $tab['title'],
|
||||
'titleKey' => $tab['titleKey'] ?? null,
|
||||
'titleFallback' => $tab['titleFallback'] ?? null,
|
||||
'fields' => $fields,
|
||||
];
|
||||
}
|
||||
|
||||
$benefit_tabs = [];
|
||||
foreach ($groups['benefit']['fields'][0]['tabs'] as $tab) {
|
||||
$benefit_tabs[$tab['key']] = [
|
||||
'title' => $tab['title'],
|
||||
'titleKey' => $tab['titleKey'] ?? null,
|
||||
'titleFallback' => $tab['titleFallback'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// ---- runtime: defaults (nothing stored) -------------------------------
|
||||
Settings::reset();
|
||||
$defaults = PaymentRuntime::get_payment_vip_settings();
|
||||
|
||||
// ---- runtime: fully customised stored snapshot -------------------------
|
||||
Settings::$snapshot = [
|
||||
'pay_user_vip_1_s' => true,
|
||||
'pay_user_vip_2_s' => true,
|
||||
'pay_user_vip_1_name' => '<strong>青铜会员</strong>',
|
||||
'pay_user_vip_2_name' => '<span>王者会员</span>',
|
||||
'pay_user_vip_1_desc' => '<em>专属权益</em>',
|
||||
'pay_user_vip_1_equity' => '<p>一对一支持</p>',
|
||||
'vip_renew' => true,
|
||||
'vip_renew_desc' => '<b>续费有礼</b>',
|
||||
'vip_renew_price_type' => 'customize',
|
||||
'vip_renew_discount' => 7.5,
|
||||
'vip_renew_reduce' => '15.00',
|
||||
'vip_upgrade' => true,
|
||||
'vip_upgrade_desc' => '<i>升级王者</i>',
|
||||
'vip_upgrade_product' => [
|
||||
'month_to_month_s' => true,
|
||||
'unit_price' => '12.50',
|
||||
'unit_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 按天升级',
|
||||
'unit_tag_class' => 'jb-blue',
|
||||
'jump_s' => true,
|
||||
'jump_price' => '259.00',
|
||||
'jump_show_price' => '399.00',
|
||||
'jump_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 直升永久',
|
||||
'jump_tag_class' => 'jb-red',
|
||||
'permanent_price' => '159.00',
|
||||
'permanent_show_price' => '259.00',
|
||||
'permanent_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 王者特惠',
|
||||
'permanent_tag_class' => 'jb-vip2',
|
||||
],
|
||||
'vip_1_product' => [
|
||||
['price' => '88.80', 'show_price' => '188.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 新客', 'tag_class' => 'jb-green', 'time' => 1, 'unit' => 'month'],
|
||||
['price' => '0', 'show_price' => '', 'tag' => '', 'time' => 0, 'unit' => 'day'],
|
||||
['price' => '10', 'show_price' => '20', 'tag' => '', 'time' => 2, 'unit' => 'month'],
|
||||
],
|
||||
'vip_2_product' => [
|
||||
['price' => '300', 'show_price' => '600', 'tag' => 'x', 'tag_class' => 'not-a-real-class', 'time' => 12, 'unit' => 'month'],
|
||||
],
|
||||
'vip_1_renew_product' => [
|
||||
['price' => '60', 'show_price' => '99', 'tag' => '续费专享', 'tag_class' => 'b-red', 'time' => 1, 'unit' => 'month'],
|
||||
],
|
||||
'vip_2_renew_product' => [],
|
||||
'pay_vip_points_exchange_s' => true,
|
||||
'pay_vip_points_exchange_desc' => '<p>积分兑换说明</p>',
|
||||
'pay_vip_points_exchange_product' => [
|
||||
['points' => 800, 'level' => 1, 'time' => 30, 'unit' => 'day'],
|
||||
['points' => 9000, 'level' => 2, 'time' => 'Permanent'],
|
||||
['points' => 0, 'level' => 1, 'time' => 10, 'unit' => 'day'],
|
||||
['points' => 100, 'level' => 9, 'time' => 10, 'unit' => 'day'],
|
||||
['points' => 100, 'level' => 1, 'time' => '', 'unit' => 'day'],
|
||||
['points' => 120, 'level' => 2, 'time' => 3, 'unit' => 'month'],
|
||||
],
|
||||
];
|
||||
$custom = PaymentRuntime::get_payment_vip_settings();
|
||||
|
||||
// ---- legacy scalar payloads must fall back to defaults -----------------
|
||||
Settings::$snapshot = [
|
||||
'pay_user_vip_1_s' => true,
|
||||
'vip_1_product' => "30|59|1|month|限时|月卡",
|
||||
'pay_vip_points_exchange_product' => "1|500|30|积分月卡",
|
||||
'vip_upgrade_product' => "unit|1|按剩余天数升级",
|
||||
];
|
||||
$legacy = PaymentRuntime::get_payment_vip_settings();
|
||||
|
||||
// ---- sanitize -> validate round-trip -----------------------------------
|
||||
// 复现并锁定:积分兑换明细.level 的选项值为整数,而 SPA 提交的可能是数字
|
||||
// 或字符串;净化必须把值收敛回选项声明的类型,否则严格 in_array 校验会以
|
||||
// "has an invalid value" 拒绝一次完全合法的保存。
|
||||
$sanitize = (static function (): array {
|
||||
$provider = \AKSRT\Admin\SchemaProvider::get_instance();
|
||||
$cases = [
|
||||
'vip_html' => [
|
||||
'pay_user_vip_1_name' => '<b>会员</b>',
|
||||
'pay_user_vip_1_desc' => '<em>简介</em>',
|
||||
'pay_user_vip_1_equity' => '<p>权益</p>',
|
||||
'pay_user_vip_desc' => '<span>会员介绍</span>',
|
||||
'vip_renew_desc' => '<strong>续费</strong>',
|
||||
'vip_upgrade_desc' => '<i>升级</i>',
|
||||
'vip_1_product' => [
|
||||
['price' => '88.80', 'show_price' => '99.00', 'tag' => '<b>推荐</b>', 'tag_class' => 'jb-yellow', 'time' => 1, 'unit' => 'month'],
|
||||
],
|
||||
],
|
||||
'default_rows' => [
|
||||
'pay_vip_points_exchange_s' => true,
|
||||
'pay_vip_points_exchange_product' => [
|
||||
['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'day'],
|
||||
['points' => 5000, 'level' => 1, 'time' => 'Permanent'],
|
||||
],
|
||||
],
|
||||
'string_level' => [
|
||||
'pay_vip_points_exchange_s' => true,
|
||||
'pay_vip_points_exchange_product' => [
|
||||
['points' => 800, 'level' => '2', 'time' => 3, 'unit' => 'month'],
|
||||
],
|
||||
],
|
||||
'invalid_level' => [
|
||||
'pay_vip_points_exchange_s' => true,
|
||||
'pay_vip_points_exchange_product' => [
|
||||
['points' => 500, 'level' => 9, 'time' => 30, 'unit' => 'day'],
|
||||
],
|
||||
],
|
||||
'invalid_unit' => [
|
||||
'pay_vip_points_exchange_s' => true,
|
||||
'pay_vip_points_exchange_product' => [
|
||||
['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'year'],
|
||||
],
|
||||
],
|
||||
'invalid_tag_class' => [
|
||||
'vip_1_product' => [
|
||||
['price' => '88.80', 'show_price' => '99', 'tag' => 'x', 'tag_class' => 'nope', 'time' => 1, 'unit' => 'month'],
|
||||
],
|
||||
],
|
||||
];
|
||||
$out = [];
|
||||
foreach ($cases as $name => $payload) {
|
||||
$clean = $provider->sanitize_payload('payment', $payload, 'vip');
|
||||
$result = $provider->validate('payment', $clean, 'vip');
|
||||
$out[$name] = [
|
||||
'fields' => $clean,
|
||||
'points_rows' => $clean['pay_vip_points_exchange_product'] ?? null,
|
||||
'valid' => $result['valid'],
|
||||
'errors' => $result['errors'],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
})();
|
||||
|
||||
echo wp_json_encode([
|
||||
'schema' => [
|
||||
'version' => $vip['version'],
|
||||
'group_keys' => array_column($vip['groups'], 'key'),
|
||||
'basic_field_keys' => array_column($groups['basic']['fields'], 'key'),
|
||||
'benefit_tabs' => $benefit_tabs,
|
||||
'opt_tabs' => $opt_tabs,
|
||||
'points_fields' => array_column($groups['points-exchange']['fields'], 'key'),
|
||||
'points_group' => $groups['points-exchange']['fields'][1],
|
||||
'tag_palette' => $opt_tabs['vip1']['fields']['vip_1_product']['fields'][3]['options'],
|
||||
],
|
||||
'runtime' => [
|
||||
'defaults' => $defaults,
|
||||
'custom' => $custom,
|
||||
'legacy' => $legacy,
|
||||
],
|
||||
'sanitize' => $sanitize,
|
||||
'accordion' => (static function (array $groups): array {
|
||||
$out = ['groups' => [], 'sections' => []];
|
||||
foreach ($groups as $group) {
|
||||
foreach ($group['fields'] as $top) {
|
||||
if (($top['type'] ?? '') !== 'tabbed') {
|
||||
continue;
|
||||
}
|
||||
foreach ($top['tabs'] as $tab) {
|
||||
foreach ($tab['fields'] as $field) {
|
||||
if (($field['type'] ?? '') === 'group') {
|
||||
$out['groups'][$tab['key'] . '/' . $field['key']] = [
|
||||
'collapsible' => $field['collapsible'] ?? null,
|
||||
'defaultCollapsed' => $field['defaultCollapsed'] ?? null,
|
||||
'listPreview' => $field['listPreview'] ?? null,
|
||||
];
|
||||
}
|
||||
if (($field['type'] ?? '') === 'accordion') {
|
||||
$out['sections'][$tab['key'] . '/' . $field['key']] = array_map(
|
||||
static fn(array $section): array => [
|
||||
'title' => $section['title'],
|
||||
'keys' => $section['keys'],
|
||||
],
|
||||
$field['sections'] ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($groups as $group) {
|
||||
if ($group['key'] !== 'points-exchange') {
|
||||
continue;
|
||||
}
|
||||
foreach ($group['fields'] as $field) {
|
||||
if (($field['type'] ?? '') !== 'group') {
|
||||
continue;
|
||||
}
|
||||
$out['groups']['points-exchange/' . $field['key']] = [
|
||||
'collapsible' => $field['collapsible'] ?? null,
|
||||
'defaultCollapsed' => $field['defaultCollapsed'] ?? null,
|
||||
'listPreview' => $field['listPreview'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
})($groups),
|
||||
'pricing' => (static function (): array {
|
||||
$probe = new VipPricingProbe();
|
||||
$base = [
|
||||
'price' => '100.00', 'show_price' => '200.00', 'tag' => '限时',
|
||||
'tag_class' => 'jb-red', 'time' => 1, 'unit' => 'month',
|
||||
];
|
||||
return [
|
||||
'same' => $probe->price($base, 'renew', ['price_type' => 'same'], 30),
|
||||
'discount_8' => $probe->price($base, 'renew', ['price_type' => 'discount', 'discount' => 8], 30),
|
||||
'discount_7_5' => $probe->price($base, 'renew', ['price_type' => 'discount', 'discount' => 7.5], 30),
|
||||
'discount_10_clamped' => $probe->price($base, 'renew', ['price_type' => 'discount', 'discount' => 10], 30),
|
||||
'reduce_20' => $probe->price($base, 'renew', ['price_type' => 'reduce', 'reduce' => '20.00'], 30),
|
||||
'customize' => $probe->price($base, 'renew', ['price_type' => 'customize'], 30),
|
||||
'pay' => $probe->price($base, 'pay', ['price_type' => 'discount'], 30),
|
||||
];
|
||||
})(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 用户中心 VIP 页状态卡契约(inc/helpers/user-center-renderers/vip-tab.php)。
|
||||
*
|
||||
* 硬约束:
|
||||
* 1) 未开通用户不渲染到期时间行——状态标签已显示「未开通」,参考主题
|
||||
* zibpay_user_page_tab_content_vip 对从未开通用户同样不输出任何到期
|
||||
* 信息(仅生效中/永久/已过期才有);
|
||||
* 2) 生效中、永久、已过期三个分支的到期文案保持产出;
|
||||
* 3) 到期行整体按文案非空条件渲染,不留空壳 div。
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
|
||||
|
||||
const vipTab = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
|
||||
|
||||
test('未开通用户不渲染到期时间行', () => {
|
||||
// 未开通初始化不再产出「到期时间:未开通」文案。
|
||||
assert.ok(!vipTab.includes('到期时间:未开通'), '未开通不应再产出到期文案');
|
||||
assert.match(vipTab, /\$vip_expiry_text\s*=\s*'';/);
|
||||
// 到期行按文案非空条件渲染。
|
||||
assert.match(vipTab, /\$vip_expiry_text !== '' \? '<div class="uc-vip-status__expiry">/);
|
||||
});
|
||||
|
||||
test('生效中/永久/已过期分支仍产出到期文案', () => {
|
||||
assert.match(vipTab, /到期时间:永久会员/);
|
||||
assert.match(vipTab, /到期时间:%s/);
|
||||
assert.match(vipTab, /到期时间:已过期/);
|
||||
// 三个分支都会给 $vip_expiry_text 赋值。
|
||||
const assignments = vipTab.match(/\$vip_expiry_text\s*=/g) || [];
|
||||
assert.ok(assignments.length >= 4, '初始化 + 永久 + 生效中 + 已过期均应赋值');
|
||||
});
|
||||
|
||||
test('未开通状态名称前不渲染图标位', () => {
|
||||
// 图标位仅在有等级(生效中/永久/已过期)时输出;未开通(level 0)不输出,
|
||||
// 避免「未开通会员」前面挂通用证书图标。
|
||||
assert.match(vipTab, /\$vip_level_number > 0 \? '<span class="uc-vip-status__icon"/);
|
||||
assert.ok(!vipTab.includes(". '<span class=\"uc-vip-status__icon\" aria-hidden=\"true\">' . \$status_icon"), '图标位不应再无条件渲染');
|
||||
});
|
||||
Reference in new issue
Block a user