- enable the native Links manager and drop the custom friend-link option/REST path
- add per-page link settings for the friends template
- render link categories from get_bookmarks() with search and page-content options
- add a validated public link submission endpoint with logo upload, captcha, rate limiting and duplicate checks
- create pending links for review and notify administrators by email and in-app message
- add native Links admin pending count, columns and edit notes
- cover the new submission and cleanup contracts
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Point 站点 Logo / 暗色模式 Logo schema defaults (PHP + VUE) and the
Settings::logo_url() fallback at assets/img/logo.png.
- Point the footer night image default at the same file.
- Remove logo_dark.png and the now-unreferenced assets/icons/default-logo.svg
along with the private default_asset_url() helper that was its only reader.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The navbar logo carried an inline `height:36px;width:auto;max-width:{logo_width}px`
while `.navbar-logo > img` already owns `height:36px;padding:0 30px`. Under
`box-sizing:border-box` that max-width capped the border box, so the 60px of
horizontal padding stayed inside it and wide logos were squeezed against a
fixed height, breaking the intrinsic aspect ratio.
- Render the nav logo with no inline sizing; CSS alone sizes it.
- Add a `$lazy` argument to jio_get_adaptive_theme_img() so the navbar can emit
the real src up front instead of a square lazyload placeholder; defaults to
'auto' so every other caller keeps the lazy_other behaviour.
- Remove the superseded logo_width plumbing: the setting (PHP + VUE schemas),
Settings::logo_width(), jio_get_site_logo_width(), the --jio-logo-width token
and the unused has_logo context key.
- Cover the contract in the header reference test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Point the 浏览器图标 / 移动端图标 schema defaults (PHP + VUE) at
assets/img/favicon.png instead of an empty media field.
- Fall back to the same PNG in Settings::favicon_url() when no custom
favicon and no native site_icon are configured.
- Add Settings::mobile_icon_url() and emit apple-touch-icon from SEOHead;
the 移动端图标 field previously had no consumer and never reached the page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Traced against the running site (docker theme at AKSRT-theme) with real
computed styles:
- theme_main_radius defaulted to 12, so --main-radius was 12px and every
dropdown panel, card and button was rounder than the reference, which
sets 8px. Default is now 8 in the runtime reader and both schemas.
- The public bundle loads after main.min.css. Its bare img rule replaced
the inline image baseline the menu relies on, and html{overflow-x} made
the root a clipping context that crops the fixed senior mega menu.
Reset is now scoped to content; the horizontal-overflow guard is on body.
- The senior mega menu renders a .container inside the dropdown where the
reference keeps Bootstrap's 15px gutter; the padding-free site-shell
.container rule stripped it, running the card/link grid to the edges.
Rebuilt public.css / public.min.css and the admin SPA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment moderation pipeline no longer calls Akismet. Removed:
- check_akismet() and the Akismet step in Moderation::process().
- akismet_* config keys from Moderation defaults and ExtensionRuntime.
- akismet decision state, the jio_moderation_akismet / akismet_result
comment meta writes, and the legacy-meta fallbacks in the comments column.
- The Akismet fieldset in both the PHP and Vue permissions schemas.
Rate limit, GeoIP, blocked keywords and AI moderation are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The payout channel was a two-item checkbox (alipay / wechat). Sites that pay
out through a bank transfer, USDT or any other route had no way to express it,
and every surface behind it — the withdrawal modal, the server-side check, the
admin review list — hard-coded those two names.
affiliate_payout_channels is now a repeater on both the PHP and Vue schemas,
using the same draggable component as the post filters and gateway config. Each
row carries a stable key, a display label, an account placeholder, an optional
note and an enabled flag. Disabled channels disappear from the front end but
stay resolvable, so historical withdrawal records still render their original
name.
PaymentRuntime::payout_channels() normalises the stored structure: it drops rows
with an empty key or label, de-duplicates keys, and falls back to the built-in
two channels when nothing is configured. enabled_payout_channels() feeds the
front end and payout_channel() resolves a single key for record display.
CommissionLedger's validation now compares against the configured, enabled keys
instead of a literal allowlist.
The withdrawal modal renders the configured list and swaps the account
placeholder and note when the channel changes; credits.js drives that. The
admin review page reads the same settings for its method labels and filter, and
the review dialog gained a "渠道说明" row so the operator sees the payout
instruction, hidden when empty.
Default channels live in SchemaPayment as plain data — the runtime imports them
rather than the reverse, because the schema layer must not depend on a runtime
settings class (doing it the other way made the schema harness fatal).
Verification: drove payout_channels() through default / custom / dirty / duplicate
inputs and confirmed enabled filtering, note lookup and disabled-channel
resolution. Added payment-withdrawal-channels-contract.test.cjs covering the
schema shape, runtime helpers, front-end wiring and admin read path. 873 tests
pass, PSR-4 audit clean, admin SPA builds.
A settings screen renders twenty-odd AppSecret and private-key fields as
type="password". Chrome reads any page carrying a password input as a login
form and fills the saved username and password into the first text box it
finds — the settings search field at the top of the panel.
Suppressing autocomplete on that one input could not fix it, because the
trigger is the password inputs elsewhere on the page. Secret fields now render
as text with CSS masking (-webkit-text-security: disc), so they still look
like ciphertext but no longer register as password inputs; the mask lifts on
focus so a freshly typed value can be checked. That covers the 21 schema-driven
fields through JioField plus the four captcha provider forms and the WeChat
template page.
The search box keeps a second layer of defence: a descriptive name and
autocomplete/autocorrect/spellcheck disabled, with the LastPass and 1Password
ignore markers set.
Verification: vite build succeeds and the emitted bundles no longer contain a
single type="password"; 980 admin/widget tests pass.
Note: assets/admin/build/ is gitignored, so the production build must be run on
deploy. -webkit-text-security is WebKit/Blink only — Firefox falls back to
plain text.
The previous commit added five direct clients (Douyin, Microsoft, Facebook,
Twitter, Apple) written against each platform's own endpoints. Maintaining
five bespoke OAuth implementations — including Apple's ES256 client secret and
Twitter's PKCE — is maintenance the bundled yurunsoft SDK does not ask for,
and none of them could be verified without production credentials.
Only providers yurunsoft/yurun-oauth-login actually implements stay on the
direct surface: GitHub, Gitee, QQ, Google, Weibo, Weixin, Baidu and Alipay.
Baidu and Alipay move into OAuthProviderBroker's existing client() switch and
normalize() map rather than living in a separate class, so there is no new
layer to reason about.
OAuthProviderClient, its harness and its contract test are deleted outright
(-809 lines). The Apple form_post handling added to handle_callback() is
reverted too — without Apple there is no POST callback to read.
The aggregate-login channel keeps its 16 platforms, matching the reference;
that list describes what the aggregation provider offers, not a local
implementation. Brand icons for those names stay for the same reason.
Verification: 869 widget/navigation tests pass, the PSR-4 audit reports 0
errors, and the catalog constant, PHP schema and Vue schema now list the same
16 aggregate platforms.
The theme shipped six direct providers while the reference carries fourteen,
and its aggregate-login channel offered three of sixteen platforms.
OAuthProviderClient now owns the providers the bundled SDK does not cover.
Douyin, Microsoft, Facebook, Twitter and Apple talk to their documented OAuth2
endpoints directly; Baidu and Alipay reuse yurunsoft, which already ships both
(Alipay needs its application private key, so provider_config learned an
extra_keys map to surface it). The broker dispatches through handles(), so the
existing start/callback routes stay provider-agnostic.
Three provider quirks needed handling rather than a plain copy:
Apple returns the callback as response_mode=form_post, and handle_callback
only read $_GET, so it now reads $_POST when the request is a POST — otherwise
every Apple login would fail with code_missing. Its client secret is built as
an ES256 JWT from Team ID / Key ID / .p8 rather than a static string.
Twitter requires PKCE. The verifier is derived from the state token, so it
travels with the existing transaction row and needs no extra storage.
Facebook hands back protocol-less avatar URLs, which are upgraded to https the
same way the OAuth avatar resolver does.
Settings gained matching toggles and credential groups on both sides — the PHP
schema and the Vue schema — and the aggregate platform list went from three to
sixteen, with CLoginClient's hard-coded allowlist replaced by the catalog
constant. Brand icons were extended so each new provider resolves to its
sprite; facebook and twitter moved to their colour sprites, and the
`twitter -> x` alias was dropped since it bypassed the new mapping.
The oauth-security contract previously asserted that Baidu and Alipay must not
appear on the OAuth surface. That was the old intent, so the assertion is
replaced with a positive one, keeping the separate check that the Alipay
payment gateway still exists.
Verification: a new harness stubs the HTTP layer and drives all five custom
clients — authorization URLs carry redirect/state (Twitter also a PKCE
challenge), identities normalize to the shared shape, Facebook's avatar is
upgraded to https, Apple's private-relay email survives, and an error payload
throws instead of yielding an empty identity. 871 widget/navigation tests pass
and the PSR-4 audit reports 0 errors.
Not covered: no live handshake happened — every provider needs real AppID and
secret pairs, and Apple/Douyin require developer-account review before the
full round trip can be exercised.
The 代码高亮 and 系统公告 schemas existed but appeared in no menu, so their
routes were never generated (routes come from the sidebar constants). The
reference keeps both inside the 常用功能 panel rather than as their own
entries, so follow that structure instead of adding sidebar items.
- global/common now carries three groups after the lazy-load ones:
代码高亮 (switch / line numbers / buttons / max height / light+dark theme)
and 弹窗通知 (policy, content, 4 buttons, size, radius, icon, expiry)
- group keys are uniquified (code-highlight, code-theme, notice-*) so they do
not collide with the existing performance group
- delete the orphan code-highlight.js / system-notice.js schemas and their
section entries on both the Vue and PHP sides
- delete post-list/pay.js and its PHP counterpart: every post_pay_* and
post_download_* key there had zero runtime consumers, and the reference has
no such page (paid-content config lives under 支付&付费)
tools/health and tools/import-export turned out to already be reachable as
custom pages, and remain untouched.
Tests: admin contract audit 0 errors (warnings 56 → 51); SPA builds clean;
suite 967/968 with the pre-existing frontend-lifecycle failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The theme was renamed Zibll/子比 → AKSRT → Jio, but upstream branding
survived the sweeps in CSS classes, the SVG sprite prefix, a persisted
widget field ID, JS events, admin Vue class names, test infrastructure
and docblocks.
Renames:
- zib-widget / zib-widget-wrap / zib-slider → jio-widget-box /
jio-widget-box-wrap / jio-slider-box (jio-widget is taken by the modern
widget shell, jio-box-* by the card primitives)
- zib-partners-scroll + zps-* → jio-partners-scroll + jps-*
- zib-combo-info-card + zci-* → jio-combo-info-card + jci-*
- zibsvg- → jiosvg-, including the exported picker helpers and the
fa-zibsvg- family-prefix guard
- zibpay_type → pay_type; the dual-read fallback in PostQuery collapses
to a single key
- [zibajax="submit"] dropped (no PHP emitter); zib_ajax.success →
jio_ajax.success
- aks-* admin Vue classes → jio-*
- zib_palette() → palette()
User-facing vendor traces are gone: the floating "more" button default no
longer ships a zibll.com link or 子比主题 marketing copy, the notice
widget ships no preset promo entries, and the four 官网教程 links are
removed from the post-list admin schema.
Test infrastructure renamed to neutral names (docker-compose.reference.yml,
Caddyfile, article-baseline.json, modern-widgets-contract.test.cjs) with
their DB/volume/env/slug identifiers following.
Kept intentionally, documented in tests/fixtures/README.md: the golden
fixture reference fingerprint (a SHA-256 record of an external directory,
read by validate-widget-golden / generate-widget-golden-fixture /
audit-widget-system), the doesNotMatch guards that prove the old tokens are
gone, and the reference theme's own meta key in the visual fixtures.
No backward-compat or data migration: saved widget configs holding old
icon keys or the old field ID silently lose them, per the project's
rewrite-over-compatibility rule.
Verified: 132/132 node contract tests, php tests/widget-system/run.php
(146 assertions), audit-admin-contracts, audit-psr4-paths and
validate-widget-golden all green; tracked bundles regenerated and in sync.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mechanical rename turned AKSRT-theme into Jio-theme, but the
directory itself is Jio. Fix the three places that name the folder
(README install steps and tree, wp-cli-visual-fixtures usage comment,
DocsPage docs deep-link) and rebuild the SPA bundle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
user_medal_args was declared as type=json without sanitize_type, so
sanitize_field_value() fell through to sanitize_text_field((string) $value)
and stored the literal string "Array". validate() has no json shape check,
so the save reported success.
Consequence: parse_medal_catalog() json_decoded "Array" into an empty
catalog, the dictionary came back empty, and user_context() dropped every
owned badge — the front end showed "获取徽章" and the grant modal listed
nothing, hiding both the newly added and the pre-existing badges.
Add the missing sanitize_type to match its sibling level/integral fields,
and guard sanitize_field_value() so structured container types can never
reach the scalar fallback again. Mirrors the declaration in the Vue schema.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AksImageSelectField pinned every preview to a 60x60 box with
object-fit:cover, but these are wide layout strips: header layouts are
597x57 (10.47:1), the footer template is 800x190 (4.21:1) and the slide
align set is 200x101. A 10:1 strip inside a square crop keeps ~6% of its
width, so the layout it is supposed to illustrate is unrecognisable.
Constrain the width instead and let the height follow, which is what the
reference theme's CSF image_select does (max-width:100%; height:auto).
Each field now declares the width that suits its images, and an option
may still override it: header 200, footer 240, slide align 160, mobile
header 120.
Only presentation changed. No image files and no stored values moved,
so existing settings are unaffected.
assets/admin/public/images/header-layout duplicated
assets/images/header-layout byte for byte (all five PNGs, matching
md5). The directory only existed because assets/admin/public is Vite's
default publicDir, so every build copied it into build/images.
Nothing referenced it: SchemaPageDisplay builds its preview URLs from
assets/images/header-layout via self::asset(), and a repo-wide search
finds no reference to public/images anywhere. Remove the source and let
build/images stop being regenerated.
- Overview page: realtime today vs. same time yesterday, 6 KPI cards with
period-over-period deltas, trend (paid / net / refund curves + order count
line), category curves, payment method donut, top 10 paid content,
conversion / close / refund rates, todos and shortcuts
- Date presets, custom range and category filter via
GET aksrt/v1/admin/pay-overview (manage_options + wp_rest nonce)
- Metrics: paid amount by paid date including later-refunded orders, refunds
by refund date, net = paid - refund; CNY only, points and recharge excluded,
foreign-currency orders listed separately; integer cents internally
- Daily records cached per month in transients (today/yesterday live),
invalidated on order status change and on purge
- ECharts 6.1.0 subset bundled to assets/js/runtime/libs/echarts-pay.min.js
(npm run build:pay-charts), loaded only on the overview page
- Split payment-attempt methods out of OrderRepositoryCore into
OrderRepositoryAttempts; remove dead get_stats(), cash_decimal(),
Order::get_stats() and the old .aksrt-native-links styles
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.
- 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>
- 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>
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 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.
升级价格 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.
Rename and reposition the two VIP modal copy settings to match the zibll reference backend, drop dead renew/upgrade desc config, and align copy defaults.
- pay_user_vip_desc: modal top intro -> 会员介绍, textarea -> input (one-line), zibll help text, default copy from zibll
- pay_user_vip_more: bottom note -> 开通会员更多内容, rows 4 + sanitize_type html (HTML was stripped on save), zibll help text and three-div default
- Both fields move from the member-level group to a new 界面文案 (modal-copy) group at the end of the VIP section; dependsOn any-of-two-levels adapted from zibll's pay_user_vip_1_s dependency
- Copy defaults synced in three places (SPA schema, PHP schema, PaymentRuntime fallbacks, since Settings::get_all() does not merge schema defaults): 黄金会员/钻石会员, 尊享专属特权, zibll four-item equity list
- Remove vip_renew_desc / vip_upgrade_desc: AKSRT has no renew/upgrade card render surface (render_vip_card takes no type) and zero consumers repo-wide; zibll renders them on page-level cards only
- pay_user_vip_1/2_equity and pay_vip_pass_charge_desc gain sanitize_type html so admins no longer lose list/HTML markup on save
Verification: new tests/widget-system/vip-modal-copy-fields.test.cjs (6 contract tests); node --test tests/**/*.test.cjs 634/634 pass; php -l clean on both changed PHP files; assets/admin npm run build succeeds