Files
wordpress-theme-aksrt/assets/admin/src/components/form/AksFormRenderer.vue
T
kate522 af8dccdc45 feat(vip): restructure membership settings into structured groups
Replace the pipe-delimited VIP configuration with structured groups and
accordions, matching the reference theme (Zibll) 1:1.

Settings:
- 会员等级 keeps only the nav buttons and the two level switches, plus the
  "must enable level 1 first" guard. Level identity fields (display name,
  intro, benefits HTML, icon) and 会员商品 move into the 一级会员/二级会员
  tabs of 会员购买选项, so each level is configured in one place.
- Level tab titles bind to the configured display name via a new
  titleKey/titleFallback hook in AksFormRenderer.
- 会员商品 is a repeater with exactly the reference fields: 执行价/原价/
  促销标签/标签颜色/会员有效时间/时长单位. The tag colour uses the full
  b+jb palette and now reaches the product card instead of a hardcoded
  jb-yellow.
- 会员续费 gains the 自定义商品 type backed by per-level renew-product
  groups; the discount becomes a 0.1-9.9 slider with float sanitizing.
- 会员升级 becomes the reference three-block accordion (month-to-month
  day-rate, jump to permanent, permanent to permanent), each with its own
  price, strikethrough price, promo tag and tag colour.
- 积分兑换明细 is a repeater (points/level/duration/unit) where the
  duration accepts the literal Permanent.

Runtime:
- PaymentRuntime normalizes the structured groups into purchasable rows
  (money normalization, palette validation, zero-price and out-of-range
  rows dropped) and no longer emits any *_raw strings.
- The catalog builder carries tag_class through and routes renewals to the
  renew-product group under 自定义商品.
- The points-exchange order note carries time/unit so month durations use
  calendar math instead of a 30-day approximation.

Dead code removed: parse_level_products, parse_packages,
parse_upgrade_product_line and their pipe-splitting, plus nine zero-call
PaymentRuntime proxy methods in HomeCms. No dual-format or migration path
was added; SchemaProvider already falls back to defaults when a stored
group value is not an array, so existing pipe strings degrade safely.

Verified: php -l on every changed PHP file, 655 Node contract tests passing
(14 new ones in vip-settings-structured-contract.test.cjs exercising the
real schema build and runtime normalization via a PHP harness), and the
admin SPA builds.
2026-10-18 17:14:00 +08:00

405 lines
13 KiB
Vue

