feat(vip): collapse the structured membership groups into accordions
The structured groups introduced in the previous commit rendered every row fully expanded, which buried the fields an admin actually came to edit. They now behave like accordions and start collapsed. - 会员商品, 一级/二级会员续费 and 积分兑换明细 opt in via `collapsible` on the group schema. Each row shows only its header, carrying a summary line built from `listPreview` (价格:99.00 · 3 个月 / 积分:500 · 30 天), and expands on click. `defaultCollapsed` keeps the collapsed default; setting it to false restores the previous always-open behaviour. - 升级价格 splits into its three reference-theme blocks (月费升月费 / 月费升永久 / 永久升永久) through a new `sections` option on the accordion type, each block independently collapsible. - AksField keeps the existing open state keyed by index so adding or removing a row does not disturb the others, and the drag handle, delete button and chevron are excluded from the header toggle. - Non-collapsible groups are untouched: the accordion markup only renders when the schema opts in. Verified: php -l on the changed PHP, 656 Node contract tests passing (including new assertions that every collapsible group is opted in, that the upgrade sections cover exactly the declared fields, and that the SPA mirror matches), and the admin SPA builds.
This commit is contained in:
1 parent
af8dccdc45
commit
05f16d5fd7
5 files changed
+411
-21
No files matched your search
@@ -476,14 +476,43 @@
|
|||||||
|
|
||||||
<!-- Fieldset (single nested object) -->
|
<!-- Fieldset (single nested object) -->
|
||||||
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
|
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
|
||||||
<AksField
|
<template v-if="fieldsetSections.length">
|
||||||
v-for="field in visibleNestedFields"
|
<div
|
||||||
:key="field.key"
|
v-for="(section, sIndex) in fieldsetSections"
|
||||||
:schema="field"
|
:key="section.title"
|
||||||
:model-value="fieldsetValue(field)"
|
class="aksrt-field__accordion-section"
|
||||||
:form-data="formData"
|
:class="{ 'is-collapsed': !isFieldsetSectionOpen(sIndex) }"
|
||||||
@update:model-value="updateFieldsetValue(field.key, $event)"
|
>
|
||||||
/>
|
<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 section.fields"
|
||||||
|
: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>
|
||||||
|
|
||||||
<div v-else-if="schema.type === 'link'" class="aksrt-field__link">
|
<div v-else-if="schema.type === 'link'" class="aksrt-field__link">
|
||||||
@@ -517,23 +546,38 @@
|
|||||||
animation="180"
|
animation="180"
|
||||||
>
|
>
|
||||||
<template #item="{ element, index }">
|
<template #item="{ element, index }">
|
||||||
<div class="aksrt-field__repeater-item">
|
<div class="aksrt-field__repeater-item" :class="{ 'is-collapsible': repeaterCollapsible, 'is-collapsed': repeaterCollapsible && !isRepeaterItemOpen(index) }">
|
||||||
<div class="aksrt-field__repeater-header">
|
<div
|
||||||
|
class="aksrt-field__repeater-header"
|
||||||
|
:class="{ 'is-clickable': repeaterCollapsible }"
|
||||||
|
@click="repeaterCollapsible ? toggleRepeaterItem(index) : undefined"
|
||||||
|
>
|
||||||
<div class="aksrt-field__repeater-title">
|
<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 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>
|
</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>
|
||||||
<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">
|
<template v-for="field in (schema.fields || [])" :key="field.key">
|
||||||
<AksField
|
<AksField
|
||||||
v-if="matchDependency(field.dependsOn, element)"
|
v-if="matchDependency(field.dependsOn, element)"
|
||||||
@@ -730,6 +774,60 @@ const repeaterItems = computed(() => Array.isArray(props.modelValue) ? props.mod
|
|||||||
const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0))
|
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))
|
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)。 */
|
/** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */
|
||||||
const repeaterDragList = computed({
|
const repeaterDragList = computed({
|
||||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
|
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
|
||||||
@@ -762,6 +860,41 @@ function fieldsetValue(field) {
|
|||||||
return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default
|
return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* accordion/fieldset 的分块手风琴:schema.sections 声明每块的标题与字段
|
||||||
|
* key,默认全部收起,点击块标题展开。未声明 sections 时保持平铺渲染。
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
const fieldsetSections = computed(() => {
|
||||||
|
const sections = Array.isArray(props.schema.sections) ? props.schema : []
|
||||||
|
if (!sections.length) return []
|
||||||
|
const byKey = {}
|
||||||
|
for (const field of (props.schema.fields || [])) {
|
||||||
|
byKey[field.key] = field
|
||||||
|
}
|
||||||
|
return sections.map((section) => ({
|
||||||
|
title: String(section.title || ''),
|
||||||
|
fields: (Array.isArray(section.keys) ? section.keys : [])
|
||||||
|
.map((key) => byKey[key])
|
||||||
|
.filter(Boolean),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const openFieldsetSections = ref(new Set())
|
||||||
|
|
||||||
|
function isFieldsetSectionOpen(index) {
|
||||||
|
return openFieldsetSections.value.has(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function updateFieldsetValue(key, value) {
|
||||||
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
||||||
? props.modelValue
|
? props.modelValue
|
||||||
@@ -1451,6 +1584,48 @@ function confirmRepeaterModal() {
|
|||||||
background: var(--aksrt-color-canvas);
|
background: var(--aksrt-color-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aksrt-field__accordion-section + .aksrt-field__accordion-section {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.aksrt-field__repeater {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1479,6 +1654,47 @@ function confirmRepeaterModal() {
|
|||||||
min-width: 0;
|
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 {
|
.aksrt-field__repeater-handle {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
color: var(--aksrt-color-text-tertiary);
|
color: var(--aksrt-color-text-tertiary);
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ const vipUnitOptions = [
|
|||||||
{ label: '月', value: 'month' },
|
{ 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)。
|
* 会员商品字段集(参考主题 CFS_Module::vip_product)。
|
||||||
*/
|
*/
|
||||||
@@ -69,6 +83,7 @@ function vipLevelTabFields(level) {
|
|||||||
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
|
{ 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' },
|
{ 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 }] },
|
dependsOn: { all: [dep, { key: `vip_${level}_product_s`, value: true }] },
|
||||||
help: '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
|
help: '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
|
||||||
fields: vipProductFields(),
|
fields: vipProductFields(),
|
||||||
@@ -331,6 +346,7 @@ export const vipSchema = {
|
|||||||
],
|
],
|
||||||
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
|
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
|
||||||
help: '一级会员续费的商品选项',
|
help: '一级会员续费的商品选项',
|
||||||
|
...vipProductGroupOptions,
|
||||||
fields: vipProductFields(),
|
fields: vipProductFields(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -345,6 +361,7 @@ export const vipSchema = {
|
|||||||
],
|
],
|
||||||
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
|
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
|
||||||
help: '二级会员续费的商品选项',
|
help: '二级会员续费的商品选项',
|
||||||
|
...vipProductGroupOptions,
|
||||||
fields: vipProductFields(),
|
fields: vipProductFields(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -376,6 +393,11 @@ export const vipSchema = {
|
|||||||
},
|
},
|
||||||
dependsOn: { key: 'vip_upgrade', value: true },
|
dependsOn: { key: 'vip_upgrade', value: true },
|
||||||
help: '会员升级的商品选项',
|
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: [
|
fields: [
|
||||||
{ key: '_upgrade_unit_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '月费会员升级为月费会员,用户会员有效期不会改变;价格按照会员剩余天数计算升级价格,请在下方设置每天的单价' },
|
{ key: '_upgrade_unit_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '月费会员升级为月费会员,用户会员有效期不会改变;价格按照会员剩余天数计算升级价格,请在下方设置每天的单价' },
|
||||||
{ key: 'month_to_month_s', label: '月费会员升级月费会员', type: 'switch', default: true },
|
{ key: 'month_to_month_s', label: '月费会员升级月费会员', type: 'switch', default: true },
|
||||||
@@ -418,6 +440,7 @@ export const vipSchema = {
|
|||||||
],
|
],
|
||||||
dependsOn: { key: 'pay_vip_points_exchange_s', value: true },
|
dependsOn: { key: 'pay_vip_points_exchange_s', value: true },
|
||||||
help: '每一行为一个可兑换的会员套餐',
|
help: '每一行为一个可兑换的会员套餐',
|
||||||
|
...vipPointsGroupOptions,
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'points', label: '使用积分', type: 'number', default: 100, min: 0, unit: '积分' },
|
{ key: 'points', label: '使用积分', type: 'number', default: 100, min: 0, unit: '积分' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ class SchemaPayment {
|
|||||||
],
|
],
|
||||||
'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'customize'],
|
'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'customize'],
|
||||||
'help' => '一级会员续费的商品选项',
|
'help' => '一级会员续费的商品选项',
|
||||||
|
...self::vip_product_group_options(),
|
||||||
'fields' => self::vip_product_fields(),
|
'fields' => self::vip_product_fields(),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -240,6 +241,7 @@ class SchemaPayment {
|
|||||||
],
|
],
|
||||||
'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'customize'],
|
'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'customize'],
|
||||||
'help' => '二级会员续费的商品选项',
|
'help' => '二级会员续费的商品选项',
|
||||||
|
...self::vip_product_group_options(),
|
||||||
'fields' => self::vip_product_fields(),
|
'fields' => self::vip_product_fields(),
|
||||||
],
|
],
|
||||||
]],
|
]],
|
||||||
@@ -254,6 +256,11 @@ class SchemaPayment {
|
|||||||
],
|
],
|
||||||
'dependsOn' => ['key' => 'vip_upgrade', 'value' => true],
|
'dependsOn' => ['key' => 'vip_upgrade', 'value' => true],
|
||||||
'help' => '会员升级的商品选项',
|
'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' => [
|
'fields' => [
|
||||||
['key' => '_upgrade_unit_notice', 'label' => '', 'type' => 'notice', 'ui_only' => true, 'alertType' => 'warning', 'content' => '月费会员升级为月费会员,用户会员有效期不会改变;价格按照会员剩余天数计算升级价格,请在下方设置每天的单价'],
|
['key' => '_upgrade_unit_notice', 'label' => '', 'type' => 'notice', 'ui_only' => true, 'alertType' => 'warning', 'content' => '月费会员升级为月费会员,用户会员有效期不会改变;价格按照会员剩余天数计算升级价格,请在下方设置每天的单价'],
|
||||||
['key' => 'month_to_month_s', 'label' => '月费会员升级月费会员', 'type' => 'switch', 'default' => true],
|
['key' => 'month_to_month_s', 'label' => '月费会员升级月费会员', 'type' => 'switch', 'default' => true],
|
||||||
@@ -285,6 +292,9 @@ class SchemaPayment {
|
|||||||
],
|
],
|
||||||
'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true],
|
'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true],
|
||||||
'help' => '每一行为一个可兑换的会员套餐',
|
'help' => '每一行为一个可兑换的会员套餐',
|
||||||
|
'collapsible' => true,
|
||||||
|
'defaultCollapsed' => true,
|
||||||
|
'listPreview' => ['prefix' => '积分:', 'titleKey' => 'points', 'descKey' => 'time', 'descUnitKey' => 'unit'],
|
||||||
'fields' => [
|
'fields' => [
|
||||||
['key' => 'points', 'label' => '使用积分', 'type' => 'number', 'default' => 100, 'min' => 0, 'unit' => '积分'],
|
['key' => 'points', 'label' => '使用积分', 'type' => 'number', 'default' => 100, 'min' => 0, 'unit' => '积分'],
|
||||||
['key' => 'level', 'label' => '兑换会员', 'type' => 'radio', 'default' => 1, 'inline' => true, 'options' => [
|
['key' => 'level', 'label' => '兑换会员', 'type' => 'radio', 'default' => 1, 'inline' => true, 'options' => [
|
||||||
@@ -591,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 种)。
|
* PayPal / Stripe 结算货币选项(参考主题同款 26 种)。
|
||||||
*
|
*
|
||||||
@@ -668,6 +691,7 @@ class SchemaPayment {
|
|||||||
[
|
[
|
||||||
'key' => 'vip_' . $level . '_product', 'label' => '会员商品', 'type' => 'group', 'max' => 8,
|
'key' => 'vip_' . $level . '_product', 'label' => '会员商品', 'type' => 'group', 'max' => 8,
|
||||||
'button_title' => '添加会员商品', 'default' => self::vip_product_defaults(),
|
'button_title' => '添加会员商品', 'default' => self::vip_product_defaults(),
|
||||||
|
...self::vip_product_group_options(),
|
||||||
'dependsOn' => ['all' => [$dep, ['key' => 'vip_' . $level . '_product_s', 'value' => true]]],
|
'dependsOn' => ['all' => [$dep, ['key' => 'vip_' . $level . '_product_s', 'value' => true]]],
|
||||||
'help' => '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
|
'help' => '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
|
||||||
'fields' => self::vip_product_fields(),
|
'fields' => self::vip_product_fields(),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const packagesTrait = read('src', 'Content', 'VipServicePackages.php');
|
|||||||
const purchaseSource = read('src', 'Content', 'VipServicePurchase.php');
|
const purchaseSource = read('src', 'Content', 'VipServicePurchase.php');
|
||||||
const modalSource = read('src', 'Content', 'VipServiceModal.php');
|
const modalSource = read('src', 'Content', 'VipServiceModal.php');
|
||||||
const rendererVue = read('assets', 'admin', 'src', 'components', 'form', 'AksFormRenderer.vue');
|
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 payVipJs = read('assets', 'js', 'pay-vip.js');
|
||||||
const vipTabPhp = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
|
const vipTabPhp = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
|
||||||
const homeCms = read('src', 'Settings', 'HomeCms.php');
|
const homeCms = read('src', 'Settings', 'HomeCms.php');
|
||||||
@@ -56,6 +57,27 @@ function runHarness() {
|
|||||||
return JSON.parse(out);
|
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 与本文无关。
|
// 只切 VIP 设置函数:充值/积分设置里的 products_raw 与本文无关。
|
||||||
function vipSettingsSlice() {
|
function vipSettingsSlice() {
|
||||||
const start = paymentRuntime.indexOf('public static function get_payment_vip_settings');
|
const start = paymentRuntime.indexOf('public static function get_payment_vip_settings');
|
||||||
@@ -121,6 +143,56 @@ test('等级 Tab 标题由显示名称动态驱动', () => {
|
|||||||
assert.match(rendererVue, /:name="tabName\(tab\)"/);
|
assert.match(rendererVue, /:name="tabName\(tab\)"/);
|
||||||
assert.match(rendererVue, /:tab="tabName\(tab\)"/);
|
assert.match(rendererVue, /:tab="tabName\(tab\)"/);
|
||||||
assert.match(rendererVue, /function tabName\(tab\) \{[\s\S]*?tab\.titleKey[\s\S]*?tab\.titleFallback/);
|
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 色', () => {
|
test('会员商品字段集恰好 6 项且标签颜色为参考主题 16 色', () => {
|
||||||
@@ -356,6 +428,15 @@ test('SPA 镜像 schema 与 PHP schema 同构', async () => {
|
|||||||
const points = schema.groups.find((g) => g.key === 'points-exchange').fields[1];
|
const points = schema.groups.find((g) => g.key === 'points-exchange').fields[1];
|
||||||
assert.strictEqual(points.type, 'group');
|
assert.strictEqual(points.type, 'group');
|
||||||
assert.deepStrictEqual(points.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']);
|
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 侧一致。
|
// SPA 默认值与 PHP 侧一致。
|
||||||
assert.strictEqual(schema.defaults.vip_1_product[0].price, 99);
|
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_1_renew_product[0].price, 69);
|
||||||
|
|||||||
@@ -187,6 +187,52 @@ namespace {
|
|||||||
'custom' => $custom,
|
'custom' => $custom,
|
||||||
'legacy' => $legacy,
|
'legacy' => $legacy,
|
||||||
],
|
],
|
||||||
|
'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 {
|
'pricing' => (static function (): array {
|
||||||
$probe = new VipPricingProbe();
|
$probe = new VipPricingProbe();
|
||||||
$base = [
|
$base = [
|
||||||
|
|||||||
Reference in new issue
Block a user