Accept the stdClass user data passed by WordPress and derive the user ID only from the hook object. Cover callback registration, nonce and permission guards, and mismatched posted IDs; refresh the identity review dialog contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace inline identity review forms with focused detail and review dialogs, improve list filters and caching, and align evidence permissions and deletion feedback. Add contract and runtime regression coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unify status filters, search, bulk actions and pagination in one invite list card. Remove the standalone filter summary and preserve usable search widths on small screens, with structural regression coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace standalone add and export views with accessible dialogs, refine list interactions, and remove obsolete paths and unused counts. Fix filtered totals and reject oversized imports without truncation, with regression coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Display credits_added as 积分增加 with a success badge across message
filters, list rows and details. Extend rendering regression coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Center the operations heading and row buttons, with 20px padding on both
sides of the cell. Add a regression contract for alignment and spacing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a dedicated operations column for viewing, marking read and deleting
messages. Keep the message cell focused on its title, excerpt and ID;
adjust column widths and empty-state colspan with regression coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Register Chinese labels and matching badge tones for balance and points
increase/decrease types across message filters, list badges and details.
Add regression coverage without changing notification delivery behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add message details, per-row actions, selection-aware bulk controls and
confirmation dialogs. Merge filters into the list card and link overview
statistics to the matching views with site-timezone today filtering.
Improve the compose dialog with Unicode character counters, recipient mode
segments, role counts and explicit broadcast confirmation verified against
the resolved role and recipient count on submission. Split rendering into
focused sibling traits and cover rendering, handlers and UI interactions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the inline <details> compose card on users.php?page=aksrt_messages
with a primary header button that opens a native <dialog>. NativeUi::header()
gains dialog-type actions via NativeUi::button_dialog(). The dialog reopens
automatically after a failed send.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Replace per-row inline <details> forms and the separate bulk form with a
single <dialog> shared by single-user and bulk adjustments
- Add stats row, styled tabs, date range filter with reset, sortable expiry
- Show fallback tier, remaining time, and disable unavailable operations
- History tab: labelled operation/status badges, avatars, local time,
batch marker and error message
- Move inline script/onclick to assets/js/admin-vip.js; add vip.css
- Surface the service error message when every user fails
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add NativeUi helper + native-ui.css (tokens mirror the SPA Naive UI theme)
and move the pay center, orders, products, coupons, charge cards, income,
withdrawals, tickets, invite codes, VIP, verify, messages and report/ban
pages onto shared header, stat cards, cards, filters, badges and empty
states. Remove inline styles and dead helpers; split PayChargeCards into
sibling traits to stay under the file size limit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WP core widgets.js applies a negative margin-left to widgets whose
control width exceeds 250px, pushing the form over the available
widgets list. Use the default width like the reference theme.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Replace transfer modal/admin-post flow with reference-style AJAX
export/import/template dialogs (admin-transfer.js, TransferController)
- Customizer "移出模块" action, reference lazy form loading with t-notice
- Common fields use reference labels, dependencies, compact/inline rows,
top/bottom-only extra spacing; form header with description/reminder
- Remove placement hint, template preview and legacy transfer code
- Fix widget audit (current_datetime stub, honor catalog common flags)
and regenerate stale golden fixtures for posts_toc, quote, posts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Align homepage and category discovery, modern widgets, admin controls, quotes, and comments with the reference theme. Remove superseded paths and add rendering and behavior coverage. Update stale asset and finance contracts; all 760 Node tests and 139 widget assertions pass.
- Render pending orders (max 5, >=3 min left) in a top-right float button
with hover list and countdown, gated by pay_wait_float_s
- Fix order countdown parsing expires_at as UTC
- Drop unused .paid-modal-lists-box thumb rule
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_win.ver only tracked main.min.js, so rebuilding svg-icon.js alone kept
serving the cached sprite and new icons (ticket-color) rendered blank.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the 114 built-in sprite icons out of assets/js/runtime/svg-icon.js into
assets/icons/sprite/{key}.svg and generate svg-icon.js with
tools/build-svg-sprite.mjs. Add ticket-color and use it for the user center
ticket entry. Update sidebar contract tests for the creator card labels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
widgets/frontend.css loads after main.min.css and its global
.zib-widget{padding:15px} overrode .padding-6.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Rewrite sidebar stats as a single zib-widget row (user-statistics-item, em14 / em09 opacity5 mt6), drop legacy .uc-stats styles
- Restore reference body line-height (1.42857143) on stats and service/settings boxes
- Move 我的订单 to the front of 我的服务 to match reference order
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the native input[type=color] in the page config meta box with a
wpColorPicker-enhanced text input, enqueued only on page edit screens, and
contract-lock that no native color inputs remain in theme PHP.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- pay-pages-pagination asserts the $parent_file base URL added in b74381cf
- Drop native loading="lazy" from message avatars and medal presets per
the lazyload contract
- verify-ban-settings-defaults requires SchemaUsersTicket and new deps
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add AKSRT\Core\Color::sanitize($value, $allow_alpha) as the single PHP
color sanitizer; replace sanitize_hex_color calls and per-class regexes
(FloatButtons, FieldSchema, ThemeSearch, icon cards, TicketStatusService)
- SchemaProvider / ControllerBase sanitize `color` fields as colors;
alpha kept only when the schema sets showAlpha
- Split SchemaProvider OAuth validation and option matching into sibling
traits to stay under the 1000-line limit
- Add AksColorPicker.vue + utils/color.js mirroring the PHP rules; AksField,
FloatBtnCommonFields and TicketStatusPanel use it
- Widget background color input now uses wpColorPicker
- Remove dead AksCompositeField dimensions/spacing/background branches and
the unused Gutenberg ColorPicker var
- Add color-unification contract test; update harness requires and tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the 备案信息 group (icp_filing, police_filing, footer_text) from the
global.common PHP/Vue schemas, the FacadeBulk readers and the
get_global_settings() payload. No frontend consumer existed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Media and gallery previews now hug the image (inline box, capped height)
instead of stretching across fixed grid cells, removing empty letterbox space.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Media/gallery previews no longer stretch or crop: use object-fit contain,
natural sizing for single media, and the uncropped medium size instead of
the square thumbnail for gallery and native meta-box previews.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- OAuth provider and payment gateway groups are collapsible and collapsed by default; remove dead expandWhen logic
- Textarea fields support secret masking; mark alipay_privatekey as secret in JS and PHP schemas
- Extract AksSecretClear shared by input and textarea secret fields
- Replace inline styles in payment/post-list notice HTML with aksrt-hint / aksrt-warn classes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Badge page: rewrite as collapsible sortable category/item cards with
preset medal icon picker, dirty-tracked save bar and duplicate slug check;
drop user_badge_* dual-read and legacy name/icon_url fallbacks
- OAuth: provider config groups collapsible, expanded when provider is on;
clogin visibility moved to group-level dependsOn
- Gateway: expandWhen accepts dependsOn-style rules; select-gated groups no
longer always expand ('null' was truthy); mark five gateway secrets as
secret/password in JS and PHP; class-based notice list styling
- Remove unused UserRuntime::parse_badge_dictionary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add useCollapsibleItems: key-based open state, clickable/keyboard head, aria-expanded
- sortable items with sub-fields and every inline repeater/group row now collapse
- Repeater head summary falls back to the first filled field when no listPreview
- Drop per-schema collapsible/defaultCollapsed for repeater groups (JS + PHP)
- Align FloatBtnItemPanel head with the shared collapse attrs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Validate status_key (^[a-z][a-z0-9_]{1,23}$, unique) on create/update
- Renaming a key moves aksrt_ticket.status in the same transaction
- Default statuses remain immutable
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Global input[type=radio] rules in main.css out-specified the ticket
picker's hide rule, pulling the radio back into flow before the pill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- aksrt_ticket gains urge_count / last_urge_time (schema 3.1.0)
- cooldown moves from transient to an atomic conditional UPDATE
- urge inserts a system reply rendered as a centered notice (not quotable)
- admin list badge + info card row for urge count / last time
- new ticket_urge_admin mail event via aksrt_ticket_urged
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mark front-end uploads (comment/ticket/report) as pending, return the
attachment id, discard own pending uploads on remove via
DELETE /upload/pending/{id}, claim referenced URLs on successful submit,
and purge unclaimed uploads older than 24h with an hourly cron.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The static-array callable pointed at an instance method, which PHP 8
rejects as non-callable, so WP answered every upload with rest_invalid_handler.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Detail modal: fixed-height dialog, thread is the only (thin) scroll area
- Collapsible pinned summary bar (collapsed by default, state kept across re-render)
- Composer: toolbar (image / urge / close) above input, pending image tray with remove
- Paste screenshots to upload; create form uses the same button + tray
- Urge/close now POST (routes are CREATABLE); urge cooldown countdown via urge_wait
- Notify admins on urge when ticket is unassigned
- Split composer/form styles into tickets-compose.css; drop dead uploader code
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hidden file input was appended to the flex-centered modal shell,
becoming a second flex child that pushed the dialog left and exposed
the native file button (theme display rules override [hidden]).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Status stat cards double as quick filters; debounced search with stale-response guard
- Card list with status stripe, windowed pagination, skeleton/empty/error states
- Detail dialog as chat timeline with in-place refresh, quote preview, Ctrl+Enter send
- Create form with department chips, inline validation, multi-image upload, success notice
- Split shared helpers into ticket-center-core.js and enqueue it before the panel script
- Drop dead data-aksrt-ticket-config and legacy colorful modal header
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Move table cell styles out of scoped block so h()-rendered swatches get size
- Replace the bare color-picker trigger with preset swatch radios + custom picker
- Disable alpha so picked colors sync to the hex input (was #rrggbbaa, dropped)
- Normalize hex via toHex() matching TicketStatusService::sanitize_color()
- Add live badge preview, hex input chip, inline closed-state hint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/users/ticket loaded in the SPA but the form never did: the console showed
"未知的设置分组: ticket" from /settings/schema/users/ticket. The ticket section
was composed one array level too high in SchemaUsers::build() — as a sibling of
'sections' rather than inside it — so SchemaProvider::get_sections(), which reads
only $schema['sections'], never saw it and the REST handler 404'd.
Every other axis was wired correctly: the route, the menu entry, users/ticket.js
and SchemaUsersTicket itself. That is why only this page failed while sibling
/users/* pages worked. Move the entry inside sections.
The contract test could not catch this because it asserted only that the compose
call appears somewhere in the file. Replace it with a bracket-balanced scan that
locates the sections array bounds and asserts the compose call falls inside, so
nesting mistakes are visible.
Also update the two ticket contract tests that still read the trait from
src/Ticket/TicketRepository-traits/, its pre-flattening path. They assert on the
trait's contents, so they were silently skipping the very file the earlier fatal
was about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TicketRepository fatalled on /user/tickets/ with "Trait AKSRT\Ticket\TicketQuery
not found". src/ classes load only via Composer PSR-4 (AKSRT\ => src/) — there is
no bootstrap require list for them — so the autoloader resolved the traits by
namespace to src/Ticket/TicketQuery.php and src/Ticket/TicketStatus.php. Both
files actually sat in src/Ticket/TicketRepository-traits/, one level deeper, and
were therefore invisible: PHP hit the `use` statement with no trait declared and
died the moment the repository compiled.
The nested directory name imitated the old inc/classes/**/*-traits/ layout, which
only worked because inc/bootstrap.php carried an explicit require_once for every
one of those files. No such mechanism exists under src/. Move both traits up to
sit flat as siblings of the class that uses them — the same shape as
GlobalControllerPart1, ModerationPart1 and the rest of src/. No code changes
inside the traits; their namespace was already correct.
Add tools/audit-psr4-paths.php, which checks every symbol under src/ against the
path Composer maps it to and exits 1 on mismatch. It flags this exact layout, so
the nesting cannot come back unnoticed. Document it in CLAUDE.md next to the
existing admin-contract audit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old 售后 flow was an order-refund appendage: tickets were hard-bound to an
order number, users could only file them from 「我的订单」against a paid order,
and the admin action bottomed out at 「标记订单已退款」. It could not carry
account questions or feature requests that have nothing to do with an order.
Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket:
title + department + description are required, linking an order is optional
(user's own paid orders only). Add departments, custom statuses, preset replies
and internal admin notes as four configuration surfaces. Conversation history
moves off the ticket row's JSON log into dedicated ticket_reply rows, which
unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end
moves from 支付中心 → 售后服务 to users.php → 工单管理.
Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new
ones, delete the old code. No dual paths, no data migration. The upstream
plugin's upstream/downstream delivery and internal-ticket conversion are
deliberately left out — both are multi-provider platform concerns with no
meaning on a single WordPress site.
Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply,
ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION
2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status
「待接单」is flagged as non-editable.
Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse
UserController::rest_upload_comment_image with a ticket-specific permission
callback, mirroring the existing /report/upload-image route rather than
growing a second upload path.
Notifications go through AKSRT\Messages\Message::send() and deep-link to the
right surface; email/SMS stay out of scope since the theme's mail and SMS
stacks are separate implementations.
Refund gate opens up: marking an order refunded no longer requires an in-flight
ticket, only that the order is paid.
Also generalise a hardcoded special case in tools/audit-admin-contracts.php —
schema domain inference now reads the parent directory instead of matching
"PostList" by name, so new leaf schema files need no per-name exception.
Verification:
php tools/audit-admin-contracts.php exit 0, errors=0
node --test (widget-system + admin) 664 pass / 0 fail
php -l over all new/changed files clean
node --check over all JS clean
npm run build + build:public clean
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The poster modal shell already matched the reference theme, but the canvas
drawing diverged from zibll's poster-share.js rebate branch in four places:
- No 推荐人 tags layer. The reference paints a red #fc4e1f 24px line at
img_height+160; AKSRT dropped the layer entirely, so the poster lost the
referrer attribution that identifies whose link it is.
- The logo slot painted text. The reference contains the real logo image
into (80, height-190, 300, 100); AKSRT drew the site name as a 30px string
plus a hardcoded 长按识别二维码访问 caption in that slot.
- Bottom copy was hardcoded. The reference reads share_desc; now sourced
from the new pay_rebate_poster_desc_bottom field with share_desc fallback.
- No banner fallback. The reference walks banner → banner_default →
banner_spare; a single broken URL left the top half of the poster black.
Also aligns the week label to the reference's 周 prefix (was 星期日) and ports
drawTextOverflow for wrapping/truncation, which retires the local wrapText.
Adds pay_rebate_poster_banner and pay_rebate_poster_desc_bottom to both the
Vue and PHP schemas; rest_me now returns logo, compatible and referrer_name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The blue download anchor rendered square while the adjacent yellow close
button stayed round. Both carry .but.cir, so the asymmetry had to come
from a rule matching only one of them.
credits.css:334 scopes .uc-content .but.jb-blue to a flat #3b82f6 skin
with border-radius:4px — that rule exists to stop the same class
flattening the top-nav publish chip and the site-wide login/submit/badge
buttons, so it stays. But the poster modal is emitted inside .uc-content,
and at (0,3,0) loading after main.css it out-ranks .poster-share
.but.cir's (0,2,0) on both counts, overriding the 5em pill with 4px on
the blue button only. jb-yellow is not in that selector, which is exactly
why its neighbour survived.
Restate the pill at matching weight, scoped to this modal. Padding comes
along because .uc-content .but.jb-blue also sets 0.45em 1em, which would
otherwise re-inflate the 36px circle. The 36x36 box and drop shadow stay
with .poster-share .but.cir in main.css.
Add a contract test for the radius restatement and the sizing it relies
on; verified it fails when the selector is renamed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The field rendered in two places on /user/revenue/: the 我的佣金 card's
meta line and the 累计佣金 row, both gated on a non-zero balance. Remove
both, along with the now-unused $debt_amount local and the dead
.uc-commission-card__debt rule.
Source trace, since the label is only the surface: CommissionLedger::
overview() reads debt_minor from aksrt_commission_debt and returns it as
'debt'; record_debt() books the shortfall when a refund cannot be clawed
back in full, and repay_debt_from_pool() later clears it out of the
commission pool. That mechanism is untouched — arrears still accrue and
still get repaid, the UI just no longer surfaces them. The reference
theme's rebate card has no equivalent concept, so this is not a parity
target either.
Keep 'debt' in the template's overview() fallback array: it mirrors the
real return shape and the repayment path still depends on the value.
Add a contract test asserting the label, its class and the local are gone
from the display layer while overview() keeps returning the key; verified
it fails if the label is reintroduced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two regressions from the previous commit made /user/revenue/ unusable.
The rewrite dropped data-aksrt-affiliate-poster-modal from the modal shell.
That single attribute was both the JS lookup key and the CSS hook, so
initAffiliatePoster() bailed at `if (!button || !modal) return` — which is
why the 推广海报 button never got un-hidden — and no CSS could target the
shell. Restore it.
With the shell back but still visible, main.css's .flex{display:flex} loads
after bootstrap and matches .modal{display:none} on specificity, so the
resting modal rendered as a full-viewport flex box. Combined with .fade's
opacity:0 that is an invisible overlay swallowing every click on the page.
Pin the resting state to display:none and the open state to flex, the same
way credits.css already does for the charge and points-pay modals — that
file documents this exact trap.
Add a contract test asserting the markup, JS selector and CSS hook share
one attribute and that the resting state is explicitly hidden.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 推广海报 modal on the revenue page used a bespoke .uc-modal card —
white panel, 12px radius, heavy shadow, title bar, corner × button and a
full-width 保存海报 button — plus a light-gray plate with its own
border-radius around the poster canvas. None of that exists in the
reference theme, so the popup read as bordered and off-spec.
Rebuild the modal on the reference poster-share structure: a borderless
360px .modal-dialog.poster-share centred by .modal.flex.jc, a
.loading-mask overlay while the canvas renders, and the download/close
buttons moved onto a .abs-center.hover-show-con layer that only fades in
on hover. initAffiliatePoster() now drives Bootstrap 3 modal semantics
(.in toggle, .modal-backdrop, body.modal-open, ESC and backdrop-click to
close), mirroring the existing openChargeModal/closeChargeModal pair, and
animates .modal-content from the 220px placeholder to the poster's real
height before clearing the inline styles.
No CSS added — every rule the reference modal needs was already ported.
Drop the now-dead .uc-affiliate-poster rules; the shared .uc-modal shell
and openModal/closeModal stay for the withdraw and transfer modals.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous fix inherited the drag key onto a replacement row object, but the row data is reactive: Vue hands the renderer a fresh proxy for every new object, so the WeakMap still missed and minted a new key on each keystroke. That remounted the row and dropped input focus after one character, so editing 执行价, 原价, 促销标签, 使用积分, 兑换时长 and 会员有效时间 was unusable.
updateRepeaterItem now writes through the existing row instead of spreading a new object, so the row identity survives across renders and the drag key stays valid. The emitted array is still a fresh array, so parents re-render normally, and rows that are not objects still fall back to a new object.
Verified in a real browser against the actual payment/vip schema: all six fields keep the same DOM node across repeated keystrokes, typed values reach the model, and add/remove still work. The contract test now simulates Vue reactive proxying and asserts row identity is preserved; it fails against the previous implementation. Full suite: 663 tests passing.
The inline group/repeater renderer feeds vuedraggable an item key from a WeakMap, because rows loaded from the server carry no _id. updateRepeaterItem rebuilt every row with a fresh object spread on each keystroke, so the WeakMap missed and minted a brand new key, which remounted the whole row and dropped the input focus after a single character.
Inherit the existing drag key onto the replacement row object so the key survives for the whole life of the row. Values that match no option still fall through to the previous coercion, so invalid input keeps reporting a precise error, and rows that already carry an _id continue to key off it.
Covered by a new contract test that extracts the real repeaterDragKey and updateRepeaterItem from AksField.vue and drives them through a simulated typing session. Full suite: 661 tests passing.
The 积分兑换明细.level radio declares its options as the integers 1 and 2, but sanitize_field_value() ran every radio value through sanitize_text_field(), so the sanitized payload held the string 1. validate() then compared with in_array(..., true) and rejected both default rows, which made the VIP payment section impossible to save.
Resolve enum values to the value declared by their option during sanitization, covering select, radio, palette and image_select. A value that matches no option still falls through to the previous string coercion, so invalid input keeps producing a precise validation error. This also keeps the stored type aligned with the schema, which the naive-ui radio group compares with === when it re-renders a saved setting.
Verified with an end-to-end sanitize, validate, write and read round-trip, and locked in with a contract test in the VIP structured-settings suite. The widget-system, admin, front-submit and navigation suites all pass (658 tests).
升级价格 declared three reference-theme blocks through the accordion sections option, but the section builder handed the computed the whole field object instead of its sections array, so the guard always saw an empty length and the renderer silently fell back to a flat list. The three blocks ran into each other with no separation at all.
- Move the section expansion into a pure buildFieldsetSections() helper so the mistake is now covered by a real contract test that runs the actual schema through it, instead of a regex over the component source.
- Apply dependsOn to fields inside each block, so 天单价/促销标签 hide when 月费会员升级月费会员 is switched off, matching the flat path.
- Give structured groups (会员商品/续费商品/积分兑换明细/升级价格) their own bottom margin and widen the gap between accordion blocks, so two adjacent groups no longer sit flush against each other.
Verified: 657 Node contract tests passing, the admin SPA builds.
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.
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.
The points-exchange desc was stripped at all three layers: no sanitize_type on the schema fields (save ran sanitize_textarea_field), the runtime re-ran the same strip, and the points modal escaped it with esc_html. The reference theme declares sanitize=false and concatenates the value raw into the modal.
- Schema (SPA + PHP): sanitize_type html, rows 2, help text noting HTML support
- PaymentRuntime: output via wp_kses_post
- VipServiceModal points modal: render via wp_kses_post instead of esc_html
Verification: new contract test covers schema/runtime/modal layers; node --test tests/**/*.test.cjs 641/641 pass; php -l clean on both PHP files.
Three layers were dropping the markup: the save path was already relaxed to wp_kses_post via schema sanitize_type html, but the runtime output re-filtered more through the narrow equity whitelist (h/img/hr/table stripped), and pay-vip.js inserted it with node() whose textContent showed the tags as literal text. The card-pass desc had the same output-side strip despite its save-side fix.
- PaymentRuntime: more and pay_vip_pass_charge_desc now output via wp_kses_post, matching the save-time level (repo precedent: revenue note, after-sale desc)
- pay-vip.js: new htmlNode() helper (innerHTML) for server-sanitized rich text; more and cardPass.desc use it, plain-text fields keep node()
Verification: 3 new contract tests cover output-level kses and innerHTML rendering; node --test tests/**/*.test.cjs 640/640 pass; php -l and node --check clean.