<template>
<div class="aksrt-form-renderer">
<!-- Page header with title and description -->
<div v-if="title" class="aksrt-form-renderer__header">
<div class="aksrt-form-renderer__header-left">
<FaIcon v-if="icon" :icon="icon" :size="22" class="aksrt-form-renderer__header-icon" />
<div>
<h1 class="aksrt-form-renderer__title">{{ title }}</h1>
<p v-if="description" class="aksrt-form-renderer__desc">{{ description }}</p>
</div>
</div>
<div v-if="showSearch" class="aksrt-form-renderer__search">
<n-input
v-model:value="searchQuery"
placeholder="搜索设置项..."
size="small"
clearable
>
<template #prefix>
<FaIcon icon="fa fas fa-search" :size="12" />
</template>
</n-input>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="aksrt-form-renderer__loading">
<n-spin size="medium" />
<span>加载中...</span>
</div>
<!-- Error state -->
<n-alert
v-else-if="loadError"
type="error"
:title="'加载失败'"
:style="{ marginBottom: '16px' }"
>
{{ loadError }}
<template #footer>
<n-button size="small" @click="$emit('reload')">重试</n-button>
</template>
</n-alert>
<!-- Form content -->
<template v-else-if="schema && schema.length">
<template v-for="group in filteredSchema" :key="groupKey(group)">
<AksFieldGroup
v-if="isGroupVisible(group)"
:title="group.title"
:description="group.description"
:icon="group.icon"
:collapsible="!!group.collapsible"
:default-collapsed="resolveGroupCollapsed(group)"
>
<template v-if="group.fields && !hasGroupSlot(group)">
<template v-for="field in group.fields" :key="field.key">
<template v-if="isFieldVisible(field)">
<!-- Tabbed: renders tabs whose sub-fields are flat top-level keys -->
<div
v-if="field.type === 'tabbed' && (field.tabs || []).length"
class="aksrt-form-renderer__tabbed"
>
<n-tabs type="line" animated>
<n-tab-pane
v-for="tab in field.tabs"
:key="tab.key || tab.title"
:name="tabName(tab)"
:tab="tabName(tab)"
>
<template v-for="sub in (tab.fields || [])" :key="sub.key">
<AksField
v-if="isFieldVisible(sub)"
:schema="sub"
:model-value="getFieldValue(sub.key)"
:form-data="formData"
:error-message="errors?.[sub.key]"
@update:model-value="setFieldValue(sub.key, $event)"
@action="emit('field-action', $event)"
/>
</template>
</n-tab-pane>
</n-tabs>
</div>
<AksSorterField
v-else-if="field.type === 'custom' && field.renderer === 'sorter'"
:model-value="getFieldValue(field.key) || {}"
:options="field.options || []"
@update:model-value="setFieldValue(field.key, $event)"
/>
<slot
v-else-if="field.type === 'custom'"
:name="`field-${field.key}`"
v-bind="{ field, value: getFieldValue(field.key), setValue: (value) => setFieldValue(field.key, value), formData }"
>
<AksField
:schema="field"
:model-value="getFieldValue(field.key)"
:form-data="formData"
:error-message="errors?.[field.key]"
@update:model-value="setFieldValue(field.key, $event)"
@action="emit('field-action', $event)"
/>
</slot>
<AksField
v-else
:schema="field"
:model-value="getFieldValue(field.key)"
:form-data="formData"
:error-message="errors?.[field.key]"
@update:model-value="setFieldValue(field.key, $event)"
@action="emit('field-action', $event)"
/>
</template>
</template>
</template>
<!-- Custom group content slot -->
<slot :name="`group-${group.key}`" v-bind="{ group, formData }" />
</AksFieldGroup>
</template>
</template>
<!-- Empty state -->
<div v-else class="aksrt-form-renderer__empty">
<FaIcon icon="fa fas fa-cog" :size="32" />
<p>暂无配置项</p>
</div>
<!-- Sticky save bar -->
<AksSaveBar :dirty="dirty" :saving="saving" @save="$emit('save')" @reload="$emit('reload')" />
</div>
</template>
<script setup>
/**
* AksFormRenderer — the core schema-driven form engine.
*
* Takes a schema definition (array of field groups) and a reactive form data object,
* and renders a complete settings page with:
* - Grouped fields in card-style containers
* - Inline search/filter
* - Dirty state tracking with sticky save bar
* - Dependency-based field visibility
* - Loading and error states
*/
import { ref, computed, useSlots } from 'vue'
import FaIcon from './FaIcon.vue'
import AksSaveBar from './AksSaveBar.vue'
import AksFieldGroup from './AksFieldGroup.vue'
import AksField from './AksField.vue'
import AksSorterField from './AksSorterField.vue'
const props = defineProps({
/** Page title */
title: { type: String, default: '' },
/** Page description */
description: { type: String, default: '' },
/** Title icon (FA5 class) */
icon: { type: String, default: '' },
/**
* Schema definition: array of field groups.
* Each group: { key, title, description?, icon?, collapsible?, fields: FieldDef[] }
* Each field: { key, label, type, default, options?, dependsOn?, help?, ... }
*/
schema: { type: Array, default: () => [] },
/** Reactive form data object */
formData: { type: Object, default: () => ({}) },
/** Whether the form has unsaved changes */
dirty: { type: Boolean, default: false },
/** Whether data is currently loading */
loading: { type: Boolean, default: false },
/** Whether a save is in progress */
saving: { type: Boolean, default: false },
/** Load error message */
loadError: { type: String, default: '' },
/** Whether to show the search bar */
showSearch: { type: Boolean, default: true },
/** Validation errors keyed by field key */
errors: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:formData', 'save', 'reload', 'field-action'])
const slots = useSlots()
const searchQuery = ref('')
/** Evaluate dependsOn rule against current form data */
function matchDependsOn(dependsOn) {
if (!dependsOn) return true
if (typeof dependsOn === 'string') {
return !!props.formData[dependsOn]
}
if (typeof dependsOn === 'object') {
if (Array.isArray(dependsOn.all)) return dependsOn.all.every(matchDependsOn)
if (Array.isArray(dependsOn.any)) return dependsOn.any.some(matchDependsOn)
const { key, value, values, not, operator } = dependsOn
const currentVal = props.formData[key]
if (operator === 'truthy') return !!currentVal
if (operator === 'falsy') return !currentVal
if (operator === 'not_empty') return Array.isArray(currentVal) ? currentVal.length > 0 : currentVal !== '' && currentVal != null
if (operator === 'empty') return Array.isArray(currentVal) ? currentVal.length === 0 : currentVal === '' || currentVal == null
if (operator === 'eq') return currentVal === value
if (operator === 'neq') return currentVal !== value
if (not) {
if (Array.isArray(values)) {
// eslint-disable-next-line eqeqeq
return !values.some((v) => currentVal == v)
}
// eslint-disable-next-line eqeqeq
return currentVal !== value && currentVal != value
}
// values: match any of a list (e.g. provider ∈ [qq, polling])
if (Array.isArray(values)) {
// eslint-disable-next-line eqeqeq
return values.some((v) => currentVal == v)
}
// Loose equality so true matches 1 / '1' from some option stores.
// eslint-disable-next-line eqeqeq
return currentVal == value || (!!value === true && !!currentVal)
}
return true
}
function hasGroupSlot(group) {
return !!slots[`group-${group.key}`]
}
/** Check if a field should be visible based on its dependsOn rule */
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)
}
/**
* Collapse preference for a group.
* - expandWhen: form key; when truthy, start expanded (channel just enabled → open form)
* - defaultCollapsed: static default
*/
function resolveGroupCollapsed(group) {
if (group.expandWhen) {
const on = !!props.formData?.[group.expandWhen]
return !on
}
return !!group.defaultCollapsed
}
/** Stable key; remount when expandWhen flips so newly enabled channels open expanded. */
function groupKey(group) {
const base = group.key || group.title || 'group'
if (group.expandWhen) {
return `${base}:${props.formData?.[group.expandWhen] ? 'on' : 'off'}`
}
return base
}
/** Get field value from formData */
function getFieldValue(key) {
return props.formData?.[key]
}
/** Set field value, emitting update for parent */
function setFieldValue(key, value) {
const newData = { ...props.formData, [key]: value }
emit('update:formData', newData)
}
/** Filter schema groups/fields by search query */
const filteredSchema = computed(() => {
const q = searchQuery.value?.toLowerCase().trim()
if (!q) return props.schema
return props.schema
.map(group => {
if (!group.fields) return null
// Check if group title matches
const groupMatch = (group.title || '').toLowerCase().includes(q)
|| (group.description || '').toLowerCase().includes(q)
// Filter fields by label, key, or help text matching query
const matchingFields = group.fields.filter(field => {
if (groupMatch) return true
if (field.type === 'tabbed' && Array.isArray(field.tabs)) {
return (field.label || '').toLowerCase().includes(q)
|| field.tabs.some(tab => (tab.fields || []).some(sub =>
(sub.label || '').toLowerCase().includes(q)
|| (sub.key || '').toLowerCase().includes(q)
|| (sub.help || '').toLowerCase().includes(q)
|| (sub.description || '').toLowerCase().includes(q)
))
}
return (field.label || '').toLowerCase().includes(q)
|| (field.key || '').toLowerCase().includes(q)
|| (field.help || '').toLowerCase().includes(q)
|| (field.description || '').toLowerCase().includes(q)
})
if (matchingFields.length === 0) return null
return { ...group, fields: groupMatch ? group.fields : matchingFields }
})
.filter(Boolean)
})
</script>
<style scoped>
.aksrt-form-renderer__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
}
.aksrt-form-renderer__header-left {
display: flex;
align-items: flex-start;
gap: 12px;
}
.aksrt-form-renderer__header-icon {
color: var(--aksrt-color-primary);
margin-top: 4px;
}
.aksrt-form-renderer__title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--aksrt-color-text);
letter-spacing: -0.02em;
}
.aksrt-form-renderer__desc {
margin: 4px 0 0;
font-size: 13px;
color: var(--aksrt-color-muted);
line-height: 1.6;
}
.aksrt-form-renderer__tabbed {
margin-bottom: 8px;
}
.aksrt-form-renderer__search {
min-width: 200px;
max-width: 280px;
}
.aksrt-form-renderer__loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: var(--aksrt-color-muted);
font-size: 14px;
}
.aksrt-form-renderer__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: var(--aksrt-color-border-strong);
}
.aksrt-form-renderer__empty p {
margin: 0;
font-size: 14px;
}
/* Mobile */
@media (max-width: 768px) {
.aksrt-form-renderer__header {
flex-direction: column;
gap: 10px;
}
.aksrt-form-renderer__search {
max-width: 100%;
width: 100%;
}
}
</style>