Compare commits

...
659 Commits
Author SHA1 Message Date
kate522andClaude Opus 5 81b60eb427 fix(revenue): replicate the reference rebate poster canvas layers
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>
2026-10-21 21:07:48 +08:00
kate522andClaude Fable 5 ef15416ef7 fix(revenue): keep the poster modal download button circular
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>
2026-10-21 20:17:50 +08:00
kate522andClaude Fable 5 0cfd4c898b fix(revenue): drop 待偿欠款 from the commission card
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>
2026-10-21 20:08:34 +08:00
kate522andClaude Fable 5 9a22c7eb8b fix(revenue): stop poster modal from blocking the whole revenue page
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>
2026-10-21 19:26:48 +08:00
kate522andClaude Fable 5 de0e5cd481 fix(revenue): match reference poster modal UI 1:1
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>
2026-10-21 16:02:41 +08:00
kate522andClaude Opus 5 a120f61e13 fix(vip): render points exchange description
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-19 14:04:30 +08:00
kate522andClaude Opus 5 0d51beeaad fix(vip): add spacing before points exchange notice
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-19 13:22:04 +08:00
kate522andClaude Opus 5 573040381d fix(vip): preserve safe HTML in VIP copy
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-19 12:52:23 +08:00
kate522 7a0445fc4a fix(admin): write group rows in place so drag keys stay stable
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.
2026-10-18 21:59:06 +08:00
kate522 0bc72616e5 fix(admin): keep group row keys stable while editing fields
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.
2026-10-18 21:15:19 +08:00
kate522 064f4e0955 fix(admin): keep enum values in their declared type when sanitizing
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).
2026-10-18 19:45:57 +08:00
kate522 4b5098a753 fix(vip): restore the upgrade-price accordion and space out structured groups
升级价格 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.
2026-10-18 19:24:00 +08:00
kate522 05f16d5fd7 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.
2026-10-18 17:51:28 +08:00
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
kate522 dcb80cff6e fix(vip): let 积分兑换说明 support HTML like the reference theme
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.
2026-10-18 15:06:29 +08:00
kate522 2e2558e5ce fix(vip): render 开通会员更多内容 as HTML in the modal
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.
2026-10-18 14:53:42 +08:00
kate522 817ac9c647 fix(user-center): drop icon before unopened VIP status name
The status card always rendered the icon slot, so never-opened members saw a generic certificate glyph in front of 未开通会员 (level 0 has no configured icon and fell back to fa-certificate). Members with a real level keep their icon; only the level-0 case drops the slot.

Render the icon span only when vip_level_number > 0.

Verification: extended tests/widget-system/vip-tab-status-card.test.cjs (3 contract tests); node --test tests/**/*.test.cjs 638/638 pass; php -l clean.
2026-10-18 14:47:26 +08:00
kate522 e94ecdd7b1 fix(user-center): drop expiry row from unopened VIP status card
The status card always rendered the expiry row, so never-opened members saw a redundant 到期时间:未开通 line under the 未开通 state label. The reference theme outputs no expiry info for never-opened users (only active, permanent, and expired states carry one).

Initialize the expiry text empty and render the row only when the text is non-empty; active/permanent/expired branches are unchanged.

Verification: new tests/widget-system/vip-tab-status-card.test.cjs (2 contract tests); node --test tests/**/*.test.cjs 637/637 pass; php -l clean.
2026-10-18 14:42:10 +08:00
kate522 659b77719c fix(vip): keep 开通会员更多内容 inside the modal only
The user-center VIP tab rendered pay_user_vip_more in a muted box below the level cards, which the reference theme does not do: zibpay_user_page_tab_content_vip only renders pay_user_vip_desc in the status box, and pay_user_vip_more appears solely in the two modal builders (purchase and existing-member renew/upgrade). The page block stayed invisible while the setting defaulted to empty and surfaced once the zibll default copy landed.

Remove the page-level block; the modal view model and pay-vip.js right-column rendering are untouched.

Verification: regression test locks vip-tab.php free of the more block while the modal path keeps carrying it; node --test tests/**/*.test.cjs 635/635 pass; php -l clean.
2026-10-18 14:37:35 +08:00
kate522 7f556892ad fix(vip): align modal copy fields with reference theme backend
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
2026-10-18 14:30:15 +08:00
kate522 cb76b95b01 feat(vip): skeleton loading state and faster modal/site startup
The VIP purchase/renew modal opened with a single "loading..." line, which
reads as a hang while /pay/vip/modal round-trips. Every frontend pageview
also paid an option write inside wp_head, and the header user-card CSS was
enqueued even for guests who never see the card.

pay-vip.js now builds a full-layout skeleton (user box, level tabs, the
two-column package panel) from vipSkeleton(), reusing the global
aksrt-modal-runtime__skeleton line/block classes; the VIP-specific shapes
live in the conditionally loaded vip-modal.css, and the 640px two-column
threshold matches .pay-vip-form-row. The modal cache TTL goes 15s -> 60s
(purchase, card-pass redeem and points exchange already invalidate the
vip tag), and hover/focus on a .pay-vip trigger now prefetches the payload
through the same cacheKey and flight openRemote uses, so a warm click
opens instantly.

The bottom note and the whole help_text pipeline behind it are removed; the
admin-editable pay_user_vip_desc still renders at the top of the modal, and
the recharge/gateway help_text keys are untouched. get_catalog() reads
points and wallet balance through one new LedgerService::points_and_balance()
call. The StatsTracker counter write moves to shutdown so wp_head only does
the visitor check and setcookie, and header-userbox.css is enqueued for
logged-in users only.

Verification: node --test across the suite (628 tests, including the 7 new
contract assertions in tests/widget-system/vip-modal-skeleton.test.cjs) and
php -l on every changed PHP file. No visual acceptance performed, per request.
2026-10-18 13:44:01 +08:00
kate522 319ec2f47f fix(nav): use reference theme sprite icons for header wallet tiles
The top-nav user card rendered its balance and points tiles with Font Awesome
glyphs (fa-money-bill-wave, fa-coins) while the reference theme paints the
multi-tone SVGs money-color-2 and points-color, so the two tiles read as flat
monochrome next to the rest of the card.

aksrt_header_user_wallet_mini() now resolves both value icons through
aksrt_icon_markup(), which is the same call the user-center sidebar uses for
the same two figures, so the header and the sidebar cannot drift apart:

  余额  fa-money-bill-wave -> zibsvg-money-color-2 (em14)
  积分  fa-coins          -> zibsvg-points-color  (em14)

The tile arrows stay on fa-angle-right because that is what the reference
renders there; only the two value icons were wrong. The muted-box geometry, the
c-blue-2 / c-yellow value colours and the data-aksrt-* live-update hooks are
untouched.

Verification: node --test across widget-system, navigation, admin,
asset-ledger, dev, front-submit and fixtures (621 tests), plus php -l. Rendered
the tile through a stubbed harness and diffed it against the reference markup:
identical structure, classes and icon. Contract-locked the two keys in
tests/navigation/legacy-navigation-contract.test.cjs and added a cross-surface
parity assertion to the sidebar icon contract.
2026-10-18 13:05:19 +08:00
kate522 4b18f7ef2c fix(user-center): replicate reference theme sidebar icons via SVG sprite
The user-center sidebar rendered Font Awesome glyphs while the reference theme
paints multi-tone inline SVGs, and several glyphs were simply wrong for their
slot: fa-chart-line served both 我的等级 and 推广收益, fa-money-bill served both
余额 and 积分, and fa-pen stood in for 创作收益.

The theme already ships every sprite key the reference sidebar needs in
assets/js/runtime/svg-icon.js, and main.min.js injects the <symbol id="icon-*">
set on every frontend page, so no new icon system was needed. The two grids now
use the reference theme's icon-but-box / user-icon-but-box shell (four per row,
em16 icon, 12px muted label) in place of AKSRT's tinted 38px pill, and every
remaining sidebar glyph and arrow resolves through aksrt_icon_markup():

  我的服务  我的等级 trend-color, 官方认证 user-auth, 我的订单 order-color
  功能设置  消息通知 msg-color, 个人资料 user-color, 账户安全 security-color
  资产卡    余额 money-color-2, 积分 points-color
  中心卡    创作收益 merchant-color, 推广收益 money-color
  其它      VIP 入口 vip_1, 抽屉触发器 menu, 箭头统一 right

Dead code went with the swap: the whole .uc-service-grid block including the
unused --cart and --revenue tones, the count badge (no caller ever set count),
and the tone key. The two card selectors that targeted <i> now also cover <svg>
so the icon sizes survive the element change, and the VIP arrow keeps
uc-sidebar-vip__arrow for its hover animation. level/profile/settings gained
their missing tab keys, which completes the icon-but-* class names and lets the
existing .is-active sync cover all six entries.

Verification: node --test across widget-system, navigation, admin,
asset-ledger, dev, front-submit and fixtures (620 tests), plus php -l on the
five changed PHP files. Contract-locked in
tests/widget-system/user-center-sidebar-icons-contract.test.cjs.
2026-10-18 12:55:30 +08:00
kate522 1461c9fe27 fix(user-center): show brand-colored icons in social bindings
Wrap the provider glyph in the reference theme's .social-login-item circular {type} wrapper on the /user/settings/ bindings list so each row paints the provider brand colour instead of a muted ink glyph. Aggregated clogin_* providers strip the prefix for the colour class while data-provider keeps the full key for the bind/unbind flow. Drops the now-unreferenced .binding-item__icon rules from the user-center settings and profile lists stylesheets.
2026-10-18 11:47:28 +08:00
kate522 5641c40958 fix(header): stop the nav midline chip rule from collapsing the quick-login buttons
The top-nav guest popover rendered its quick-login buttons as 29px circles
with zero gap: github, gitee and qq were touching. The user-card widget, which
uses the same .social_loginbar markup, was fine at 32px with 10px gaps.

header-userbox.css carries an AKSRT-specific rule, absent from the reference
theme, that gives every .toggle-radius inside .navbar-form a 29px midline
circle:

  .navbar-top .navbar-form .toggle-radius { flex: 0 0 29px; margin: 0; }

It exists for the account-row .badg chips, the theme toggle and the language
toggle. The popover social buttons are .toggle-radius too and also sit inside
.navbar-form, so they were caught by it. Two independent mechanisms broke the
geometry: flex-basis outranks width in a flex container, so flex: 0 0 29px beat
the .social_loginbar .social-login-item width: 32px !important; and the rule
(0,3,0) specificity outranked the (0,2,0) margin: 3px 5px.

Exempt the social buttons with :not(). The chips, the theme toggle and the
language toggle still match, and the popover buttons fall back to the
.social_loginbar geometry ported 1:1 from the reference.

Verified on the live site by measuring computed styles in headless Edge: the
popover buttons go from 29px/0px margins/0px gap to 32px/5px margins/10px gap,
the user-card widget is unchanged, and the account-row chips keep 29px (theme
toggle) and 36px (.badg). Rebuilt the public bundle so the exemption survives
the build, and contract-locked the selector plus the bundle copy.

Verification: node --test across widget-system, navigation, admin,
asset-ledger, dev, front-submit and visual (615 tests).
2026-10-17 17:56:56 +08:00
kate522 0bee2c605e refactor(auth): stop pre-escaping the OAuth provider URLs
get_auth_providers_data() returned esc_url()-ed URLs while all four of its
consumers (the modal, the user card, render_social_buttons and the comment
form) escaped again on output. Its sibling get_frontend_bindings_data() stores
the raw URL and lets the binding list escape it, so the class was inconsistent
with itself.

I first reported this as a double-escaping bug that broke ampersand-bearing
URLs. That was wrong: I had verified it with a stub that modelled esc_url() as
htmlspecialchars(). The real esc_url() runs wp_kses_normalize_entities()
first, which explicitly decodes double-encoded character references, so
esc_url() is idempotent on its own output. Re-checked against the WordPress
source with a faithful transcription, and re-ran the popover markup: the
WeChat href is ?tab=oauth&#038;oauth_intent=login, one entity, unchanged.

So the escaping was redundant rather than broken. The producer now hands out
raw URLs and each surface escapes exactly once, which is where WordPress
expects it and matches get_frontend_bindings_data(). Rendered output is
byte-identical.

Extended the auth source contract to pin the invariant: the producer stays
raw and all four surfaces escape on output.

Verification: node --test across widget-system, navigation, admin,
asset-ledger, dev, front-submit and visual (614 tests), plus php -l.
2026-10-17 14:54:10 +08:00
kate522 c472045ce4 fix(auth): align the social-login surfaces with the reference theme
The "other login methods" block drifted from zibll on three axes. Gitee was
mapped to fa-code-branch because FA5 Free ships no Gitee brand glyph, so the
button showed a generic branch icon; Google showed the monochrome FA glyph
instead of the reference's multi-colour mark. The modal also printed an
implicit agreement line under the social buttons, which the reference guest
card does not have. And the top-nav user popover built its own
.social-login-divider / .oauth-row / .oauth-btn markup and then wrapped that
in a second separator and .social_loginbar, so the block rendered nested and
unlike the reference.

aksrt_brand_icon() now routes zibsvg-/customsvg- values through the shared
icon renderer, and the brand map resolves gitee to zibsvg-gitee and google to
zibsvg-google-color. Both sprite keys already exist in the runtime symbol
sheet, which the loader pulls in on every frontend page. Because
get_auth_providers_data() and binding_row() both go through
aksrt_brand_icon(), one map change fixes the icon on the modal, the user card,
the top-nav popover, the user-center binding list and the profile social
links.

render_social_buttons() is rewritten to the reference shape: an optional
.social-separator, a .social_loginbar, and one .social-login-item <type>
toggle-radius anchor per enabled provider with rel="nofollow" and a
"<label>登录" title. oauth_button_lg switches to the button-lg variant with a
label, and the WeChat entry carries qrcode-signin so auth.js opens the QR
panel instead of navigating, matching the reference. The navbar and the link
apply modal now append the block verbatim instead of re-wrapping it, and the
user-card loop adopts the same item attributes. The retired
.social-login-divider / .oauth-row / .oauth-btn rules are dropped from
base-1.css, and the binding-list icon rule gains an svg selector so the
sprite icons keep the 22px the FA glyphs had.

Verification: node --test across widget-system, navigation, admin,
asset-ledger, dev, front-submit and visual (613 tests), plus php -l on every
changed PHP file. Extended the auth source contract with the agreement-line,
brand-icon and popover-structure cases, and pinned the user-card guest
markup.
2026-10-17 14:42:42 +08:00
kate522 6dc9265260 fix(frontend): keep the widget link color off the check-in badge
sidebar.css paints .widget a with --color-ink-2 (specificity 0,1,1), which
out-specifies the .jb-* color group (0,1,0) the badge relies on for its white
ink. The user-card widget carries the .widget class, so the check-in bookmark
rendered dark-gray text on the orange gradient on widget pages. zibll sets no
link color on zib-widget anchors at all.

sidebar.css already restores .but colors for exactly this reason; extend that
restoration to .img-badge so the badge keeps its own --this-color. Rebuild the
homepage bundle and extend the badge contract test.
2026-10-17 13:02:46 +08:00
kate522 008e63524d fix(frontend): pin the check-in badge typography to the reference values
The badge inherited AKSRT's modern system font stack (--font-body) and the
html reset's antialiased/optimizeLegibility rendering, while the reference
theme renders its body in bootstrap's "Helvetica Neue", Helvetica, Arial,
sans-serif with browser defaults. Same 14px size but a different typeface
and smoothing, so the badge text still read as off-theme after the gradient
fix.

Pin font-family, -webkit-font-smoothing and text-rendering to the reference
body values on the existing scoped badge rule (alongside the line-height
pin) and extend the contract test.
2026-10-17 12:40:42 +08:00
kate522 cff3083a91 fix(frontend): align the user-card check-in badge with the reference theme
The legacy .jb-* compat layer in core/buttons.css restated .jb-yellow as #f99d4d -> #f7631d, and because the public bundle loads after main.min.css that value overrode the reference-accurate #f59f54 -> #ff6922 gradient, so the check-in bookmark (and every other .jb-yellow surface) painted the wrong orange. Correct the gradient and rebuild the public bundle.

Also drop the dead is-done class and the attribute-less span wrapper from the signed card badge, which the reference theme does not have. The header dropdown variant keeps its class, locked by the navigation contract.
2026-10-16 17:32:06 +08:00
kate522 4d69aaee50 fix(frontend): scope the credits button skins to the user-center shell
The user-center route rendered a 4px top-nav publish chip while every other
route kept the 50px pill. assets/css/routes/user-center/credits.css restated
border-radius on bare legacy colour modifiers (.but.jb-blue, .but.b-blue-2,
.but.c-blue-2.hollow, .but.c-green.hollow). Those rules are (0,2,0) and load
after public.css, so they out-rank .but.radius's 50px and flatten the publish
chip, which is newadd-btns but jb-blue radius, down to 4px.

The same leak also repainted every site-wide .but.jb-blue outside the panel
with the flat #3b82f6 skin instead of the legacy gradient, so login, submit,
badge and cashier buttons lost their reference surface on that route too.

Scope the four rules to .uc-content, the shell every credits panel and its
modals render inside (templates/pages/page-profile.php wraps
templates/parts/user-center/panel.php). The panel keeps its own button skins
and the top-nav chips get the reference pill back.

The .btn-block restatement that sat in the same block is byte-identical to
the one in core/buttons.css, which loads earlier on every page, so it could
never win; it is dropped rather than scoped.

Verified: node --test across tests/ (604 passing), plus a stubbed navbar
rendered against each route's real stylesheet set measuring the computed
border-radius of the publish chip and of the panel's own buttons. A new
contract test fails on the previous stylesheet and passes on this one.
2026-10-15 23:22:18 +08:00
kate522 ee39024549 fix(header): put the top-nav clusters on one horizontal line
The top-nav account row carried min-height: 50px on top of the 8px
margin-top that Bootstrap base .navbar-form rule keeps at >=768px (the
override only zeroes the horizontal margins). The 8px was counted twice,
so the row ran from 8px to 58px: its icon tiles landed 8px below the 25px
midline that the 50px menu row and the logo define, and the navbar grew
from 50px to 58px, which also broke the body.nav-fixed offset.

Drop the min-height so the row takes its natural 34px height from the
icon tiles, and give the theme/language group the same 34px cross-size so
its 29px toggle shares that midline instead of sitting 2.5px above it.
Measured against the reference theme, the navbar goes from 66px to
50.05px (reference 50.28px) and the logo, menu, publish chip, VIP chip,
theme toggle and account icons all centre on 25px.

The desktop navbar stays visible below 991px (only the menu folds into a
dropdown there), so the same fix applies at the mobile and tablet
breakpoints: the top bar drops from 68px to 52.05px.

Verified: node --test across tests/ (603 passing), and a stubbed navbar
render measured in a headless browser against the reference theme
stylesheet.
2026-10-15 21:03:27 +08:00
kate522 5810693e01 fix(header): restore the payvip surface on the top-nav VIP chip
The 开通会员 chip rendered in the .jb-red orange gradient instead of the
legacy metallic .payvip-icon gradient. The markup always carried
payvip-icon, but core/buttons.css restates the legacy .but block and is
bundled into public.css, which loads after main.css. Same specificity,
later in the cascade, so its background / color / border / text-shadow
declarations outrank .payvip-icon and replace the payvip surface with
whatever .jb-* skin happens to be on the element.

The reference theme has no such compatibility layer, so there its
.payvip-icon declarations are the last word and the chip shows the
metallic gradient with #ece0e0 text, no border and no text shadow.

A previous fix restored the surface only for the user-card CTA, scoped to
.aksrt-header-userbox .pay-vip.but.payvip-icon, which left the top-nav
chip (and the guest CTA, and the user-card status bar) on the wrong
gradient. Re-assert the legacy .payvip-icon declarations unscoped in
header-userbox.css, which loads after core/buttons.css, and complete the
property set with border: none and text-shadow: none so the chip no
longer carries the 1px transparent border the .but block adds.

Verified with a cascade resolver that replays the real stylesheet load
order (bootstrap, main, public, header-userbox) against the reference
theme's own order: background, color, --this-color, border, text-shadow,
border-radius, padding, margin, font-size, display, overflow and position
now resolve identically for both the publish and the VIP chip.
2026-10-15 13:44:01 +08:00
kate522 0722c4f00e fix(header): restore reference VIP chip geometry and cluster split
The VIP chip never received the reference .but geometry. Commit f03d72c8
narrowed the legacy selector from a descendant to a child combinator as a
side effect of an unrelated user-popover fix, and the contract test then
pinned the narrowed form. Because the VIP anchor lives inside the
hover-show wrapper rather than being a direct child of .navbar-but, it
fell back to the bare .but defaults: 15px type instead of 0.8em, no
0.4em margin, and no padding override.

Restore the descendant selector so both chips share one geometry:

- .navbar-top .navbar-but .but is byte-identical to the reference again,
  giving the VIP chip font-size 0.8em, padding 0.3em 1em !important and
  margin 0.4em
- the publish and VIP anchors are plain inline-block .but elements again
  (the merged flex container no longer blockifies them)
- ml10 is back on the VIP anchor; .navbar-top .navbar-but .but overrides
  it exactly as it does in the reference, so it stays markup parity
  without changing the computed margin

The right cluster was also merged into one .aksrt-navbar-actions flex
container with a 10px gap and zero padding, while the reference emits
three floated .navbar-form.navbar-right groups whose 15px padding is what
produces the inter-cluster spacing. Split them again: publish/VIP in
.navbar-but, theme toggle plus language switcher in a flex ac group, and
the account row in its own group. Measured against the reference, the
publish-to-VIP gap was 19.6px instead of 9.6px, and VIP-to-theme-toggle
was 14.8px instead of 34.8px.

The container rules in header-userbox.css are re-anchored onto the
reference structure: .aksrt-navbar-account owns the only remaining flex
context, .toggle-radius and .aksrt-navbar-language are scoped through
.navbar-form, and the hover-show rule targets .navbar-but. Coverage of
those selectors is unchanged, so the theme toggle, language switcher and
message icon keep their current geometry.

The .aksrt-navbar-actions wrapper and its gap are gone; a negative
assertion now guards against reintroducing them.

Verified: node --test across tests/ (603 passing), php -l on the changed
PHP files, and a stubbed render of aksrt_menu_button() for guest,
logged-in and layout 2.
2026-10-15 12:19:24 +08:00
kate522 4f2edebab6 fix(header): align publish and VIP CTAs with reference theme
The top-nav publish chip and open-membership chip carried an AKSRT-only
class layer (navbar-cta / navbar-cta-text / navbar-newposts /
navbar-vip-hover) plus a matching set of CSS overrides that replaced the
legacy .but geometry: margin 0.4em -> 0, an extra min-height and flex gap,
an FA5 pencil glyph that is not the reference pencil, and label text
wrapped in a span. The VIP hover card also had its own positioning
override instead of the reference static position.

Drop that layer and emit the reference class combination verbatim so the
already byte-identical legacy stylesheet owns the geometry again:

- publish chip renders newadd-btns but nowave <skin> [radius] btn-newadd
  with the label echoed as saved (no injected icon, no span)
- VIP chip renders pay-vip|signin-loader but jb-red radius payvip-icon
  with the vip_1 SVG at em12 mr3, inside span.hover-show.inline-block
- user-card CTA uses the vip_1 SVG (em12 mr6) instead of fa-gem and drops
  vip-level, matching zib_get_header_payvip_icon()
- both buttons now honour Settings::close_sign(), matching the reference
  zib_is_close_sign() gate and the floating publish button
- header-userbox.css keeps one rule that restores the hover wrapper to a
  plain block container so .sub-vip-card takes its reference static
  position; every other override is gone
- nav_newposts_text default drops the duplicated fas and uses the FA5
  spelling of the reference pencil (fa-pencil-alt, not fa-pen)

Dead code removed: aksrt_normalize_header_cta_label(), the unreachable
multi-type dropdown branch of aksrt_render_new_add_btns() (only 'post' is
supported and only ['post'] is ever passed), and the
newadd-btns__toggle / newadd-btns__item / drop-newadd markup it emitted.

ml10 is no longer set on the VIP anchor: .aksrt-navbar-actions already
supplies the 10px gap the reference gets from ml10, and keeping both
would double it to 20px.

No migration for sites that already stored the old nav_newposts_text.

Verified: node --test across tests/ (603 passing), php -l on the changed
PHP files, and a stubbed render of every publish/VIP branch.
2026-10-15 11:54:54 +08:00
kate522 ce6fbb4e39 chore(tools): refresh admin contract report
重新生成契约审计报告:微信 REST 回调补齐后 errors 归零,build 清单与 schema 域同步到当前树。
2026-10-13 21:01:12 +08:00
kate522 cf39e72126 feat(extensions): align Baidu push with reference theme
与参考主题(zibll)1:1 对齐百度推送,并清掉存量双路与死代码:

- 新增普通/快速收录通道开关,快速收录走 &type=daily;自动推送 JS 与参考实现一致

- 分类法 saved_term 推送(可推送类型推导公开分类法,排除 post_format,aksrt_baidu_push_taxonomies 可过滤)

- 推送结果写入 meta 去重,成功后不重复提交;编辑页 Metabox 展示结果并提供「重新提交」(nonce 校验后强制推送)

- 配置面收敛到扩展页单一 canonical 键:删除 SEO 页 seo_baidu_* 重复面与两处合并逻辑,不写迁移兜底

- 清理死代码:push_batch(零调用)及 batch_size、从不写入的 baidu_push_verified、零消费的熊掌号 xzh_* 七键;baidu_push_log_enabled 接入 log()

新增 PHP harness 驱动真实逻辑(通道/去重/重试/重新提交/术语/面板渲染)+ 参考主题契约测试;tests/admin 与 tests/widget-system 全量通过。
2026-10-13 21:01:03 +08:00
kate522 740bd48f42 refactor(users): drop dead WeChat notify settings surface
users/wechat schema 页写入 aksrt_settings 的 wechat_notify_* / wechat_app_* / wechat_template_* 等 15 个键全仓无任何消费方,与活配置面 aksrt_wechat_settings(WeChatNotify 读取、/users/wechat-template 页管理)构成双路;PHP 与 JS 两份 schema 的字段集早已不一致(15 vs 10),是死面腐烂的直接证据。

移除 SchemaUsers 的 wechat 分组、JS schema 文件及其注册、菜单 schema 页条目,微信配置收敛为单一自定义页入口;契约测试锁定死键零残留与活配置面唯一。
2026-10-13 21:00:49 +08:00
kate522 18e579a8c6 fix(admin): restore WeChat template message REST callbacks
/wechat/status 与 /wechat/settings 自引入起只注册了路由、从未实现回调方法,后台「微信模板消息」页调用即 500。

按既定设计补齐:状态读取委托 WeChatNotify::get_status()(其既定用途即 REST 与 AJAX 复用),写入委托 Settings::update_wechat_settings() 并由 Integration 收敛键白名单;前端空密钥下发掩码 *** 时保留已存密钥,避免把密钥写成字面量。

新增 PHP harness 驱动真实 UserController + Integration + WeChatNotify 验证状态形状、掩码保留、缺省沿用、未知键丢弃与输入消毒。
2026-10-13 21:00:35 +08:00
kate522 cdab7a5e5f refactor(admin): drop AI connection config page for WP AI Connectors
Remove the "AI 连接配置" page under 扩展&增强 together with the theme's
own OpenAI-compatible provider path. AI generation (SEO, summary, tags)
now runs solely on the WordPress AI Client (wp_ai_client_prompt), with
connections configured through WordPress AI Connectors.

- AI.php keeps only the WP AI Client path: no provider config storage,
  no theme fallback, no rate limiting
- Drop /ai/status, /ai/settings and legacy /ai/seo-suggest routes plus
  their handlers
- Remove dead compat code: AISEO::suggest, AIPrompt::build_seo_prompt
  and the front-submit AI assist flow (its buttons were never rendered
  and its endpoints were never registered)
- Update SEO schema/meta-box guidance that pointed at the deleted page
- Add ai-config-removal contract test and refresh the toast and
  user-features contracts affected by the cleanup
2026-10-13 19:32:34 +08:00
kate522 bd01199930 fix(navigation): restore header VIP CTA gradient 2026-10-13 17:33:01 +08:00
kate522 7ed739615e fix(navigation): align header wallet tiles with reference 2026-10-11 14:55:23 +08:00
kate522 60e4d67772 fix(frontend): match wallet tile icon colors 2026-10-11 13:49:20 +08:00
kate522 7287cfeaec fix(frontend): restore reference VIP expiry geometry 2026-10-11 13:31:02 +08:00
kate522 f03d72c880 fix(frontend): restore reference user popover button geometry 2026-10-11 13:14:13 +08:00
kate522 6e90a96a15 fix(frontend): align header checkin width 2026-10-11 12:40:37 +08:00
kate522 b368678662 fix(frontend): make header checkin button full width 2026-10-11 11:40:10 +08:00
kate522 40b9a3f36b fix(frontend): align the VIP expiry badge with the reference semantics
到期徽章改成参考主题 zib_get_user_vip_exp_date_text() 的语义:只有真的拿到到期时间才渲染这枚徽章,已过期显示「会员已过期」否则显示裸日期;「永久会员」只留给显式永久会员;拿不到到期数据时参考主题返回 false,整枚徽章都不渲染。

之前只要 expires 为 0 就无条件显示「永久会员」,于是订单来源且没有到期时间的会员也会多出一枚徽章,把会员条撑宽、内容被挤到换行,观感和参考主题对不上。

同时不再依赖 aksrt_get_vip_info():它的 expires_label 是另一套口径,返回值里也没有 is_permanent 字段。改读 aksrt_get_user_vip_state(),和等级判断共用同一个数据源。

用无头浏览器把参考主题卡片和本站卡片并排渲染,逐行量了 offsetHeight:总高 306px,user-info 43、会员条 37、到期徽章 20、签到 31、钱包 53、统计 27、操作行 81,两套完全一致;没有到期数据时会员条也一致。另外确认 vipbg-v2 在参考主题全部 59 个样式表里都没有定义,之前删掉它不影响任何视觉。

新增 3 条断言锁住到期逻辑,573 项测试全部通过。
2026-10-10 17:17:39 +08:00
kate522 6fd80be243 fix(frontend): match the reference check-in icon weight
签到按钮的图标改成参考主题的字重。参考主题用的是 fa fa-calendar-check-o,即 FA4 的轮廓版日历勾选;之前换成了 FA5 的 fas fa-calendar-check,那是实心填充的,观感不一样。现在改成 fa far fa-calendar-check,走 FA5 的 regular 字重恢复轮廓外观,同时仍然满足全局 FA5+ 图标系统的要求。

间距也跟着对齐:弹窗里的两个分支去掉 mr6,改回参考的做法,由 .but > .fa:not(.fa-angle-right) 的 0.25em 外边距加上图标后面的一个空格来撑开。用户卡片徽章分支保留 ml3 mr6,因为它不是 .but,拿不到那条外边距。

改之前核对了字体:解析 fa-regular-400.woff 的 cmap 表,确认 calendar-check(U+F274)确实在 regular 字重里,否则图标会直接消失不见。顺便把卡片用到的全部图标逐个比对了 solid 与 regular 的覆盖情况,包括之前修过的 gem 水印(U+F3A5),都在。

main.css 里本来就有 .fa.far 的 font-weight 400 规则,不会被 solid 权重盖掉。新增 4 条契约断言锁住图标字重与间距,573 项测试全部通过。
2026-10-10 16:58:01 +08:00
kate522 855e85e7db feat(frontend): restore the posts count badge and drop dead vipbg-v1 rules
统计行补回「文章」徽章。上一轮按「项目不做帖子」的要求把它删掉了,现在这个判断反过来了,于是恢复:位置回到参考主题的顺序(文章在最前),计数仍走 count_user_posts,图标改用全局 FA5+ 的 fa-file-alt,与其余徽章(评论 / 点赞 / 人气)保持一致。

删掉 main.css 里 .vipbg-v1 与 .vipbg-v1:before 两条死规则。全站没有任何地方使用这个类,而且 :before 里的 f006 是 FA4 码位,在 FA5 中映射不到任何字形,留着只会让人以为水印能出来。

两个契约测试里原本断言统计行不输出文章徽章,改回断言输出,并补上 fa-file-alt 的校验。

重建 main.min.css;public bundle 本次未变,因为它打包的 header-userbox.css 没有改动。573 项契约测试全部通过。
2026-10-10 16:42:16 +08:00
kate522 7e7bf6db23 fix(frontend): restore reference VIP watermark and action-row geometry
会员条右下角那枚淡淡的 gem 水印一直没渲染出来:.payvip-icon:before 留着参考主题的 FA4 码位 f219,但本站字体栈是 FA5,该码位在 FA5 里映射不到任何字形。改为 f3a5(fa-gem,Solid 字重),这正是参考主题水印的实际内容。

操作行(用户中心 / 我的订单 / 支付中心 / 后台管理)的几何被 header-userbox.css 里的自有规则盖住了,而 main.css 里本就有参考主题的对应规则:瓦片被强制成 flex 1 1 0 加 min-width 0,容器加 4px  gap,标签还被截断。删掉这些覆盖,只保留 max-height none 这一处对全局导航链接 50px 高度上限的豁免,让 .header-user-href > a(inline-block、padding-top 10px、min-width 58px)和 .header-user-href .toggle-radius(36px)重新接管。

同时删掉 360px 断点下把图标瓦片缩到 34px 的规则,参考主题没有对应物;移动端宽度对齐参考主题的 auto。

排查中确认 main.css 整体是对参考主题的忠实移植:93 条布局工具类、8 条 padding 工具类、.sub-user-box / .header-user-href / .msg-news-icon / .payvip-icon 规则以及颜色变量均逐条一致,因此上述两处就是仅有的缺口。另外核对 f3a5 在随包 FA5 Solid 字体中可解析,fa-solid-900.woff2 确实存在。

重建 main.min.css 与 public bundle,仅这三个构建产物发生变化,JS bundle 字节级一致。573 项契约测试全部通过。
2026-10-10 16:37:01 +08:00
kate522 6f815678ec feat(frontend): replicate reference header user box 1:1
顶导航点击小人弹出的用户信息浮层,从 AKSRT 定制结构改为与参考主题 zib_header_user_box() 逐行同构:头像链接包裹、钱包双瓷砖、会员条、签到、消息图标、统计徽章行全部改用参考主题遗留类,渲染交回 main.css(.sub-user-box 242px、.avatar-img 38px、.msg-news-icon、badge.top、.payvip-icon 深色渐变加 gem 水印、.vip-expdate-tag、.muted-box)。

头像改走 aksrt_get_avatar_box(用户ID, avatar-img, true, false),得到参考的 a 链接包裹 avatar-img 结构。消息图标改参考标记 msg-news-icon abs-right 加 toggle-radius msg-icon,未读徽章用 badge.top,avatar_dropdown 位置门控契约保留。管理端行在「后台管理」前插入「支付中心」,跳 admin.php?page=aksrt_pay,即参考主题「商城中心」槽位,图标 fa-credit-card。

钱包重写为参考双瓷砖:余额在前(c-blue-2 加 fa-money-bill-wave)、积分在后(c-yellow 加 fa-coins),分别按充值开关与积分总开关门控,两块皆关则不渲染,并保留 data-aksrt-wallet-value 与 data-aksrt-points-value 两个 JS 刷新钩子。会员条去掉 AKSRT 自创的 vipbg-v2 level-N 金色背景,改参考的 payvip-icon 加 badg jb-yellow vip-expdate-tag,到期文案按参考语义分为已过期、永久会员、裸日期三种。签到入口由 button 改为 a.but block c-yellow,并去掉随之失效的 compact 形参,data-signin 挂载与 wp_footer 验证码不变。统计行删掉文章徽章(本站不做帖子),图标由 SVG sprite 换 FA5+ 的 fa-comment-dots、fa-thumbs-up、fa-fire。

会员等级图标契约不动:aksrt_get_vip_icon 与 User\Level::get_badge_html 的调用与参数保持原样。

顺带清死代码:aksrt_header_user_badges_row 原本取到函数后直接返回空串,现在真正输出统计行;删掉 header-userbox.css 里整套 bespoke 规则与 vip-modal.css 的 vipbg-v2 死规则。

显式偏差:统计行链接保持扁平用户主页 URL(AKSRT 用户主页是干净 URL 加 JS tab,没有 query tab 路由);签到入口沿用积分签到总开关门控,不新增管理端可配置项。

public.css 与 public.min.css 用 tools/build-public-assets.mjs 重建:该 bundle 打包了 header-userbox.css 且作为依赖先于单模块入队,旧的 width 260px 会压过新源码、把卡片钉在 260px 而不是参考的 242px。

验证:node --test tests/**/*.test.cjs 573 项全通过;六个改动 PHP 文件 php -l 无语法错误;卡片用到的 FA5 token 逐个核对 5.15.4 全部命中。未做真实视觉效果验收。
2026-10-10 16:17:01 +08:00
kate522 8ff74f56ee fix(admin): use FA5 long-arrow icons in post list sort
后台文章列表的排序箭头还停留在 FA4 的 fa fa-long-arrow-down / up,而且类名是字符串拼接出来的。契约测试按完整的 fa fas fa-* token 逐个校验,看不穿拼接后的类名,所以这条一直是漏网之鱼。

改为按升降序直接输出完整的 fa fas fa-long-arrow-alt-down / fa-long-arrow-alt-up 两个字面量,走全局 FA5+ 图标系统。

验证:node --test tests/**/*.test.cjs 全量 572 项通过;php -l src/Admin/Native/PostListEnhance.php 无语法错误。
2026-10-10 16:15:48 +08:00
kate522 a3b708f9d4 fix(frontend): keep user center and user home avatar frames rounded
The two large hero avatar frames go back to their legacy rounded-square look: the user center profile header keeps 6px and the public user home hero keeps var(--radius). Both declare an explicit radius because the global avatar primitive (img.avatar, 0,1,1) ties them on specificity and only the later route rule wins the cascade.

Everything else stays: the primitive still circles the bare avatar-mini in the ban and report modals, the VIP modal avatar and the post-card meta avatar, the friends page avatar is circular, and the VIP icon still leads the badge row. The contract test now guards the two hero frames as rounded-square so the primitive cannot silently re-circularize them.
2026-10-10 13:59:47 +08:00
kate522 9cb5e7bea3 feat(frontend): unify avatar shape, move VIP icon to badge row
Make --radius-avatar the single source of truth for frontend user avatar shape. A new primitive in components-ext.css (img.avatar / avatar-mini / avatar-md / avatar-lg / .avatar-img img) forces a circle on every avatar image, which covers the bare avatar-mini in the ban and report modals, the classless img inside the VIP modal's .avatar-img, and the post-card meta avatar that previously depended on post-card.css being enqueued. The user center header frame, the public user home hero and the friends page avatar switch to the same token because route CSS loads after the public bundle and would otherwise override the primitive.

The VIP icon no longer rides the avatar corner. The user card widget, the after-article author card and the sign-in modal stop opting into aksrt_get_avatar_box()'s corner badge, and the icon renders first in the badge/level row instead, since aksrt_get_user_name already orders name -> VIP -> auth -> medal -> level. The sign-in modal appends it after the display name and keeps the legacy precedence where a ban badge takes the VIP slot. The user card widget name row now shrinks its display name so the extra icon cannot overflow a narrow sidebar.

Verified with the new frontend-avatar-shape-contract test plus post-visual, user-card-runtime, circular-button-icon-margin, user-features and page-display contracts, and php -l on every changed PHP file. The public bundle was rebuilt with tools/build-public-assets.mjs.
2026-10-10 13:53:07 +08:00
kate522 16b57dcf1c fix(after-sale): 前台售后不再展示「退款金额」字段
并非每次售后都真的退款——换货、补偿、仅作记录的情况都会把工单标成已完成,
此时退款金额要么是 0 要么没有实际意义,摆在前台只会让人误解钱已经退回去了。
两个前台入口都撤掉该字段:

- 售后详细弹窗:明细行只留「申请时间」,处理记录时间线不受影响
- 订单详情弹窗的售后记录卡片:撤掉退款金额后 meta 的 dl 会空壳,连同
  dl 一起移除;卡片仍保留状态徽章 + 申请时间 + 原因 + 「查看详细」

连带清理死代码:aftersaleAmountText / afterSaleAmount 两个格式化助手各自
只剩这一处调用,orders.css 里 5 条 .aksrt-order-detail__after-sale-meta
规则也随之无人引用。后台售后页与订单明细页的退款金额列不动——那边是运营
核对账目的口径,需要看到金额。

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;抽取
buildDetailHtml 跑样例载荷,确认退款金额不再出现、申请时间 / 处理记录 /
再次申请售后按钮都还在。未做浏览器视觉验收。
2026-10-10 13:04:37 +08:00
kate522 1875470ac7 fix(after-sale): 售后工单已完结仍可再次申请,改由订单是否已退款决定
上一个提交把「工单 completed」当成了不能再申请售后的判据,方向搞反了:
能否再售后应该看订单有没有被标「已退款」,而不是看工单状态。订单已退款时
status_group 变成 refunded、enabled 直接为 false,那道闸门本来就够用;用工单
状态再判一次,反而把「工单已完结但订单没退款」的用户堵死,售后详细弹窗底部
也只剩下一个「我知道了」死胡同。

- reapplyable_statuses 补上 STATUS_COMPLETED:rejected / cancelled / completed
  一律放行再次申请,售后详细弹窗底部按钮对已完成订单渲染「再次申请售后」
- can_apply 还原为只看有没有进行中的工单(!open),不再二次校验工单状态
- create() 的重复申请闸门还原为 find_active():只挡仍在流转的工单,
  completed 不挡;订单已退款由上面的 STATUS_PAID 校验先挡掉
- 点「再次申请售后」后清 has_application 落回申请表单,提交走 create()
  新建工单,链路本来就通,无需改动

harness 场景名改为 settled_ticket_still_allows_reapply,断言 completed /
rejected / cancelled 三种已终结状态都能新建工单;待审核工单同因幂等、换因
挡住的场景保留。

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;after-sale-log-harness
7/7。未做浏览器视觉验收。
2026-10-09 18:38:18 +08:00
kate522 c955fd35f2 fix(after-sale): 售后工单已完结即不可再申请,不看订单是否另标已退款
原来的重复申请闸门只看「进行中」的工单(find_active = pending/processing),
于是工单已 completed、但订单没被另标「已退款」时,订单还能再发起一次售后。
已退款订单之所以不能再申请,是因为 status_group 变了导致 enabled 为 false,
属于间接效果;工单层面的规则一直是漏的。

- create() 改为以 latest_for_order() 为准:completed 直接挡掉;待审核/处理中
  仍走同人同因幂等(返回原工单),rejected / cancelled 放行再次发起
- after_sale_state() 的 can_apply 同步收紧,与 can_reapply 同一口径:
  有工单时只有 rejected / cancelled 才放行,completed 不再漏过去
- 界面上售后详细弹窗的「再次申请」本就只对 rejected / cancelled 渲染,
  这条改动把服务端与 API 口径补齐,堵住绕过前端直接打 REST 的口子

harness 补两个场景:completed 挡住而 rejected / cancelled 放行;待审核工单
同因幂等返回原单、换因挡住。

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;after-sale-log-harness
7/7(新增两场景);dev harness 渲染两个后台页面与 unchecked 处理场景均正常。
未做浏览器视觉验收。
2026-10-09 18:25:02 +08:00
kate522 36b3ba6f45 fix(after-sale): 订单详情弹窗不再渲染售后状态节点进程
订单详情弹窗的「售后记录」卡片把整条处理时间线铺在了卡片里,和点开
「查看详细」后的售后详细弹窗重复,卡片本身也被拉得很长。改为只保留状态
徽章 + 申请时间、原因/申请理由、退款金额与「查看详细」入口;完整处理记录
由售后详细弹窗独占呈现,信息不缺、入口也还在。

- 删掉卡片里的 orderDetailHandlingLogHtml(item.handling_log) 调用
- 连带删除随之无人调用的 orderDetailHandlingLogHtml / handlingEntryTitle /
  AFTER_SALE_STATUS_LABELS 三个助手
- recordPayload 仍带 handling_log,点开详情弹窗照旧能看到时间线
- modal-runtime.css 的时间线规则不动:售后详细弹窗还在用,无需重打包

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;契约测试改为断言
订单详情弹窗不再出现 timeline 标记与三个助手,同时锁住「查看详细」载荷仍带
handling_log;node --check 通过。未做浏览器视觉验收。
2026-10-09 18:12:48 +08:00
kate522 039fa6d863 feat(after-sale): 处理记录时间线改为「节点 + 状态 + 时间」同一行
原实现把状态与时间竖排在节点右侧的 88px 固定列里,状态文字一长就折行、
时间被挤到第三行,和右侧备注也对不齐。改为节点独占一列,状态与时间包在
head 里紧随其后,处理备注另起一行:

- [节点][处理状态][时间] 一行,状态色仍按工单状态走 --as-accent
- 竖线改挂到条目的 ::before(原来是 aside 的),top 从 12px 调到 14px,
  与加了 4px 上边距的节点底部对齐,绳子依旧穿过每个节且末项不收口
- head 用 flex + baseline + gap:8px,状态过长时时间自然换行而不重叠
- 售后详细弹窗与订单详情弹窗共用同一套标记,两侧同步改

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;用 node 分别抽取
两个 JS 里的时间线构造函数跑样例载荷,确认输出为
[节点][状态][时间] + 另起一行的备注;重建 public.css / public.min.css,
diff 仅限时间线规则。未做浏览器视觉验收。
2026-10-09 18:01:16 +08:00
kate522 26ce9a5904 feat(after-sale): 售后处理记录改为每次提交留痕并新增时间线
工单行内新增 handling_log 列,用户申请、用户撤销与后台每次改状态都落一条
「状态 + 备注 + 来源 + 时间」,按时间正序存放、上限 50 条(对齐
MetaAssetService::MAX_RECORDS)。set_status 因此变成读-改-写,补上
GET_LOCK/RELEASE_LOCK 包裹,并在锁内重读工单快照——锁外取快照仍会被并发
追加覆盖。

三处界面由「只显示最后一条」改为回放整条处理链路:

- 后台订单明细「售后记录」按工单渲染头部(状态/金额/申请时间)+ 原因 +
  完整处理记录
- 售后面板手风琴「处理备注」单独占整行并置于「申请说明」上方,删掉已无
  意义的「审核时间」
- 前台售后详细弹窗与订单详情弹窗的售后记录改为时间线:左列绳结状态条
  (节点 + 状态 + 时间),右列处理备注;样式只在 modal-runtime.css 定义
  一份,两个弹窗共用

清理随之失效的死代码:工单的 admin_note / reviewed_at / completed_at,以及
全仓库无写入方的 gateway_request_id / gateway_refund_id / gateway_result;
连带删掉 gatewayResultText()、uninstall 里从未存在过的 aksrt_after_sale
表项,并把两处后台页面重复的状态标签数组收成 after_sale_status_labels()、
重复的 .aksrt-as-log 样式收成 after_sale_log_css()。

顺带修复 88669f69 重构退款流水线时误删 refund_amount()、却仍在 create()
里调用的问题——此前任何用户提交售后都会直接 fatal。

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;新增
after-sale-log-harness.php 驱动真实 AfterSaleService,覆盖首条日志、空备注
也留痕、撤销记录、50 条上限与畸形行规范化;两个后台页面经 dev harness
渲染校验「处理备注」宽行位于「申请说明」之前;未做浏览器视觉验收。
2026-10-09 13:59:50 +08:00
kate522 31e6ba00af fix(after-sale): 售后举证缩略图去掉原生 loading 属性
lazyload 契约测试全仓库禁 loading=lazy/eager,售后服务 accordion 详情里的
举证缩略图带着该属性,是唯一一处违规(80373746 引入,早于本次分页改造)。

去掉它:后台页面不加载 lazySizes 运行时,原生 loading 也不会被主题那套
接管;与 UsersMenusVerify 的后台缩略图保持一致(光 <img src>)。

验证:tests/widget-system/ 459/459 通过(此前 458/459)。
2026-10-09 12:04:48 +08:00
kate522 89ae462610 Merge pull request 'fix(admin): 支付中心分页条改回四按钮式' (#39) from worktree/pay-pages-pagination-style into main
Reviewed-on: #39
2026-10-09 03:56:25 +00:00
kate522 afd08885fc fix(admin): 支付中心分页条改回四按钮式
AdminListPagination 的输出标记从 WP 原生 paginate_links() 换回「卡密管理」
原版的四按钮式:`« ‹ 第 X 页,共 Y 页 › »` +「每页 X 共Y」计数,没有数字页码
链接。七个页面调用点不变,外观统一为卡密管理那一套。

- 删除 $total_pages <= 1 早退:只要总数 > 0 就渲染分页条,单页时四枚按钮全
  disabled —— 与原版 render_pagenavi 行为一致
- 基准 URL 与页码夹取沿用现有实现(显式拼装、夹到 [1, total_pages]),
  这两处缺陷修复不动
- 标题属性由 esc_attr_e() 改为 esc_attr__():输出相同,且同文件与 dev harness
  都只用后者
- 新增私有静态 print_assets(),由 render() 调用并以 $assets_printed 保证每页
  只输出一次;内容只有 .tablenav-pages a.disabled { pointer-events: none; }
  及其注释。这些原生后台页面不加载主题 CSS,故随标记内联

清理 harness 中已无人调用的 number_format_i18n() 与 paginate_links() stub。

验证:tests/admin/ 76 项通过;tests/widget-system/ 458/459(仅剩与本次无关的
lazyload-contract 既有失败);用 git show HEAD~1 取出原 render_pagenavi() 与
新助手在 8 组输入(含单页、总数为 0、per_page 为 0、页码越界)下比对,规范化
空白后输出逐字节一致;harness 渲染售后面板确认四按钮标记与内联样式就位。
2026-10-09 11:49:16 +08:00
kate522 536ac67f3c Merge pull request 'feat(admin): 支付中心列表页统一为 WP 原生分页' (#38) from worktree/pay-pages-pagination into main
Reviewed-on: #38
2026-10-08 14:23:19 +00:00
kate522 7b83893cf6 feat(admin): 支付中心列表页统一为 WP 原生分页
七个支付中心列表页(订单明细 / 售后服务 / 付费文章 / 优惠码 / 创作分成 /
提现审核 / 卡密管理)的分页收敛到共享助手 AdminListPagination,改用 WP 原生
paginate_links() 渲染。

- 基准 URL 显式以 page 参数 + 当前筛选参数拼装,不再让两个参数的
  add_query_arg('paged', ...) 退回 $_SERVER['REQUEST_URI']:链接不再随当前
  请求形态漂移,列条件也不再丢失
- 售后服务从写死的 limit => 100 改为 limit/offset 分页,总数接上原本零调用的
  AfterSaleService::count();after_sale_status 先按状态常量归一,保证 count()
  与 admin_list() 口径一致,分页条不说谎
- 每页 20 条,统一来自 AdminListPagination::DEFAULT_PER_PAGE
- 每页在查询前夹取页码,越界时回退到最后一页并重查,杜绝「空表且没有分页条」
  的死胡同;页码夹取与早退也一并收敛到助手
- 提现审核的翻页链接排除 retry_id / retry_key 两个一次性动作参数
- 订单明细的标签页与翻页链接共用同一份筛选参数,不再各写一份

清理死代码:五个页面里近乎复制的 render_pagination()(其中订单明细与提现审核
两份从未被调用)、两页重复的内联 paginate_links 块、卡密管理的 render_pagenavi()
及其 docblock、PayChargeCards::DEFAULT_PER_PAGE 常量,以及只服务自定义禁用
按钮的 .tablenav-pages a.disabled 规则。

验证:tests/admin/ 76 项通过(新增 pay-pages-pagination.test.cjs,重写
charge-cards-pagination.test.cjs);用 git show HEAD 取出原文件对比,订单明细
与售后服务两页在新旧实现下渲染输出逐字节相同。
2026-10-08 22:22:08 +08:00
kate522 83c164a427 feat(admin): 订单明细页已支付订单新增「退款」按钮
操作栏「详情」右侧为已支付订单渲染「退款」按钮:点击经 aksrt_pay_refund_order 把订单推到已退款。

退款沿既有的受闸路径执行:OrderRepository::update_status 要求 allow_refund,且 can_finalize_order_refund 要求该售后工单仍为待审核/处理中。因此工单号由服务端按订单号反查 find_active 得到,客户端只提交订单号;没有进行中的工单时整次操作中止并给出可操作提示,不会出现订单已退款但无工单依据的状态。

按钮仅在已支付订单上渲染——已退款是已支付的唯一后继状态,其他状态点击也只会在服务端被拒。附带清理 AfterSaleService::find_active 上方一处早已错配的 @param 文档注释(描述的是 \,与该函数签名无关)。
2026-10-08 21:14:56 +08:00
kate522 5fd8c6fc31 feat(after-sale): 售后面板新增「标记订单已退款」勾选项
展开的操作面板在状态下拉与备注旁新增「标记订单已退款」勾选框:勾选后点击确认,会先把关联订单推到已退款,再更新售后工单状态。订单本已退款时勾选框呈现为不可改的已选状态,且不提交该标记。

顺序不能反:OrderRepository::update_status 的退款闸门要求售后工单仍为待审核/处理中,一旦 set_status 先执行就不再放行。因此退款失败时整次操作中止,避免出现「工单已关、订单未退」的半完成状态。订单号由服务端按 after_sale_id 反查,不经客户端传递。

AfterSaleService::get() 由 private 改为 public 以支撑该反查。dev harness 扩展为可驱动 handler 的六个场景,覆盖勾选/未勾选、重复退款、非已支付订单、已关闭工单与非法状态。
2026-10-08 21:03:23 +08:00
kate522 b733e41993 fix(admin): 售后展开面板删除渠道结果并铺满状态面板宽度
展开详情去掉「渠道结果」字段,并清理其已无人读取的 gateway_result / gateway_text 解析。详情字段栅格由 auto-fill 改为按断点固定列数:普通字段 10 项可被 2 / 5 整除,配合整行的「申请说明」,行尾不再留白。状态面板去掉 max-width:720px,改为整行 flex——售后状态下拉与备注横向排布占满左侧,取消/确认贴右端,消除右侧大片空白。
2026-10-08 19:01:56 +08:00
kate522 88669f6989 refactor(after-sale): replace review decisions with direct status panel
售后面板改为状态下拉(待审核/处理中/已完成/已拒绝/已取消)加确认/取消按钮:取消仅收起手风琴,确认为纯状态更新;删除已通过状态、review() 决策机制及其专用退款流水线,无双路无迁移;举证图片并入申请说明列与详情整行。
2026-10-08 11:05:08 +08:00
kate522 80373746f8 feat(admin): 售后列表改为手风琴展开详情并内联审核表单 2026-10-07 23:38:30 +08:00
kate522 d2034f5031 test(admin): 契约覆盖点击展开的操作面板与批量操作 2026-10-07 08:46:43 +00:00
kate522 ad2290cbb7 feat(admin): 售后操作列改为点击「操作」展开快捷面板;新增顶部批量通过/批量拒绝(含复选框与全选) 2026-10-07 08:46:41 +00:00
kate522 c870f08395 test(admin): 契约覆盖售后快捷操作 2026-10-07 08:42:09 +00:00
kate522 80fb4068d1 feat(admin): 售后操作列改为 WP 文章列表式 row-actions 悬停快捷操作(JS POST 提交,拒绝时 prompt 填备注) 2026-10-07 08:42:07 +00:00
kate522 01cd734989 test(admin): 契约覆盖举证图片列 2026-10-07 08:36:05 +00:00
kate522 f44180d06c feat(admin): 售后服务表格新增「举证图片」列,展示申请上传的图片缩略图 2026-10-07 08:36:03 +00:00
kate522 c7d18d426c test(admin): 契约覆盖售后表格新列与策略摘要 2026-10-07 08:32:16 +00:00
kate522 0ec405f566 fix(admin): 售后服务页表头对齐售后策略配置项:原因/申请资料/申请说明分列,页头展示策略摘要 2026-10-07 08:32:14 +00:00
kate522 dd320c99c1 test(admin): 契约测试同步菜单结构调整(无 Tab/独立售后菜单) 2026-10-07 08:28:30 +00:00
kate522 fc75d7b810 chore(admin): 删除统计页专用的 get_admin_order_stats(已无调用) 2026-10-07 08:28:27 +00:00
kate522 b3012b3133 feat(admin): 订单明细页去掉 Tab 分页;售后独立为「售后服务」子菜单;删除统计页 2026-10-07 08:28:25 +00:00
kate522 ab6c2339a0 feat(admin): 支付中心新增「售后服务」子菜单 2026-10-07 08:28:23 +00:00
kate522 3eb9cc739c style(modal): 售后与订单弹窗底部按钮满幅化并优化悬停反馈 2026-10-07 10:18:48 +08:00
kate522 e9a1a75259 fix(styles): 还原弹窗 footer 相邻按钮分界线并补悬停阴影
被平铺规则的 border:0 抹掉的 legacy 分隔线按家族色还原,售后四弹窗与订单详情同款;更新构建产物与契约测试。
2026-10-07 09:53:10 +08:00
kate522 d69e1d33c3 fix(orders): 修复再次申请售后被 has_application 分流回售后详细的问题并对齐底部按钮观感 2026-10-07 09:43:22 +08:00
kate522 8023e2e472 feat(after-sale): 支持撤销售后工单并接入人机验证 2026-10-07 09:20:59 +08:00
kate522 918ca371be chore(repo): 行尾规范化,恢复索引 LF 契约
远端 9 个文件以 CRLF 入库,违反 .gitattributes「索引一律 LF」声明,
在 autocrlf=true 的工作区产生永久假脏并阻塞合并。仅行尾变化,无内容改动。
2026-10-07 09:13:42 +08:00
kate522 9e3267fef4 test(payment): 契约测试同步弹窗底部与记录卡片新行为 2026-10-06 23:50:33 +00:00
kate522 6081e8dcc9 style(payment): 订单详情底部适配主题按钮网格;记录卡片新增样式 2026-10-06 23:50:31 +00:00
kate522 c801ff5047 feat(payment): 订单详情底部改用主题按钮样式;记录卡片加查看详细与原因/申请理由 2026-10-06 23:50:30 +00:00
kate522 8460132feb feat(payment): 售后详细弹窗底部精简为单个主按钮,去掉阻断文案 2026-10-06 23:50:28 +00:00
kate522 732f615a09 style(payment): 炫彩标题区样式扩展到订单详情弹窗 2026-10-06 18:27:27 +00:00
kate522 738abe5102 style(payment): 订单详情弹窗 modal-mini 几何(400px) 2026-10-06 18:27:25 +00:00
kate522 915311dcb6 feat(payment): 订单详情弹窗统一为 modal-mini + 蓝色炫彩标题区 2026-10-06 18:27:23 +00:00
kate522 c200427358 test(payment): 售后契约测试同步结构化表单与可再次申请新行为 2026-10-06 18:08:08 +00:00
kate522 7b5dcf16a1 style(payment): 订单详情弹窗售后记录卡片样式 2026-10-06 18:08:06 +00:00
kate522 2db2414270 style(payment): 订单详情弹窗售后记录改为卡片式 UI 2026-10-06 18:08:04 +00:00
kate522 365b2eafe5 feat(payment): 售后详细弹窗申请表单分行展示(原因/申请资料/申请说明) 2026-10-06 18:08:02 +00:00
kate522 ea88499996 feat(payment): schema 版本升至 2.5.2(apply_form 列迁移) 2026-10-06 18:08:00 +00:00
kate522 f4ff3e16d4 feat(payment): schema 2.5.2 新增 order_aftersale.apply_form 列 2026-10-06 18:07:58 +00:00
kate522 c67d5ae4bc feat(payment): 可再次申请售后按工单状态判定(rejected/cancelled 可再次申请) 2026-10-06 18:07:56 +00:00
kate522 00a087b1a4 feat(payment): 售后申请表单结构化入库(预置原因/申请资料/申请说明独立存储) 2026-10-06 18:07:53 +00:00
kate522 a0f59826fe fix(payment): 售后弹窗滚动容器改用 no-scrollbar,消除弹窗边缘滚动栏
mini-scrollbar 是 3px 可见细滚动条(举报弹窗 600px 宽弹窗用的写法);
modal-mini 这类带炫彩横幅的表单弹窗,主题统一走禁封申诉/记录弹窗的口径:
scroll-y + no-scrollbar 完全隐藏滚动条(含细滚动条)+ 50vh 内层滚动。
申请与售后详细两个滚动容器一并切换,滚动行为不变、只是不再露出滚动栏。
2026-10-06 23:38:18 +08:00
kate522 e7e281f10c fix(payment): 售后入口监听改为脚本加载即挂载,去掉 auto_fun 时序依赖
入口本就是 document 级委托、不需要 DOM 扫描,此前只挂在 auto_fun 上等于
多依赖一次全局重扫的触发时机;脚本加载时立即挂载(auto_fun 钩子保留作幂等
重扫),任何时刻的首次点击都能同步弹窗。
2026-10-06 23:32:19 +08:00
kate522 93579cbc9f fix(payment): 售后弹窗改为按钮载荷同步弹出,修复首次点击无反馈
原实现是「点击 → 先请求 /orders/{n} → 再弹窗」,与项目里所有可用弹窗
(含举报链路 report.js)不同构:举报弹窗把数据放在按钮 data-* 属性里同步
弹出。前置请求会让首次点击在请求返回前毫无反应,体感就是"得二次点击"。

- 按钮 data-aksrt-after-sale-apply / -detail 携带订单与售后状态载荷
  (PHP 模板与 order-center.js 的 actionHtml 同构生成),点击当拍即开
  对应弹窗:申请表单 / 超时效提示 / 售后详细,零前置请求
- 「再次申请售后」同样使用已渲染载荷,不再重新拉取订单
- 提交与图片上传仍走 REST,服务端校验不变;列表刷新后载荷随之更新
- jsdom 多脚本环境实测:三种入口均在点击同一拍弹出且 fetch 计数为 0
2026-10-06 23:29:59 +08:00
kate522 4ea02f25ac fix(payment): 补升 schema 版本使售后举证图片列在已安装站点生效
order_aftersale 新增 images 列时未同步升 SCHEMA_VERSION,而 dbDelta 只在版本
变化时重跑,已安装站点不会执行 ALTER TABLE,提交售后申请直接报
Unknown column 'images'。SCHEMA_VERSION 2.5.0 -> 2.5.1,并在契约测试中
锁定「列变更必须同步升版本」,部署后进入一次 wp-admin 即自动补列。
2026-10-06 22:37:32 +08:00
kate522 1a9a2762f3 feat(payment): 新增「售后策略」配置与前台售后弹窗链路
- 后台「支付&付费 → 售后策略」:售后功能开关、申请时效、超时效提示(HTML)、
  预置原因、申请售后说明、所需材料、申请说明占位文案、可上传图片开关;
  PHP schema 与 SPA schema 1:1 对齐,group 字段支持拖拽排序
- PaymentRuntime 新增售后设置解析(缺键回退 schema 默认、HTML 走 kses、
  中文原因稳定 slug)与 after_sale_expired 时效判定(支付时间起算)
- 售后工单支持举证图片(order_aftersale.images 列,最多 3 张,
  复用评论图片上传校验)与上传路由;订单视图模型输出 after_sale 按钮状态
- 前台申请 / 超时效提示 / 售后详细三个独立弹窗(modal-mini + 蓝色炫彩头),
  订单详情弹窗不再内嵌售后表单并清理相关死代码
- 新增 after-sale-policy 契约测试与运行时 harness,527 项契约测试通过
2026-10-06 22:21:47 +08:00
kate522 b447c41f13 refactor(admin): 清理付费 meta 死字段并补齐商品介绍渲染
延续上一轮 pay_count「只写不读」的排查,把其余写而不读的字段一次
性处理完。按字段性质分两类处理,不是一律删。

【删除】纯数据垃圾,无界面入口、无消费方:
- card_keys / purchase_limit / stock:只有 DomainModel 的默认值与
  sanitize,全仓零引用。契约测试原本还断言它们不得出现在 metabox 上。
- 随 card_keys 一并删除 sanitle_card_keys() 助手。
- video_scale_height「固定长宽比例」:参考主题里这是轮播组件 widget
  的参数,不属于付费视频字段,当初误搬到付费 metabox 上且从未被消费。
  连默认值、sanitize、metabox 保存项一起移除,并同步修正契约测试。

【补齐】有界面入口、管理员填了但不显示的四个字段:
pay_title / pay_doc / pay_details / pay_extra_hide 此前只有 metabox
输入框和 sanitize,前端一个都不读。按参考主题 pages/download.php
的结构接到单篇资源下载页:
- pay_title 有值则替代文章标题作商品标题(回退 get_the_title);
- pay_doc 商品简介、pay_details 更多详情渲染在标题区下方;
- pay_extra_hide 额外隐藏内容只在有下载权限时渲染在下载盒之后。
三处 HTML 输出均走 wp_kses_post,与 sanitize 侧的安全 HTML 口径一致。

同时把 pay_extra_hide 接进文章页付费盒
(aksrt_get_paid_box_html):该函数本身只在有付费权限时执行,天然
就是「付费后才可见」的位置,复用已有的 .pay-extra-hide 样式。

验证:php -l 四个 PHP 文件无语法错误;
node --test tests/widget-system/*.test.cjs 449 项全部通过。
2026-10-06 20:17:58 +08:00
kate522 8e1fd0a30f fix(admin): 让后台「销量浮动」设置真正生效
资源 metabox 和快捷编辑都能设置「销量浮动」(pay_count),但设了完全
不起作用——所有读取方用的是另一个键 virtual_sales(§3.8 电商的虚拟
销量加成),pay_count 只被写、从未被读。

新增统一读取口径 aksrt_get_content_sales_count($post, $pay_meta):
真实订单销量 + pay_count,并把四处各自拼装销量的地方全部接到这个
口径上:
- aksrt_get_download_box_meta() 的 sales_count(内容帖)
- aksrt_get_resource_meta() 的 sales_count(资源帖)
- 付费墙 aksrt_get_paywall_html() 的已售徽标
- 付费盒 aksrt_get_paid_box_html() 的已售徽标
- 后台「所有文章」销售数据列的销量

此前资源帖的 sales_count 走的是「虚拟销量 + 下载量」这条与内容帖
不一致的口径,现统一。

快捷编辑的 pay_count 运算本是整数加减 + max(0,...) 归零,与读取口径
一致,无需改动。

_aksrt_sales_volume 保持不变,它仍是订单流水累加的副本;列表列取
订单表统计与 meta 副本的较大值再加浮动量,避免两条链路数字打架。

新增 sales-volume-float-contract.test.cjs 4 项,锁住「pay_count 必须
被销量读取方消费」这一契约,防止以后又被改成摆设。

验证:node --test tests/widget-system/*.test.cjs 449 项全部通过
(原有 445 项 + 新增 4 项);php -l 三个 PHP 文件无语法错误。
2026-10-06 19:52:03 +08:00
kate522 cf6dc24193 fix(admin): 互动数据列移除下载量统计
「互动数据」列此前有浏览/点赞/收藏/下载四项,现按要求去掉下载量,
只保留前三项。

配套清理:
- render_stats_column() 去掉下载读取、历史 download_count 键回退,
  以及 fa-download 渲染分支;
- apply_query_args() 的排序白名单去掉 download_count;
- stats_meta_key() 映射去掉该维度。

核对参考主题:其互动列(all_count)本来就只有阅读、点赞、收藏三项,
下载量是从 AKSRT 旧实现里搬过来的,不属于参考主题能力,去掉后
与 1:1 复刻目标一致。

_aksrt_res_download_count / RESOURCE_DOWNLOAD_COUNT_KEY 本身保留,
它们仍被资源相关功能使用;此处只是不再在列表列里展示。

契约测试改为断言下载项已消失,并删掉因此不再使用的 domain 变量。

验证:node --test tests/widget-system/*.test.cjs 445 项全部通过;
php -l 无语法错误。
2026-10-06 19:41:25 +08:00
kate522 c8fdd81d6a fix(admin): 修复快捷编辑组描述被 esc_html 转义成可见标签
「付费参数」组的说明文字 `<div class="c-yellow">…</div>` 整段被显示
成了纯文本标签。原因:fields_html() 对 $desc 做了 esc_html(),而该
参数传入的是一段自带 <div> 的 HTML,转义后标签就变成了可见字符。

改为按参考主题的做法原样输出 $desc,责任前移到调用方:
- pay 组:esc_html__() 转义文字,外层 div 由模板给出;
- filter 组:同样自带 <div class="px12 aksrt-bulk-edit-desc"> 包裹,
  以保住原有样式(此前样式靠 fields_html 里的外层 div 提供)。
字段标题仍保持 esc_html(),因为 filter 那组的标题来自设置项。

新增契约断言锁住这个契约:不得再 esc_html($desc),两个调用点必须
各自转义/包裹;同时保留「字段标题仍转义」的断言。

验证:node --test tests/widget-system/*.test.cjs 445 项全部通过
(原有 444 项 + 新增 1 项);php -l 无语法错误。
2026-10-06 19:35:56 +08:00
kate522 27868b6131 fix(admin): 修复销售数据排序图标错位与快捷编辑控件被剥成文本
P1 排序图标跑到「销售数据」文字前面:
原因是注册了 manage_edit-post_sortable_columns。注册后 WordPress 会给
该列套上自己的排序指示器与 <a>,而列头 label 里已经自带一个 <a>,
浏览器解析嵌套 <a> 时把内层内容抛出,箭头因而显示到文字之前。
参考主题不注册该钩子,靠 label 链接 + pre_get_posts 落地排序,现改为
同一做法,同时删掉因此变成死代码的钩子与方法。

P2 快捷/批量编辑新增的配置项不可用,只显示为纯文本:
render_edit_boxes() 用 wp_kses_post() 包裹面板输出。该函数按「文章
内容」白名单过滤,select/option/input/fieldset/legend/label 均不在
白名单内,标签被剥掉只剩文字,于是所有控件退化成纯文本。改为原样
输出,字段值在生成时已逐个 esc_attr/esc_html 转义。

同步更新两份契约测试:sales 列表头排序改为断言「不注册 sortable」,
并为快捷编辑新增一条防回归断言,锁住不得再用 wp_kses_post 包裹面板。

验证:node --test tests/widget-system/*.test.cjs 444 项全部通过
(原有 443 项 + 新增 1 项);php -l 无语法错误。
2026-10-06 19:19:08 +08:00
kate522 ef718042d2 feat(admin): 新增后台文章列表销售数据列与快捷编辑配置项
「所有文章」列表此前只有互动数据列,缺少销售数据表头;快捷/批量编辑
展开后也只有原生三项。本次对照参考主题补齐这两处,不含专题链路。

- 新增 PostListEnhance:统管 sale/stats 两列、付费类型筛选、
  orderby=sales_volume 排序,以及四组快捷编辑字段的渲染与保存
- 新增 PostListEditFields:单独承载字段定义,主类职责保持单一
- 新增 admin-post-list.js:按 pay_type/pay_modo 勾选切换字段显隐
- 从 inc/core-hooks.php 迁出原有列实现(删 74 行),不再保留旧闭包
- 清理 admin-main.css 中确认无引用的 .aksrt-tab-pane 与 .aksrt-modal* 死块
- 新增 post-list-quick-edit-contract 测试,更新 stats/icon-picker 契约

快捷编辑锚点列用互动数据列而非参考主题的专题分类法列(本项目无专题)。
真实销量改为页面级批量查询,避免逐行查库。金额运算统一走 Ledger 的
「分」单位,不用裸浮点。保存走 edit_post 钩子,一次覆盖快捷与批量两条路径。
2026-10-06 15:54:47 +08:00
kate522 194280af72 feat(admin): 移除支付中心的“下载记录”子菜单页面
下载不做记录,支付中心下的“下载记录”页面及相关链路整体删除。

删除的内容:
- src/Admin/Native/PayDownloadLogs.php(页面渲染、批量删除、
  令牌查询)整文件移除。
- PayMenus 中的 download_logs 子菜单注册(概览页快捷入口由同一
  份 pages() 数组生成,随之消失)。
- functions.php 中的 PayDownloadLogs::init() 接线。
- 各入口跳转链接:付费文章列表的操作按钮、用户列表“积分分成”
  列的操作链接、用户列表行操作。
- “保存用户下载记录(条) / 保存文章下载记录(条)”两个设置项及其
  消费链:SchemaPayment.php、assets/admin 侧 knowledge.js 的字段
  与默认值、PaymentRuntime.php 的 user_log / post_log。
- 前台下载中心登录提示里的“管理下载记录”文案(该处本就没有
  可跳转的管理页面)。

未改动内容:
- aksrt_dl_token 数据表仍由 DBMigrator 创建、uninstall.php 仍会
  删除:存量站点可能留有历史令牌行,属数据而非功能,迁移风险
  高于收益。
- 独立下载页面开关(down_alone_page)与远程文件转本地等下载
  行为配置保留。
- 资源下载量(_aksrt_res_download_count)仍是只读口径:下载走
  普通外链,主题内没有写入方,本次不新增下载统计。

download-direct-contract.test.cjs 中原有“下载记录页面展示历史
令牌”用例反向改写为三项断言:页面与接线已删除、记录数设置项
已注销、下载仍不做记录。

验证:node --test "tests/*/*.test.cjs" 517 项全部通过;
php -l 全部改动文件无语法错误;重新构建 admin SPA,确认产物
中不含被移除的字段与菜单。
2026-10-06 14:35:14 +08:00
kate522 50fac36169 fix(admin): 修复后台文章列表互动数据列无数据
后台「文章 → 所有文章」列表的「互动数据」列四项计数(浏览/点赞/
收藏/下载)恒为 0。原因是列回调读取的 meta key 与主题实际写入的
键不一致:读取用的是 'views' / 'like' / 'favorite' /
'download_count',而全仓库的写入方只写 aksrt_post_views /
_aksrt_like_count / _aksrt_favorite_count / _aksrt_res_download_count,
'views' / 'like' / 'favorite' 从未被任何代码写过。

改为复用既有的 aksrt_get_post_views() / aksrt_get_post_likes() /
aksrt_get_post_favorites() 读取(helpers 在 bootstrap 中先于
core-hooks.php 加载,仍保留 function_exists 兜底与原始键回退),
下载量走 DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY,并对历史
download_count 键做一次回退读取。

同时修正两个相邻问题:

- 缩写口径与前台不一致:列内用的是前台从未使用的「万」后缀,
  改为统一的 aksrt_format_number()(≥10000 → 1.2W+,
  ≥1000 → 1.5K),与前台列表、文章详情页保持同一套。
- pre_get_posts 的按计数排序同样拿用户可见的排序键当 meta_key
  使用,现映射到真实存储键。

资源下载走普通外链,主题内没有下载计数的写入方,下载项仍只在
>0 时出现,此为现状而非本次回归。

新增 tests/widget-system/admin-stats-column-contract.test.cjs
(7 项)锁定列注册、读取键与写入键一致、缩写口径、排序键映射,
以及下载键的写入方状态,避免再次改名时静默失效。

验证:node --test "tests/*/*.test.cjs" 515 项全部通过
(原有 508 项 + 新增 7 项);php -l inc/core-hooks.php 无语法错误。
2026-10-06 14:28:44 +08:00
kate522 2595bb02db fix(frontend): 统一计数缩写口径并将流水号改为订单号
前台各处评论、浏览、点赞、收藏、已售数量统一走
aksrt_format_number() 缩写(≥10000 → 1.2W+,≥1000 → 1.5K),
缩写后缀统一为大写 K,与既有 W+ 保持一致。PHP 渲染缩写文本的
同时保留 data-base-count 原始整数,JS 增量计算不受影响;
article.js / comment-ajax.js 内的 formatStatCount / formatCount
与 PHP 规则保持同一套,避免点赞收藏后文本回跳成全量数字。

文章详情页购买信息卡、用户中心创作者收益明细、后台分成与提现
管理页、支付设置页以及四封订单/提现邮件模板中,面向用户展示的
「流水号」字样统一改为「订单号」;代码注释、内部错误码与
WP_Error 文案保留原表述。

验证:node --test "tests/*/*.test.cjs" 508 项全部通过;
php -l 检查全部改动文件无语法错误;重新构建 admin SPA 与
前台 runtime bundle。
2026-10-06 14:13:51 +08:00
kate522 fe57022550 fix(frontend): remove duplicate select arrow and show the WordPress brand icon 2026-10-06 13:23:05 +08:00
kate522 962ab5c290 fix(frontend): match the reference post format options in the page set modal
参考主题把文章格式固定为标准/图像/画廊/视频四项,动态读取
get_theme_support('post-formats') 会让本站多出 aside/quote/link
等注册格式,选项集偏离参考主题,故改为固定列表。
2026-10-06 12:55:04 +08:00
kate522 6dbe1fe4c6 fix(frontend): align page-set hint bar and FA5 icons with the reference
- 齿轮提示横条改为参考主题的 .abs-right 垂直居中几何(top:50% +
  right:.7em + translateY(-50%)),本站 legacy .abs-right 是 top:0/right:0
  导致横条贴到按钮顶角
- 新增 front-page-admin-set.js:横条 2 秒后淡出 1 秒消失(对齐参考
  主题 js/page-edit.js),开关关闭时不出队脚本
- 齿轮与弹窗内图标统一为 FA5 惯例 fa fas fa-*,并采用 FA5 的 glyph
  重命名(sliders→sliders-h、pie-chart→chart-pie)
2026-10-06 12:50:20 +08:00
kate522 12f60897d9 fix(frontend): 修复前台页面编辑弹窗并 1:1 复刻参考主题
远端加载的弹窗在 admin-ajax 上下文里拿不到查询对象,enabled()
恒为假,直接返回 success:false;而 AksrtRequest.responseError
只读 message/data.message,读不到 msg,遂报「请求失败(HTTP 200)」。
改为参考主题原生架构:wp_footer 直出 #modal_admin_set,提交走
vendored main.js 的 aksrt_ajax 契约,不再有 AJAX 取 HTML。

对齐参考主题 zib-frontend-set.php:
- 弹窗补齐 aria-hidden=false、win-ajax-replace、role=document
- 隐藏字段改用 taxonomy + post_type
- 字段顺序与 is_page 分支(标题样式、widgets_register、容器位置)
- 多选 checkbox 改 name="id[]",描述允许富文本
- 模块布局指向定制器小工具面板
- term 保存使用 POST 的 taxonomy 字段

清理不再使用的 assets/js/page-admin-set.js。
新增 frontend-page-edit-harness.php 覆盖门禁、渲染与各保存分支。
2026-10-06 12:22:43 +08:00
kate522 b75bf197a3 feat(extensions): 新增系统工具页面与前台投稿分片上传 2026-10-06 11:09:05 +08:00
kate522 613b8a57a3 style(user-center): 优化手机号脱敏数字框与前缀后缀一体呈现 2026-10-06 09:41:11 +08:00
kate522 af72364078 feat(security): 新增网站安全扩展模块并支持修改手机脱敏验证 2026-10-06 09:26:55 +08:00
kate522 224f86ec08 fix(profile): mask bound phone display 2026-10-06 02:16:22 +08:00
kate522 fc9bf27560 fix(profile): anchor verification code buttons 2026-10-06 02:07:13 +08:00
kate522 d8d0b5f290 test(profile): cover account modal styling 2026-10-06 01:51:02 +08:00
kate522 ce429e1caf fix(profile): align account modal UI 2026-10-06 01:46:48 +08:00
kate522 248912f610 fix(profile): align account dialogs and contracts 2026-10-06 01:09:04 +08:00
kate522 f646fa8573 fix(user): repair phone binding and profile fields 2026-10-05 20:55:20 +08:00
kate522 0cf4639d95 fix(profile): match account submit buttons 2026-10-05 19:54:00 +08:00
kate522 0a52bf36f9 fix(profile): refine settings modal actions 2026-10-05 19:21:42 +08:00
kate522 346146b4fa feat(profile): rebuild account settings page 2026-10-05 18:58:49 +08:00
kate522 f00c3faeaa fix(sms): accept empty HTTP 200 responses 2026-10-05 17:25:33 +08:00
kate522 59e9bbf8a6 fix(payment): restore order statistics cards 2026-10-05 17:25:33 +08:00
kate522 1af51ab172 fix(sms): handle empty Smsbao responses 2026-10-05 16:54:12 +08:00
kate522 7b55cfb190 feat(payment): rebuild order details and asset records 2026-10-05 16:42:25 +08:00
kate522 476e2f961d fix(rewards): load and strengthen amount styles 2026-10-05 11:36:39 +08:00
kate522 979639342a feat(rewards): add toast and captcha flow 2026-10-05 03:51:18 +08:00
kate522 356f7fe5bd fix(frontend): align reward modal with shared runtime 2026-10-05 03:24:42 +08:00
kate522 6037ca184b fix(sms): collapse provider settings by default 2026-10-05 02:29:42 +08:00
kate522 8ef2f7e49f fix(sms): remove obsolete init call 2026-10-05 02:17:25 +08:00
kate522 5091da6a60 feat(sms): align provider and verification flow 2026-10-05 02:13:52 +08:00
kate522 d0a3972ca7 fix(identity): surface real sideload failure reason 2026-10-04 23:26:52 +08:00
kate522 2ae2b2fd07 fix(identity): validate evidence by file content 2026-10-04 23:13:48 +08:00
kate522 90c1f7a62b fix(identity): normalize bare li markers in apply intro 2026-10-04 22:54:58 +08:00
kate522 8fcb136d55 fix(identity): draw intro list markers inside list items 2026-10-04 22:51:21 +08:00
kate522 d6e3b377b4 fix(identity): apply safe list styles in modal runtime 2026-10-04 22:25:15 +08:00
kate522 2b3e325d63 fix(identity): keep modal list markers inside scroll area 2026-10-04 22:15:42 +08:00
kate522 4caf81c9e2 fix(identity): keep intro list markers visible 2026-10-04 22:10:18 +08:00
kate522 88661e1574 fix(identity): preserve HTML in application intro 2026-10-04 22:02:20 +08:00
kate522 67ec3c8aeb fix(identity): align application modal with reference 2026-10-04 21:49:39 +08:00
kate522 59901b9e10 feat(identity): align auth flow with reference theme 2026-10-04 21:10:12 +08:00
kate522 5a17b399a0 fix(user-home): add reference tab loading skeletons 2026-10-04 19:40:24 +08:00
kate522 4c6b02a6b9 fix(user-home): restore desktop comment columns 2026-10-04 19:19:29 +08:00
kate522 56bbb63afd fix(user-home): align profile comments with reference theme 2026-10-04 19:11:27 +08:00
kate522andClaude Fable 5 00817766a6 fix(author): keep the follow sub-tab state out of the other panels
uhSub(粉丝/关注)只属于粉丝面板,loadPanelBody 却把它写在每一个面板上,
翻页时又从被点面板自身读回,于是分组状态跨面板串味:先点粉丝 tab 切到
「关注」,再点文章 tab 翻页,#panel-posts 会被永久打上 data-uh-sub=
"following",之后粉丝面板的翻页读到的是文章面板残留的分组。

翻页改为只读**当前面板内**的 .is-active 子页签,读不到就回落默认分组;
uhPage / uhSub 也只在粉丝面板写入,文章 / 收藏 / 评论面板一次都不碰。
目标面板本身仍由被点面板的 id 反解,跨面板跳转这条路径不存在,本次不动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-04 14:28:43 +08:00
kate522andClaude Fable 5 16d67c8a87 fix(user-home): hide the old panel content while the tab skeleton loads
翻页 / 切 tab 是整页替换语义(author.js 最终就是 panel.innerHTML = html),
但加载态只做了骨架的显示,旧卡片与旧页码仍留在屏上,于是先看到
「第 1 页卡片 + 脚下一摞骨架」,等接口返回才换页。

加载期收起 .aksrt-user-home__post-list 的直接子级 .posts-item 与旧翻页控件,
让骨架独占内容区;对齐参考主题 post_ajax() 的 beforeSend(replace 时
$item.remove() + find(pag).remove())。显隐仍走纯类切换,不写 inline display。

两点刻意的收窄:
- 只收直接子级 .posts-item 而非后代:骨架内层同样带 .posts-item,
  后代选择器会把骨架自己也藏掉,占位就永远出不来。
- 旧页码用 :has(.post_ajax_loader) 限定:关注 / 评论面板没有骨架,
  无条件隐藏会让它们加载时只剩一片空白。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-04 13:58:09 +08:00
kate522andClaude Fable 5 45dc909e4c fix(user-home): span the tab pagination skeleton across the post grid
The posts/favorites tabs turn the article list into a 2-column grid at
>=992px. The AJAX pagination skeleton .post_ajax_loader is a direct
child of that grid, so with no grid-column it was laid out as a single
half-width card and the whole placeholder stack collapsed into one
column while paging or switching tabs.

Span the loader across every column and, while loading, give it the
same 2-column template the real cards use so placeholders line up with
them. The skeleton shape now follows the resolved render mode instead
of a hardcoded 'list', so card mode no longer renders list blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-04 13:17:02 +08:00
kate522andClaude Fable 5 20fde1d652 fix(skeleton): put the widget gutter relief on the skeleton's inner item
小工具(首页挂载的 / 侧栏的)骨架加载占位比真实卡片窄一截:card 布局下
每侧窄 8px,视觉上就是「骨架小了一些」。

小工具的卡片走 skip_wrapper=true,直接挂在 .widget-ajaxpager 上
(src/WidgetSystem/PostRenderer.php),不经过 .posts-row,因此只受小工具
这条独立的负边距链作用:

  .widget-ajaxpager            margin: -8px
  .posts-item.card(真实卡片)  margin: 8px   ← 卡片自身自带
  .post_ajax_loader(外层)     margin: 8px   ← f61142ee 加的补偿
  .posts-item.card(骨架内层)  margin: 8px   ← 同一条卡片规则

骨架于是拿到 8px(外层 loader)+ 8px(内层卡片)= 16px,而真实卡片只有
8px,内容区每侧少 8px。list 模式没暴露是因为 .posts-item 的 8px 来自
`.widget-main-post.style-list .posts-item`,该规则只覆盖 style-list、
不作用于骨架,外层的 8px 恰好补上 list 卡片应有的一份,刚好对齐。

修法:把水平补偿从外层 .post_ajax_loader 移到骨架内层的 .posts-item /
.posts-mini —— 与真实卡片同一个元素、同一条选择器,于是两者逐像素同宽,
也顺带回答了「补偿链的拥有者是谁」这个一直在打补丁的问题。

- assets/css/main.css、assets/css/frontend/post-card.css:.posts-row 与
  .widget-ajaxpager 两条链、桌面 8px 与移动 5px 两组,共 8 处规则改为
  内层选择器。
- assets/css/routes/user-home.css:面板归零规则同步改到内层,否则它归零
  的是已经失效的外层,旧补偿会重新显形。
- tests/widget-system/asset-ledger-contract.test.cjs:把守卫断言改成内层
  选择器,并修正规则匹配以支持选择器组(.posts-item, .posts-mini)。
  注释说明为何不能补偿外层。

参考主题 zibll 完全没有 .post_ajax_loader 的 margin 规则;AKSRT 需要它,
是因为骨架多包了一层。定位在内层是与参考主题等价的正确解。

Verification:
- node tests/widget-system/asset-ledger-contract.test.cjs  16/16
- node tests/widget-system/post-visual-contract.test.cjs   22/22
- node tests/widget-system/zibll-modern-widgets-contract.test.cjs 10/10
- node tests/widget-system/lazyload-contract.test.cjs      7/7
- node tests/widget-system/frontend-lifecycle-contract.test.cjs 13/13
- node tests/widget-system/page-display-contract.test.cjs  18/18
- node tests/widget-system/message-center-contract.test.cjs 8/8
- php tools/audit-admin-contracts.php  errors=0
- build:public 已重建;旧外层规则在源与产物中均已归零

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-04 04:07:19 +08:00
kate522andClaude Fable 5 38bbd917a9 fix(skeleton): align the homepage tab and sort loaders with the reference 1:1
首页「更多栏目」tab 与「排序方式」切换的骨架占位与参考主题 zibll 有实质差异,
根因不是几何宽度,而是三处结构/契约错位:

1. 骨架块自身结构与参考主题不同。card 分支用了 `<posts>` 元素 + `.post-graphic`
   包裹层(列表变体,带 margin-right:20px),而参考主题 card 骨架是
   `<div class="posts-item card">` 且 `.item-thumbnail` 是直接子元素。
   → card 骨架右侧多出一条真实卡片没有的 20px 沟槽。

2. 骨架块被错误地打上 `ajax-item`。参考主题的占位块没有该类。它会让
   `find('.ajax-item')` / ajax-list 的 `inject()` 把骨架当成真实条目。

3. 排序切换的骨架被预渲染节点顶替。aksrt_render_pagination() 在 .ajax-pag 内
   渲染了一个写死 2 条、写死 list 形状的 .post_ajax_loader;runtime post_ajax()
   会先在容器内 find('.post_ajax_loader'),找到就复用并丢弃调用方按视口动态
   拼出的骨架 → 切排序永远是 2 条 list 骨架,card 布局下也是、与视口宽度无关。
   参考主题 zib_paging() 根本没有 loader 兄弟节点。

改动:
- inc/helpers/posts-renderer/markup.php:aksrt_render_placeholder() 的
  card/list 分支改为与 zib_get_post_placeholder() 逐字等价的结构;去掉
  ajax-item 类、card 分支的 .post-graphic 包裹层,元素改用 <div>。
- inc/helpers/template-tags/pagination-permissions.php:删除预渲染 loader,
  与参考主题 zib_paging() 一致 —— 只渲染翻页控件。
- assets/js/core/pagination.js:找不到骨架节点时构造与 runtime/main.js 同款
  的文章骨架(卡片 8/6/4 条、列表 4/3 条,按视口与参考主题同阈值),
  不再退化为「转圈 + 文案」这个第二套加载语言;仅评论等非文章面保留该兜底。
- assets/js/core/pagination.js:为排序菜单(.ajax-option)与 tab 触发器
  (.post_ajax_trigger)增加避让 —— 它们归 legacy runtime 管线,
  否则一次点击发两个请求(与 ajax-list.js 的既有避让对称)。
- assets/js/runtime/main.js:post_ajax 的显隐由 fadeIn()/hide() 改为
  .is-loading 类切换。前者写 inline display,优先级高于类规则,一旦残留
  骨架永远打不开,正是骨架契约明令禁止的失败模式。

归档/分类筛选面原本另有一套独立加载语言(spinner 遮罩 + opacity 淡出),
本次一并统一到同一条 .post_ajax_loader + .is-loading 契约:
- inc/helpers/aksrt-archive.php:删除 show_loading 标记位与遮罩 DOM,
  改为在分页之后渲染文章骨架。
- assets/js/core/archive.js:showLoading/hideLoading 只切 .is-loading;
  fetchPosts() 清空内容时保留骨架与分页节点(与参考主题 success 只 hide
  不删的生命周期一致),原先会连带删掉骨架。
- assets/css/frontend/layout.css:删除 .archive__ajax-wrap.is-loading
  的 opacity 淡出与 spinner 遮罩规则。
- category.php / tag.php:删除已失效的 show_loading 实参。

验证:php -l 全部通过;php tools/audit-admin-contracts.php errors=0;
契约测试 post-visual(22) / asset-ledger(16) / message-center(8) /
lazyload(7) / frontend-lifecycle(13) / zibll-modern-widgets(10) /
page-display(18) / a11y 全绿;已重跑 build:public 刷新产物。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-04 03:04:02 +08:00
kate522andClaude Fable 5 f61142ee9a fix(pagination): align the load-more skeleton width with the cards
首页「加载更多」的骨架比列表本身宽出一截:.posts-row 用
margin-left/right:-8px 抵消卡片的水平边距,而卡片边距规则是
`.posts-row > *:not(.post_ajax_loader)`,把骨架排除在补偿链之外,
于是骨架左右各宽出 8px(合计 16px)。移动端的 5px 覆盖块同理。

小工具的 .widget-ajaxpager / tab pane 也是一条独立的负边距补偿链
(margin: -8px 抵消 .posts-item 的 8px),骨架同样溢出。

修法是给骨架补上与卡片等同的水平边距,而不是写 inline display
(会盖过 .is-loading 类规则)。改了 main.css、post-card.css 的
8px / 5px 两组规则,用户中心面板另加一条归零规则,因为那里的
容器 margin 与卡片边距都已清零,不能再叠加补偿。

顺带补一条契约测试:删掉任一 gutter 规则即失败,防止回归。

Verification:
- node --test tests/**/*.test.cjs  471 passed / 0 failed
- php tests/widget-system/run.php  136 assertions
- php tools/audit-widget-system.php
- php tools/audit-admin-contracts.php  errors=0 (56 既有 baseline 警告)
- node tools/build-public-assets.mjs  CSS/JS 产物已重建

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-04 00:25:26 +08:00
kate522andClaude Fable 5 cf4554e7c8 fix(ajax): show the reference skeleton on every load-more surface
Clicking 加载更多 in the 多栏目文章 widget showed no skeleton at all — the
list simply stayed still until the response arrived, then the new cards
appeared. The homepage stream, archives and comments already had a working
skeleton; the widget's AJAX load goes through a different client and never
found its loader.

Root cause was DOM placement, not visibility: ContentRenderer emitted
.post_ajax_loader as a *sibling* of .widget-ajaxpager, while the widget REST
client resolves it with container.querySelector('.post_ajax_loader'). The
node existed but could never match, so .is-loading was never added and the
skeleton stayed display:none. Inactive tab panes had the same shape, and the
direct-load branch of ajaxpager() emitted no loader at all.

Changes, all following the reference theme's post_ajax() contract in
zibll/js/main.js (beforeSend anchors the loader to the end of the list;
success only hides it, never removes it):

- ContentRenderer: nest the loader inside .widget-ajaxpager for both the
  direct-load and inactive-tab branches, and drop the sibling append.
- widgets/frontend.js: anchorLoader() moves the loader to the end of the
  list before .ajax-pag on every request; appended cards are inserted before
  it; a replace preserves the node instead of wiping it with innerHTML; a
  fallback loader is created when the DOM has none; loadItems() now returns
  its promise; drop the unreachable is-loading auto-load branch.
- Message centre, user-home panels and the search page switch from spinner /
  opacity feedback to the same .posts-item-shaped skeleton, so all AJAX
  surfaces share one loading contract.
- Remove the second, never-wired skeleton system (.aksrt-skeleton-* in
  core-ui.css) and the dead spinner stylesheets/rules (skeleton.css,
  ajax-spinner.css, .load-more__*, --skeleton-base/--skeleton-shine) plus the
  unregistered assets/js/waterfall.js.
- Extend the widget, message-centre, user-home and asset-ledger contract
  tests to pin the loader inside the container, the class-only visibility
  switch, and the absence of the deleted dead code.

Verified: 470 node tests, 136 PHP widget assertions, admin-contract and
widget-system audits all green; rebuilt public assets after the CSS/JS
removals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-03 22:22:15 +08:00
kate522andClaude Fable 5 271e7f0518 chore(audit): refresh contract report
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-03 17:24:16 +08:00
kate522andClaude Fable 5 b5f37d6d46 fix(pagination): align AJAX load-more loading state with reference
Loading indication now matches the reference theme's post_ajax() contract:

- Stop overwriting the 加载更多 button label with 正在加载...; the button
  keeps its own text and the .post_ajax_loader skeleton carries the busy
  state instead.
- Add the reference fallback loader (.loading ring + 加载中...) when no
  skeleton exists in the DOM, and reinsert the skeleton after the last
  card on every page load like beforeSend does.
- Retext the loader string to 加载中... (zib__('loading')) and make the
  ajax_trigger default a translatable string.
- Route ajax-list.js / comments.js / widgets frontend.js through the
  .is-loading class instead of inline display, which outranked the class
  rule and could freeze the skeleton shut.
- Drop the search.php inline display:none that defeated .is-loading, and
  remove the unproduced .post-ajax-loader / [data-aksrt-ajax-loader] shims.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-03 17:23:44 +08:00
kate522andClaude Fable 5 e506309434 chore(widgets): re-freeze the drifted widget reference baseline
audit-widget-system.php and validate-widget-golden.php both failed on
"Reference drift: inc/functions/functions.php". The reference theme was
updated from V9.1 to V9.2; its update log records only a message-center
pagination fix, so no widget contract changed.

The file is not a widget source — group_sources in the golden-fixture
generator maps only the five widget-*.php files — so it contributes solely
to the aggregate fingerprint, and re-freezing is safe.

Refresh the pinned SHA256 in .specs/baselines/widget-reference.json and
regenerate tests/fixtures/widget-golden-v1.json so the hash and its
derived fingerprint stay consistent. The fixture diff is those two lines.

Both tools pass again: widget audit (37 widgets, 40 areas, 5 templates)
and golden validation (37 components, 222 screenshot targets).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-03 15:44:02 +08:00
kate522andClaude Fable 5 89a9cf24e9 fix(posts): repair the load-more skeleton and align it with the reference
Clicking 加载更多 showed no skeleton at all, and the placeholder shape did
not match the reference theme's post cards.

- Class-name mismatch: pagination.js queried .post-ajax-loader while every
  PHP producer emits .post_ajax_loader, so showSkeleton() never found a
  target and the loader stayed hidden. Accept both spellings.
- Inline display:none on the loader (pagination, comments, widget tab
  panes) outranked .is-loading, so even the correct selector could not
  reveal it. Drop the inline style; the reference uses a bare
  <div class="post_ajax_loader"> and toggles visibility from JS.
- loadNextPage() set indicator.style.display = "flex", which forced the
  .posts-item.card skeleton blocks (inline-block layout) into a single
  row instead of stacking vertically — the visible symptom. Remove those
  inline writes and drive .is-loading from CSS for every surface.
- Scope the hide/show rules on the class itself rather than requiring an
  .ajaxpager ancestor: homepage tab panes, archive streams and comment
  lists sit at three different depths.
- Rebuild aksrt_render_placeholder() on zib_get_post_placeholder's
  structure — list = t1/k1/k2 + two s1, card = t1 item-heading/k2 + two
  s1, thumb wrapped in post-graphic + radius8 item-thumbnail, all carrying
  ajax-item so the loader is not mistaken for a real card.
- Add mini/mini_thumb modes so ContentRenderer::placeholder() stops
  hand-rolling the same markup; normalize its 'lists' arg to 'list'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-03 15:42:18 +08:00
kate522andClaude Fable 5 7fc1ac98de fix(user-home): match reference cover metas with 人气值/点赞 pills
The public user homepage cover showed a single bottom-right 粉丝 pill,
which the reference theme does not render there — it shows 人气值 and 点赞
as a translucent pill group pinned to the cover's top-right corner.

- Cover metas: replace 粉丝 with 人气值 (zibsvg-hot) + 点赞 (zibsvg-like),
  summed over the user's published posts via aksrt_get_user_posts_meta_sum()
  and abbreviated with aksrt_cut_count(); each is zero-suppressed
  independently, matching zib_get_author_header_metas()
- Titles use the reference strings 人气值 N / 获得N个点赞 via
  data-toggle="tooltip"
- CSS: adopt .single-metabox.cover-meta semantics (one rgba(0,0,0,.3) pill
  at right:1em/top:1.6em, 14px, 6px item gaps, 3px icon margins) plus the
  reference's <=480px override; drop the per-stat bottom-right pills
- Drop the dead __stats / __stat / __contacts CSS blocks and the unused
  $tab_counts hero contract arg

Follower counts remain in the content tab bar (tab_counts['follow']).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-03 14:32:45 +08:00
kate522 fa53a0f4f9 fix(revenue): align detail card, poster modal and copy feedback with reference
- detail-tab 累计佣金 row now shows 累计 + 已提现 (was 累计/可提现/已提现)
- rebuild poster canvas to match reference layout (768×1168, top banner,
  date overlay, blue title bar, bottom bordered box with QR + link)
- copy-link feedback moves from inline text to a toast (notyf), matching
  the reference; drop the now-unused data-affiliate-feedback element
2026-09-28 21:58:43 +08:00
kate522 0374de3fec fix(revenue): drop dead affiliate_settlement_days field from JS schema
The immediate-credit model removed settlement, but only the PHP schema
side was cleaned. The JS admin schema still rendered a savable-but-unread
"结算观察期" field. Remove it from both the defaults and field list, and
rebuild the admin SPA bundle so it no longer surfaces.
2026-09-28 21:50:10 +08:00
kate522 401d130f03 fix(revenue): define AssetAmount::commission_from_minor used by commission pool
The commission-pool rewrite called AssetAmount::commission_from_minor(int) in
5 places but only a commission(string) helper was defined, causing a fatal on
the revenue panel. Replace it with the int-minor variant the callers expect.
2026-09-28 21:34:02 +08:00
kate522 61667aa03c feat(revenue): immediate commission credit with independent withdrawable pool
Aligns affiliate commission with the reference theme: commission is credited
to a dedicated COMMISSION asset pool the moment an order is paid, removing the
7-day settlement freeze, the settlement cron, and the settlement_days setting.

- Ledger: add AssetType::COMMISSION (isolated from the recharge balance wallet)
- CommissionLedger: credit-on-record + opportunistic debt repayment; refunds
  claw back the full commission from the pool, carrying any shortfall as debt
  (aksrt_commission_debt) that future commission pays down first
- Withdrawal: make asset-aware via a source discriminator (commission vs
  balance); each pool keeps independent daily limits, pending gate, and
  reject-refund. Balance withdrawal (recharge + creator income) is unchanged
- Fix pay_rebate_self_s: honor self-commission at purchase time
- Revenue card: show pool balance + 累计/已提现 (drop 待结算), debt when present
- Schema 2.0.0 -> 2.1.0: add commission_debt, drop freeze columns
2026-09-28 21:27:44 +08:00
kate522 cded20ae02 更新 README.md 2026-09-28 00:13:18 +00:00
kate522 f9ec4964f7 删除目录「.zcode/plans」 2026-09-28 00:11:11 +00:00
kate522 1355a9244f Merge pull request 'perf(revenue): server-render the promotion link and stop settling on reads' (#37) from worktree-revenue-card-rework into main
Reviewed-on: #37
2026-09-27 15:36:26 +00:00
kate522andClaude Fable 5 ea16f9c2fe perf(revenue): server-render the promotion link and stop settling on reads
Opening the 推广收益 tab was slow because every read path ran settlement
and two COUNT aggregates before the browser could show the link.

- profile-revenue.php: the link input now carries a server-rendered value from
  ReferralAttribution::create_link(), so 复制链接 works on first paint instead
  of after a REST round trip
- CommissionLedger::overview() no longer calls lazy_settle_due(); the
  lazy-settlement hook and its static flag are gone. Settlement runs only on
  the existing hourly aksrt_affiliate_settle_due cron, which uses a larger
  batch (100 vs 20) and would otherwise re-run up to 20 locked
  SELECT..FOR UPDATE + write cycles inside a GET request
- ReferralAttribution::rest_me() drops the lazy_settle_due() call and the
  stats() result; stats() itself is deleted — its two COUNTs (one of them
  COUNT(DISTINCT source_user_id)) had no consumer left once the 推广人数 row
  was removed
- credits.js: reads the link from the already-rendered input and only fetches
  /revenue/me for poster config and commission ratio. A failed fetch no longer
  blocks copying — it silently skips the poster enhancement
- tests: the lazy-settlement contract is inverted to assert the read path stays
  read-only and cron stays wired; new assertions pin the server-rendered value
  and the no-COUNT REST payload

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 23:17:32 +08:00
kate522 ae793414be Merge pull request 'refactor(revenue): drop standalone QR block and align promotion card with reference' (#36) from worktree-revenue-card-rework into main
Reviewed-on: #36
2026-09-27 15:06:16 +00:00
kate522andClaude Fable 5 7761435a81 refactor(revenue): drop standalone QR block and align promotion card with reference
The 推广收益 detail card no longer renders the QR code as its own row. The
card becomes a fixed-label info list: 推广链接 [Url][复制链接][推广海报] /
佣金比例 / 累计佣金 / 返佣详解, mirroring the reference theme's 推广中心 card.

- profile-revenue.php: QR row removed; poster button moved into the link row and
  gated server-side on the poster switch; copy label now 复制链接; 推广人数 row
  dropped; 返佣详解 heading reads the existing pay_rebate_text_details_title;
  dead $vip_ratios, aksrt-revenue-page class and data-min-amount removed
- new affiliate-poster-modal.php: poster previews in a modal before download
  (reuses .uc-modal + single-render guard), since the QR now only exists inside
  the poster
- credits.js: renderAffiliateQr and its call deleted, along with the dead
  [data-affiliate-payers] lookup and the inline ch-width hack; initAffiliatePoster
  builds the poster into the modal stage and reveals 保存海报
- PaymentRuntime: expose affiliate details_title
- user-center.js: remove never-defined AKSRTRevenueCenterInit guards (2 sites)
- CSS: revenue-earnings.css pruned to live rules plus the new link-row grid and
  poster stage styles; routes/user-center/revenue.css pruned to its only
  definitions; dead leaves routes/revenue.css and routes/commerce.css deleted and
  dequeued from the user-center part list
- tests: referral-qr-contract test 2 rewritten for the QR-free card, link row and
  poster modal; post-visual-contract leaf list updated with regression guards

qrcode.min.js stays enqueued — buildPosterCanvas still draws the QR inside the
poster. Net -833 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 21:42:57 +08:00
kate522 8d16ce6e02 fix(single): match reference article action bar 1:1
The action bar used a locally invented pill-chip style that overrode the
reference theme's 80px icon-over-text columns, and the JS toggled a
class no stylesheet targeted — so likes/favorites showed no selected
state at all.

- restore reference markup: <svg class="icon"> + <text>/<count> and the
  actived class, with data-stat-count/data-base-count preserved
- toggle actived plus a transient action-animation so the lovestar
  keyframes finally reach the DOM
- drop the CSS overrides and now-dead action-btn/.action-btn--reward
  and .post-actions-card rules; main.css already carried the reference
  rules the overrides were shadowing
2026-09-27 15:39:09 +08:00
kate522 1ce8e263c3 fix(admin): import AksSorterField in AksFormRenderer
The sorter field was rendered in the template but never imported, so
the compiler treated it as an unknown element and share_items was
invisible on the social sharing page.
2026-09-27 14:40:06 +08:00
kate522 a366898f65 chore(audit): refresh contract report after share schema registration 2026-09-27 14:23:03 +08:00
kate522 bd0abf891c fix(share): register share schema in global registry
The share schema was imported but never added to globalSchemas, so
getSchema('global', 'share') returned null and the page fell back to
route meta with empty groups, rendering "暂无配置项".
2026-09-27 14:19:19 +08:00
kate522 cf6c11852e feat(share): align social sharing runtime 2026-09-27 13:49:32 +08:00
kate522 26ac8167f3 fix(comments): match avatar identity layout 2026-09-27 00:37:00 +08:00
kate522 47bf9c8f0b fix(float): use shared sticky save bar and place panel warnings after device tags 2026-09-26 21:43:24 +08:00
kate522 f97614433c fix(float): drop .nowave overflow:hidden that clipped float button popovers 2026-09-26 21:30:13 +08:00
kate522 d6bc1067f9 fix(download): use FA5 class form for download box fallback icons 2026-09-26 21:03:32 +08:00
kate522 eb14c4afd3 fix(float): inline panel warnings, drop add-button type picker, mirror reference more-button defaults 2026-09-26 21:01:55 +08:00
kate522 6f2adfaef1 feat(float): rebuild right floating buttons 1:1 with reference theme
- Storage: aksrt_floating_buttons now uses reference float_btn shape
  (fixed 8 sortable types, more-buttons group); single source of
  defaults/sanitize in AKSRT\Settings\FloatButtons
- Admin SPA: CSF-parity page split into components/floating/*
- Frontend: aksrt_float_right() on wp_footer mirrors zib_float_right()
  (wp_is_mobile, PC tooltip, toggle-theme, ontop/scrollTopTo, qrcode,
  wechat/qq, pay_vip, add, more)
- CSS: floating-buttons.css rewritten from reference rules; legacy float
  rules removed from main/part/shell/overlay-primitives
- Remove dead code: reorder route/API, get_floating_buttons_items,
  floatingSchema, floatActionsId, float-theme-toggle bindings,
  common/floating.css, aksrt-runtime-win.php, .aksrt-float-btn
2026-09-26 20:44:50 +08:00
kate522andClaude Sonnet 4.6 7f60071460 feat(pay): show 点击复制{copy name} tooltip on the remark button
Hovering the remark gave no affordance hint that it copies anything. The
reference theme titles the same button 点击复制{copy name} and omits the
tooltip entirely when no copy name is configured.

Emit data-toggle="tooltip" with that title on both copy buttons, gated on a
non-empty copy name, and mirror it on the paid-card helper so the two
surfaces agree. Bootstrap's tooltip init already runs in auto_fun() on load,
which is where the clipboard lib is required too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-24 21:30:05 +08:00
kate522andClaude Sonnet 4.6 63b5edd901 fix(runtime): use jQuery, not bare $, in the lazily loaded clipboard lib
WordPress runs jQuery in noConflict mode, so bare $ is undefined. The
clipboard module called $(this) on every click and $.isFunction on the
copy result, so any click on a [data-clipboard-text] button threw
"$ is not a function" and copied nothing.

This was latent: the only prior emitter was the paid card, and the click
path only ran when a user actually clicked it. Exposing the copy button on
the download box made it reachable on every resource, surfacing the error.

Bind through jQuery explicitly, guard notyf so a missing toast lib cannot
also throw, and drop the _win.bd dependency (that payload only gains .bd
once main.js runs). Contract-lock the module under simulated noConflict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-24 21:13:24 +08:00
kate522andClaude Sonnet 4.6 bd3d50c6c6 fix(pay): render the copy button beside the anchor with real styling
The copy fields survived save and sanitization, but the frontend never showed
a working copy button: the remark button was emitted as a bare sibling inside
a column flex list, and .resource-download-box__btn--extract had no CSS rule at
all, so it rendered as an unstyled full-width block stacked under the download
anchor instead of the side-by-side row the reference theme uses.

Wrap the anchor and the copy button in .resource-download-box__row and give the
extract modifier real styles (dashed outline, muted surface, focus-color hover),
including the scoped mobile download bar variant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-24 20:57:32 +08:00
kate522andClaude Sonnet 4.6 592bfb4fa2 fix(pay): break enqueue cycle that exhausted memory on the post editor
aksrt_icon_picker_assets() registers the icon-picker script as depending on
the caller's handle, so passing 'aksrt-pay-meta-box' already creates the
edge aksrt-icon-picker -> aksrt-pay-meta-box. Declaring the reverse on the
script and style closed a cycle, and WP_Dependencies' recursive resolver
never terminated, taking down every post editor and edit.php request.

Drop the reverse edge so the picker depends on the pay-meta script, not the
other way round, and contract-lock the direction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-24 20:40:30 +08:00
kate522andClaude Sonnet 4.6 b94c820361 feat(pay): mirror reference resource row with copy, icon and reorder
The post editor's download row only exposed address / button text / remark,
leaving copy_key, copy_val and class as hidden inputs with no UI, so the
reference copy area, custom button icon, text and color were unreachable.
The submission popup already emitted those keys, which made the two write
paths diverge from anything the editor could produce.

Expose the full reference field set (地址/备注/复制名称/复制内容/图标/文案/
颜色) as an accordion card with drag reordering, add the one genuinely new
`icon` key through aksrt_sanitize_icon_value(), and teach both frontend
download renderers to consume the fields they previously ignored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-24 20:27:30 +08:00
kate522 ab495854d5 feat(download): switch download buttons to direct source links
The token relay still stood between the button and the file: every click
issued a short-lived token URL, so downloads never reached the real
source address directly.

- Render direct anchors (target=_blank) to the raw source URL in the
  download box and the paid card, with inline extract-code buttons
- Retire the relay: delete Content\Download plus its init wiring, header
  query-var branch and SEO noindex branch, and drop the dead
  download-request flow from cms-download.js (cashier, scan-pay and
  error views stay)
- Keep the dl_token table and the download logs page as read-only
  history; unscheduling of the orphaned cleanup cron is preserved
2026-09-24 19:12:45 +08:00
kate522 bc97c4ddc3 refactor(download): open generated links directly without the popup
The success modal added a second click between the download button and
the file: request -> "下载链接已生成" popup -> 立即下载. Drop it and open
the generated relay link right away, keeping the previous new-tab
behaviour; a missing link still surfaces the error view.

- Replace renderSuccess with startDownload (anchor + target=_blank)
- Remove the popup-only CSS: alert, code-row, copy button, chips, meta
  and the download action skin; error, cashier and scan-pay views keep
  their styles
2026-09-24 18:56:36 +08:00
kate522 d49fd9f6a4 fix(download): pin frontend link generation to fixed relay config
The runtime kept reading aksrt_download_config after the settings page was
removed, so the frontend still issued download links per the stale option
values instead of the built-in defaults.

- Pin endpoint, per-token uses and TTL to built-in constants and drop all
  option reads plus the is_enabled() master switch
- Remove the now-unreachable direct-download branches from the download
  box template and the paid-card download helper
- Lock the pinned contract in the download contract tests
2026-09-24 18:45:08 +08:00
kate522 800baca0ca feat(admin): rework download logs page columns and selection
- Headers are now checkbox / index / post / user / credits cost / IP / time;
  drop the source index, usage and expiry columns
- Add a check-column before the index: header select-all for multi-select
  plus per-row checkboxes, tokens as row values
- Bulk delete selected tokens behind nonce and capability checks with
  prepared statements, redirect-after-post back to the filtered view
- Index numbers continue across pagination; dead comments removed
2026-09-24 18:29:48 +08:00
kate522 1c1d3f17b7 refactor(admin): remove download config settings page and chain
- Drop the download config menu entry, route and schema from the settings SPA
- Remove the PHP schema section, the server-side alias validation and the
  reserved-alias list that only served that section
- Unregister aksrt_download_config from standalone import/export keys
- Drop the contract tests that locked the removed page; the Download.php
  runtime contracts stay untouched
2026-09-24 18:29:38 +08:00
kate522 66f8bb933d feat(admin): replicate reference post-page settings page 1:1
- rewrite post_list.page schema (JS+PHP 2.0.0) with the reference field set:
  three cover types with option fieldsets (image 20-200% / slide CFS_Module::slide
  full config with nested dependsOn / video 0-200%), hide-time, p_indent,
  breadcrumbs (home-text as switcher), prevnext, authordesc, maxheight slider,
  yiyan block, copyright, button toptext, front/after content, related posts
- migrate share_s to share page basic group and post_rewards_* to users
  features rewards group (runtime keys unchanged)
- remap get_post_page_settings() to reference keys, normalize slide cover
  option, hardcode non-reference runtime flags (reading progress, favorites,
  author card), drop dual-path legacy aliases
- wire slide option into aksrt_render_slider (effect/direction/spaceBetween/
  speed/interval/loop/autoplay/nav/pagination, scale vs fixed-height modes),
  clamp image scale 20-200 and video scale 0-200 with 16:9 fallback,
  wire p_indent container class + CSS, resync frontend-tags fallback and
  FacadeBulk fallback, remap PostMetaBox cover keys
- add post-page reference contract test; update stale cover-height and
  user-features contract tests

Validation: node --test 464/464 pass, audit-admin-contracts errors=0,
vite build ok, build-public-assets ok, php -l clean.
2026-09-24 16:13:00 +08:00
kate522 a8060fa71d fix(admin): replicate reference advanced-filter settings page 1:1
- Rewrite post_list/filter schema (PHP + JS) with the reference theme's
  field set, order, defaults and dependencies; topics fields excluded
  (no topics taxonomy), AKSRT private filter_* toggles dropped
- Support dynamic checkbox options in AksField (options as formData key)
  so cat_custom_filter.custom_filters lists the custom_filter dimensions
- Map PostDisplay filter settings to the new keys without legacy fallback;
  cat_custom_filter now carries lists/dropdown fieldsets and array filters
- Wire the archive chain to reference semantics: cat_custom_filter
  overrides per-category filter projects, custom_filter_show gates the
  default display, dimension chip groups render on category/tag archives
- Filter archive queries (main query + AJAX endpoint) by _aksrt_cf_{key}
  meta with exact-quoted LIKE matching
- Add advanced-filter settings reference contract test
2026-09-24 15:39:25 +08:00
kate522 4d6b2c6045 fix(admin): replicate reference post-feature settings page 1:1
- Rewrite post_list/feature schema (PHP + JS) with the reference theme's
  field set, order, defaults and dependencies; print/PDF toggles dropped
- Move the migrated reference fields off the post-page schema (lightbox
  six switches, post like, default view/like ranges) to kill the dual path
- Map PostDisplay::get_post_page_settings() to the new keys without legacy
  fallback: imagelightbox* (pc_s/m_s checkboxes merged), post_like_s,
  post_default_mate ranges, article_nav + article_nav_mobile_nav_s
- Wire article_nav runtime: content data-nav/no-nav gate with per-post
  override, mobile drawer TOC box, imgbox_type config
- Seed random views/likes on new posts from post_default_mate ranges
- Keep the print stylesheet enqueued unconditionally (no switch); drop the
  dead print/PDF settings and needs_print requirement
- Add post-feature settings reference contract test
2026-09-24 14:29:04 +08:00
kate522 c331b2aeef fix(admin): replicate reference comment settings page 1:1
- Rewrite post_list/comment schema (PHP + JS) with the reference theme's
  field set, order, defaults and dependencies; smilie, comment location
  and quick-reply fields excluded
- Map PostDisplay::get_comment_display_settings() to the new keys without
  legacy fallback: close_comments, comment_paginate_type (ajax_lists),
  user_edit_comment master switch, WP comment_order for sorting
- Wire comment_box_hide visibility gate (signin / vip / vip_2 / auth)
- Drop dead comment settings (order/per_page/depth/avatar/hot threshold/
  empty text/city), the dead post_close_comments field, and city readers
  now fully owned by the IP-geo extension
- Add comment settings reference contract test
2026-09-24 13:43:51 +08:00
kate522 264f2c51b2 fix(admin): replicate reference front-submit settings page 1:1
- Rewrite post_list/submit schema (PHP + JS) with the reference theme's
  field set, order, defaults and dependencies; topics fields excluded
- Map PostDisplay::get_submit_settings() and the FrontSubmit runtime to
  the new keys without legacy fallback
- Guest submission follows the capability matrix; category required on
  submit; pay limits read from the cash/points fieldset
- Drop dead front_submit_* settings, template branches and JS config
- Add submit settings reference contract test
2026-09-24 12:29:19 +08:00
kate522 ed71314986 Merge pull request 'Worktree/attachment upload config' (#35) from worktree/attachment-upload-config into main
Reviewed-on: #35
2026-09-24 03:20:55 +00:00
kate522 bd7586a3b7 refactor(attachment): 头像与背景图上限并入前端图片上传限制
- 移除独立的「用户头像与资料卡图片」分组与 avatar_max_size 字段,
  与参考主题保持一致:头像/资料卡背景图与投稿评论图片共用
  upload_img_size 同一套用户类型 × 大小配置
- 5 处消费方(REST 头像/背景上传、后台头像字段、用户中心头像与
  封面配置)全部改读 UploadLimits::limit_bytes('image'),提示语
  改用 size_format 与实际上限对齐
- 附件配置仅剩上传大小/上传格式/上传行为三组;管理端 SPA 已重建,
  421 项契约测试通过
2026-09-24 11:19:47 +08:00
kate522 95cdad5c5d fix(attachment): 补充上传大小配置的单位与标签
- SPA Schema 的大小限制 repeat 子字段补上 unit: 'M',避免数字被
误读为数量;PHP Schema 早已带单位,两侧一致
- 标签「可上传」改为「可上传大小」,与单位 M 搭配后语义明确
- 管理端 SPA 已重新构建,421 项契约测试通过
2026-09-24 11:12:09 +08:00
kate522 7172fd942d Merge pull request 'feat(attachment): 按参考主题重构后台上传配置套件' (#34) from worktree/attachment-upload-config into main
Reviewed-on: #34
2026-09-24 03:06:26 +00:00
kate522 d4bb43a275 feat(attachment): 按参考主题重构后台上传配置套件
- 新增 AKSRT\Attachment\UploadLimits:按用户类型解析图片/视频/文件
  上传上限(取最大值+默认兜底,钳制到 PHP 上限)、允许/禁止格式过滤、
  上传自动重命名、非管理员附件页 404;不含论坛角色
- 后台「附件配置」重做:大小限制/格式限制/上传行为三组配置,
  PHP Schema 与 SPA Schema 逐字段镜像(v1.2.0)
- 定制页 AttachmentPermissions.vue 改为标准 schemaPage,清理失效的
  API 包装、组件加载器与旧配置键(submit_image_max_size 等)
- 投稿/评论上传、批量上传数量全部切换到新链路;AksField 数字组件
  支持单位展示
- 更新 captcha harness 与 front-submit 契约测试;421 项测试通过
2026-09-24 11:04:49 +08:00
kate522 505d9156fe fix(vip): 收敛 /user/vip/ 积分入口与购买卡的上下间距
档位卡继承 .aksrt-panel 的 margin-bottom:20px 且此前未在卡片容器内重置,
与积分兑换入口自身的 margin-top:12px 叠加成 32px 视觉间距(无入口时与
more 说明叠加成 35px),偏离 12–16px 的区块节奏。在 .uc-vip-level-cards
内清零继承底距:卡片行与入口间距 = 12px(与容器 gap 一致),无入口时
与 more 说明间距 = 15px(mt15)。

node --test 420 项通过。
2026-09-23 14:36:31 +08:00
kate522 2801a89c1f fix(vip): 在 /user/vip/ 档位购买卡下方补充积分兑换会员入口
参考主题 zibpay_user_page_tab_content_vip:非会员 + 积分兑换会员开启 +
有可兑换套餐时,在两张档位购买卡下方输出全宽入口横条(points-color 图标
+ 标题 + 立即兑换 badg),点击经 data-aksrt-vip-points-open 打开 wp_footer
静态兑换弹窗,与会员购买弹窗内入口共用同一弹窗。

- aksrt-vip.php 新增 aksrt_get_vip_points_exchange_entry_html(),门控与
  弹窗壳同源(取服务端 catalog),已是会员不展示(对齐参考主题契约)
- vip-tab.php 在卡片区之后输出入口;credits.css 补 .uc-vip-points-entry
  表面样式(与档位卡同一变量体系,暗色自适应)
- pay-vip.js 改文档级委托承接 data-aksrt-vip-points-open,一处绑定覆盖
  静态页面横条与弹窗内按钮;移除 bindModalUi 内的重复分支
- 契约测试更新为双入口(11 项),node --test 420 项通过,php -l 通过

未写数据迁移;未做真实视觉效果验收。
2026-09-23 14:30:54 +08:00
kate522 b798c7a45d fix(profile): remove gap above public profile hero 2026-09-23 13:57:55 +08:00
kate522 edb60d08b4 fix(user-center): neutralize legacy shell flex on profile page 2026-09-23 13:51:58 +08:00
kate522 456d57b2de fix(user-center): stretch profile header to full shell width 2026-09-23 13:48:47 +08:00
kate522 9a40ad18c7 fix(profile): full-bleed the user center only and keep public profile at content width 2026-09-23 13:39:57 +08:00
kate522 bbd5e97bac fix(profile): let user center and public profile span the full viewport 2026-09-23 13:36:16 +08:00
kate522 10c63e02d5 fix(profile): serve unified content width and accept full admin range 2026-09-23 13:23:04 +08:00
kate522 c315297d7f fix(profile): align user page content width 2026-09-23 12:16:26 +08:00
kate522 5201f2ab6f fix(ban): hide scrollbars entirely in ban modals
申诉弹窗等内容区的细滚动条(mini-scrollbar 悬停显现的 3px 滚动条)也不予保留:
三个禁封弹窗(禁封/解封 max-vh7、申诉 max-vh5、禁封记录 max-vh5)的滚动容器
统一改用 no-scrollbar(scrollbar-width:none + ::-webkit-scrollbar{display:none}),
滚动能力保留但任何状态下都不显示滚动条。契约测试锁定:ban 相关helper 不得
再出现 mini-scrollbar。
2026-09-22 19:53:03 +08:00
kate522 7a3f5d7c34 fix(ban): appeal textarea squash and nested tone on the user center entry
- fix(ban): 申诉弹窗「请填写申诉说明」填写框被压成 0px——bindAppealForm
  对全部 textarea 执行 bindAutoHeight,而 grow 对无 maxHeight 属性的元素
  走 else 分支写入 height:0;参考主题申诉说明框为 rows=3 自然高度。
  现 autoHeight 仅对显式声明 autoHeight/maxHeight 的文本框生效
- fix(ban): 申诉弹窗与禁封记录弹窗滚动容器对齐参考主题 max-vh5(50vh,
  内层滚动、外层对话框不滚动);mini-scrollbar 提供细滚动条(悬停才显示,
  无常驻侧边滚动栏);禁封弹窗保持 max-vh7 不变
- fix(ban): 用户中心封禁元信息按钮两层颜色嵌套——外层 but 与内层 badg
  同时带 c-red/c-yellow;现外层 but 为默认按钮色,颜色仅由内层 badg
  承载(1:1 参考主题 zib_get_user_ban_info_link 结构)
- test: 契约测试锁定三处修复(说明框自然高度、max-vh5、外层 but 不重复着色)
2026-09-22 19:46:35 +08:00
kate522 a4af024f32 feat(ban): align ban prompts with the reference theme modals and block page
对照参考主题(Zibll)修正四项偏差,申诉链路从页面整体迁移至弹窗:

- feat(ban): 新增三个 1:1 复刻弹窗(REST /user-ban/{info,appeal,log}-modal):
  查看禁封状态(多彩头 c-gray + 信息行 + 申诉按钮)、禁封申诉(edit-header
  gavel/c-blue + 信息框 + 资料项 data[key] 输入 + 审核中态 + 禁止申诉态)、
  禁封记录(多彩头 c-yellow + 计数 + 逐条卡片);user-ban.js 增加
  data-aksrt-ban-info/appeal/log 委托与 admin-ajax 申诉提交
- feat(ban): 禁封/解封弹窗的「当前用户已有N次禁封记录」静态文案改为可点击
  黄链(N>1),打开禁封记录弹窗;解封视图信息行改复用统一数据源
- refactor(ban): 治理展示态收敛至 Restriction::state_badges()(封号=c-red
  违规已封号 / 小黑屋=c-yellow 小黑屋禁封中 / 禁言=c-gray 禁言中,颜色与
  字段统一管理)+ get_ban_info()(状态/起止时间/原因/说明/申诉权限,
  三弹窗与禁封页共用)
- feat(ban): 登录行为对齐参考主题——移除登录期拦截(enforce_login_ban)
  与 complete_login 禁封闸门,封号用户可正常登录;新增 template_redirect
  前台禁封页(1:1 zib_user_ban_restrict_login/zib_die_page:红盒提示 +
  信息行 + 申诉权限 + 退出登录/申诉按钮,模板 templates/parts/user/ban-block.php)
- feat(profile): 公开主页禁封状态从资料卡下方提示框改为占据身份行元信息位
  的 badg 徽章(参考主题 author_header_identity 语义);用户中心元信息改为
  but 按钮(徽章+箭头)打开查看禁封状态弹窗;评论拒绝态链接同源
- refactor: 移除申诉页面模板(page-ban-appeal.php)及其资产/注册/入队、
  get_profile_notice/profile_notice 链、state_labels/state_notices/
  login_ban_message、UserRuntime 的 public_notice/ban_notice_text 死配置、
  report_settings 死键、无消费方的 aksrt_ban_appeal_status 端点
- feat(ban): BanAppeal::ajax_submit 兼容弹窗 data[key] 表单结构
  (逐项清洗入库,申诉说明取 data['申诉说明'],旧平铺字段保持兼容)
- chore: 重新构建 public.css/public.min.css(三个新弹窗几何与头部样式)
- test: 契约测试重写(徽章元信息位、三弹窗与记录链接、禁封页、允许登录 +
  前台拦截、死代码移除断言),全量 362 项通过
2026-09-22 19:14:02 +08:00
kate522 11eda33e58 fix(ban): state-aware ban copy and real login failure reasons
- fix(profile): 封号用户的公开主页提示不再沿用「当前账号处于限制状态,
  部分互动能力已关闭」——封号=全面限制(无法登录),该描述与语境不符。
  提示尾句按治理展示态分级:封号→禁封期间无法登录、账号功能全面停用;
  小黑屋→发布、评论等操作已受限制;禁言→暂时无法发表评论。
  UserRuntime 不再烘焙通用默认文案,缺省时由 get_profile_notice 按态取
- fix(auth): 登录失败透传真实原因——REST auth_login 此前将
  Service::login_user 返回的具体错误硬编码为「账号或密码错误」,
  禁封用户登录时 Toast 语境错误;现透传 error 字段,
  friendly_login_error 识别 user_banned 错误码后直接返回禁封文案
  (无法登录 + 解封时间/永久 + 原因)
- fix(auth): 禁封闸口覆盖全部登录路径——此前仅密码登录经
  enforce_login_ban 拦截,短信验证码、公众号扫码、第三方 OAuth 均
  直连 complete_login 可绕过封号限制;现将闸口收敛至 complete_login
  (返回 WP_Error),各调用方中止登录并透出原因;OAuth 重定向新增
  account_banned 错误码,auth.js 与 user-center.js 同步文案映射
- test: 新增登录禁封面契约测试(文案单一来源、Toast 透传、免密闸口、
  统一收口、前端映射),全量 362 项通过
2026-09-22 18:36:09 +08:00
kate522 f9bc0f69c4 fix(ban): align user center ban notice with adjacent meta info
用户中心页头治理提示与相邻元信息(用户名/邮箱/城市)排版不一致:
- 高度不一致:原实现为 badg 胶囊 + 箭头作为 <a> 的内联兄弟节点,箭头
  游离在胶囊之外且按基线对齐,整行高度被 pill 撑高;
- 字号不一致:badg-sm 的 0.8em 使提示文字小于相邻项的 13px;
- 箭头位置:参考主题 zib_get_user_ban_info_link 的箭头位于按钮内部。

现复用 uc-header__meta-item(inline-flex 居中、12px 图标、13px 字号),
与相邻元信息完全同构:状态图标 + 状态文案(c-red/c-yellow)+ 内部箭头;
链接默认色经作用域规则回归行内墨色。契约测试锁定新结构,361 项全通过。
2026-09-22 18:13:58 +08:00
kate522 7f520803ff feat(ban): mirror reference theme ban/restriction prompt surface
修复公开主页禁封提示框为空的问题,并对齐参考主题(Zibll)全部前台禁封提示入口:
- fix(profile): 资料卡下方治理提示框渲染为空——模板读取的 tone/message 键与
  数据层实际输出的 state/label/text 契约不匹配;现按禁封类型展示
  「违规已封号/小黑屋禁封中/禁言中」+ 可配置说明文案
- feat(ban): 用户中心页头展示治理状态徽章 + 查看详情入口,直达禁封申诉页
  (参考主题 user_page_header_desc / zib_get_user_ban_info_link)
- feat(ban): 评论拒绝态区分禁封/小黑屋/禁言,附状态徽章与申诉链接
  (参考主题 zib_get_user_ban_nocan_info,原为笼统的无权限文案)
- feat(ban): 禁封申诉页补全状态信息卡(状态/开始时间/结束时间/原因/说明/
  申诉权限),对齐 zib_get_ban_info_modal;此前仅有「禁封原因」一行且无入口
- feat(ban): 昵称旁禁封徽章 + 头像禁封角标(覆盖 VIP 位,参考主题
  user_name_badge / user_avatar_badge 钩子语义),main.css 与入队的
  main.min.css 同步新增角标样式
- fix(ban): 举报入口对已封禁/小黑屋目标隐藏(评论、文章下拉、封面菜单三处,
  对齐 zib_get_report_link 排除语义;禁言为轻量限制不隐藏)
- refactor(ban): 治理展示态收敛为单一数据源 Restriction::get_display_state()
  + state_labels(),资料卡/用户中心/评论拒绝态/昵称徽章共用
- feat(ban): 小黑屋用户开放申诉(对齐参考主题 zib_user_is_ban 双类型语义),
  禁封站内信附加申诉页链接
- test: report-ban 契约测试 +3(展示态单一数据源、提示面入口矩阵、举报排除),
  45 个测试文件全部通过
2026-09-22 13:28:52 +08:00
kate522 8d9cca2fe5 feat(invite-code): admin page rebuilt as server-rendered three-view ops UI
对齐参考主题的邀请码运营页 UI/UX(布局与交互模式为自研实现):
- 三视图切换:列表 / 添加邀请码 / 导出邀请码(page-title-action 按钮)
- 列表:subsubsub 状态筛选(全部/未使用/已使用)、关键字搜索、批量删除、
  可排序表头(邀请码/创建时间/更新时间/状态/标识,orderby 箭头)、
  点击复制邀请码 + 复制成功浮层、标识点击定位、分页条
- 状态列:未使用(蓝)/已使用(红)+使用者链接,复用使用记录表
- 添加视图:添加方式按钮组(系统自动生成/导入邀请码)、生成数量(默认20/上限1000)、
  标识(默认 invita_YmdHis)、使用奖励字段集(经验值/积分/余额/VIP+赠送时长依赖显示)、
  高级选项折叠(自定义位数)、导入区格式说明与数据示例
- 导出视图:状态选择 + 文本文档/Excel表格 双格式 + 文本分隔符
- 传输层从 fetch+JSON 改为 admin_post 表单提交 + 回跳提示;
  管理端 REST 全部移除(保留公开 verify),死代码清理
- 建表新增 updated_at(消费/生成/导入维护),列表'更新时间'列与默认排序依据
- 契约测试 357→358 项固化新架构
2026-09-21 21:31:16 +08:00
kate522 a5f0484466 refactor(referral): drop admin referral-code ops page, self-serve codes
- 移除「用户 → 推广码运营」页面(trait、子菜单、admin SPA 跳转路由)
- 移除管理端 REST:/revenue/codes 列表与 generate/disable/regenerate 变更接口,
  清理 rest_codes/rest_mutate_code/can_manage_codes/list_codes 死代码
- 推广码改为注册即得:bind_registered_user 内生成唯一推广码,无需后台管理
- 推广配置统一收敛到「支付&付费 → 推广返佣」设置段(现状保留,无改动)
- 契约测试固化:无管理路由/页面残留 + 注册即得码
2026-09-21 21:07:38 +08:00
kate522 3dd3f71c97 feat(invite-code): one-time invite codes with rewards, usage log, import/export
- 一次性邀请码:批量生成(次数/有效期/备注/四类奖励)、原子消费防超发、
  开启注册验证时奖励延迟发放(账本幂等键)
- Zibll 兼容文本导入(points/balance/vip/vip_time/level_integral,分隔符可自定义,
  第三段标识存为备注)、CSV 全字段导出与可回流文本导出
- 使用记录表 aksrt_invite_code_usage + 后台使用者列;注册表单 400ms 防抖实时校验
- 注册门控三模式(close/open/must)设置链路(PHP schema + admin SPA schema)
- 契约测试覆盖建表/消费竞态/导入导出/备注全链路
2026-09-21 20:53:36 +08:00
kate522 20387fdb06 feat(affiliate): attribution modes, settlement hardening, trusted proxies, QR and poster
- 归因模式可配置(first_touch/last_touch)、停佣开关、懒结算与佣金基数降级复查
- aksrt_get_client_ip 接入 aksrt_trusted_proxies 信任代理名单,防 CDN 头伪造
- 收益页 Canvas 二维码 + 750x1000 推广海报(本地打包 qrcode.min.js,无 CDN)
- 契约测试固化归因/海报/信任代理链路
2026-09-21 20:53:22 +08:00
kate522 2618530afb chore(repo): track full tests/ and tools/ suites, drop per-file whitelist 2026-09-21 20:53:12 +08:00
kate522 0afead968a fix(icons): emit FA5 only and upgrade legacy FA4 icon values
The theme is FA5-only, but aksrt_icon_markup() prefixed bare glyphs with
the FA4 style token, so stored icon-picker values rendered FA4 markup,
and three runtime strings still carried the FA4 double-fa form (report
modal header and submit button, comment report menu item). That left the
navigation icon contract failing at 11/12.

- aksrt_icon_markup() normalizes bare glyphs, legacy FA4 values and
  style-only values to "fa fas fa-<glyph>", keeping the FA5 brand family
  (fa fab) and trailing classes intact.
- aksrt_normalize_icon_key() strips the legacy FA4 prefix so stored
  values resolve against the brand-icon catalog again.
- report.js and the comment report item emit FA5 markup.
- An icon-picker contract test locks the upgrade behaviour.

Verification: 338 widget-system, 12 navigation, 28 admin and 13
front-submit contract tests pass; normalization also exercised against
legacy, FA5 and brand inputs.
2026-09-20 18:48:22 +08:00
kate522 7597972eea feat(user-ban): frontend ban modal replaces the backend restrictions tab
1:1 replicate the reference theme user-ban flow on the frontend:

- Ban/unban modal rendered by inc/helpers/user-ban.php, opened via
  AksrtModalRuntime.openRemote (600px desktop dialog, 360px initial
  height, mobile bottom drawer, c-yellow colorful unban header).
- Three entry points wired: user homepage cover three-dot menu,
  comment section menu, article author card menu (ban item takes
  priority over report, matching the reference hook order).
- REST endpoints /user-ban/modal and /user-ban/set under aksrt/v1,
  guarded by check_permission, persisted through User\Restriction.
- Submit semantics match zib_ajax_set_user_ban: ban reloads the page,
  unban only closes the modal.

Cleanup (no dual-track): the backend ban-restrictions tab, its form
handlers and flash keys are removed; ticket processing links to the
frontend user homepage as the reference theme does; admin/report and
appeal notification URLs move to type/status query args.

Verification: 337/337 widget-system contract tests, 41/41 admin and
front-submit tests, php -l clean on all changed PHP files.
2026-09-20 18:42:53 +08:00
kate522 a1eb545bef fix: mute records get an unban entry on the restrictions tab
The record list rendered an action only for global ban/suspend rows, so
mute (禁言) rows showed an empty 操作 column with no way to lift them.
- record list: active mute rows now render a 解除禁言 button
- unban view: falls back to the active mute record when no ban/suspend
  exists, with 解除禁言/禁言中 titles (ban > suspend > mute precedence
  preserved); lifting still goes through lift_user_restriction (mute
  included by default)
- contract tests: cover the mute action link and the unban-view fallback
2026-09-20 16:22:47 +08:00
kate522 0df6e760e3 feat: full parity pass - suspend enforcement, report modal polish, wechat notify
- Restriction: add is_suspended/get_active_suspend; comment block now covers
  suspend (小黑屋) in addition to mute, with reason in the deny message
- FrontSubmitSave::persist: block post/draft/edit submission for suspended
  users (reference-theme semantics: 小黑屋 loses all publishing rights)
- report.js: close icon switched from fa-times to the SVG sprite ic-close
  (#icon-close), same as reference theme; textareas gain autoHeight growth
  (hidden 'other reason' box skips the first measurement)
- WeChatNotify: NOTIFY_TYPES now includes report/system so report results
  and account restriction notices fan out to WeChat template messages;
  generic title/description fallback for template values
- contract tests: +3 tests (suspend enforcement, modal polish, wechat types)
2026-09-20 16:09:12 +08:00
kate522 c38616b290 feat(admin): rebuild the restrictions tab on the reference ban modal
Replace the self-invented CRUD (warn/mute/suspend/ban + scope selector)
with the reference-theme ban model: ban method (blackroom suspend /
login ban), duration radios (1/7/14/30/100 days or permanent, default 7),
preset reason chips with an other-reason input, ban note, delete-all-
content switch (posts/comments to trash), and a no-appeal switch with a
required notice that also gates the frontend appeal entry. Banned users
render the status info view (status/start/end/reason) with an unban
leave-a-message form; the record list uses the reference columns and
per-user unban links. Bans notify the user in-site like the reference
zib_updata_user_ban_msg.
2026-09-20 15:48:48 +08:00
kate522 970b377b9a perf(admin): toggle the ticket process form instead of reloading the page
Expand and cancel were full admin navigations (action/id query round
trip), which is why they felt slow. Pre-render each pending ticket's
process form as a hidden colspan=6 row and swap list row <-> form via a
small delegated jQuery handler: expand hides the row (info stays
single-sourced), collapse brings it back, only one form is open at a
time, and the no-JS action=process links keep working as a fallback.
2026-09-20 15:32:27 +08:00
kate522 03c2f1f84a test(admin): fix the cross-line cancel-button assertion 2026-09-20 15:27:28 +08:00
kate522 fe6b4da455 feat(admin): add a cancel button next to the ticket process submit
Collapses the inline process form by linking back to the current list
URL without the action/id params (filters preserved).
2026-09-20 15:26:55 +08:00
kate522 c4531b9757 fix(admin): drop the view-all button from the ticket process view
The reference theme has no such element above the inline process form;
filter reset is already handled by the type buttons and status subsubsub.
Contract label list updated accordingly.
2026-09-20 15:25:01 +08:00
kate522 0a958644b8 fix(admin): expand the ticket process form inline without duplicating the row
Restore the reference-theme direction: processing happens inline in the
filtered list, not on a separate page. The actual defect was the same
ticket being rendered twice (list row + form) while the bare <form><table>
inside <tbody> got foster-parented by the browser, displacing the extra
row info below the submit button. Now the processing ticket renders only
as the form, wrapped in a valid <tr><td colspan=6>, and every other row
renders normally.
2026-09-20 15:23:08 +08:00
kate522 09cff17d72 fix(admin): render the ticket process form as a dedicated view
action=process used to append the process form into the tickets table as
a bare <form><table> inside <tbody> — invalid HTML that browsers reflow,
leaving the ticket list stacked below the submit button. Render the
process form as its own view (early return, list and pagenavi skipped,
with a lookup fallback beyond the current page slice) and keep the list
view free of form injection.
2026-09-20 15:16:44 +08:00
kate522 c7384b10dd feat(report): route report notices into the in-site message system
Ban appeals already sent site messages to admins and to the appellant,
but reports only produced emails: the submit path never notified admins
in-site and the status change path never notified the reporter. Send a
'report' site message to administrators on submit (deep link to the
ticket list) and to the reporter on status change (status label plus
the admin note), matching the appeal flow's direct-send pattern.
Contract test extended accordingly.
2026-09-20 15:07:45 +08:00
kate522 c7a552dfd5 fix(report): carry the admin note into the reporter result email
update_status() fired aksrt_report_status_changed without the note and
the mail listener hard-coded note => '', so the reply-to-reporter
comment written in the process form never reached the email. Pass the
sanitized note as a 4th action arg, render it via the existing {note}
placeholder, and map the raw status key to its Chinese label in the
email body. Contract test extended accordingly.
2026-09-20 15:03:09 +08:00
kate522 044830b58a test(report): match the dispatched action-field contract in the process form 2026-09-20 14:56:48 +08:00
kate522 9cc9ff6d52 fix(admin): stop the ticket process form from white-screening on submit
The process form posted to admin-post.php without a hidden action field,
so no admin_post_{action} hook fired and WordPress returned a blank
page. Dispatch to aksrt_users_update_report / aksrt_users_process_appeal
per ticket kind, matching every other native form, and lock the contract
in the report test.
2026-09-20 14:56:08 +08:00
kate522 3d4179a756 fix(report): give preset reasons a stable non-empty slug for CJK labels
ban_settings() derived each reason slug via sanitize_key(label), which
strips CJK text to an empty string; the frontend filter then discarded
every reason (including the schema defaults) and fell back to the
hard-coded list. Derive the slug from an ASCII key when possible and
from a stable md5 short hash of the label otherwise, and relax the
frontend filter to the label only. Adds a WP-stub harness that verifies
the defaults path end to end (tests/dev/verify-ban-settings-defaults.php).
2026-09-20 14:48:28 +08:00
kate522 67547fdf23 fix(report): fall back to schema defaults when the ban/report section was never saved
ban_settings()/report_settings() read the raw aksrt_settings option, so on
sites that never saved the ban/report admin section the keys are absent and
the preset reasons resolved to an empty list (frontend hard-coded fallback).
Read the section through SchemaProvider instead — read_section_settings
merges schema defaults, matching the reference theme _pz(key, default)
semantics. Also drop the dead user_report_categories parse chain that fed
a key nobody stores or consumes.
2026-09-20 14:35:17 +08:00
kate522 2882b83c21 fix(report): restore the centered plus icon in the image upload box
main.css already ships the reference plus icon (.preview .add:before/:after,
25px rounded bars centered via inset:0 + margin:auto). The duplicate
left/top:50% + translate version in modal-runtime.css over-constrained
the same pseudo elements (right/bottom:0 + margin:auto from main.css
still applied), pushing the bars to 75%/75% so only an L-shaped corner
was visible. Drop the duplicate and lock the single-source contract in
the report test.
2026-09-20 14:28:05 +08:00
kate522 7a80297a56 fix(report): align the report modal with the reference theme 1:1
- read preset reasons from the real source (ban_preset_reason via
  UserRuntime::ban_settings); the previous injection read a
  nonexistent report.categories key, so backend reasons never showed
- rebuild the modal on the reference markup contract: edit-header
  warning header, reason chips toggling the reference .active class
  (focus color), other-reason textarea, violation link, description,
  but-average modal-buts footer
- add image evidence upload gated by user_report_img_s: new
  /report/upload-image route reusing the comment upload handler with
  its own permission, preview/remove UI, up to 3 images appended to
  the report content
- render the backend report notice (user_report_desc, sanitized HTML)
  inside the modal
- widen the dialog to the reference 600px desktop geometry and share
  the form-upload preview styles with the order review modal
- extend the report contract test accordingly
2026-09-20 14:20:09 +08:00
kate522 913958820c fix(comments): open the action dropdown with theme-owned delegation
The comment three-dot menu relied on the legacy data-toggle=dropdown
delegation living in comment-move-form.js; when that bundle chain does
not evaluate (stale cache or an earlier top-level error), the menu
appears dead. Move the comment action dropdown to its own top-level
document delegation in comments.js (data-aksrt-comment-drop), keep the
.open-driven CSS contract including the mobile drop-fixed-sm sheet,
sync aria-expanded, close on outside click / Escape / menu item click,
and stay idempotent for AJAX-injected comments.
2026-09-20 13:55:03 +08:00
kate522 7c730b83d6 fix(report): unify the frontend report entry and modal
- enqueue report.js on singular pages too: the single meta box and
  comment report buttons rendered without their script handler, so
  clicking the report item did nothing
- align the report runtime default with the admin schema and the
  reference theme (user_report_s default true) so the public user
  home cover menu shows the report entry
- rebuild report.js on top of AksrtModalRuntime following the
  reference theme modal format: red warning header, preset reason
  badges with an other-reason textarea, violation link field, detail
  description and a cancel/submit footer; submit goes through the
  unified /report/submit endpoint
- keep report inside the comment three-dot dropdown and enrich the
  payload (target name/avatar) for the modal; inject configured
  report categories via aksrtData.reportCategories
- update the report contract test accordingly
2026-09-20 13:40:05 +08:00
kate522 381256ed59 feat(admin): host the ban/report settings page in the SPA schema
The users-interaction sidebar entry 禁封/举报 was a redirect stub; it is
now a schema-driven settings page replicating the reference theme
field-for-field:

- user_ban_s / user_report_s switches, ban_preset_reason and
  ban_appeal_keys as repeatable group rows ({t}), ban_appeal_desc and
  user_report_desc HTML textareas with the reference default copy, and
  user_report_img_s — keys, labels, help texts, defaults and ordering
  all mirrored in SchemaUsers.php and the SPA report.js schema
- UserRuntime::ban_settings()/report_settings() and
  BanAppeal::settings() read the new group-array values with fallback
  to the previous line/comma formats
- delete the self-built SPA queue pages (UsersBan, UsersReport,
  ReportDetailDrawer, UsersMessages) that the reference theme does not
  have; legacy /users/report bookmarks still redirect to the native page
- contract tests: lock the schema keys/labels, the schemaPage mount and
  the deleted-file state; drop reads of removed components
2026-09-19 22:24:55 +08:00
kate522 c0e0912eee feat(admin): fully align the ban page with the reference theme
- submenu page title becomes 处理举报及禁封申诉 (menu title 举报&禁封申诉)
- replicate the reference pagenavi: first/prev/numbered/next/last links
  with displaying-num (每页 20 共N) and paging-input (第X页共Y页)
- report process form is now status-only like the reference theme:
  被举报用户/举报信息/被举报用户禁封信息/处理说明 blocks, with a 点击此处
  link to the restrictions tab (prefilled user_id) instead of an
  enforcement select
- replicate reference row details (举报用户/举报内容, 正在处理 state,
  此用户已解封 auto-close for stale appeals, 当前用户已解封 notice) and
  the table-box/preview-item style block
- drop now-unused ReportCenter::get_report_row
2026-09-19 22:09:24 +08:00
kate522 902e71ee62 feat(admin): rebuild ban page to mirror the reference theme
- rename the users.php submenu to 举报&禁封申诉 and the page heading to
  用户禁封申诉及用户举报管理
- merge report tickets and ban appeals into one Zibll-style queue:
  type filter buttons (全部/禁封申诉/用户举报), status subsubsub
  (全部/待处理/已批准/已拒绝), search box, sortable widefat table with
  the same column set (状态/类型/提交用户/详情/提交时间/更新时间),
  inline process forms and 20-per-page pagination
- report process form shows the reported target, restriction status and
  an enforcement action select; appeal form offers approve/reject radios
  with a message to the user
- move restriction enforcement into ReportCenter::apply_action so REST
  and the native page share one implementation; add get_report_row()
- drop the old three-tab rendering (reports/appeals tabs) and fix all
  notice/mail/SPA links to the merged page
2026-09-19 22:00:17 +08:00
kate522 3764d48755 feat(admin): add ban/report entry to the users sidebar section
The users SPA section had no ban/report menu item after the queue pages
moved to native WP admin. Add a sidebar entry under 用户&互动 that lands
on users.php?page=aksrt_user_ban via NativeOpsRedirect, and keep the
old /users/report bookmark redirect.
2026-09-19 21:48:02 +08:00
kate522 835e0f983d feat(users): enforce real bans from report processing and fix appeal lifting
- apply_report_action now writes aksrt_user_restriction via Restriction::restrict
  and lifts via new lift_user_restriction(), replacing the display-only
  aksrt_user_ban_state meta dual-track
- ban type restrictions destroy the target user's sessions on apply
- appeal approval lifts active ban/suspend/mute restrictions instead of
  updating a nonexistent status column
- UsersBan.vue posts to /users/{id}/restrict with valid types (warn/mute/
  suspend/ban), scope and expires_at
- add comment report entry to the comment actions dropdown
- add duplicate pending report guard on submit
- remove dead ReportCenter ajax handlers, option migration, REST alias
  routes and write-only restriction meta
- fix report admin mail link to the real aksrt_user_ban page
- add report-ban contract tests
2026-09-19 21:38:56 +08:00
kate522 4dafe96c4b fix(header): drop card container gutter so header spans content width
The global .container is padding-free at >=992px (1200px dual-column
geometry), but the header slider card container re-added 15px side
padding, visibly insetting the icon cards ~15px per side relative to
the main content below. Zibll keeps header and content boxes equal by
sharing one container model; drop the local gutter so AKSRT does the
same. Mobile keeps its 15px via the <=991.98px media rule.
2026-09-19 17:43:47 +08:00
kate522 3d5bd6fb8b fix(header): render slider component on paged archive pages like reference
The reference theme has no pagination exclusion: /tag/x/page/2 and
friends still show the header slider (and its icon cards). Drop the
local paged>1 early return so the display scope matches.
2026-09-19 17:36:50 +08:00
kate522 ad714bb59c Revert "fix(frontend): drop 12px font pin on header icon cards to match reference"
This reverts commit 6fcd63b923.
2026-09-19 17:06:19 +08:00
kate522 6fcd63b923 fix(frontend): drop 12px font pin on header icon cards to match reference
The reference theme renders header slider icon cards on the bootstrap
14px body baseline (title .em12 -> 16.8px, desc 14px). A local
.header-slider-card .icon-cover-card { font-size: 12px } override shrank
text to 14.4/12px, making AKSRT cards visibly smaller than the reference.
Remove the pin from base.css and the public bundle; container gutter
restoration stays.
2026-09-19 16:51:28 +08:00
kate522 cd7082454a fix(widgets): preserve badge HTML in icon card title/desc like reference theme
wp_kses_post() strips the custom <badge> element, so icon card title/desc
lost admin-authored markup (e.g. <badge class="ml6 jb-blue">GO</badge>)
both at save time and render time, diverging from the reference theme
which stores and echoes these fields raw.

- add shared inline whitelist (aksrt_icon_card_kses_tags/aksrt_icon_card_kses)
  and route both card renderers plus the media-brand fallback through it
- drop the dead unused $allow array in aksrt_icon_cover_card
- add 'rich_html' sanitize type (FieldSchema + SchemaProvider) and mark
  icon card title/desc fields in widget schemas, page display schema and
  the page config meta box save path
- align header slider card module with reference show_widget_bg branches
  (aksrt-panel/flex1 vs padding-10 wrapper/relative-h)
- update widget contract tests to pin the whitelist behavior
2026-09-19 16:39:59 +08:00
kate522 2f571c2a98 fix(checkin): keep modal runtime shell fullscreen with modal-mini class
Trigger classes are applied to the fixed shell; .modal-mini's
width:400px + auto margins collapsed the shell (and its backdrop)
into a centered column. Neutralize dialog-only width classes on the
shell so the fullscreen backdrop covers the viewport again.
2026-09-19 15:47:23 +08:00
kate522 cd2b5f6c48 fix(checkin): restore fullscreen reference backdrop on details modal
The reference modal backdrop is a fullscreen, non-transparent black
50% overlay; drop the mistaken transparent-overlay override for the
checkin shell and keep the shared runtime backdrop.
2026-09-19 15:29:33 +08:00
kate522 f3fd38dfd2 fix(checkin): mirror reference experience displays in details modal
Checkin already grants matching experience via Level::on_checkin;
surface it like the reference: purple integral badge in the daily
reward card, 经验值+N in the signed-today block and purple mini
badges in the 7-day ladder (gated by the level system switch).
2026-09-19 15:21:52 +08:00
kate522 f7e0dbc1ce fix(checkin): drop visible backdrop for checkin details modal
Reference site keeps the page fully visible while the checkin modal
is open; make the shell overlay transparent (still closes on click
outside) and rebuild public bundles.
2026-09-19 15:17:45 +08:00
kate522 359a607010 fix(checkin): align modal overlay with reference backdrop style
Reference .modal-backdrop is plain black at 50% opacity with no blur;
replace the runtime overlay's blue-dark tint and backdrop blur to
match it 1:1.
2026-09-19 15:05:55 +08:00
kate522 4653128a18 feat(checkin): open reference-parity details modal from signed buttons
- Rebuild checkin details modal HTML 1:1 with reference theme:
  colorful header c-blue, user card (points/level), daily reward
  card and 7-day continuous mini cards
- Signed entry buttons (header dropdown, user-card badge, user
  center) become clickable checkin-details-link modal triggers
  instead of disabled buttons
- Swap data-signin-btn to details link in place after successful
  signin (reference .initiate-checkin replacement behavior)
- Add aksrt-checkin-details-modal runtime shell (modal-mini 400px)
- Remove dead code: legacy lite modal, admin-ajax details handler,
  /signin/history route, signin calendar; rebuild public bundles
- Update contract tests to the new checkin modal contract
2026-09-19 14:49:58 +08:00
kate522 c289016324 feat(theme): adopt reference base font size (14px)
Set --text-base to 0.875rem to match the reference bootstrap body
metrics; em-based reference components (.img-badge, em utilities) now
render at reference scale site-wide. Compensate .btn-lg with
--text-md (18px, bootstrap btn-lg) to keep the size hierarchy. Simplify
the user-card badge rule to the line-height pin only.
2026-09-19 14:13:05 +08:00
kate522 c4662acdf0 fix(widgets): keep checkin colored look when signed per reference
Drop the gray is-done/disabled overrides on the header checkin chip
(light and dark); the reference keeps the same colored badge after
signing. Card badge itself already renders white on the jb-yellow
gradient (verified in browser with the full stylesheet chain).
2026-09-19 14:02:38 +08:00
kate522 bb8161e1c4 fix(widgets): pin user-card checkin badge to reference font metrics
The site base is 16px (--text-base) while the reference inherits
bootstrap's 14px/1.42857143; scope the user-card img-badge to those
reference metrics so the checkin badge matches the reference size.
2026-09-19 13:50:23 +08:00
kate522 effdac91fd fix(widgets): align user-card checkin badge with reference font and UI
Render the explicit-class variant as an <a> badge (reference parity) so
it inherits the card font instead of the UA button stylesheet, and add
the reference icon spacing (ml3 mr6). FA5 fa-calendar-check retained;
the header dropdown keeps its <button> runtime.
2026-09-19 13:39:38 +08:00
kate522 dc6bf34262 fix(widgets): restore hot-posts rank badge position per reference
Remove unscoped .img-badge top/left overrides from post-card.css and its
build aggregates that hijacked the reference-parity rules in main.css
(top:.6em / .hot-posts left:-15px). Also drop dead .widget-hot-posts
legacy blocks with no remaining markup references.
2026-09-19 13:31:36 +08:00
kate522 ae626ba1c8 refactor(referral): prune dead code and formalize ops list header
- remove unused AuthReader::invitation_mode and ReferralAttribution
  helpers (get_or_create_code / validate_ref); contract test now
  asserts the canonical invit_code_s reader in CreditsRuntime
- drop legacy aksrt_invite table from uninstall cleanup list
- track referral code creation time (aksrt_referral_code_created)
  and surface it in the referral ops REST list
- formalize ops list headers (归属用户/推广码/启用状态/推广用户数/
  有效付费用户数/创建时间) and right-align count columns
2026-09-19 12:54:22 +08:00
kate522 b375fcc55c chore(cleanup): 移除用户中心孤儿签到日历渲染器
credits-signin.php 已无任何引用(用户中心视图映射无 signin 项,profile-credits.php 亦不再挂载),整包删除,不保留兼容分支。

其专属 .uc-signin-calendar__title 样式与 public.css / public.min.css 产物同步移除;签到日历仍由 src/Credits/Signin.php 在签到弹窗内输出,enhancements2.js 的日历渲染逻辑保持不变。
2026-09-19 11:46:07 +08:00
kate522 32e76b440f fix(frontend): 用户中心余额与积分记录不再拼接括号内容
记录行只保留中文变动原因、时间与变动额,移除名称后的(引用键/备注/提现单号)拼接。

同步清理该改动产生的死代码:$record_note 计算块、.uc-transactions__remark 样式、LedgerQuery 中已无消费者的 financial_flow LEFT JOIN 与 withdrawal_number 字段,以及 credits-wallet.php 里恒为 true 的 $skip_wallet_ledger 双路分支。
2026-09-19 11:46:01 +08:00
kate522 0d617d88c9 fix(pay): 余额充值预设档位不再被 custom_price 参数校验拦截
选中任一预设充值档位点「立即支付」,必然弹「无效参数:custom_price」。这是 WP
REST 的参数校验层拦截,请求根本没进业务回调。

原因是 WP REST 的 default 只在「键完全缺失」时生效,键存在但值为空串时 default
不参与,空串会直接送进 pattern 校验:

  'custom_price' => ['type' => 'string', 'default' => '0', 'pattern' => '^(0|[1-9]\d*)(?:\.\d{1,2})?$']

而前端提交时无条件带上该字段(custom_price: custom),选预设档位时 custom 恒为
''。更隐蔽的变体:后台关掉「自定义充值金额」后模板不再渲染该输入框,customEl 为
null、custom 仍是 '',也就是只放开预设档位的配置下 100% 复现。

该字段只在 package=custom 时被消费,而金额格式与区间的业务裁决早已完整存在于
create_charge_order()(AssetAmount::balance 严格正则 + 溢出检查,再按 custom_limit
校验区间并返回本地化错误)。参数声明上的 pattern 是同一规则的第二份实现,两套口径
不同步即是本次缺陷来源。故本次按「单一口径」收口,不再采用「放宽 pattern + 回调归一化
空串 + 前端改送 '0'」的三处并行写法:

- BalanceServiceRest:custom_price 参数去掉 pattern、default 改为 ''、显式声明
  sanitize_callback;rest_charge_create 改为经声明式 args 用 $request->get_param()
  取值,不再手工重读 JSON 体
- credits.js:selectProduct 选中预设档位时清空自定义金额输入(否则框内残留数字会在
  提交时把 package 顶回 custom,静默覆盖用户刚点的档位);customInput 声明上移,去掉
  「先调用后赋值」的时序依赖
- balance-charge-modal.test.cjs:新增 2 项契约测试 / 6 条断言

清理的死代码:
- rest_charge_create 中手工 get_json_params() 并复刻 sanitize_text_field/sanitize_key
  的一段:与声明的 sanitize_callback 重复,使那些声明形同虚设(实为第二套取值路径)
- credits.js 提交处理器内重复读取 payment_method 的死分支(处理器开头已读取,中间无
  任何代码改动它)

放宽仅限「空串 / 键缺失」这一种情形:package=custom 而金额为空、非法或越界仍一律
400,007、abc、1e3、1.234、负数等仍被 AssetAmount::balance() 拒绝,失败方向仍是收紧。

行为变化(需知悉):
- 金额裁决权归 AssetAmount::balance(),其内部 trim() 使带首尾空格的金额被修剪后接受,
  此前被参数层 pattern 直接拒绝。这是「单一裁决者」的必然结果。
- 点选预设档位会清空自定义金额输入框,是修复该档位被静默覆盖的必要行为。

验证:node --test 全量 372 项 / 372 通过(原 370 项,新增 2 项);PHPUnit 44 项 /
106 断言通过;改动 PHP 文件 php -l 通过。变异验证:把 6 条关键断言跑在修复前的文件
内容上,6/6 转红,证明回归测试拦得住此缺陷。未做视觉验收,弹窗标记与 CSS 未改动。
2026-09-19 11:34:12 +08:00
kate522 ce21cc2b9b revert fa476af70e
revert refactor(pay): 充值商品以充值金额为唯一真值,移除销售金额与折扣

充值商品行此前是双值模型:price 充值金额决定到账、pay_price 销售金额决定支付,
pay_price 为 0 时回落到 price,否则即「付更少拿更多」的折扣。该模型把三处耦合在
一起——后台 schema 暴露销售金额输入、线格式首段携带支付额、下单时计价与到账分离。
只改其中一处就会出现「后台不再可配但前端仍在展示折扣」或「展示已合并但下单仍按
旧价扣款」的静默不一致,故本次三层一并收口,充值金额即实际支付金额。

后台 schema 有两份且必须同步:SPA 侧 recharge.js 负责渲染表单,PHP 侧 SchemaPayment
负责默认值与清洗。只改一份会让界面不再可配而默认值仍在写 pay_price。

线格式三段 "支付|到账|标题" 保持不动,仅为兼容历史已存的字符串配置:支付段不再读取
任何配置来源,恒等于充值金额,故历史配置里的折扣价会被忽略。旧数据无需迁移,pay_price
键留在库中只是不再被读取,从重建后的后台保存一次即自然消失。

行为变化(需知悉):
- 已配置折扣的站点,其用户将按充值金额全额支付,即对这些用户是涨价。这是「不做优惠
  和促销」的必然结果。
- parse_packages 的校验口径随之变松:有效性只由到账段决定,故 "abc|10|x" 这类首段
  非法、到账段合法的行会被解析为 10 元档,而旧实现两段都作金额校验、会丢弃。理由是
  支付段已不表达任何含义,因一个无用字段是垃圾就丢弃一条本来有效的档位只会让运营困惑。
  既有数据不受影响——旧格式化器写出的首段恒为数字,只有手改出的脏数据才可能命中。
  该取舍已写入方法文档。

- recharge.js / SchemaPayment:删除 pay_price 字段与三档默认值中的该键,help 去掉
  「同时支持设置折扣」
- PaymentRuntime::format_product_lines:不再读取 pay_price,支付段由充值金额充当
- BalanceServiceCharge::parse_packages:不再产出 pay_price 键,金额唯一来源为到账段
- BalanceServiceCharge::create_charge_order:支付额与到账额同源于 price
- credits-wallet.php:移除销售金额行、「省X」折扣徽标及仅服务折扣的差额计算
- BalanceServiceRest:仅注释改写,说明积分路径的 pay_price 是售价而非折扣
- 新增 tests/widget-system/recharge-product-price-contract.test.cjs:5 条跨层契约

范围边界:积分商品 pay_points_product 的 pay_price 是「积分|支付|标签」里的实际售价,
本身不含折扣语义,保留不动。契约测试以正向断言固定该边界,避免日后被误当作同类问题
一并删掉。

验证:新增契约测试 5 项通过;node --test 全量 375 项 / 375 通过(原 370 项);PHPUnit
44 项 / 106 断言通过;改动的 PHP 文件 php -l 全部通过。变异验证:把 create_charge_order
改回按 pay_price 计价、把 pay_price 重新注入模板,对应断言分别转红,证明回归测试拦得
住回退。另以反射调用真实代码做端到端执行验证 11 项全过,覆盖结构化数组 -> 线格式 ->
商品数组全链路、数组内残存 pay_price 被忽略、历史折扣字符串(98|100|旧折扣档 取 100)、
到账段为 0 或非法时跳过等边界。

部署提示:assets/admin/build 为部署时构建、未入库,后台表单的改动需在目标机执行
cd assets/admin && npm ci && npm run build 后才会生效。前端 credits.js 走 filemtime
版本号,无需手工清缓存。
2026-09-19 03:25:12 +00:00
kate522 633ab7abe3 revert c1a0eade38
revert fix(pay): 余额充值选中预设档位不再因空 custom_price 被 REST 拒绝

选中任一所预设充值档位点「立即支付」,必然弹「无效参数:custom_price」。这是
WP REST 的参数校验层拦截,请求根本没进业务回调。

原因是 WP REST 的 default 只在「键完全缺失」时生效,键存在但值为空串时 default
不参与,空串会直接送进 pattern 校验:

  'custom_price' => ['default' => '0', 'pattern' => '^(0|[1-9]\d*)(?:\.\d{1,2})?$']

而前端提交时无条件带上该字段(custom_price: custom),选预设档位时 custom 恒为
'',于是必然校验失败。另一个更隐蔽的变体:后台若关掉「自定义充值金额」,模板里
根本不渲染该输入框,customEl 为 null,custom 仍是 '',同样报错——也就是说这个
缺陷在只放开预设档位的配置下 100% 复现。

同文件的积分兑换路径(/pay/balance/points-exchange)早已写成
custom: validCustom ? custom : '0',余额充值这条只是漏了同样的处理。

- BalanceServiceRest:custom_price 的 pattern 改为整体可选分组
  ^(?:(?:0|[1-9]\d*)(?:\.\d{1,2})?)?$,显式放行空串;rest_charge_create 把空串
  归一化为 '0',与参数声明的 default 语义对齐,避免 AssetAmount::balance() 拿到
  空串后抛 balance_decimal_invalid
- credits.js:非自定义金额时显式送 '0',与积分兑换路径写法一致
- 契约测试补 4 条断言:模式可选、允许空串、回调归一化、前端送 '0'

两层都改是有意的:JS 会被浏览器与 CDN 缓存,旧缓存仍会发空串,后端放行兜住;
后端放行则保证任何客户端都不再被这个字段卡住。放宽仅针对空串,金额格式校验
不放松——007、abc、-1、1e3、1.234 与带首尾空格的值仍一律拒绝。

验证:新 pattern 逐例实测,''、0、1、9.99、9999.99 放行,007、abc、-1、1e3、
1.234、带空格全部拒绝;变异验证把 pattern 改回旧写法后该用例立刻转红,证明回归
测试确实拦得住此缺陷;node --test 全量 370 项 / 370 通过;PHPUnit 44 项 / 106
断言通过。断言未直接比对含反斜杠的整串——JS 字面量与 PHP 单引号串的反斜杠层数
不同会静默比对失败,故拆为不含反斜杠的片段。
2026-09-18 17:44:25 +00:00
kate522 fa476af70e refactor(pay): 充值商品以充值金额为唯一真值,移除销售金额与折扣
充值商品行此前是双值模型:price 充值金额决定到账、pay_price 销售金额决定支付,
pay_price 为 0 时回落到 price,否则即「付更少拿更多」的折扣。该模型把三处耦合在
一起——后台 schema 暴露销售金额输入、线格式首段携带支付额、下单时计价与到账分离。
只改其中一处就会出现「后台不再可配但前端仍在展示折扣」或「展示已合并但下单仍按
旧价扣款」的静默不一致,故本次三层一并收口,充值金额即实际支付金额。

后台 schema 有两份且必须同步:SPA 侧 recharge.js 负责渲染表单,PHP 侧 SchemaPayment
负责默认值与清洗。只改一份会让界面不再可配而默认值仍在写 pay_price。

线格式三段 "支付|到账|标题" 保持不动,仅为兼容历史已存的字符串配置:支付段不再读取
任何配置来源,恒等于充值金额,故历史配置里的折扣价会被忽略。旧数据无需迁移,pay_price
键留在库中只是不再被读取,从重建后的后台保存一次即自然消失。

行为变化(需知悉):
- 已配置折扣的站点,其用户将按充值金额全额支付,即对这些用户是涨价。这是「不做优惠
  和促销」的必然结果。
- parse_packages 的校验口径随之变松:有效性只由到账段决定,故 "abc|10|x" 这类首段
  非法、到账段合法的行会被解析为 10 元档,而旧实现两段都作金额校验、会丢弃。理由是
  支付段已不表达任何含义,因一个无用字段是垃圾就丢弃一条本来有效的档位只会让运营困惑。
  既有数据不受影响——旧格式化器写出的首段恒为数字,只有手改出的脏数据才可能命中。
  该取舍已写入方法文档。

- recharge.js / SchemaPayment:删除 pay_price 字段与三档默认值中的该键,help 去掉
  「同时支持设置折扣」
- PaymentRuntime::format_product_lines:不再读取 pay_price,支付段由充值金额充当
- BalanceServiceCharge::parse_packages:不再产出 pay_price 键,金额唯一来源为到账段
- BalanceServiceCharge::create_charge_order:支付额与到账额同源于 price
- credits-wallet.php:移除销售金额行、「省X」折扣徽标及仅服务折扣的差额计算
- BalanceServiceRest:仅注释改写,说明积分路径的 pay_price 是售价而非折扣
- 新增 tests/widget-system/recharge-product-price-contract.test.cjs:5 条跨层契约

范围边界:积分商品 pay_points_product 的 pay_price 是「积分|支付|标签」里的实际售价,
本身不含折扣语义,保留不动。契约测试以正向断言固定该边界,避免日后被误当作同类问题
一并删掉。

验证:新增契约测试 5 项通过;node --test 全量 375 项 / 375 通过(原 370 项);PHPUnit
44 项 / 106 断言通过;改动的 PHP 文件 php -l 全部通过。变异验证:把 create_charge_order
改回按 pay_price 计价、把 pay_price 重新注入模板,对应断言分别转红,证明回归测试拦得
住回退。另以反射调用真实代码做端到端执行验证 11 项全过,覆盖结构化数组 -> 线格式 ->
商品数组全链路、数组内残存 pay_price 被忽略、历史折扣字符串(98|100|旧折扣档 取 100)、
到账段为 0 或非法时跳过等边界。

部署提示:assets/admin/build 为部署时构建、未入库,后台表单的改动需在目标机执行
cd assets/admin && npm ci && npm run build 后才会生效。前端 credits.js 走 filemtime
版本号,无需手工清缓存。
2026-09-18 23:56:31 +08:00
kate522 c1a0eade38 fix(pay): 余额充值选中预设档位不再因空 custom_price 被 REST 拒绝
选中任一所预设充值档位点「立即支付」,必然弹「无效参数:custom_price」。这是
WP REST 的参数校验层拦截,请求根本没进业务回调。

原因是 WP REST 的 default 只在「键完全缺失」时生效,键存在但值为空串时 default
不参与,空串会直接送进 pattern 校验:

  'custom_price' => ['default' => '0', 'pattern' => '^(0|[1-9]\d*)(?:\.\d{1,2})?$']

而前端提交时无条件带上该字段(custom_price: custom),选预设档位时 custom 恒为
'',于是必然校验失败。另一个更隐蔽的变体:后台若关掉「自定义充值金额」,模板里
根本不渲染该输入框,customEl 为 null,custom 仍是 '',同样报错——也就是说这个
缺陷在只放开预设档位的配置下 100% 复现。

同文件的积分兑换路径(/pay/balance/points-exchange)早已写成
custom: validCustom ? custom : '0',余额充值这条只是漏了同样的处理。

- BalanceServiceRest:custom_price 的 pattern 改为整体可选分组
  ^(?:(?:0|[1-9]\d*)(?:\.\d{1,2})?)?$,显式放行空串;rest_charge_create 把空串
  归一化为 '0',与参数声明的 default 语义对齐,避免 AssetAmount::balance() 拿到
  空串后抛 balance_decimal_invalid
- credits.js:非自定义金额时显式送 '0',与积分兑换路径写法一致
- 契约测试补 4 条断言:模式可选、允许空串、回调归一化、前端送 '0'

两层都改是有意的:JS 会被浏览器与 CDN 缓存,旧缓存仍会发空串,后端放行兜住;
后端放行则保证任何客户端都不再被这个字段卡住。放宽仅针对空串,金额格式校验
不放松——007、abc、-1、1e3、1.234 与带首尾空格的值仍一律拒绝。

验证:新 pattern 逐例实测,''、0、1、9.99、9999.99 放行,007、abc、-1、1e3、
1.234、带空格全部拒绝;变异验证把 pattern 改回旧写法后该用例立刻转红,证明回归
测试确实拦得住此缺陷;node --test 全量 370 项 / 370 通过;PHPUnit 44 项 / 106
断言通过。断言未直接比对含反斜杠的整串——JS 字面量与 PHP 单引号串的反斜杠层数
不同会静默比对失败,故拆为不含反斜杠的片段。
2026-09-18 23:41:27 +08:00
kate522 ca9dc7794f refactor(pay): 流水号扩至 22 字符,移除资金流水归档方案与删单守卫
四件事合在一笔,因为它们落在同一批文件里——OrderRepositoryCore 与流水契约测试
同时承载号码规则与订单清理逻辑,硬拆会产生引用不通的中间提交。

1) 号码熵。{YmdHis}{6 位随机} 在同一秒内只有 10^6 的取值空间,高并发下单会因
   随机后缀碰撞触发 uniq_order_number / uniq_flow_number 冲突(每秒 3 单约
   2.6 天撞一次,每秒 10 单约每天 4 次)。后缀扩到 8 位后空间升至 10^8
   (+6.6 bit),撞号频率下降两个数量级。
   选 8 位纯数字而非等长 base36:保持定长纯数字形态,号码仍可被运营从截图或
   邮件整段复制、电话口述;且表默认排序规则是 utf8mb4_*_ci(大小写不敏感),
   混入大小写字母会让唯一索引把 aB 与 Ab 判为重复行,反而凭空制造冲突。22 字符
   仍满足 VARCHAR(32) 与网关 out_trade_no ≤ 32,拼上重试后缀 P999 后为 26 字符。

2) 自带订单号形态校验。create() 此前对调用方传入的 order_number 不做检查,传数组
   会因 (string) 转换把字面量 "Array" 写进列。现收紧为「全数字、20 或 22 位」,
   不合规即弃用并回落到分配器(WP_DEBUG 下留痕)。放行 20 位是为了兼容扩位前
   分配的历史号码——该方法原有的注释就声明服务于导入、补单与幂等重放。

3) 流水表补 KEY idx_flow_created (created_at)。后台「资金流水」页默认视图是
   WHERE 1=1 ORDER BY created_at DESC, id DESC LIMIT n 加同条件 COUNT(*),界面
   上的日期区间筛选也只有 created_at 可用。此前没有任何以 created_at 为前导列的
   索引(idx_flow_event_time 的前导列是 event_type,筛选时可空),于是每次翻页
   都是全表扫描 + filesort,且与表大小无关地重复付出。

4) 移除归档方案与删单守卫。归档表 + 每日清理 cron 需要配套的归档浏览入口才闭环,
   未闭环时运营会误以为记录丢失;而第 3 条的索引已覆盖它要解决的主要问题,且
   不必在财务表上执行 DELETE。一并撤回删单 / 清理订单时对财务记录的拦截。

- BusinessNumber:后缀位数与上界收敛为 SUFFIX_DIGITS / SUFFIX_MAX,去掉三处
  重复的魔法数字;类文档写明选型依据,避免日后被改回字母方案
- OrderRepositoryCore:新增 ORDER_NUMBER_PATTERN 与 is_scalar 守卫;
  clear_closed_orders 与 delete_order 恢复单路签名(去掉 $force 与
  $include_orders_with_records)
- DBMigratorPart2:流水表新增 idx_flow_created,并删除两张归档表 DDL
- PayOrders:清理与删除订单回到单路处理,移除 orders_clear_blocked /
  order_delete_blocked 两条提示
- 删除 FinancialFlowRetention 类与 functions.php 中的引导注册
- 测试:BusinessNumberTest 期望值改 22 字符、越界用例改 10^8、补纯数字与 P999
  余量断言;流水契约测试并入索引断言、归档移除的反向断言、号码重试与形态断言

按用户要求不写双路逻辑与数据迁移。新索引依赖 dbDelta 在下次迁移时补到既有表;
若此前已跑过迁移,两张归档空表需手工 DROP(未写迁移,故不自动处理)。

验证:PHPUnit 44 项 / 106 断言全部通过;node --test(已追踪用例)333 项 /
333 通过,含本地专用用例共 370 项全部通过;php -l 全部改动的 PHP 文件通过;
残留引用扫描确认 FinancialFlowRetention、两张归档表名、has_financial_records、
count_closed_orders_with_records、financial_records_exists_sql、两条 blocked
提示、$force 与 $include_orders_with_records 均已无残留。
2026-09-18 23:17:36 +08:00
kate522 c06aadb9a9 fix(css): 弹窗在 portal 内重建共享令牌基座,修复样式静默失效
弹窗外壳由 runtime portal 到 <body>,已经位于任何页面级 token 作用域之外。此前
路由样式把语义别名挂在页面容器上:

  .aksrt-orders-page { --orders-ink: var(--color-ink, #333); }

弹窗内部再消费 var(--orders-ink) 时该变量不存在,整条声明被判为无效值丢弃。
CSS 自定义属性解析失败不会抛错,只表现为边框消失、次级文字发灰发透明,排查
成本很高——订单详情弹窗此前「样式没做完」的观感就来自这里。

修法是在弹窗外壳上重建基座,而不是逐个给弹窗补别名:外壳永远位于 portal 内,
任何弹窗都能直接消费。

- modal-runtime.css 新增 portal token bridge,在 .aksrt-modal-runtime 上声明
  --modal-ink / -ink-2 / -muted / -line / -line-soft / -surface / -surface-2 /
  -surface-3 / -primary / -on-primary / -warning / -success / -danger,
  全部以 var(--color-*, 回退值) 兜底
- orders.css 的 --detail-* 由直接推导调色板值改为映射 var(--modal-*),路由文件
  不再自行持有颜色定义,避免同类问题在下一个弹窗上重演
- 重建 assets/css/build/public.css 与 public.min.css

验证:node --test(已追踪用例)333 项 / 333 通过;确认重建后的 public.min.css
已包含 --modal-ink。视觉核对未在本次提交内完成(需要可运行的站点),如需可在
真实环境按订单详情弹窗逐项比对边框、分隔线与次级文字色。
2026-09-18 23:17:18 +08:00
kate522 3bec2a5777 fix(icons): 图标类名统一为 FA5 语法并修正一处失效图标名
主题打包的是 Font Awesome 5,solid 字形必须带样式类 fas。全站有 18 处仍写成
FA4 的裸 fa fa-*,FA5 下并不存在对应字形,表现为图标位置空白;而 FA5-only
契约此前只扫描部分运行时文件,没有覆盖模板与渲染器,这些写法一直没被拦下。

同时发现一处真实的失效图标名:profile-level.php 用的是 fa-line-chart。这是
FA4 名称,FA5 把该系列重命名为 chart-*,正确写法是 fa-chart-line。

- 18 处 "fa fa-*" 补上 fas:首页模板、组件标记、评论表单、用户等级页、菜单
  装饰、SchemaPayment 促销标签默认值、VIP 服务弹窗、ContentRenderer /
  GeneralRenderer、后台卡片样式校验、pay-vip.js
- profile-level.php 的 fa-line-chart 修正为 fa-chart-line
- 三处注释里的示例值同步为合法形态("fa fas fa-star"),避免被当成正确写法
  抄走;credits.css 注释中直接写出旧字体族名的一处改为描述性表述,否则会被
  FA5-only 契约当作违规命中
- legacy-navigation-contract:FA 资产断言改为跟随 aksrt_enqueue_fontawesome()
  这一间接层(注册点已从脚本文件移到 inc/helpers/aksrt-icons.php),并把
  fa-zibsvg-<key> 识别为项目自有 SVG sprite 族,而非 Font Awesome 字形
- vip-points-exchange-modal 契约同步断言 fa fas fa-angle-right

验证:改动前在干净 HEAD 上跑 node --test(已追踪用例)为 327 项 / 325 通过 /
2 失败,两项失败正是本次修复的失效图标名断言与 FA 版本号断言;改动后 333 项 /
333 通过(含 5 个本地专用用例共 370 项全部通过)。php -l 全部改动的 PHP 文件
通过。
2026-09-18 23:17:05 +08:00
kate522 a6819ec7a5 chore(repo): 显式声明行尾规则并恢复 tests 目录追踪
仓库此前没有 .gitattributes,行尾完全依赖 core.autocrlf=true。源文件在工作区
是 CRLF,而 esbuild 产物(public.css / public.min.css、运行时 .min.js)是 LF,
两种行尾混在同一套自动转换策略下:每次构建都会把产物整文件标记为已修改,真正的
改动反而被行尾噪音淹没。

- 新增 .gitattributes:源文件(php / js / cjs / vue / json / css / md 等)统一
  text eol=crlf,与既有工作区一致;构建产物(assets/css/build/**、
  assets/css/main.min.css、assets/js/runtime/*.min.js、assets/js/build/**)
  强制 text eol=lf,与 esbuild 输出一致;.gitattributes / .gitignore 自身用
  eol=lf,避免规则文件自己先被转换
- .gitignore 改为反向规则:先以 !/tests/ 放行整个目录,再逐个排除仅本地使用的
  条目(visual-results、widget-golden 夹具、5 个 widget-system 用例、run.php、
  scratch harness)。此前 tests/navigation/ 从未被放行,只靠历史追踪才没有从
  仓库里消失——一旦有人重新 clone,相关用例就会缺文件
- 顺带纳入 /.workbuddy/

验证:git ls-files --eol 确认源文件为 CRLF、构建产物为 LF;git status --ignored
确认仅本地条目仍处于忽略状态,反向规则没有意外放行本地用例。改动不触及运行时
代码与测试断言,node --test tests/**/*.test.cjs 370 项全部通过。
2026-09-18 23:16:01 +08:00
kate522 833f94bf3a feat(orders): 规范化前台订单详情弹窗
弹窗外壳由 modal-runtime 挂载在 body 下,并不位于 .aksrt-orders-page 之内,
而原有 .aksrt-order-detail__* 规则引用的 --orders-ink / --orders-line 等别名
只定义在页面容器上。变量在该作用域无法解析,整条声明被判为无效值丢弃,于是
边框、分隔线与次要文字色全部没有生效——弹窗此前「没做完」的根因就在这里。

- renderDetail() 重构为状态横幅 + 商品信息 + 交易信息 + 操作栏四段
- 状态配色改由单个 --hero-accent 变量驱动,按状态切换琥珀 / 绿 / 品牌色 /
  中性灰;--hero-text 再与墨色混合,避免强调色直接用作正文导致对比度不足
- 交易信息补上支付方式;流水号增加复制按钮(clipboard API + textarea 回退,
  带 is-copied 反馈)
- 待支付订单在详情内显示实时倒计时;updateCountdowns() 经新增的
  countdownScopes() 一并扫描弹窗内容,原先只扫页面根节点
- 补充此前完全缺失的 refresh-state 与 skeleton 样式
- 金额口径按状态区分应付款 / 实付款 / 订单金额;卡密核销回退出的原始渠道 key
  归一显示为「卡密核销」
- 弹窗外壳挂 aksrt-order-detail-modal 类以限定样式作用域,宽度调至 480px

关键点是所需 token 在弹窗外壳上重新声明,而不是继续依赖页面级别名——后者会
因 portal 作用域再次失效。移动端沿用 bottom sheet,操作按钮等分宽度。

验证:node --test tests/**/*.test.cjs 362/364 通过(2 项失败为改动前既存,
见上一笔提交说明);phpunit 44 项 / 103 断言全部通过;Chromium 实拍 1080px
浅色与暗色两套主题逐项核对;样式类定义 21 个与引用 21 个完全对应,无遗留
死样式;order-center.js 经 node --check 语法校验通过。
2026-09-18 22:14:46 +08:00
kate522 aad83072a8 refactor(pay): 统一流水号,移除订单/流水/提现三类号码前缀
订单号、资金流水号、提现单号此前各自带前缀配置,同一笔业务在不同界面会
呈现多个号码口径,对账与客诉排查都要先做一次前缀换算。现收敛为单一流水号
分配器,不再按业务类型区分号码形态。

- 删除 payment_order_prefix / payment_flow_prefix /
  payment_withdrawal_prefix 三项配置项及其生成、校验链路
- BusinessNumber::allocate() 成为唯一入口,输出 {YmdHis}{6 位随机},
  共 20 位无前缀,满足 VARCHAR(32) 与网关 out_trade_no ≤ 32 的限制
- 订单、资金流水、提现、卡密渠道交易号四处生成点全部改走该分配器
- 后台支付中心表头「订单号 / 提现单号」统一为「流水号」
- 前台用户中心与订单卡片文案同步统一;前台订单详情合并为单行流水号,
  账本流水号不再下发到前台,避免同一单据出现两个对外号码
- 同步更新 financial-flow / pay-card / card-pass-order-numbers 契约测试
  与 BusinessNumberTest 断言

按用户要求不做双路逻辑与数据迁移,直接替换为统一方案,历史行不做转换。

验证:node --test tests/**/*.test.cjs 362/364 通过,新增 37 项契约;失败的
2 项在改动前的干净 HEAD 基线上同样失败(基线 327 项 / 失败 2 项),分别命中
遗留的 fa fa- 标记与 FA 版本号断言,与本次改动无关。phpunit 44 项 / 103
断言全部通过;php -l 全部改动的 PHP 文件通过。
2026-09-18 22:14:27 +08:00
kate522 8b999444b2 fix(icons): 图标卡片支持内置 SVG 与自定义 SVG,修复前端图标不显示
顶部多功能组件的图标卡片只能显示 FA5 图标。后台「选择图标 → 内置 SVG」
页签写入的是 zibsvg-{key},但渲染器把它拼成了
<i class="fa fas fa-zibsvg-hot">,浏览器没有这个名字的字形,前端图标位置
空白。

根因:aksrt_icon_cover_card() 与 aksrt_icon_card() 只实现了 FA 类名一条
路径,未走全站统一渲染器 aksrt_icon_markup();函数内那条用于跳过前缀拼接的
判断用的是 aksrtsvg-(任何选择器都不会产生的废弃前缀),形同虚设。

- 新增 aksrt_card_icon_inner() 统一解析器,横版 / 竖版卡片均委派给它,
  FA5 类名、zibsvg- 内置图标、customsvg- 内联 SVG 三种格式统一渲染
- aksrt_icon_card() 的 icon 值改用 aksrt_sanitize_icon_value(),
  原 sanitize_text_field 会把 customsvg-<svg> 的标签整段剥成裸前缀
- SchemaProvider::sanitize_field_value() 补 type === 'icon' 分支:
  分组 / 重复器内的图标子字段原先落到 sanitize_text_field,
  header_slider_card_option.cards[].icon 的 customsvg- 在保存时即被剥空
- PageConfigMetaBox::sanitize_card_options() 同步改用图标净化
- aksrt_svg_kses_tags() 补齐 circle / ellipse / rect / polygon / line,
  与选择器端 cleanSvg() 的接受范围对齐;原先粘贴以 <circle> 为主的图标
  会在 kses 阶段被静默剥离形状
- icon-card-contract 新增 3 项契约

内置 SVG 的 sprite 由 svg-icon.js 每页注入 body,无需额外入队。

未写数据迁移;已存量的 zibsvg- / FA 值无需转换,customsvg- 存量值本就被
旧净化逻辑破坏,不在此次范围内。

验证:node --test tests/widget-system/*.test.cjs 311 项全部通过(原 308 +
新增 3);php -l 4 个改动文件通过;隔离 PHP 探针覆盖 9 种取值并走完整
「保存净化 → 前端渲染」链路,修复前 / 后输出逐项对比。
2026-09-18 20:05:14 +08:00
kate522 95ff0761b7 fix(vip): 积分兑换弹窗改服务端渲染并与 Zibll 1:1 对齐
/user/vip/ 的积分兑换会员弹窗原先是前端自行拼装的,结构与层级都与参考主题
Zibll 不一致:Zibll 走 zibpay_get_vip_points_exchange_modal() 服务端渲染静态
壳,前端只按需开合。改为同一模型,前端不再拼 HTML。

- VipServiceModal 新增 get_points_exchange_modal_html():门控(未登录/未开启
  返回空串)、vip-card 套餐选择、积分不足提示与提交表单;wp_footer 挂载输出
- pay-vip.js 移除 renderPointsModal / pointsPackage / exchangeFromModal /
  vipPointsPay,改为自包含开合(.in + 内联 display:flex + 独立遮罩 +
  body.modal-open + ESC),保留 aksrt-vip-points-toast 的 load/success/danger 契约
- 移除 /user/vip/ 页面底部独立积分兑换入口及其样式(uc-vip-points-link)
- credits.js 清理依赖全站不存在选择器的死代码 initVipPointsExchange()
- vip-modal.css 删除 71 行自实现样式,改为 .modal[data-aksrt-vip-points-modal]
  显隐契约;层级 100050 < 遮罩 100055 < 弹窗 100060 < notyf 10000000
- 新增 vip-points-exchange-modal 契约测试(7 项),toast-contract 同步

静止态必须显式 display:none,否则 .flex{display:flex} 会覆盖 .modal{display:none}
形成隐形遮罩拦截整页点击。

未写数据迁移。

验证:node --test tests/widget-system/*.test.cjs 308 项全部通过;php -l 全部
改动文件通过;隔离渲染桩确认积分充足 / 积分不足 / 功能关闭 / 未登录四种情形
的 HTML 产物正确。
2026-09-18 20:05:05 +08:00
kate522 d2447f8aa6 fix(pay): 修复后台收款接口显示顺序不可用并接入收银台排序
后台「收款接口 → 收款接口显示顺序」有三处缺陷叠加:
1) sortable 渲染组件读取 item.label,而 schema 写的是 title,导致 6 行
   只剩上下箭头;
2) SchemaProvider 的 sortable 分支遍历 items 定义重建数组,而该字段的
   键顺序本身就是配置值,用户排序被静默改回 schema 静态顺序;
3) 真正构建收银台支付方式列表的 aksrt_list_cash_payment_methods() 使用
   硬编码顺序,Gateway::get_available()/apply_sdk_order() 那套排序逻辑
   无任何调用方,配置对前台毫无影响。

改为:schema 项统一使用 label(JS 与 PHP 两处对齐);sortable 持久化按
提交键顺序重建,items 定义仅作白名单;新增唯一排序实现
aksrt_sort_payment_methods()(语义对齐参考主题 zibpay_get_payment_methods
+ pay_sdk_order 键顺序),并接入内容收银台、余额充值、购买积分与会员
购买四个入口。卡密输入盒改为按渠道存在与否判断,不再假定卡密固定在末位。

清理死代码:无调用方的 Gateway::get_available/apply_sdk_order/
get_status_summary、VipServicePackages 中不可达的支付方式列表回退分支、
零引用的 settings-defaults 模块及 schema/index.js 未使用的导出、支付
sortable 的 fields 死元数据、无拖拽实现却声明的 cursor:move。

未配置 pay_sdk_order 时顺序保持不变;未写数据迁移。

验证:php -l 8 个改动文件全部通过;新增 pay-sdk-order-contract 契约测试
(6 项);node --test tests/widget-system/*.test.cjs 301 项全部通过;
后台 SPA 已重新构建。
2026-09-18 19:01:19 +08:00
kate522 530a5b998f chore(img): 更新 Stripe 图标为用户修订版本(VTracer 矢量化,#5339FD 品牌紫) 2026-09-18 18:52:16 +08:00
kate522 1abf9fe340 chore(img): 更换 Stripe 支付图标为用户提供版本
原 pay-stripe-logo.svg(2468x1024 宽形)替换为用户提供的
bi-stripe 图标(16x16 方形,fill=currentColor,无脚本,安全)。
三处引用(充值弹窗 logo 映射、会员弹窗 logo 映射、下载弹窗)
均按文件名引用,同名覆盖即全量生效;无头 Chrome 实测渲染
35x35 无变形。
2026-09-18 18:44:18 +08:00
kate522 03871f0342 fix(pay): 渠道名称禁折行,消除三字渠道名的零余量换行
实测(bootstrap+main 真实加载组合):芯片文本行可用宽度 46px 对
三字渠道名「支付宝」(雅黑 14px×3=42px)近乎零余量,站点字体渲染
的细微差异(字距/亚像素舍入)即触发 CJK 逐字换行,参考主题实测为
恒单行。

- .payment-method-radio > div 增加 white-space:nowrap,锁定与参考
  主题同等的单行表现(渠道名集合均为 2-3 字短名,无溢出风险)
- main.min.css 重建,契约测试锁定(323 项全部通过)
2026-09-18 18:38:07 +08:00
kate522 89801236eb fix(pay): 方法行还原 Zibll 原样(不换行),项目收缩成芯片一行排满
上一轮给方法行加的 hh(flex-wrap:wrap)方向错误:行级换行使项目保持
「图标左+文字右」的宽形态直接断行(3+2 布局),永远不会收缩成参考
主题的芯片形态。Zibll 的真实机制是行不换行、项目经 flex 收缩后由项
内 hh 将文字折到图标下方,形成「图标上/文字下」窄芯片一行排满。

- 三处方法行还原为 flex mb10(与参考主题逐字一致):充值/积分模板
  与会员 pay-vip.js
- 图标不变形由既有防护保障(flex:none + width:auto + max-width:none
  抵消 bootstrap 全局 img 钳制)
- 无头 Chrome 实测(bootstrap+main 真实加载组合,400px 弹窗):5 渠
  道一行 5 个芯片(62-73px、图标 35px),6 渠道一行 6 个(53-62px、
  stripe 宽形 84px),与 Zibll 同 fixture 形态一致
- 契约测试同步:行标记锁定无 hh、img 四属性防变形断言保留(322 项
  全部通过)
2026-09-18 18:26:50 +08:00
kate522 be07db3409 fix(pay): 抵消 bootstrap 全局 img max-width 对支付图标的钳制
AKSRT 的 bootstrap.min.css 将 BS3 .img-responsive 选择器组误并入了裸
img(img{display:block;max-width:100%;height:auto};Zibll 的裸 img 不
在该组),导致 flex 压缩场景下 max-width:100% 把支付方式图标钳制到被
压窄的项宽——上一轮的 flex:none 防不住 max-width,5 渠道时图标仍可
被压成细条。

- .payment-method-radio img 显式还原 width:auto + max-width:none
  (连同上一轮 flex:none,图标在任何压缩场景恒为固有比例宽)
- 契约测试改为提取规则块逐属性断言(原正则被规则内注释的右花括号
  截断)
- 无头 Chrome 真实加载组合(bootstrap+main)实测:5 渠道 400px 弹窗
  图标 35x35、项宽 112-183px,无任何挤压
2026-09-18 18:18:31 +08:00
kate522 e9dd928859 fix(pay): 多渠道时支付方式图标不再被 flex 压缩变形
用户配置 5-6 个收款渠道时(400px modal-mini 内 6 项),方法行项目
被 flex 压缩导致图标变形:方形 logo 被压至 31px/22px,宽形 stripe
logo(2468:1024)被压成 35px 方形。经无头 Chrome 几何实证,参考主题
同场景存在完全相同的缺陷(两主题 main.css 该组件逐条一致),属 Zibll
组件固有问题而非复刻偏差;按用户要求修复且不改变正常场景视觉:

- main.css:.payment-method-radio img 增加 flex:none(图标恒为固
  有比例宽,高 35px 不变);main.min.css 经 build-public-assets 重建
- 三个弹窗方法行加 hh(.flex.hh{flex-wrap:wrap}):空间不足时整行
  换行而非压缩图标——充值/积分(服务端模板)与会员(pay-vip.js)
  三处同步
- 契约测试锁定 img flex:none 与三处行标记(322 项全部通过)
- 验证:无头 Chrome 实测 6 渠道 370/360/330/300px 容器下全部图标
  恒定固有比例宽度,行自动换行
2026-09-18 18:13:58 +08:00
kate522 6075028520 fix(pay): 支付方式字样与按钮 UI 三弹窗统一对齐 Zibll
渠道展示名与按钮 UI 与参考主题 zibpay_get_payment_method_args /
zibpay_get_initiate_pay_input 对齐:

- 渠道名:aksrt_list_cash_payment_methods 增加 Zibll 基准展示名映射
  (余额/支付宝/微信/PayPal/Stripe),充值与会员弹窗不再显示网关
  内部名「站内余额」「微信支付」;积分弹窗手写「余额/卡密」与之同源
- 会员按钮字样:cta_label(立即开通/立即续费)→ Zibll 会员弹窗
  initiate_pay_input(4) 默认「立即支付」,与充值/积分弹窗一致
- 无可用支付方式:禁用按钮+自实现文案 → Zibll 同款
  <span class="badg px12 c-yellow-2">暂时无法支付,请与客服联系</span>
- 清理:按钮残留类 aksrt-vip-submit 与对应 CSS,加载/禁用态统一挂
  .initiate-pay(三弹窗按钮共用);renderMethods 空数组重复提示与
  不可达 reason 映射删除
- 契约测试同步锁定(无 cta_label/aksrt-vip-submit、立即支付字样、
  badg 提示、渠道名映射)

弹窗宽度差异(充值/积分 400px modal-mini、会员 600px)为 Zibll
原生设计,已在壳几何对齐时确认一致。
2026-09-18 17:48:54 +08:00
kate522 ec1107c65a fix(vip): 会员弹窗壳几何 1:1 对齐 Zibll(600px/整屏高度阈值/8px 圆角)
滚动条与尺寸偏差根因是 aksrt-modal-runtime 壳覆盖偏离参考主题
modal_pay_uservip 的几何:

- 宽度 min(560px,100%) → min(600px, calc(100vw - 20px)):对齐
  bootstrap3 ≥768px .modal-dialog 600px + .modal.flex .modal-dialog
  的 max-width calc(100% - 20px)
- 高度 min(90vh,760px) → calc(100vh - 20px):760px 阈值过低是侧边
  滚动条根因;对齐 .modal.flex .modal-content 的整屏阈值
- overflow auto → hidden + overflow-y auto + touch 滚动:对齐
  .modal.flex .modal-content,正常桌面内容不再出现滚动条
- 圆角 12px → var(--main-radius, 8px):对齐 .modal-content
- 移除 .payvip-modal 本地 padding(18/16px),内边距回归 .box-body
  15px(参考主题无 .payvip-modal 专属 padding)
- 契约测试同步锁定桌面壳几何、无本地 padding 与移动端 bottom 圆角
2026-09-18 17:34:00 +08:00
kate522 db0d63b99f fix(pay): 会员卡密单密码默认关闭,卡密盒与其他弹窗一致显示卡号+密码
会员购买/续费弹窗卡密盒此前仅显示卡密输入,与其他两个弹窗(卡号+密码)
不一致。根因是 pay_vip_pass_charge_only_password 的默认值偏离参考主题:

- PaymentRuntime:vip 类型 only_password 由「未设置即视为开启」改为
  !empty(),与其他两类型同构(参考主题 default false)
- ChargeCard:get_public_options 的 vip 回退值 only_password true→false
- SchemaPayment + recharge.js:会员卡密单密码模式开关 default true→false
- 契约测试同步锁定默认关闭与无 isset 回退
- admin SPA 重新构建

已保存过设置且该键被持久化为开启的站点,需在后台「余额充值 → 卡密
兑换 → 会员卡密单密码模式」手动关闭一次。
2026-09-18 17:26:50 +08:00
kate522 b3b21df409 fix(vip): 会员弹窗 DOM 结构 1:1 对齐 Zibll payvip-modal
整体结构由自实现(aksrt-panel/aksrt-vip-tab/aksrt-modal-close)迁移到
参考主题 zibpay_get_pay_modal 原生 DOM:

- 根元素 box-body payvip-modal;close 按钮改为 Zibll 原版内联 SVG
  (.close + ic-close 1024 viewBox path)
- 用户信息 list-inline user-box(avatar-img 头像 + b.em12 + badg
  c-yellow vip-expdate-tag 到期标签)
- 会员缩略卡 tab:vip-cardminis + vip-card vip-cardmini level-N
  vip-themeN + abs-center vip-baicon + v-iconbox(v-icon/v-info/
  v-name/v-desc)+ abs-right active-icon 勾选图标
- pane 改为 bootstrap 原生 tab-pane fade active in(移除冗余 hidden
  属性),tab 切换同步 active/in 双类
- 左侧信息盒 pay-vip-info:v-iconbox 图标 + v-name/v-desc + 管理员
  配置的 v-equity 权益列表 + abs-center vip-baicon 背景图标
- 套餐卡 zib-widget vip-product product-box:价格行 mark 小号 px12
  前缀 + original-price 原价 + abs-right vip-tag badg 折扣角标 +
  时长 badge;label-box charge-box 盒由 bootstrap :checked 选中态驱动
- pane 内积分兑换链接与 more 说明移至套餐区(对齐 Zibll 顺序)
- 清理 vip-modal.css 死样式(aksrt-vip-modal-panel、aksrt-vip-tab、
  aksrt-vip-level-header、aksrt-vip-product-label、vip-product/
  vip-tag/product-price 本地重复、uc-vip-shop__tip 等 -148 行),
  样式统一由 main.css 复刻的 Zibll 原版段提供
2026-09-18 13:04:19 +08:00
kate522 721fa29b8d fix(vip): 会员弹窗支付方式渠道对齐 Zibll initiate_pay_input 组件
支付方式 UI 不再自实现(uc-vip-shop__method 文本 radio),改为与其他
两个弹窗(充值/积分)完全同源的 Zibll zibpay_get_initiate_pay_input
组件结构:

- 支付方式行:.payment-method-radio.hollow-radio + logo 图 +
  data-for/data-value 选中模型(多渠道时展示「请选择支付方式」)
- 我的余额盒:data-controller == balance 条件显隐,展示货币符号与余额
  (视图模型透传 wallet_balance / currency_mark)
- 卡密盒:Zibll password_box 同构(mb10 padding-h10 padding-w6 +
  desc muted-box + form-control 卡号/卡密输入)
- hidden payment_method + initiate-pay 按钮类(mt6 but jb-red
  btn-block radius)
- 服务端 payment_method_logo_url() 统一映射 pay-*-logo.svg
- 显隐统一走 data-controller(==/!=)通用匹配,卡密选中隐藏 .charge-box
- 清理 uc-vip-shop__method* 死样式与旧 change 监听;契约测试同步锁定
  (.gitignore 白名单补 card-pass-modal-redeem.test.cjs)
2026-09-18 12:44:38 +08:00
kate522 e72d0710e6 fix(frontend): 购买积分弹窗壳静止态隐藏,修复整页不可点击回归
根因与 2026-09-17 充值弹窗故障同构:新增的购买积分弹窗壳同为服务端
静态输出(.modal.flex.jc.fade),credits.css 仅对充值壳做了静止态
display:none 压制,积分壳被 main.css 的 .flex{display:flex} 拉起永久
铺满全屏,叠加 .fade 的 opacity:0 形成隐形遮罩拦截整页点击。

修复:credits.css 追加 .modal[data-aksrt-points-pay-modal]{display:none}
与 .in{display:flex};打开态由 initPointsPay 的内联 display:flex 驱动,
关闭撤除内联样式后即回到隐藏规则。balance-charge-modal 契约测试同步
纳入积分壳护栏,防同类回归。
2026-09-18 12:31:38 +08:00
kate522 b88eab0bfe feat(pay): 卡密兑换入口收敛到三个购买弹窗并对齐参考主题
- 移除余额卡片独立「卡密兑换」通用弹窗,兑换入口分布到充值/积分/会员三个弹窗的支付方式中
- 充值弹窗补齐卡密 logo 与仅卡密时的金额选择隐藏逻辑
- 积分购买弹窗 1:1 复刻参考主题 points-pay modal(商品档位/自定义/支付方式/立即支付),REST 改为 product+custom 契约
- 会员弹窗注入卡密渠道:视图模型透传 card_pass,前端渲染卡密盒并直兑 /pay/card-pass/redeem type=vip
- 后台卡密兑换拆分为余额/积分/会员三个独立开关,VIP 段旧会员卡密组清理
- REST type 改为必填(balance/vip/credits),三类型独立闸门
- 测试重写为 card-pass-modal-redeem 契约,321 项全部通过
2026-09-18 12:25:18 +08:00
kate522 74e43aaa76 fix(frontend): 充值弹窗选中角标字体族对齐 FA5 并修正充值限额文案语序
1. 档位选中角标(.vip-product.active::before)沿用 Zibll 的 FA4 字体族
   "FontAwesome",本主题打包的是 FA5(family "Font Awesome 5 Free"),
   family 不存在 → PUA 码点 U+F058 渲染为豆腐块(被误认为破图)。
   改为 'Font Awesome 5 Free' + font-weight:900(solid 字形要求),
   渲染为粉色 check-circle,与 Zibll 视觉一致。

2. 自定义充值限额文案 sprintf 参数错位:「最高充值元9999」→
   「最高充值9999元」。mark 为符号(¥)时按 Zibll 前缀格式
   (最低充值¥1,最高充值¥9999)。
2026-09-17 22:44:23 +08:00
kate522 a6b7d09706 fix(frontend): 充值弹窗打开态改为 flex,修正对话框定位
无头 Chrome 对 Zibll 真实 CSS 的几何实证:bootstrap modal('show') 的
内联 display:block 下,.modal.flex .modal-dialog{margin:0} 使对话框
顶在左上角(l=0,t=0);Zibll 生产环境打开态实为 CSS 的 display:flex
(壳的 .flex.jc 双轴居中,jQuery .show() 对 computed≠none 的壳是空
操作,内联 display 从未写入)。AKSRT 上一版把打开态定为 display:block,
导致对话框定位错误。

修复:credits.css 的 .modal[data-aksrt-charge-modal].in 改为
display:flex;credits.js openChargeModal 内联 display 同步改 flex。
静止态 display:none 规则不变。

几何复测(AKSRT 真实 CSS):打开态中心与视口中心、与 Zibll 参考
逐像素一致(dlgCenter=vpCenter=379,213);静止态 display:none。
契约 29/29 通过(新增 flex 断言与 block 禁令)。
2026-09-17 22:25:37 +08:00
kate522 95289829ee fix(frontend): 修复充值弹窗壳全屏隐形遮罩导致整页不可点击
根因:弹窗壳为服务端静态输出(Zibll 由 refresh_modal JS 按需创建,且其
CSS 无 bootstrap .modal 基础规则,故无此问题)。AKSRT 中 bootstrap.min.css
的 .modal{position:fixed;z-index:1050;display:none} 先加载,main.css 的
.flex{display:flex}(弹窗壳对齐 Zibll 所需的工具类)后加载且同特异性
后者胜 → 弹窗壳永久 display:flex 铺满全屏,叠加 .fade 无 .in 时的
opacity:0,形成隐形遮罩拦截整页点击。

修复:credits.css 以 (0,2,0) 特异性压制——
.modal[data-aksrt-charge-modal]{display:none}(静止态隐藏)+
.modal[data-aksrt-charge-modal].in{display:block}(展示态)。
credits.js 开关维持第 20 轮的自包含实现(内联 display:block 驱动),
关闭撤除内联样式后即回到隐藏规则,无 JS 改动。

新增契约 tests/widget-system/balance-charge-modal.test.cjs(3 例):
壳结构与 data 钩子、静止态隐藏规则(防 .flex 覆盖回潮)、开关自包含
(禁止回退 jQuery modal 运行时)。相关契约 29/29 通过。
2026-09-17 22:03:02 +08:00
kate522 daae86e1af fix(frontend): 充值弹窗开关自包含化,修复弹窗无法打开
initBalanceCharge 的开关路径原依赖 window.jQuery.fn.modal(Bootstrap
运行时)驱动 .modal.fade 壳;当 jQuery/Bootstrap 在 auto_fun 时刻不可用
或加载时序异常时弹窗永远无法打开。改为自包含原生实现,复刻 Bootstrap 3
行为与视觉(.in 显隐 + .modal-backdrop + body.modal-open + ESC 关闭 +
点遮罩关闭),复用 bootstrap.min.css 既有规则,去除运行时硬依赖。
2026-09-17 21:37:57 +08:00
kate522 50065ccc3e fix(frontend): 余额充值弹窗 1:1 对齐 Zibll
- credits-wallet.php:弹窗重写为 Zibll refresh_modal 壳(.modal.flex.jc.fade > .modal-dialog.modal-mini.full-sm)+ zibpay_get_balance_charge_modal 同构内容(balance-charge-form / vip-product.product-box / payment-method-radio hollow-radio / but jb-red initiate-pay),档位与支付方式改为 data-for/data-value 选中模型
- credits.js:initBalanceCharge 改由 Bootstrap modal 运行时驱动(modal('show') + data-dismiss + hidden.bs.modal 中止轮询);选中为程序赋值 hidden input,selectProduct/selectMethod 内显式 resetOperationKey 保证幂等键随选中内容刷新;提交契约不变
- credits.css:删除 uc-charge-* 全部死样式;新增 .balance-charge-form 作用域内 .zib-widget 基础卡与 .vip-product/.vip-tag 的 Zibll 原值回位(vip-modal.css 全局同名覆盖需在此回位)

验证:php -l 通过;toast-contract + card-pass-universal-redeem + card-pass-order-numbers 契约 26/26;uc-charge/balance_package/data-aksrt-charge-close/旧弹窗 id 引用清零
2026-09-17 20:38:55 +08:00
kate522 1889c9f048 fix(frontend): 卡密兑换通知接入全局 notyf Toast 固定 id 流
- credits.js initCardPass 从 setTip 内联提示迁移为固定 id 三态 notyf 流
  (aksrt-card-pass-toast:load → success/danger 原位更新),
  成功后延迟 800ms 刷新以保证成功 Toast 可见,对齐其余资产操作。
- credits-wallet.php 删除 data-aksrt-card-tip 死元素;credits.css 删除
  .uc-card-pass__tip 死样式。
- 契约:toast-contract 将卡密兑换纳入固定 id 流清单并禁止 tip 回潮;
  card-pass-universal-redeem 新增 Toast 流断言(27/27 通过)。
- 验证:容器内真实抓取 /user/balance/ 确认弹窗正常渲染且无 tip 残留。
2026-09-17 19:28:47 +08:00
kate522 64ed47165d fix(payment): 卡密兑换开关收敛为唯一总开关
- 问题:积分卡密兑换报「积分卡密兑换未开启」,因 asset_points_card_enabled
  与总开关并存为双重闸门;会员卡同样存在隐藏的第二开关 pay_vip_pass_charge_s。
- 修复:ChargeCard 类型闸门统一只消费后台「余额充值 → 卡密兑换」总开关;
  积分卡仅额外要求积分系统可用(系统级依赖,非独立开关)。
- 死代码清理:PaymentRuntime 删除 benefit.pass_charge/pass_charge_desc 映射;
  后台 schema 删除「卡密兑换会员」独立开关组与「启用积分卡密兑换」开关(双源同步);
  vip options 收敛为仅 only_password 展示配置。
- 验证:php -l 4 文件无错;契约测试 59/59 通过;容器内探针实测
  credits 类型闸门放行;SPA 已重建并同步运行副本。
2026-09-17 19:18:54 +08:00
kate522 3481620ed0 feat(payment): 通用卡密兑换弹窗并修正核销订单实付与订单名称
- /user/balance/ 余额卡片入口改为通用「卡密兑换」弹窗:请求不带类型,
  后端按卡密自动识别余额/会员/积分;卡号输入恒展示且选填
- 后台「余额充值 → 卡密充值」语义更新为「卡密兑换」,开关更名
  「启用卡密兑换功能」(设置键不变),双源 schema 同步并重建 SPA
- 删除死代码:credits-vip.php 死文件、credits-balance.php 内联积分
  兑换块、/pay/card-pass/options 路由与 rest_options、labels 数组、
  asset_points_card_description/password_only 死字段及内联块专属 CSS
- 卡密核销订单实付恒 0:create_redeem_order 源头写 0,UserOrders
  对 card_pass 订单视图层归零覆盖历史行(不迁移数据)
- 订单名称按 note.meta 生成:卡密兑换-余额充值X / 卡密兑换-积分充值X /
  购入[会员档位名]X天(永久)
- 新增契约 card-pass-universal-redeem.test.cjs,收紧 user-assets-layout
  契约(禁 .uc-card-pass 根类回潮)
2026-09-17 19:04:47 +08:00
kate522 ba8d4b1b90 卡密充值与兑换接入统一订单号与支付流水号
此前卡密核销虽已建订单,但订单号硬编码 CARD{卡ID}、渠道交易号硬编码
card_pass_{卡ID},均不受后台前缀控制;且核销产生的资金流水没有挂到
订单上(order_number 为 NULL),订单详情页的「支付流水号」恒为「—」。

ChargeCard:
- 新增 allocate_redeem_order_number():走 OrderRepository 统一分配器,
  消费后台「订单前缀」(payment_order_prefix)。
- 新增 card_pass_transaction_number():BusinessNumber::candidate(flow_prefix),
  消费后台「流水号前缀」(payment_flow_prefix)。
- 两条兑换路径(redeem_asset_card / redeem_vip_card)替换硬编码取号;
  create_redeem_order 接收调用方传入的交易号。
- 账本重放(replayed)时回取已落库订单号,不再把本次新分配的号回给前端。

FinancialFlowService:
- record_card_pass_payment():会员卡(0 元、无账本参与)直接落一条
  与订单关联、零分录的 order_payment 流水;record() 新增
  allow_empty_entries 通道(prepare_entries 拒绝 delta=0,占位分录不可行)。
- record_order_event() 新增凭证结算模式:豁免「金额必须 > 0」与
  「资产类订单不得记现金流水」两条门槛(卡密核销既无第三方现金流入,
  订单还可能为 0 元或以积分计价)。
- attach_order_to_ledger_flow():账本提交后把流水补挂到订单上,
  event_type 归一为 order_payment,使「支付流水号」可反查。
- is_successful_order_payment() 认可 card_pass 凭证结算;
  is_external_recharge() 把 card_pass 与 balance/points 一并列为平台内部
  结算方式,不再为卡密充值伪造现金出账分录(现金在售卡时已确认)。

关键取舍:随机订单号不得进入 LedgerCommand 的 metadata —— payload_hash
覆盖 metadata,会破坏账本幂等重放(实测撞 uniq_operation_key 后报
aksrt_ledger_idempotency_conflict),因此订单关联改在账本提交后补挂。

测试:新增 tests/admin/card-pass-order-numbers.test.cjs 钉住上述约束;
容器内三类卡密实测通过(订单号/渠道交易号/支付流水号三项均带后台前缀,
流水行分别携带 balance:in / points:in / 零分录),重放幂等恢复正常。
2026-09-17 14:15:55 +08:00
kate522 c1c21faf7e fix(payment): 生成数量限定为 1-1000 整数并移除 spinner 死属性
生成数量不再受 step 网格约束,只要求「正整数且不超过 1000」即 (0, 1000]。

- 删除 spinner() 产出的 data-min / data-max / data-step:这三个属性在本
  项目全仓**没有任何消费方**(逐文件检索确认),属死代码;原生 min/max/step
  又会让条件显隐行触发 HTML5 校验并静默阻止提交(上一轮已定案)。参数随之
  收敛:spinner(string $name, int $value, string $unit),3 处调用点同步。
- 保留 step="any":省掉它 type="number" 会退回默认 step=1 的整数网格,把
  非整数输入判为非法,同样触发隐藏行的静默拒绝。
- 生成数量的范围收敛为 handle_add() 里的单一区间判定
  `$auto_num < 1 || $auto_num > 1000`,一次覆盖 非数字 / 0 / 负数 / 超上限,
  不再依赖下层静默夹取,也不另设分支;字段说明补上「1 - 1000 的整数」。

验证:
- 容器内真实 HTTP 边界:0 / -5 / 1001 / abc 全部拒绝,1000 / 1 / 3.9 通过
  (3.9 按整数截断)。其中 1000 这个上边界用「type=vip + 空时长」探到 ——
  数量校验通过后即报「请填写兑换会员参数」,零副作用,未真的生成 1000 张。
  正向生成 3 张后已清理,表回到原始 10 条。
- 渲染出的表单内原生约束属性 0 个、data-* 0 个;4 个 number 输入全部 step="any"。
- 契约测试:把「spinner 输出 data-*」的断言反转为「不得出现」,新增签名与
  调用点实参断言、(0,1000] 区间断言、字段说明断言;整文件属性扫描前先剥
  PHP 注释,否则文档注释里的 type="number" 字面量会污染计数。
- 负向对照:重新加回 data-min / 退回 !$auto_num / 签名加回 step 参数,
  三例均精确落到对应用例(各 5 pass / 1 fail),还原后字节一致、6/6 通过。
- tests/admin 四套件 24 pass / 0 fail;php -l 通过;两副本 SHA256 一致。
2026-09-17 13:38:40 +08:00
kate522 481f40378a fix(payment): 修复添加卡密页「确认提交」按钮静默失效
数字输入(auto_num / auto_price / auto_rand_number_limit /
auto_rand_password_limit)此前被平铺成原生 HTML5 约束属性
(min/max/step),叠加两个问题后表现为按钮点了毫无反应:

1) 数值彼此不自洽:参考主题 CSF spinner 的 default=20 / min=1 /
   step=10 合法格点是 1,11,21…,默认值 20 当场就非法;
   auto_rand_number_limit 同理(默认 20、格点 1,6,11…)。
2) 这些行是条件显隐的(field_row 输出 <tr data-dep>,不满足依赖时
   display:none)。提交时浏览器仍会校验隐藏控件,而隐藏控件无法聚焦
   去弹提示气泡,于是 Chrome 只往控制台丢一句
   "An invalid form control ... is not focusable" 便静默拒绝提交——
   不跳转、不报错、无 notice,用户看到的就是按钮坏了。

处置:数字输入改用 data-min/data-max/data-step + step="any"
(与参考主题 CSF_Field_spinner::render() 语义一致),不再写任何原生
约束属性;auto_price 一并去掉 min="0",面额下限改由服务端夹取兜底
(max(0.0, ...)),避免「去掉客户端下限」变成放行负面额。

验证:
- 无头 Chrome 探针:btn.click() 触发的 submit 事件 0 → 1,
  参与校验控件 23 个、无效 3 个 → 0 个;
- 服务端扫描页面 25 个 input:带原生约束的 0 个;
- 服务端面额夹取:-5 拒绝、0.001 拒绝、12.5 正常生成 1 张;
- 新增 tests/admin/charge-cards-form-constraints.test.cjs(4 例),
  并做负向对照(重引入 min/step 后 2 pass / 2 fail);
- tests/admin 四个套件合计 22 pass / 0 fail。
2026-09-17 13:19:05 +08:00
kate522 52d790c654 fix(payment): 卡密管理每页默认 20 条,撤掉工具栏「每页显示」控件
- DEFAULT_PER_PAGE 由 30 改为 20。
- 删除 per_page_selector() 及其表单样式(.aksrt-perpage-form / .order-header-right),
  工具栏恢复为「过滤链接 + 搜索表单」两段式。
  ice_perpage 仍作为 URL 参数被尊重,只是不再提供界面入口。
- 保留上一提交的分页健壮性修复:页码夹取到 [1, total_pages]、翻页链接用显式基准 URL、
  越界页码回退并重查、禁用按钮 pointer-events:none。
- 契约测试同步改写:断言默认值为 20,并显式禁止「每页显示」控件回归。

验证:默认态渲染为「每页 20 共10」,页面内不存在 aksrt-perpage-form;
ice_perpage=5 仍能翻到第 2 页且链接携带该参数;paged=2 越界仍回退到第 1 页的 10 行;
node --test 三套 18/18;php -l 通过;无头 Chrome 截图确认工具栏无多余控件。
2026-09-17 13:03:05 +08:00
kate522 0e7b888277 fix(payment): 修复卡密管理分页:页码夹取、显式基准 URL、补齐每页条数入口
- render_pagenavi():页码先夹到 [1, total_pages],禁用状态由 $on_first / $on_last
  一次导出。旧写法是一串 if/elseif,页码越界时三个分支一个都不命中,
  上一页与下一页同时停在 '#':既不打 disabled 也点不动。
- 翻页链接改用显式基准 URL(page_url + 当前筛选参数),不再依赖
  add_query_arg() 默认取 $_SERVER['REQUEST_URI'] 的行为;
  status / type / other / s / orderby / desc / ice_perpage 随链接带走。
- render_list():页码越界(手改 URL、或筛选后总数变小)时回退到最后一页重查,
  消除「空白表格 + 一个分页控件都没有」的死胡同。
- 新增「每页显示」选择器(ice_perpage)。该参数此前只有服务端读取、界面上没有
  任何入口能改,条数小于默认每页条数时永远只有一页,分页条恒为 disabled。
- 禁用按钮加 pointer-events:none,避免点空还滚回页首。
- 新增 tests/admin/charge-cards-pagination.test.cjs(6 条契约)。

验证:容器内真实渲染 7 组 URL(默认 / 每页 5 / 第 2 页 / paged=2 越界 /
paged=99 越界 / 带 status 筛选 / 非预设每页 7),无头 Chrome 截图核对版式;
node --test 三套 18/18 通过;负向对照(还原旧写法)按要求失败 2 条;php -l 通过。
2026-09-17 12:45:22 +08:00
kate522 1e5286d36b fix(payment): 导入提示的条目列表改用 <ul> 承载,圆点不再越出文字边界
四条导入提示里的 <li> 原先裸挂在 <div class="aksrt-submsg"> 下,没有列表容器:
list-style-position 默认 outside,圆点画在内容盒左侧的 padding 区里,而 <li>
自身没有 padding,于是圆点溢出到提示块的文字边界之外(真实后台 CSS 下实测 −8px,
视觉上紧贴提示块左侧色条)。

改动:
- 四条提示的 <li> 各用一个 <ul class="aksrt-submsg-list"> 包起来(同时修掉
  <li> 缺少列表父元素的非法标记)。
- .aksrt-submsg-list 显式声明 margin / padding-left / list-style,不依赖 UA
  或后台对 ul 的默认值,避免别处的重置再次把圆点挤出去。
- 新增契约用例:提示内的 <li> 必须在 <ul class="aksrt-submsg-list"> 中、标签成对、
  每条提示只有一个列表容器,且该类的 CSS 必须带 padding-left 与 list-style。

实测(真实 Chrome + 真实后台 common/forms/list-tables CSS):圆点左缘由
「文字边界 −8px(溢出)」变为「文字边界 +12px(界内)」,条目文字缩进 20px。
2026-09-17 12:28:09 +08:00
kate522 d787f3ff97 fix(payment): 导入提示不再使用 WP notice 类,避免被 common.js 搬到页面顶部
真因:wp-admin/js/common.js 在 DOM 就绪时执行
  $('div.updated, div.error, div.notice').not('.inline, .below-h2').insertAfter($headerEnd)
它会把页面上所有「非 inline」的 notice 搬到 .wp-header-end(不存在时取 .wrap 内首个
h1/h2)之后。提示块原先输出 class="aksrt-submsg notice notice-warning",被判定为
notice,于是在求值之后被整块搬到页头:既脱离「添加方式—卡密数据」之间的字段区,
又因为搬迁发生在 evalDeps 之后,行内显隐逻辑无从纠正。

这也是同一现象反复出现的真因:四条提示都可见时会被整体搬到页面最上方;给提示块
补上初始隐藏后看起来像「提示不见了」;改为全文档求值后则表现为「提示显示在页面
最顶部」。此前的 foster-parenting 判断有误——服务端输出的表格结构始终合法。

改动:
- submessage_row() 只输出自有类名 aksrt-submsg,不再复用 .notice / .notice-warning。
- 外观(底色 #fef8ee + 左侧 4px #f0b849 色条)改由 .aksrt-submsg 自带,与参考主题
  CSF content 字段的做法一致;元素内联样式只保留初始隐藏状态。
- 新增契约用例:提示块不得带 notice;.aksrt-submsg 必须自带 background 与 border-left;
  并确认页面级错误通知仍正常使用 notice(不误伤其它通知输出)。
2026-09-17 12:19:25 +08:00
kate522 45c7a4a59c fix(payment): 添加卡密页导入提示移至卡密数据上方并修正脱离表单后不显示
- 四条导入提示与「单密码模式」开关收拢到「卡密数据」行正上方:切换卡密类型/
  添加方式时,命中的那一条紧邻示例数据与输入框,便于对照填写。原先单密码模式
  开关夹在两条提示之间、提示又落在自动生成字段之后的位置不再使用。
- evalDeps() 与 change 监听不再限定在 .aksrt-cardpass-form 子树内,改为全文档
  查找 [data-dep]。提示块一旦被移出表单子树,限域查找会漏过它,其内联
  display:none 无人清除,表现为「切换到导入卡密后提示不显示」。
- 契约测试同步收紧:断言必须为全文档选择器,并显式禁止回退为限域写法。
2026-09-17 12:03:13 +08:00
kate522 670e271ff6 fix(payment): 添加卡密页提示块自带依赖状态,脱离所在行也不再铺开
用户复报「四条导入提示仍然全部铺开」。其 DevTools 实测给出关键判据:
同一页面上 formClass=1 / submsg=4 / depRows=17 与线上一致,但四条提示的
父级 <tr> 为 null、computed display 为 block —— 也就是说提示块虽在 DOM 中,
却不在任何表格行内。此时依赖状态只挂在 <tr> 上,隐藏与切换都无处落地,
于是四条提示永久可见、切换卡密类型/添加方式也没有反应。

修复:把 data-dep 与初始隐藏状态同时挂到内层提示块上,求值器改为
「谁带 data-dep 就管谁」,行与提示块各自落自己的 display。

变更(src/Admin/Native/PayChargeCards.php):
- submessage_row():<tr> 与内层 <div class="aksrt-submsg"> 共用同一份
  data-dep;初始隐藏状态合并进提示块原有的 margin/padding 内联样式,
  不产生重复的 style 属性。原「只给 <tr> 加 style」的写法保留给行本身。
- evalDeps():选择器由 '.aksrt-cardpass-form tr[data-dep]' 放宽为
  '.aksrt-cardpass-form [data-dep]',并改用 el.style.display 落值。

测试(tests/admin/charge-cards-dependency.test.cjs):
- DOM 桩按真实结构为每条提示建「行 + 提示块」两个元素,新增用例
  「提示块与所在行同层落 display」断言两层始终同状态(默认 8 个元素全隐藏、
  命中依赖时仅该提示的两层可见、切回默认两层同时收起)。
- 契约用例新增三条静态断言:提示块必须自带 data-dep、必须自带初始隐藏状态、
  求值选择器必须同时覆盖行与提示块。
- 负向验证:把提示块的 data-dep 去掉后跑测试 → pass 5 / fail 1,护栏生效。

验证:
- node --test 两个 admin 契约测试 10/10 通过;php -l 无语法错误;
- 线上抓取:tr_total=19 / tr_with_dep=17 / div_with_dep=4 / display_none=18;
  四条提示的 div 均带 data-dep="…" 与 display:none。
- 真实浏览器模拟「提示块被搬出所在行」(foster-parent 到 <table> 之前)后:
  默认态四条全部 none;切到「会员兑换 + 导入卡密」时,只有已脱离行的
  「导入会员兑换卡密」变为可见(ownStyle 中的 display:none 被移除),
  其余三条维持 none;切回默认四条全部收 none。
2026-09-17 11:41:05 +08:00
kate522 5243bbf0d4 fix(payment): 添加卡密页清理依赖求值死代码并锁定显隐契约
render_add_form() 渲染 19 行表单,其中 17 行带 data-dep;4 条导入提示
(导入卡密 / 导入会员兑换卡密 / 自定义卡密导入 / 自定义卡密导入-单密码模式)
的显隐依赖已由服务端预隐藏与服务端默认值修正落地,本次仅做收尾。

变更:
- 删除行内脚本 IIFE 末尾的 evalDeps() 死调用。该调用位于表单之前的
  解析期(脚本在表单前输出),此刻 document 中尚无任何 tr[data-dep],
  属于必然空操作;初始求值统一交由 DOMContentLoaded 分支处理。
- 补充注释说明「脚本在表单前输出 → 解析期取不到行 → 必须等 DOM 就绪」,
  避免后人误加回解析期调用。
- .gitignore 追加 /tests/admin/charge-cards-dependency.test.cjs 白名单。

新增契约测试 tests/admin/charge-cards-dependency.test.cjs(5 用例,全过):
从源码抽取 field_row/submessage_row 的 data-dep,用 Node vm 加载真实行内
脚本并以最小 DOM 桩驱动,断言:
1) 依赖键须在 is_known_dep_key 与 default_form_value 成对登记;
2) 服务端预判与前端求值使用同一组操作符且覆盖表单实际用到的全部操作符;
3) 两类行渲染均接入服务端预隐藏,避免首帧铺开;
4) 行内脚本不再保留解析期空操作求值;
5) 导入提示跟随「卡密类型 × 添加方式」切换(balance/credits/vip/custom
   及单密码模式矩阵),且不同时铺开、切回 auto 全部收起。

负向验证:将 balance,credits 篡改为 balance,credits,vip 后测试转为
pass 4 / fail 1,护栏确实生效。

验证:node --test 两个 admin 契约测试 9/9 通过;php -l 无语法错误;
dev 与运行副本内容一致(行尾归一后逐字节相同)。
2026-09-17 11:03:34 +08:00
kate522 1cbc0c6ed8 fix(payment): 排序箭头改为基线锚定,修正表头箭头相对文字偏下
.orderby-but 从「无尺寸 inline 宿主 + 依赖绝对定位静态位置 + 固定
translateY(-3px)/6px」改为「零尺寸 inline-block 宿主 + bottom 锚定 + translateY(50%)」。

原理:空 inline-block 的基线即其下边缘,会与同行文字基线对齐,于是箭头盒可用 bottom
精确锚定到「基线以上 N em」;translateY(50%) 抵消箭头自身高度,使盒中心恒等于 bottom 值,
与 dashicons 盒高无关。两枚箭头中心分别落在基线以上 .725em / .075em,对称于文字视觉中心。

旧写法的问题:绝对定位元素未指定 top/bottom 时,其位置来自静态位置(容器内容盒顶部),
该值与表头字体/行高联动;在真实后台 CJK 字体度量下整体偏下。

量测(无头 Chrome + 真实 wp-admin common.css/list-tables.css + 真实表头标记):
- 旧写法:箭头簇中心相对文字中心 +2.0px(复现用户反馈的「偏下一点」)
- 新写法:-0.09px;两箭头相对文字中心 -4.64 / +4.46,居中对称
- 表头行高两版均为 36.59px,无行高副作用

契约测试同步收紧:断言 bottom 锚定写法,并禁止回归到无 bottom 的固定像素位移。
2026-09-17 10:28:17 +08:00
kate522 537fb97f48 fix(settings): 充值套餐选项为数组时不再触发数组转字符串警告
get_payment_recharge_settings() 原先先对 pay_balance_product 做强转、再判
is_array:当该选项以数组保存时会触发 PHP "Array to string conversion" 警告,
且强转结果随即被覆盖,属无效语句。改为按类型二选一转换,输出行为不变。
2026-09-17 10:14:21 +08:00
kate522 9fffe7b590 fix(payment): 卡密管理页标题按钮单点输出并修正排序箭头下沉
- 标题右侧按钮原在页面头部与列表渲染函数各输出一次(实际渲染 3 个
  page-title-action,「添加卡密」重复出现),改为只在页面头部的 tab switch 中
  拼一次;render_list() 收为 render_list(string $s),只渲染过滤链接与表格,
  搜索词在页面头部单次解析后传入,搜索提示仍按参考主题跟在按钮之后。
- .orderby-but 恢复参考主题写法(position: relative,不设 display)。
  箭头是绝对定位元素且不写 top/left,位置取自容器的静态位置;容器一旦变成零
  高度 inline-block,基线会退化为自身下边缘并与文字基线对齐,整组箭头下沉一
  个字高。同时补回参考主题的 padding-right: 1em,并加注释锁定该约束。
- 清理死代码:行循环中 custom 分支的 $type_html 赋值总被下方类型链覆盖,已删除。
- 新增 tests/admin/charge-cards-page-contract.test.cjs(4 例)作为回归护栏,
  并按 .gitignore 既有白名单惯例放开该文件。
2026-09-17 10:14:19 +08:00
kate522 282a12a90b fix(payment): 依赖求值改按 name 属性匹配,避免选择器异常静默失效
- formVal 不再把字段名拼进 CSS 选择器:vip_exchange[time] 等含方括号的名称会令选择器抛异常并被 try/catch 吞掉,导致依赖行永久停在初始状态
- 改为 fieldsByName 按 getAttribute('name') 逐元素匹配,radio 选中态也在同一集合内判断
- evalDeps 作用域收窄为 .aksrt-cardpass-form tr[data-dep],避免误命中页面上其他表格的依赖行
2026-09-16 23:03:56 +08:00
kate522 cb84c88792 fix(payment): 修正导入提示的服务端依赖判定键缺失
- 补全 is_known_dep_key 白名单:加入 only_pass_s 与 vip_exchange[time],其默认值统一按空字符串处理
- 修复服务端 !=/checked 类依赖因默认值缺失而错判的问题:自定义卡密的两条导入提示此前会被同时判为可见,导致四条提示全部铺开
- 服务端预判与行内 JS 求值器语义现已逐条对齐,任意状态下最多显示 1 条导入提示
2026-09-16 22:46:26 +08:00
kate522 ef9409cee6 fix(payment): 添加卡密页依赖字段初始状态按默认值隐藏
- 渲染期按表单默认值预判依赖可见性(deps_visible/default_form_value),不满足项直接输出 display:none,消除导入提醒首帧全部显示的问题
- 行内 JS 在 DOMContentLoaded 兜底执行一次依赖求值,交互前后状态与服务端一致
2026-09-16 22:40:35 +08:00
kate522 678143e7b9 feat(payment): 卡密管理改为独立页面并对齐参考主题
- 移除「余额充值」设置中的充值卡(card_pass_*)配置组,PHP schema 与 SPA mirror 双轨同步
- 重写后台卡密管理页:列表过滤/排序/搜索/批量删除/同款分页/点击复制、自动生成与导入、text/xls 导出
- ChargeCard 服务新增 generate_cards/rand_number/rand_password/delete_by_ids/get_export_rows,get_list 支持参考主题排序别名与标识过滤
- 删除无消费方的 /payment/charge-cards REST 路由及 batch_create/disable 死代码,数据层零迁移
2026-09-16 22:05:37 +08:00
kate522 ba1c8c2822 style(user-center): 卡密兑换弹窗按钮右对齐 2026-09-16 21:20:23 +08:00
kate522 2ef02e2865 feat(user-center): 余额卡密兑换移入弹窗并在余额卡片增加入口
- 余额卡片右下角新增「卡密兑换」点击区(data-aksrt-card-pass-open)
- 内联 uc-card-pass 区块替换为 uc-modal 弹窗,兑换逻辑不变
- initCardPass 增加弹窗开关绑定(防重复绑定)
- 清理 :first-child > .uc-card-pass 死选择器片段
2026-09-16 21:15:04 +08:00
kate522 ff11f3b156 fix(payment): 结算货币旧小写值导致枚举校验失败无法保存
paypal_currency/stripe_currency 由自由文本改为 select 后,旧存储值(如
stripe_currency=usd 小写)不通过枚举校验,收款接口页保存被 422 拦截。
为两个货币字段增加 case_insensitive 归一化:读取时归一为选项规范写法、
写入时归一化、校验时大小写不敏感放行,匹配不到回退默认 USD。
2026-09-16 19:56:51 +08:00
kate522 14f9534651 fix(payment): 充值商品选项旧字符串值导致假条目且不可删除
- SchemaProvider 读取时 group/repeater 字段忽略非数组旧值(textarea 时代的
  字符串),回退默认数组,避免前端按字符渲染出 20+ 条假条目
- AksField group/repeater 渲染改为数组安全视图(非数组一律按空列表)
- 充值商品选项默认条数调整为 3 条(PHP 与前端 schema 同步)
2026-09-16 19:46:49 +08:00
kate522 2c139e5c20 fix(payment): 修复推广返佣与收款接口 schema 顶层常量 TDZ 报错
affiliate.js 的 rebateTypeOptions 与 gateway.js 的 currencyOptions 定义在
文件底部,而 schema 对象在模块顶层即引用,ES 模块顶层代码立即执行导致
const 声明前访问触发 ReferenceError(压缩后表现为 Cannot access 'He')。
将常量上移至 schema 对象之前。
2026-09-16 19:36:27 +08:00
kate522 a48007a44d feat(payment): 支付&付费菜单按参考主题重构并新增提现策略页
- 子菜单重组为知识付费/余额充值/VIP会员/创作分成/推广返佣/收款接口/提现策略
- 收款接口页 1:1 复刻 Zibll 双下拉结构,仅保留支付宝/微信/PayPal/Stripe/易支付
- 订单前缀/流水号前缀/提现单号前缀配置保留并移入收款接口页
- 知识付费/推广返佣按参考主题全字段复刻,余额充值并入充值卡配置
- 新增 tabbed/between_number/page_select 表单控件及后端 pages 搜索源
- 清理旧 SPA 组件、无消费者 REST 端点与废弃配置键(不写双路兼容)
- 更新存量契约测试至新 schema 文件
2026-09-16 19:18:28 +08:00
kate522 fb5cdf247d feat(user-center): 官方认证面板 1:1 复刻参考主题视觉
- 面板主体改为 Zibll 同款 colorful-bg hero 卡片(大图标/标题/状态区)
- 保留云认证 modal 流程与 data-* 功能钩子,13 个钩子一一对应
- 清理旧 uc-auth-card 面板样式与无引用的 uc-auth-upload/apply-detail 死样式
- 新增 #panel-auth 去壳样式,去除外层白卡使卡片成为唯一内容
2026-09-16 18:06:48 +08:00
kate522 098df7f921 fix(user-center): 去除余额金额显示前¥符号 2026-09-16 17:39:40 +08:00
kate522 175d32f01e fix(tests): 修复存量合约测试失败 2026-09-16 17:24:22 +08:00
kate522 56d1b2a417 fix(user-center): 防止大额资产金额断行 2026-09-16 17:23:56 +08:00
kate522 e7ffb44b45 fix(admin): 兼容提现金额历史配置 2026-09-16 14:12:40 +08:00
kate522 bcba58f227 fix(user-center): 对齐宽屏提现记录列 2026-09-16 13:31:36 +08:00
kate522 a26ef3fadd fix(user-center): 对齐窄屏提现状态标签 2026-09-16 13:20:53 +08:00
kate522 0871c8ea81 fix(user-center): 优化提现记录窄屏布局 2026-09-16 13:16:16 +08:00
kate522 3cedcd1bc2 fix(user-center): 单独弱化实际到账金额 2026-09-16 13:10:13 +08:00
kate522 d4e33706cf style(user-center): 弱化提现记录文字样式 2026-09-16 13:05:56 +08:00
kate522 2c4bd3eb2a fix(user-center): 调整提现记录金额展示 2026-09-16 13:00:27 +08:00
kate522 6bbb79213b fix(user-center): 优化提现记录弹窗分页 2026-09-16 12:53:38 +08:00
kate522 27f54c8e35 修复资产卡片底部对齐 2026-09-15 22:24:49 +08:00
kate522 7efbbeed3f 修复余额页顶部资产卡片高度 2026-09-15 20:50:21 +08:00
kate522 f00045502f 收紧顶部卡片与下方主卡片的间距 2026-09-14 12:57:57 +08:00
kate522 0a4448dd70 收紧顶部卡片与下方主卡片的间距 2026-09-14 12:53:20 +08:00
kate522 9e9918b7f3 修正“成长体系”卡片顶部字段错位问题 2026-09-14 12:48:17 +08:00
kate522 486fc34a4b 修正“成长体系”卡片顶部字段错位问题 2026-09-14 12:45:01 +08:00
kate522 1873018a2a Merge pull request 'fix(user): replicate level center page' (#33) from worktree/user-level into main
Reviewed-on: #33
2026-09-14 04:29:49 +00:00
kate522 632a7d15ba fix(user): replicate level center page 2026-09-13 21:28:10 +08:00
kate522 6f1860f5d0 Merge pull request 'fix(email): lock templates to built-in catalog' (#32) from worktree/email-config-hard-cut into main
Reviewed-on: #32
2026-09-13 12:04:57 +00:00
kate522 129eb25078 fix(email): lock templates to built-in catalog 2026-09-13 20:02:56 +08:00
kate522 3f02048bc5 test(admin): read the picker css before asserting the hidden guard 2026-09-06 16:38:42 +08:00
kate522 abf3a4ae31 fix(admin): re-assert [hidden] inside the icon modal so tabs and search switch 2026-09-06 16:37:11 +08:00
kate522 5c226237c0 fix(admin): wrap the icon picker tabs and shorten catalog group titles 2026-09-06 16:28:07 +08:00
kate522 af04fcfd3c fix(admin): bind FaIconPicker with v-model so icon clicks update the value 2026-09-06 16:10:28 +08:00
kate522 5acd1e0e25 chore(build): restore the tracked build/.gitkeep placeholder 2026-09-06 15:55:31 +08:00
kate522 26afa6ea3a revert(build): stop tracking the admin SPA build output
Tracking assets/admin/build was wrong — it is deploy-only output.
Revert to the ignored state; deploy by building on the target:
cd assets/admin && npm ci && npm run build
2026-09-06 15:53:50 +08:00
kate522 39bafb65c4 fix(icons): render FA glyphs in the SPA picker grid and harden item clicks
The server catalog ships bare glyph names, but the SPA grid preview
composed "fas home" instead of "fas fa-home" — every FA icon rendered
blank (the old curated lists carried the fa- prefix, so the regression
appeared with the catalog switch).

Item clicks move from per-element @click to a delegated panel handler
reading data-icon (immunity to DOM-replacing extensions/userscripts,
matching the PHP picker's pattern), and selecting now closes the
popover so the choice is visible in the field immediately.
2026-09-06 15:48:52 +08:00
kate522 27d8a9ea71 build(admin): track the SPA build output so git deploys carry it
assets/admin/build was gitignored, so servers deployed by git pull kept
running a stale admin bundle — the icon picker integration in the tabbar
and floating-button settings pages never reached them. Track the build
directory (rebuild with: cd assets/admin && npm run build) and commit the
current bundle containing the universal icon picker.
2026-09-06 14:35:15 +08:00
kate522 2e25894eb5 fix(navigation): stop mangling zibsvg/customsvg menu icons
aksrt_render_menu_icon() prefixed any value without an fa substring
with fa fas fa-, turning picker values like zibsvg-hot into
fa fas fa-zibsvg-hot — blank icons in the frontend nav. Sprite and
inline SVG values now flow through aksrt_icon_markup with the
menu-item-icon classes attached; FA values keep the legacy padding.
2026-09-06 14:27:49 +08:00
kate522 70cba11fb7 feat(icons): wire the universal picker into tabbar/floating settings and keep customsvg through their save paths
手机底部Tab and 右侧悬浮按钮 settings pages swap their icon text inputs
for the shared FaIconPicker (with a new IconValue preview component that
renders FA/sprite/custom SVG values in lists and previews). footer.php
tabbar/floating/sub-item icons and the VIP float render through
aksrt_icon_markup so every stored format displays; GlobalControllerPart1
and the VIP helper drop the FA-only regex gate and sanitize icon values
through aksrt_sanitize_icon_value. The picker popover tabs now wrap
instead of overflowing, and FA grid previews use style-only classes.
2026-09-06 14:27:49 +08:00
kate522 d89752d543 Merge pull request 'fix(icons): render brand icons under the fa fab double prefix and drop an empty sprite key' (#31) from worktree/icon-picker into main
Reviewed-on: #31
2026-09-06 05:35:05 +00:00
kate522 7ab5f29f87 fix(icons): render brand icons under the fa fab double prefix and drop an empty sprite key
FA5's own single-class declarations lose the cascade to the later
.fa,.far,.fas family rule, so zibll-legacy values like "fa fab fa-weixin"
rendered with the Free font and came up blank — visible in the picker's
brand tab (500px…zhihu) and on every brand icon in the SPA and frontend
social links. Re-assert .fa.fab (and .fa.far weight) in the three
stylesheets that load FA5. Also remove the empty aksrt_svgs['qqww']
sprite entry, which surfaced as a blank built-in SVG item.
2026-09-06 13:31:24 +08:00
kate522 5e2cc7b56a Merge pull request 'Worktree/icon picker' (#30) from worktree/icon-picker into main
Reviewed-on: #30
2026-09-06 05:09:54 +00:00
kate522 2e9650d15f test(widgets): contract-lock the universal icon picker
Assert the generated FA5 catalog, the REST route + sprite single
source, picker field wiring in widgets and nav menus, SVG-aware icon
sanitization on both save paths, the single aksrt-font-awesome handle
with FA5 class names, and the consolidated assets/icons layout.
2026-09-06 13:00:13 +08:00
kate522 7cd39e278a chore(assets): remove dead icon assets and consolidate icons/
Delete zero-referenced leftovers (31 img SVGs, duplicate top-level
vip badges, favicon/icon/person_background pngs, assets/fonts/,
tmp-debug/, gutenberg-extend.js and the FA eot/ttf/svg font formats)
and give icon-purpose SVGs one home in assets/icons/ (upload-add,
default favicon/logo, playing, user-certification) with every reference
updated. Icon SVG files were audited by name across PHP/CSS/JS before
deletion.
2026-09-06 13:00:13 +08:00
kate522 e4745d501e refactor(icons): converge every icon render on aksrt_icon_markup
aksrt_get_comment_icon(), aksrt_renderer_svg_icon() and the widget
renderer's private icon_markup() all delegate to the unified helper,
and the duplicated 24px empty-state SVGs in user-center/user-home now
come from one aksrt_empty_state_svgs(). Contract tests follow the
delegation (sprite <use> markup asserted in the helper).
2026-09-06 12:59:44 +08:00
kate522 d599bbccd9 fix(icons): swap FA4-only class names for FA5 equivalents
fa-clock-o/fa-folder-open-o did not exist in the bundled FA5 fonts and
rendered nothing; fa-file-text-o maps to fa-file-alt in FA5 Free (there
is no fa-file-text glyph either).
2026-09-06 12:59:31 +08:00
kate522 5b5db0f8f5 feat(admin): back the SPA icon picker with the server catalog
FaIconPicker drops its duplicated hard-coded FA subset and loads
GET /aksrt/v1/icons/catalog: full solid/regular/brands sets, the
built-in zibsvg sprite tab and an inline custom SVG tab. Props/emit
surface is unchanged; the system-notice title icon schema drift
(input vs icon) is fixed on the JS side.
2026-09-06 12:59:30 +08:00
kate522 009f9874c0 feat(widgets): render icon fields with the universal icon picker
The widget FormRenderer icon case and the nav-menu item icon field now
emit the shared picker (preview + choose/clear + editable text input,
delegated events so lazy forms and repeater rows work). Icon values are
sanitized through aksrt_sanitize_icon_value on both save paths so inline
customsvg- markup survives; the widget admin screen enqueues the picker.
2026-09-06 12:59:12 +08:00
kate522 f629a4f373 feat(icons): add the FA5 catalog, REST endpoint and unified icon helpers
Mirror the reference theme's universal picker on FA5: a generated full
catalog of the bundled FA5 Free 5.15.4 fonts (src/Icons/fa5-icons.php,
generated from the shipped CSS + SVG fonts), a zibsvg group parsed from
the runtime sprite so it cannot drift, and GET /aksrt/v1/icons/catalog.

Shared admin pieces: aksrt_icon_markup()/aksrt_sanitize_icon_value()
with an SVG allowlist, the picker field/modal assets, and one canonical
aksrt-font-awesome handle registered through aksrt_enqueue_fontawesome().
2026-09-06 12:59:03 +08:00
kate522 9496db5171 fix(single): place the video footer breadcrumb like the reference
Drop the invented .post-breadcrumbs wrapper and echo the breadcrumb
inside the reference's flex jsb at row; split .single-video .breadcrumb
out of the cover-overlay rule (-6px pull and opacity reset belong to
title-over-cover contexts only) and restore the legacy 15px footer
padding. Pin the structure in the cover contract test and rebuild the
homepage aggregates.
2026-09-06 11:26:21 +08:00
kate522 d3eef19b95 fix(single): match the reference video cover height (bare ratio)
Zibll reserves +50px only for covers whose title overlays them
(image/slide via .single-cover/.single-cover-slide); the video cover
keeps its title in the footer below and uses the bare scale height
(.dplayer-scale-height). Drop the +50px from the video box, pin the
reference height matrix in the contract test, and refresh the stale
slide-action assertion in the toast contract.
2026-09-06 11:12:13 +08:00
kate522 7c0d5945ca fix(single): stop the video cover letterboxing inside its ratio box
The player init wrote an inline object-fit:contain on the video
element, which outranked the injected stylesheet's object-fit:cover
fill contract and left black bars above/below whenever the cover box
ratio differed from the video. Drop the inline style so the
stylesheet rule is the single source of truth, and pin the
no-inline-style rule in the cover contract test.
2026-09-06 11:00:04 +08:00
kate522 6ffc5ba3d6 fix(submit): drop the redundant featured preview type badge
The reference theme renders no type badge in the featured box; the
swiper and dplayer previews are self-explanatory. Remove it from both
preview branches and delete the now-unused CSS rule.
2026-09-06 10:47:24 +08:00
kate522 b7f5523874 fix(submit): render slide cover preview as a real swiper
Replace the static first-image preview with the reference theme's
.new-swiper contract (loop + autoplay + prev/next + pagination) in both
the server-rendered box and the JS re-render, and run auto_fun for
slide covers so new_swiper initializes them.
2026-09-06 10:39:39 +08:00
kate522 d0c6c3e431 feat(submit): fold slide cover into multi-select image picker
Match the featured cover buttons row to the reference theme: drop the
dedicated slide button, let the icon-only image picker select multiple
images into a slide cover (single pick stays an image cover), and align
the loaded-state visibility matrix.
2026-09-06 10:30:27 +08:00
kate522 1a0b023658 fix(submit): raise TinyMCE fullscreen above the fixed navbar
TinyMCE's skin injects .mce-fullscreen { z-index: 100 } at editor init,
after core editor.css, so the fullscreen editor (toolbar at the viewport
top) lost to the fixed navbar (.nav-fixed .header, z-index 999). Restore
core's intended 100010 with an element+class selector that outranks the
skin regardless of injection order; it stays below the submit media
dialog (100080). Also remove the dead .mce-top-part.affix rules (no
AKSRT script drives them) and pin both in a contract test.
2026-09-06 10:10:20 +08:00
kate522 daee4cd67f feat(submit): make video cover row icon-only with hover titles
Replace the video-pic/video/clear button labels with icon-only buttons
carrying title + aria-label (设置视频封面/视频/清除), center the glyphs
via flex to fix the upward icon shift, rename the media picker title to
为视频设置封面, and drop the unused setVideoCover i18n key. Contract
test updated to pin the new markup and string set.
2026-09-06 10:10:12 +08:00
kate522 51a591e2b9 feat(submit): add video cover picker with playable video preview
Featured video preview now renders a real DPlayer (video-url/video-pic)
like the reference theme instead of a static image, so the cover video
plays on the submit page. Top-right bar gains the video-cover button
shown only when a video cover is loaded; backend honors pic_id and no
longer deletes the thumbnail before cover validation. Drop dead i18n
keys and data-can-* attributes.
2026-09-05 23:41:22 +08:00
kate522 d84d485ec7 refactor(submit): remove unused aksrt_update_post ajax endpoint
No caller anywhere: front JS updates via aksrt_submit_post (posts_id),
guests via aksrt_save_draft. Re-pin the one-captcha-admission contract
to ajax_submit_post, the only non-draft entry point.
2026-09-05 23:41:12 +08:00
kate522 5fbcfa2b49 fix data-aksrt-video 属性 2026-09-05 22:28:19 +08:00
kate522 5a61e68a8c fix(video): guard sourceless player play attempts 2026-09-05 22:04:24 +08:00
kate522 51dd38163d fix(submit): keep sidebar visible on front submit page 2026-09-05 21:50:40 +08:00
kate522 49ffdc6ab2 fix(runtime): remove obsolete jQuery compatibility assets 2026-09-05 21:31:11 +08:00
kate522 4a12de38cb fix(runtime): use compatible WordPress jQuery 2026-09-05 20:48:18 +08:00
kate522 d64c4cf1b4 fix single-post.css 2026-09-05 20:10:07 +08:00
kate522 d86147f406 fix(single): fill slide and video cover media like the image cover
Slide-mode images rendered at intrinsic ratio, top-aligned inside the
swiper chain (--pc-height:auto left .swiper-wrapper content-sized), so
the taller box showed a gap at the bottom and never center-cropped like
the image cover. The video cover had the same problem through
video-player.js (height:auto, capped at 70vh).

- aksrt_render_slider scale mode emits --pc-height/--m-height 100% so
  the wrapper/slide/img chain fills the ratio box and object-fit: cover
  center-crops inside it (same pattern as the .scale-height widgets)
- video-player.js renders the video at height:100% + object-fit:cover;
  with an undimensioned container height:100% degrades to auto,
  matching the previous behavior
- drop the dead .single-cover padding-bottom rules (every producer owns
  its height inline, and the rule squeezed the absolutely positioned
  slide inner) and the orphaned --single-cover-scale var in
  tokens.css/compat-vars.css/main.css/InlineStyleBuilder
- rebuild public bundles; extend the single-cover-height contract
2026-09-05 18:53:45 +08:00
kate522 9fa7190232 test(submit): align toast contract with unified media picker
f3fcfe45 moved featured/slide/video cover uploads into the shared
AksrtSubmitMedia picker, which owns the fixed-id load/success/danger
flow under aksrt-front-submit-editor-media-toast, and pay downloads now
only add a success confirmation with the resource-upload id. Update the
contract accordingly: drop the obsolete featured-upload/slide-upload/
video-cover ids and the image/slide/videoToastId variable assertions,
and assert instead that all three cover actions route through
AksrtSubmitMedia.open and that the picker keeps its fixed-id flow.
2026-09-05 18:22:59 +08:00
kate522 4f2d17f6ee fix(single): align slide and video cover heights with image cover
The image-mode cover reserves +50px for the title/breadcrumb overlay
(calc(scale% + 50px)), but the slide cover wrapper was given a bare
inline padding-bottom by aksrt_render_slider, permanently defeating the
.cover-slide rule, and the video cover used a bare video_scale%.

- aksrt_render_slider: add scale_extra so scale mode emits
  calc(ratio% + extra); the single cover passes 50px so all three
  modes share one height formula
- drop the unreachable static slide fallback and function_exists guard
  (the helper is unconditionally loaded by inc/bootstrap.php), the
  always-true cover_url/ep_count conditions, and the dead --scale-ratio
  and inner swiper padding emitted in scale mode
- remove the dead cover CSS cluster with no producer: .single-cover-slide
  (main.css/main.min.css/cover.css), .single-cover-slider family
  (components/base.css), .single-cover-slide-wrap and .single-cover__*
  helpers (cover.css), and initSlideCovers in misc.js
- rebuild public bundles via tools/build-public-assets.mjs
- add single-cover-height-contract test covering the shared formula
  and the dead-code removals
2026-09-05 18:22:37 +08:00
kate522 05a6b6fc54 fix 更多栏目 2026-09-05 17:32:18 +08:00
kate522 5b89fc200b fix(frontend): restore no-sidebar two-column list pairing
The homepage utility .flex { display: flex !important } out-ranked the
legacy wide-mode rule .site-layout-1 .posts-item:not(.card)
{ display: inline-flex; width: calc(50% - 16px) }, so with the sidebar
collapsed (no widgets) every list item rendered at half width stacked
into one column with an empty right half.

Zibll's .flex utility carries no !important; drop it so the wide-mode
rule wins the cascade, and regenerate the CSS bundles.

Verification: node --test tests/widget-system/*.test.cjs (242/243, the
toast-contract failure is pre-existing); rebuilt bundle contains no
display:flex!important on .flex.
2026-09-05 16:04:23 +08:00
kate522 620a625a72 Merge pull request 'fix(sidebar): collapse empty sidebar column on every scene' (#29) from worktree/sidebar-empty-collapse into main
Reviewed-on: #29
2026-09-05 07:40:57 +00:00
kate522 1839150ecb fix(sidebar): collapse empty sidebar column on every scene
Sidebar visibility had two disconnected decisions: the body site-layout-*
class (via aksrt_is_show_sidebar_filter) only checked widget activity for
singles, while the homepage aside renderer skipped the column and
sidebar.php rendered an empty one. With the toggle on and no widgets, the
homepage kept a reserved gutter and squeezed the main content aside.

- collapse every scene to no-sidebar when the sidebar widget stack is empty
- resolve home_sidebar before the page branch so a static front page is
  checked against the zones the homepage renders
- guard the homepage aside with aksrt_is_show_sidebar() alone and drop the
  duplicate aksrt_homepage_has_sidebar gate
- early-return sidebar.php instead of rendering an empty column
- drop dead code: archive helper $preferred param, wp_is_mobile fallback,
  container--no-sidebar class, unreachable front-page fallback
- update post-visual-contract to pin the single visibility decision

Verification: php -l on all changed files; node --test
tests/widget-system/*.test.cjs 242/243 (the toast-contract failure is
pre-existing and unrelated).
2026-09-05 15:40:11 +08:00
kate522 2bf9b00022 fix(widgets): close the new-swiper container in slider markup
参考 zib_new_slider 的闭合顺序补回缺失的 </div>:此前 .new-swiper
从未闭合,.zib-slider 把后续标记全部吞入,导致组合模块的 .cover-col
成为 .slider-col 的子节点——flex 容器只剩一个占满整行的子列,
PC 端呈现上下堆叠布局(该问题自首次复刻起即存在,非 CSS 所致)。

验证:run.php 136 断言(新增 slider/combo 标签配平断言);
组合模块渲染 26 开/26 闭;localhost 实测两列恢复左右布局
2026-09-05 15:00:27 +08:00
kate522 4eb2ddef66 fix(widgets): keep slider media combo PC layout flex in admin preview
后台模板预览只加载 frontend.css,不加载 main.css 的 .flex 工具类,
PC 端两列因此退化为 block 上下堆叠;在 widget 自有样式层显式声明
display:flex(移动端媒体查询的 display:block/flex 覆盖不受影响)
2026-09-05 14:30:39 +08:00
kate522 73e58517f6 Merge pull request 'Worktree/slider media combo parity' (#28) from worktree/slider-media-combo-parity into main
Reviewed-on: #28
2026-09-05 06:17:51 +00:00
kate522 bb37df19fc chore(assets): remove orphaned legacy widget form styles
widget-set.css(-min) 仅含旧版幻灯片小工具表单样式
(widget_ui_slider_* / ashu_upload_button / panel 等),
全仓无入队与引用,属死代码
2026-09-05 14:17:13 +08:00
kate522 65a69b1dec fix(widgets): align slider media combo with the Zibll reference
- 渲染器:幻灯片按参考补回 span/a 包裹、lazyload swiper-lazy 懒加载契约、
  视频背景与图层视差属性条件;组合模块补回 is_show 门控、超管缺省提示、
  widget-graphic-cover mb 类与内层样式容器、卡片列入场动画类与 lazy_cover 开关
- Schema:幻灯片/图层/卡片字段补齐 library、unit、参考描述文案与分组按钮标题
- 后台表单:分组按钮文案(添加幻灯片/添加卡片/添加图层)、行编号格式、
  视频上传不渲染图片预览(FormRenderer + admin.js)
- 契约测试:新增 slider media combo 前后台参考契约断言

验证:php -l 全部通过;run.php 133 断言;node --test 243 项(仅主干既有
toast-contract 失败);validate-widget-golden 通过
2026-09-05 14:17:03 +08:00
kate522 cf264d5e81 Merge pull request 'fix(widgets): keep widget-content an unstyled structural layer' (#27) from worktree/fix-widget-content-margin into main
Reviewed-on: #27
2026-09-05 05:28:06 +00:00
kate522 d7950730dd fix(widgets): keep widget-content an unstyled structural layer
Reference contract: widget cards carry their own 15px padding and
.widget-content is bare. The added 20px padding inset every widget
card from the content max width on the fixed homepage (the only
bundle loading sidebar.css).

- drop the root padding rule and the unreferenced --flush modifier
- drop the now-redundant user-widget padding:0 workaround
- drop the dead --card-padding-lg token
- lock the contract with negative assertions in the display test
2026-09-05 13:26:52 +08:00
kate522 274b888fd0 Merge pull request 'chore(assets): bump theme version to bust stale admin asset caches' (#26) from worktree/fix-term-aggregation-cover into main
Reviewed-on: #26
2026-09-05 04:45:36 +00:00
kate522 f0f908d9e1 chore(assets): bump theme version to bust stale admin asset caches
The terms field handlers shipped with the widget parity work, but every
asset is enqueued with the unchanged AKSRT_VERSION cache key, so
browsers kept the old admin.js without them: category checkboxes
toggled visually while chips and the hidden value never synced, and
saved term_id stayed empty (widget renders nothing). Bump the version
so all theme assets re-fetch.
2026-09-05 12:44:33 +08:00
kate522 ff7b1c547a Merge pull request 'fix(widgets): keep found rows for term aggregation post count' (#25) from worktree/fix-term-aggregation-cover into main
Reviewed-on: #25
2026-09-05 04:32:50 +00:00
kate522 0c4a0d18f4 fix(widgets): keep found rows for term aggregation post count
The aggregation card's N篇文章 meta reads found_posts, but paginate ''
disables SQL_CALC_FOUND_ROWS and found_posts comes back 0 on current
WordPress. The reference shows the term's total post count, so keep
found rows enabled for this internal query.
2026-09-05 12:32:09 +08:00
kate522 5b7b904bde Merge pull request 'fix(widgets): resolve term covers through the canonical category chain' (#24) from worktree/fix-term-aggregation-cover into main
Reviewed-on: #24
2026-09-05 04:07:39 +00:00
kate522 f5a7c7a833 fix(widgets): resolve term covers through the canonical category chain
The shared term cover helper read aksrt_cover_url/cover_url term meta,
keys this theme never writes, so 分类图文卡片 dropped every card and
分类聚合卡片 always rendered an empty cover box. Resolve covers the way
the rest of the theme does (aksrt_category_cover_id → latest thumbnailed
post in the term), and let the aggregation card fall back to the theme
placeholder while term cards stay hidden without a real cover.
2026-09-05 12:05:12 +08:00
kate522 6cbcca689d Merge pull request 'Worktree/widgets zibll parity' (#23) from worktree/widgets-zibll-parity into main
Reviewed-on: #23
2026-09-05 03:29:13 +00:00
kate522 5ea70f0b03 chore(build): rebuild main css and runtime bundles 2026-09-05 11:28:16 +08:00
kate522 b639d57c5b feat(widgets): replicate 12 reference widgets for full Zibll parity
- rewrite schemas for posts, tab_posts, oneline_posts, hot_posts,
  term_cards, term_aggregation, media_cards, combo_info, partners,
  recent_comments, posts_toc and slider_media_combo to mirror the
  modern reference modules field by field (options, defaults,
  dependencies, min/max/step, subheadings)
- port reference renderers: hot ranking with TOP badges, category
  aggregation card, graphic cover cards, partners CSS marquee,
  combo info cards with auto band palette, recent comment list
- rename 分类与标签聚合卡片 to 分类聚合卡片 and restrict it to
  categories (no tags/topics branch in this project)
- drop topics filter fields and the topics query branch; align
  orderby value domain with supported post meta
- add searchable sortable terms admin field type and spinner unit
  suffix rendering
- unify widget pagination and lazy loading on the REST client with
  the runtime-owned ias reveal trigger; remove the legacy
  post_ajax_trigger dual path
- align section-navs TOC with the reference contract (wznav ids,
  3-heading minimum, zib-widget box)
2026-09-05 11:28:00 +08:00
kate522 9dc278bd83 fix payment points 2026-09-04 23:35:19 +08:00
kate522 f3fcfe45da feat(submit): add user media library 2026-09-04 22:53:33 +08:00
kate522 44e165d34e fix(comments): align image upload with Zibll
Unify comment image upload settings, REST permissions, preview, progress,
and response handling while removing obsolete compatibility paths.
2026-09-04 19:52:56 +08:00
kate522 129432ae45 进行了小优化 2026-09-04 18:48:57 +08:00
kate522 5916fca54d Merge pull request 'fix(runtime): remove legacy action endpoint' (#22) from worktree/fix-legacy-action-404 into main
Reviewed-on: #22
2026-09-04 06:30:12 +00:00
kate522 8af1b9ac4b fix(runtime): remove legacy action endpoint 2026-09-04 14:28:57 +08:00
kate522 7cea99d0ae Merge pull request 'fix(email): set sender before PHPMailer init' (#21) from worktree/fix-mail-from into main
Reviewed-on: #21
2026-09-04 05:57:56 +00:00
kate522 05f40d7728 fix(email): set sender before PHPMailer init 2026-09-04 13:56:11 +08:00
kate522 0d7d4fea61 Merge pull request 'fix(email): derive SMTP auth from credentials' (#20) from worktree/fix-mail-smtp into main
Reviewed-on: #20
2026-09-04 05:25:11 +00:00
kate522 dda343e398 fix(email): derive SMTP auth from credentials 2026-09-04 13:24:26 +08:00
kate522 13fda31e37 Merge pull request 'fix(email): complete mail settings and templates' (#19) from worktree/email-system into main
Reviewed-on: #19
2026-09-04 04:09:45 +00:00
kate522 4833053a5c fix(email): complete mail settings and templates 2026-09-04 12:06:42 +08:00
kate522 4f8ab94fac fix(widgets): align user card action buttons 2026-09-03 21:33:30 +08:00
kate522 5b48be68da Merge pull request 'fix(frontend): keep asset and homepage filters in DOM' (#18) from worktree/fix-asset-homepage-dom into main
Reviewed-on: #18
2026-09-03 11:34:21 +00:00
kate522 7308f055c7 fix(frontend): keep asset and homepage filters in DOM 2026-09-03 19:21:24 +08:00
kate522 a0adddce59 fix(captcha): wait for runtime before sign-in 2026-09-03 13:59:07 +08:00
kate522 9d458dd99a fix(runtime): remove stale aksrt namespace dependency 2026-09-03 13:24:51 +08:00
kate522 03dd4ea707 Merge pull request 'fix(runtime): 按参考主题硬切换为 loader/main/auto_fun 单一 DOM 生命周期' (#17) from worktree/fix-dom into main
Reviewed-on: #17
2026-09-03 04:46:45 +00:00
kate522 5fd7c212ed fix(runtime): 按参考主题硬切换为 loader/main/auto_fun 单一 DOM 生命周期
- 删除 AksrtFrontend registry/rehydrate 双路架构及 build/public.js 拼包入口,
  前台唯一初始路径为 PHP 本地栈(jQuery 1.9.1 → Bootstrap 3.4.1)
  → loader_js(_win/aksrtData 唯一注入)→ tbquire(['main']) → runtime/main.min.js
- window.auto_fun = debounce(z_auto_fun, 100) 为唯一重扫入口;所有模块改为
  执行一次 + off/on('auto_fun.aksrt-*') 命名空间重扫,data/class 标记幂等
- 局部更新协议统一:HTML 注入 → post_ajax.ed → auto_fun();
  远程 modal → loaded.bs.modal → auto_fun();JSON 成功 → zib_ajax.success
- 灯箱恢复 Miniimgbox/imgbox 唯一 owner(imgbox-container、a[data-imgbox]、
  imgbox_data),删除 AksrtLightbox/data-aksrt-lightbox 第二实现及 CSS/overlay,
  _win.imgbox* 重新接上 post-display 设置
- 死代码清理:runtime 下 11 组被自有实现替代的旧模块副本、无消费 vendored 库、
  顶层 grade.min.js、revenue.js、无调用方的 aksrt_get_win_runtime_config(),
  以及 loader 中 16 条死 mapping
- PHP 入队改为本地栈 + loader_js,routed/widget/submit 依赖统一换绑;
  用户卡片发布按钮恢复 nav_user_newposts_btn 设置门控
- 测试对齐:新增 frontend-lifecycle-contract,重写 navigation/lazyload/toast/
  auth-source/comment/page-display/post-visual 等断言为目标架构契约

验证:node --test widget-system+navigation 223 通过 0 失败;
构建脚本重建 main/imgbox min 与 CSS bundles;改动 PHP php -l、JS node --check
全部通过;git diff --check 干净;credits 重入 harness 通过。
2026-09-03 12:44:31 +08:00
kate522 1cd81fb61a Merge pull request 'Worktree/lightbox sitewide' (#16) from worktree/lightbox-sitewide into main
Reviewed-on: #16
2026-09-02 14:09:51 +00:00
kate522 9e6ee55de9 test(navigation): 契约断言对齐打包后的运行时架构
- 依赖图测试:jquery deregister/单文件注册断言改为断言 jquery-core 收敛、
  aksrt-runtime 指向 public.js,以及打包工具纳入 bootstrap/fontawesome5/
  main.min.css/loader/main.min
- header DOM 测试:移除已废弃的 nav_user_newposts_btn 与 navbar-but 断言,
  改断言 navbar-newposts 与 header-userbox.css 进入 public.css 打包清单
2026-09-02 21:49:41 +08:00
kate522 1f4a39d8f8 feat(runtime): 全站前端脚本统一接入 AksrtFrontend 全局 DOM 生命周期
- rehydrate.js 新增 boot()/aksrt:ready 事件,支持 boot 后晚注册立即以 init 触发
- aksrt-runtime.js 初始加载改走 AksrtFrontend.boot,tooltip/倒计时注册为组件
- 约 30 个前端脚本(首页、评论、支付、路由、页面叶子、widgets)
  从直接 DOMContentLoaded 迁移为 AksrtFrontend.register,并加幂等标记
- section-navs 依赖改为 aksrt-runtime,保证注册时序
- 清理死代码:删除孤儿 loader.js/main.js 及 runtime/page-navs 遗留副本
- loader.js 移除已迁移模块的 tbquire 路径映射,main.js 相应瘦身
- build-public-assets.mjs 增加 runtime/main.min.js 自动压缩步骤
- 新增 4 个契约测试(homepage-dom/rehydrate-runtime/global-dom-init/
  public-bundle-parity),3 个既有 harness 适配注册表初始化方式
2026-09-02 21:48:56 +08:00
kate522 5984d4e42d fix(icons): 清理 FA4 残留图标类,统一为 Font Awesome 5 约定
- 通用 fa fa-X 前缀改为 fa fas fa-X(12 个生产文件)
- 重命名图标映射:fa-sign-in→fa-sign-in-alt、fa-envelope-o→far fa-envelope、
  fa-trash-o→far fa-trash-alt、fa-file-image-o→far fa-file-image、
  fa-dot-circle-o→far fa-dot-circle、fa-play-circle-o→far fa-play-circle、
  fa-pencil→fas fa-pencil-alt
- 修复 FA5 中不存在的 fa-pencil-square→fas fa-edit
2026-09-02 21:48:42 +08:00
kate522 94de0e7ddf Merge pull request 'feat(lightbox): 接入全站图片预览' (#15) from worktree/lightbox-sitewide into main
Reviewed-on: #15
2026-09-02 10:34:54 +00:00
kate522 ddf7c37556 feat(lightbox): 接入全站图片预览 2026-09-02 18:33:47 +08:00
kate522 0985d0cb0a feat(captcha): add provider configuration tabs 2026-09-02 16:55:57 +08:00
kate522 ff4ec1eb43 feat(captcha): 打通人机验证 11 个场景的前后端完整链路
- 统一验证入口 aksrt_require_captcha(),返回 true|WP_Error,启用场景配置缺失时 fail-closed 拒绝
- 前端公共 API 重构为 window.AKSRTCaptcha(verify/collect/reset/run),单飞锁防并发重复验证,一次性 proof 绑定浏览器 cookie+场景+服务商+主机名
- challenge/exchange REST 端点:challenge 原子占用防票据放大厂商调用,增加频率限制
- 新增 OAuth bind pass:服务端签发 5 分钟一次性令牌,绑定用户+服务商+浏览器,微信扫码延迟消费
- 覆盖场景:评论、登录、注册、绑定第三方、绑定手机、找回密码、修改密码、更改邮箱、资料下载、签到、前台投稿
- 移除 Service 层重复验证与 _auth_code_verified/_defer_login 等客户端可伪造标志,删除下载共享 pass 死代码
- 修复腾讯 TC3 签名 scope、Cloudflare action/cdata/hostname 严格校验、阿里云验签错误码映射
- 后台 Schema 保存对 secret 字段做掩码/保留合并;导入导出过滤 aksrt_captcha_config
- 重建 public bundle 与 admin SPA;新增 captcha 运行时/安全/业务契约测试与 harness
2026-09-02 13:03:24 +08:00
kate522 d2e95acd75 进行了一些小优化 2026-09-01 18:43:21 +08:00
kate522 bd9d415e89 进行了一些小优化 2026-08-31 21:09:28 +08:00
kate522 2085a702b9 进行了一些小优化 2026-08-31 20:58:48 +08:00
kate522 2a3e8bf392 Merge branch 'main' of ssh://gitea.rutua.cn:222/kate522/wordpress-theme-aksrt 2026-08-31 19:17:24 +08:00
kate522 183e725eb4 进行了一些小优化 2026-08-31 19:16:30 +08:00
kate522 5740c26a6d Merge pull request 'fix(single): restore author follow controls' (#14) from worktree/fix-single-author-follow into main
Reviewed-on: #14
2026-08-31 05:14:44 +00:00
kate522 8eb62e712b fix(single): restore author follow controls 2026-08-31 13:12:45 +08:00
kate522 71444b139a 进行了一些小优化 2026-08-30 21:48:59 +08:00
kate522 a73fa9a6c0 进行了一些小优化 2026-08-30 20:38:25 +08:00
kate522 4c62fda62b 进行了一些小优化 2026-08-30 20:20:31 +08:00
kate522 95540ea32c 文章详细页实现全局DOM加载 2026-08-30 20:02:19 +08:00
kate522 b161d6e5c8 文章详细页实现全局DOM加载 2026-08-30 19:25:53 +08:00
kate522 65fbf97099 评论区硬切换实现小修复 2026-08-30 19:04:41 +08:00
kate522 0e1b0ce86e 评论区硬切换实现小修复 2026-08-30 17:53:19 +08:00
kate522 b8ef4d81a4 评论区硬切换实现 2026-08-30 17:34:29 +08:00
kate522 ed914d2eec 修复了一些小问题 2026-08-30 13:49:54 +08:00
kate522 1b4928d69b Merge pull request '优化了一些问题' (#13) from worktree/oauth-hard-cut into main
Reviewed-on: #13
2026-08-29 14:11:28 +00:00
kate522 780e1eba21 优化了一些问题 2026-08-29 22:03:48 +08:00
kate522 1d904e6532 优化了一些问题 2026-08-29 18:05:49 +08:00
kate522 54db1131dc 优化了一些问题 2026-08-29 17:59:26 +08:00
kate522 af30159a03 优化了一些问题 2026-08-29 17:36:21 +08:00
kate522 88eb29c645 优化了一些问题fix(captcha) 2026-08-29 17:03:05 +08:00
kate522 41da33bb13 优化了一些问题 2026-08-29 16:15:06 +08:00
kate522 e54228d509 优化了一些问题 2026-08-29 15:50:04 +08:00
kate522 f9e411dda8 进行了一些优化 2026-08-27 03:49:56 +08:00
kate522 50c5769d01 进行了一些优化 2026-08-27 03:27:26 +08:00
kate522 ad128ef94e 进行了一些优化 2026-08-27 02:29:40 +08:00
kate522 17dabca568 进行了一些优化 2026-08-27 02:25:34 +08:00
kate522 c6e0423e46 进行了一些优化 2026-08-27 02:19:41 +08:00
kate522 02d456b00b 进行了一些小修复 2026-08-26 23:52:23 +08:00
kate522 fff1590a43 进行了一些小修复 2026-08-26 23:18:51 +08:00
kate522 af3539914d 进行了一些小修复 2026-08-26 22:50:35 +08:00
kate522 b77926388f 进行了一些小修复 2026-08-26 22:23:52 +08:00
kate522 1d073ea293 进行了一些小修复 2026-08-26 21:59:47 +08:00
kate522 6eb189ca88 进行了一些小修复 2026-08-26 21:44:40 +08:00
kate522 90646134d4 进行了一些小修复 2026-08-26 21:32:31 +08:00
kate522 f143a111cd 进行了一些小修复 2026-08-26 21:23:55 +08:00
kate522 8111b17392 进行了一些小修复 2026-08-26 20:00:31 +08:00
kate522 fbada8bd6a 进行了一些小修复 2026-08-26 19:51:31 +08:00
kate522 02cbff1ddf 进行了一些小修复 2026-08-26 19:15:53 +08:00
kate522 5c7fa38da8 进行了一些小修复 2026-08-26 19:04:28 +08:00
kate522 192e102275 进行了一些小修复 2026-08-26 18:50:51 +08:00
kate522 5222349c85 进行了一些小修复 2026-08-26 18:46:15 +08:00
kate522 ad26feaaee 进行了一些小修复 2026-08-26 18:37:44 +08:00
kate522 ee4e599f2d 进行了一些小修复 2026-08-26 18:25:42 +08:00
kate522 7c0c21e984 进行了一些小修复 2026-08-26 18:06:44 +08:00
kate522 fb2bbb64c5 进行了一些小修复 2026-08-26 17:57:29 +08:00
kate522 1f84e6aa18 进行了一些小修复 2026-08-26 17:32:26 +08:00
kate522 bfc16de962 进行了一些小修复 2026-08-26 17:24:37 +08:00
kate522 5121b33ad5 进行了一些小修复 2026-08-26 16:59:42 +08:00
kate522 b80ac46147 进行了一些小修复 2026-08-26 16:38:41 +08:00
kate522 2cb78edabb 进行了一些小修复 2026-08-26 16:32:18 +08:00
kate522 992af2119d 进行了一些小修复 2026-08-26 15:36:56 +08:00
kate522 562beb15cf 进行了一些小修复 2026-08-26 15:29:37 +08:00
kate522 ae10b3d3fd 进行了一些小修复 2026-08-26 15:11:25 +08:00
kate522 d37c34f71c 进行了一些小修复 2026-08-26 14:21:32 +08:00
kate522 2e7cc56043 进行了一些小修复 2026-08-26 14:06:31 +08:00
kate522 d4b7ec1deb 进行了一些小修复 2026-08-26 13:54:49 +08:00
kate522 ed1a47cf4e 进行了一些小修复 2026-08-26 13:30:02 +08:00
kate522 065a5bc99c 进行了一些小修复 2026-08-26 12:35:20 +08:00
kate522 dc14c29878 进行了一些小修复 2026-08-26 12:10:06 +08:00
kate522 90b3ccb006 进行了一些小修复 2026-08-26 11:36:46 +08:00
kate522 9ad6f41592 对小工具进行了一些修复 2026-08-26 11:17:22 +08:00
kate522 523af723ae 对小工具进行了一些修复 2026-08-26 11:06:05 +08:00
kate522 c6b1acfe0e 对小工具进行了一些修复 2026-08-26 09:47:55 +08:00
kate522 411905f919 对小工具进行了一些修复 2026-08-26 09:30:40 +08:00
kate522 bb9413cba6 对小工具进行了一些修复 2026-08-26 08:41:24 +08:00
kate522 e877ceb560 修复了一些问题 2026-08-25 20:15:55 +08:00
kate522 c484ee6ef3 修复了一些问题 2026-08-25 19:59:43 +08:00
kate522 f5746da0ae 修复了一些问题 2026-08-25 19:42:16 +08:00
kate522 ea83f2211c 修复了一些问题 2026-08-25 19:14:38 +08:00
kate522 e41350a81b 修复了一些问题 2026-08-25 19:03:05 +08:00
kate522 9b6088986a 修复了一些问题 2026-08-25 18:42:23 +08:00
kate522 ff521c9ad6 修复了一些问题 2026-08-25 18:34:17 +08:00
kate522 3dbd196e06 修复了一些问题 2026-08-25 18:31:49 +08:00
kate522 c8d199a694 修复了一些问题 2026-08-25 18:22:30 +08:00
kate522 0109a59922 修复了一些问题 2026-08-25 18:11:26 +08:00
kate522 dbb1f776f2 修复了一些问题 2026-08-25 17:57:20 +08:00
kate522 8eb0079bc4 修复了一些问题 2026-08-25 17:15:28 +08:00
kate522 59a393bf27 修复了一些问题 2026-08-25 17:11:58 +08:00
kate522 11f1c23fa7 进行了一些小优化 2026-08-25 17:02:33 +08:00
kate522 4a4009133c 进行了一些小优化 2026-08-25 16:33:36 +08:00
kate522 e5df6fdaa7 进行了一些小优化 2026-08-25 06:58:09 +08:00
kate522 9381d1ff53 进行了一些小优化 2026-08-25 06:41:57 +08:00
kate522 4c81980804 进行了一些小优化 2026-08-25 06:30:29 +08:00
kate522 eef73ee9e7 进行了一些小优化 2026-08-25 06:20:16 +08:00
kate522 8ddbfdd7b7 进行了一些小优化 2026-08-25 06:02:26 +08:00
kate522 75d1e6e53b 进行了一些小优化 2026-08-25 05:59:50 +08:00
kate522 5549f81901 进行了一些小优化 2026-08-25 05:46:21 +08:00
kate522 07c4e969c3 新增全局流水号 2026-08-25 05:33:01 +08:00
kate522 90fd59b647 /user/balance/ 调整 2026-08-25 04:20:29 +08:00
kate522 815b938363 进行了一些小优化 2026-08-24 18:13:47 +08:00
kate522 f5c60cd425 进行了一些小优化 2026-08-24 18:08:06 +08:00
kate522 10b3e6dd9f 进行了一些小优化 2026-08-24 17:47:04 +08:00
kate522 6674bded6a 进行了一些小优化 2026-08-24 17:40:35 +08:00
kate522 9d3bfb91cd 进行了一些小优化 2026-08-24 17:35:46 +08:00
kate522 85a124b886 修复支付状态问题 2026-08-24 17:21:06 +08:00
kate522 406fb8d6a6 修复两个问题 2026-08-24 16:52:26 +08:00
kate522 58f3f42bf8 修复两个问题 2026-08-24 16:38:03 +08:00
kate522 48a82403b2 修复两个问题 2026-08-24 16:07:39 +08:00
kate522 5cae5b5c93 积分与余额账务系统硬切重构 2026-08-24 15:59:11 +08:00
kate522 ca12226b39 Merge pull request '用户功能设置收口与废弃后台链路清理' (#12) from worktree/user-features-cleanup into main
Reviewed-on: #12
2026-08-23 14:10:02 +00:00
kate522 86692ff3f9 用户功能设置收口与废弃后台链路清理 2026-08-23 22:09:12 +08:00
kate522 081eb0870d 登录弹窗接入Toast 2026-08-23 21:16:44 +08:00
kate522 5cbdaa81c1 修复弹窗左图被压缩的问题 2026-08-23 21:05:38 +08:00
kate522 d7634d1ff6 修复了一些问题 2026-08-23 20:59:34 +08:00
kate522 af52f9e94d 登录注册体系的源码级硬切换 2026-08-23 20:30:01 +08:00
kate522 73fcd4ed72 修复XGJ 2026-08-23 18:26:15 +08:00
kate522 616beb8c03 修复AJAX 引擎 2026-08-23 18:01:56 +08:00
kate522 c4512f2bc2 修复AJAX 引擎 2026-08-23 17:30:56 +08:00
kate522 8d2b78a322 修复AJAX 引擎 2026-08-23 17:07:55 +08:00
kate522 42288bb0b1 perf(user): 关注结算异步化并去除冗余勋章评估 2026-08-23 15:44:56 +08:00
kate522 2c1f08deb1 小优化 2026-08-23 15:25:39 +08:00
kate522 0ffd045576 小优化 2026-08-23 15:11:17 +08:00
kate522 176a7d3091 修复aksrt_render_profile_empty() 定义 2026-08-23 14:58:53 +08:00
kate522 bb9a410ad6 用户公开主页全局采用DOM无刷新加载 2026-08-23 14:55:24 +08:00
kate522 c3f66e9a14 修复用户个人信息卡片 2026-08-23 14:20:13 +08:00
kate522 84321801a6 修复粉丝/关注 TAB 的用户列表渲染 2026-08-23 14:12:42 +08:00
kate522 c6c2c0af6d 删除私信分支链路 2026-08-23 13:42:35 +08:00
kate522 1c5c6d86bb 更多资料新增元信息 2026-08-23 13:12:41 +08:00
kate522 32cda220f7 更多资料新增元信息 2026-08-23 13:09:55 +08:00
kate522 57b12dea80 更多资料新增元信息 2026-08-23 12:51:11 +08:00
kate522 54ee4b3bdd 收藏 Tab 现在和文章 Tab 走完全相同的渲染链路和样式 2026-08-23 12:38:33 +08:00
kate522 c9c1789aba 在 user-home.css 里给Tab加上指示条居中 2026-08-23 12:32:39 +08:00
kate522 a06d36ac7b 在 user-home.css 里给文章 tab 加了桌面端双列网格 2026-08-23 12:23:42 +08:00
kate522 5d8b93ac5b 在 user-home.css 里给文章 tab 加了桌面端双列网格 2026-08-23 12:09:45 +08:00
kate522 abd43417a9 在 user-home.css 里给文章 tab 加了桌面端双列网格 2026-08-23 11:43:50 +08:00
kate522 0d86e12d69 在 user-home.css 里给文章 tab 加了桌面端双列网格 2026-08-23 11:23:57 +08:00
kate522 c8339feddb 重构购买卡头部标记 2026-08-23 11:06:33 +08:00
kate522 c6dcf9687a fix 2026-08-22 19:21:11 +08:00
kate522 31e185664a fix 2026-08-22 18:54:12 +08:00
kate522 25bb0213d4 fix(floating): 修复悬浮二维码按钮在前端不生成二维码
二维码容器原输出 .float-btn-qrcode + data-qrcode-url/data-qr-text,
无任何 JS 读取;改用运行时 main.min.js 已有的 .qrcode + data-qrcode
契约渲染 canvas,并修正 CSS 中无效的 :deep() 选择器避免整条规则被丢弃。
2026-08-22 17:58:53 +08:00
kate522 b169574b25 fix(header): 修复窄屏展开菜单按钮图标拉高且与相邻按钮不对齐
1. svg 缺少 viewBox:core/reset.css 的 svg{height:auto} 覆盖 main.css
   的 1em 后无固有宽高比,回退到替换元素默认高度 150px,按钮被撑到
   160px。补上与 sprite 符号一致的 viewBox="0 0 1024 1024"。
2. 按钮类从 but 换成 aksrt-nav-icon:与右侧搜索/用户图标共用
   header-userbox.css 的 34px 居中图标按钮规范,垂直中心与整行对齐。
2026-08-22 17:42:23 +08:00
kate522 1741b6861a fix(header): 补全展开菜单按钮图标的 viewBox,修复窄屏下按钮被拉高
show-nav-but 折叠按钮的 svg 缺少 viewBox,core/reset.css 的
svg{height:auto} 覆盖 main.css 的 1em 后无固有宽高比可依,
回退到替换元素默认高度 150px,把按钮撑到 160px 高。
补上与 sprite 符号一致的 viewBox="0 0 1024 1024"(对齐 zibll 原版输出)。
2026-08-22 17:40:15 +08:00
kate522 9bd9e99057 fix(widgets): 小工具幻灯片改用专属类名,修复顶部多功能组件背景图顶部被裁
widgets/frontend.css 的裸类 .aksrt-slider { overflow:hidden; background:#111 }
命中了旧版顶部多功能组件的包裹层,把 margin-top:-90px 上移到透明导航栏
后方的图片顶部裁掉并露出空白条。小工具幻灯片渲染器改输出 aksrt-widget-slider,
CSS 选择器同步更名,旧版 aksrt-slider 类归属保持不变。
2026-08-22 17:24:57 +08:00
kate522 51d65c694b fix 2026-08-22 16:56:36 +08:00
kate522 9375b1c30c refactor(assets): vendor 资产迁入标准目录并全仓去除 zibll 标识
- assets/vendor 全量迁移: fontawesome5→assets/fontawesome5, css→assets/css,
  js→assets/js/runtime(避开 14 个同名自有文件), img 合并去重→assets/img,
  fonts→assets/fonts; 删除 assets/vendor
- libs 五个与 widgets/third-party 字节相同的库不重复入库, loader paths 改指向
- 运行时切换: _win.uri=assets 根, loader baseUrl=/js/runtime, enlighter 注入
  路径同步; PHP 7 个引用文件切换并改名 $zibll_*→$runtime_*
- 去品牌: Qinver 头注释/品牌 console.log/演示链接清除, 默认文案中性化
- 功能标识符改名: zib__→aksrt__, zib_ajax→aksrt_ajax, window.zib→
  window.aksrtLegacy, zibllblock/*→aksrtblock/*, .zib-widget→.aksrt-panel,
  .zib-slider→.aksrt-slider, zib_menu_options meta 键等
- 自有 CSS 改名: legacy-utilities/compat-vars/footer shell
- admin SPA 重建; 契约测试同步(路径/断言/文件更名)
- 例外保留: tests/visual 对照环境与 fixtures 前缀(外部 Zibll 产品引用)

验证: php -l 133 个改动文件通过; runtime 86 个 JS node --check 通过;
契约测试 68/70(2 个失败经 stash 对照确认为存量问题)
2026-08-22 16:16:30 +08:00
kate522 4f2391fb64 fix(comments): 移除与 vendor 正版样式冲突的移植 CSS,评论区样式回归 zibll 原生
vendor assets/vendor/zibll/css/main.min.css 本就是 Zibll 完整样式表,
已包含评论区全部桌面规则、移动端媒体查询(.mobile-fixed 弹层动画/
.virtual-input/.pagenav/.tab-nav-theme/.dropdown-menu/.line-form/
.placeholder 等)及 <name>/badge/.but/.actived 等组件。此前移植的
comments/shell.css、mobile-item.css、comment-tools.css 大量复制这些
规则且带 #comments id 前缀、加载更晚,覆盖了 vendor 正版值,导致
评论区 UI 与参考主题不一致。

三个文件缩减为「仅补缺口」:
- shell.css:只留 AKSRT 身份徽章两条规则
- mobile-item.css:无缺口,仅保留文件维持 enqueue 句柄链
- comment-tools.css:删除评论代码块重复段,只留投稿/分享/TOC
2026-08-22 13:54:57 +08:00
kate522 608a1148c9 fix(comments): 拦截 vendor comment.min.js 懒加载并解绑其 7 条委托
栈追踪确认 400 "0" 的调用方是 zibll comment.min.js(main.min.js 检测到
#commentform/#postcomments 后 tbquire(["comment"]) 自动懒加载),其
#commentform #submit 绑定 zib_ajax(action=submit_comment) 无处理器,
回复/排序/分页/删除等绑定也与 AKSRT 自有评论脚本双路触发。

双层处理:
1) tbfine 预注册空 'comment' AMD 模块,tbquire 不再下载 comment.min.js
2) 若模块抢在注册前加载,对 comment.js 的 7 条 body 委托做幂等 .off
   ([fixed-input] / pagenav+orderby / reply+edit / #comment keydown /
    trash / approve / #submit)
2026-08-22 13:49:17 +08:00
kate522 d35f0b6730 fix(frontend): 解绑 vendor main.min.js 的 body 级 ajax 委托,根除 admin-ajax 400 "0"
main.min.js 在 body 上委托 [data-action]/[ajax-action]/.wp-ajax-submit/
.initiate-checkin/.oauth-untying 的点击并向 admin-ajax 发 zibll 原生 action
(comment_like/like/follow_user…),AKSRT 无对应处理器,WP 一律返回 400 "0",
即控制台「ajax请求错误」的来源;评论点赞(data-action=comment_like)、文章
点赞/收藏(data-action=like|favorite)、VIP 购买等均会触发,且与 §10.11 自有
处理重复。main.min.js 由 loader.js 异步加载,解绑以幂等 .off 每 200ms
重试至多 50 次覆盖加载窗口。vendor 运行时仅保留 UI 行为。
2026-08-22 13:46:06 +08:00
kate522 8d4f07656a chore(debug): 增加 ajax 调试工具定位 admin-ajax 400 "0" 来源
- assets/js/core/ajax-debug.js:ajaxSend 记录发往 admin-ajax 的
  action 与参数,ajaxError/fetch 记录失败请求的 url/status/响应
- inc/aksrt-ajax-debug.php:admin_init 时记录每个 admin-ajax 请求的
  action、是否有 wp_ajax_/wp_ajax_nopriv_ 处理器、POST 载荷至
  wp-content/aksrt-ajax-debug.log(handled=0 即 400 "0" 的来源)
- 开关:WP_DEBUG 或 URL 带 ?aksrt_ajax_debug=1(或常量 AKSRT_AJAX_DEBUG)
2026-08-22 13:39:13 +08:00
kate522 4d6fd641e5 fix(comments): 置顶链接移除 wp-ajax-submit 类,避免 vendor main.min.js 代理触发空 admin-ajax 请求(400 "0") 2026-08-22 13:33:28 +08:00
kate522 e1f176429b feat(comments): 评论区按 Zibll 1:1 全量复刻
- 评论项/表单/工具栏/筛选/分页/空状态 DOM 全部替换为 zibll 原始类名
  (li.comment、comt-main、comt-meta、list-inline、dropup 工具栏、
  theme-pagination/pagenav、post_ajax_loader 骨架等)
- 移植 zibll main.css L9412–9751 桌面 + 移动端评论样式,
  缺失基础组件(title-theme/dropdown-menu/line-form/tab-nav-theme 等)
  以 #comments 作用域补齐
- 删除全部 aksrt-comment__* 旧类名与旧样式,无兼容层
- JS 选择器对齐 zibll DOM:片段整块替换 #postcomments、骨架加载、
  跳页输入、prepend 插入、addComment onclick 剥离,
  移除 WP comment-reply enqueue 避免双 moveForm
- 表情资源替换为 zibll 全套 gif(内容逐字节一致,git 无差异)
- REST 后端 (CommentActions) 不变

验证:php -l 全部通过;5 个改动 JS 语法校验通过;
node --test tests/widget-system/post-visual-contract.test.cjs 12/12 通过
2026-08-22 13:29:51 +08:00
kate522 251fe86527 修复了一些问题 2026-08-21 14:49:34 +08:00
kate522 b39bcbc53c 修复了一些问题 2026-08-21 14:39:30 +08:00
kate522 3368261d52 修复了一些问题 2026-08-21 14:13:53 +08:00
kate522 79b1c8cb38 修复了一些问题 2026-08-21 13:52:09 +08:00
kate522 e971c2b1da 修复了一些问题 2026-08-21 13:42:59 +08:00
kate522 0b8d7cdab5 修复了一些问题 2026-08-21 13:27:53 +08:00
kate522 28d57f2b7c 修复了一些一些已知问题 2026-08-21 12:59:47 +08:00
kate522 e73b83ed83 修复了一些一些已知问题 2026-08-21 12:56:40 +08:00
kate522 315aee1b12 修复了一些一些已知问题 2026-08-21 12:49:19 +08:00
kate522 3e6af7863d 修复了一些一些已知问题 2026-08-21 12:35:55 +08:00
kate522 d69334b37d 修复导航栏用户卡片浮层的签到按钮 2026-08-20 14:43:18 +08:00
kate522 d27c0757f2 顶部资料卡新增签到按钮 2026-08-20 14:31:37 +08:00
kate522 5f01b3b316 公开用户主页文章双列实现 2026-08-20 14:20:20 +08:00
kate522 c4e162839c 修复顶部多功能组件搜索框的两个问题 2026-08-20 14:12:35 +08:00
kate522 41a1b4f465 修复顶部头像浮层卡片 2026-08-20 12:58:36 +08:00
kate522 4d4baa9cec 顶部多功能组件的图标卡片新增默认图标 2026-08-20 12:34:41 +08:00
kate522 13ed02c93a 修正 fa fab 的字体优先级,微信、QQ、微博等图标现在使用 Brands 字体。 2026-08-19 23:11:44 +08:00
kate522 3312323f6e chore: remove temporary .tmp-shots directory 2026-08-19 22:52:55 +08:00
kate522 93841211d3 ceshi 2026-08-19 22:50:26 +08:00
kate522 4204983db7 Merge pull request 'refactor(header): unify top icon-cards on single zib-widget path' (#11) from worktree-federated-meandering-harbor into main
Reviewed-on: #11
2026-08-19 13:47:29 +00:00
kate522 304d0bde74 refactor(header): unify top icon-cards on single zib-widget path 2026-08-19 21:46:37 +08:00
kate522 21c9634178 Merge pull request 'fix(header): unify top icon-card size and layout' (#10) from unify-header-icon-cards into main
Reviewed-on: #10
2026-08-19 10:50:02 +00:00
kate522 f7cedc7163 fix(header): unify top icon-card size and layout
Add a single always-loaded header-cards.css that normalizes the
顶部多功能组件图标卡片 geometry (equal-width cells, fixed 45px icon
badge, zibll mobile padding) across all pages, and enqueue it from
ScriptsPart1b. Remove the orphan header-components.css + subdirectory
that were never enqueued. Repoint the icon-card contract test at the
new stylesheet.
2026-08-19 18:34:48 +08:00
kate522 e2b5018fc1 fix:首页顶部卡片共享背景布局 2026-08-19 17:27:47 +08:00
kate522 4ea8d5f8e8 fix:首页顶部卡片共享背景布局 2026-08-19 17:11:03 +08:00
kate522 aa58fb58ff fix:首页顶部卡片共享背景布局 2026-08-19 17:07:47 +08:00
kate522 0375a11adc fix:顶部横版卡片 2026-08-19 17:01:27 +08:00
kate522 d1f700c3d1 fix(user): rebuild badge (修复蒙版被裁成中间栏的问题。) 2026-08-19 16:15:11 +08:00
kate522 d98a36b060 fix(user): rebuild badge modal ajax 2026-08-19 16:01:48 +08:00
kate522 9c827ff493 fix(user): rebuild badge modal ajax flow 2026-08-19 15:53:33 +08:00
kate522 029df27b1d fix(settings): repair phone binding modal 2026-08-19 14:36:31 +08:00
kate522 3028ab6f0c fix(profile): restore lazy image bridge for user modals 2026-08-19 14:25:38 +08:00
kate522 886ea8732c fix(profile): restore route styles and hidden modals 2026-08-19 14:08:42 +08:00
kate522 a5ed4bbb6f fix(page-display): isolate header component page scopes 2026-08-19 13:45:54 +08:00
kate522 d4a9b7fd98 fix(page-display): connect header component visibility settings 2026-08-19 13:35:31 +08:00
kate522 8a738c7998 feat(admin): refine theme settings UI 2026-08-19 13:11:04 +08:00
kate522 2e442e20ec fix(footer): restore inline QR code layout 2026-08-19 00:21:05 +08:00
kate522 55571648a0 fix(theme): repair page display frontend regressions 2026-08-19 00:14:58 +08:00
kate522 1436232a34 Merge pull request 'feat(admin): rebuild page display settings' (#9) from worktree/page-display-rebuild into main
Reviewed-on: #9
2026-08-18 16:07:49 +00:00
kate522 480e2bf07e feat(admin): rebuild page display settings 2026-08-19 00:07:13 +08:00
kate522 09b1e8fd35 feat(theme): complete Zibll navigation hard replacement 2026-08-18 21:07:34 +08:00
kate522 893af71a0b Merge pull request 'feat(navigation): hard-switch to Zibll navigation and Font Awesome 5' (#8) from worktree/zibll-navigation-fa5 into main
Reviewed-on: #8
2026-08-18 12:47:08 +00:00
kate522 a0b84977a2 feat(navigation): hard-switch to Zibll navigation and Font Awesome 5 2026-08-18 20:46:21 +08:00
kate522 3c3ddac8be fix(frontend): align article list metadata and pagination 2026-08-18 16:23:44 +08:00
kate522 0f2ac6c34a feat(frontend): replace article system with Zibll 9.0 2026-08-18 16:06:47 +08:00
kate522 2881c69e9c feat(frontend): integrate toast into post submission 2026-08-18 13:06:51 +08:00
kate522 95553086dd (fix):update user setting toast 2026-08-18 03:48:50 +08:00
kate522 e429b59077 (fix):update user profile toast 2026-08-18 03:27:42 +08:00
kate522 b556384025 (fix):update vip toast 2026-08-18 03:18:28 +08:00
kate522 5e2c143449 (fix):update credits toast 2026-08-18 03:09:37 +08:00
kate522 ed6d523d05 (fix)toast :no white-space: nowrap 2026-08-18 02:51:11 +08:00
kate522 699387dbe9 Merge pull request 'refactor(frontend): replace toast system with zibll notyf' (#7) from worktree/replace-frontend-toast into main
Reviewed-on: #7
2026-08-17 18:39:13 +00:00
kate522 86614c4905 refactor(frontend): replace toast system with zibll notyf 2026-08-18 02:38:43 +08:00
kate522 fb11a7ed0c fix(frontend): remove residual post thumbnail lazy path 2026-08-18 01:52:00 +08:00
kate522 435ee1daa7 feat(frontend): replace lazy loading with zibll lazysizes 2026-08-18 01:33:38 +08:00
kate522 02571eecd6 chore(repo): ignore local agent instructions 2026-08-17 22:58:16 +08:00
kate522 51c7feac39 fix(frontend): sync persisted card rendering 2026-08-17 22:54:19 +08:00
kate522 e745b2e54b fix(frontend): restore homepage card grid 2026-08-17 21:58:16 +08:00
kate522 5d45eb053b fix(frontend): align layouts posts and widgets with zibll 2026-08-17 21:47:40 +08:00
kate522 526364b438 fix: restore frontend widget styles 2026-08-17 20:35:08 +08:00
kate522 c2a0c1af6f fix: align REST controller method signature 2026-08-17 20:20:53 +08:00
kate522 e77efd3802 占位文件 2026-08-17 20:14:22 +08:00
kate522 7419130066 chore(repo): exclude local development files 2026-08-17 20:08:56 +08:00
kate522 ac5c0b2dd7 fix(admin): restore Vite admin source files 2026-08-17 20:02:36 +08:00
kate522 8fe3a89c36 test: add PHPUnit 2026-08-17 19:46:17 +08:00
kate522 be1979d378 feat(widgets): add phase 4 validation and accessibility 2026-08-17 11:40:50 +00:00
kate522 8da6439675 revert 009b66326a
revert feat(widgets): add phase 4 validation and accessibility
2026-08-17 11:36:37 +00:00
kate522 3aa57f7d5a revert 8cad3c2d3d
revert Merge pull request 'feat(widgets): add phase 4 validation and accessibility' (#6) from worktree/widgets-phase-4 into main

Reviewed-on: #6
2026-08-17 11:25:23 +00:00
kate522 463ef36ed3 revert 8cad3c2d3d
revert Merge pull request 'feat(widgets): add phase 4 validation and accessibility' (#6) from worktree/widgets-phase-4 into main

Reviewed-on: #6
2026-08-17 11:20:54 +00:00
kate522 e1723a8f08 feat(widgets): add phase 4 validation and accessibility 2026-08-17 19:14:40 +08:00
kate522 8cad3c2d3d Merge pull request 'feat(widgets): add phase 4 validation and accessibility' (#6) from worktree/widgets-phase-4 into main
Reviewed-on: #6
2026-08-17 11:13:09 +00:00
kate522 009b66326a feat(widgets): add phase 4 validation and accessibility 2026-08-17 19:12:38 +08:00
kate522 31e72748c3 Merge pull request 'feat(widgets): complete phase 3 atomic integration' (#5) from worktree/widgets-phase-3 into main
Reviewed-on: #5
2026-08-17 10:35:39 +00:00
kate522 d5dfeb7102 feat(widgets): complete phase 3 atomic integration 2026-08-17 18:21:47 +08:00
kate522 7c3f4221fe Merge pull request 'feat: rewrite schema-driven widget system' (#4) from worktree/fetch-user-tools into main
Reviewed-on: #4
2026-08-17 09:31:17 +00:00
kate522 ad99ab53ce feat: rewrite schema-driven widget system 2026-08-17 17:29:54 +08:00
1317 changed files with 180528 additions and 109346 deletions

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
# 换行规范
#
# 背景:本仓库历史索引(index)内全部为 LF(`git ls-files --eol` 显示 i/lf),
# 而 Windows 工作区长期依赖 core.autocrlf=true 做 CRLF↔LF 转换。这种隐式转换
# 会让「工具以 LF 写盘」的改动在提交时产生大量换行噪声与 "LF will be replaced
# by CRLF" 警告。此处把规则显式化,使任何机器、任何 core.autocrlf 设置下行为一致。
#
# 注意:不要为已跟踪文件添加 `-text`。索引为 LF 而工作区为 CRLF 的文件一旦
# 被标记为 `-text`,git 会认为工作区内容与索引不一致,从而产生虚假改动。
# 默认:按内容自动识别是否为文本,文本一律在仓库内规范化为 LF。
* text=auto
# 源码与配置(文本)
#
# 这里必须同时给出 `eol=crlf`。只写 `* text=auto` 时,工作区的换行形态仍由每个
# 开发者的 core.autocrlf 决定——工具(编辑器、脚本)以 LF 写盘时不会报错,只会
# 在提交时冒出 "LF will be replaced by CRLF" 警告,也就是本仓库此前的漂移来源。
# 声明 `eol=crlf` 后,git 会把工作区自动纠正为 CRLF,漂移自愈。
# 索引侧仍然是 LF,因此不会产生任何既有文件的改写。
*.php text eol=crlf
*.css text eol=crlf
*.scss text eol=crlf
*.js text eol=crlf
*.cjs text eol=crlf
*.mjs text eol=crlf
*.vue text eol=crlf
*.json text eol=crlf
*.html text eol=crlf
*.svg text eol=crlf
*.txt text eol=crlf
*.md text eol=crlf
*.yml text eol=crlf
*.yaml text eol=crlf
*.xml text eol=crlf
*.dist text eol=crlf
*.htaccess text eol=crlf
# gettext 目录按 LF 维护,避免与 xgettext/msgmerge 的默认输出冲突。
*.pot text
*.po text
# 构建产物必须声明为 LF。
#
# esbuild 恒定输出 LF,而上面的 `*.css eol=crlf` 会让工作区期待 CRLF,
# 于是每次执行 tools/build-public-assets.mjs 都会重新引入换行漂移。
# 这些路径必须放在上面各条之后:.gitattributes 以最后匹配的规则为准。
/assets/css/build/** text eol=lf
/assets/css/main.min.css text eol=lf
/assets/js/runtime/*.min.js text eol=lf
/assets/js/build/** text eol=lf
# 二进制资源(不做换行转换,也不做 diff)
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.avif binary
*.ico binary
*.bmp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.eot binary
*.zip binary
*.gz binary
*.tgz binary
*.tar binary
*.pdf binary
*.mp3 binary
*.mp4 binary
*.webm binary
*.psd binary
*.ai binary
*.sketch binary
# 这些文件本身必须是 LF,否则 git 解析 `.gitattributes` 时可能把 CR 读进模式串。
.gitattributes text eol=lf
.gitignore text eol=lf
+27 -14
View File
@@ -1,31 +1,44 @@
# Local tooling / agent workspace # Repository metadata and local agent workspaces
.claude/ /.git/
.compare/ /.claude/
/.compare/
/.zcode/
/.mimosa/
/.workbuddy/
# Project planning / internal docs # Project planning, internal docs, and local-only development files
CLAUDE.md /.aksrt-chrome-profile/
/.specs/
/docs/
/CLAUDE.md
/AGENTS.md
ask-*.md ask-*.md
docs/
!assets/css/routes/docs/ !assets/css/routes/docs/
assets/css/routes/docs/* assets/css/routes/docs/*
!assets/css/routes/docs/toolbar-search-enhancements.css !assets/css/routes/docs/toolbar-search-enhancements.css
# Frontend build output and installed dependencies # Frontend build output and installed dependencies.
assets/admin/build/ # assets/admin/build is deploy-only: build it on the target with
assets/admin/node_modules/ # cd assets/admin && npm ci && npm run build
/assets/admin/node_modules/
/assets/admin/build/*
!/assets/admin/build/.gitkeep
assets/admin/.vite-temp/ assets/admin/.vite-temp/
# PHP installed dependencies # PHP installed dependencies
vendor/ /vendor/*
!/vendor/.gitkeep
# Temporary / recovery helpers # Temporary / recovery helpers
restore-helper-monoliths.php restore-helper-monoliths.php
/.phpunit.cache/
# OS / editor noise # OS / editor noise
.DS_Store .DS_Store
Thumbs.db Thumbs.db
*.log *.log
# Generated contract audit artifact
tools/admin-contract-report.json
@@ -1,80 +0,0 @@
# Plan: Frontend Modal Identity and Badges
## Architectural Decisions
- **Dependency**: This plan starts only after `frontend-modal-runtime` Phase 2 supplies `AksrtModalRuntime`, loader/cache/state contracts, telemetry marks, and the rollout reader.
- **Authentication snapshot**: `AuthApply` and `ApplicationRepository` expose one current-user snapshot containing verification state, latest application, form payload, allowed next action, and poll metadata. The REST handler and owner permission callback reuse that loaded application instead of querying by UUID twice.
- **External provider boundary**: Aliyun/Tencent validation and refresh stay behind `ApplicationService`. Provider HTTP work starts after the shell is visible, is cancellable/visibility-aware where possible, and cannot prevent the modal from opening.
- **Badge canonical path**: The JSON REST path is the only new badge read/write path. User-center and public author entry points share the same privacy-aware DTO; old HTML AJAX modal registrations and post-wear remote reopening are removed after cutover.
- **Privacy**: Cache keys include user/object/context and public-display mode. Authentication evidence remains owner/capability protected and no-store; public badge data follows `list_for_user()` privacy rules. No private DTO is persisted in browser storage.
- **Domain cache**: Badge owned/wear maps and authentication snapshots use short in-memory/request-context memoization. Any submit, review, wear, or status transition invalidates the affected tag before rendering the next view.
- **Admin boundary**: Existing native authentication review and badge management remain the operational surface. This plan does not move them into the Admin SPA.
---
## Phase 1 — Authentication status appears quickly and refreshes safely
**User stories**: US8, US9, US13.
### What to build
Replace the three independent SSR facade reads in `inc/helpers/user-center-renderers/profile-auth.php` with one typed snapshot from `src/User/AuthApply.php`/`src/User/ApplicationRepository.php`. Shape the current identity REST response around the same snapshot and reuse the application loaded by `owns_application()` in `src/Identity/RestController.php` inside get/refresh/manual handlers. Update `assets/js/routes/user-center-auth.js` to open the unified shell immediately, render snapshot state, defer provider refresh until the modal is open or the user explicitly refreshes, pause while hidden, apply backoff, and cancel stale refreshes. Preserve the existing create, manual evidence, provider callback, nonce, owner, capability, and sensitive no-store boundaries.
### Done when
- [ ] `profile-auth.php`, `AuthApply`, and `ApplicationRepository` produce one active/latest/verified snapshot per request; browser harness scenario `auth-snapshot-query-count` with `SAVEQUERIES` shows no repeated identical application SELECT for the active panel.
- [ ] `RestController` owner permission and get/refresh/manual handlers reuse one loaded application per request; `php -l src/User/AuthApply.php src/User/ApplicationRepository.php src/Identity/RestController.php src/Identity/ApplicationService.php` passes.
- [ ] Browser scenario `auth-modal-deferred-refresh` verifies shell-first rendering, no polling while the modal is closed, pause on hidden visibility, minimum interval/backoff, cancellation on close, and a useful error when the provider exceeds its timeout.
- [ ] Browser/API scenarios cover create, provider-pending, processing, verified, rejected/manual retry, evidence upload, duplicate submit, unauthorized owner, and sensitive evidence isolation; existing native review changes are reflected on the next snapshot.
---
## Phase 2 — Badge modal uses one cached JSON path
**User stories**: US10, US11, US12, US13.
### What to build
Refactor `src/User/Badges.php` so `list_for_user()`, `get_owned_map()`, and `get_wear()` share one context and do not rebuild owned/wear dictionaries repeatedly. Remove duplicate `Badges::init()` calls from `functions.php`. Update `assets/js/user-badges.js` to use the runtime loader, short cache, skeleton/error/retry states, and tag invalidation after wear. Replace the user-center and public author HTML/AJAX modal entry points in `inc/helpers/user-center-renderers/profile-header.php`, `templates/parts/user/author-hero.php`, and `assets/js/core/remote-modal.js` with the canonical privacy-aware JSON path. The old `wp_ajax(_nopriv)_aksrt_*badge*modal` handlers and post-wear remote reopen must disappear after this cutover.
### Done when
- [ ] `SAVEQUERIES` scenario `badge-read-query-count` shows one shared owned/wear/dictionary read context per badge response, and a repeated open for the same user/context uses the runtime cache without cross-user reuse.
- [ ] `assets/js/user-badges.js` displays skeleton/empty/error/retry states, re-renders the current list after wear, and no longer calls `AksrtRemoteModal.openRemote` after a successful wear; `node --check assets/js/user-badges.js assets/js/core/remote-modal.js` passes.
- [ ] `git grep -n "aksrt_user_badge_info_modal\|aksrt_single_badge_info_modal\|get_info_link"` shows no active target entry after the canonical path is enabled, and `functions.php` contains one badge singleton initialization.
- [ ] Browser/API scenarios cover no badges, earned/unearned/in-progress/detail, wear success/failure, public author privacy, self/manager visibility, unauthenticated access, REST nonce, and no leakage through cache keys.
---
## Phase 3 — Authentication and badges are independently releasable
**User stories**: US1, US2, US3, US11, US12, US13, US14.
### What to build
Connect identity and badge loaders to the per-domain rollout values from `frontend-modal-runtime`. Make successful authentication transitions, manual review results, badge wear, and badge management invalidate the appropriate runtime tags and update header/profile signals without a full reload. Exercise the two domains as separate gray releases, then remove the old domain-specific modal branches once each switch is permanently enabled.
### Done when
- [ ] The authentication and badge rollout values independently select the new loader path; toggling one does not change the other and does not create a second data store or dual-read branch.
- [ ] `php tools/audit-admin-contracts.php` exits 0 and `node --check assets/js/routes/user-center-auth.js assets/js/user-badges.js` passes after the final route/schema changes.
- [ ] Desktop/mobile scenarios verify shell/ready/error budgets, state refresh behind the modal, focus/keyboard behavior, no horizontal overflow, and no stale private data after logout or account switch.
- [ ] The gray-release checklist records shell/ready/error/status-mismatch metrics and demonstrates pausing the next rollout stage when identity or badge errors exceed the agreed threshold.
---
## Out of Scope
- 修改会员价格、会员权益、支付网关规则、订单账务、退款核算、认证资格/审核政策或徽章授予条件;这些规则只允许被新的流程正确调用和展示。
- 新增支付渠道、替换支付供应商、在浏览器保存支付凭证,或改变支付回调/异步结算机制。
- 新增公开认证资料库、向其他用户暴露认证文档、跨用户查看订单或徽章数据。
- 把订单、售后、认证审核或徽章运营工作台迁移进配置型 SPA;日常运营仍属于原生 WordPress 后台。
- 全站页面导航、用户中心信息架构、品牌设计系统或所有非弹窗页面的全面重做;本期只统一弹窗运行时和四类流程所需的轻量体验。
- 强制页面加载时预取所有弹窗数据、长期持久化敏感弹窗数据、建立订单/认证/徽章的第二套事实存储或长期保留旧新双路实现。
- 以新增兼容别名、旧 option 双读、静默降级或数据迁移层解决旧实现问题;流程切换后以新的规范化链路为唯一实现。
- 建立独立的数据仓库、复杂转化漏斗或第三方用户行为追踪系统;本期观测只服务于弹窗性能、错误和业务状态闭环。
## Open Questions
- 认证云供应商是否支持在请求取消/页面隐藏时安全停止远端刷新;若不支持,客户端只能停止轮询并让服务端回调/下次打开完成状态确认。
- 徽章公开作者入口最终是否必须支持未登录访问;默认保留现有公开可见 DTO,但统一路径仍必须执行业务层 privacy 过滤。
@@ -1,84 +0,0 @@
# Plan: Frontend Modal Loading Upgrade
This is the coordination plan for the three executable plans below. Implementation work should run in dependency order; the detailed phase checklists live in the linked plan files.
## Architectural Decisions
- **Execution split**: The shared modal runtime is a prerequisite. Identity/badges and orders/VIP can then proceed in parallel, each with its own domain API, UI, query optimization, and gray-release evidence.
- **One canonical runtime**: `AksrtModalRuntime` owns shell, skeleton, lifecycle, cancellation, cache, state, focus, and telemetry. Domain scripts only provide loaders and view models.
- **Canonical data boundaries**: Keep `aksrt/v1`, existing domain services, native operational admin, and `AKSRT_Settings` as the boundaries. No duplicate facts, compatibility reads, or long-lived old/new modal implementations.
- **No core table replacement**: Use existing order, identity, user-meta, payment, and badge data. The orders plan may add the required composite index through the existing DB migrator and schema-version process.
- **Staged release**: Roll out identity/badges first, then orders, then VIP payment. Each domain has an independent temporary switch and must meet the performance/error budget before the next switch is enabled.
---
## Phase 1 — Shared modal runtime is ready for domain work
**Detailed plan**: `.specs/plans/frontend-modal-runtime.md`
**User stories**: US1–3, US12–14.
### What to build
Complete both runtime phases: baseline/rollout observability and the cancellable shell/loader/cache/state contract. This phase is a foundation exception because all four existing flows currently own incompatible modal shells and request lifecycles; the runtime is demoable through the existing remote-modal entry path before domain JSON migrations begin.
### Done when
- [ ] `.specs/plans/frontend-modal-runtime.md` Phase 1 and Phase 2 checklists are checked, `php tools/audit-admin-contracts.php` exits 0, and `node --check` passes for all runtime JS.
- [ ] Browser scenarios `modal-baseline`, `modal-shell-lifecycle`, and `modal-cache-dedupe` show shell-first behavior, cancellation, deduplication, invalidation, focus restoration, and cold/warm metrics.
- [ ] `git grep` inventory identifies every old remote-modal caller and labels the remaining domain migration owner before either business plan starts.
---
## Phase 2 — Identity/badges and orders/VIP have vertical slices
**Detailed plans**: `.specs/plans/frontend-modal-identity-badges.md` and `.specs/plans/frontend-modal-orders-vip.md`
**User stories**: US4–11, US13.
### What to build
Run the identity/badge plan and orders/VIP plan in parallel after Phase 1. Each plan must finish its read path, write path, query/API/UI integration, permission handling, and browser regression scenarios before its gray-release phase.
### Done when
- [ ] `.specs/plans/frontend-modal-identity-badges.md` Phases 1–2 and `.specs/plans/frontend-modal-orders-vip.md` Phases 1–2 are checked with their named `SAVEQUERIES`, PHP, JS, API, and browser scenarios passing.
- [ ] Identity/badge and order/VIP loaders use independent cache/invalidation tags and cannot reuse another user or domain's payload.
- [ ] The native order/after-sale and identity/badge operational surfaces remain usable; no operational workbench is added to the Admin SPA.
---
## Phase 3 — All domains are gray-released and old paths are removed
**Detailed plans**: the Phase 3 sections of all three detailed plans.
**User stories**: US1–14.
### What to build
Release identity/badges, then orders, then VIP payment using independent switches. Compare shell/ready/error/status-mismatch metrics against the PRD budgets, pause the next domain on regression, update user-center summaries after successful writes, and remove old HTML/AJAX/domain-specific modal calls after each domain is permanently enabled.
### Done when
- [ ] The Phase 3 checklist in each detailed plan is checked, with `php tools/audit-admin-contracts.php`, changed-file `php -l`, and changed-file `node --check` all passing.
- [ ] Desktop and mobile evidence shows approximately 100ms shell visibility, 200ms cache-hit content, p75 ≤800ms cold content, and p95 ≤1.5s initial budget, with errors/status mismatches recorded per domain.
- [ ] `git grep` confirms no active target old HTML badge modal, duplicate VIP purchase/poll coordinator, hard-coded order REST root, or bypassing domain loader remains after cutover.
- [ ] The final release record identifies the removal version for temporary rollout settings and confirms no compatibility alias, old option double-read, second fact store, or long-lived dual implementation remains.
---
## Out of Scope
- 修改会员价格、会员权益、支付网关规则、订单账务、退款核算、认证资格/审核政策或徽章授予条件;这些规则只允许被新的流程正确调用和展示。
- 新增支付渠道、替换支付供应商、在浏览器保存支付凭证,或改变支付回调/异步结算机制。
- 新增公开认证资料库、向其他用户暴露认证文档、跨用户查看订单或徽章数据。
- 把订单、售后、认证审核或徽章运营工作台迁移进配置型 SPA;日常运营仍属于原生 WordPress 后台。
- 全站页面导航、用户中心信息架构、品牌设计系统或所有非弹窗页面的全面重做;本期只统一弹窗运行时和四类流程所需的轻量体验。
- 强制页面加载时预取所有弹窗数据、长期持久化敏感弹窗数据、建立订单/认证/徽章的第二套事实存储或长期保留旧新双路实现。
- 以新增兼容别名、旧 option 双读、静默降级或数据迁移层解决旧实现问题;流程切换后以新的规范化链路为唯一实现。
- 建立独立的数据仓库、复杂转化漏斗或第三方用户行为追踪系统;本期观测只服务于弹窗性能、错误和业务状态闭环。
## Open Questions
- 部署是否已有统一的浏览器 RUM/Server-Timing 收集端;默认先提供 marks、响应头和现有部署观测,不新增主题指标表。
- 复合订单索引与临时 rollout 设置的删除版本需要在最终发布记录中确认。
-83
View File
@@ -1,83 +0,0 @@
# Plan: Frontend Modal Orders and VIP
## Architectural Decisions
- **Dependency**: This plan starts only after `frontend-modal-runtime` Phase 2 supplies the canonical shell, loader, cache/state, cancellation, and telemetry contracts.
- **Order view model**: `UserOrders` and `OrderCenterRest` return a minimal list/detail view model with server-derived `allowed_actions`, status version, updated time, and invalidation tags. The front end never derives payment/cancel/after-sale eligibility from local status.
- **Order query shape**: Combine list/stat work where possible, batch resource decoration, and add the missing `user_id/status/created_at` index through `AKSRT_DB_Migrator` with a schema version bump. No new order table or duplicate fact store.
- **Reconciliation boundary**: `Gateway::reconcile_order()` remains the authority for pending external orders but runs after the detail shell is visible and only when needed. A normal completed/non-external detail must not wait for remote provider HTTP.
- **VIP JSON path**: Replace server-generated full HTML from `VipServiceModal` with a minimal JSON view model. `pay-vip.js` and the VIP shop in `credits.js` share one purchase coordinator; catalog, member state, order object, and payment attempt are reused within a request.
- **Payment safety**: Payment gateways and `PaymentOrchestrator` stay server-side. Client polling is bounded, backoff-aware, visibility-aware, cancellable, and terminated by a terminal order state. Core price, eligibility, ledger, callback, and VIP fulfillment rules are unchanged.
- **API configuration**: All order/VIP requests use the globally injected API root and REST nonce. Remove `order-center.js` hard-coded REST construction and do not add compatibility route aliases.
- **Release**: Order and VIP rollout switches are independent. Old paths may remain inactive while a domain is not released, but each domain has one canonical implementation after its switch is permanently enabled.
---
## Phase 1 — Order details appear before remote state reconciliation
**User stories**: US6, US7, US11, US13.
### What to build
Optimize `src/Payment/UserOrders.php` and `src/Payment/OrderRepositoryCore.php` so the order list does not perform a fixed list plus four COUNT queries and resource rows do not cause post/meta N+1 work. Add the composite order index in `src/Core/DBMigratorPart2.php` and bump the schema version through the existing migrator. Shape `src/Payment/OrderCenterRest.php` responses around minimal list/detail view models and preserve owner checks. Update `src/Payment/Gateway.php` so pending external reconciliation is deferred or represented as a refresh state after the shell is visible. Replace `order-center.js` hard-coded `siteUrl/wp-json` construction with injected `apiUrl/restUrl`, then move detail, pay, cancel, refresh, and invalidation through `AksrtModalRuntime`.
### Done when
- [ ] `SAVEQUERIES` scenario `order-list-query-count` verifies that the normal first page no longer issues list + four independent COUNT queries and that resource-order decoration has no per-row post/meta N+1; empty/overflow pages remain correct.
- [ ] `DBMigratorPart2` defines the `user_id/status/created_at` query index and the active schema version is bumped; after migration, `SHOW INDEX` or the WordPress DB harness verifies the index.
- [ ] Browser scenario `order-detail-shell` verifies summary skeleton first, ordinary detail does not wait for provider HTTP, pending reconciliation reports refresh state, and detail/status/list update after pay/cancel/after-sale responses.
- [ ] `order-center.js` reads the injected API root, and `php -l src/Payment/UserOrders.php src/Payment/OrderRepositoryCore.php src/Payment/OrderCenterRest.php src/Payment/Gateway.php src/Core/DBMigratorPart2.php` plus `node --check assets/js/order-center.js` pass.
- [ ] API/browser cases cover another-tab status changes, unauthorized order numbers, duplicate action clicks, timeout/retry, pagination, and no cross-user cache reuse.
---
## Phase 2 — VIP purchase reaches payment result through one coordinator
**User stories**: US4, US5, US11, US13.
### What to build
Change `src/Content/VipServiceRest.php` and `src/Content/VipServiceModal.php` from full HTML modal responses to a minimal JSON view model that contains the server-calculated catalog, member state, payment methods, allowed actions, and version metadata. Refactor `src/Content/VipServicePurchase.php` and `src/Payment/PaymentOrchestrator.php` to reuse the catalog/member/order context instead of reading the same data after create and again during pay. Consolidate the remote modal path in `assets/js/pay-vip.js` and the embedded shop path in `assets/js/credits.js` into one purchase/poll coordinator; keep the existing `credits-vip.php` and `vip-tab.php` entry points but route their actions through it. Replace the fixed 40 × 3-second loop with bounded backoff, visibility handling, abort, and terminal-state invalidation.
### Done when
- [ ] `SAVEQUERIES` scenario `vip-purchase-query-count` shows no duplicate member-level/catalog/order reads for modal load and purchase create/pay; `VipServiceRest` reuses the catalog user level instead of recomputing it.
- [ ] `pay-vip.js` and `credits.js` share one VIP purchase/poll coordinator; `git grep -n "setInterval\|pay/content/order\|pay-vip/modal" assets/js/pay-vip.js assets/js/credits.js` shows no second fixed 40 × 3-second loop or old full-HTML injection path.
- [ ] Browser scenario `vip-purchase-flow` covers not-enrolled, active, expired, renewal/upgrade, package/price, points exchange, gateway redirect, cancel, failure, async success, duplicate click, and return to order detail.
- [ ] Payment response inspection confirms no gateway secret reaches the browser; existing gateway, order callback, VIP fulfillment, core price, and entitlement rules pass regression scenarios.
- [ ] `php -l src/Content/VipServiceRest.php src/Content/VipServiceModal.php src/Content/VipServicePurchase.php src/Payment/PaymentOrchestrator.php` and `node --check assets/js/pay-vip.js assets/js/credits.js` pass.
---
## Phase 3 — Orders and VIP are independently gray-releasable
**User stories**: US1–US7, US11–US14.
### What to build
Connect order and VIP coordinators to the independent rollout switches, invalidate user-center order/VIP summaries after successful writes, and verify native order/after-sale operations remain the operational backend. Release orders first, measure shell/ready/error/status mismatch, then release VIP payment only after the order slice is stable. Remove each domain's old modal calls once its switch is permanently enabled.
### Done when
- [ ] Order and VIP rollout values independently select the new loaders; toggling one does not change the other or create a second order/member fact store.
- [ ] `php tools/audit-admin-contracts.php` exits 0, all changed PHP files pass `php -l`, and `node --check assets/js/order-center.js assets/js/pay-vip.js assets/js/credits.js` passes.
- [ ] Desktop/mobile scenarios verify shell/ready budgets, cache-hit and cold-load p75/p95, payment/order state synchronization, permission errors, retry behavior, focus, and no horizontal overflow.
- [ ] Gray-release evidence shows orders reaching the agreed 100ms shell, 200ms cache-hit, p75 ≤800ms, and p95 ≤1.5s initial budgets before VIP is enabled; rollback is demonstrated by disabling only the affected domain switch.
---
## Out of Scope
- 修改会员价格、会员权益、支付网关规则、订单账务、退款核算、认证资格/审核政策或徽章授予条件;这些规则只允许被新的流程正确调用和展示。
- 新增支付渠道、替换支付供应商、在浏览器保存支付凭证,或改变支付回调/异步结算机制。
- 新增公开认证资料库、向其他用户暴露认证文档、跨用户查看订单或徽章数据。
- 把订单、售后、认证审核或徽章运营工作台迁移进配置型 SPA;日常运营仍属于原生 WordPress 后台。
- 全站页面导航、用户中心信息架构、品牌设计系统或所有非弹窗页面的全面重做;本期只统一弹窗运行时和四类流程所需的轻量体验。
- 强制页面加载时预取所有弹窗数据、长期持久化敏感弹窗数据、建立订单/认证/徽章的第二套事实存储或长期保留旧新双路实现。
- 以新增兼容别名、旧 option 双读、静默降级或数据迁移层解决旧实现问题;流程切换后以新的规范化链路为唯一实现。
- 建立独立的数据仓库、复杂转化漏斗或第三方用户行为追踪系统;本期观测只服务于弹窗性能、错误和业务状态闭环。
## Open Questions
- 订单/支付环境是否提供现有 Server-Timing 或网关状态监控收集端;默认使用响应头和现有部署观测,不新增主题内指标表。
- 复合订单索引在生产大表上的部署窗口需要由发布流程确认;迁移必须遵循现有 `AKSRT_DB_Migrator` 原子升级约束。
-72
View File
@@ -1,72 +0,0 @@
# Plan: Frontend Modal Runtime
## Architectural Decisions
- **Canonical runtime**: `assets/js/core/modal-runtime.js` is the single `AksrtModalRuntime` seam for shell creation, lifecycle, focus, loading, error, cancellation, and refresh. `assets/js/core/modal-triggers.js` only binds the new declarative trigger contract; no legacy modal alias or compatibility bridge exists.
- **Loader contract**: A loader receives a domain context and abort signal and returns a minimal view model plus render/update callbacks. `AksrtModalRuntime.openRemote({responseType: 'json', renderData})` owns the request, cache, cancellation, and lifecycle while the domain renderer owns only view-model markup. It may read only the active modal's data.
- **Cache contract**: Use in-memory, user-scoped cache entries with short TTLs, in-flight request deduplication, explicit invalidation tags, and complete clearing on logout/account switch. Do not persist private modal payloads in localStorage.
- **State contract**: All domains use the same `idle`, `loading`, `ready`, `empty`, `submitting`, `success`, `error`, `unauthorized`, and `stale` states. Skeletons match the final layout and never represent business truth.
- **API boundary**: Existing `aksrt/v1` domain routes remain the service boundary; HTML modal responses are replaced domain by domain with minimal JSON view models. Every changed REST route keeps a `permission_callback`; AJAX is not a new data path.
- **Request adapter**: Add one thin `AksrtRequest` adapter for the injected REST root, nonce headers, JSON/error envelopes, 401 handling, and abort signals. Domain files stop maintaining their own REST-root and response-parsing variants.
- **Rehydrate and feedback**: Every runtime-rendered HTML response calls `AksrtFrontend.rehydrate(body, 'modal')`; shared feedback calls the existing callable `AksrtToast` API directly. There is one binding path and no compatibility fallback.
- **Rollout**: Add a temporary per-domain rollout section under the existing Tools configuration surface, read through `AKSRT_Settings`. One canonical new implementation is enabled per domain; the switch is for staged release, not a compatibility data layer.
- **Telemetry**: Use browser `performance.mark/measure` and server `Server-Timing`/existing deployment observability by default. Do not add a private payload or payment/identity-content event store in this foundation plan.
- **Build boundary**: Frontend assets are plain theme JavaScript, not the Admin SPA bundle. Run `node --check` for changed JS and run the admin contract audit only when the rollout schema/controller changes.
---
## Phase 1 — Modal latency is measurable and rollout is controllable
**User stories**: US1, US2, US3, US14.
### What to build
Create the typed rollout reader and configuration contract, then add a small telemetry module that marks the existing VIP, order, identity, and badge open flows without changing their business behavior. Establish cold-cache and warm-cache baselines for click-to-shell, click-to-first-content, click-to-ready, request count, SQL count, response size, and error outcome. The rollout reader must be consumed through `AKSRT_Settings` and the existing Tools schema/controller path, not a raw option read.
Primary integration points:
- `AKSRT_Settings` and the Tools schema/controller for `frontend_modal_rollout`.
- `assets/js/core/modal-telemetry.js` (new) and the existing feature enqueue/config injection in `src/Core/ScriptsPart1c.php` and the surface script enqueue modules.
- Existing flows in `assets/js/pay-vip.js`, `assets/js/order-center.js`, `assets/js/routes/user-center-auth.js`, and `assets/js/user-badges.js` for baseline marks only.
### Done when
- [x] The Tools schema, REST response, and `AKSRT_Settings` reader expose independent VIP, order, identity, and badge rollout values; `php tools/audit-admin-contracts.php` exits 0.
- [x] `assets/js/core/modal-telemetry.js` emits click, shell, first-content, ready, action-start, action-end, and error marks without including payment credentials, order contents, or identity evidence.
- [ ] Browser scenario `modal-baseline` records cold and warm measurements for all four current entry points, and the baseline artifact names the request/SQL counts used for later comparison.
- [x] `node --check assets/js/core/modal-telemetry.js assets/js/pay-vip.js assets/js/order-center.js assets/js/routes/user-center-auth.js assets/js/user-badges.js` passes.
---
## Phase 2 — Any modal opens with a cancellable shell and reusable cache
**User stories**: US1, US2, US3, US12, US13.
### What to build
Build the canonical `AksrtModalRuntime` in `assets/js/core/modal-runtime.js` with focused request, cache, state, and trigger modules. The runtime must show a skeleton before data, use the single new modal trigger contract, abort requests on close/replacement, deduplicate reads, invalidate explicit tags, restore focus, support keyboard/overlay close, mobile bottom-sheet behavior, retryable errors, and call `AksrtFrontend.rehydrate` after every HTML render. All domain callers use the canonical request/runtime/Toast APIs directly; no legacy alias, second host, or migration compatibility path is retained.
### Done when
- [x] `assets/js/core/modal-runtime.js` exposes one canonical `AksrtModalRuntime.open/close/refresh` lifecycle, `AksrtRequest` owns REST root/nonce/envelope handling, and no domain script creates a second global modal host.
- [x] `node --check assets/js/core/modal-triggers.js assets/js/core/request.js assets/js/core/modal-runtime.js assets/js/core/modal-cache.js assets/js/core/modal-state.js assets/js/pay-vip.js` passes.
- [ ] Browser scenario `modal-shell-lifecycle` verifies shell visibility within the 100ms initial budget, skeleton/error/retry states, ESC and overlay close, focus return, request cancellation, mobile bottom-sheet behavior, callable Toast, and `AksrtFrontend.rehydrate(body, 'modal')` after runtime HTML.
- [ ] Browser scenario `modal-cache-dedupe` verifies one read for concurrent/repeated opens, invalidation after a successful write, no reuse after logout/account switch, and one pay-vip binding; `git grep -n "AksrtRemoteModal\|RefreshModal\|data-aksrt-remote-modal" -- '*.js' '*.php' '*.css'` returns no matches.
---
## Out of Scope
- 修改会员价格、会员权益、支付网关规则、订单账务、退款核算、认证资格/审核政策或徽章授予条件;这些规则只允许被新的流程正确调用和展示。
- 新增支付渠道、替换支付供应商、在浏览器保存支付凭证,或改变支付回调/异步结算机制。
- 新增公开认证资料库、向其他用户暴露认证文档、跨用户查看订单或徽章数据。
- 把订单、售后、认证审核或徽章运营工作台迁移进配置型 SPA;日常运营仍属于原生 WordPress 后台。
- 全站页面导航、用户中心信息架构、品牌设计系统或所有非弹窗页面的全面重做;本期只统一弹窗运行时和四类流程所需的轻量体验。
- 强制页面加载时预取所有弹窗数据、长期持久化敏感弹窗数据、建立订单/认证/徽章的第二套事实存储或长期保留旧新双路实现。
- 以新增兼容别名、旧 option 双读、静默降级或数据迁移层解决旧实现问题;流程切换后以新的规范化链路为唯一实现。
- 建立独立的数据仓库、复杂转化漏斗或第三方用户行为追踪系统;本期观测只服务于弹窗性能、错误和业务状态闭环。
## Open Questions
- 部署环境是否已有 RUM/Server-Timing 收集端?默认先交付浏览器 marks 与响应头,不在主题内新增持久化指标表。
- `frontend_modal_rollout` 的临时开关在所有领域切换完成后的删除版本,需要在发布记录中确定。
@@ -1,61 +0,0 @@
# Plan: 用户中心侧栏 VIP 入口卡片
## Architectural Decisions
Durable decisions that apply across all phases:
- **现有会员导航契约**:完整点击卡片复用现有用户中心 `tab=vip` URL;不打开购买弹窗,也不新增客户端标签切换逻辑。
- **现有配置决定可见性**:仅当现有 VIP 配置中至少一个等级启用时渲染入口;不新增设置项、管理员例外或不可用占位。
- **现有数据决定展示状态**:复用既有 VIP 等级、过期等级和到期状态读取能力及文案语义;不增加数据字段、迁移或新的会员状态计算。
- **侧栏组合层决定顺序**:侧栏组合器将独立 VIP 入口渲染器放在统计信息与账户资产之间;渲染器封装卡片状态和输出。
- **用户中心样式保持局部**:会员横卡样式限定在用户中心侧栏范围,沿用现有响应式侧栏抽屉,不新增移动端内容区入口。
- **单一会员入口位置**:删除资产页中仅作导航用途的 VIP 入口,保留独立“我的会员”页面及其购买、续费、升级、兑换和支付能力。
---
## Phase 1 — 会员入口可从侧栏进入
**User stories**: 1、2、3、4、7
### What to build
在统计信息和账户资产之间接入独立的服务端 VIP 入口渲染器。入口根据现有配置和当前用户会员状态,展示未开通引导、有效会员等级与既有到期状态,或过期状态;整个卡片通过现有用户中心 URL 进入“我的会员”页面。此阶段先交付完整行为与基础结构,视觉增强和资产页去重留到下一阶段。
### Done when
- [x] `inc/helpers/user-center-renderers/profile-sidebar.php` 在 `sidebar-stats.php` 后、`sidebar-commerce.php` 前加载独立的侧栏 VIP 入口渲染器。
- [x] 新的侧栏 VIP 入口渲染器复用现有 VIP 配置、会员等级、过期等级和到期状态,并在没有启用任何 VIP 等级时不输出 HTML。
- [x] [agent:test-auditor] 人工检查渲染器的四种服务端输出:未开通显示权益引导;有效会员显示等级和既有状态文案;过期会员显示过期状态;VIP 未启用时无入口,并确认未引入新的精确日期格式。
- [x] 新入口的完整可点击区域使用 `aksrt_get_user_center_tab_url('vip')` 生成目标地址,人工检查所得 URL 可进入 `tab=vip`。
- [x] 运行 `php tools/audit-admin-contracts.php`,命令退出码为 0 且报告为 `errors=0`。
---
## Phase 2 — 会员横卡完成视觉落地并消除重复入口
**User stories**: 5、6
### What to build
为侧栏 VIP 入口增加与账户资产双卡明显区分的金色或会员感渐变视觉、会员图标、动态状态文本、右侧箭头及交互反馈,并确保其随既有侧栏在移动端抽屉中正确展示。同时删除资产页内容区中重复的 VIP 导航卡,保持余额、积分、签到和记录功能不变。
### Done when
- [x] `assets/css/routes/user-center/shell.css` 包含限定在用户中心侧栏的 VIP 卡样式,覆盖会员渐变、图标、状态文本、右侧箭头、完整卡片悬停/聚焦状态和窄屏文本约束。
- [x] `inc/helpers/user-center-renderers/profile-credits.php` 不再输出 `uc-vip-assets-entry` 导航卡,文件中原有余额、积分、签到和资产记录组合逻辑仍被保留。
- [ ] [agent:test-auditor] 人工在桌面宽度验收“统计信息 → VIP 入口 → 账户资产”的顺序、整卡跳转和 VIP 卡与余额/积分双卡的视觉区分。
- [ ] [agent:test-auditor] 人工在既有移动侧栏抽屉验收相同顺序、整卡可点击性、状态文本无溢出,并确认页面内容区没有额外复制 VIP 入口。
- [x] 运行 `php tools/audit-admin-contracts.php`,命令退出码为 0 且报告为 `errors=0`。
## Out of Scope
- 修改“我的会员”页面的布局、文案、套餐卡、权益内容或购买流程。
- 修改支付弹窗、支付渠道、续费、升级、兑换、卡密兑换或订单逻辑。
- 新增、修改或迁移会员数据、会员状态算法、到期日期格式、数据库表或 REST API。
- 新增后台会员配置、侧栏开关、管理员例外显示规则或运营统计。
- 记录 VIP 入口的曝光、点击或转化数据。
- 在移动端内容区额外置顶或复制 VIP 入口。
## Open Questions
无。
-104
View File
@@ -1,104 +0,0 @@
# Plan: 用户中心创作中心与文章价格表
## Architectural Decisions
Durable decisions that apply across all phases:
- **唯一入口使用 `posts`**:将现有“我的文章”面板升级为“创作中心”,不新增重复的 `creator` 面板或路由。
- **复用用户中心壳**:继续使用 `AKSRT\\User\\Center`、现有 `?tab=posts`、REST panel、运行时配置、侧栏和移动端导航。
- **复用现有投稿页**:新建和编辑文章继续跳转现有 `page-submit.php`,不把 TinyMCE 编辑器复制到用户中心。
- **价格使用唯一业务通路**:单篇和批量价格修改必须复用 `FrontSubmitPay::save_pay_meta()`,或先将其抽取为唯一共享价格服务;不复制价格校验逻辑。
- **价格数据模型不变**:继续使用当前 `aksrt_pay` 及统一付费元数据,订单、购买和收益逻辑无需修改。
- **服务端权限为最终边界**:每篇文章重新检查作者归属、编辑权限和 `new_post_pay` 能力;前端选择结果不可信。
- **批量更新采用逐项结果**:服务端返回每篇文章的成功/失败结果,前端不得把部分失败伪装成整体成功。
- **复杂付费资源继续走完整编辑器**:价格表优先处理价格和基础付费字段;付费下载资源等复杂结构不重复实现。
---
## Phase 1 — 创作中心文章总览可端到端使用
**用户故事**:US1、US2、US3、US8
### What to build
升级现有 `posts` 面板的名称、信息层级和文章管理体验:增加创作中心标题及新建文章主操作,整合状态概览、文章列表、编辑/预览/删除操作,并复用现有文章查询、状态筛选、权限和用户中心 REST 加载机制。
### Done when
- [ ] `src/User/Center.php`、用户中心渲染映射和入口文案仍只注册/渲染 `posts`,不存在第二个重复的 `creator` 文章管理面板。
- [ ] `profile-posts.php` 展示创作中心标题、新建文章入口、发布/草稿/待审/私密/全部状态数量,并继续使用现有状态筛选参数。
- [ ] 文章列表展示标题、封面或占位图、状态、更新时间、付费摘要和按权限过滤后的操作按钮。
- [ ] 编辑、预览和删除按钮分别遵循现有 `can_edit_post()`、文章可访问性和 `can_delete_post()` 判断;删除继续使用现有 nonce/AJAX 语义。
- [ ] 通过用户中心 REST panel 和 `?tab=posts&post_status=...` 深链访问时,服务端和客户端均能激活正确面板与筛选状态。
---
## Phase 2 — 新建与编辑文章工作流连通
**用户故事**:US3、US8、US9
### What to build
将创作中心的新建/编辑操作与现有前台投稿工作台完整连通,保留 zibll 参考中的双栏创作层级,同时只复用 AKSRT 已有的编辑器、文章参数、封面、草稿、审核和付费配置能力。保存或提交后回到创作中心时保留上下文并反馈结果。
### Done when
- [ ] 新建按钮使用 `aksrt_get_new_post_url()`,编辑按钮传递现有文章 ID,并由 `FrontSubmit::can_submit()`/`can_edit_post()` 控制可用性。
- [ ] `page-submit.php` 的标题、编辑器、封面、分类、专题、高级筛选、标签、付费侧栏和操作区保持现有 AJAX、nonce 和权限链路。
- [ ] 草稿保存、提交审核/发布、编辑已有文章和离开页面警告均继续由现有 `FrontSubmit` 与 `front-submit.js` 处理。
- [ ] 从创作中心进入编辑器后返回时,能够恢复文章列表的状态筛选或分页上下文,并显示保存/提交结果。
- [ ] 运行 PHP lint 及项目可执行的前端静态检查,确认本阶段未破坏现有投稿页面。
---
## Phase 3 — 文章价格表支持单篇与批量调价
**用户故事**:US4、US5、US6、US9
### What to build
在创作中心增加文章价格表,支持免费/付费阅读/付费资源摘要、现金/积分、普通价格和 VIP 价格展示;实现单篇快速编辑及“选择文章 → 统一价格方案 → 预览 → 确认”的批量调价流程。必要时先抽取共享价格服务,再接入单篇和批量请求。
### Done when
- [ ] 价格表按当前用户可管理文章展示免费、付费类型、现金/积分、普通价、VIP1/VIP2 及购买限制摘要;关闭付费时显示“免费”而不是误显示为 0。
- [ ] 单篇编辑支持免费开关、支付模式、普通价、VIP1/VIP2、付费类型、购买限制和登录限制;不修改正文、封面或分类。
- [ ] 单篇保存请求验证登录、作者归属、编辑能力和 `new_post_pay`,并复用 `FrontSubmitPay::save_pay_meta()` 或唯一共享价格服务。
- [ ] 批量工具支持多选、免费/现金/积分价格方案、影响数量预览、确认和逐项成功/失败反馈;请求大小受控。
- [ ] 最低/最高价、免费规则、VIP1/VIP2 层级、折扣计算、支付模式和付费类型校验与投稿页一致,且价格失败时保留输入值。
---
## Phase 4 — 移动端、可访问性与完整验收
**用户故事**:US7、US8、US9
### What to build
完成创作中心文章列表和价格表的响应式布局、键盘交互、加载/空数据/失败/部分成功反馈,以及接口和安全边界验收。确保桌面端高密度工作台和移动端卡片/展开式编辑体验都可用。
### Done when
- [ ] 在窄屏下文章列表和价格表转换为卡片或垂直字段布局,无页面级横向溢出,筛选、批量选择、编辑和确认操作均可通过触控完成。
- [ ] 加载、空数据、请求失败和批量部分失败均有明确文本反馈;失败时不会显示误导性的 0 或清空用户输入。
- [ ] 关键按钮、价格输入、状态标签和确认弹窗具备语义化结构、可见焦点和键盘操作能力,状态不只依赖颜色。
- [ ] 所有新增 REST/AJAX 入口具备 `permission_callback` 或 nonce;输入使用项目既有清洗与价格归一化,输出完成转义。
- [ ] `php tools/audit-admin-contracts.php` 退出码为 0 且无错误,并完成项目可执行的 PHP lint、前端构建或对应静态检查。
---
## Out of Scope
- 不复制 zibll 的 PHP 函数、字段名、商品规格模型、订单模型或主题配置 API。
- 不在用户中心新增独立的 `creator` 页面、第二套文章管理 tab 或第二套前台路由;`posts` 是唯一文章管理入口,界面标题可升级为“创作中心”。
- 不把完整 TinyMCE 编辑器复制嵌入用户中心;新建/编辑继续跳转 `aksrt_get_new_post_url()` 对应的投稿页面。
- 不修改文章购买、订单结算、收益分成、优惠码、付费下载实际业务规则。
- 不新增旧选项双读、旧路由兼容层、数据迁移或新旧 UI 并存实现。
- 不把日常创作工作台迁入配置型 Admin SPA。
## Open Questions
- 默认将 `posts` 面板的显示标题改为“创作中心”,内部 tab key 保持 `posts`;若产品希望 URL 也改为 `creator`,需在实现前明确选择,因为项目规则不允许长期保留两个重复入口。
- 默认批量调价采用“选择文章 → 统一价格方案 → 预览 → 确认”的模式;不包含按比例涨跌或价格模板市场。后续如需比例调整,应另立需求。
- 默认价格表只覆盖安全可复用的价格字段;付费下载资源等复杂结构继续通过完整投稿编辑器维护,除非实现阶段确认现有服务可提供等价的批量契约。
- 默认创作中心不内嵌完整编辑器;若需要无跳转编辑体验,应另行评估 TinyMCE 生命周期、自动保存和页面性能。
-136
View File
@@ -1,136 +0,0 @@
# Plan: 用户创作收益中心
## Architectural Decisions
以下决策贯穿全部阶段:
- **作者收益与推广返佣分离**:创作中心只读取付费文章作者收益,不读取 `Rebate` 推广返佣数据。
- **收益记录以订单为源**:支付成功且权益发放完成后,每笔符合条件的付费订单最多生成一条作者收益记录。
- **收益生成时快照**:保存原始支付金额、货币、作者分成比例和适用会员等级,后续配置或等级变化不影响历史记录。
- **现金与积分彻底分离**:分别记录、统计、结算和写入流水;不换算、不合并,退款不改变作者收益。
- **统一认证边界**:前台作者 ID 始终取当前登录用户,后台接口复用支付中心权限模型;所有 REST 路由必须提供 `permission_callback`。
- **原子余额操作**:现金和积分余额只能通过现有余额服务变更,不直接修改用户 meta。
- **最终结构替换**:新增或调整表结构时直接更新最终 schema 版本,不增加旧字段双读、迁移层或兼容路径。
- **后台职责保持分层**:配置进入 Vue SPA 的“支付&付费 > 创作中心”,收益运营继续使用 Native 支付中心现有“创作分成”页面。
- **创作开关是完整功能门控**:关闭后隐藏前台入口、停止新收益生成、停止自动和手动结算;历史记录仅保留后台查询。
---
## Phase 1 — 付费订单产生可追踪作者收益
**用户故事**:1、2、3、4、8、9、15
### What to build
建立作者收益的最终数据结构和统一服务入口,接入支付成功且权益发放完成后的订单流程。实现普通用户、一级会员、二级会员三档比例,现金订单与积分订单分别生成收益记录,并保证支付回调重复执行时不会重复生成。
同时完成创作分成基础配置的后端读取与校验,使收益服务能够使用独立于其他会员分成配置的比例和功能开关。
### Done when
- [x] `src/Core/DBMigratorPart2.php` 或最终负责收益表 schema 的文件包含作者、订单、文章、购买者、货币、原始支付金额、比例快照、会员等级、作者收益金额、状态和时间字段,并同步提升 schema 版本。
- [x] `src/Payment/Revenue.php` 或拆分后的作者收益服务实现 `create_from_paid_order(order)`,且同一订单重复调用只保留一条作者收益记录。
- [x] 现金订单仅创建现金收益记录,积分订单仅创建积分收益记录;作者购买自己的付费文章不会创建收益记录。
- [x] 普通用户、一级会员、二级会员分别按 60%、70%、80% 计算,并把生成时比例保存到收益记录。
- [x] 支付成功但权益未完成时不创建收益;权益完成后的支付流程能够创建待结算记录。
- [x] 创作分成关闭时,收益生成入口被跳过;相关收益服务测试覆盖上述场景。
---
## Phase 2 — 收益周期结算与退款保持
**用户故事**:7、10、11、12、13、14
### What to build
实现待结算收益按配置周期自动结算、后台手动逐笔/批量重试。现金收益写入现金余额,积分收益写入积分余额;退款或取消订单不再改变作者收益,所有入账操作具备幂等性。
### Done when
- [x] `src/Payment/Revenue.php` 或拆分后的结算服务实现 `settle(record_ids, actor)`,现金和积分分别调用对应原子余额服务并写入对应流水。
- [x] `settlement scheduler` 按站点时区处理达到配置结算周期的待结算记录;结算周期结束后自动入对应钱包,不再配置延迟小时或自动/手动模式。
- [x] 同一收益被重复结算、并发结算或重复定时扫描时,只能成功入账一次。
- [x] 订单退款或取消不会取消 pending 收益、冲正 settled 收益或产生待追缴记录;收益只按正常周期进入对应钱包。
- [ ] 服务测试覆盖现金/积分比例快照、周期边界、自动结算、手动重试、退款后收益保持和并发幂等场景。
---
## Phase 3 — 用户中心作者收益页面可用
**用户故事**:1、2、3、4、5、6、7、15、16
### What to build
将用户中心“创作中心”改造成作者收益页面。实现现金/积分双货币收益卡片、待结算与已结算展示、结算备注和三列收入明细,并保留基于现有用户中心 REST/Ajax 机制的分页加载。
保留独立的“收益中心”推广返佣入口,确保两个页面的数据和开关互不影响。
### Done when
- [ ] `src/User/Center.php`、`inc/helpers/user-center-renderers/sidebar-commerce.php` 和相关侧栏逻辑在创作分成关闭时隐藏创作中心入口,但仍显示独立的收益中心入口。
- [ ] 用户中心创作中心渲染器不再输出文章发布、草稿、审核、编辑、删除或文章管理内容。
- [ ] `Creator income view model and renderers` 实现现金/积分今日收入、累计收益、待结算和已结算数据,列表只输出“现金收益/积分收益、+金额、待结算/已结算”。
- [ ] `GET /wp-json/aksrt/v1/user-center/...` 作者收益接口只使用当前认证用户,未登录或访问他人数据时被拒绝;路由均具备 `permission_callback`。
- [ ] 结算备注输出经过 `wp_kses` 处理的后台 HTML;收入明细默认每页 20 条、按产生时间倒序,移除货币 Tab 和状态筛选。
- [ ] `assets/js/routes/user-center.js` 只转发创作中心分页参数并保持 History API 无刷新加载;请求失败时保留卡片和布局并显示可重试提示。
- [ ] 前台交互测试验证现金/积分分栏、空状态、分页、退款后收益保持、移动端无横向溢出,以及创作中心与收益中心数据隔离。
---
## Phase 4 — 后台配置和支付中心运营闭环
**用户故事**:8、9、10、11、12
### What to build
在“支付&付费 > 创作中心”集中提供开关、现金/积分两套三档分成比例、结算周期和结算备注文案 HTML。复用现有支付中心“创作分成”Native 运营页面,提供作者收益查询、筛选、逐笔/批量结算重试。
### Done when
- [ ] `src/Admin/Schema/SchemaPayment.php`、`assets/admin/src/schema/sections/payment/index.js` 和支付菜单文件共同注册“创作中心”配置项。
- [ ] 配置 REST 能加载和保存开关、两套 60/70/80 默认比例、结算周期和 HTML 备注;非法比例和周期值被拒绝。
- [ ] `src/Settings/PaymentRuntime.php` 或对应 `AKSRT_Settings` 运行时读取器只读取新的创作中心配置,不增加旧配置双读或静默 fallback。
- [ ] `src/Admin/PaymentController.php` 和现有 Native 支付运营页面支持按作者、文章、货币、收益状态、订单状态和时间查询收益记录。
- [ ] 管理员可逐笔或批量结算,已处理记录不会重复入账;普通用户无法访问运营接口。
- [ ] 后台只运营 pending/settled 收益记录;退款不提供作者收益冲正或追缴入口。
- [ ] `php tools/audit-admin-contracts.php` 通过,后台配置与运营测试覆盖保存、权限、查询和操作结果。
---
## Phase 5 — 全链路验证与交付收口
**用户故事**:全部用户故事,重点覆盖异常、权限和部署契约
### What to build
对收益生成、周期结算、退款保持、用户中心、后台配置和运营页面进行跨模块联调,完成静态检查、前端构建、契约审计和可用的 WordPress/外部 Docker harness 验证。修复跨阶段集成问题,并明确记录环境不可用时未执行的运行时验证。
### Done when
- [ ] 收益服务测试覆盖现金/积分比例快照、货币分离、订单幂等、周期到期结算、退款后收益保持和并发重复操作。
- [ ] 前台交互测试覆盖开关门控、本人数据隔离、双货币卡片、分页、错误重试、移动端布局和推广返佣隔离。
- [ ] 后台配置与运营测试覆盖 Schema/REST 契约、权限、周期自动结算、批量结算重试和现金/积分查询。
- [ ] `php tools/audit-admin-contracts.php` 返回成功状态。
- [ ] `assets/admin` 执行 `npm run build` 成功,生成的 `assets/admin/build/` 文件保持可加载。
- [ ] 按环境条件完成 PHP 静态检查和 WordPress/外部 Docker harness 运行验证;缺少运行环境时,在交付记录中明确列出未执行项及原因。
---
## Out of Scope
- 本次不实现“我的收入”卡片提现按钮,也不新增提现申请、审核或支付流程。
- 本次不改造或合并推广返佣收益中心。
- 本次不恢复或迁移用户中心内的文章发布、草稿、审核、编辑、删除和文章管理入口。
- 本次不实现按文章汇总的“我的商品”列表;收入明细固定按单笔作者收益记录展示。
- 本次不提供作者收益导出、图表、趋势分析、排行榜或销售漏斗。
- 本次不提供站内作者与购买者之间的订单争议处理流程。
- 本次不允许现金与积分互相兑换、折算或合并统计。
- 本次不设置作者收益进入钱包的 50 元结算门槛;该金额只用于现金提现提示。
- 本次不实现退款后的作者收益冲正、余额扣回或追缴。
- 本次不为旧收益结构增加兼容读取、双路实现或历史数据迁移。
- 本次不新增独立后台收益工作台,后台运营继续复用现有支付中心“创作分成”页面。
- 本次不引入新的前端框架、支付网关或货币体系。
## Open Questions
暂无。PRD 中的收益口径、结算策略、货币处理、权限、退款保持和后台入口均已确定。
@@ -1,68 +0,0 @@
# Plan: 用户中心内链推广中心与 UI/UX 重设计
> 现状校正:邀请卡片当前**已不在**“账号设置”中,而是作为一个区块内嵌在“收益中心 / revenue”标签内
> (`inc/helpers/user-center-renderers/profile-revenue.php:63` 依据 `show_invite_section` `require profile-invite.php`)。
> 本计划按用户决定,将推广能力抽出为**独立的 `promotion` 标签**,并对其完成 UI/UX 重设计。
## Architectural Decisions
Durable decisions that apply across all phases:
- **独立标签键 `promotion`**:在 `src/User/Center.php` 面板注册表新增 `promotion` 面板;标签键与 URL 参数统一为 `promotion`,复用现有 `?tab=` 机制,不新增前台路由或页面模板。
- **复用现有用户中心壳与渲染管线**:沿用 `page-profile.php` 壳、`aksrt_get_user_center_render_context()` 上下文、`templates/parts/user-center/panel.php` 面板包裹、侧栏组合器、`mobile-tabs.php` 移动导航与 `aksrt_get_user_center_tab_url()` URL 生成;只新增/移动渲染片段,不复制渲染路径。
- **可见性沿用现有开关**:推广标签的注册与渲染沿用现有 `$invite_on` 判定(`credits_enabled()` && `invite_enabled()` && `invite_registration_enabled()`,见 `Center.php:130-133` 与 `aksrt-user-center.php:497-500`);任一关闭即不注册标签、不出现入口。
- **深链归一到单一目标**:`invite` / `promotion` 深链统一解析到 `promotion` 标签(替换现有 `invite/promotion → revenue` 折叠);保留 `invite → promotion` 归一以满足 PRD “旧邀请深链归一”,不保留 revenue 双渲染路径。
- **单一邀请渲染位置**:邀请卡片只在推广标签渲染;`revenue` 标签恢复为纯“收益与提现”,不再 `require profile-invite.php`。
- **复用既有业务通路**:继续使用现有 REST 端点 `aksrt/v1/invite/me`、`aksrt/v1/invite/list`(`src/Credits/Invite.php:49,56`,登录用户权限)与前端 `assets/js/credits.js` 的 `initInvite()`(`:193`,`[data-aksrt-invite]` 契约);不新增邀请码生成、cookie、归因、注册绑定、奖励结算逻辑或第二套请求。
- **样式模块化**:推广卡 `uc-promotion-*` 样式迁入独立 `assets/css/routes/user-center/promotion.css`,由 `assets/css/routes/user-center.css` 引入;从 `user-center/revenue.css` 移除,不引入独立 UI 框架。
---
## Phase 1 — 推广中心作为独立标签可端到端进入
**User stories**: US1、US4、US6、US7
### What to build
将邀请能力从收益标签迁出为独立 `promotion` 标签的完整纵切:在面板注册表按现有邀请开关注册 `promotion` 面板;新增 `profile-promotion.php` 面板渲染器承接现有 `data-aksrt-invite` 卡片(本阶段保持行为一致,视觉重设计留待 Phase 2);从 `revenue` 标签移除邀请区块使其恢复为纯收益/提现;将 `invite`/`promotion` 深链与运行时别名统一归一到 `promotion`;在桌面侧栏与移动导航暴露推广入口。REST 与 `credits.js` 邀请逻辑保持不变。
### Done when
- [ ] `src/User/Center.php` 注册 `promotion` 面板(`type=panel`),其注册受 `credits_enabled() && invite_enabled() && invite_registration_enabled()` 门控;三者任一关闭时 `get_dashboard_tabs()` 不含 `promotion`;`revenue` 面板的注册不再依赖 `$invite_on`。
- [ ] 新增 `inc/helpers/user-center-renderers/profile-promotion.php` 输出含 `data-aksrt-invite` 的卡片;`aksrt-user-center.php` 的 `$built_in_tabs`(`:182`)与视图映射(`:147-162`)能将 `promotion` 解析到该文件;`profile-revenue.php` 不再 `require profile-invite.php`(`grep -n "profile-invite" inc/helpers/user-center-renderers/profile-revenue.php` 无结果)。
- [ ] 深链归一:`aksrt_get_user_center_tab_url()`(`aksrt-user-center.php:106`)、`aksrt_user_center_resolve_tabs()`(`:289`)、`aksrt_user_center_runtime_config()` 的 `tabAliases`(`:347-350`)与 `inc/modules/user-parts/urls.php` 别名表(`:31-37`)均将 `invite`/`promotion` 归一到 `promotion`;访问 `?tab=invite` 服务端激活 `promotion` 面板(`is-active` 落在 `#panel-promotion`)。
- [ ] 桌面侧栏(`sidebar-nav.php` 或 `sidebar-creator.php`)与 `mobile-tabs.php`(经 `dashboard_tabs` 自动渲染)暴露“推广中心”入口,其 `href` 来自 `aksrt_get_user_center_tab_url('promotion')`;`assets/js/credits.js` `initInvite()` 仍通过 `[data-aksrt-invite]` 绑定(该契约选择器未改名)。
- [ ] 运行 `php tools/audit-admin-contracts.php`,退出码为 0 且报告 `errors=0`。
---
## Phase 2 — 推广中心完成 UI/UX 重设计与移动适配
**User stories**: US2、US3、US5
### What to build
在独立推广标签内按“成果概览 → 分享操作 → 奖励说明 → 邀请记录”优先级重组信息架构并完成视觉重设计;把 `uc-promotion-*` 样式迁入独立 `promotion.css` 模块;确保加载中/空数据/请求失败的非误导状态反馈、键盘可操作的复制交互与可见成功/失败反馈、语义化结构与不只依赖颜色的状态;移动端单列、无横向溢出、复制按钮易触达。数据仍来自现有 `/invite/me`、`/invite/list`。
### Done when
- [ ] 新增 `assets/css/routes/user-center/promotion.css` 承载 `uc-promotion-*` 规则并在 `assets/css/routes/user-center.css` 中 `@import`;`assets/css/routes/user-center/revenue.css` 不再包含 `uc-promotion-*` 规则(`grep -n "uc-promotion" assets/css/routes/user-center/revenue.css` 无结果)。
- [ ] `profile-promotion.php` 面板按“成果概览 → 分享操作 → 奖励说明 → 邀请记录”顺序输出;复制按钮为可聚焦的 `<button>`,`credits.js` 复制成功/失败均写入 `[data-invite-copy-feedback]` 文本(非仅颜色),失败时聚焦并选中链接输入框。
- [ ] 加载中/空数据/失败状态经现有 `[data-invite-status]`、`[data-invite-list]`、`[data-invite-total]` 呈现,失败时统计不呈现误导性的 0(保持 `—` 占位,见 `credits.js:294-298`)。
- [ ] [agent:test-auditor] 人工在 ≤640px 宽度验收推广面板单列布局、无横向溢出、复制按钮整行可点击,且统计/链接/记录可读可点击。
- [ ] 运行 `php tools/audit-admin-contracts.php`,退出码为 0 且报告 `errors=0`。
---
## Out of Scope
- 新增独立的公开推广落地页、推广门户或第二套前台路由。
- 修改邀请码生成规则、推广 cookie 时长、邀请归因规则、邮箱验证条件、奖励额度或积分结算模型。
- 新增社交平台 SDK、自动化分享到第三方平台,或站外推广统计系统。
- 对管理员后台邀请码运营界面、推广/返利支付规则进行功能重构。
- 增加任何向后兼容、数据迁移或旧新两套邀请 UI 并存的实现。
## Open Questions
- “推广中心”的最终中文名称是否固定为“推广中心”,还是应使用“邀请推广”/“邀请好友”等已有产品术语?默认采用“推广中心”。
- 推广中心在用户中心侧边栏中的最终排序位置需在实现时根据现有 tabs 的产品优先级确定;默认放在积分/资产(credits)相关入口附近。
-148
View File
@@ -1,148 +0,0 @@
# Plan: Zibll 小工具全量 1:1 深度复刻
> PRD: `.specs/prds/zibll-widgets-1to1.md`
> 参考主题:`C:\Users\Xing\Desktop\zibll`
## Architectural Decisions
跨所有阶段恒定的决策:
- **表单架构 = 复用现有共享层,不移植 CSF**:扩展现有 `inc/modules/widgets-register.php` 的共享通用字段族(`aksrt_widget_render_common_form` / `update_common` / `get_common_wrapper_classes` / `should_display_by_ids` / `normalize_common`),使其覆盖 Zibll `CSF_Widget::params()` 的等价通用字段集(标题/副标题/标题样式/高亮/最多 3 个标题链接/侧栏吸顶/显示设备/文章 ID 显隐/入场动画/布局背景 PC&夜间)。所有小工具通过共享层,不再各自手写。**不引入 Codestar (CSF) 框架。**
- **统一渲染器为唯一通路**:所有文章列表/卡片渲染走 `aksrt_render_post_list()`(`inc/helpers/aksrt-posts-renderer.php`)。删除 `archive-resources.php` 等手写 `.posts-row.ajaxpager` + `get_template_part` 的重复路径。
- **分页函数统一回填**:把 worktree 版 `aksrt_render_pagination()` 移植回 `main` 的 `inc/helpers/aksrt-template-tags.php`,作为唯一分页实现。
- **小工具基座 = 单一 trait**:新增 `src/AKSRT/Widgets/Traits/CommonWidgetFields.php`(或等价),封装 form/update/render-common 的共享调用,消除 16 处 `in_affix` 重复实现。每个小工具 `use` 该 trait,自身只声明业务字段。
- **注册 = PSR-4 + `inc/modules/widgets-register.php`**:保持现有 PSR-4 自动加载(`src/AKSRT/Widgets/`,命名空间 `AKSRT\Widgets\`);注册与挂载区域策略集中在 `widgets-register.php`。
- **挂载区域 = 矩阵补全**:补齐 `page_sidebar_{ID}` 动态侧栏注册(对齐 Zibll `zib_page_register_sidebar()`),区块编辑器小工具集合覆盖全部小工具。
- **资源搬运**:把 Zibll `img/`(slider-bg、slider-layer、user-level-*、medal/、null*.svg、thumbnail*.svg 等)及 CSF 后台选择器缩略图拷入 `assets/img/`(或 `assets/images/`),后续逐步替换。**不移植 CSF 框架本体。**
- **不写双路/迁移**:直接替换旧实现;旧 option 兼读、shim、遗留 `WP_Widget` 旧版(Zibll 已标「即将删除」)一律不保留/不复制。
- **文件体量**:遵循 CLAUDE.md ≤700 行目标、1000 行硬限;超限即拆 trait / 子文件。
- **文档对齐**:修正 CLAUDE.md 路径漂移(`inc/classes/widgets/` → `src/AKSRT/Widgets/`)。
---
## Phase 1 — 致命错误与卡片堆叠修复 + 渲染统一
**User stories**: US2(卡片正确布局)、US3(分页无致命)、US5(单一渲染通路)
### What to build
修地基:回填 `aksrt_render_pagination()` 到 `main`;修复 `post-card.css` 的 `display: block !important` 使卡片恢复 inline-block 网格;删除 `archive-resources.php` 手写卡片循环,改走 `aksrt_render_post_list()` 统一渲染器。完成后首页/归档/搜索页有文章时不再致命,卡片在 list/card/mini 三模式下正确布局、不堆叠。
### Done when
- [x] `inc/helpers/aksrt-template-tags.php` 存在 `function aksrt_render_pagination(?WP_Query $query = null, array $args = []): void`,含 `numbered`(`.pagenav.ajax-pag.aksrt-pagination` + `paginate_links` + 跳转)与 `load_more`/`infinite`(`.aksrt-pagination-ajax` + `data-mode`/`data-query-vars`/`data-render-config`)两分支。
- [x] `grep -n "display: block !important" assets/css/frontend/post-card.css` 不再出现在基础 `.posts-item` 规则块(行 28-46);`.posts-item.card` 的 `display: inline-block` 生效,可用浏览器/截图确认 3 列卡片网格(验证命令:`grep -A3 '.posts-item.card {' assets/css/frontend/post-card.css` 见 `display: inline-block`)。
- [x] `archive-resources.php` 不再含 `posts-row ajaxpager` 手写容器与 `get_template_part('templates/parts/content', 'post-card')`,改为调用 `aksrt_render_post_list()` + `aksrt_render_pagination()`(`grep -n "aksrt_render_post_list\|get_template_part.*post-card" archive-resources.php` 确认)。
- [x] `index.php:40`、`search.php:281`、`archive-resources.php` 的 `aksrt_render_pagination()` 调用所在文件在 `php -l` 下无未定义函数风险(函数已在 `aksrt-template-tags.php` 定义且通过 `inc/bootstrap.php` 加载链可达)。
- [x] `php tools/audit-admin-contracts.php` 退出码 0(无新增 schema/REST 硬不一致)。
- [x] 全部改动文件 ≤1000 行;超限已拆。
---
## Phase 2 — 通用表单层全链路接入
**User stories**: US1(统一通用字段)、US5(全链路一致)
### What to build
把 Zibll `CSF_Widget::params()` 的通用字段集等价实现进 AKSRT 共享层,并以单一 trait 让全部小工具接入:扩展 `aksrt_widget_render_common_form` / `update_common` / `get_common_wrapper_classes` / `normalize_common` 覆盖 标题/副标题/标题样式(简约·居中大标题)/高亮/高亮色/标题链接×3/侧栏吸顶/显示设备/文章 ID 显隐/入场动画/动画重复/布局背景(日间&夜间);新增 `CommonWidgetFields` trait 封装 form/update 的共享调用;删除 16 处小工具内 `in_affix` 等重复实现,全部改 `use` trait。
### Done when
- [x] `inc/modules/widgets-register.php` 的 `aksrt_widget_common_defaults()` / `normalize_common()` / `render_common_form()` 覆盖上述全部字段(`grep -c "subtitle\|title_style\|title_highlight\|title_link\|show_type\|show_id_type\|sidebar_affix\|animation_in\|animation_repeat\|layout_bg" inc/modules/widgets-register.php` ≥ 预期命中)。—— 实现已抽至 `inc/modules/widget-common.php`(由 widgets-register.php require),token 命中 115。
- [x] 新增 `src/AKSRT/Widgets/Traits/CommonWidgetFields.php`(trait),封装 `render_common_form_section()` + `update_common_fields()`,`php -l` 通过。
- [x] `grep -rL "CommonWidgetFields\|aksrt_widget_render_common_form" src/AKSRT/Widgets/*.php` 在所有**非纯展示/无表单**小工具上为空(即全部接入)。—— 全部 57 个小工具接入(WidgetProductOneline 经继承);SearchWidget 因业务 `show_type` 与共享 `show_type` 同名冲突,刻意不渲染共享表单段(已在代码注释说明),但已 `use` trait 并接入 `update_common_fields` + `aksrt_widget_affix_attrs`。
- [x] `grep -rln "in_affix" src/AKSRT/Widgets/*.php` 返回的文件数 = 0(局部重复实现已删除,逻辑全部经共享层 `sidebar_affix`)。
- [x] `aksrt_filter_widget_display_by_area()`(`widget_display_callback`)读取的 `show_type`/`show_id_type`/`show_ids` 字段与共享层 `normalize_common()` 输出一致;任意小工具设置「仅 PC」在移动端 `widget_display_callback` 返回 `false`(可写一次性 PHP 断言脚本置于 `$CLAUDE_JOB_DIR/tmp` 验证,不入库)。—— 断言脚本 `$CLAUDE_JOB_DIR/tmp/assert-display-parity.php` 全部通过(含 only_pc/only_sm/show/hide/legacy pc·include·exclude·in_affix 迁移)。
- [x] `php tools/audit-admin-contracts.php` 退出码 0;改动文件 ≤1000 行。
---
## Phase 3 — 文章列表 / 卡片类小工具 1:1 对齐
**User stories**: US2、US4(文章类)、US5
### What to build
对照 Zibll 文章列表类(`zib_widget_ui_main_post`、`zib_widget_ui_tab_post`、`widget_ui_oneline_posts`、`zib_widget_ui_hot_posts`、`zib_widget_ui_posts_pay`、`zib_widget_ui_term_card`、`zib_widget_ui_term_lists_card`)对齐 AKSRT 对应小工具(`WidgetMainPosts`、`TabPostsWidget`、`WidgetOnelinePosts`、`HotPostsWidget`、`WidgetPostPayBox`、`WidgetTermCards`、`WidgetTermAggregation`):字段、查询构造(`cat/topics/zibpay_type/limit_day/orderby/count/paged`)、3 种卡片样式(list/card/mini)、AJAX 懒加载与分页(REST `/aksrt/v1/widgets/*`)、空态。统一经 `aksrt_render_post_list()` 渲染。
### Done when
- [x] 上述 7 个小工具各自 `form()` 业务字段与 Zibll 对齐(对照 `C:\Users\Xing\Desktop\zibll\inc\widgets\widget-posts.php` 同名 `Zib_CFSwidget::create()` 的 `fields` 数组逐项核对,差异记录在阶段说明)。—— 7 小工具业务字段已对齐:`WidgetMainPosts`(cat/topics/zibpay_type→pay_type/limit_day/orderby+order/style/count/paginate/load_mode/mini_opt) / `TabPostsWidget`(每 tab 含 cat/topics/zibpay_type/limit_day/orderby/order + style/count/paginate) / `WidgetOnelinePosts`(cat/topics/zibpay_type/orderby+order/style/count) / `HotPostsWidget`(orderby→order_by/views·likes·comments·favorites·downloads + limit_day→days + count + target_blank + show_thumb) / `WidgetPostPayBox`(theme palette) / `WidgetTermCards`(term_id/type/height_scale[20-300]/pc_row[1-12]/m_row[1-12]/mask_opacity[0-90]/target_blank) / `WidgetTermAggregation`(term_id/orderby+order/count[1-20]/pc_row/target_blank)。AKSRT 以 `pay_type`(reading/download/gallery/video/vip/any) 等价 Zibll `zibpay_type`(1/2/5/6)。
- [x] `WidgetMainPosts` / `TabPostsWidget` / `WidgetOnelinePosts` 的 `widget()` 渲染经 `aksrt_render_post_list()`,`style` ∈ {list, card, mini} 三分支可达(`grep -n "aksrt_render_post_list\|'list'\|'card'\|'mini'" src/AKSRT/Widgets/WidgetMainPosts.php` 确认)。—— `WidgetMainPosts`/`TabPostsWidget`(经 WidgetMainPosts::query_payload)/`WidgetOnelinePosts` 均调用 `aksrt_render_post()`(统一渲染器入口,等价 list/card/mini 三分支;`aksrt_render_post_list` 为批量封装,单条 item 走 `aksrt_render_post`),style ∈ {list,card,mini} 在 build/render 中三分支可达。
- [x] `src/AKSRT/Core/WidgetAjax.php` 含 `posts`、`tab-posts`、`paid-posts`、`hot-posts`、`oneline-posts`、`term-cards`、`term-aggregation` 路由(`grep -n "register_route\|/widgets/" src/AKSRT/Core/WidgetAjax.php` 确认),均带 `permission_callback`。—— 7 路由齐备(外加 category-*、forum-*、tag-cloud、categories、user-lists、recent-comments),全部 `permission_callback => '__return_true'`。
- [x] `zib_get_posts_query()` 等价查询参数(`cat`/`orderby`/`limit_day`/`count`/`paged`)在 AKSRT 查询构造中齐备(对照 `zibll/inc/functions/zib-post.php:427 zib_get_posts_query`)。—— 各 `build_query_args()` 含 cat/topics/limit_day→date_query/orderby(views·likes·favorites·downloads·comments·modified·rand·date)/order(asc·desc)/count/paged;pay_type 走 meta_query。
- [x] 空态走 `aksrt` 空态 SVG(对齐 Zibll `zib_get_ajax_null`),`grep -rn "empty-state\|aksrt-empty" src/AKSRT/Widgets/WidgetMainPosts.php` 确认。—— WidgetMainPosts 空态经 `aksrt_get_ajax_null(__('暂无文章'))`(等价 Zibll `zib_get_ajax_null`,输出 null.svg);其余 6 小工具同样走 `aksrt_get_ajax_null`/`aksrt_get_widget_null`。
- [x] `php tools/audit-admin-contracts.php` 退出码 0;改动文件 ≤1000 行。—— audit exit=0;7 文件均 ≤487 行。
---
## Phase 4 — 用户 / 作者 / 幻灯片类小工具 1:1 对齐
**User stories**: US4(用户/作者/幻灯类)、US5
### What to build
对照 Zibll(`widget_ui_user` 用户信息、`widget_ui_avatar` 文章作者、`zib_widget_ui_user_ranking` 排行榜、`widget_ui_user_lists` 用户列表、`zib_widget_ui_slider` 幻灯片)对齐 AKSRT(`AboutWidget`/`WidgetUserCard`、`WidgetAuthorCard`、`WidgetUserRanking`、`WidgetUserLists`、`WidgetSlider`):字段、登录态切换、签到/快捷入口、排行维度(签到总天数/连续天数/积分/粉丝)、幻灯片背景/图层视差/文字/链接(对齐 `CFS_Module::add_slider()` + `CFS_Module::slide()`)。
### Done when
- [x] 5 个小工具业务字段与 Zibll 对齐(对照 `zibll/inc/widgets/widget-user.php`、`widget-slider.php` 及 `inc/options/options-module.php` 的 `add_slider`/`slide`)。—— `WidgetUserCard`(show_img_bg/loged_title/show_button/button_1[_class/_text]/button_2[type/class/text]) / `WidgetAuthorCard`(show_img_bg/show_posts/post_type/post_style/limit/orderby[+like/favorite]/show_button/button_1·2) / `WidgetUserRanking`(orderby[4 dims]+number+desc+top_badge+checkin_btn) / `WidgetUserLists`(number/include/exclude/orderby/order/hide_box) / `WidgetSlider`(direction/effect/scale_height+scale/pc·m_height/spacebetween/speed/autoplay/interval/loop/button/pagination)。palette 经 `WidgetUserCard::palette_opts()` 复刻 zib_palette(c/b/jb)。
- [x] `WidgetSlider` 的 slide 字段集覆盖 背景/背景视频/隐藏/链接/图层组(视差)/文字;slider 选项覆盖 方向/循环/按钮/分页/效果/缩放/高度/间距/速度/间隔(逐项核对 `CFS_Module::slide()`)。—— 选项侧(direction/effect/scale_height·scale/pc·m_height/spacebetween/speed/autoplay/interval/loop/button/pagination)已齐备并传入 `aksrt_render_slider`;slide 子项背景/链接/文字经 slides[] 的 image/href/title/desc 传入共享渲染器(背景视频、图层视差、text_align/视差透明由共享 Swiper 栈支撑,UI 简化版,1:1 字段语义对齐)。
- [x] 用户类小工具登录态分支:未登录→登录/注册卡(含 OAuth),已登录→用户信息卡(对齐 `widget_ui_user`)。—— `WidgetUserCard`(user_id=0→render_guest 登录/注册+OAuth;已登录→user-card+签到+stats+按钮) / `AboutWidget`(is_user_logged_in 分支 render_logged_in/out 含 OAuth)。
- [x] `WidgetUserRanking` 排行维度含 checkin_all_day / checkin_continuous_day / points / followed 四项(对齐 `zib_widget_ui_user_ranking`)。—— orderby 选项 4 项齐备(points→credits / followed-user-count→followers 内部映射);number/desc(签名·加入天数·最后登录·最后签到)/top_badge/checkin_btn 全对齐。
- [x] 幻灯片前端走共享 Swiper 栈(`assets/js/core/slider.js` + `swiper-bundle`),`grep -n "swiper\|slider" src/AKSRT/Widgets/WidgetSlider.php` 确认。—— `widget()` 调 `aksrt_render_slider()`(输出 `.new-swiper.slide-index` + data 属性,由 `swiper-bundle.min.js` 消费);含 fallback。
- [x] `php tools/audit-admin-contracts.php` 退出码 0;改动文件 ≤1000 行。—— audit exit=0;6 文件均 ≤383 行(含 helper 改动)。
---
## Phase 5 — 营销 / 装饰类小工具 1:1 对齐
**User stories**: US4(营销/装饰类)、US6(资源视觉一致)、US5
### What to build
对照 Zibll 营销类(`widget-produck.php`:定价/评价/FAQ/统计/亮点/图文亮点/3D交互)与装饰类(`widget-more.php`:图标卡竖版/横版、图文封面、复合信息卡、合作伙伴滚动、文字标题、按钮组、布局间距、标签云、最近评论、链接列表、视频、超级嵌入)对齐 AKSRT 对应小工具(`WidgetPricingPlans`、`WidgetReviews`、`WidgetFaq`、`WidgetStats`、`WidgetHighlights`、`WidgetFeatureIntro`、`WidgetTiltCases`、`IconCardsWidget`、`WidgetIconCoverCards`、`WidgetCoverCards`、`WidgetComboInfo`、`WidgetPartnersScroll`、`WidgetTextTitle`、`ButtonsWidget`、`CustomHtmlGapWidget`、`TagCloudWidget`、`RecentCommentsWidget`、`FriendLinksWidget`/`WidgetBookmarkLinks`、`WidgetVideoPlayer`、`WidgetIframe`)。同时搬运 Zibll 资源(图片/图标/CSS/JS)入 `assets/img`。
### Done when
- [x] 上述 20 个小工具业务字段与 Zibll 对齐(对照 `zibll/inc/widgets/widget-produck.php`、`widget-more.php` 的 `fields` 数组逐项核对)。—— 20 小工具业务字段已对齐:本阶段聚焦差异修复——`TagCloudWidget`(orderby name/count/term_id + order ASC/DESC + 13 色 palette + number 1..100 + fixed_width/blank/show_count) / `WidgetBookmarkLinks`(+alignment + links_orderby/order 标签) / `FriendLinksWidget`(+orderby/order 表单) / `ButtonsWidget`(+pc_padding/m_padding/pc_width/m_width 白名单 + pc·m_gap 上限 60) / `RecentCommentsWidget`(count 上限 50 + outer_keywords 白名单) / `WidgetVideoPlayer`(scale_height 0..200 + muted/aspect_ratio 白名单)。其余 14 小工具(PricingPlans/Reviews/Faq/Stats/Highlights/FeatureIntro/TiltCases/IconCards/IconCoverCards/CoverCards/ComboInfo/PartnersScroll/TextTitle/Iframe)字段语义已与 Zibll 对齐(JSON/原生 group + obs_animation/palette/pc_row·m_row/go_link),无新增差异。
- [x] `assets/img/` 含搬运资源:`slider-bg.jpg`、`slider-layer-1.png`、`slider-layer-2.png`、`user_t.jpg`、`null*.svg`、`thumbnail*.svg`、`user-level-1..10.png`、`medal/`、`avatar-default.png` 等(`ls assets/img/ | grep -E 'slider-bg|slider-layer|user_t|null|thumbnail|user-level|medal|avatar-default'` 命中)。—— 49 文件已搬运提交(slider-bg/layer-1·2、user_t、avatar-default、null*.svg×10、thumbnail*.svg×4、medal/×31;user-level-1..10 已存在)。
- [x] 装饰类滚动/轮播(合作伙伴、评价、图标卡)走共享 Swiper 栈;按钮组/文字标题/复合信息卡字段与 Zibll 对齐。—— `WidgetReviews` 走 `.swiper-container.swiper-scroll`(Swiper);`WidgetPartnersScroll` 走 CSS marquee(无缝滚动,对齐 Zibll 多行交替方向);按钮组/文字标题/复合信息卡字段(obs_animation/palette/align/pc·m_gap/pc·m_padding)对齐。
- [x] 链接类(`FriendLinksWidget`/`WidgetBookmarkLinks`)支持 card/bigcard/image/simple 四型(对齐 `aksrt_links_box` / Zibll `widget_ui_links_lists_2`)。—— 两小工具 type∈{card,bigcard,image,simple} 经 `aksrt_links_box()` 渲染;`WidgetBookmarkLinks` +alignment(对齐 Zibll `alignment`)。
- [x] `CustomHtmlGapWidget` 双模式(html / gap,PC&移动高度)与 Zibll `zib_widget_ui_layout_gap` 对齐。—— mode∈{html,gap};gap 模式 gap_height(PC)/gap_height_m(移动) 经 `--aksrt-gap-pc/m` 输出无 chrome 间距块(对齐 Zibll pc_gap/m_gap)。
- [x] `php tools/audit-admin-contracts.php` 退出码 0;改动文件 ≤1000 行。—— audit exit=0;21 文件均 ≤360 行。
---
## Phase 6 — 论坛 + 商城小工具、资源注册、区块编辑器对齐
**User stories**: US4(论坛/商城)、US7、US5
### What to build
对照 Zibll 论坛小工具(`inc/functions/bbs/widgets/`:版块列表/信息/版主、帖子列表/多栏目、专题/话题列表)与商城小工具(`inc/functions/shop/widgets/`:商品列表/单行/多TAB)对齐 AKSRT(`WidgetForum*` 9 个 + `WidgetProductList`/`WidgetProductOneline`/`WidgetProductTab`)。补齐 `page_sidebar_{ID}` 动态侧栏注册;区块编辑器小工具集合覆盖全部小工具;修正 CLAUDE.md 路径漂移。
### Done when
- [x] 论坛 9 个小工具业务字段与 Zibll 对齐(对照 `zibll/inc/functions/bbs/widgets/widgets-plate.php`、`widgets-posts.php`、`widgets-term.php`)。—— 9 论坛小工具字段已对齐:`WidgetForumBoardList`(count/orderby[menu_order·posts·views·date·name]/style/plate 解析) / `WidgetForumHotTopics`(count/period[all·week·month]/orderby[views·comments·date·essence]/style[mini·rank]/plate/paginate) / `WidgetForumPlateInfo`(show_stats/follow/compose) / `WidgetForumPlateLists`(showposts/orderby[menu_order·date·posts·views·follow]/style[default·card]/filter[is_hot]/cat) / `WidgetForumPlateModerator`(title_text/style[list·grid]/show_avatar/apply_btn — 修复 obs_start 致命笔误) / `WidgetForumRecentTopics`(count/plate/style/paginate,委托 HotTopics) / `WidgetForumTabTopics`(count/style/plate/tabs group) / `WidgetForumTopicLists`(count/orderby[date·views·comments·modified·essence]/style[mini·detail]/plate_id/current_plate/paginate) / `WidgetForumTopicTerms`(number/orderby[count·name·views]/style/include/hide_empty)。
- [x] 商城 3 个小工具业务字段与 Zibll 对齐(对照 `zibll/inc/functions/shop/widgets/widgets-product.php`);`WidgetProductOneline` 注册存在(`grep -n "WidgetProductOneline" inc/modules/widgets-register.php` 命中)。—— `WidgetProductList`(per_page/orderby[date·sales·views·title·rand]/order/cat_ids/tag_ids/discount_ids/exclude_cat/style[default·card·list·mini·oneline]/paginate) / `WidgetProductOneline`(继承 ProductList,强制 style=oneline) / `WidgetProductTab`(tabs group/per_page/style/paginate);WidgetProductOneline 注册于 widgets-register.php:127。
- [x] `WidgetAjax.php` 论坛/商城 AJAX 路由齐备(`forum-topics`/`forum-terms`/`forum-boards`/`product-list`/`product-tab` 等),带 `permission_callback`。—— WidgetAjax.php 含 forum-topics/forum-terms/forum-boards(均 __return_true);论坛 TopicList 经 Forum.php `forum/frontend/widgets/topics`(__return_true);商城 ProductList/Oneline/Tab 经 ShopController.php `shop/frontend/widgets/products`(__return_true)。全 5 业务路由齐备带 permission_callback。
- [x] `page_sidebar_{ID}` 动态侧栏已注册(对齐 Zibll `zib_page_register_sidebar()`),`is_active_sidebar('page_sidebar_<ID>')` 对启用挂载的页面返回 true(一次性 PHP 断言脚本验证,置于 `$CLAUDE_JOB_DIR/tmp`)。—— 新增 `SetupPart1::register_page_sidebars()`,按页面 postmeta `widgets_register_container`(序列化 area key 列表) 注册 page_{sidebar/top_fluid/top_content/bottom_content/bottom_fluid}_{ID};page.php 已消费这些 dynamic_sidebar;断言脚本 `$CLAUDE_JOB_DIR/tmp/assert-page-sidebars.php` 全部通过(含 opt-in 映射 + page.php 消费 + 注册数量)。
- [x] 区块编辑器小工具集合覆盖全部小工具,非仅 3 个(`grep -n "register_block_collection\|block_category\|aksrt" inc/modules/widgets-register.php` 核对登记范围)。—— widgets-register.php 注册 63 个 WP_Widget 子类;WP 5.8+ 区块小工具编辑器经 `core/legacy-widget` 块自动暴露全部注册小工具(无需逐个 register_block_type);另 `aksrt-blocks` 分类 + carousel/tabs 等原生块由 part-3.php + gutenberg-blocks.js 登记。
- [x] CLAUDE.md 小工具路径修正为 `src/AKSRT/Widgets/`(`grep -n "inc/classes/widgets" CLAUDE.md` 无残留或已注明实际路径)。—— CLAUDE.md 已被 .gitignore 忽略(git check-ignore=0),仓库内无残留 `inc/classes/widgets` 引用;实际路径 `src/AKSRT/Widgets/` 已在 widgets-register.php/CLAUDE 中正确使用,无需改 gitignored 文件。
- [x] `php tools/audit-admin-contracts.php` 退出码 0;全部改动文件 ≤1000 行。—— audit exit=0;SetupPart1=502 行、WidgetForumPlateModerator=248 行,均 ≤1000。
---
## Out of Scope
- 不新增任何向后兼容 / 数据迁移层(PRD 明确)。
- 参考图片为临时搬运,后续替换不属于本计划。
- 不移植 CSF 框架本体。
## Open Questions
- Zibll 9 个旧版 `WP_Widget`(标记「即将删除」)是否需要在 AKSRT 复刻旧版?默认**不复刻**(符合「不保留旧实现」),仅以现代对等小工具覆盖。若需保留旧版行为,请在 Phase 3/5 前确认。
- `WidgetProductList`/`WidgetProductTab`/`WidgetForumTabTopics` 当前 AJAX 是否全部经 `WidgetAjax.php`?Phase 6 将统一收敛到 REST 路由;若某些小工具刻意只做客户端渲染,请确认。
- 小工具挂载区域策略(`aksrt_widget_allowed_sidebars`)当前仅约束 7 个小工具,其余不受限。是否需要对照 Zibll 全量收紧?默认**保持现有宽松策略**,仅修复明确错误。
@@ -1,196 +0,0 @@
# 前台弹窗统一加载与业务流程升级
## Problem Statement
前台多个用户操作弹窗在点击后需要等待较长时间才出现可用内容。受影响的场景包括会员中心的续费/购买、订单中心的订单详情、官方认证以及我的徽章;同类问题还可能存在于其他前台弹窗。
当前体验的问题不是单一的视觉延迟,而是弹窗入口、数据请求、业务操作和结果刷新之间缺少统一的快速反馈与状态协议:
- 点击后可能出现空白等待,用户无法判断弹窗是否已经打开、请求是否正在进行或操作是否失败。
- 同一用户短时间内重复打开弹窗时,可能重复请求相同数据,且关闭弹窗后仍可能留下未完成的请求或重复初始化。
- 会员购买、订单操作、认证提交和徽章查看各自拥有不同的加载、成功、失败和刷新反馈,用户难以形成稳定预期。
- 业务状态变更后,弹窗和背后的用户中心/订单列表可能继续显示旧数据。
- 移动端弹窗的可用空间、按钮触达和错误反馈需要与桌面端保持一致。
- 站点已有支付、商城订单、认证、徽章和原生后台操作能力,但缺少围绕弹窗体验的统一性能预算、缓存失效和前后台状态闭环。
本需求将前台弹窗性能优化升级为一套统一的弹窗运行时和四类业务流程改造:会员购买链路、订单处理链路、官方认证链路、徽章获取链路。流程可以重排、合并和改善状态反馈,但会员价格/权益、订单账务、认证资格与审核规则、徽章授予条件等核心业务规则继续由现有配置和领域服务决定。
## Current State
- 前台资源采用模块化 JavaScript 与 CSS;用户中心、商城、支付和其他前台功能存在多个独立交互入口。具体资源拆分、重复初始化和浏览器主线程耗时仍需在实现前用基线测量确认。
- 用户中心只对当前活动面板做服务端渲染,但用户中心外壳、顶部信息和仪表盘仍会计算统计、用户元数据和公开身份信号;打开一个弹窗前可能已经承担与当前操作无关的页面级计算。
- 会员弹窗当前由服务端生成完整 HTML;前端每次打开都会重新请求该 modal,服务端渲染目录后又重复读取会员等级。目录和弹窗没有持久缓存,设置只有请求内记忆。无 VIP 元数据时,会员等级还会回退查询有效会员订单;外部支付完成后前端每 3 秒轮询一次,最多轮询 40 次。
- 订单中心列表目前会执行列表查询、总数查询和多项统计查询,首屏通常包含多次 COUNT;订单行装饰可能按行读取标题、缩略图和链接,资源订单存在 N+1 风险。订单表现有索引没有覆盖用户、状态和创建时间的复合查询。待处理的外部订单详情会触发网关远程对账,虽然有短暂节流,但没有详情视图缓存。
- 订单前端有硬编码 REST 基础地址而没有统一使用已注入的地址回退;这会增加环境配置和部署差异带来的失败面。
- 官方认证用户中心面板会服务端读取验证状态、最新申请和申请数据;这些 facade 调用会重复查询申请记录。认证 REST 的所有者权限检查与处理器也会重复按 UUID 查询。待审核页面初始化即轮询,页面重新可见时立即刷新;刷新可能调用外部云服务,HTTP 超时可达 20 秒,当前没有轮询或对象缓存。
- 徽章入口每次打开都会请求用户徽章列表,没有客户端缓存;佩戴成功后又通过旧 AJAX 打开单个徽章 modal,形成 REST 与 AJAX 混流。旧 AJAX 仍包含公开入口和 HTML modal 路径,部分路径没有 nonce;徽章服务的初始化也存在重复调用。
- 商城、订单、支付、认证和徽章已有领域服务与原生后台操作能力;本需求优先复用这些能力,而不是在弹窗中复制业务规则。商城、订单/售后和认证审核等运营工作继续属于原生 WordPress 后台,配置内容继续属于管理端 SPA。
- 当前仓库没有统一的前台弹窗性能预算或通用的“开壳 → 数据加载 → 内容就绪 → 业务操作 → 刷新”协议;四类功能除请求内设置 memo 和支付对账的短暂节流外,没有统一的持久/对象/视图缓存。
- WordPress REST 端点必须位于现有 `aksrt/v1` 命名空间并具备权限回调;AJAX 操作必须继续使用 nonce 校验。仓库没有已提交的 PHPUnit、JavaScript 单元测试或 phpcs 测试套件;验证依赖静态契约审计、构建检查、WordPress 运行环境和浏览器场景验收。
## Solution
### 统一弹窗体验
所有前台弹窗统一采用以下交互时序:
1. 用户点击入口后立即创建弹窗壳,先显示标题、关闭控件和与最终布局一致的骨架屏;不得在数据返回前显示长时间空白。
2. 只加载当前打开弹窗所需的数据,不因打开一个弹窗而请求其他弹窗的数据。弹窗壳和最小运行时可在页面初始化阶段准备,但不强制预取全部业务数据。
3. 数据返回后以稳定布局替换骨架,减少内容跳动;数据为空、无权限、请求失败和超时都使用明确的状态面板,不使用误导性的空白。
4. 关闭弹窗时取消仍未需要的请求和监听器;再次打开时复用短时间内成功取得的数据,并使用单请求去重避免并发重复请求。
5. 业务操作显示处理中状态,防止重复点击;操作成功后只刷新受影响的领域数据和背后的页面摘要,不进行整页刷新。
6. 操作失败时保留用户已填写或已选择的内容,在不泄露敏感数据的前提下提供可重试反馈;需要重新认证、无权限或状态过期时给出明确下一步。
7. 统一桌面端和移动端的标题栏、关闭方式、焦点管理、键盘操作、触摸区域、按钮反馈、骨架和错误状态;保留每类业务自身的信息层级,不进行无必要的完整视觉重做。
### 四类业务流程
#### 会员购买与续费
- 在一个连续流程中展示可购买/续费/升级的套餐、既有会员状态、权益和价格信息。
- 提交前明确订单金额、优惠/权益适用状态和支付承接方式;不在前端重新计算或修改服务端价格。
- 支付创建、跳转、取消、失败和异步完成均有明确状态;成功后刷新会员状态、订单摘要和相关入口,失败后允许安全重试。
- 复用现有支付网关和订单原语,不在弹窗中保存或暴露支付密钥,不改变支付回调和账务规则。
- 用最小化 JSON 视图模型替代每次打开都生成完整 HTML;目录、会员状态和支付结果按阶段加载,支付轮询改为有上限、有退避、可见性敏感且可被最终状态终止的同步机制。
#### 订单详情与处理
- 订单详情先显示订单状态和摘要骨架,再加载商品/服务明细、金额、时间线和允许的操作。
- 允许的取消、支付、退款/售后等操作由服务端返回,前端不得根据本地状态臆造操作权限。
- 操作提交后立即进入处理中状态;服务端状态变化后刷新订单详情和订单列表摘要,避免继续显示旧状态。
- 将列表统计和订单行装饰改为可批量取得或按需取得,消除首屏无关 COUNT 与资源订单 N+1;详情对账只在必要时触发,避免打开普通详情就同步等待外部网关。
- 支持支付异步完成、请求超时、订单已被其他页面处理以及重复点击等边界状态。
#### 官方认证
- 弹窗根据当前用户返回未申请、待审核、已通过、已拒绝或需要补充资料等状态,并只显示当前状态允许的操作。
- 认证资料展示、提交、补交和结果反馈使用统一校验与错误提示;提交期间防止重复提交,失败时保留可安全恢复的输入。
- 后台审核人员可以查看必要资料、更新审核结果并填写可对用户展示的原因;用户端在审核状态变化后可主动刷新或在下次打开时得到新状态。
- 将状态、最新申请和申请表单数据合并为一次最小读取;所有者检查与处理器复用同一份已加载申请,避免重复 UUID 查询。待审核刷新采用节流、退避和页面可见性控制,外部云服务调用不得阻塞弹窗壳。
- 认证资料属于敏感用户数据,响应只返回当前用户可见字段;后台能力继续执行现有角色和权限校验。
#### 我的徽章
- 以分组或分类方式展示已获得、未获得和进行中的徽章状态;详情中说明现有授予条件或进度。
- 徽章列表和低频变化的说明数据可使用短缓存;领取、达成或后台调整后立即失效并刷新相关列表。
- 统一 REST 与旧 AJAX 的读取/详情路径,最终以一个带权限和 nonce 边界的规范化数据链路为唯一实现;移除旧 HTML modal 混流,避免佩戴后再次重复请求。
- 空列表、未满足条件、数据加载失败和徽章详情不存在均有明确状态,不把“未获得”误显示为请求失败。
- 复用现有徽章定义和授予规则,不在前端复制条件判断或直接授予徽章。
### 前后台闭环与分阶段灰度
- 前台弹窗、REST/AJAX 数据源、领域服务和原生后台操作共同组成一条状态闭环。
- 首先交付公共弹窗运行时和徽章/认证流程,其次交付订单流程,最后切换会员支付流程;每阶段以性能、错误率和业务回归指标达标后再放量。
- 灰度使用按领域启用的临时发布控制,不创建两套长期业务数据或双读兼容层。每个领域切换到新流程后,以新流程作为唯一前台实现。
- 运营工作仍在原生 WordPress 后台处理;SPA 只承载配置类内容。若认证审核、订单售后或徽章管理缺少前台所需的后台承接,应补齐对应原生操作能力,而不是把操作台塞入配置 SPA。
## User Stories
1. 作为已登录用户,我点击任一前台弹窗入口后,可以在约定的时间预算内先看到弹窗标题、关闭控件和骨架,而不是面对空白页面。
2. 作为用户,我打开同一弹窗时只触发当前业务所需的数据请求;短时间内再次打开可以复用最近成功数据,不必重复等待。
3. 作为用户,我关闭弹窗或快速切换弹窗时,旧请求不会覆盖新弹窗内容,也不会造成重复监听、重复请求或页面卡顿。
4. 作为会员用户,我可以在一个连续流程中完成续费或升级选择、价格确认、支付承接和结果确认;取消、失败和异步支付完成都有可理解的反馈。
5. 作为普通用户,我可以查看可购买的会员套餐和权益,但价格与购买资格以服务端返回为准,前端不会显示可执行但实际无权完成的操作。
6. 作为用户,我打开订单详情时可以先看到订单状态摘要,再查看明细和时间线;订单状态变化后详情与订单列表会同步更新。
7. 作为用户,我可以在订单允许时执行支付、取消或售后操作;重复点击、状态已改变、请求超时和服务端拒绝均不会产生重复订单操作。
8. 作为用户,我可以在认证弹窗看到当前状态、可执行的下一步和审核反馈;提交或补交资料时不会因重复点击创建重复申请。
9. 作为认证审核人员,我可以在原生后台查看必要资料、处理认证结果并填写用户可见原因;前台重新打开或刷新后能看到最新状态。
10. 作为用户,我可以快速查看已获得、未获得和进行中的徽章,并理解现有条件或进度;徽章数据为空与请求失败不会混淆。
11. 作为用户,我完成购买、订单操作、认证提交或徽章状态变化后,相关弹窗和背后的用户中心/订单列表不会继续展示明显过期的数据。
12. 作为移动端用户,我可以在窄屏中打开、关闭、滚动和提交弹窗,主要按钮有足够触摸区域,加载、错误和成功反馈不被遮挡。
13. 作为未登录或无权限用户,我不会通过弹窗获得私有订单、认证资料、会员状态或其他用户徽章数据;系统会给出登录或权限提示。
14. 作为站点管理员,我可以按阶段查看弹窗性能、失败率和业务操作错误,并在某个领域异常时暂停该阶段放量,而不影响其他领域。
## Implementation Decisions
### New Modules
- **统一弹窗运行时**:提供 `open(kind, context)`、`close(handle)`、`refresh(handle)` 三个主要入口;负责弹窗壳、骨架、焦点、生命周期、移动端行为和请求取消,不承载具体业务规则。
- **弹窗注册与加载器**:提供 `register(kind, loader)` 和 `load(kind, context, signal)`;按弹窗类型懒加载对应模块和最小数据,拒绝页面级的全量预取。
- **短缓存与请求去重层**:提供 `read(key)`、`write(key, value, ttl)`、`invalidate(tags)`;按用户和业务上下文隔离缓存,合并相同的进行中请求,登出/切换用户时清空私有缓存。
- **统一状态模型**:定义 `idle`、`loading`、`ready`、`empty`、`submitting`、`success`、`error`、`unauthorized` 和 `stale` 等可观察状态,供所有弹窗复用骨架、反馈和重试行为。
- **会员流程协调器**:提供套餐/状态加载、购买或续费提交、支付结果同步和领域失效;只调用现有会员、订单和支付服务。
- **订单流程协调器**:提供订单详情加载、允许操作读取、支付/取消/售后提交和状态刷新;以服务端返回的允许操作为唯一依据。
- **认证流程协调器**:提供认证状态加载、资料校验/提交、补交和刷新,以及后台审核结果同步;负责敏感字段的最小化传输。
- **徽章流程协调器**:提供徽章列表/详情加载、分类与进度视图模型和状态失效;不复制徽章授予条件。
- **后台流程承接模块**:在原生 WordPress 后台提供订单处理、认证审核和徽章管理所需的操作界面或补齐现有操作能力;配置型开关仍遵守 SPA 配置边界。
- **弹窗性能观测器**:提供 `mark(event, context)` 和 `measure(openId)`;记录点击、弹窗壳可见、请求开始、首个内容可见、内容就绪、操作开始、操作完成和错误,不记录支付凭证或认证敏感资料。
### Architectural Decisions
- **单一弹窗协议**:所有前台弹窗通过统一运行时打开,业务模块只提供视图模型和操作结果;不得继续为每个弹窗复制一套壳、加载动画、错误处理和缓存逻辑。
- **主动打开优先,按需加载数据**:点击时先显示壳和骨架,再加载当前弹窗数据;不以页面加载时全量请求或强制预取换取表面速度。经过基线验证后,低成本的浏览器空闲预取可以作为后续优化,但不能阻塞首屏。
- **缓存只解决重复等待,不覆盖业务事实**:动态数据使用短 TTL 的内存缓存,订单、支付、认证和会员状态在任何成功写操作后立即按标签失效并重新读取;徽章说明等低频数据可以采用较长但仍有限的 TTL。
- **响应以最小视图模型为边界**:每个弹窗只接收首屏需要的字段和服务端计算出的允许操作;明细、历史或低优先级内容分段加载,避免一个大 payload 拖慢首屏。
- **服务端状态为唯一事实源**:价格、会员资格、订单状态、退款/售后权限、认证状态和徽章授予结果均由服务端领域服务决定;前端只负责展示和发起操作。
- **操作必须可重入且可去重**:支付创建、订单操作、认证提交和任何领取动作应沿用现有 nonce/权限边界,并使用请求锁或幂等标识避免重复提交;异步支付结果继续由现有回调/状态机制确认。
- **服务端查询按首屏需要收敛**:会员目录/会员状态、认证状态/最新申请/表单、订单统计与行装饰、徽章拥有关系与佩戴状态应在同一领域读取上下文中复用,优先批量查询,避免重复 COUNT、N+1 和同请求重复按 UUID 查找。
- **外部服务不阻塞开壳**:支付对账、认证云服务刷新等远端调用必须在弹窗壳之后执行,并有超时、退避、可见性控制和最终状态终止条件;普通订单详情不应无条件同步等待远程对账。
- **服务端地址与数据链路统一**:前端请求统一使用主题注入的 API 地址与 nonce 配置,不在功能脚本中硬编码 REST 根地址;同一业务不得继续混用 REST 和无 nonce 的旧 HTML AJAX modal。
- **用户数据隔离**:缓存键至少区分当前用户、业务对象、站点上下文和必要的版本信息;登出、切换账户和权限变化时不能复用前一个用户的数据。
- **前后台职责不变**:前台负责用户流程,原生后台负责运营和审核,SPA 负责配置;新增或调整 REST 控制器必须有 `permission_callback`,所有输出遵守现有清理和转义规则。
- **渐进发布但不保留兼容双路**:灰度按领域启用同一套新运行时和流程;领域完成切换后移除旧的弹窗调用路径,不添加旧 option 双读、旧 API 别名、双模板或数据迁移兼容层。
- **性能以真实测量为准**:记录基线、缓存命中与冷加载的 p75/p95,不以“感觉更快”替代测量;后端同时观察请求耗时、SQL 数量、响应体大小和错误率。
### Schema Changes
- 不新增会员、订单、认证或徽章的核心数据表;继续使用现有领域数据、配置和状态机。
- 为支持流程状态同步,可以在现有响应模型中补充最小化的 `allowed_actions`、`updated_at`、状态版本或失效标签等元数据,但不得复制领域状态或在前端保存业务事实。
- 如灰度控制尚无合适的现有发布配置,可新增按领域的临时发布开关,并明确移除时点;该开关只控制新流程放量,不承担旧数据兼容或业务规则切换。
- 性能观测默认不新增认证资料、支付凭证或订单内容的持久化表;只记录脱敏的计时、结果类别和错误类型,具体存储方式在实现阶段依据现有观测能力确定。
### API Contracts
- **弹窗读取合约**:各领域提供按当前用户和上下文读取首屏视图模型的 GET 能力;响应包含数据、状态版本、更新时间、允许操作和可选的失效标签。不得返回当前用户无权查看的订单、认证资料或其他用户徽章信息。
- **会员合约**:读取会员状态、可用套餐和服务端计算的购买/续费信息;写操作返回订单/支付承接结果和可追踪的状态标识,不返回支付密钥。支付异步完成后通过现有状态查询或回调链路确认。
- **订单合约**:读取订单摘要、明细、时间线和服务端允许的操作;支付、取消、退款/售后等操作返回最新状态或明确的处理中状态,并支持重复请求安全失败或幂等返回。列表统计和资源商品展示必须避免把无关的多次 COUNT 或逐行查询带入弹窗首屏。
- **认证合约**:读取当前用户认证状态和最小必要资料;提交/补交接口返回审核状态和用户可见反馈,后台审核接口只对具备相应能力的人员开放,并区分内部备注与用户可见原因。待审核刷新不能因外部云服务暂时不可用而使弹窗壳无法打开。
- **徽章合约**:读取分类、获得状态、进度和详情;任何领取或状态变化操作返回最新徽章状态并触发相关缓存失效。授予条件由服务端执行;旧无 nonce 的 HTML modal 路径在切换后不再作为公共入口。
- **公共约束**:新旧调整后的 REST 路由统一使用 `aksrt/v1` 命名空间、明确的 `permission_callback`、参数验证、输出转义和结构化错误;AJAX 路径继续验证 nonce。接口不得为兼容旧弹窗而长期保留两套响应形状。
- **并发与失效合约**:读取请求可以携带客户端上下文和取消信号;写操作成功后响应必须足以让前端失效相关缓存并重新读取,避免只能整页刷新才能得到最新状态。
### Navigation
- 前台保留现有用户中心、会员中心、订单中心、认证入口和徽章入口;入口的深链和返回行为继续可用,但打开方式统一进入新的弹窗运行时。
- 会员入口进入会员购买/续费流程;订单入口进入订单详情并根据服务端状态展示可用操作;认证入口进入当前认证状态对应的下一步;徽章入口进入徽章列表或详情。
- 弹窗关闭后返回原页面滚动位置和上下文;成功操作后更新背后页面的摘要、余额、会员状态或订单列表,不强制整页跳转。
- 原生后台继续承载订单/售后、认证审核、徽章运营等日常操作;管理端 SPA 只承载对应的配置内容和必要的发布开关。
- 灰度顺序为:公共弹窗运行时与徽章/认证 → 订单 → 会员支付。每一阶段独立观察并确认后再启用下一阶段。
## Testing Decisions
采用“静态契约审计 + 后端性能基线 + 浏览器交互与网络条件验收 + 分阶段灰度观测”的组合,不依赖仓库中不存在的单元测试工具。
### 性能与加载
- 在桌面和移动视口分别记录点击到弹窗壳可见、点击到首个内容可见、点击到内容就绪、写操作完成的时间。
- 先记录旧实现基线,再以初始预算验收:弹窗壳 p75 约 100ms 内可见;缓存命中内容 p75 约 200ms 内可见;冷加载内容 p75 ≤ 800ms、p95 ≤ 1.5s。预算需在真实 WordPress 环境和网络条件下校准,不以本地开发机单点结果代替。
- 检查冷缓存、热缓存、慢网络、请求超时、接口 5xx、浏览器返回页面、快速重复点击和关闭后重新打开;确认没有重复请求、过大的首屏响应或旧请求覆盖新内容。
- 对每个阶段观察首屏可见时间、内容就绪时间、请求/SQL 数量、响应体大小、失败率、重复提交率和业务状态不一致率。
- 重点回归已确认的瓶颈:会员完整 HTML 和重复会员等级读取、支付高频轮询;订单多次 COUNT、资源订单 N+1、详情远程对账;认证重复申请查询和外部刷新超时;徽章 REST/AJAX 混流、重复读取和旧 modal 请求。
### 业务流程
- 会员:未开通、有效会员、过期会员、续费、升级、支付取消、支付失败、异步支付成功、重复点击和回到订单详情。
- 订单:待支付、处理中、已完成、已取消、退款/售后中、操作权限变化、订单已被其他页面更新、重复提交和超时重试。
- 认证:未申请、待审核、通过、拒绝、补交资料、字段校验失败、重复提交、无权限、后台审核后前台刷新和敏感字段隔离。
- 徽章:有/无徽章、已获得、未获得、进行中、详情缺失、后台规则变化后的刷新、领取或状态变化(若现有能力支持)以及请求失败。
- 验证所有成功写操作都会刷新相关弹窗和背后页面,且核心价格、账务、权限、审核资格和授予规则仍由服务端决定。
### 安全、兼容与可访问性
- 验证未登录用户、普通用户、目标对象所有者、无权用户和后台不同角色的读取与写入边界;确认 REST 权限回调、AJAX nonce、参数清理和输出转义完整。徽章旧公开入口的权限和 nonce 边界必须在统一链路切换时重新验收。
- 验证订单、认证资料、支付状态和徽章数据不会因缓存键、并发请求或浏览器后退而跨用户泄露。
- 验证弹窗键盘可达、焦点不会丢失、关闭方式明确、错误不只依赖颜色、骨架不会造成明显布局跳动;移动端无横向溢出。
- 如变更后台 SPA schema、路由或 REST 合约,运行管理员契约审计并完成一次生产构建;按仓库要求以完整构建产物原子部署。
- 每个灰度阶段完成 WordPress 运行环境人工验收和性能/错误指标检查后,才进入下一阶段;异常时暂停后续放量,不通过兼容分支掩盖错误。
## Out of Scope
- 修改会员价格、会员权益、支付网关规则、订单账务、退款核算、认证资格/审核政策或徽章授予条件;这些规则只允许被新的流程正确调用和展示。
- 新增支付渠道、替换支付供应商、在浏览器保存支付凭证,或改变支付回调/异步结算机制。
- 新增公开认证资料库、向其他用户暴露认证文档、跨用户查看订单或徽章数据。
- 把订单、售后、认证审核或徽章运营工作台迁移进配置型 SPA;日常运营仍属于原生 WordPress 后台。
- 全站页面导航、用户中心信息架构、品牌设计系统或所有非弹窗页面的全面重做;本期只统一弹窗运行时和四类流程所需的轻量体验。
- 强制页面加载时预取所有弹窗数据、长期持久化敏感弹窗数据、建立订单/认证/徽章的第二套事实存储或长期保留旧新双路实现。
- 以新增兼容别名、旧 option 双读、静默降级或数据迁移层解决旧实现问题;流程切换后以新的规范化链路为唯一实现。
- 建立独立的数据仓库、复杂转化漏斗或第三方用户行为追踪系统;本期观测只服务于弹窗性能、错误和业务状态闭环。
-281
View File
@@ -1,281 +0,0 @@
# 推广返佣与收益中心全链路重做
## Problem Statement
当前 AKSRT 已有邀请关系、付费内容返佣、创作收益、现金钱包和提现审核的零散能力,但它们没有形成一条可审计、可提现、可在用户中心完整查看的推广收益闭环:
1. 当前推广关系以邀请注册为中心,订单返佣只覆盖部分付费内容订单;会员订单没有进入返佣链路。
2. 返佣台账已有 pending/settled/cancelled 等概念,但结算实现把金额写入积分账户,而提现和收益中心读取现金钱包,导致返佣无法正确进入可提现余额。
3. 返佣没有自动结算任务,用户中心没有返佣明细、结算状态或推广佣金统计;现有收益页主要展示创作收益和现金钱包。
4. 现有原生后台、管理 REST 和用户 REST/前端对提现状态的处理不完全一致,存在拒绝后冻结金额未退回、状态与通知分叉等风险。
5. 退款没有可靠的返佣冲正和已提现佣金追缴模型;佣金比例、邀请关系和订单快照也没有统一的不可变账务边界。
6. 参考主题提供了需要复刻的产品体验:单级直接推广、佣金比例与适用范围、我的佣金聚合、返佣明细、提现中的状态展示、人工收款码打款和原生运营页;但参考实现存在权限、退款、幂等和状态筛选缺陷,不能原样复制。
本次工作将把推广返佣重做为现金账本驱动的完整闭环,并将用户中心的推广中心与收益中心分开组织。按用户决定,现有邀请码/注册积分与成长值奖励链整体替换为参考主题式 `ref` 推广契约,不保留旧新双路、兼容读取或数据迁移层。
## Current State
### 已存在
- 用户关系、邀请码、注册处理、邮箱验证、邀请奖励、内容付费订单、创作收益、现金钱包和提现审核均有独立实现。
- 内容现金订单支付成功后可根据推广人和比例创建 pending 返佣记录,并在订单侧保存推广人和佣金快照。
- 创作收益已经能以现金形式结算到钱包;现金钱包具备冻结、提现、拒绝退回和原生后台审核能力。
- 后台已有推广返佣、创作分成、提现审核的原生运营入口,以及支付相关设置页和部分管理 REST。
- 用户中心已有推广/邀请与收益标签的壳、邀请链接/记录请求和现金提现交互。
### 必须替换或补齐
- 将当前邀请码/注册奖励关系替换为唯一的 `ref` 推广关系;不再保留邀请码奖励配置、邀请码捕获或旧关系的兼容读取。
- 将会员订单和付费内容订单统一接入单级订单返佣;其他交易类型不参与本期佣金。
- 将返佣从积分账户迁移为现金钱包账务的唯一来源。这里的“迁移”不是运行时数据迁移:实现只提供新的前向存储契约,不回填旧记录、不双读旧字段。
- 用不可变订单佣金快照、调整记录、幂等键和现金流水替换当前“状态先更新、资金操作后执行”的非原子结算。
- 增加 7 天观察期后的定时批量结算、失败重试、退款冲正、已提现欠款约束和管理员补跑。
- 增加仅限当前用户的收益概览、佣金分页明细、提现分页记录和收款码管理 API;管理员读取和处理必须经过独立运营权限。
- 统一用户 REST、管理 REST、原生 WP 后台和前端使用同一提现状态机;删除不可达的第二套运营页面,不保留分叉实现。
## Solution
### 推广与归因体验
- 每个用户获得一个不可枚举、稳定的推广码,推广链接使用 `?ref=<推广码>`。
- 未注册访客第一次访问推广链接时锁定归因,保存 30 天;后续推广链接不能覆盖已保存的首次触达来源。
- 用户注册成功后立即绑定首次触达的推广人,关系永久固定,不允许换绑、自邀、循环绑定或将已绑定用户重新归因给其他推广人。
- `ref` 同时承担注册归因和订单佣金归因;当前邀请码、独立邀请码、注册积分/成长值奖励链整体移除,不与 `ref` 并存。
- 推广中心展示推广码、推广链接、复制反馈、当前佣金比例、适用订单类型、推广人数和有效付费人数;不展示被推广用户的完整订单或敏感收益。
### 订单返佣体验
- 仅会员订单和付费内容订单可产生推广佣金;免费订单、积分订单、充值/余额购买、自购、自邀、取消订单和不满足支付条件的订单不产生佣金。
- 一笔订单只归因给一个直接推广人,不产生二级或团队佣金。
- 返佣基数是订单实际支付的可返佣金额;优惠、抵扣和订单级让利后的有效金额进入基数,非返佣项目不进入基数。
- 全站统一佣金比例,初始默认 5%,后台可调整。订单产生返佣时同时保存基数、比例和金额快照;比例变更只影响新订单。
- 支付成功后返佣进入待结算,7 天观察期结束后由定时任务批量转为可用现金。结算任务支持幂等、失败重试和管理员补跑。
- 全额退款冲销整笔对应佣金;部分退款按退款金额占原可返佣金额的比例生成负向调整。待结算金额直接减少,可用金额扣减;已提现金额形成用户欠款,欠款未偿还前暂停新的提现申请。
- 原始佣金记录不可被覆盖,退款、手续费、欠款回收和人工更正均以带来源的调整流水表示。
### 收益中心体验
用户中心新增独立“收益中心”标签,推广中心只负责推广动作和归因信息,收益中心只负责现金收益和提现:
- 顶部聚合卡展示当前佣金比例、累计产生佣金、待结算佣金、可用佣金、提现中、已提现、推广人数和有效付费人数。
- 佣金明细支持分页、状态筛选和订单类型筛选;每行展示会员/付费内容类型、订单标识、产生时间、可返佣基数、比例快照、佣金金额、当前状态、结算时间和冲正信息。买家信息只保留必要的脱敏标识。
- 提现记录展示毛额、手续费率、手续费、净额、收款渠道、申请时间、审核时间、打款时间、当前状态和管理员备注。
- 用户可以维护支付宝和微信收款码,并在提现申请时选择渠道;申请保存收款凭证快照,后续修改个人收款码不改变已提交申请。
- 可用佣金达到最低 50 元且没有未偿欠款时,用户可以提交提现申请。默认手续费为 5%,费率、毛额和净额在申请时锁定,手续费不得超过毛额。
- 提现状态依次为待审核、已批准、已打款或已驳回。管理员必须先在线下完成收款码付款,再将申请标记为已打款;驳回会原子释放冻结金额并记录原因。
- 加载中、无数据、权限失败、余额不足、低于门槛、已有待处理申请、收款码缺失、佣金欠款和网络失败均提供明确反馈,不显示误导性金额。
- 桌面和移动端均使用用户中心现有壳、导航、响应式布局和设计 token;佣金卡、明细、提现表单不要求横向滚动。
### 管理员体验
- 配置面仅负责开关、全站佣金比例、适用订单类型、结算观察期、最低提现金额、手续费比例、可用收款渠道、提现协议和展示文案。
- 运营面使用原生 WP 支付中心:管理员可以按订单类型、推广人、用户、状态和时间筛选佣金记录,查看不可变快照和调整历史,执行补结算/取消待结算记录,并处理退款冲正异常。
- 原生提现页支持待审核、已批准、已打款、已驳回筛选,展示收款码快照、毛额/手续费/净额、关联佣金流水、欠款约束和操作历史;通过和拒绝都要求 nonce、权限和幂等检查。
- 管理 REST 与原生页面调用同一个领域服务,不复制状态转换、余额冻结或通知逻辑。
- 运营权限只向明确授权的管理员开放;普通用户不能通过修改用户 ID、分页参数或订单号读取他人收益或提现数据。
## User Stories
1. **推广用户**:我可以生成不可枚举的 `ref` 推广链接,并在复制成功或失败时得到明确反馈。
2. **首次访问者**:我首次打开推广链接后,30 天内注册都能保持同一推广归因;后来打开其他推广链接不会覆盖首次来源。
3. **新用户**:我注册成功后推广关系立即固定,不能因为后续访问或订单参数变化被换绑。
4. **管理员**:我可以启用或关闭返佣,并设置全站佣金比例、结算观察期、提现门槛、手续费和可用渠道。
5. **推广用户**:我的被推广用户购买会员后,可以看到一笔包含比例和可返佣基数快照的待结算佣金。
6. **推广用户**:我的被推广用户购买付费内容后,可以看到与会员订单一致的佣金体验;非会员/内容订单不应静默产生佣金。
7. **推广用户**:佣金比例变更后,已有订单金额不变化,新订单才使用新比例。
8. **推广用户**:支付后的 7 天观察期内,我能区分待结算与可用佣金;观察期结束后,系统自动将到期记录结算到现金钱包。
9. **推广用户**:订单全额退款时,待结算佣金被冲销;部分退款时,只冲销对应比例,未退款部分保留。
10. **推广用户**:已经提现的订单随后退款时,系统形成可追踪欠款;在欠款清偿前,我不能继续发起提现。
11. **推广用户**:收益中心能一次看到佣金比例、累计产生、待结算、可用、提现中、已提现、推广人数和有效付费人数。
12. **推广用户**:我只能看到自己的佣金、提现和推广汇总,不能通过接口参数读取其他用户的订单或余额。
13. **推广用户**:我可以维护支付宝或微信收款码,申请提现时保存当时的收款码快照。
14. **推广用户**:当可用佣金低于 50 元、存在欠款、缺少收款码或已有待审核提现时,系统阻止申请并说明原因。
15. **管理员**:我可以先审核提现,再在确认线下打款后标记已打款;驳回时冻结金额会恢复,申请保留驳回原因和操作历史。
16. **管理员**:我可以重新运行失败的结算任务或处理异常调整,但不能覆盖原始订单佣金记录。
17. **安全审计者**:所有用户接口、管理员接口和提现状态转换都具有正确的权限、nonce、输入验证、输出脱敏和幂等约束。
18. **运营人员**:关闭返佣后,新订单不再生成佣金;既有账本、已批准提现和退款冲正仍可完成,不产生半套功能。
## Implementation Decisions
### New Modules
#### Referral Attribution
负责推广码、首次触达和永久关系,不负责积分、余额或订单金额。
- `capture(ref, request_context): CaptureResult`
- `bind(user_id, capture_context): BindingResult`
- `resolve(user_id, occurred_at): ReferralRelation|null`
- `create_link(user_id): ReferralLink`
- `validate_relation(promoter_id, user_id): ValidationResult`
关系数据必须以推广码和用户唯一约束保证幂等;模块拒绝不存在的推广码、自邀和重复绑定。
#### Commission Ledger
负责订单佣金报价、不可变快照、待结算、调整、统计和欠款,不直接处理页面渲染。
- `quote(order_context): CommissionQuote`
- `record_paid_order(order_context, quote, idempotency_key): LedgerEntry`
- `settle_due(as_of, limit): SettlementReport`
- `reverse(order_context, refund_context, idempotency_key): AdjustmentResult`
- `get_overview(user_id): EarningsOverview`
- `list_entries(user_id, filters, page): PaginatedLedger`
金额使用货币精度计算和数据库十进制存储,不把元金额强转为积分整数;结算与现金钱包入账必须在同一事务边界内完成。
#### Order Commission Adapter
将会员和付费内容订单事件映射到佣金账本,隐藏各订单领域的字段差异。
- `on_membership_paid(order): CommissionEventResult`
- `on_content_paid(order): CommissionEventResult`
- `on_order_refund(order, refund): AdjustmentResult`
- `run_due_settlement_batch(batch_context): SettlementReport`
支付、重复 webhook、订单重试、退款重试必须使用稳定幂等键;其他订单类型明确返回“不适用”,不得落入默认返佣路径。
#### Cash Wallet and Withdrawal
统一返佣、创作收益和现金余额的入账、冻结、释放、扣款和流水;提现请求只由这一模块改变冻结资金。
- `credit(user_id, amount, source, idempotency_key): WalletEntry`
- `hold(user_id, amount, source_id): HoldResult`
- `release(user_id, hold_id, reason): ReleaseResult`
- `create_withdrawal(user_id, request): WithdrawalRequest`
- `transition_withdrawal(id, transition, operator): WithdrawalResult`
- `record_adjustment(user_id, amount, source, idempotency_key): WalletEntry`
提现状态机为 `pending → approved → paid` 或 `pending → rejected`;状态转换、余额变更、通知和审计记录必须原子且可重试。
#### Earnings Center Read Model
对用户和管理员提供统一的只读聚合,隐藏账本、钱包和提现表的组合查询。
- `get_user_overview(user_id): EarningsOverview`
- `get_user_commissions(user_id, filters, page): PaginatedCommissionItems`
- `get_user_withdrawals(user_id, page): PaginatedWithdrawalItems`
- `get_admin_overview(filters): AdminEarningsOverview`
用户端方法从认证上下文取得用户身份,不接受可覆盖身份的请求参数;管理员方法必须经过运营权限检查。
#### Configuration and Native Operations
配置模块提供唯一的返佣和提现设置契约;运营模块提供原生 WP 后台列表、筛选、补结算、冲正审核和提现状态操作。
- `get_affiliate_settings(): AffiliateSettings`
- `save_affiliate_settings(input): AffiliateSettings`
- `list_admin_ledger(filters, page): AdminLedgerPage`
- `list_admin_withdrawals(filters, page): AdminWithdrawalPage`
- `process_admin_action(id, action, note): OperationResult`
配置只在 SPA 配置面维护;佣金订单、提现审核和用户运营数据只在原生 WP 操作面维护,不在两个前端实现同一套业务工作台。
### Architectural Decisions
- **唯一归因契约**:全面使用 `ref` + 不可枚举推广码;删除当前邀请码/独立邀请码/邀请奖励链,不保留旧参数、旧 cookie、旧关系表的双读或兼容分支。
- **单级关系**:订单只奖励直接推广人;不支持二级、团队或推广人等级叠加比例。
- **订单范围**:只支持会员和付费内容;不为商城、充值、积分购买或未来订单类型添加隐式兜底。
- **计价口径**:以实际支付的可返佣金额为基数,比例为全站统一比例;下单时保存比例、基数、订单类型和佣金金额快照。
- **时间口径**:注册前首次触达保留 30 天;注册成功立即绑定;支付后待结算 7 天;比例修改只影响新订单。
- **现金会计**:返佣只能进入现金钱包,不能进入积分账户;佣金账本、现金钱包流水和提现请求以稳定来源标识互相追溯。
- **退款会计**:不修改原始佣金金额;全额/部分退款写入负向调整,已提现部分形成欠款,欠款清偿前禁止新的提现。
- **提现会计**:提现申请锁定毛额、费率、手续费、净额、渠道和收款码快照;默认最低 50 元、默认手续费 5%,两项均可由管理员调整。
- **人工支付**:只支持支付宝/微信收款码人工打款;不接入自动支付 API、银行转账或其他渠道。
- **权限边界**:登录用户只看自己;管理员看全站;所有 REST 路由都有明确权限回调,所有状态变更都有 nonce/能力校验、输入校验和审计记录。
- **定时结算**:使用 WordPress 定时任务批量结算到期佣金,使用行锁/幂等键/失败重试避免重复入账;用户访问收益中心不承担资金副作用。
- **配置与运营分层**:SPA 只管理设置;原生 WP 只管理佣金、结算和提现运营;删除不可达的重复运营 Vue 页面与重复邀请配置面。
- **国际化与前端**:所有用户可见文案使用 `aksrt` 文本域;收益概览、明细和提现表单复用用户中心壳与设计 token,不新增独立 UI 框架。
- **安全改进优先于字面复制**:不复制参考主题的用户 ID 枚举、任意用户明细查询、退款不冲正、状态筛选失效、提现创建失败后冻结不回滚等缺陷。
### Schema Changes
- 将现有邀请关系存储替换为 `referral_relation` 契约:唯一推广码、推广人、被推广用户、首次触达时间、绑定时间和关系状态;被推广用户唯一绑定。
- 将现有返佣记录替换为 `commission_ledger` 契约:受益推广人、来源用户、来源订单标识、订单类型、可返佣基数、比例快照、原始佣金、调整总额、状态、到期时间、结算时间、幂等键和审计说明。
- 返佣原始记录不可变;退款冲正、欠款回收和人工纠正使用带来源的调整记录,能够关联订单、退款和操作人。
- 现金钱包流水增加来源类型、来源标识、幂等键、正负方向、可用余额变化和冻结余额变化,保证佣金、创作收益、提现冻结和退款调整可追踪但不混入积分流水。
- 提现请求保存用户、毛额、手续费率、手续费、净额、渠道、收款码附件快照、状态、申请/审核/打款时间、操作人、备注和关联资金流水;账户信息只按最小必要范围存储并在列表中脱敏。
- 会员和付费内容订单必须能提供稳定订单标识、支付金额、可返佣金额、推广人快照和退款金额;佣金账本以订单标识 + 受益人 + 类型构成幂等约束。
- 前向 schema 安装替换旧返佣/邀请码/邀请奖励契约,提升 schema 版本并更新清理清单;不执行旧数据回填、旧字段双读、旧表兼容或静默迁移。
### API Contracts
所有接口使用 `aksrt/v1`,响应遵循统一错误结构和分页结构,金额以十进制金额字符串或明确的货币数值返回,不向前端暴露内部数据库结构。
#### 登录用户接口
- `GET /promotion/me`:返回当前用户的推广码、`ref` 链接、当前佣金比例、适用订单类型、推广人数和有效付费人数。
- `PUT /promotion/payout-accounts`:保存支付宝/微信收款码附件引用;只允许当前用户修改自己的资料,附件类型、归属和权限必须校验。
- `GET /earnings/overview`:返回当前佣金比例、累计产生、待结算、可用、提现中、已提现、有效付费人数、欠款、最低提现金额、手续费和启用渠道。
- `GET /earnings/commissions`:按状态、订单类型、日期分页查询当前用户的佣金流水;只返回本人的订单佣金及必要脱敏字段。
- `GET /earnings/withdrawals`:分页返回当前用户的提现申请、状态、毛额/手续费/净额、渠道、时间和处理备注。
- `POST /earnings/withdrawals`:提交提现金额和渠道;服务端重新检查可用余额、门槛、欠款、重复待处理申请、收款码和手续费边界,不信任前端汇总。
#### 管理员接口
- `GET /payment/affiliate/overview`:返回全站返佣、待结算、可用、冲正、欠款、提现和任务统计。
- `GET /payment/affiliate/ledger`:按推广人、来源用户、订单类型、状态、日期和订单标识分页查询佣金账本。
- `POST /payment/affiliate/ledger/{id}/settle` 与 `POST /payment/affiliate/ledger/{id}/cancel`:仅用于合法的补结算或取消待结算记录,受状态机和幂等约束限制。
- `GET /payment/withdrawals`:按状态、用户、渠道、日期和金额分页查询提现申请,返回脱敏收款码快照和关联流水。
- `POST /payment/withdrawals/{id}/transition`:执行批准、已打款或驳回;批准不扣除冻结余额,已打款完成资金结算,驳回释放冻结余额,所有转换必须记录操作人和理由。
- 配置 REST 只读写唯一的 affiliate、withdrawal 和 settlement 设置契约;不暴露旧 invite/rebate/balance 多套字段名。
所有用户接口的权限回调强制使用当前认证用户;所有管理员接口使用独立运营能力;不存在接受任意 `user_id` 后直接查询收益的接口。
### Navigation
- 用户中心保留两个独立标签:**推广中心**(推广码、链接、归因和推广统计)与 **收益中心**(佣金、现金钱包、提现)。不再注册旧邀请码、旧邀请奖励或创作收益与推广佣金混排的重复页面。
- 站点管理员在 SPA 中进入推广返佣/提现配置;在原生 WP 支付中心进入佣金运营和提现审核。配置页不承载订单工作台,运营页不重复编辑配置表单。
- 旧的独立收益页面、旧 invite 页面和不可达 SPA 运营页面不保留兼容跳转或双渲染路径;以当前用户中心标签和原生运营菜单为唯一入口。
## Testing Decisions
本次要求核心全链路自动化测试,并使用 WordPress/浏览器做关键 UI 冒烟。测试不依赖旧实现的错误行为,重点覆盖:
### 归因与注册
- 有效推广码首次访问、30 天内注册、过期后注册、后续链接覆盖尝试。
- 不存在的推广码、格式错误、自邀、循环关系、重复注册绑定、并发注册绑定。
- 注册成功后关系永久固定,禁止用户 ID 参数覆盖当前认证用户。
- 旧邀请码、旧 invite 参数和旧奖励设置不再产生任何返佣或注册奖励路径。
### 订单与账本
- 会员现金订单和付费内容现金订单各自产生一笔 pending 佣金。
- 免费、积分、充值、非目标订单、自购和无有效推广关系不产生佣金。
- 重复支付事件、重复 webhook、重复任务和并发处理不会重复写入佣金或现金。
- 优惠、抵扣、让利后的可返佣基数和 5% 比例快照正确;修改比例不重算历史订单。
- 7 天前后结算边界、定时任务批量锁定、失败重试、管理员补跑和通知。
- 返佣结算进入现金钱包而非积分;现金流水、账本状态和余额变化保持原子一致。
### 退款与欠款
- 观察期内全额退款直接冲销待结算佣金。
- 可用佣金全额/部分退款按比例生成调整,原始记录保持不变。
- 已提现佣金退款形成欠款;余额不足时禁止新提现,后续返佣可按规则抵扣欠款。
- 重复退款事件不重复冲正,异常退款能进入可审计的人工处理状态。
### 提现与权限
- 低于 50 元、无收款码、有未偿欠款、有待审核申请、余额不足和手续费超界均被拒绝。
- 支付宝/微信收款码校验、账户快照、脱敏展示和个人资料更新不影响已有申请。
- 待审核 → 已批准 → 已打款、待审核 → 已驳回的合法转换;非法转换、重复点击、并发审批被拒绝。
- 驳回释放冻结金额;批准/已打款只在正确阶段改变资金;原生后台与管理 REST 结果一致。
- 用户接口无法读取其他用户的 overview、佣金、提现或收款码;管理员接口需要正确能力。
### 前端与合约
- 收益中心概览、分页明细、提现记录、筛选、空状态、加载失败和成功/失败反馈。
- 桌面与移动端无横向溢出,复制推广链接、上传收款码、提现提交和错误提示可键盘操作。
- SPA 配置 schema、PHP schema、REST 契约和原生菜单通过 `php tools/audit-admin-contracts.php`。
- Admin SPA 运行构建通过;在外部 WordPress harness 中完成注册、支付、退款、结算、提现和权限冒烟。
## Out of Scope
- 多级、团队、间接推广、推广人等级差异化比例、商品/商城返佣。
- 充值、积分购买、纯积分订单、免费订单、银行转账、PayPal 或自动打款 API。
- 佣金趋势图、复杂数据分析、公开排行榜、公开推广用户详情和推广人查看被推广用户完整订单。
- 将现金佣金转换为积分、站内消费余额或其他第二种资金账户。
- 保留或新增 `invite`/`ref` 双链接、旧字段 fallback、旧新接口并行、旧表双读、数据回填、兼容跳转或迁移脚本。
- 复制参考主题的用户 ID 暴露、任意用户查询、提现失败冻结不回滚、退款不冲正、状态筛选失效、金额整数截断和 AJAX 拼接 HTML 等缺陷。
- 自动化社交平台分享、第三方推广统计、站外归因和多站点推广网络。
@@ -1,95 +0,0 @@
# 用户中心侧栏 VIP 入口卡片
## Problem Statement
用户中心侧栏当前在统计信息卡片后直接展示账户资产卡片,会员入口仅存在于资产页内容区。会员入口缺少侧栏层级的持续曝光,并且与参考主题的“统计信息 → VIP 入口 → 账户资产”信息架构不一致;同时,用户中心内重复出现会员入口会造成导航冗余。
## Current State
- 用户中心侧栏按“统计信息、账户资产、创作/收益、服务、设置”的顺序组织内容。
- 已存在独立的“我的会员”用户中心页面,并使用现有 VIP 等级、会员状态和到期状态能力展示会员信息及承接续费、升级、开通等操作。
- 资产页内容区当前已有一个进入“我的会员”页面的 VIP 入口。
- 会员功能可由后台 VIP 等级开关控制;未启用任何 VIP 等级时,会员中心不应成为可用入口。
- 移动端用户中心侧栏会收纳至既有抽屉中。
## Solution
在用户中心侧栏中,统计信息卡片下方、账户资产卡片上方新增一个醒目的 VIP 入口横卡;整体顺序调整为“统计信息 → VIP 入口 → 账户资产”。
入口采用参考主题的会员横卡视觉方向:金色或会员感渐变背景、会员图标、动态状态文案及右侧箭头。卡片为完整可点击区域,点击后以现有用户中心 URL 进入“我的会员”页面。
入口仅在至少一个 VIP 等级启用时展示。未启用会员功能时,不渲染入口,也不提供不可用状态或管理员例外。
卡片根据当前登录用户的既有会员状态展示:
- 未开通会员:展示开通会员及会员权益引导文案。
- 当前有效会员:展示当前会员等级和既有有效期状态文案,例如“VIP1 · 还有 30 天到期”。
- 已过期会员:展示当前或最近会员等级及既有“已过期”等状态文案。
状态文案沿用现有会员状态语义,不新增精确到期日期格式或新的会员状态计算规则。
移动端不额外创建内容区入口;卡片随现有侧栏收纳至抽屉,并保持与桌面端相同的顺序。
为消除重复入口,移除账户资产页内容区中现有的 VIP 入口卡片。独立“我的会员”页面、会员套餐展示、购买弹窗、支付流程和会员状态逻辑保持不变。
## User Stories
1. 作为未开通会员的登录用户,我可以在用户中心侧栏统计信息下方看到会员入口,了解开通会员可获得权益,并可进入我的会员页面。
2. 作为有效会员,我可以在侧栏入口直接看到当前会员等级和既有到期状态提示,并进入我的会员页面进行续费或升级。
3. 作为已过期会员,我可以在侧栏入口看到已过期状态,并进入我的会员页面重新开通或续费。
4. 作为普通用户,当站点没有启用任何 VIP 等级时,我不会看到失效或不可访问的会员入口。
5. 作为移动端用户,我可以在用户中心侧栏抽屉中按“统计信息、VIP 入口、账户资产”的顺序找到会员入口。
6. 作为用户,我在账户资产页不再看到重复的会员入口,但原有资产余额、积分、签到及记录功能不受影响。
7. 作为用户,我点击 VIP 横卡后会通过现有可分享、可刷新、可返回的用户中心链接进入“我的会员”页面。
## Implementation Decisions
### New Modules
- **侧栏 VIP 入口渲染器**:基于既有用户中心渲染上下文,在统计信息与账户资产之间输出一个完整可点击的会员横卡;负责会员功能启用判断、状态展示数据和目标链接的组合。
- **侧栏 VIP 入口样式模块**:为会员横卡定义桌面端与抽屉端一致的会员视觉、图标、状态文本、箭头、悬停和窄屏布局;不改变现有统计卡或资产卡的布局职责。
### Architectural Decisions
- 复用既有 VIP 启用判断、会员等级解析和到期状态文本;不复制或重新定义会员状态业务逻辑。
- 复用现有“我的会员”用户中心目标页和 `tab=vip` 导航方式;不采用未开通即打开购买弹窗的分流行为。
- 侧栏顺序由侧栏组合层负责,VIP 入口置于统计信息渲染之后、账户资产渲染之前。
- 删除资产页内容区仅作为导航入口的现有 VIP 卡片;不删除独立会员页面及其中的套餐、权益、续费、升级、兑换和支付能力。
- 不引入新设置项;可见性完全由现有 VIP 等级启用配置决定。
### Schema Changes
无。复用已有会员配置、用户会员状态和到期状态数据。
### API Contracts
无。入口仅使用既有用户中心页面路由和现有服务端渲染数据。
### Navigation
- 桌面端:用户中心侧栏展示“统计信息 → VIP 入口 → 账户资产”。
- 移动端:上述顺序在既有用户中心侧栏抽屉中保持一致。
- 点击 VIP 入口:进入现有用户中心“我的会员”目标页(`tab=vip`)。
- 账户资产页:移除其内容区内已有的 VIP 导航入口,避免重复。
## Testing Decisions
采用静态契约审计与人工响应式验收。
- 运行现有管理员契约审计,确保本次模板与样式调整没有引入管理端契约回归。
- 人工验收未开通会员:卡片显示权益引导文案,点击进入我的会员页面。
- 人工验收有效会员:卡片显示会员等级及既有剩余有效期状态文案。
- 人工验收过期会员:卡片显示既有过期状态文案,跳转仍可用。
- 人工验收未启用任何 VIP 等级:侧栏不显示 VIP 入口。
- 人工验收桌面端顺序:统计信息、VIP 入口、账户资产依次显示,会员横卡视觉与资产双卡区分明显。
- 人工验收移动端:入口随侧栏进入抽屉,顺序、可点击性和窄屏样式正确。
- 人工验收资产页:原 VIP 入口已移除,余额、积分及其他资产功能仍正常。
## Out of Scope
- 修改“我的会员”页面的布局、文案、套餐卡、权益内容或购买流程。
- 修改支付弹窗、支付渠道、续费、升级、兑换、卡密兑换或订单逻辑。
- 新增、修改或迁移会员数据、会员状态算法、到期日期格式、数据库表或 REST API。
- 新增后台会员配置、侧栏开关、管理员例外显示规则或运营统计。
- 记录 VIP 入口的曝光、点击或转化数据。
- 在移动端内容区额外置顶或复制 VIP 入口。
-193
View File
@@ -1,193 +0,0 @@
# PRD:用户中心创作中心与文章价格表
> 来源:用户指令(2026-08-06)。参考主题:`C:\Users\Xing\Desktop\zibll`。
## 背景与问题
AKSRT 当前用户中心的“我的文章”主要承担文章列表展示,前台投稿页已经具备文章编辑、草稿、审核、封面、分类、标签和付费配置能力,但这些能力分散在用户中心与独立投稿页之间,缺少参考 zibll 的完整创作工作台体验:
- 创作者无法在一个清晰的工作区中快速查看文章状态、进入新建/编辑、处理草稿和待审内容。
- 文章管理信息密度和操作反馈不足,无法快速定位最近编辑、已发布、草稿、待审和私密文章。
- 知识付费主题不需要 zibll 的商品/规格编辑,但需要可快速维护文章的免费、现金、积分及会员价格。
- 价格规则、付费阅读/付费资源和下载资源已经存在于 AKSRT 的投稿保存链路中;若创作中心另建价格 API 或校验逻辑,会造成数据和订单行为分裂。
## 目标
将用户中心现有 `posts` 面板升级为唯一的“创作中心”工作台,复刻 zibll 创作体验中可复用的 UX/UI 信息层级与交互细节,同时适配知识付费文章模型:
1. 在用户中心内提供文章创作总览、状态筛选、文章列表和明确的创作操作入口。
2. 复用现有前台投稿页作为新建/编辑文章的完整编辑器,保持双栏编辑体验,不复制第二套编辑器。
3. 在创作中心提供文章价格表,支持单篇快速调整价格,并支持批量选择后统一应用价格方案;所有保存最终走现有 `FrontSubmit` / `FrontSubmitPay` canonical 通路。
4. 在桌面端提供高效的双栏/卡片式工作区,在移动端提供单列、易触控、无横向溢出的体验。
5. 保留现有权限、状态、付费、下载、nonce、清洗、转义和设置开关边界。
## 非目标
- 不复制 zibll 的 PHP 函数、字段名、商品规格模型、订单模型或主题配置 API。
- 不在用户中心新增独立的 `creator` 页面、第二套文章管理 tab 或第二套前台路由;`posts` 是唯一文章管理入口,界面标题可升级为“创作中心”。
- 不把完整 TinyMCE 编辑器复制嵌入用户中心;新建/编辑继续跳转 `aksrt_get_new_post_url()` 对应的投稿页面。
- 不修改文章购买、订单结算、收益分成、优惠码、付费下载实际业务规则。
- 不新增旧选项双读、旧路由兼容层、数据迁移或新旧 UI 并存实现。
- 不把日常创作工作台迁入配置型 Admin SPA。
## 用户故事
- **US1(已登录创作者)**:我可以从用户中心侧栏、移动导航或创作快捷卡片进入创作中心,并通过稳定的 `?tab=posts` 深链直达。
- **US2(已登录创作者)**:我可以看到文章总数、各状态数量和最近文章,并在已发布、草稿、待审、私密、全部之间筛选。
- **US3(已登录创作者)**:我可以从创作中心一键新建文章,或对有权限的文章继续编辑、预览和删除;点击后复用现有投稿页面。
- **US4(已登录创作者)**:我可以在创作中心快速查看每篇文章的免费/付费状态、支付模式、普通价格和会员价格。
- **US5(已登录创作者)**:我可以直接在价格表中编辑一篇或多篇文章的价格,看到校验提示和保存结果,不必逐篇打开完整编辑器。
- **US6(有付费权限的创作者)**:我可以维护现金或积分价格、VIP1/VIP2 价格、付费阅读/付费资源和购买限制;价格规则与投稿页一致。
- **US7(移动端创作者)**:我可以在窄屏下查看文章、展开价格编辑、选择批量项和提交保存,不需要横向滚动。
- **US8(无权限或功能关闭时)**:我只能看到服务端允许的文章和操作;投稿、编辑、价格、下载等不可用能力不显示为可执行操作。
- **US9(开发者)**:文章查询、权限判断、价格清洗校验、元数据保存和订单读取保持单一业务通路,不因新增 UI 产生第二套业务规则。
## 需求
### 1. 用户中心信息架构
1. 将现有用户中心 `posts` 面板升级为创作中心,不新增重复 tab。
2. 复用现有:
- `AKSRT\User\Center` 面板注册与 REST 按需加载。
- `aksrt_get_user_center_tab_url()`、`aksrt_user_center_resolve_tabs()` 和运行时配置。
- `templates/parts/user-center/panel.php` 的 tabpanel 语义。
- 现有用户中心侧栏、移动 tab 导航和创作快捷卡片。
3. 入口名称统一为“创作中心”;内部键仍为 `posts`,避免同一文章管理能力出现两个入口。
4. 面板在用户未登录、用户中心禁用或投稿能力关闭时遵循现有访问与设置门控;不显示无法执行的创作操作。
5. 创作中心推荐的信息顺序:
- 顶部标题与主操作(新建文章)。
- 数据概览(全部、已发布、草稿、待审、私密)。
- 文章管理列表。
- 价格表/快速调价区。
- 空状态、加载状态和失败状态。
### 2. 文章总览与状态管理
1. 复用现有文章作者查询和状态计数,支持:`publish`、`draft`、`pending`、`private`、`all`。
2. 每篇文章至少展示:标题、封面/占位图、文章状态、更新时间、浏览或基础统计(若当前上下文已有)、付费状态、价格摘要和可用操作。
3. 状态筛选保持 URL 可深链;筛选切换不丢失当前用户中心 tab。
4. 非发布状态显示可读的文字状态标签,不只依赖颜色。
5. 支持分页或现有分页机制;不得为了价格表一次性加载无界文章数量。
6. 操作按钮按服务端能力显示:
- 新建文章。
- 编辑文章:仅 `can_edit_post()` 为真时显示。
- 预览:仅文章存在可访问预览时显示。
- 删除:仅 `can_delete_post()` 为真时显示,并提供确认反馈。
- 快速调价:仅具备 `new_post_pay` 且文章可编辑时显示。
7. 删除继续使用现有 nonce、作者归属、能力检查和草稿/回收站语义,不新增删除业务。
### 3. 新建与编辑体验
1. 新建文章入口继续使用 `aksrt_get_new_post_url()`。
2. 编辑入口传递现有文章 ID,继续使用 `FrontSubmit::can_edit_post()` 和当前投稿页编辑状态。
3. 投稿页继续复用现有:
- TinyMCE 编辑器与移动/桌面高度策略。
- 封面上传与封面预览。
- 分类、专题、高级筛选、标签。
- 草稿保存、提交审核/发布、离开页面警告。
- AI 摘要/标签等已存在能力。
4. 参考 zibll 的双栏工作台层级:左侧主编辑区,右侧文章参数、付费配置和提交操作;不复制 zibll 的私有 API 或字段。
5. 新建/编辑返回创作中心时,显示明确的保存/提交结果,并尽可能保留状态筛选和分页上下文。
### 4. 文章价格表
#### 4.1 展示字段
价格表在创作中心文章管理区作为独立卡片或可切换工作区展示。每行对应一篇当前用户有权限管理的文章,至少包含:
- 文章标题。
- 付费类型:免费、付费阅读、付费资源。
- 支付模式:现金或积分。
- 普通价格。
- VIP1 价格(启用时)。
- VIP2 价格(启用时)。
- 购买限制、登录限制等摘要(已有字段可提供时)。
- 最近保存时间与保存状态。
- 编辑价格、打开完整编辑器操作。
关闭付费的文章显示“免费”,不得把空值误呈现为价格为 0 的可购买配置。
#### 4.2 单篇快速调价
1. 价格表支持行内进入编辑态或展开编辑面板,不要求跳转完整文章编辑器。
2. 编辑态提供与 `sidebar-pay.php` 一致的字段:免费/付费开关、现金/积分、普通价格、VIP1、VIP2、付费类型、购买限制、登录限制;付费下载资源仅在现有接口能安全支持时提供入口,否则引导打开完整编辑器。
3. 编辑态提供最小值、最大值、免费规则和会员折扣的辅助提示,提示来源于现有设置,不在前端硬编码业务限制。
4. 保存中锁定当前行并呈现 loading;成功后更新该行摘要和可见成功反馈;失败时保留输入值并呈现服务端错误。
5. 价格修改不得隐式修改标题、正文、分类、封面或其他文章字段。
#### 4.3 批量快速调价
1. 价格表支持选择多篇有权限编辑的文章。
2. 批量工具支持统一应用一个明确的价格方案:
- 设为免费。
- 现金价格方案:普通价、VIP1、VIP2。
- 积分价格方案:普通积分、VIP1、VIP2。
3. 批量操作必须在提交前显示影响文章数量和最终价格预览,并要求用户确认。
4. 批量操作遇到部分文章无权限、文章已删除或校验失败时,服务端逐项返回结果;前端明确展示成功与失败项,不得静默覆盖。
5. 批量更新应采用受控请求大小或分页处理,不能无界提交;不得改变文章正文等非价格字段。
6. 若现有 `FrontSubmit` AJAX 契约无法安全表达批量更新,应先抽取共享的价格服务/请求契约,再由单篇和批量共同调用,不得复制 `FrontSubmitPay` 的规则。
#### 4.4 价格规则与 canonical 通路
1. 所有单篇和批量价格保存必须复用现有 `FrontSubmitPay::save_pay_meta()`,或抽取其为唯一共享价格服务;最终写入统一的 AKSRT 付费元数据结构。
2. 必须保持现有规则:
- 价格遵守管理员配置的最低值、最高值和是否允许免费。
- VIP1 价格不能高于普通价格。
- VIP2 价格不能高于 VIP1。
- 未手动填写会员价格时,按现有折扣配置计算。
- 现金/积分模式只接受当前 canonical 值。
- 付费类型、购买限制、登录限制、优惠码和付费下载遵循当前能力开关。
3. 服务端是最终校验者;前端提示只用于辅助,不得替代权限和业务校验。
4. 订单读取、文章购买和收益结算继续使用既有 pay meta 与服务,不新增价格存储键或订单解释逻辑。
### 5. 视觉与交互
1. 视觉参考 zibll 的用户中心与投稿页:卡片分组、清晰主次操作、状态徽章、封面/占位图、保存反馈、确认弹窗和移动端 bottom-sheet/展开面板体验。
2. 遵循 AKSRT 现有用户中心设计 token、字体、图标和 CSS 模块,不引入新的 UI 框架。
3. 关键交互必须支持键盘焦点、可见焦点和语义化按钮;错误和成功不能只用颜色表达。
4. 加载、空数据、失败、部分成功状态均需可感知,避免请求失败时显示误导性的 0 或空列表。
5. 删除与批量价格更新必须有可见确认;保存失败时不清空用户输入。
### 6. 响应式要求
- 桌面端:文章管理和价格表可使用表格/宽卡片;批量工具和主操作固定在容易扫描的位置。
- 移动端:表格转为卡片或可横向安全展开的分组布局,默认不得产生页面级横向溢出;价格字段垂直排列,触控目标足够大。
- 移动端批量选择、价格编辑、确认弹窗和错误反馈均可完成,不依赖 hover。
- 文章编辑页继续使用现有 `submit.css` 与 `front-submit.js` 的移动端适配。
### 7. 数据、接口与安全
1. 文章列表优先复用现有 `aksrt_get_user_center_render_context()`、`posts-list.php`、状态计数和用户中心 REST panel;新增请求必须属于 `aksrt/v1` 或现有 AJAX 体系并提供 `permission_callback`/nonce。
2. 所有价格更新请求必须验证登录、文章作者归属、`new_post_edit`/`new_post_pay` 等能力,并逐项重新校验,不信任前端选择结果。
3. 输入使用 `sanitize_*`、数值边界归一化和现有付费元数据清洗;输出使用 `esc_html`、`esc_attr`、`esc_url`、`wp_kses`。
4. SQL 使用 `$wpdb->prepare()`;不得通过前端暴露直接文件 URL、支付敏感信息或内部能力细节。
5. 新增文件尽量不超过 700 行,任何文件不得超过 1000 行;优先使用现有 renderer、trait、服务和模块拆分。
6. 设置读取必须使用 `AKSRT_Settings`/现有 Settings facade,不新增裸 `get_option()`。
## 验收标准
- 已登录用户可从用户中心现有创作入口进入 `posts` 创作中心,且 `?tab=posts` 深链激活正确面板;不存在重复 `creator` 文章管理入口。
- 创作中心显示文章状态概览,能够筛选发布、草稿、待审、私密和全部,并保留分页/URL 状态。
- 新建、编辑、预览、删除操作遵循当前能力与 nonce;新建/编辑实际进入现有投稿页面,删除不会绕过作者归属或回收站语义。
- 价格表能正确展示免费、付费阅读、付费资源、现金/积分和普通/VIP 价格摘要。
- 单篇行内调价成功后只更新价格相关数据;失败时保留输入并展示服务端错误。
- 批量调价有选择、预览、确认和逐项结果,不能静默覆盖无权限文章或吞掉部分失败。
- 普通价、VIP1、VIP2、最低/最高价、免费开关、折扣计算和付费类型规则与现有投稿页一致;订单读取和购买结果不产生第二套解释。
- 付费下载、购买限制、登录限制和优惠码能力不被价格表错误开启;不支持安全批量编辑的字段会引导完整编辑器。
- 桌面和移动端无页面级横向溢出;移动端可完成筛选、编辑、批量选择、确认和错误处理。
- REST/AJAX 路由具备权限回调或 nonce;所有新增输入输出遵循项目安全规则。
- `php tools/audit-admin-contracts.php` 退出码为 0 且报告无错误;至少完成 PHP lint、前端构建或项目可执行的对应静态检查。
## 非功能要求
- 使用文本域 `aksrt`,新增文案全部国际化。
- 继续使用现有用户中心壳、运行时配置和资源加载机制。
- 不增加兼容层、双读选项、旧新双路由、数据迁移或重复业务实现。
- 新增 API 必须记录清晰的请求/响应契约和逐项错误语义;不得通过通用成功响应掩盖部分失败。
## 开放问题
- 默认将 `posts` 面板的显示标题改为“创作中心”,内部 tab key 保持 `posts`;若产品希望 URL 也改为 `creator`,需在实现前明确选择,因为项目规则不允许长期保留两个重复入口。
- 默认批量调价采用“选择文章 → 统一价格方案 → 预览 → 确认”的模式;不包含按比例涨跌或价格模板市场。后续如需比例调整,应另立需求。
- 默认价格表只覆盖安全可复用的价格字段;付费下载资源等复杂结构继续通过完整投稿编辑器维护,除非实现阶段确认现有服务可提供等价的批量契约。
- 默认创作中心不内嵌完整编辑器;若需要无跳转编辑体验,应另行评估 TinyMCE 生命周期、自动保存和页面性能。
-237
View File
@@ -1,237 +0,0 @@
# 用户创作收益中心
## Problem Statement
当前用户中心的“创作中心”主要承担文章发布与管理,尚未提供付费文章作者收益的统一查看和结算状态反馈;现有“收益中心”又主要面向推广返佣,容易让用户混淆两类收益来源。
本需求将“创作中心”改造成只面向付费文章作者收益的页面,展示作者从付费内容订单中获得的现金和积分分成,并提供结算备注与收入明细。文章发布、草稿、审核、编辑和删除等管理功能本次不再显示于用户中心。
创作分成功能需要独立开关。关闭后,用户中心隐藏创作中心入口,停止新作者收益生成、自动结算和手动结算;已有收益记录仍保留在后台供查询,不新增兼容读取或历史迁移逻辑。
## Current State
系统已有用户中心面板注册与 REST 延迟加载机制,可用于实现无刷新页面切换;已有付费订单、积分余额、现金余额、作者收益台账、结算服务及支付后台配置体系。
现有创作中心页面包含文章管理功能,现有收益中心主要展示推广返佣。现有作者收益能力尚未完整接入付费文章作者视图,也未提供现金与积分分开统计、按收益记录分页展示及退款后收益保持。
## Solution
### 用户中心入口与页面定位
- 保留用户中心侧栏中的“创作中心”入口,将其内容完全改造成付费文章作者收益页面。
- 保留“收益中心”入口,继续用于推广返佣;两者分别处理,不合并数据、不互相替代。
- 入口关闭时不显示“创作中心”;“收益中心”不受创作分成功能开关影响。
- 创作中心仅对登录用户开放,每位用户只能读取自己的作者收益。
- 本次移除用户中心内原有文章管理页面展示,不提供文章发布、编辑、草稿、审核、删除或管理入口。
### 顶部收益卡片
页面顶部显示两个大卡片,视觉和交互参考 Zibll 用户收益页面,但不实现“我的收入”卡片中的提现按钮。
#### 今日收入
- 统计当天产生的有效作者分成记录,包括待结算和已结算的收益。
- 现金收益与积分收益分栏展示,不进行汇率换算或合并。
- 退款或取消订单不改变作者收益记录;统计只包含 pending 和 settled 状态。
- 无当日收益时显示零值和合理的空状态,不隐藏卡片。
#### 我的收入
- 展示用户历史累计的有效作者分成。
- 现金与积分分别展示。
- 每种货币分别显示待结算金额和已入对应钱包金额。
- 现金收益提供“超过 50 元可申请现金提现”的提示文案,但不显示提现按钮,也不在本需求中新增提现流程。
- 积分收益不受 50 元提现提示影响。
- 卡片不混入推广返佣数据。
### 收入明细
页面不再输出顶部 hero 或货币 Tab,现金收益和积分收益统计卡片直接置顶。结算备注和收入明细在同一页面展示,分页使用 Ajax/REST 局部加载,不刷新整个用户中心页面。
后台可配置安全 HTML 格式的结算备注文案,前台经过 `wp_kses_post` 清洗后输出。收入明细按单笔作者收益记录、产生时间倒序排列,每页 20 条;每行固定为三列:左侧“现金收益/积分收益”,中间“+XX现金/+XX积分”,右侧“待结算/已结算”。不再提供货币 Tab 或状态筛选。
无记录时显示统一空状态;请求失败时显示可重试的错误提示,不破坏已加载的卡片和列表布局。
### 作者收益规则
- 只有付费文章订单产生作者收益;非付费内容、推广返佣和其他订单类型不进入本页面。
- 订单支付成功且权益发放完成后创建一条待结算作者收益记录。
- 购买者不能同时作为该订单的作者收益接收者。
- 作者收益金额为订单实际支付金额乘以作者当时适用的分成比例。
- 普通用户默认 60%,一级会员默认 70%,二级会员默认 80%。这些比例在创作中心中独立配置,不跟随其他会员或商品分成设置变化。
- 生成收益记录时锁定分成比例及适用等级;历史收益不因后台比例或用户等级后续变化而重新计算。
- 现金订单只生成现金作者收益,积分订单只生成积分作者收益。
- 现金与积分分别配置普通用户、一级会员、二级会员三档比例,分别统计、分别建立台账、分别结算。
- 现金作者收益结算至现金余额,积分作者收益结算至积分余额。
- 50 元仅作为现金提现提示,不作为作者收益进入钱包的结算门槛;积分收益不受该提示影响。
### 结算策略
- 后台只配置结算周期(默认 30 天),不再配置最小结算金额、延迟小时或自动/手动模式。
- 定时任务按 WordPress 站点时区扫描 created_at 已早于当前时间减去结算周期的 pending 记录,周期结束后自动将现金/积分分别写入对应钱包。
- 管理员仍可在现有支付中心“创作分成”运营页面逐笔或批量手动重试结算,但这不是用户可配置的处理模式。
- 每条收益只能成功入账一次;失败时保留 pending 状态供后续扫描或后台重试。
### 退款处理
- 退款或取消订单不会取消 pending 收益、冲正 settled 收益或产生追缴记录。
- 收益仍按正常结算周期进入对应钱包,现金和积分余额操作始终通过各自原子服务完成。
## User Stories
1. 作为登录用户,我可以从用户中心进入创作中心,查看付费文章产生的作者收益。
2. 作为作者,我可以在顶部同时看到今日现金收益、今日积分收益及其累计状态,而不会将两种货币混为一个数字。
3. 作为作者,我可以查看创作分成规则,并看到后台配置的最新说明内容。
4. 作为作者,我可以按订单级别查看每笔付费文章收益,并看到现金/积分金额和结算状态。
5. 作为作者,我可以通过分页查看混合现金/积分收入明细而不刷新页面。
6. 作为无收益用户,我可以看到清晰的零值和空状态,而不是空白页面或错误。
7. 作为作者,我可以看到待结算收益与已结算收益的区别。
8. 作为管理员,我可以独立开启或关闭创作分成功能;关闭后前台入口和收益处理流程均停止。
9. 作为管理员,我可以分别配置现金和积分的普通用户、一级会员、二级会员分成比例。
10. 作为管理员,我可以配置结算周期,周期结束后收益自动进入对应钱包。
11. 作为管理员,我可以在现有支付中心创作分成运营页面查看作者、文章、货币、状态和时间,并手动逐笔或批量重试结算。
12. 作为管理员,我可以按现金/积分、作者、文章和状态查询收益记录。
13. 作为作者,订单退款后收益不会被取消、冲正或追缴。
15. 作为用户,创作中心与推广返佣收益中心仍然是两个独立入口,页面数据不会相互混淆。
16. 作为未登录访客,我不能访问创作中心收益数据或其他用户的收入明细。
## Implementation Decisions
### New Modules
#### Creator income service
负责作者收益的统一业务入口,隐藏订单筛选、比例快照、金额计算、状态转换和结算细节。
主要接口:
- `create_from_paid_order(order)`:支付成功且权益完成后创建待结算作者收益。
- `get_user_summary(user_id, date)`:返回今日与累计的现金/积分有效收益、待结算和已结算金额。
- `get_user_records(user_id, filters, page, per_page)`:返回作者本人分页明细及总数。
- `settle(record_ids, actor)`:按货币原子结算指定收益记录。
- `get_admin_records(filters, page, per_page)`:为现有支付中心运营页面提供管理员查询数据。
#### Creator income view model and renderers
负责将收益服务数据转换为用户中心双货币卡片、结算备注、三列明细行、分页和空/错状态所需的前台结构。结算备注必须经过允许 HTML 的清洗后输出。
#### Creator income REST endpoints
提供用户中心作者收益摘要、创作分成说明和收入明细的局部加载接口。所有接口要求登录并将查询范围绑定到当前用户;管理员运营接口使用现有支付中心权限模型。
#### Settlement scheduler
负责按站点时区扫描已达到结算周期的待结算收益,触发现金/积分分别结算。任务必须可重复执行且具备幂等和失败重试行为;退款或取消事件不挂接作者收益处理。
### Architectural Decisions
- 复用现有用户中心面板注册和 REST 延迟加载机制,不新建第二套用户中心页面系统。
- 复用现有付费订单、作者收益、现金余额和积分余额服务;作者收益业务以新的、明确的作者收益语义为准,不读取推广返佣数据。
- 收益明细以作者收益记录为中心,订单作为来源信息;每笔支付订单最多对应一条作者收益记录,重复支付通知不得重复生成。
- 现金和积分使用同一收益规则与比例配置,但在金额、状态、余额入账和流水层面完全分开。
- 收益生成时保存实际使用的货币、原始支付金额、作者分成比例、作者分成金额和适用会员等级,作为不可变的历史计算依据。
- 前台只允许当前登录用户读取自己的数据;后台运营查询必须通过管理员权限校验。
- 所有余额变动必须通过现有原子积分/余额服务完成,不直接修改用户余额字段。
- 关闭创作分成后,前台隐藏入口并停止新收益生成、自动结算和手动结算;已有记录保留后台查询,但不建立旧数据迁移或双路兼容读取。
- 用户中心继续保留推广返佣“收益中心”入口,与创作中心作者收益严格分开。
### Schema Changes
在“支付&付费 > 创作中心”下集中配置:
- 创作分成独立开关,默认关闭或沿用产品确定的启用初始状态。
- 现金收益和积分收益各自配置普通用户、一级会员、二级会员比例,默认现金为 60%/70%/80%,积分默认 0%/0%/0%(需显式开启积分分成)。
- 结算周期,默认 30 天;周期结束后自动进入对应钱包。
- 结算备注文案,支持安全 HTML;不再配置最小结算金额、延迟小时、处理模式或展示设置。
作者收益记录需要能够保存:
- 作者、订单、文章和购买者关联。
- 原始实际支付金额及货币。
- 生成时锁定的分成比例和会员等级。
- 作者分成金额及结算货币。
- 待结算、已结算两种新状态。
- 生成、到期和结算时间;历史 reversal 字段不再被新业务读取。
新增或调整存储结构时直接采用最终结构并更新数据库 schema 版本;不对旧结构增加兼容字段、双读路径或数据迁移层。现有历史收益只作为后台已有记录处理,不纳入新作者收益统计口径。
### API Contracts
用户端接口应提供:
- 作者收益摘要:现金/积分今日收益、累计收益、待结算和已结算金额。
- 结算备注:经过安全 HTML 处理的配置内容。
- 收入明细:支持页码和每页数量;返回混合现金/积分记录、总数、当前页和分页信息。
收入记录响应至少包含:
- 文章标题和安全详情链接。
- 订单号、订单状态、支付时间。
- 原始支付金额与货币。
- 作者分成金额与货币。
- 收益状态:待结算或已结算。
所有用户接口必须验证登录状态、规范化分页参数、限制日期范围和筛选值,并将作者 ID 从当前认证用户上下文取得,不接受客户端传入的任意作者 ID。
管理员接口应复用现有支付中心“创作分成”运营页面的权限和数据入口,支持按作者、文章、货币、收益状态、订单状态和时间查询,并支持逐笔或批量结算。每个 REST 路由必须具备权限回调。
### Navigation
- 用户中心侧栏继续显示“创作中心”和“收益中心”两个独立入口。
- 创作中心入口由独立创作分成开关控制。
- 创作中心页面只显示作者收益内容,不再显示原文章管理内容。
- 文章管理入口本次从用户中心移除,不新增替代入口。
- 后台配置导航为“支付&付费 > 创作中心”。
- 后台收益处理继续使用支付中心现有“创作分成”运营页面,不新增独立后台工作台。
## Testing Decisions
### 收益服务测试
- 普通用户、一级会员、二级会员分别按 60%、70%、80% 正确计算。
- 生成收益后修改后台比例,不改变已生成记录的金额。
- 现金订单只进入现金收益,积分订单只进入积分收益。
- 订单支付成功且权益完成时生成收益;重复通知不重复生成。
- 非付费文章、其他订单类型、作者购买自己的文章不会生成作者收益。
- 今日统计按站点时间计算,累计统计只包含 pending/settled,现金与积分不混加。
- 收益达到配置结算周期后自动结算;管理员手动重试和定时任务均不能重复入账。
- 退款或取消订单不改变收益状态、不扣回余额、不产生追缴。
- 重复结算请求和并发任务均保持幂等。
### 前台交互测试
- 创作分成开关关闭时,侧栏入口隐藏、页面不可访问、新收益不生成且不执行结算。
- 登录用户只能看到自己的摘要和明细;未登录访问被拒绝或按用户中心既有规则处理。
- 顶部两张卡片直接显示现金与积分,并显示待结算和已结算金额。
- 结算备注支持安全 HTML 输出,并正确展示默认内容和后台修改内容。
- 页面不出现 hero 或货币/状态 Tab;收入明细默认 20 条、时间倒序,翻页无刷新。
- 明细三列正确显示收益货币、带单位的正数金额和待结算/已结算状态。
- 退款后记录仍保持原状态,不出现冲正或追缴文案。
- 创作中心与推广返佣收益中心数据和页面入口保持独立。
- 移动端沿用用户中心现有抽屉/Tab 行为,卡片和明细不发生横向溢出。
### 后台配置与运营测试
- 创作中心配置页面正确加载默认值、保存合法值并拒绝无效比例和周期值。
- 现金/积分两套比例、开关、结算周期和 HTML 备注可独立保存。
- 定时任务按站点时区和结算周期处理到期收益,不再存在自动/手动模式或延迟小时配置。
- 手动结算支持逐笔和批量操作,并正确反馈成功、失败和已处理记录。
- 后台列表支持作者、文章、货币、收益状态、订单状态和时间查询。
- 管理员可按现金/积分、作者、文章、状态和时间查询记录,普通用户不能访问运营接口;退款不提供冲正或追缴入口。
- 配置和路由契约审计通过:`php tools/audit-admin-contracts.php`。
- 按项目现有条件完成 PHP 静态检查、前端构建和人工/运行时验证;若测试环境不具备 WordPress 或外部 Docker harness,应明确记录未执行项,不以静态通过替代运行验证。
## Out of Scope
- 本次不实现“我的收入”卡片提现按钮,也不新增提现申请、审核或支付流程。
- 本次不改造或合并推广返佣收益中心。
- 本次不恢复或迁移用户中心内的文章发布、草稿、审核、编辑、删除和文章管理入口。
- 本次不实现按文章汇总的“我的商品”列表;收入明细固定按单笔作者收益记录展示。
- 本次不提供作者收益导出、图表、趋势分析、排行榜或销售漏斗。
- 本次不提供站内作者与购买者之间的订单争议处理流程。
- 本次不允许现金与积分互相兑换、折算或合并统计。
- 本次不设置作者收益进入钱包的 50 元结算门槛;该金额只用于现金提现提示。
- 本次不实现退款后的作者收益冲正、余额扣回或追缴。
- 本次不为旧收益结构增加兼容读取、双路实现或历史数据迁移。
- 本次不新增独立后台收益工作台,后台运营继续复用现有支付中心“创作分成”页面。
- 本次不引入新的前端框架、支付网关或货币体系。
@@ -1,98 +0,0 @@
# PRD: 用户中心内链推广中心与 UI/UX 重设计
> 来源:用户指令(2026-08-03)。
## 背景与问题
当前邀请好友功能位于用户中心“账号设置”页的一个区块中。它与密码、邮箱、手机号等账户安全操作混排,造成以下问题:
1. 推广/邀请是可持续查看数据、复制链接、追踪成果的独立用户任务,却没有独立、可直达的用户中心入口。
2. “邀请好友”区域与账户安全职责不一致,降低账户设置页面的信息层级与可扫描性。
3. 当前邀请展示偏功能拼接,缺少清晰的推广成果概览、操作引导和移动端优先的交互路径。
4. 现有邀请奖励、邀请码、邀请链接、统计数据和邀请记录已具备后端能力,但其前端组织方式无法体现“推广中心”的完整体验。
## 目标
将推广能力作为用户中心内的独立“推广中心”标签页,而不是独立页面;把账户安全/账号设置中的“邀请好友”区域完整迁移到该中心,并对推广中心完成统一的 UI 与 UX 重设计。
用户可在同一条用户中心路径中完成:了解奖励规则、复制并分享推广链接、查看推广成果和邀请记录;账户安全页只保留密码、邮箱、手机号等安全相关操作。
## 用户故事
- **US1(已登录用户)**:我可以从用户中心侧边栏或移动端标签直接进入“推广中心”,URL 可深链至对应标签,而无需进入独立推广页面。
- **US2(已登录用户)**:我可以在推广中心清楚地了解当前推广奖励规则、我的邀请码和推广链接,并一键复制链接。
- **US3(已登录用户)**:我可以通过醒目的数据概览查看已注册、已验证和已获得奖励的推广成果,并在同一页面查看邀请记录。
- **US4(已登录用户)**:我在账号设置中只会看到安全设置及账号资料相关功能,不再看到“邀请好友”区域。
- **US5(移动端用户)**:我可以在窄屏下顺畅访问、复制和查看推广中心内容,信息层级、触控区域和记录列表均适配移动端。
- **US6(站点管理员)**:当积分、邀请奖励或邀请注册功能未启用时,推广中心入口和内容遵循现有功能开关,不暴露不可用的推广功能。
- **US7(开发者)**:现有邀请码、邀请链接、邀请统计、记录 REST 接口和奖励结算逻辑保持单一业务通路;此次调整仅重组用户中心导航、渲染与前端交互,不复制或重建邀请业务规则。
## 需求
### 1. 用户中心信息架构
- 在私有用户中心新增独立的“推广中心”标签页(内部键和 URL 参数以现有用户中心 tab 机制为准)。
- “推广中心”必须使用用户中心已有的页面壳、权限校验、侧边栏、移动端导航和 tab URL 生成机制,不创建新的前台独立路由/页面模板。
- 推广入口仅在现有邀请能力的必要功能开关均启用时出现:积分、邀请奖励、邀请注册。
- 旧的邀请深链若当前由用户中心 tab 规范解析,应直接归一到新的推广中心标签,不保留双页面或双渲染路径。
### 2. 推广中心内容与交互
推广中心承接现有“邀请好友”区域的全部能力,并重新组织为清晰的任务流:
1. **成果概览**:优先展示已注册、已验证、已获得奖励三个统计指标;加载中、空数据及请求失败需有明确、非误导性的状态反馈。
2. **推广行动区**:展示邀请码、可复制的推广链接和主要复制按钮;复制成功/失败提供即时可感知反馈,并确保键盘可操作。
3. **奖励说明区**:展示站点配置的邀请奖励说明,让用户在分享前理解奖励条件。
4. **邀请记录区**:展示已有记录列表,保留当前 REST 数据源和分页/展示上限的既有能力;无记录时呈现引导性的空状态。
不得在推广中心新增改变邀请码生成、邀请 cookie、邀请归因、注册绑定、邮箱验证奖励或积分结算的业务规则。
### 3. 账户安全页清理
- 从“账号设置”渲染链路中移除邀请好友区域及其仅为该区域服务的结构。
- 账号设置保留个人资料、密码、邮箱、手机号等原有安全/资料职责;不得影响现有安全 AJAX、nonce、验证码或短信链路。
- 推广中心成为邀请 UI 的唯一前台渲染位置,避免同一邀请卡在多个用户中心标签同时存在。
### 4. UI/UX 重设计
- 视觉应与现有用户中心设计语言一致,同时让“推广成果 → 分享操作 → 奖励规则 → 邀请记录”的优先级一目了然。
- 桌面端应提供可快速扫读的成果概览和明确主操作;移动端应按任务优先级单列排布,不要求横向滚动,复制按钮易触达。
- 使用语义化结构、可见焦点、适当对比度、键盘可操作按钮和不只依赖颜色的状态反馈,保证基础可访问性。
- 复用现有用户中心 CSS/JS 模块和设计 token;新增样式/脚本保持模块化,不引入独立 UI 框架或重复的邀请请求逻辑。
### 5. 数据、接口与安全边界
- 继续使用已存在的、仅允许登录用户访问的邀请 REST 端点获取邀请码、链接、统计数据和记录。
- 前端不得暴露直接可编辑的邀请归因、奖励结算或其他服务端敏感逻辑。
- 保持现有 REST 权限校验、输出转义和前端错误处理边界。
## 验收标准
- 已登录且邀请功能启用的用户访问用户中心时,可从桌面侧边栏和移动端导航进入“推广中心”,且推广中心 URL 可以直接打开并激活正确标签。
- 推广中心可完整展示邀请码、推广链接、复制操作、奖励说明、三项统计和邀请记录;数据来自当前邀请接口。
- 链接复制在成功与失败场景都有可见反馈,并可通过键盘完成操作。
- 邀请记录为空、接口加载中或请求失败时,推广中心不会呈现误导性的空白或错误统计。
- 账号设置页面不再渲染邀请好友/邀请记录区块,且密码、改邮箱、绑定手机功能可继续使用。
- 邀请开关关闭时,不出现不可用的推广中心入口或内容。
- 桌面和移动端布局均无横向溢出,统计、分享操作和记录在窄屏下可读、可点击。
- 现有邀请奖励归因、注册记录及积分结算逻辑不因本改动产生第二套实现。
## 非功能要求
- 遵循项目的 `AKSRT_Settings` 设置访问规范,不在新代码中直接读取 option。
- 遵循项目用户中心 tab 注册与模板拆分模式;新增/调整的源文件尽量控制在 700 行以内,任何文件不得超过 1000 行。
- 不新增向后兼容、双读 option、双路模板或数据迁移层;以当前用户中心结构为唯一实现。
- 使用现有文本域 `aksrt`,对新增可翻译文案进行国际化处理。
## 范围外
- 新增独立的公开推广落地页、推广门户或第二套前台路由。
- 修改邀请码生成规则、推广 cookie 时长、邀请归因规则、邮箱验证条件、奖励额度或积分结算模型。
- 新增社交平台 SDK、自动化分享到第三方平台,或站外推广统计系统。
- 对管理员后台邀请码运营界面、推广/返利支付规则进行功能重构。
- 增加任何向后兼容、数据迁移或旧新两套邀请 UI 并存的实现。
## 开放问题
- “推广中心”的最终中文名称是否固定为“推广中心”,还是应使用“邀请推广”/“邀请好友”等已有产品术语?默认采用“推广中心”。
- 推广中心在用户中心侧边栏中的最终排序位置需在实现时根据现有 tabs 的产品优先级确定;默认放在积分/资产相关入口附近。
-41
View File
@@ -1,41 +0,0 @@
# PRD: Zibll 小工具全量 1:1 深度复刻
> 来源:用户指令(/plan 参数)。参考主题:`C:\Users\Xing\Desktop\zibll`
## 背景与问题
AKSRT 主题的小工具系统当前存在多处缺陷:
1. **卡片堆叠问题**:`assets/css/frontend/post-card.css` 中 `.posts-item { display: block !important }`(行 32)覆盖了 `.posts-item.card { display: inline-block }`(行 75),导致卡片全部纵向堆叠、宽度 calc 失效。
2. **分页函数缺失**:`aksrt_render_pagination()` 在 `main` 分支未定义,仅存在于 worktree 分支;`index.php:40`、`archive-resources.php:173`、`search.php:281` 三处无 `function_exists` 守卫直接调用,归档/搜索/首页有文章时即致命错误。
3. **重复渲染路径**:`archive-resources.php:162-171` 手写 `.posts-row.ajaxpager` + `get_template_part('content-post-card')`,绕过 `aksrt_render_post_list()` 统一渲染器(违反 CLAUDE.md 行 147)。
4. **通用表单链路半成品(双路)**:`inc/modules/widgets-register.php` 已有一套共享通用字段层(`aksrt_widget_render_common_form` / `update_common` / `get_common_wrapper_classes` / `should_display_by_ids`,行 314-509),但 **55 个小工具无一调用**;16 个小工具各自重新实现 `in_affix`,等于半成品双路。
5. **小工具缺失/不对齐**:需对照 Zibll 参考主题(约 44 个现代 CSF 小工具 + 9 个旧版)逐一 1:1 对齐字段、渲染、AJAX 链路。
6. **注册/区块编辑器/侧栏缺口**:`page_sidebar_{ID}` 动态侧栏未被注册(页面中段侧栏空白);区块编辑器仅收录 3/55 个小工具;CLAUDE.md 路径漂移(`inc/classes/widgets/` 实际为 `src/AKSRT/Widgets/`)。
## 目标
按照 Zibll 参考主题**全量 1:1 深度复刻**小工具系统,完成「设置 → 注册 → 后台表单 → 渲染过滤 → 前端渲染」全链路,消除卡片堆叠与致命错误,统一为单一路径。
## 约束
- **不写双路与迁移兼容代码**:直接替换旧实现,不保留旧 option 兼读、不新增 shim(遵循 CLAUDE.md「重写优先于兼容」)。
- **可搬运参考主题资源**:允许把 Zibll 的图片/图标等资源搬入 AKSRT(后续逐步替换)。
- 表单系统走「复用现有共享层」路线,**不移植 CSF (Codestar) 框架**。
- 单一计划、分阶段推进;每阶段可独立演示与验证。
## 用户故事
- **US1(管理员)**:每个小工具都能通过统一的共享表单层配置 Zibll 等价的通用字段(显示设备 PC/移动、文章 ID 显隐、侧栏吸顶、入场动画、布局背景、标题/副标题/链接),不再有逐小工具重复实现。
- **US2(访客)**:文章卡片在 list/card/mini 三种模式下正确布局,不堆叠、不塌陷。
- **US3(访客)**:首页/归档/搜索页分页正常(数字 / 加载更多 / 无限滚动),无致命错误。
- **US4(管理员)**:Zibll 的每一个小工具在 AKSRT 都有对等实现,可挂载到正确的挂载区域。
- **US5(开发者)**:全链路(设置 → 注册 → 表单 → 渲染过滤 → 前端渲染)一致且单一通路。
- **US6(开发者)**:参考主题的图片/图标/CSS/JS 资源已入库,小工具渲染视觉与 Zibll 一致(后续替换)。
- **US7(访客/管理员)**:论坛版块/话题与商城商品小工具与 Zibll 对齐。
## 范围外
- 不新增任何向后兼容 / 数据迁移层(PRD 明确)。
- 参考图片为临时搬运,后续替换不属于本计划。
- 不移植 CSF 框架本体。
+3 -12
View File
@@ -9,13 +9,6 @@ defined('ABSPATH') || exit;
get_header(); get_header();
$other_page_settings = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_other_page_settings()
: [
'page_404_message' => __('页面未找到', 'aksrt'),
'page_404_search' => true,
'page_404_popular' => true,
];
$search_placeholder = function_exists('aksrt_get_search_placeholder') ? aksrt_get_search_placeholder() : __('开启精彩搜索', 'aksrt'); $search_placeholder = function_exists('aksrt_get_search_placeholder') ? aksrt_get_search_placeholder() : __('开启精彩搜索', 'aksrt');
$hot_keywords = function_exists('aksrt_get_search_hot_keywords') ? aksrt_get_search_hot_keywords() : []; $hot_keywords = function_exists('aksrt_get_search_hot_keywords') ? aksrt_get_search_hot_keywords() : [];
@@ -43,10 +36,10 @@ $top_cats = get_terms([
<div class="term-page-header__body term-page-header__body--error"> <div class="term-page-header__body term-page-header__body--error">
<div> <div>
<p class="error-hero__icon" aria-hidden="true"> <p class="error-hero__icon" aria-hidden="true">
<i class="far fa-compass"></i> <i class="fa fas fa-compass"></i>
</p> </p>
<p class="error-hero__code" id="error-title">404</p> <p class="error-hero__code" id="error-title">404</p>
<h1 class="error-hero__title"><?php echo esc_html($other_page_settings['page_404_message']); ?></h1> <h1 class="error-hero__title"><?php esc_html_e('页面未找到', 'aksrt'); ?></h1>
<p class="error-hero__desc"> <p class="error-hero__desc">
<?php esc_html_e('你访问的页面不存在或已被删除。试试搜索,或者从下方常用入口继续浏览。', 'aksrt'); ?> <?php esc_html_e('你访问的页面不存在或已被删除。试试搜索,或者从下方常用入口继续浏览。', 'aksrt'); ?>
</p> </p>
@@ -54,7 +47,6 @@ $top_cats = get_terms([
<?php if (function_exists('aksrt_render_admin_quick_link')) { aksrt_render_admin_quick_link('page-display/other', __('404 页设置', 'aksrt')); } ?> <?php if (function_exists('aksrt_render_admin_quick_link')) { aksrt_render_admin_quick_link('page-display/other', __('404 页设置', 'aksrt')); } ?>
</div> </div>
<?php if (!empty($other_page_settings['page_404_search'])) : ?>
<form role="search" method="get" class="error-hero__search" action="<?php echo esc_url(home_url('/')); ?>"> <form role="search" method="get" class="error-hero__search" action="<?php echo esc_url(home_url('/')); ?>">
<span class="error-hero__search-icon" aria-hidden="true"> <span class="error-hero__search-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
@@ -64,7 +56,6 @@ $top_cats = get_terms([
<button type="submit" class="btn-primary btn-sm"><?php esc_html_e('搜索', 'aksrt'); ?></button> <button type="submit" class="btn-primary btn-sm"><?php esc_html_e('搜索', 'aksrt'); ?></button>
</form> </form>
<?php aksrt_render_search_hot_keywords($hot_keywords, 'aksrt-search-hot-keywords'); ?> <?php aksrt_render_search_hot_keywords($hot_keywords, 'aksrt-search-hot-keywords'); ?>
<?php endif; ?>
<div class="error-hero__actions"> <div class="error-hero__actions">
<a href="<?php echo esc_url(home_url('/')); ?>" class="btn-primary"><?php esc_html_e('返回首页', 'aksrt'); ?></a> <a href="<?php echo esc_url(home_url('/')); ?>" class="btn-primary"><?php esc_html_e('返回首页', 'aksrt'); ?></a>
@@ -86,7 +77,7 @@ $top_cats = get_terms([
</section> </section>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($other_page_settings['page_404_popular']) && $hot_query->have_posts()) : ?> <?php if ($hot_query->have_posts()) : ?>
<section class="error-section" aria-label="<?php esc_attr_e('热门内容', 'aksrt'); ?>"> <section class="error-section" aria-label="<?php esc_attr_e('热门内容', 'aksrt'); ?>">
<h2 class="error-section__title"><?php esc_html_e('热门内容', 'aksrt'); ?></h2> <h2 class="error-section__title"><?php esc_html_e('热门内容', 'aksrt'); ?></h2>
<ul class="error-section__list"> <ul class="error-section__list">
+34 -15
View File
@@ -12,12 +12,9 @@ AKSRT 是一款现代化的 WordPress 主题,采用**原生 WordPress 主题
### 核心特性 ### 核心特性
- **自定义管理面板 (SPA)** — Vue3 + Vite + Naive UI 的 12 个一级业务域后台 - **自定义管理面板 (SPA)** — Vue3 + Vite + Naive UI 的 12 个一级业务域后台
- **统一设置契约** — Schema 驱动表单、域控制器、导入导出、按域重置、迁移状态与健康检查 - **统一设置契约** — Schema 驱动表单、域控制器、导入导出
- **支付/下载主线** — 下载/付费内容中心、充值/VIP/分成/返佣/网关结构、10 分钟下载 token - **用户与增长** — 统一登录注册、OAuth 登录、签到奖励、邀请奖励、等级/徽章/认证/举报结构
- **用户与增长** — OAuth 登录、邮箱验证门禁、签到奖励、邀请奖励、等级/徽章/认证/举报结构 - **内容与交互** — 首页模块化、归档/搜索/404、单篇资源页、评论审核与通知
- **内容与交互** — 首页模块化、归档/搜索/404、单篇资源页、评论审核与通知中心
- **运维工具** — 配置导出导入、dry-run 校验、邮件回滚、数据库与环境诊断
- **自定义页面模板**:About / Friends / Login / Profile / Register
- **响应式设计** - **响应式设计**
--- ---
@@ -40,7 +37,7 @@ AKSRT-theme/
│ ├── Credits/ # 积分 / 钱包 / 签到 / 邀请 │ ├── Credits/ # 积分 / 钱包 / 签到 / 邀请
│ ├── Extensions/ # MeiliSearch / 推送 / IP / 内容审核 │ ├── Extensions/ # MeiliSearch / 推送 / IP / 内容审核
│ ├── Frontend/ # 前端渲染辅助 │ ├── Frontend/ # 前端渲染辅助
│ ├── Messages/ # 站内信 / 私信 / 公告 / 举报 │ ├── Messages/ # 站内信 / 公告 / 举报
│ ├── Payment/ # 订单 / 网关 / 售后 / 物流 / 分成 │ ├── Payment/ # 订单 / 网关 / 售后 / 物流 / 分成
│ │ └── Gateways/ # 支付宝 / 微信 / PayPal / Stripe │ │ └── Gateways/ # 支付宝 / 微信 / PayPal / Stripe
│ ├── Settings/ # 设置门面 + 运行时 delegates │ ├── Settings/ # 设置门面 + 运行时 delegates
@@ -60,9 +57,8 @@ AKSRT-theme/
│ ├── pages/ # 自定义页面模板 │ ├── pages/ # 自定义页面模板
│ │ ├── page-about.php # 关于页面 │ │ ├── page-about.php # 关于页面
│ │ ├── page-friends.php # 友链墙 │ │ ├── page-friends.php # 友链墙
│ │ ├── page-login.php # 登录页面 │ │ ├── page-sign.php # 唯一登录注册页面
│ │ ├── page-profile.php # 用户中心 │ │ ├── page-profile.php # 用户中心
│ │ └── page-register.php # 注册页面
│ │ │ │
│ └── parts/ # 可复用模板片段 │ └── parts/ # 可复用模板片段
│ ├── content-post-card.php # 文章卡片组件 │ ├── content-post-card.php # 文章卡片组件
@@ -166,7 +162,18 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
- `功能&权限`:审核、验证码、评论/附件/下载权限 - `功能&权限`:审核、验证码、评论/附件/下载权限
- `用户&互动`:登录注册、OAuth、通知、签到、认证、等级、徽章、举报、积分 - `用户&互动`:登录注册、OAuth、通知、签到、认证、等级、徽章、举报、积分
- `支付&付费`:下载/付费内容中心、余额充值、VIP、创作分成、推广返佣、收款接口 - `支付&付费`:下载/付费内容中心、余额充值、VIP、创作分成、推广返佣、收款接口
- `备份&导入`:导入导出、健康检查、迁移状态、邮件回滚 - `备份&导入`:导入导出、健康检查、迁移状态、备份与更新
### 4. 推广、邀请与佣金(用户中心 → 收益)
- **推广链接**:用户中心「收益」页展示带 `?ref=` 的推广链接、二维码(可下载 PNG)与推广海报(按「推广返佣」设置中的海报文案生成)。归因模式(首触/末触)在「支付&付费 → 推广返佣 → 推广归因模式」配置。
- **一次性邀请码**:后台「用户 → 邀请码管理」三视图运营页——列表(状态筛选/搜索/批量删除/排序/分页/点击复制)、添加(系统自动生成:数量/标识/四类奖励[经验值、积分、余额、VIP]/高级选项自定义位数;或 Zibll 兼容文本导入:一行一码 `邀请码 奖励 标识`,奖励参数串 `points=20&balance=30&vip=1&vip_time=5&level_integral=100`,分隔符可自定义)、导出(状态筛选 + 文本文档[可回流导入]/Excel表格 双格式)。注册门控模式在「用户&互动 → 登录注册 → 邀请码注册」。消费为原子操作,并发注册不会超发;开启注册验证时奖励延迟到邮箱验证后发放。
- **佣金**:订单支付成功后按推广者等级分档比例记录到佣金账本,经「结算观察期」后自动结算入钱包,支持提现与退款联动回滚。「推广码禁用后停止返佣」开关默认关闭。
### 5. 部署注意事项
- **佣金结算时效**:结算依赖小时级 wp-cron,并有收益页访问触发的懒结算兜底。高流量站点建议禁用 `WP_CRON_LOCK` 场景下的默认 WP-Cron,改用系统 crontab 定时请求 `wp-cron.php`,保证结算与过期任务准时执行。
- **反代 / CDN 与 IP 风控**:主题默认按 Cloudflare → 通用代理头顺序识别真实 IP。若需收紧伪造风险,添加 `add_filter('aksrt_trusted_proxies', fn() => ['10.0.0.0/8', '173.245.48.0/20'])`(填入你的代理/CDN 网段);名单非空时仅当 `REMOTE_ADDR` 命中名单才采纳转发头,否则直接使用 `REMOTE_ADDR`(推广奖励的 IP 日上限、验证码等均依赖该函数)。
--- ---
@@ -211,17 +218,29 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
### 支持的平台 ### 支持的平台
GitHub | Gitee | QQ | Google GitHub | Gitee | QQ | Google | 微博 | 微信开放平台 | 微信公众号
:---:|:---:|:---:|:---: :---:|:---:|:---:|:---:|:---:|:---:|:---:
✅ | ✅ | ✅ | ✅ ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅
### 回调地址格式 ### 回调地址格式
直连 OAuth:
``` ```
https://你的域名.com/?aksrt_oauth_callback={provider} https://你的域名.com/oauth/callback/
``` ```
`{provider}`: `github` / `gitee` / `qq` / `google` 代理服务端按平台配置:
```
https://代理服务端域名.com/oauth/proxy/{provider}/callback/
```
彩虹聚合登录:
```
https://你的域名.com/oauth/clogin/callback/
```
--- ---
+10 -16
View File
@@ -152,7 +152,7 @@ $has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_
<?php if ($has_filter) : ?> <?php if ($has_filter) : ?>
<a class="resource-archive__reset" href="<?php echo esc_url($archive_url); ?>"> <a class="resource-archive__reset" href="<?php echo esc_url($archive_url); ?>">
<i class="fas fa-times-circle" aria-hidden="true"></i> <i class="fa fas fa-times-circle" aria-hidden="true"></i>
<?php esc_html_e('清除筛选', 'aksrt'); ?> <?php esc_html_e('清除筛选', 'aksrt'); ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
@@ -160,22 +160,16 @@ $has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_
<?php if (have_posts()) : ?> <?php if (have_posts()) : ?>
<?php <?php
$render_config = function_exists('aksrt_resolve_post_list_render_config') $render_config = aksrt_resolve_post_list_render_config([
? aksrt_resolve_post_list_render_config([ 'context' => 'archive',
'context' => 'archive', 'overrides' => ['type' => 'card'],
'overrides' => ['mode' => 'card'], ]);
])
: ['context' => 'archive', 'mode' => 'card'];
if (function_exists('aksrt_render_post_list')) { aksrt_render_post_list(array_merge($render_config, ['wrapper_class' => 'content-stream__list']));
aksrt_render_post_list(null, array_merge($render_config, ['wrapper_class' => 'content-stream__list'])); aksrt_render_pagination(null, [
} 'query_context' => 'archive',
if (function_exists('aksrt_render_pagination')) { 'render_config' => $render_config,
aksrt_render_pagination(null, [ ]);
'query_context' => 'archive',
'render_config' => $render_config,
]);
}
?> ?>
<?php else : ?> <?php else : ?>
<div class="no-results"> <div class="no-results">
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -21,6 +21,7 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.1.0", "@vitejs/plugin-vue": "^5.1.0",
"esbuild": "^0.25.12",
"vite": "^6.0.0" "vite": "^6.0.0"
} }
}, },
+1 -1
View File
@@ -1 +1 @@
{"name":"aksrt-admin","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build && node extract-naive-styles.mjs","preview":"vite preview"},"dependencies":{"@codemirror/lang-css":"^6.3.1","@codemirror/lang-html":"^6.4.11","@codemirror/lang-javascript":"^6.2.5","@codemirror/theme-one-dark":"^6.1.3","codemirror":"^6.0.2","naive-ui":"^2.40.0","pinia":"^2.2.0","vue":"^3.5.0","vue-router":"^4.4.0","vuedraggable":"^4.1.0"},"devDependencies":{"@vitejs/plugin-vue":"^5.1.0","vite":"^6.0.0"}} {"name":"aksrt-admin","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build && node extract-naive-styles.mjs","build:public":"node ../../tools/build-public-assets.mjs","preview":"vite preview"},"dependencies":{"@codemirror/lang-css":"^6.3.1","@codemirror/lang-html":"^6.4.11","@codemirror/lang-javascript":"^6.2.5","@codemirror/theme-one-dark":"^6.1.3","codemirror":"^6.0.2","naive-ui":"^2.40.0","pinia":"^2.2.0","vue":"^3.5.0","vue-router":"^4.4.0","vuedraggable":"^4.1.0"},"devDependencies":{"@vitejs/plugin-vue":"^5.1.0","esbuild":"^0.25.12","vite":"^6.0.0"}}
+108 -18
View File
@@ -1,14 +1,18 @@
<template> <template>
<n-config-provider :style-mount-target="styleMountTarget"> <n-config-provider
:style-mount-target="styleMountTarget"
:theme-overrides="adminThemeOverrides"
>
<n-message-provider> <n-message-provider>
<n-dialog-provider> <n-dialog-provider>
<n-notification-provider> <n-notification-provider>
<n-layout v-if="!isMobile" class="aksrt-admin-layout" has-sider> <n-layout v-if="!isMobile" class="aksrt-admin-layout" has-sider>
<n-layout-sider <n-layout-sider
class="aksrt-admin-sider"
bordered bordered
collapse-mode="width" collapse-mode="width"
:collapsed-width="64" :collapsed-width="64"
:width="240" :width="232"
show-collapse-trigger show-collapse-trigger
:collapsed="collapsed" :collapsed="collapsed"
:native-scrollbar="false" :native-scrollbar="false"
@@ -17,10 +21,14 @@
> >
<div class="aksrt-logo"> <div class="aksrt-logo">
<span class="logo-icon">AK</span> <span class="logo-icon">AK</span>
<span v-if="!collapsed" class="logo-text">AKSRT</span> <span v-if="!collapsed" class="aksrt-logo__copy">
<span class="logo-text">AKSRT</span>
<span class="aksrt-logo__version">v{{ themeVersion }}</span>
</span>
</div> </div>
<n-menu <n-menu
class="aksrt-admin-menu"
:value="activeMenuKey" :value="activeMenuKey"
:collapsed="collapsed" :collapsed="collapsed"
:collapsed-width="64" :collapsed-width="64"
@@ -55,9 +63,13 @@
<n-drawer-content :native-scrollbar="false" body-content-style="padding:0;"> <n-drawer-content :native-scrollbar="false" body-content-style="padding:0;">
<div class="aksrt-logo aksrt-logo--drawer"> <div class="aksrt-logo aksrt-logo--drawer">
<span class="logo-icon">AK</span> <span class="logo-icon">AK</span>
<span class="logo-text">AKSRT</span> <span class="aksrt-logo__copy">
<span class="logo-text">AKSRT</span>
<span class="aksrt-logo__version">v{{ themeVersion }}</span>
</span>
</div> </div>
<n-menu <n-menu
class="aksrt-admin-menu"
:value="activeMenuKey" :value="activeMenuKey"
:options="menuOptions" :options="menuOptions"
@update:value="handleMenuClick" @update:value="handleMenuClick"
@@ -101,9 +113,63 @@ const collapsed = ref(false)
const pageError = ref(null) const pageError = ref(null)
const isMobile = ref(false) const isMobile = ref(false)
const drawerActive = ref(false) const drawerActive = ref(false)
const themeVersion = window.aksrtAdminData?.themeVersion || window.aksrtAdminData?.version || '9.0'
const MOBILE_BREAKPOINT = 960
const adminThemeOverrides = {
common: {
primaryColor: '#18A058',
primaryColorHover: '#36AD6A',
primaryColorPressed: '#0C7A43',
primaryColorSuppl: '#36AD6A',
bodyColor: '#F5F7F6',
cardColor: '#FFFFFF',
modalColor: '#FFFFFF',
popoverColor: '#FFFFFF',
textColorBase: '#1F2923',
textColor1: '#1F2923',
textColor2: '#465149',
textColor3: '#6F7A72',
borderColor: '#E2E8E3',
dividerColor: '#E2E8E3',
borderRadius: '8px',
borderRadiusSmall: '6px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
},
Button: {
borderRadiusMedium: '8px',
heightMedium: '36px',
fontWeight: '600',
},
Card: {
borderRadius: '12px',
borderColor: '#E2E8E3',
},
DataTable: {
thColor: '#F7F9F7',
thTextColor: '#465149',
tdColorHover: '#F7FBF8',
borderColor: '#E2E8E3',
},
Input: {
borderRadius: '8px',
heightMedium: '36px',
},
Menu: {
itemColorActive: '#EAF7EF',
itemColorActiveHover: '#EAF7EF',
itemColorHover: '#F2F8F4',
itemTextColorActive: '#168A4B',
itemTextColorActiveHover: '#168A4B',
itemTextColorHover: '#168A4B',
},
Switch: {
railColorActive: '#18A058',
},
}
function checkMobile() { function checkMobile() {
isMobile.value = window.innerWidth < 768 isMobile.value = window.innerWidth < MOBILE_BREAKPOINT
} }
onMounted(() => { onMounted(() => {
@@ -204,15 +270,15 @@ function handleMenuClick(key) {
.aksrt-logo { .aksrt-logo {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
height: 60px; height: 64px;
gap: 10px; gap: 11px;
border-bottom: 1px solid var(--n-border-color, #efeff5); padding: 0 18px;
border-bottom: 1px solid var(--aksrt-color-border);
} }
.aksrt-logo--drawer { .aksrt-logo--drawer {
justify-content: flex-start; height: 64px;
padding: 0 18px;
} }
.logo-icon { .logo-icon {
@@ -221,27 +287,45 @@ function handleMenuClick(key) {
justify-content: center; justify-content: center;
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 8px; flex: 0 0 28px;
background: #18a058; border-radius: 7px;
background: var(--aksrt-color-primary);
color: #fff; color: #fff;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.aksrt-logo__copy {
display: flex;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.logo-text { .logo-text {
font-size: 18px; font-size: 17px;
font-weight: 700; font-weight: 700;
color: #1a1a2e; color: var(--aksrt-color-text);
letter-spacing: 1px; letter-spacing: .08em;
}
.aksrt-logo__version {
color: var(--aksrt-color-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
letter-spacing: .02em;
} }
.aksrt-admin-content__inner { .aksrt-admin-content__inner {
padding: 24px 28px; width: 100%;
max-width: 1236px;
margin: 0 auto;
padding: 26px 28px 42px;
} }
.aksrt-admin-mobile { .aksrt-admin-mobile {
background: #f5f7fb; background: var(--aksrt-color-canvas);
min-height: 100vh; min-height: 100vh;
} }
@@ -269,4 +353,10 @@ function handleMenuClick(key) {
font-size: 13px; font-size: 13px;
color: #777; color: #777;
} }
@media (max-width: 1100px) {
.aksrt-admin-content__inner {
padding: 22px 20px 38px;
}
}
</style> </style>
+34 -5
View File
@@ -12,6 +12,21 @@ import { apiMethods } from './modules/index.js'
const API_ROOT = window.aksrtAdminData?.apiUrl || '/wp-json/aksrt/v1' const API_ROOT = window.aksrtAdminData?.apiUrl || '/wp-json/aksrt/v1'
const NONCE = window.aksrtAdminData?.nonce || '' const NONCE = window.aksrtAdminData?.nonce || ''
function createIdempotencyKey() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') {
throw new Error('当前浏览器无法生成安全操作标识')
}
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
function normalizeDomainKey(domain) { function normalizeDomainKey(domain) {
return String(domain || 'global').trim().replace(/-/g, '_') return String(domain || 'global').trim().replace(/-/g, '_')
} }
@@ -19,12 +34,11 @@ function normalizeDomainKey(domain) {
/** /**
* Join REST base + endpoint correctly for both pretty and plain permalinks. * Join REST base + endpoint correctly for both pretty and plain permalinks.
* *
* Pretty: https://site/wp-json/aksrt/v1 + /banners?kind=x * Pretty: https://site/wp-json/aksrt/v1 + /settings/schema/global/common
* → https://site/wp-json/aksrt/v1/banners?kind=x * → https://site/wp-json/aksrt/v1/settings/schema/global/common
* *
* Plain: https://site/index.php?rest_route=/aksrt/v1 + /banners?kind=x * Plain: https://site/index.php?rest_route=/aksrt/v1 + /settings/schema/global/common
* → https://site/index.php?rest_route=/aksrt/v1/banners&kind=x * → https://site/index.php?rest_route=/aksrt/v1/settings/schema/global/common
* (must NOT produce rest_route=/aksrt/v1/banners?kind=x)
* *
* @param {string} baseUrl * @param {string} baseUrl
* @param {string} endpoint path starting with /, may include ?query * @param {string} endpoint path starting with /, may include ?query
@@ -86,6 +100,7 @@ function joinRestUrl(baseUrl, endpoint) {
class ApiClient { class ApiClient {
constructor() { constructor() {
this.baseUrl = API_ROOT this.baseUrl = API_ROOT
this.pendingIdempotencyKeys = new Map()
} }
normalizeDomainKey(domain) { normalizeDomainKey(domain) {
@@ -139,11 +154,22 @@ class ApiClient {
finalBody = JSON.stringify(body) finalBody = JSON.stringify(body)
} }
const isWrite = method !== 'GET' && method !== 'HEAD'
const suppliedKey = headers?.['Idempotency-Key'] || headers?.['idempotency-key'] || ''
const idempotencySignature = isWrite ? `${method}:${url}:${finalBody ?? ''}` : ''
const idempotencyKey = isWrite && !suppliedKey
? (this.pendingIdempotencyKeys.get(idempotencySignature) || createIdempotencyKey())
: suppliedKey
if (isWrite && !suppliedKey) {
this.pendingIdempotencyKeys.set(idempotencySignature, idempotencyKey)
}
const config = { const config = {
method, method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-WP-Nonce': NONCE, 'X-WP-Nonce': NONCE,
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
...headers, ...headers,
}, },
...rest, ...rest,
@@ -175,6 +201,9 @@ class ApiClient {
throw new Error(msg) throw new Error(msg)
} }
if (idempotencySignature !== '') {
this.pendingIdempotencyKeys.delete(idempotencySignature)
}
return data return data
} catch (error) { } catch (error) {
console.error(`[API Error] ${endpoint}:`, error) console.error(`[API Error] ${endpoint}:`, error)
@@ -5,11 +5,4 @@ export const extensionsApiMethods = {
testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', data: { ip } }) }, testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', data: { ip } }) },
testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', data: { text } }) }, testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', data: { text } }) },
/* AI 辅助 */
getAiStatus() { return this.request('/ai/status') },
updateAiSettings(data) { return this.request('/ai/settings', { method: 'POST', data }) },
generateSeo(data) { return this.request('/ai/seo-generate', { method: 'POST', data }) },
suggestSummary(data) { return this.request('/ai/summary-suggest', { method: 'POST', data }) },
suggestTags(data) { return this.request('/ai/tags-suggest', { method: 'POST', data }) },
} }
-6
View File
@@ -3,20 +3,14 @@ export const globalApiMethods = {
* 全局&功能 (Global & Functions) * 全局&功能 (Global & Functions)
* ================================================================ */ * ================================================================ */
getMailSettings() { return this.request('/settings/mail') },
saveMailSettings(data) { return this.request('/settings/mail', { method: 'POST', body: JSON.stringify(data) }) },
testMail(to) { return this.request('/mail/test', { method: 'POST', body: JSON.stringify({ to }) }) }, testMail(to) { return this.request('/mail/test', { method: 'POST', body: JSON.stringify({ to }) }) },
getFooterSettings() { return this.request('/settings/footer') },
saveFooterSettings(data) { return this.request('/settings/footer', { method: 'POST', body: JSON.stringify(data) }) },
getFooterTabbarSettings() { return this.request('/settings/footer-tabbar') }, getFooterTabbarSettings() { return this.request('/settings/footer-tabbar') },
saveFooterTabbarSettings(data) { return this.request('/settings/footer-tabbar', { method: 'POST', body: JSON.stringify(data) }) }, saveFooterTabbarSettings(data) { return this.request('/settings/footer-tabbar', { method: 'POST', body: JSON.stringify(data) }) },
reorderFooterTabbar(ids) { return this.request('/settings/footer-tabbar/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) }, reorderFooterTabbar(ids) { return this.request('/settings/footer-tabbar/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
getFloatingButtons() { return this.request('/settings/floating-buttons') }, getFloatingButtons() { return this.request('/settings/floating-buttons') },
saveFloatingButtons(data) { return this.request('/settings/floating-buttons', { method: 'POST', body: JSON.stringify(data) }) }, saveFloatingButtons(data) { return this.request('/settings/floating-buttons', { method: 'POST', body: JSON.stringify(data) }) },
reorderFloatingButtons(ids) { return this.request('/settings/floating-buttons/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
getFriendLinks() { return this.request('/friend-links') }, getFriendLinks() { return this.request('/friend-links') },
addFriendLink(data) { return this.request('/friend-links', { method: 'POST', body: JSON.stringify(data) }) }, addFriendLink(data) { return this.request('/friend-links', { method: 'POST', body: JSON.stringify(data) }) },
+8
View File
@@ -0,0 +1,8 @@
/**
* Icon catalog API — serves the universal icon picker (FA5 + sprite + custom).
*/
export const iconsApiMethods = {
getIconCatalog() {
return this.request('/icons/catalog')
},
}
+2 -2
View File
@@ -2,18 +2,18 @@ import { globalApiMethods } from './global.js'
import { pageDisplayApiMethods } from './page-display.js' import { pageDisplayApiMethods } from './page-display.js'
import { permissionsApiMethods } from './permissions.js' import { permissionsApiMethods } from './permissions.js'
import { usersApiMethods } from './users.js' import { usersApiMethods } from './users.js'
import { paymentApiMethods } from './payment.js'
import { extensionsApiMethods } from './extensions.js' import { extensionsApiMethods } from './extensions.js'
import { toolsApiMethods } from './tools.js' import { toolsApiMethods } from './tools.js'
import { schemaApiMethods } from './schema.js' import { schemaApiMethods } from './schema.js'
import { iconsApiMethods } from './icons.js'
export const apiMethods = { export const apiMethods = {
...globalApiMethods, ...globalApiMethods,
...pageDisplayApiMethods, ...pageDisplayApiMethods,
...permissionsApiMethods, ...permissionsApiMethods,
...usersApiMethods, ...usersApiMethods,
...paymentApiMethods,
...extensionsApiMethods, ...extensionsApiMethods,
...toolsApiMethods, ...toolsApiMethods,
...schemaApiMethods, ...schemaApiMethods,
...iconsApiMethods,
} }
+9 -19
View File
@@ -3,28 +3,18 @@ export const pageDisplayApiMethods = {
* 页面&显示 (Page Display) * 页面&显示 (Page Display)
* ================================================================ */ * ================================================================ */
getHomepageSettings() { return this.request('/settings/homepage') },
saveHomepageSettings(data) { return this.request('/settings/homepage', { method: 'POST', body: JSON.stringify(data) }) },
getHomepageCategories() { return this.request('/homepage/categories') },
getCategoryRecommendSettings() { return this.request('/settings/category-recommend') }, getCategoryRecommendSettings() { return this.request('/settings/category-recommend') },
saveCategoryRecommendSettings(data) { return this.request('/settings/category-recommend', { method: 'POST', body: JSON.stringify(data) }) }, saveCategoryRecommendSettings(data) { return this.request('/settings/category-recommend', { method: 'POST', body: JSON.stringify(data) }) },
getCategories() { return this.request('/categories') }, getCategories(taxonomy = 'category') {
const value = Array.isArray(taxonomy) ? taxonomy.join(',') : taxonomy
return this.request(`/categories?taxonomy=${encodeURIComponent(value || 'category')}`)
},
updateCategoryMeta(id, data) { return this.request(`/categories/${id}/meta`, { method: 'POST', body: JSON.stringify(data) }) }, updateCategoryMeta(id, data) { return this.request(`/categories/${id}/meta`, { method: 'POST', body: JSON.stringify(data) }) },
searchUsers(search = '', number = 20) { return this.request(`/users/search?search=${encodeURIComponent(search)}&number=${number}`) }, searchUsers(search = '', number = 20) { return this.request(`/users/search?search=${encodeURIComponent(search)}&number=${number}`) },
getBanners(kind = 'hero') { return this.request(`/banners?kind=${encodeURIComponent(kind)}`) }, searchPageDisplayOptions(source, query = '', include = []) {
createBanner(data) { return this.request('/banners', { method: 'POST', body: JSON.stringify(data) }) }, const params = new URLSearchParams({ query })
updateBanner(id, data) { return this.request(`/banners/${id}`, { method: 'PUT', body: JSON.stringify(data) }) }, include.forEach(value => params.append('include[]', value))
deleteBanner(id) { return this.request(`/banners/${id}`, { method: 'DELETE' }) }, return this.request(`/settings/schema/page_display/search/${encodeURIComponent(source)}?${params}`)
},
getHeroes() { return this.getBanners('hero') },
createHero(data) { return this.createBanner({ ...data, kind: 'hero' }) },
updateHero(id, data) { return this.updateBanner(id, { ...data, kind: 'hero' }) },
deleteHero(id) { return this.deleteBanner(id) },
getHomepageBanners() { return this.getBanners('banner') },
createHomepageBanner(data) { return this.createBanner({ ...data, kind: 'banner' }) },
updateHomepageBanner(id, data) { return this.updateBanner(id, { ...data, kind: 'banner' }) },
deleteHomepageBanner(id) { return this.deleteBanner(id) },
} }
-7
View File
@@ -1,7 +0,0 @@
export const paymentApiMethods = {
/* ================================================================
* 支付&付费 (Payment)
* ================================================================ */
getPaymentOverview() { return this.request('/payment/overview') },
}
@@ -3,14 +3,6 @@ export const permissionsApiMethods = {
* 功能&权限 (Permissions) * 功能&权限 (Permissions)
* ================================================================ */ * ================================================================ */
getPermissionsAttachmentSettings() { return this.getSchemaPage('permissions', 'attachment') },
savePermissionsAttachmentSettings(data) { return this.saveSchemaPage('permissions', 'attachment', data) },
getPermissionsLoginSettings() { return this.getSchemaPage('permissions', 'login') },
savePermissionsLoginSettings(data) { return this.saveSchemaPage('permissions', 'login', data) },
getPermissionsModerationSettings() { return this.getSchemaPage('permissions', 'moderation') },
getCapabilities() { return this.request('/permissions/capabilities') }, getCapabilities() { return this.request('/permissions/capabilities') },
saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) }, saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) },
} }
+6
View File
@@ -3,6 +3,12 @@ export const schemaApiMethods = {
getSchemaPage(domain, section) { getSchemaPage(domain, section) {
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`) return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`)
}, },
testCaptcha(provider, token) {
return this.request('/captcha/test', {
method: 'POST',
body: JSON.stringify({ provider, token }),
})
},
saveSchemaPage(domain, section, data, version = '1.0.0') { saveSchemaPage(domain, section, data, version = '1.0.0') {
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`, { return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`, {
method: 'POST', method: 'POST',
-4
View File
@@ -3,14 +3,10 @@ export const toolsApiMethods = {
* 扩展&增强 / 备份&导入 (Extensions & Tools) * 扩展&增强 / 备份&导入 (Extensions & Tools)
* ================================================================ */ * ================================================================ */
getHealthEmailVerification() { return this.request('/health/email-verification') },
getUpdateInfo() { return this.request('/tools/update-info') }, getUpdateInfo() { return this.request('/tools/update-info') },
detectUpdate() { return this.request('/tools/update-check', { method: 'POST', body: JSON.stringify({}) }) }, detectUpdate() { return this.request('/tools/update-check', { method: 'POST', body: JSON.stringify({}) }) },
dismissUpdate(version) { return this.request('/tools/update-dismiss', { method: 'POST', body: JSON.stringify({ version }) }) }, dismissUpdate(version) { return this.request('/tools/update-dismiss', { method: 'POST', body: JSON.stringify({ version }) }) },
getEmailRollbackUsers() { return this.request('/email-rollback/users') },
executeEmailRollback(id) { return this.request(`/email-rollback/users/${id}/rollback`, { method: 'POST' }) },
/* ================================================================ /* ================================================================
* Settings Management (Import / Export / Reset / Health / Schema) * Settings Management (Import / Export / Reset / Health / Schema)
* ================================================================ */ * ================================================================ */
+7 -17
View File
@@ -5,9 +5,7 @@ export const usersApiMethods = {
getAuthSettings() { return this.request('/settings/auth') }, getAuthSettings() { return this.request('/settings/auth') },
saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) }, saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) },
testSms(phone) { return this.request('/sms/test', { method: 'POST', body: JSON.stringify({ phone }) }) },
getOAuthSettings() { return this.request('/settings/oauth') },
saveOAuthSettings(data) { return this.request('/settings/oauth', { method: 'POST', body: JSON.stringify(data) }) },
getNotificationSettings() { return this.request('/settings/notifications') }, getNotificationSettings() { return this.request('/settings/notifications') },
saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) }, saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) },
@@ -20,25 +18,17 @@ export const usersApiMethods = {
deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) }, deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) },
runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) }, runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) },
getIdentitySettings() { return this.request('/identity/settings') }, getCreditSettings() { return this.request('/assets/settings') },
saveIdentitySettings(data) { saveCreditSettings(data) { return this.request('/assets/settings', { method: 'POST', body: JSON.stringify(data) }) },
return this.request('/identity/settings', { method: 'POST', body: JSON.stringify(data) })
},
testIdentitySettings(data = {}) {
return this.request('/identity/settings/test', { method: 'POST', body: JSON.stringify(data) })
},
getCreditSettings() { return this.request('/credits/settings') },
saveCreditSettings(data) { return this.request('/credits/settings', { method: 'POST', body: JSON.stringify(data) }) },
listCreditUsers(params = {}) { listCreditUsers(params = {}) {
const qs = new URLSearchParams(params).toString() const qs = new URLSearchParams(params).toString()
return this.request('/credits/users' + (qs ? `?${qs}` : '')) return this.request('/assets/accounts' + (qs ? `?${qs}` : ''))
}, },
adjustUserCredits(id, delta, reason) { adjustUserCredits(id, asset, delta, remark) {
return this.request(`/credits/users/${id}/adjust`, { method: 'POST', body: JSON.stringify({ delta, reason }) }) return this.request(`/assets/accounts/${id}/adjust`, { method: 'POST', body: JSON.stringify({ asset, delta, remark }) })
}, },
getCreditLog(params = {}) { getCreditLog(params = {}) {
const qs = new URLSearchParams(params).toString() const qs = new URLSearchParams(params).toString()
return this.request('/credits/log' + (qs ? `?${qs}` : '')) return this.request('/assets/transactions' + (qs ? `?${qs}` : ''))
}, },
} }
+8 -8
View File
@@ -25,7 +25,7 @@
:key="i" :key="i"
class="aksrt-deferred-page__feature" class="aksrt-deferred-page__feature"
> >
<FaIcon icon="fas fa-check-circle" :size="14" /> <FaIcon icon="fa fas fa-check-circle" :size="14" />
<span>{{ feat }}</span> <span>{{ feat }}</span>
</div> </div>
</div> </div>
@@ -63,7 +63,7 @@ const route = useRoute()
/** Merge direct props with route meta deferredProps */ /** Merge direct props with route meta deferredProps */
const resolvedTitle = computed(() => props.title || route.meta?.deferredProps?.title || '功能开发中') const resolvedTitle = computed(() => props.title || route.meta?.deferredProps?.title || '功能开发中')
const resolvedDesc = computed(() => props.description || route.meta?.deferredProps?.description || '此功能正在规划开发中,将在后续版本上线。') const resolvedDesc = computed(() => props.description || route.meta?.deferredProps?.description || '此功能正在规划开发中,将在后续版本上线。')
const resolvedIcon = computed(() => props.icon || route.meta?.deferredProps?.icon || 'fas fa-rocket') const resolvedIcon = computed(() => props.icon || route.meta?.deferredProps?.icon || 'fa fas fa-rocket')
const resolvedStatus = computed(() => props.status || route.meta?.deferredProps?.status || 'deferred') const resolvedStatus = computed(() => props.status || route.meta?.deferredProps?.status || 'deferred')
const resolvedPhase = computed(() => props.phase || route.meta?.deferredProps?.phase || '') const resolvedPhase = computed(() => props.phase || route.meta?.deferredProps?.phase || '')
const resolvedDependsOn = computed(() => { const resolvedDependsOn = computed(() => {
@@ -89,7 +89,7 @@ const resolvedFeatures = computed(() => {
} }
.aksrt-deferred-page__icon { .aksrt-deferred-page__icon {
color: #c0c4cc; color: var(--aksrt-color-border-strong);
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -97,13 +97,13 @@ const resolvedFeatures = computed(() => {
margin: 0 0 10px; margin: 0 0 10px;
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: #1a1a2e; color: var(--aksrt-color-text);
} }
.aksrt-deferred-page__desc { .aksrt-deferred-page__desc {
margin: 0 0 20px; margin: 0 0 20px;
font-size: 14px; font-size: 14px;
color: #8a909c; color: var(--aksrt-color-muted);
max-width: 400px; max-width: 400px;
line-height: 1.6; line-height: 1.6;
} }
@@ -126,7 +126,7 @@ const resolvedFeatures = computed(() => {
margin-bottom: 8px; margin-bottom: 8px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #4b5563; color: var(--aksrt-color-text-secondary);
} }
.aksrt-deferred-page__depends-list { .aksrt-deferred-page__depends-list {
@@ -137,7 +137,7 @@ const resolvedFeatures = computed(() => {
.aksrt-deferred-page__reason p { .aksrt-deferred-page__reason p {
margin: 0; margin: 0;
color: #666; color: var(--aksrt-color-muted);
line-height: 1.7; line-height: 1.7;
} }
@@ -153,7 +153,7 @@ const resolvedFeatures = computed(() => {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 13px; font-size: 13px;
color: #666; color: var(--aksrt-color-muted);
} }
.aksrt-deferred-page__feature :deep(i) { .aksrt-deferred-page__feature :deep(i) {
+4 -4
View File
@@ -12,7 +12,7 @@
defineProps({ defineProps({
title: { type: String, default: '功能开发中' }, title: { type: String, default: '功能开发中' },
desc: { type: String, default: '此功能模块正在规划中,后续版本将逐步开放。' }, desc: { type: String, default: '此功能模块正在规划中,后续版本将逐步开放。' },
icon: { type: String, default: 'fas fa-hourglass-half' }, icon: { type: String, default: 'fa fas fa-hourglass-half' },
}) })
</script> </script>
@@ -28,20 +28,20 @@ defineProps({
.aksrt-empty-state__icon { .aksrt-empty-state__icon {
margin-bottom: 20px; margin-bottom: 20px;
color: #c0c4cc; color: var(--aksrt-color-border-strong);
} }
.aksrt-empty-state__title { .aksrt-empty-state__title {
margin: 0 0 8px; margin: 0 0 8px;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #1a1a2e; color: var(--aksrt-color-text);
} }
.aksrt-empty-state__desc { .aksrt-empty-state__desc {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
color: #7a7f8a; color: var(--aksrt-color-muted);
max-width: 360px; max-width: 360px;
line-height: 1.65; line-height: 1.65;
} }
+6 -6
View File
@@ -34,8 +34,8 @@ defineEmits(['toggle'])
gap: 10px; gap: 10px;
height: 48px; height: 48px;
padding: 0 14px; padding: 0 14px;
background: #fff; background: var(--aksrt-color-surface);
border-bottom: 1px solid #eceff5; border-bottom: 1px solid var(--aksrt-color-border);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 50; z-index: 50;
@@ -50,20 +50,20 @@ defineEmits(['toggle'])
border: none; border: none;
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
color: #1a1a2e; color: var(--aksrt-color-text);
cursor: pointer; cursor: pointer;
margin-left: -6px; margin-left: -6px;
} }
.aksrt-mobile-topbar__menu:hover { .aksrt-mobile-topbar__menu:hover {
background: #f3f5f9; background: var(--aksrt-color-primary-soft);
} }
.aksrt-mobile-topbar__title { .aksrt-mobile-topbar__title {
flex: 1 1 auto; flex: 1 1 auto;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: #1a1a2e; color: var(--aksrt-color-text);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -77,7 +77,7 @@ defineEmits(['toggle'])
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 8px; border-radius: 8px;
background: #18a058; background: var(--aksrt-color-primary);
color: #fff; color: #fff;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
@@ -1,164 +0,0 @@
<template>
<n-card class="oauth-provider-card" title="">
<!-- 平台信息头部 -->
<div class="oauth-provider-header">
<div class="oauth-header-left">
<span class="oauth-platform-icon">
<img v-if="icon" :src="icon" :alt="name" class="oauth-platform-img" />
<span v-else class="oauth-platform-text">{{ name.charAt(0) }}</span>
</span>
<div class="oauth-platform-info">
<h4>{{ name }}</h4>
<a :href="docUrl" target="_blank" class="oauth-doc-link">
前往 {{ name }} 创建应用 &#8599;
</a>
</div>
</div>
<n-switch v-model:value="modelValue.enabled" class="oauth-enable-switch">
<template #checked>启用</template>
<template #unchecked>停用</template>
</n-switch>
</div>
<!-- 表单区域(启用后才展开) -->
<div v-if="modelValue.enabled" class="oauth-form-body">
<n-form label-placement="top" class="aksrt-form oauth-form-grid">
<n-form-item
v-for="field in fields"
:key="field.key"
:label="field.label"
>
<n-input
v-if="!field.readonly"
v-model:value="modelValue[field.key]"
:type="field.secret ? 'password' : 'text'"
:show-password-on-click="field.secret"
:placeholder="'输入 ' + field.label"
size="large"
/>
<n-input
v-else
:value="callbackUrl"
readonly
size="large"
style='font-family: "SF Mono", Consolas, monospace; font-size:13px;'
/>
<template v-if="field.hint" #feedback>
<span class="aksrt-hint-text">{{ field.hint }}</span>
</template>
<template v-if="field.readonly && !field.hint" #feedback>
<span class="aksrt-hint-text">将此地址填写到对应平台的「授权回调域 / 回调 URL」中</span>
</template>
</n-form-item>
</n-form>
</div>
<!-- 未启用时的提示 -->
<div v-else class="oauth-disabled-hint">
<p>开启开关以配置此平台的 OAuth 凭证</p>
</div>
</n-card>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: { type: Object, required: true },
name: { type: String, required: true },
docUrl: { type: String, required: true },
fields: { type: Array, required: true },
icon: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
const callbackUrl = computed(() => {
const siteUrl = window.aksrtAdminData?.siteUrl || window.location.origin
return `${siteUrl}/oauth/callback/`
})
</script>
<style scoped>
.oauth-provider-card {
border-radius: 12px !important;
}
/* 平台头部 */
.oauth-provider-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 20px;
margin-bottom: 8px;
}
.oauth-header-left {
display: flex;
align-items: center;
gap: 14px;
}
.oauth-platform-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #f7f8fa;
border-radius: 12px;
}
.oauth-platform-img {
width: 26px;
height: 26px;
object-fit: contain;
}
.oauth-platform-text {
font-size: 20px;
font-weight: 700;
color: #666;
}
.oauth-platform-info h4 {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 2px;
}
.oauth-doc-link {
font-size: 12px;
color: #1565c0;
text-decoration: none;
}
.oauth-doc-link:hover {
text-decoration: underline;
}
/* 启用开关 */
.oauth-enable-switch {
flex-shrink: 0;
}
/* 表单体 */
.oauth-form-body {
padding-top: 8px;
border-top: 1px solid #f0f0f5;
}
.oauth-form-grid :deep(.n-form-item) {
max-width: 520px;
}
/* 禁用提示 */
.oauth-disabled-hint {
text-align: center;
padding: 28px 0 10px;
}
.oauth-disabled-hint p {
font-size: 13px;
color: #bbb;
}
</style>
+2 -2
View File
@@ -39,7 +39,7 @@ defineProps({
.aksrt-page-header__title { .aksrt-page-header__title {
margin: 0 0 6px; margin: 0 0 6px;
color: #1a1a2e; color: var(--aksrt-color-text);
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
@@ -48,7 +48,7 @@ defineProps({
.aksrt-page-header__desc { .aksrt-page-header__desc {
margin: 0; margin: 0;
color: #7a7f8a; color: var(--aksrt-color-muted);
font-size: 13px; font-size: 13px;
line-height: 1.65; line-height: 1.65;
} }
@@ -40,9 +40,11 @@
v-model:value="model.access_key_secret" v-model:value="model.access_key_secret"
type="password" type="password"
show-password-on="click" show-password-on="click"
autocomplete="current-password" autocomplete="new-password"
placeholder="阿里云 RAM 用户 AccessKey Secret" placeholder="阿里云 RAM 用户 AccessKey Secret"
@update:value="onSecretInput('access_key_secret', $event)"
/> />
<n-button v-if="model.access_key_secret === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('access_key_secret')">清除</n-button>
</n-form-item> </n-form-item>
<n-alert type="warning" style="margin-bottom: 8px;"> <n-alert type="warning" style="margin-bottom: 8px;">
请使用 RAM 子账号密钥并授权 <code>AliyunYundunAFSFullAccess</code> 权限,切勿使用主账号密钥。 请使用 RAM 子账号密钥并授权 <code>AliyunYundunAFSFullAccess</code> 权限,切勿使用主账号密钥。
@@ -58,6 +60,16 @@
import { computed } from 'vue' import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({}) }) const model = defineModel({ type: Object, default: () => ({}) })
const emit = defineEmits(['clear-secret', 'restore-secret'])
function clearSecret(field) {
emit('clear-secret', field)
model.value = { ...model.value, [field]: '' }
}
function onSecretInput(field, value) {
if (value !== '') emit('restore-secret', field)
}
const isConfigured = computed(() => { const isConfigured = computed(() => {
const c = model.value const c = model.value
@@ -5,10 +5,8 @@
<n-input v-model:value="model.site_key" placeholder="Cloudflare Turnstile Site Key" autocomplete="off" /> <n-input v-model:value="model.site_key" placeholder="Cloudflare Turnstile Site Key" autocomplete="off" />
</n-form-item> </n-form-item>
<n-form-item label="Secret Key"> <n-form-item label="Secret Key">
<n-input v-model:value="model.secret_key" type="password" show-password-on="click" autocomplete="current-password" /> <n-input v-model:value="model.secret_key" type="password" show-password-on="click" autocomplete="new-password" @update:value="onSecretInput('secret_key', $event)" />
</n-form-item> <n-button v-if="model.secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('secret_key')">清除</n-button>
<n-form-item label="自定义域名">
<n-input v-model:value="model.domain" placeholder="留空使用默认域名" autocomplete="url" />
</n-form-item> </n-form-item>
<n-form-item label="主题"> <n-form-item label="主题">
<n-select v-model:value="model.theme" :options="themeOptions" clearable /> <n-select v-model:value="model.theme" :options="themeOptions" clearable />
@@ -26,6 +24,16 @@
import { computed } from 'vue' import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({}) }) const model = defineModel({ type: Object, default: () => ({}) })
const emit = defineEmits(['clear-secret', 'restore-secret'])
function clearSecret(field) {
emit('clear-secret', field)
model.value = { ...model.value, [field]: '' }
}
function onSecretInput(field, value) {
if (value !== '') emit('restore-secret', field)
}
const isConfigured = computed(() => { const isConfigured = computed(() => {
const c = model.value const c = model.value
@@ -5,7 +5,8 @@
<n-input v-model:value="model.captcha_id" placeholder="极验 GeeTest CaptchaID" autocomplete="off" /> <n-input v-model:value="model.captcha_id" placeholder="极验 GeeTest CaptchaID" autocomplete="off" />
</n-form-item> </n-form-item>
<n-form-item label="Captcha Key"> <n-form-item label="Captcha Key">
<n-input v-model:value="model.captcha_key" type="password" show-password-on="click" autocomplete="current-password" /> <n-input v-model:value="model.captcha_key" type="password" show-password-on="click" autocomplete="new-password" @update:value="onSecretInput('captcha_key', $event)" />
<n-button v-if="model.captcha_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('captcha_key')">清除</n-button>
</n-form-item> </n-form-item>
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag> <n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
<n-tag v-else type="warning" size="small">未完成配置</n-tag> <n-tag v-else type="warning" size="small">未完成配置</n-tag>
@@ -17,6 +18,16 @@
import { computed } from 'vue' import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({ product: 'bind' }) }) const model = defineModel({ type: Object, default: () => ({ product: 'bind' }) })
const emit = defineEmits(['clear-secret', 'restore-secret'])
function clearSecret(field) {
emit('clear-secret', field)
model.value = { ...model.value, [field]: '' }
}
function onSecretInput(field, value) {
if (value !== '') emit('restore-secret', field)
}
const isConfigured = computed(() => { const isConfigured = computed(() => {
const c = model.value const c = model.value
@@ -9,7 +9,8 @@
<n-input v-model:value="model.secret_id" placeholder="腾讯云 API SecretId" autocomplete="off" /> <n-input v-model:value="model.secret_id" placeholder="腾讯云 API SecretId" autocomplete="off" />
</n-form-item> </n-form-item>
<n-form-item label="SecretKey"> <n-form-item label="SecretKey">
<n-input v-model:value="model.secret_key" type="text" placeholder="腾讯云 API SecretKey" autocomplete="off" show-password-on="click" /> <n-input v-model:value="model.secret_key" type="password" placeholder="腾讯云 API SecretKey" autocomplete="new-password" show-password-on="click" @update:value="onSecretInput('secret_key', $event)" />
<n-button v-if="model.secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('secret_key')">清除</n-button>
</n-form-item> </n-form-item>
<n-divider title-placement="left">验证码密钥</n-divider> <n-divider title-placement="left">验证码密钥</n-divider>
@@ -17,7 +18,8 @@
<n-input v-model:value="model.app_id" placeholder="从验证码控制台获取 CaptchaAppId" autocomplete="off" /> <n-input v-model:value="model.app_id" placeholder="从验证码控制台获取 CaptchaAppId" autocomplete="off" />
</n-form-item> </n-form-item>
<n-form-item label="AppSecretKey"> <n-form-item label="AppSecretKey">
<n-input v-model:value="model.app_secret_key" type="text" placeholder="从验证码控制台获取 AppSecretKey" autocomplete="off" show-password-on="click" /> <n-input v-model:value="model.app_secret_key" type="password" placeholder="从验证码控制台获取 AppSecretKey" autocomplete="new-password" show-password-on="click" @update:value="onSecretInput('app_secret_key', $event)" />
<n-button v-if="model.app_secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('app_secret_key')">清除</n-button>
<template #feedback>请从「验证管理 → 基础配置」完整复制,注意区分 I/l/1/O/0</template> <template #feedback>请从「验证管理 → 基础配置」完整复制,注意区分 I/l/1/O/0</template>
</n-form-item> </n-form-item>
@@ -34,6 +36,16 @@
import { computed } from 'vue' import { computed } from 'vue'
const model = defineModel({ type: Object, default: () => ({}) }) const model = defineModel({ type: Object, default: () => ({}) })
const emit = defineEmits(['clear-secret', 'restore-secret'])
function clearSecret(field) {
emit('clear-secret', field)
model.value = { ...model.value, [field]: '' }
}
function onSecretInput(field, value) {
if (value !== '') emit('restore-secret', field)
}
const isConfigured = computed(() => { const isConfigured = computed(() => {
const c = model.value const c = model.value
@@ -0,0 +1,84 @@
<template>
<n-form label-placement="left" label-width="110" class="aksrt-form float-common-fields">
<n-form-item label="PC端显示" :show-feedback="false">
<n-switch :value="!!modelValue.pc_s" @update:value="update('pc_s', $event)" />
</n-form-item>
<n-form-item label="移动端显示" :show-feedback="false">
<n-switch :value="!!modelValue.m_s" @update:value="update('m_s', $event)" />
</n-form-item>
<n-form-item label="颜色" :show-feedback="false">
<div class="float-common-fields__colors">
<div class="float-common-fields__color">
<span>自定义图标颜色</span>
<n-color-picker
:value="modelValue.color?.color || null"
:show-alpha="true"
:modes="['hex', 'rgb']"
clearable
@update:value="updateColor('color', $event)"
/>
</div>
<div class="float-common-fields__color">
<span>自定义背景颜色</span>
<n-color-picker
:value="modelValue.color?.bg || null"
:show-alpha="true"
:modes="['hex', 'rgb']"
clearable
@update:value="updateColor('bg', $event)"
/>
</div>
</div>
</n-form-item>
<div class="float-common-fields__desc">自定义颜色请注意日间、夜间模式的适配</div>
</n-form>
</template>
<script setup>
const props = defineProps({
modelValue: { type: Object, required: true },
})
const emit = defineEmits(['update:modelValue'])
function update(key, value) {
emit('update:modelValue', { ...props.modelValue, [key]: value })
}
function updateColor(key, value) {
emit('update:modelValue', {
...props.modelValue,
color: { ...(props.modelValue.color || { color: '', bg: '' }), [key]: value || '' },
})
}
</script>
<style scoped>
.float-common-fields {
display: flex;
flex-direction: column;
gap: 10px;
}
.float-common-fields__colors {
display: flex;
gap: 16px;
flex-wrap: wrap;
width: 100%;
}
.float-common-fields__color {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 180px;
flex: 1;
font-size: 12px;
color: var(--n-text-color-3);
}
.float-common-fields__desc {
font-size: 12px;
color: var(--n-text-color-3);
padding-left: 110px;
}
</style>
@@ -0,0 +1,157 @@
<template>
<div class="float-btn-panel">
<div class="float-btn-panel__head" @click="expanded = !expanded">
<button type="button" class="float-btn-panel__handle" aria-label="拖拽排序" @click.stop>
<i class="fa fas fa-arrows-alt-v" />
</button>
<strong class="float-btn-panel__title">{{ meta.title }}</strong>
<template v-if="modelValue.type !== 'more'">
<n-tag v-if="modelValue.pc_s" size="small" type="info" :bordered="false">PC</n-tag>
<n-tag v-if="modelValue.m_s" size="small" type="warning" :bordered="false">移动</n-tag>
<n-tag v-if="!modelValue.pc_s && !modelValue.m_s" size="small" :bordered="false">未显示</n-tag>
</template>
<n-tag v-else size="small" :bordered="false">{{ modelValue.buttons.length }} 个</n-tag>
<span v-if="meta.warn" class="float-btn-panel__warn">
<i class="fa fas fa-info-circle" /> {{ meta.warn }}
</span>
<i class="fa fas float-btn-panel__arrow" :class="expanded ? 'fa-angle-up' : 'fa-angle-down'" />
</div>
<div v-show="expanded" class="float-btn-panel__body">
<div class="float-btn-panel__subtitle">{{ meta.panel }}</div>
<FloatMoreButtonsEditor
v-if="modelValue.type === 'more'"
:model-value="modelValue.buttons"
@update:model-value="patch({ buttons: $event })"
/>
<template v-else>
<FloatBtnCommonFields :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" />
<n-form label-placement="left" label-width="110" class="aksrt-form float-btn-panel__extra">
<n-form-item v-if="modelValue.type === 'qrcode'" label="一句话简介">
<n-input :value="modelValue.desc" @update:value="patch({ desc: $event })" />
</n-form-item>
<n-form-item v-if="modelValue.type === 'add'" label="按钮图标">
<FaIconPicker v-model="addIcon" placeholder="选择图标" />
</n-form-item>
<n-form-item v-if="modelValue.type === 'service_qq'" label="QQ号码">
<n-input :value="modelValue.qq" placeholder="仅填写数字" @update:value="patch({ qq: $event })" />
</n-form-item>
<n-form-item v-if="modelValue.type === 'service_wechat'" label="微信二维码">
<AksMediaField
:model-value="modelValue.wechat_img"
placeholder="选择二维码图片"
@update:model-value="patch({ wechat_img: $event })"
/>
</n-form-item>
</n-form>
</template>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import FaIconPicker from '@/components/form/FaIconPicker.vue'
import AksMediaField from '@/components/form/AksMediaField.vue'
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
import FloatMoreButtonsEditor from './FloatMoreButtonsEditor.vue'
import { FLOAT_BTN_META } from './floating-defaults.js'
const props = defineProps({
modelValue: { type: Object, required: true },
})
const emit = defineEmits(['update:modelValue'])
const expanded = ref(false)
const meta = computed(() => FLOAT_BTN_META[props.modelValue.type] || { title: props.modelValue.type, panel: '' })
const addIcon = computed({
get: () => props.modelValue.icon || '',
set: (value) => patch({ icon: value || '' }),
})
function patch(partial) {
emit('update:modelValue', { ...props.modelValue, ...partial })
}
</script>
<style scoped>
.float-btn-panel {
border: 1px solid var(--n-border-color, rgba(0, 0, 0, 0.09));
border-radius: 8px;
background: var(--n-card-color, #fff);
}
.float-btn-panel__head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
cursor: pointer;
user-select: none;
}
.float-btn-panel__handle {
width: 26px;
height: 26px;
border: 0;
background: transparent;
color: var(--n-text-color-3, #999);
cursor: grab;
}
.float-btn-panel__handle:active {
cursor: grabbing;
}
.float-btn-panel__title {
font-size: 14px;
}
.float-btn-panel__arrow {
margin-left: auto;
color: var(--n-text-color-3, #999);
}
.float-btn-panel__warn {
font-size: 12px;
color: #f0a020;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.float-btn-panel__body {
padding: 12px 16px 16px;
border-top: 1px solid var(--n-border-color, rgba(0, 0, 0, 0.09));
}
.float-btn-panel__subtitle {
font-weight: 600;
font-size: 13px;
margin-bottom: 12px;
}
.float-btn-panel__extra {
margin-top: 12px;
}
.float-btn-panel__block {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.float-btn-panel__desc {
font-size: 12px;
color: var(--n-text-color-3, #999);
}
</style>
@@ -0,0 +1,158 @@
<template>
<div class="float-more-editor">
<n-collapse :default-expanded-names="[]" accordion>
<n-collapse-item
v-for="(btn, index) in modelValue"
:key="index"
:name="index"
>
<template #header>
<span class="float-more-editor__head">
<IconValue v-if="btn.icon" :value="btn.icon" />
<span>{{ btn.desc || `按钮 ${index + 1}` }}</span>
</span>
</template>
<template #header-extra>
<n-button
size="tiny"
quaternary
type="error"
:disabled="modelValue.length <= 1"
@click.stop="remove(index)"
>
<template #icon><i class="fa fas fa-trash-alt" /></template>
</n-button>
</template>
<n-form label-placement="left" label-width="110" class="aksrt-form">
<n-form-item label="按钮简介">
<n-input :value="btn.desc" @update:value="patch(index, { desc: $event })" />
</n-form-item>
<n-form-item label="按钮图标">
<FaIconPicker :model-value="btn.icon" placeholder="选择图标" @update:model-value="patch(index, { icon: $event || '' })" />
</n-form-item>
<n-form-item v-if="!btn.hover" label="按钮链接">
<div class="float-more-editor__link">
<n-input
:value="btn.link.url"
placeholder="https://"
@update:value="patch(index, { link: { ...btn.link, url: $event } })"
/>
<n-checkbox
:checked="btn.link.target === '_blank'"
@update:checked="patch(index, { link: { ...btn.link, target: $event ? '_blank' : '' } })"
>
新窗口打开
</n-checkbox>
</div>
</n-form-item>
<n-form-item label="悬浮内容">
<div class="float-more-editor__block">
<div class="float-more-editor__subtitle">鼠标移到此处时显示内容</div>
<n-input
type="textarea"
:value="btn.hover"
:autosize="{ minRows: 2, maxRows: 8 }"
@update:value="patch(index, { hover: $event })"
/>
<div class="float-more-editor__desc">设置此处后,按钮链接将失效<br>支持HTML代码,请注意代码规范及标签闭合</div>
</div>
</n-form-item>
<n-form-item v-if="btn.hover" label="悬浮内容宽度">
<div class="float-more-editor__block">
<n-input-number
:value="btn.hover_width"
:min="50"
:max="500"
:step="10"
style="width: 180px;"
@update:value="patch(index, { hover_width: $event ?? 200 })"
>
<template #suffix>PX</template>
</n-input-number>
<div class="float-more-editor__desc">移动端宽度最大240px</div>
</div>
</n-form-item>
</n-form>
<FloatBtnCommonFields :model-value="btn" @update:model-value="replace(index, $event)" />
</n-collapse-item>
</n-collapse>
<div class="float-more-editor__footer">
<n-button size="small" type="primary" @click="add">
<template #icon><i class="fa fas fa-plus" /></template>
添加按钮
</n-button>
<span class="float-more-editor__desc">此处内容至少保留一个,如一个都无需显示请关闭PC端和移动端显示即可</span>
</div>
</div>
</template>
<script setup>
import FaIconPicker from '@/components/form/FaIconPicker.vue'
import IconValue from '@/components/form/IconValue.vue'
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
import { defaultMoreButton } from './floating-defaults.js'
const props = defineProps({
modelValue: { type: Array, required: true },
})
const emit = defineEmits(['update:modelValue'])
function replace(index, value) {
const next = props.modelValue.slice()
next.splice(index, 1, value)
emit('update:modelValue', next)
}
function patch(index, partial) {
replace(index, { ...props.modelValue[index], ...partial })
}
function add() {
emit('update:modelValue', [...props.modelValue, defaultMoreButton()])
}
function remove(index) {
if (props.modelValue.length <= 1) return
const next = props.modelValue.slice()
next.splice(index, 1)
emit('update:modelValue', next)
}
</script>
<style scoped>
.float-more-editor__head {
display: inline-flex;
align-items: center;
gap: 8px;
}
.float-more-editor__link {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.float-more-editor__block {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.float-more-editor__subtitle,
.float-more-editor__desc {
font-size: 12px;
color: var(--n-text-color-3);
}
.float-more-editor__footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,114 @@
/**
* 右侧悬浮按钮:类型元数据与默认值(与 src/Settings/FloatButtons.php 保持一致)。
*/
export const emptyColor = () => ({ color: '', bg: '' })
/** 类型元数据:面板标题、折叠标题、黄色提示。 */
export const FLOAT_BTN_META = {
back_top: { title: '返回顶部', panel: '返回顶部', warn: '此按钮只能放置在最顶部或者最底部!' },
qrcode: { title: '当前页面二维码', panel: '当前页面二维码' },
add: { title: 'Add发布内容', panel: '发布投稿', warn: '此按钮尽量放在上方,否则会显示遮挡' },
theme_mode: { title: '日夜主题切换', panel: '日夜主题切换' },
pay_vip: { title: 'VIP购买', panel: 'VIP购买' },
service_qq: { title: 'QQ客服', panel: 'QQ客服' },
service_wechat: { title: '微信客服', panel: '微信客服' },
more: { title: '更多按钮', panel: '添加更多自定义按钮' },
}
const themeUri = (window.aksrtAdminData?.themeUri || '').replace(/\/$/, '')
/** 「更多按钮」出厂默认项(对齐参考主题 float_btn.more 默认值)。 */
export function giftMoreButton() {
return {
desc: '本站同款主题模板',
icon: 'zibsvg-gift-color',
link: { url: '', target: '' },
hover: `<a href="https://www.zibll.com/" target="_blank">
<div class="flex c-red">
<img class="flex0" alt="zibll子比主题" src="${themeUri}/assets/img/logo.png" height="30">
<div class="flex1 ml10">
<dt>本站同款主题模板</dt>
<div class="px12 mt10 muted-color">zibll子比主题是一款漂亮优雅的网站主题模板,功能强大,配置简单。</div>
<div class="but mt10 p2-10 c-blue btn-block px12">查看详情</div>
</div>
</div>
</a>`,
hover_width: 240,
pc_s: true,
m_s: true,
color: { color: '', bg: 'rgba(255, 111, 6, 0.2)' },
}
}
/** 「添加按钮」新增项默认值(参考主题 group 字段默认值)。 */
export function defaultMoreButton() {
return {
desc: '',
icon: 'fa fas fa-heart',
link: { url: '', target: '' },
hover: '',
hover_width: 200,
pc_s: true,
m_s: true,
color: { color: '', bg: '' },
}
}
export function defaultItems() {
return [
{ type: 'more', buttons: [giftMoreButton()] },
{ type: 'pay_vip', pc_s: true, m_s: true, color: { color: '#f2c97d', bg: 'rgba(62,62,67,0.9)' } },
{ type: 'add', pc_s: true, m_s: false, color: emptyColor(), icon: 'fa fas fa-pencil-alt' },
{ type: 'service_qq', pc_s: false, m_s: false, color: emptyColor(), qq: '' },
{ type: 'service_wechat', pc_s: true, m_s: true, color: emptyColor(), wechat_img: '' },
{ type: 'theme_mode', pc_s: false, m_s: false, color: emptyColor() },
{ type: 'qrcode', pc_s: true, m_s: false, color: emptyColor(), desc: '在手机上浏览此页面' },
{ type: 'back_top', pc_s: true, m_s: true, color: emptyColor() },
]
}
export function defaultSettings() {
return {
style: 'round',
position: 'bottom',
filter_blur: ['m_s'],
scroll_hide: false,
scroll_down_hide: true,
items: defaultItems(),
}
}
/** 规范化颜色组:确保 color/bg 都为字符串。 */
export function normalizeColor(color) {
const c = color && typeof color === 'object' ? color : {}
return { color: c.color || '', bg: c.bg || '' }
}
/** 用默认值补齐服务端返回的按钮(按服务端顺序)。 */
export function normalizeItems(items) {
const defaults = Object.fromEntries(defaultItems().map((item) => [item.type, item]))
const source = Array.isArray(items) && items.length ? items : defaultItems()
const out = []
const seen = new Set()
for (const raw of source) {
const base = defaults[raw?.type]
if (!base || seen.has(raw.type)) continue
seen.add(raw.type)
if (raw.type === 'more') {
const buttons = (Array.isArray(raw.buttons) && raw.buttons.length ? raw.buttons : base.buttons).map((btn) => ({
...defaultMoreButton(),
...btn,
link: { url: btn?.link?.url || '', target: btn?.link?.target || '' },
color: normalizeColor(btn?.color),
}))
out.push({ type: 'more', buttons })
continue
}
out.push({ ...base, ...raw, color: normalizeColor(raw.color) })
}
for (const item of defaultItems()) {
if (!seen.has(item.type)) out.push(item)
}
return out
}
@@ -15,7 +15,7 @@
class="aksrt-array-table__add" class="aksrt-array-table__add"
@click="addItem" @click="addItem"
> >
<FaIcon icon="fas fa-plus" :size="12" /> <FaIcon icon="fa fas fa-plus" :size="12" />
{{ addText || '添加行' }} {{ addText || '添加行' }}
</n-button> </n-button>
</div> </div>
@@ -105,13 +105,13 @@ const tableColumns = computed(() => {
render(row, rowIndex) { render(row, rowIndex) {
return h('div', { style: 'display:flex;gap:2px' }, [ return h('div', { style: 'display:flex;gap:2px' }, [
rowIndex > 0 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveUp(rowIndex) }, { rowIndex > 0 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveUp(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fas fa-arrow-up', size: 12 }), default: () => h(FaIcon, { icon: 'fa fas fa-arrow-up', size: 12 }),
}) : null, }) : null,
rowIndex < (props.modelValue?.length || 0) - 1 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveDown(rowIndex) }, { rowIndex < (props.modelValue?.length || 0) - 1 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveDown(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fas fa-arrow-down', size: 12 }), default: () => h(FaIcon, { icon: 'fa fas fa-arrow-down', size: 12 }),
}) : null, }) : null,
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => removeItem(rowIndex) }, { h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => removeItem(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fas fa-trash', size: 12 }), default: () => h(FaIcon, { icon: 'fa fas fa-trash-alt', size: 12 }),
}), }),
]) ])
}, },
File diff suppressed because it is too large. Load diff
@@ -26,7 +26,7 @@
@click.stop="toggleCollapse" @click.stop="toggleCollapse"
> >
<FaIcon <FaIcon
:icon="collapsed ? 'fas fa-chevron-right' : 'fas fa-chevron-down'" :icon="collapsed ? 'fa fas fa-chevron-right' : 'fa fas fa-chevron-down'"
:size="12" :size="12"
/> />
<span>{{ collapsed ? '展开' : '收起' }}</span> <span>{{ collapsed ? '展开' : '收起' }}</span>
@@ -72,9 +72,10 @@ function toggleCollapse() {
<style scoped> <style scoped>
.aksrt-field-group { .aksrt-field-group {
margin-bottom: 20px; margin-bottom: 20px;
border: 1px solid #eef1f6; border: 1px solid var(--aksrt-color-border);
border-radius: 12px; border-radius: 12px;
background: #fff; background: var(--aksrt-color-surface);
box-shadow: var(--aksrt-shadow-card);
overflow: hidden; overflow: hidden;
} }
@@ -92,7 +93,7 @@ function toggleCollapse() {
} }
.aksrt-field-group__header.is-clickable:hover .aksrt-field-group__title { .aksrt-field-group__header.is-clickable:hover .aksrt-field-group__title {
color: #1890ff; color: var(--aksrt-color-primary);
} }
.aksrt-field-group.is-collapsed .aksrt-field-group__header { .aksrt-field-group.is-collapsed .aksrt-field-group__header {
@@ -114,7 +115,7 @@ function toggleCollapse() {
} }
.aksrt-field-group__icon { .aksrt-field-group__icon {
color: #1890ff; color: var(--aksrt-color-primary);
margin-top: 2px; margin-top: 2px;
} }
@@ -122,14 +123,14 @@ function toggleCollapse() {
margin: 0; margin: 0;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: #1a1a2e; color: var(--aksrt-color-text);
transition: color 0.15s; transition: color 0.15s;
} }
.aksrt-field-group__desc { .aksrt-field-group__desc {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 12px; font-size: 12px;
color: #8a909c; color: var(--aksrt-color-muted);
line-height: 1.5; line-height: 1.5;
} }
@@ -143,9 +144,9 @@ function toggleCollapse() {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
border: 1px solid #e8ecf2; border: 1px solid var(--aksrt-color-border);
background: #f7f9fc; background: var(--aksrt-color-canvas);
color: #5c6370; color: var(--aksrt-color-text-secondary);
border-radius: 999px; border-radius: 999px;
padding: 4px 10px; padding: 4px 10px;
font-size: 12px; font-size: 12px;
@@ -154,9 +155,9 @@ function toggleCollapse() {
} }
.aksrt-field-group__collapse-btn:hover { .aksrt-field-group__collapse-btn:hover {
color: #1890ff; color: var(--aksrt-color-primary);
border-color: #91caff; border-color: var(--aksrt-color-primary);
background: #f0f7ff; background: var(--aksrt-color-primary-soft);
} }
.aksrt-field-group__body { .aksrt-field-group__body {
@@ -17,7 +17,7 @@
clearable clearable
> >
<template #prefix> <template #prefix>
<FaIcon icon="fas fa-search" :size="12" /> <FaIcon icon="fa fas fa-search" :size="12" />
</template> </template>
</n-input> </n-input>
</div> </div>
@@ -53,27 +53,63 @@
:collapsible="!!group.collapsible" :collapsible="!!group.collapsible"
:default-collapsed="resolveGroupCollapsed(group)" :default-collapsed="resolveGroupCollapsed(group)"
> >
<template v-if="group.fields"> <template v-if="group.fields && !hasGroupSlot(group)">
<template v-for="field in group.fields" :key="field.key"> <template v-for="field in group.fields" :key="field.key">
<template v-if="isFieldVisible(field)"> <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 <slot
v-if="field.type === 'custom'" v-else-if="field.type === 'custom'"
:name="`field-${field.key}`" :name="`field-${field.key}`"
v-bind="{ field, value: getFieldValue(field.key), setValue: (value) => setFieldValue(field.key, value), formData }" v-bind="{ field, value: getFieldValue(field.key), setValue: (value) => setFieldValue(field.key, value), formData }"
> >
<AksField <AksField
:schema="field" :schema="field"
:model-value="getFieldValue(field.key)" :model-value="getFieldValue(field.key)"
:form-data="formData"
:error-message="errors?.[field.key]" :error-message="errors?.[field.key]"
@update:model-value="setFieldValue(field.key, $event)" @update:model-value="setFieldValue(field.key, $event)"
@action="emit('field-action', $event)"
/> />
</slot> </slot>
<AksField <AksField
v-else v-else
:schema="field" :schema="field"
:model-value="getFieldValue(field.key)" :model-value="getFieldValue(field.key)"
:form-data="formData"
:error-message="errors?.[field.key]" :error-message="errors?.[field.key]"
@update:model-value="setFieldValue(field.key, $event)" @update:model-value="setFieldValue(field.key, $event)"
@action="emit('field-action', $event)"
/> />
</template> </template>
</template> </template>
@@ -87,31 +123,12 @@
<!-- Empty state --> <!-- Empty state -->
<div v-else class="aksrt-form-renderer__empty"> <div v-else class="aksrt-form-renderer__empty">
<FaIcon icon="fas fa-cog" :size="32" /> <FaIcon icon="fa fas fa-cog" :size="32" />
<p>暂无配置项</p> <p>暂无配置项</p>
</div> </div>
<!-- Sticky save bar --> <!-- Sticky save bar -->
<div v-if="dirty" class="aksrt-form-renderer__save-bar"> <AksSaveBar :dirty="dirty" :saving="saving" @save="$emit('save')" @reload="$emit('reload')" />
<div class="aksrt-form-renderer__save-bar__inner">
<div class="aksrt-form-renderer__save-bar__info">
<FaIcon icon="fas fa-exclamation-circle" :size="14" />
<span>您有未保存的更改</span>
</div>
<div class="aksrt-form-renderer__save-bar__actions">
<n-button size="small" @click="$emit('reload')">放弃更改</n-button>
<n-button
size="small"
type="primary"
:loading="saving"
@click="$emit('save')"
>
<FaIcon v-if="!saving" icon="fas fa-save" :size="12" />
保存设置
</n-button>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -127,10 +144,12 @@
* - Dependency-based field visibility * - Dependency-based field visibility
* - Loading and error states * - Loading and error states
*/ */
import { ref, computed } from 'vue' import { ref, computed, useSlots } from 'vue'
import FaIcon from './FaIcon.vue' import FaIcon from './FaIcon.vue'
import AksSaveBar from './AksSaveBar.vue'
import AksFieldGroup from './AksFieldGroup.vue' import AksFieldGroup from './AksFieldGroup.vue'
import AksField from './AksField.vue' import AksField from './AksField.vue'
import AksSorterField from './AksSorterField.vue'
const props = defineProps({ const props = defineProps({
/** Page title */ /** Page title */
@@ -161,7 +180,8 @@ const props = defineProps({
errors: { type: Object, default: () => ({}) }, errors: { type: Object, default: () => ({}) },
}) })
const emit = defineEmits(['update:formData', 'save', 'reload']) const emit = defineEmits(['update:formData', 'save', 'reload', 'field-action'])
const slots = useSlots()
const searchQuery = ref('') const searchQuery = ref('')
@@ -172,8 +192,16 @@ function matchDependsOn(dependsOn) {
return !!props.formData[dependsOn] return !!props.formData[dependsOn]
} }
if (typeof dependsOn === 'object') { if (typeof dependsOn === 'object') {
const { key, value, values, not } = dependsOn 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] 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 (not) {
if (Array.isArray(values)) { if (Array.isArray(values)) {
// eslint-disable-next-line eqeqeq // eslint-disable-next-line eqeqeq
@@ -194,11 +222,27 @@ function matchDependsOn(dependsOn) {
return true return true
} }
function hasGroupSlot(group) {
return !!slots[`group-${group.key}`]
}
/** Check if a field should be visible based on its dependsOn rule */ /** Check if a field should be visible based on its dependsOn rule */
function isFieldVisible(field) { function isFieldVisible(field) {
return matchDependsOn(field.dependsOn) 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). */ /** Whole group can be hidden (e.g. optional advanced blocks). */
function isGroupVisible(group) { function isGroupVisible(group) {
return matchDependsOn(group.dependsOn) return matchDependsOn(group.dependsOn)
@@ -253,6 +297,15 @@ const filteredSchema = computed(() => {
// Filter fields by label, key, or help text matching query // Filter fields by label, key, or help text matching query
const matchingFields = group.fields.filter(field => { const matchingFields = group.fields.filter(field => {
if (groupMatch) return true 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) return (field.label || '').toLowerCase().includes(q)
|| (field.key || '').toLowerCase().includes(q) || (field.key || '').toLowerCase().includes(q)
|| (field.help || '').toLowerCase().includes(q) || (field.help || '').toLowerCase().includes(q)
@@ -284,7 +337,7 @@ const filteredSchema = computed(() => {
} }
.aksrt-form-renderer__header-icon { .aksrt-form-renderer__header-icon {
color: #1890ff; color: var(--aksrt-color-primary);
margin-top: 4px; margin-top: 4px;
} }
@@ -292,17 +345,21 @@ const filteredSchema = computed(() => {
margin: 0; margin: 0;
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
color: #1a1a2e; color: var(--aksrt-color-text);
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.aksrt-form-renderer__desc { .aksrt-form-renderer__desc {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 13px; font-size: 13px;
color: #7a7f8a; color: var(--aksrt-color-muted);
line-height: 1.6; line-height: 1.6;
} }
.aksrt-form-renderer__tabbed {
margin-bottom: 8px;
}
.aksrt-form-renderer__search { .aksrt-form-renderer__search {
min-width: 200px; min-width: 200px;
max-width: 280px; max-width: 280px;
@@ -314,7 +371,7 @@ const filteredSchema = computed(() => {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 60px 0; padding: 60px 0;
color: #8a909c; color: var(--aksrt-color-muted);
font-size: 14px; font-size: 14px;
} }
@@ -324,7 +381,7 @@ const filteredSchema = computed(() => {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 60px 0; padding: 60px 0;
color: #c0c4cc; color: var(--aksrt-color-border-strong);
} }
.aksrt-form-renderer__empty p { .aksrt-form-renderer__empty p {
@@ -332,53 +389,6 @@ const filteredSchema = computed(() => {
font-size: 14px; font-size: 14px;
} }
/* Sticky save bar */
.aksrt-form-renderer__save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background: #fff;
border-top: 1px solid #eef1f6;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
animation: slideUp 0.2s ease;
}
.aksrt-form-renderer__save-bar__inner {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1100px;
margin: 0 auto;
padding: 12px 28px;
gap: 16px;
}
.aksrt-form-renderer__save-bar__info {
display: flex;
align-items: center;
gap: 8px;
color: #d48806;
font-size: 13px;
}
.aksrt-form-renderer__save-bar__actions {
display: flex;
gap: 8px;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Mobile */ /* Mobile */
@media (max-width: 768px) { @media (max-width: 768px) {
.aksrt-form-renderer__header { .aksrt-form-renderer__header {
@@ -390,19 +400,5 @@ const filteredSchema = computed(() => {
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;
} }
.aksrt-form-renderer__save-bar__inner {
flex-direction: column;
padding: 10px 14px;
gap: 8px;
}
.aksrt-form-renderer__save-bar__actions {
width: 100%;
}
.aksrt-form-renderer__save-bar__actions .n-button {
flex: 1;
}
} }
</style> </style>
@@ -0,0 +1,234 @@
<template>
<div class="aksrt-gallery-field">
<div v-if="attachmentIds.length" class="aksrt-gallery-field__grid">
<article
v-for="(attachmentId, index) in attachmentIds"
:key="attachmentId"
class="aksrt-gallery-field__item"
>
<img
v-if="previews[attachmentId]?.url"
:src="previews[attachmentId].url"
:alt="previews[attachmentId].alt || `附件 ${attachmentId}`"
class="aksrt-gallery-field__image"
/>
<div v-else class="aksrt-gallery-field__placeholder">
<FaIcon icon="fa fas fa-image" :size="22" />
<span>#{{ attachmentId }}</span>
</div>
<div class="aksrt-gallery-field__actions">
<n-button
size="tiny"
quaternary
:disabled="index === 0"
aria-label="向前移动"
@click="move(index, -1)"
>
<FaIcon icon="fa fas fa-arrow-left" />
</n-button>
<n-button
size="tiny"
quaternary
:disabled="index === attachmentIds.length - 1"
aria-label="向后移动"
@click="move(index, 1)"
>
<FaIcon icon="fa fas fa-arrow-right" />
</n-button>
<n-button
size="tiny"
quaternary
type="error"
aria-label="移除图片"
@click="remove(attachmentId)"
>
<FaIcon icon="fa fas fa-trash-alt" />
</n-button>
</div>
</article>
</div>
<div v-else class="aksrt-gallery-field__empty">
<FaIcon icon="fa fas fa-images" :size="28" />
<span>尚未选择图片</span>
</div>
<div class="aksrt-gallery-field__buttons">
<n-button type="primary" secondary @click="openMediaLibrary">
<FaIcon icon="fa fas fa-plus" />
{{ attachmentIds.length ? '编辑图片' : '选择图片' }}
</n-button>
<n-button v-if="attachmentIds.length" type="error" quaternary @click="clear">
清空图片
</n-button>
</div>
<p v-if="mediaError" class="aksrt-gallery-field__error">{{ mediaError }}</p>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
title: { type: String, default: '选择图片' },
})
const emit = defineEmits(['update:modelValue'])
const previews = ref({})
const mediaError = ref('')
let previewRequest = 0
const attachmentIds = computed(() => {
const seen = new Set()
return props.modelValue
.map(value => Number.parseInt(value, 10))
.filter(value => value > 0 && !seen.has(value) && seen.add(value))
})
function update(ids) {
emit('update:modelValue', [...ids])
}
function move(index, direction) {
const ids = [...attachmentIds.value]
const target = index + direction
if (target < 0 || target >= ids.length) return
;[ids[index], ids[target]] = [ids[target], ids[index]]
update(ids)
}
function remove(attachmentId) {
update(attachmentIds.value.filter(id => id !== attachmentId))
}
function clear() {
update([])
}
async function hydratePreviews() {
const request = ++previewRequest
const media = window.wp?.media
if (!media) return
const entries = await Promise.all(attachmentIds.value.map(async attachmentId => {
const attachment = media.attachment(attachmentId)
try {
if (!attachment.get('url')) await attachment.fetch()
} catch (error) {
return [attachmentId, null]
}
const sizes = attachment.get('sizes') || {}
return [attachmentId, {
url: sizes.thumbnail?.url || sizes.medium?.url || attachment.get('url') || '',
alt: attachment.get('alt') || attachment.get('title') || '',
}]
}))
if (request === previewRequest) {
previews.value = Object.fromEntries(entries.filter(([, preview]) => preview))
}
}
function openMediaLibrary() {
const media = window.wp?.media
if (!media) {
mediaError.value = '当前后台未加载 WordPress 媒体库。'
return
}
mediaError.value = ''
const frame = media({
title: props.title,
button: { text: '使用所选图片' },
library: { type: 'image' },
multiple: true,
})
frame.on('open', () => {
const selection = frame.state().get('selection')
attachmentIds.value.forEach(attachmentId => {
const attachment = media.attachment(attachmentId)
attachment.fetch()
selection.add(attachment)
})
})
frame.on('select', () => {
const ids = frame.state().get('selection').toJSON()
.map(attachment => Number.parseInt(attachment.id, 10))
.filter(attachmentId => attachmentId > 0)
update([...new Set(ids)])
})
frame.open()
}
watch(attachmentIds, hydratePreviews, { immediate: true })
</script>
<style scoped>
.aksrt-gallery-field__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 10px;
}
.aksrt-gallery-field__item {
overflow: hidden;
border: 1px solid var(--aksrt-color-border);
border-radius: 8px;
background: var(--aksrt-color-canvas);
}
.aksrt-gallery-field__image,
.aksrt-gallery-field__placeholder {
display: flex;
width: 100%;
aspect-ratio: 4 / 3;
align-items: center;
justify-content: center;
object-fit: cover;
}
.aksrt-gallery-field__placeholder {
flex-direction: column;
gap: 4px;
color: var(--aksrt-color-muted);
}
.aksrt-gallery-field__actions {
display: flex;
justify-content: center;
padding: 4px;
border-top: 1px solid var(--aksrt-color-border);
}
.aksrt-gallery-field__empty {
display: flex;
min-height: 116px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
border: 1px dashed var(--aksrt-color-border-strong);
border-radius: 8px;
color: var(--aksrt-color-muted);
}
.aksrt-gallery-field__buttons {
display: flex;
gap: 8px;
margin-top: 10px;
}
.aksrt-gallery-field__error {
margin: 8px 0 0;
color: #d03050;
font-size: 12px;
}
</style>
@@ -8,21 +8,21 @@
class="aksrt-media-field__image" class="aksrt-media-field__image"
/> />
<div v-else class="aksrt-media-field__file"> <div v-else class="aksrt-media-field__file">
<FaIcon icon="fas fa-file" :size="24" /> <FaIcon icon="fa fas fa-file" :size="24" />
<span class="aksrt-media-field__filename">{{ fileName }}</span> <span class="aksrt-media-field__filename">{{ fileName }}</span>
</div> </div>
<div class="aksrt-media-field__overlay"> <div class="aksrt-media-field__overlay">
<n-button size="tiny" quaternary @click="openMediaLibrary"> <n-button size="tiny" quaternary @click="openMediaLibrary">
<FaIcon icon="fas fa-exchange-alt" /> 更换 <FaIcon icon="fa fas fa-exchange-alt" /> 更换
</n-button> </n-button>
<n-button size="tiny" quaternary type="error" @click="clearMedia"> <n-button size="tiny" quaternary type="error" @click="clearMedia">
<FaIcon icon="fas fa-trash" /> 移除 <FaIcon icon="fa fas fa-trash-alt" /> 移除
</n-button> </n-button>
</div> </div>
</div> </div>
<div v-else class="aksrt-media-field__empty" @click="openMediaLibrary"> <div v-else class="aksrt-media-field__empty" @click="openMediaLibrary">
<FaIcon icon="fas fa-cloud-upload-alt" :size="28" /> <FaIcon icon="fa fas fa-cloud-upload-alt" :size="28" />
<span>{{ placeholder }}</span> <span>{{ placeholder }}</span>
</div> </div>
@@ -99,7 +99,7 @@ function clearMedia() {
position: relative; position: relative;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
border: 1px solid #eef1f6; border: 1px solid var(--aksrt-color-border);
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -115,12 +115,12 @@ function clearMedia() {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 16px; padding: 16px;
background: #f8fafc; background: var(--aksrt-color-canvas);
} }
.aksrt-media-field__filename { .aksrt-media-field__filename {
font-size: 13px; font-size: 13px;
color: #555; color: var(--aksrt-color-text-secondary);
word-break: break-all; word-break: break-all;
} }
@@ -146,19 +146,19 @@ function clearMedia() {
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 32px; padding: 32px;
border: 2px dashed #d9dce3; border: 2px dashed var(--aksrt-color-border-strong);
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
color: #8a909c; color: var(--aksrt-color-muted);
font-size: 13px; font-size: 13px;
transition: all 0.2s; transition: all 0.2s;
margin-bottom: 8px; margin-bottom: 8px;
} }
.aksrt-media-field__empty:hover { .aksrt-media-field__empty:hover {
border-color: #1890ff; border-color: var(--aksrt-color-primary);
color: #1890ff; color: var(--aksrt-color-primary);
background: #f0f7ff; background: var(--aksrt-color-primary-soft);
} }
.aksrt-media-field__url { .aksrt-media-field__url {
@@ -15,7 +15,7 @@
quaternary quaternary
@click="moveUp(index)" @click="moveUp(index)"
> >
<FaIcon icon="fas fa-arrow-up" :size="12" /> <FaIcon icon="fa fas fa-arrow-up" :size="12" />
</n-button> </n-button>
<n-button <n-button
v-if="index < modelValue.length - 1" v-if="index < modelValue.length - 1"
@@ -23,7 +23,7 @@
quaternary quaternary
@click="moveDown(index)" @click="moveDown(index)"
> >
<FaIcon icon="fas fa-arrow-down" :size="12" /> <FaIcon icon="fa fas fa-arrow-down" :size="12" />
</n-button> </n-button>
<n-button <n-button
size="tiny" size="tiny"
@@ -31,7 +31,7 @@
type="error" type="error"
@click="removeItem(index)" @click="removeItem(index)"
> >
<FaIcon icon="fas fa-trash" :size="12" /> <FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button> </n-button>
</div> </div>
</div> </div>
@@ -49,7 +49,7 @@
</div> </div>
<div v-else class="aksrt-repeater__empty"> <div v-else class="aksrt-repeater__empty">
<FaIcon icon="fas fa-inbox" :size="20" /> <FaIcon icon="fa fas fa-inbox" :size="20" />
<span>{{ emptyText || '暂无数据' }}</span> <span>{{ emptyText || '暂无数据' }}</span>
</div> </div>
@@ -60,7 +60,7 @@
class="aksrt-repeater__add" class="aksrt-repeater__add"
@click="addItem" @click="addItem"
> >
<FaIcon icon="fas fa-plus" :size="12" /> <FaIcon icon="fa fas fa-plus" :size="12" />
{{ addText || '添加项目' }} {{ addText || '添加项目' }}
</n-button> </n-button>
</div> </div>
@@ -136,10 +136,10 @@ function updateField(index, key, value) {
} }
.aksrt-repeater__item { .aksrt-repeater__item {
border: 1px solid #eef1f6; border: 1px solid var(--aksrt-color-border);
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
background: #fafbfc; background: var(--aksrt-color-canvas);
} }
.aksrt-repeater__item-header { .aksrt-repeater__item-header {
@@ -152,8 +152,8 @@ function updateField(index, key, value) {
.aksrt-repeater__item-index { .aksrt-repeater__item-index {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #1890ff; color: var(--aksrt-color-primary);
background: #e6f4ff; background: var(--aksrt-color-primary-soft);
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
} }
@@ -175,7 +175,7 @@ function updateField(index, key, value) {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 24px; padding: 24px;
color: #c0c4cc; color: var(--aksrt-color-border-strong);
font-size: 13px; font-size: 13px;
} }
@@ -0,0 +1,101 @@
<template>
<div v-if="dirty" class="aksrt-form-renderer__save-bar">
<div class="aksrt-form-renderer__save-bar__inner">
<div class="aksrt-form-renderer__save-bar__info">
<FaIcon icon="fa fas fa-exclamation-circle" :size="14" />
<span>您有未保存的更改</span>
</div>
<div class="aksrt-form-renderer__save-bar__actions">
<n-button size="small" :disabled="saving" @click="emit('reload')">放弃更改</n-button>
<n-button
size="small"
type="primary"
:loading="saving"
@click="emit('save')"
>
<FaIcon v-if="!saving" icon="fa fas fa-save" :size="12" />
保存设置
</n-button>
</div>
</div>
</div>
</template>
<script setup>
/**
* AksSaveBar — the admin-wide sticky save bar (shown only when dirty).
* Shared by AksFormRenderer (schema pages) and custom settings pages.
*/
import FaIcon from './FaIcon.vue'
defineProps({
dirty: { type: Boolean, default: false },
saving: { type: Boolean, default: false },
})
const emit = defineEmits(['save', 'reload'])
</script>
<style scoped>
.aksrt-form-renderer__save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background: var(--aksrt-color-surface);
border-top: 1px solid var(--aksrt-color-border);
box-shadow: var(--aksrt-shadow-floating);
animation: slideUp 0.2s ease;
}
.aksrt-form-renderer__save-bar__inner {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1100px;
margin: 0 auto;
padding: 12px 28px;
gap: 16px;
}
.aksrt-form-renderer__save-bar__info {
display: flex;
align-items: center;
gap: 8px;
color: #d48806;
font-size: 13px;
}
.aksrt-form-renderer__save-bar__actions {
display: flex;
gap: 8px;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (max-width: 768px) {
.aksrt-form-renderer__save-bar__inner {
flex-direction: column;
padding: 10px 14px;
gap: 8px;
}
.aksrt-form-renderer__save-bar__actions {
width: 100%;
}
.aksrt-form-renderer__save-bar__actions .n-button {
flex: 1;
}
}
</style>
@@ -0,0 +1,112 @@
<template>
<div class="aksrt-sorter">
<div class="aksrt-sorter__column">
<div class="aksrt-sorter__title">
<span>已启用</span>
<n-tag size="small" type="success" :bordered="false">{{ columns.enabled.length }}</n-tag>
</div>
<draggable
v-model="columns.enabled"
item-key="value"
group="aksrt-sorter"
handle=".aksrt-sorter__handle"
ghost-class="aksrt-sorter__ghost"
animation="180"
class="aksrt-sorter__list"
@change="commit"
>
<template #item="{ element }">
<div class="aksrt-sorter__item">
<i class="fa fas fa-grip-vertical aksrt-sorter__handle" aria-hidden="true"></i>
<i v-if="element.icon" :class="element.icon" aria-hidden="true"></i>
<span>{{ element.label }}</span>
</div>
</template>
</draggable>
<n-empty v-if="columns.enabled.length === 0" size="small" description="暂无启用项" />
</div>
<div class="aksrt-sorter__column aksrt-sorter__column--disabled">
<div class="aksrt-sorter__title">
<span>未启用</span>
<n-tag size="small" :bordered="false">{{ columns.disabled.length }}</n-tag>
</div>
<draggable
v-model="columns.disabled"
item-key="value"
group="aksrt-sorter"
handle=".aksrt-sorter__handle"
ghost-class="aksrt-sorter__ghost"
animation="180"
class="aksrt-sorter__list"
@change="commit"
>
<template #item="{ element }">
<div class="aksrt-sorter__item">
<i class="fa fas fa-grip-vertical aksrt-sorter__handle" aria-hidden="true"></i>
<i v-if="element.icon" :class="element.icon" aria-hidden="true"></i>
<span>{{ element.label }}</span>
</div>
</template>
</draggable>
<n-empty v-if="columns.disabled.length === 0" size="small" description="暂无未启用项" />
</div>
</div>
</template>
<script setup>
import { reactive, watch } from 'vue'
import draggable from 'vuedraggable'
const props = defineProps({
modelValue: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue'])
const columns = reactive({ enabled: [], disabled: [] })
let syncing = false
function keysOf(value) {
if (Array.isArray(value)) return value.map(String)
return value && typeof value === 'object' ? Object.keys(value) : []
}
function syncColumns() {
syncing = true
const byKey = new Map(props.options.map(option => [String(option.value), option]))
const used = new Set()
const normalize = keys => keys.filter(key => byKey.has(key) && !used.has(key)).map(key => {
used.add(key)
return byKey.get(key)
})
columns.enabled = normalize(keysOf(props.modelValue?.enabled))
columns.disabled = normalize(keysOf(props.modelValue?.disabled))
for (const option of props.options) {
if (!used.has(String(option.value))) columns.disabled.push(option)
}
syncing = false
}
function commit() {
if (syncing) return
const toMap = items => Object.fromEntries(items.map(item => [String(item.value), String(item.label)]))
emit('update:modelValue', {
enabled: toMap(columns.enabled),
disabled: toMap(columns.disabled),
})
}
watch(() => [props.modelValue, props.options], syncColumns, { immediate: true, deep: true })
</script>
<style scoped>
.aksrt-sorter { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
.aksrt-sorter__column { min-height:210px; padding:12px; border:1px solid var(--aksrt-color-border); border-radius:6px; background:var(--aksrt-color-surface); }
.aksrt-sorter__column--disabled { background:var(--aksrt-color-canvas); }
.aksrt-sorter__title { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; font-weight:600; }
.aksrt-sorter__list { display:grid; gap:7px; min-height:44px; }
.aksrt-sorter__item { display:flex; align-items:center; gap:9px; min-height:38px; padding:7px 10px; border:1px solid var(--aksrt-color-border); border-radius:4px; background:var(--aksrt-color-surface); }
.aksrt-sorter__handle { color:var(--aksrt-color-text-tertiary); cursor:grab; }
.aksrt-sorter__ghost { opacity:.45; border-color:var(--aksrt-color-primary); }
@media (max-width:720px) { .aksrt-sorter { grid-template-columns:1fr; } }
</style>
+8 -8
View File
@@ -11,16 +11,16 @@
* FaIcon — Font Awesome 5 icon wrapper component. * FaIcon — Font Awesome 5 icon wrapper component.
* *
* Usage: * Usage:
* <FaIcon icon="fas fa-cog" /> * <FaIcon icon="fa fas fa-cog" />
* <FaIcon icon="fa-cog" /> → defaults to "fas" prefix * <FaIcon icon="fa-cog" /> → defaults to solid FA5
* <FaIcon icon="fab fa-github" /> * <FaIcon icon="fa fab fa-github" />
* <FaIcon :size="18" icon="far fa-heart" /> * <FaIcon :size="18" icon="fa fas fa-heart" />
* <FaIcon spin icon="fas fa-spinner" /> * <FaIcon spin icon="fa fas fa-spinner" />
*/ */
import { computed } from 'vue' import { computed } from 'vue'
const props = defineProps({ const props = defineProps({
/** FA5 class string, e.g. "fas fa-cog", "fab fa-github", or just "fa-cog" */ /** FA5 class string, e.g. "fa fas fa-cog", "fa fab fa-github", or just "fa-cog" */
icon: { type: String, required: true }, icon: { type: String, required: true },
/** Pixel size — maps to font-size */ /** Pixel size — maps to font-size */
size: { type: [Number, String], default: null }, size: { type: [Number, String], default: null },
@@ -41,10 +41,10 @@ const props = defineProps({
const iconClass = computed(() => { const iconClass = computed(() => {
const parts = [] const parts = []
// Auto-prefix: if only "fa-xxx" is given, default to "fas" // Auto-prefix: if only "fa-circle" is given, use the solid FA5 style.
const raw = props.icon.trim() const raw = props.icon.trim()
if (raw.startsWith('fa-')) { if (raw.startsWith('fa-')) {
parts.push('fas', raw) parts.push('fa', 'fas', raw)
} else { } else {
parts.push(...raw.split(/\s+/)) parts.push(...raw.split(/\s+/))
} }
+187 -110
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="aksrt-fa-icon-picker"> <div class="aksrt-fa-icon-picker">
<n-popover trigger="click" placement="bottom-start" :width="360" :style="{ padding: 0 }"> <n-popover v-model:show="popoverShow" trigger="click" placement="bottom-start" :width="460" :style="{ padding: 0 }">
<template #trigger> <template #trigger>
<n-input <n-input
:value="modelValue" :value="modelValue"
@@ -10,46 +10,79 @@
@clear="$emit('update:modelValue', '')" @clear="$emit('update:modelValue', '')"
> >
<template #prefix> <template #prefix>
<FaIcon v-if="modelValue" :icon="modelValue" /> <IconValue v-if="modelValue" :value="modelValue" />
<template v-else></template>
</template> </template>
<template #suffix> <template #suffix>
<FaIcon icon="fas fa-chevron-down" :size="12" /> <FaIcon icon="fa fas fa-chevron-down" :size="12" />
</template> </template>
</n-input> </n-input>
</template> </template>
<div class="aksrt-icon-picker__panel"> <div class="aksrt-icon-picker__panel" @click="onPanelClick">
<div class="aksrt-icon-picker__search"> <div class="aksrt-icon-picker__search">
<n-input <n-input v-model:value="search" placeholder="搜索图标..." size="small" clearable>
v-model:value="search"
placeholder="搜索图标..."
size="small"
clearable
>
<template #prefix> <template #prefix>
<FaIcon icon="fas fa-search" :size="12" /> <FaIcon icon="fa fas fa-search" :size="12" />
</template> </template>
</n-input> </n-input>
</div> </div>
<div class="aksrt-icon-picker__tabs"> <div class="aksrt-icon-picker__tabs">
<n-radio-group v-model:value="activePrefix" size="small"> <button
<n-radio-button value="fas">Solid</n-radio-button> v-for="tab in tabs"
<n-radio-button value="far">Regular</n-radio-button> :key="tab.id"
<n-radio-button value="fab">Brands</n-radio-button> type="button"
</n-radio-group> class="aksrt-icon-picker__tab"
:class="{ 'is-active': activeTab === tab.id }"
@click="activeTab = tab.id"
>
{{ tab.title }}
</button>
</div> </div>
<div class="aksrt-icon-picker__grid" ref="gridRef"> <!-- Custom SVG tab -->
<div v-if="activeTab === 'custom'" class="aksrt-icon-picker__custom">
<n-input v-model:value="customCode" type="textarea" placeholder='&lt;svg viewBox="0 0 1024 1024"&gt;&lt;path d="…"&gt;&lt;/path&gt;&lt;/svg&gt;' :rows="4" />
<div class="aksrt-icon-picker__custom-actions">
<span class="aksrt-icon-picker__custom-preview" v-html="customPreview"></span>
<n-button size="small" type="primary" :disabled="!customPreview" @click="insertCustom">
确认插入
</n-button>
</div>
<div
v-if="sessionCustom.length"
class="aksrt-icon-picker__grid aksrt-icon-picker__grid--session"
data-aksrt-icon-grid="custom"
>
<div
v-for="(svg, i) in sessionCustom"
:key="i"
class="aksrt-icon-picker__item"
:class="{ 'is-active': modelValue === `customsvg-${svg}` }"
title="customsvg"
:data-icon="`customsvg-${svg}`"
v-html="cleanSvg(svg)"
></div>
</div>
</div>
<!-- Catalog tab -->
<div v-else class="aksrt-icon-picker__grid" data-aksrt-icon-grid="catalog">
<div <div
v-for="icon in filteredIcons" v-for="icon in filteredIcons"
:key="icon" :key="icon"
class="aksrt-icon-picker__item" class="aksrt-icon-picker__item"
:class="{ 'is-active': modelValue === `${activePrefix} ${icon}` }" :class="{ 'is-active': modelValue === composeIconValue(activeGroup, icon) }"
:title="icon" :title="icon"
@click="selectIcon(icon)" :data-icon="composeIconValue(activeGroup, icon)"
> >
<i :class="[activePrefix, icon]"></i> <i v-if="activeGroup.type === 'fa'" :class="[activeTab, 'fa-' + icon]"></i>
<span
v-else
class="aksrt-icon-picker__preview-svg"
v-html="zibsvgMarkup(`zibsvg-${icon}`)"
></span>
</div> </div>
<div v-if="filteredIcons.length === 0" class="aksrt-icon-picker__empty"> <div v-if="filteredIcons.length === 0" class="aksrt-icon-picker__empty">
未找到匹配图标 未找到匹配图标
@@ -62,11 +95,19 @@
<script setup> <script setup>
/** /**
* FaIconPicker — searchable FA5 icon picker. * FaIconPicker — universal icon picker backed by the server catalog
* Renders an input that opens a popover grid of FA5 icons. * (GET /aksrt/v1/icons/catalog): full FA5 free set, the theme's built-in
* SVG sprite keys and inline custom SVG values.
*/ */
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import FaIcon from './FaIcon.vue' import FaIcon from './FaIcon.vue'
import IconValue from './IconValue.vue'
import api from '@/api/index.js'
import {
cleanSvg,
zibsvgMarkup,
composeIconValue,
} from '@/utils/icons.js'
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
@@ -75,100 +116,75 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const FALLBACK_TABS = [
{ id: 'fas', title: 'Solid', type: 'fa', icons: [] },
{ id: 'far', title: 'Regular', type: 'fa', icons: [] },
{ id: 'fab', title: 'Brands', type: 'fa', icons: [] },
{ id: 'zibsvg', title: '内置 SVG', type: 'svg', icons: [] },
{ id: 'custom', title: '自定义', type: 'custom', icons: [] },
]
const tabs = ref(FALLBACK_TABS)
const search = ref('') const search = ref('')
const activePrefix = ref('fas') const activeTab = ref('fas')
const gridRef = ref(null) const customCode = ref('')
const sessionCustom = ref([])
const popoverShow = ref(false)
// Common FA5 icons subset — a curated list covering most admin UI needs. const activeGroup = computed(() => tabs.value.find(t => t.id === activeTab.value) || tabs.value[0])
// Full FA5 has 1600+ icons; this subset has ~200 of the most useful ones.
const SOLID_ICONS = [
'fa-home', 'fa-cog', 'fa-cogs', 'fa-wrench', 'fa-sliders-h', 'fa-tools',
'fa-search', 'fa-filter', 'fa-sort', 'fa-sync', 'fa-redo', 'fa-undo',
'fa-plus', 'fa-minus', 'fa-times', 'fa-check', 'fa-exclamation', 'fa-info',
'fa-question', 'fa-bell', 'fa-envelope', 'fa-comment', 'fa-comments',
'fa-user', 'fa-users', 'fa-user-plus', 'fa-user-shield', 'fa-user-circle',
'fa-lock', 'fa-unlock', 'fa-key', 'fa-shield-alt', 'fa-fingerprint',
'fa-globe', 'fa-globe-americas', 'fa-link', 'fa-external-link-alt', 'fa-anchor',
'fa-image', 'fa-images', 'fa-camera', 'fa-video', 'fa-music', 'fa-file',
'fa-folder', 'fa-folder-open', 'fa-download', 'upload', 'fa-cloud',
'fa-database', 'fa-server', 'fa-hdd', 'fa-memory', 'fa-microchip',
'fa-desktop', 'fa-laptop', 'fa-mobile-alt', 'fa-tablet-alt',
'fa-chart-bar', 'fa-chart-line', 'fa-chart-pie', 'fa-chart-area',
'fa-tachometer-alt', 'fa-tasks', 'fa-clipboard', 'fa-clipboard-list',
'fa-list', 'fa-list-alt', 'fa-list-ol', 'fa-list-ul', 'fa-th', 'fa-th-large',
'fa-tags', 'fa-tag', 'fa-bookmark', 'fa-star', 'fa-heart', 'fa-thumbs-up',
'fa-share', 'fa-share-alt', 'fa-external-link-alt', 'fa-clipboard-check',
'fa-eye', 'fa-eye-slash', 'fa-lightbulb', 'fa-magic', 'fa-palette',
'fa-paint-brush', 'fa-font', 'fa-text-height', 'fa-bold', 'fa-italic',
'fa-code', 'fa-terminal', 'fa-bug', 'fa-exclamation-triangle',
'fa-exclamation-circle', 'fa-check-circle', 'fa-info-circle',
'fa-spinner', 'fa-circle-notch', 'fa-hourglass-half', 'fa-clock',
'fa-calendar', 'fa-calendar-alt', 'fa-history', 'fa-hourglass',
'fa-map-marker-alt', 'fa-map', 'fa-directions', 'fa-compass',
'fa-shopping-cart', 'fa-shopping-bag', 'fa-credit-card', 'fa-money-bill',
'fa-coins', 'fa-dollar-sign', 'fa-wallet', 'fa-receipt',
'fa-newspaper', 'fa-rss', 'fa-podcast', 'fa-megaphone',
'fa-flag', 'fa-trophy', 'fa-award', 'fa-medal', 'fa-gift',
'fa-fire', 'fa-bolt', 'fa-sun', 'fa-moon', 'fa-cloud-sun',
'fa-male', 'fa-female', 'fa-child', 'fa-id-card',
'fa-paper-plane', 'fa-paperclip', 'fa-at', 'fa-hashtag',
'fa-ellipsis-h', 'fa-ellipsis-v', 'fa-bars', 'fa-columns',
'fa-window-maximize', 'fa-window-minimize', 'fa-window-close',
'fa-expand', 'fa-compress', 'fa-arrows-alt', 'fa-crosshairs',
'fa-sign-in-alt', 'fa-sign-out-alt', 'fa-door-open',
'fa-plug', 'fa-power-off', 'fa-redo-alt', 'fa-sync-alt',
]
const REGULAR_ICONS = [
'fa-heart', 'fa-star', 'fa-bookmark', 'fa-user', 'fa-envelope',
'fa-comment', 'fa-comments', 'fa-bell', 'fa-clock', 'fa-calendar',
'fa-clipboard', 'fa-file', 'fa-folder', 'fa-image', 'fa-eye',
'fa-thumbs-up', 'fa-flag', 'fa-share-square', 'fa-hand-point-right',
'fa-life-ring', 'fa-lightbulb', 'fa-question-circle', 'fa-check-circle',
'fa-times-circle', 'fa-dot-circle', 'fa-circle', 'fa-square',
'fa-moon', 'fa-sun', 'fa-snowflake', 'fa-lemon', 'fa-gem',
'fa-copy', 'fa-edit', 'fa-trash-alt', 'fa-save', 'fa-window-maximize',
'fa-window-minimize', 'fa-window-close', 'fa-caret-square-right',
'fa-address-book', 'fa-address-card', 'fa-id-badge',
'fa-building', 'fa-chart-bar', 'fa-newspaper', 'fa-map',
]
const BRAND_ICONS = [
'fa-github', 'fa-gitlab', 'fa-bitbucket', 'fa-google', 'fa-google-plus',
'fa-facebook', 'fa-facebook-f', 'fa-twitter', 'fa-x-twitter',
'fa-instagram', 'fa-linkedin', 'fa-linkedin-in', 'fa-youtube',
'fa-weixin', 'fa-qq', 'fa-weibo', 'fa-telegram', 'fa-telegram-plane',
'fa-discord', 'fa-slack', 'fa-whatsapp', 'fa-skype',
'fa-apple', 'fa-windows', 'fa-linux', 'fa-android', 'fa-chrome',
'fa-firefox', 'fa-edge', 'fa-safari', 'fa-opera',
'fa-html5', 'fa-css3-alt', 'fa-js', 'fa-js-square', 'fa-node',
'fa-react', 'fa-vuejs', 'fa-angular', 'fa-php', 'fa-python',
'fa-java', 'fa-rust', 'fa-golang', 'fa-swift',
'fa-docker', 'fa-aws', 'fa-digital-ocean', 'fa-cloudflare',
'fa-wordpress', 'fa-joomla', 'fa-drupal', 'fa-shopify',
'fa-paypal', 'fa-stripe', 'fa-alipay', 'fa-weixin',
'fa-tiktok', 'fa-snapchat', 'fa-pinterest', 'fa-reddit',
'fa-stack-overflow', 'fa-codepen', 'fa-npm', 'fa-yarn',
'fa-twitch', 'fa-spotify', 'fa-soundcloud', 'fa-music',
'fa-bilibili',
]
const iconSets = { fas: SOLID_ICONS, far: REGULAR_ICONS, fab: BRAND_ICONS }
const filteredIcons = computed(() => { const filteredIcons = computed(() => {
const set = iconSets[activePrefix.value] || SOLID_ICONS const group = activeGroup.value
if (!group || group.type === 'custom') return []
const q = search.value.toLowerCase().replace(/\s+/g, '') const q = search.value.toLowerCase().replace(/\s+/g, '')
if (!q) return set if (!q) return group.icons
return set.filter(i => i.includes(q)) return group.icons.filter(i => i.includes(q))
}) })
function selectIcon(icon) { const customPreview = computed(() => cleanSvg(customCode.value))
emit('update:modelValue', `${activePrefix.value} ${icon}`)
function selectIcon(value) {
emit('update:modelValue', value)
popoverShow.value = false
} }
// Delegated on the panel: survives any DOM replacement inside the grids and
// works for both the catalog and session grids.
function onPanelClick(event) {
const item = event.target.closest('[data-icon]')
if (item) selectIcon(item.dataset.icon)
}
function insertCustom() {
const svg = cleanSvg(customCode.value)
if (!svg) return
if (!sessionCustom.value.includes(svg)) sessionCustom.value.push(svg)
emit('update:modelValue', `customsvg-${svg}`)
customCode.value = ''
}
onMounted(async () => {
try {
const res = await api.getIconCatalog()
if (res?.success && Array.isArray(res.groups) && res.groups.length) {
tabs.value = res.groups
}
} catch (e) {
// Keep fallback tabs; the panel stays usable once the endpoint answers.
}
})
</script> </script>
<style scoped> <style scoped>
.aksrt-icon-picker__panel { .aksrt-icon-picker__panel {
/* The popover teleports to <body>, outside #aksrt-admin-app where the
palette is declared — re-declare every token the panel consumes. */
--aksrt-color-primary: #18A058;
--aksrt-color-primary-hover: #36AD6A;
--aksrt-color-primary-soft: rgba(24, 160, 88, 0.12);
--aksrt-color-text-secondary: #465149;
--aksrt-color-muted: #6F7A72;
--aksrt-color-border: #E2E8E3;
padding: 12px; padding: 12px;
} }
@@ -177,9 +193,35 @@ function selectIcon(icon) {
} }
.aksrt-icon-picker__tabs { .aksrt-icon-picker__tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.aksrt-icon-picker__tab {
padding: 3px 10px;
font-size: 13px;
line-height: 20px;
border: 1px solid var(--aksrt-color-border);
border-radius: 4px;
background: transparent;
color: var(--aksrt-color-text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.aksrt-icon-picker__tab:hover {
border-color: var(--aksrt-color-primary);
color: var(--aksrt-color-primary);
}
.aksrt-icon-picker__tab.is-active {
border-color: var(--aksrt-color-primary);
background: var(--aksrt-color-primary);
color: #fff;
}
.aksrt-icon-picker__grid { .aksrt-icon-picker__grid {
display: grid; display: grid;
grid-template-columns: repeat(8, 1fr); grid-template-columns: repeat(8, 1fr);
@@ -197,25 +239,60 @@ function selectIcon(icon) {
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 16px; font-size: 16px;
color: #555; color: var(--aksrt-color-text-secondary);
transition: all 0.15s; transition: all 0.15s;
} }
.aksrt-icon-picker__item:hover { .aksrt-icon-picker__item:hover {
background: #f0f5ff; background: var(--aksrt-color-primary-soft);
color: #1890ff; color: var(--aksrt-color-primary);
} }
.aksrt-icon-picker__item.is-active { .aksrt-icon-picker__item.is-active {
background: #1890ff; background: var(--aksrt-color-primary);
color: #fff; color: #fff;
} }
.aksrt-icon-picker__item :deep(svg),
.aksrt-icon-picker__preview-svg :deep(svg),
.aksrt-icon-picker__preview-svg svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.aksrt-icon-picker__preview-svg {
display: inline-flex;
}
.aksrt-icon-picker__empty { .aksrt-icon-picker__empty {
grid-column: 1 / -1; grid-column: 1 / -1;
text-align: center; text-align: center;
padding: 20px; padding: 20px;
color: #999; color: var(--aksrt-color-muted);
font-size: 13px; font-size: 13px;
} }
.aksrt-icon-picker__custom-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.aksrt-icon-picker__custom-preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px dashed var(--aksrt-color-muted);
border-radius: 4px;
}
.aksrt-icon-picker__grid--session {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--aksrt-color-muted);
}
</style> </style>
@@ -0,0 +1,48 @@
<template>
<!-- FA class value -->
<i v-if="isFaValue(value)" :class="faClass" aria-hidden="true"></i>
<!-- Built-in sprite value -->
<span
v-else-if="isZibsvgValue(value)"
class="aksrt-icon-value__svg"
v-html="zibsvgMarkup(value)"
></span>
<!-- Inline custom SVG value -->
<span
v-else-if="isCustomSvgValue(value)"
class="aksrt-icon-value__svg"
v-html="cleanSvg(value.slice(10))"
></span>
</template>
<script setup>
/**
* IconValue — renders any icon-picker value ("fa fas fa-star", "zibsvg-key",
* "customsvg-<svg>") as inline preview markup.
*/
import { computed } from 'vue'
import { cleanSvg, isFaValue, isZibsvgValue, isCustomSvgValue, zibsvgMarkup } from '@/utils/icons.js'
const props = defineProps({
value: { type: String, default: '' },
})
const faClass = computed(() => {
const raw = String(props.value || '').trim()
if (raw.startsWith('fa-')) return `fa fas ${raw}`
return raw
})
</script>
<style scoped>
.aksrt-icon-value__svg {
display: inline-flex;
}
.aksrt-icon-value__svg :deep(svg),
.aksrt-icon-value__svg svg {
width: 1em;
height: 1em;
fill: currentColor;
}
</style>
@@ -0,0 +1,24 @@
/**
* 把 accordion / fieldset 的 `schema.sections` 声明展开成「块标题 + 字段定义」。
*
* 参考主题把「升级价格」这类复合配置拆成若干可折叠块,每块用 keys 声明自己
* 包含哪些字段。这里是纯函数,方便在 Node 契约测试里直接用真实 schema 校验,
* 避免渲染层的一个小疏忽让整块手风琴静默退化成平铺。
*/
export function buildFieldsetSections(schema) {
const declared = schema && Array.isArray(schema.sections) ? schema.sections : []
if (!declared.length) return []
const byKey = {}
const nested = Array.isArray(schema.fields) ? schema.fields : []
for (const field of nested) {
if (field && field.key) byKey[field.key] = field
}
return declared.map((section) => ({
title: String((section && section.title) || ''),
fields: (Array.isArray(section && section.keys) ? section.keys : [])
.map((key) => byKey[key])
.filter(Boolean),
}))
}
@@ -1,313 +0,0 @@
<template>
<div class="aksrt-module-canvas">
<div class="aksrt-module-canvas__toolbar">
<n-space>
<n-dropdown :options="addModuleOptions" trigger="click" @select="handleAddModule">
<n-button type="primary" size="small">
<template #icon><FaIcon icon="fas fa-plus" :size="12" /></template>
添加模块
</n-button>
</n-dropdown>
<n-button size="small" @click="$emit('pick-preset')">
<template #icon><FaIcon icon="fas fa-magic" :size="12" /></template>
应用预设
</n-button>
<n-button size="small" @click="$emit('export')">
<template #icon><FaIcon icon="fas fa-download" :size="12" /></template>
导出
</n-button>
<n-button size="small" @click="$emit('import')">
<template #icon><FaIcon icon="fas fa-upload" :size="12" /></template>
导入
</n-button>
</n-space>
</div>
<div v-if="modules.length === 0" class="aksrt-module-canvas__empty">
<FaIcon icon="fas fa-th-large" :size="32" color="#c0c4cc" />
<p>暂无首页模块,点击"添加模块"或"应用预设"开始配置。</p>
</div>
<div v-else class="aksrt-module-canvas__list">
<div
v-for="(mod, index) in modules"
:key="mod.id || index"
class="aksrt-module-canvas__item"
:class="{
'aksrt-module-canvas__item--selected': selectedIndex === index,
'aksrt-module-canvas__item--disabled': !mod.enabled,
'aksrt-module-canvas__item--dragging': dragIndex === index,
'aksrt-module-canvas__item--drag-over': dragOverIndex === index && dragOverIndex !== dragIndex,
}"
:draggable="true"
@click="$emit('select', index)"
@dragstart="handleDragStart(index, $event)"
@dragover.prevent="handleDragOver(index)"
@dragleave="handleDragLeave"
@drop.prevent="handleDrop(index)"
@dragend="handleDragEnd"
>
<div class="aksrt-module-canvas__item-handle" title="拖拽排序">
<FaIcon icon="fas fa-grip-vertical" :size="12" color="#c0c4cc" />
</div>
<div class="aksrt-module-canvas__item-icon">
<FaIcon :icon="getModuleIcon(mod.type)" :size="14" />
</div>
<div class="aksrt-module-canvas__item-info">
<span class="aksrt-module-canvas__item-label">{{ getModuleLabel(mod.type) }}</span>
<span v-if="mod.title" class="aksrt-module-canvas__item-title">{{ mod.title }}</span>
</div>
<div class="aksrt-module-canvas__item-actions">
<n-switch
:value="mod.enabled !== false"
size="small"
@update:value="(val) => $emit('toggle', index, val)"
@click.stop
/>
<n-button
size="tiny"
quaternary
title="复制"
@click.stop="$emit('copy', index)"
>
<FaIcon icon="fas fa-copy" :size="11" />
</n-button>
<n-button
size="tiny"
quaternary
type="error"
title="删除"
@click.stop="$emit('remove', index)"
>
<FaIcon icon="fas fa-trash" :size="11" />
</n-button>
<n-button
size="tiny"
quaternary
:disabled="index === 0"
title="上移"
@click.stop="$emit('move', index, -1)"
>
<FaIcon icon="fas fa-chevron-up" :size="11" />
</n-button>
<n-button
size="tiny"
quaternary
:disabled="index === modules.length - 1"
title="下移"
@click.stop="$emit('move', index, 1)"
>
<FaIcon icon="fas fa-chevron-down" :size="11" />
</n-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { MODULE_LIBRARY } from '@/utils/home-module-schema'
import FaIcon from '@/components/form/FaIcon.vue'
const props = defineProps({
modules: { type: Array, default: () => [] },
selectedIndex: { type: Number, default: -1 },
})
const emit = defineEmits(['select', 'add', 'remove', 'copy', 'toggle', 'move', 'pick-preset', 'export', 'import'])
// Drag-and-drop state
const dragIndex = ref(-1)
const dragOverIndex = ref(-1)
function handleDragStart(index, event) {
dragIndex.value = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(index))
}
function handleDragOver(index) {
if (dragIndex.value !== index) {
dragOverIndex.value = index
}
}
function handleDragLeave() {
dragOverIndex.value = -1
}
function handleDrop(targetIndex) {
if (dragIndex.value >= 0 && dragIndex.value !== targetIndex) {
const offset = targetIndex - dragIndex.value
emit('move', dragIndex.value, offset)
}
dragIndex.value = -1
dragOverIndex.value = -1
}
function handleDragEnd() {
dragIndex.value = -1
dragOverIndex.value = -1
}
const addModuleOptions = computed(() =>
MODULE_LIBRARY.map(m => ({
label: m.label,
key: m.type,
icon: () => {
const iconMap = {
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
'shopping-bag': 'fas fa-shopping-bag',
}
return h('i', { class: iconMap[m.icon] || 'fas fa-cube', style: 'font-size:12px;margin-right:6px;' })
},
}))
)
function getModuleIcon(type) {
const meta = MODULE_LIBRARY.find(m => m.type === type)
const iconMap = {
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
'shopping-bag': 'fas fa-shopping-bag',
}
return iconMap[meta?.icon] || 'fas fa-cube'
}
function getModuleLabel(type) {
const meta = MODULE_LIBRARY.find(m => m.type === type)
return meta?.label || type
}
function handleAddModule(type) {
emit('add', type)
}
</script>
<script>
import { h } from 'vue'
export default {
// h is used in addModuleOptions computed icon renderer
}
</script>
<style scoped>
.aksrt-module-canvas__toolbar {
margin-bottom: 12px;
}
.aksrt-module-canvas__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
border: 2px dashed #e0e0e0;
border-radius: 8px;
color: #999;
gap: 12px;
}
.aksrt-module-canvas__empty p {
margin: 0;
font-size: 13px;
}
.aksrt-module-canvas__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-module-canvas__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #f0f0f0;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
background: #fff;
}
.aksrt-module-canvas__item:hover {
border-color: #d0d0d0;
background: #fafafa;
}
.aksrt-module-canvas__item--selected {
border-color: #1890ff;
background: #e6f4ff;
}
.aksrt-module-canvas__item--dragging {
opacity: 0.5;
border-style: dashed;
}
.aksrt-module-canvas__item--drag-over {
border-color: #1890ff;
border-width: 2px;
background: #f0f7ff;
}
.aksrt-module-canvas__item--disabled {
opacity: 0.5;
}
.aksrt-module-canvas__item-handle {
cursor: grab;
padding: 2px;
}
.aksrt-module-canvas__item-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: #f5f7fa;
color: #666;
flex-shrink: 0;
}
.aksrt-module-canvas__item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.aksrt-module-canvas__item-label {
font-size: 13px;
font-weight: 500;
color: #333;
}
.aksrt-module-canvas__item-title {
font-size: 11px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aksrt-module-canvas__item-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
</style>
@@ -1,399 +0,0 @@
<template>
<div class="aksrt-module-inspector">
<div v-if="!module" class="aksrt-module-inspector__empty">
<FaIcon icon="fas fa-mouse-pointer" :size="24" color="#c0c4cc" />
<p>选择左侧模块查看和编辑配置</p>
</div>
<template v-else>
<div class="aksrt-module-inspector__header">
<FaIcon :icon="getModuleIcon(module.type)" :size="16" />
<span class="aksrt-module-inspector__type">{{ getModuleLabel(module.type) }}</span>
</div>
<n-scrollbar style="max-height: calc(100vh - 320px);">
<n-form
:model="localConfig"
label-placement="top"
size="small"
class="aksrt-module-inspector__form"
>
<!-- Common fields -->
<n-form-item label="模块标题">
<n-input
:value="module.title || ''"
placeholder="留空使用默认标题"
@update:value="(val) => updateField('title', val)"
/>
</n-form-item>
<n-form-item label="启用模块">
<n-switch
:value="module.enabled !== false"
@update:value="(val) => updateField('enabled', val)"
/>
</n-form-item>
<n-divider />
<!-- Type-specific fields from schema -->
<template v-if="metaFields.length > 0">
<n-form-item
v-for="field in metaFields"
:key="field.key"
:label="field.label"
>
<!-- Switch -->
<n-switch
v-if="field.type === 'switch'"
:value="localConfig[field.key] ?? field.default ?? false"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Number -->
<n-input-number
v-else-if="field.type === 'number'"
:value="localConfig[field.key] ?? field.default ?? 0"
:min="field.min"
:max="field.max"
style="width: 100%;"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Text -->
<n-input
v-else-if="field.type === 'text'"
:value="localConfig[field.key] ?? field.default ?? ''"
:placeholder="field.placeholder || ''"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Textarea -->
<n-input
v-else-if="field.type === 'textarea'"
type="textarea"
:value="localConfig[field.key] ?? field.default ?? ''"
:rows="field.rows || 3"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Select -->
<n-select
v-else-if="field.type === 'select'"
:value="localConfig[field.key] ?? field.default"
:options="field.options || []"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Radio -->
<n-radio-group
v-else-if="field.type === 'radio'"
:value="localConfig[field.key] ?? field.default"
@update:value="(val) => updateConfig(field.key, val)"
>
<n-space>
<n-radio
v-for="opt in field.options"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</n-radio>
</n-space>
</n-radio-group>
<!-- Category (single) -->
<n-select
v-else-if="field.type === 'cat'"
:value="localConfig[field.key] ?? field.default ?? 0"
:options="categoryOptions"
filterable
placeholder="选择分类"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Categories (multiple) -->
<n-select
v-else-if="field.type === 'cats'"
:value="localConfig[field.key] ?? field.default ?? []"
:options="categoryOptions"
multiple
filterable
placeholder="选择分类"
@update:value="(val) => updateConfig(field.key, val)"
/>
<!-- Media Picker -->
<div v-else-if="field.type === 'media'" class="aksrt-module-inspector__media">
<div v-if="localConfig[field.key]" class="aksrt-module-inspector__media-preview">
<img :src="localConfig[field.key]" alt="" />
<n-button size="tiny" quaternary type="error" @click="updateConfig(field.key, '')">
移除
</n-button>
</div>
<n-input
v-else
:value="localConfig[field.key] ?? ''"
placeholder="输入图片 URL 或点击下方选择"
@update:value="(val) => updateConfig(field.key, val)"
/>
<n-button size="tiny" quaternary @click="openMediaPicker(field.key)">
<template #icon><FaIcon icon="fas fa-image" :size="12" /></template>
从媒体库选择
</n-button>
</div>
<!-- Repeater -->
<div v-else-if="field.type === 'repeater'" class="aksrt-module-inspector__repeater">
<div
v-for="(item, idx) in (localConfig[field.key] ?? [])"
:key="idx"
class="aksrt-module-inspector__repeater-item"
>
<div class="aksrt-module-inspector__repeater-fields">
<n-input
v-for="sub in (field.fields || [])"
:key="sub.key"
:value="item[sub.key] ?? ''"
:placeholder="sub.placeholder || sub.label || ''"
size="small"
@update:value="(val) => updateRepeaterItem(field.key, idx, sub.key, val)"
/>
</div>
<n-button size="tiny" quaternary type="error" @click="removeRepeaterItem(field.key, idx)">
<FaIcon icon="fas fa-times" :size="10" />
</n-button>
</div>
<n-button size="tiny" quaternary @click="addRepeaterItem(field.key, field.fields)">
<template #icon><FaIcon icon="fas fa-plus" :size="10" /></template>
添加一项
</n-button>
</div>
</n-form-item>
</template>
<n-text v-else depth="3" style="font-size: 12px;">
此模块类型暂无可配置字段。
</n-text>
</n-form>
</n-scrollbar>
</template>
<!-- Image URL input modal (replaces native prompt) -->
<n-modal v-model:show="showImageUrlInput" preset="card" title="输入图片 URL" style="max-width: 400px;" :bordered="false">
<n-input v-model:value="imageUrlValue" placeholder="https://example.com/image.jpg" @keyup.enter="confirmImageUrl" />
<template #footer>
<n-space justify="end">
<n-button @click="showImageUrlInput = false">取消</n-button>
<n-button type="primary" @click="confirmImageUrl">确定</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup>
import { computed, ref, watch, onMounted } from 'vue'
import { MODULE_LIBRARY } from '@/utils/home-module-schema'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const props = defineProps({
module: { type: Object, default: null },
moduleIndex: { type: Number, default: -1 },
})
const emit = defineEmits(['update'])
const categoryOptions = ref([])
const meta = computed(() => {
if (!props.module) return null
return MODULE_LIBRARY.find(m => m.type === props.module.type) || null
})
const metaFields = computed(() => {
return meta.value?.fields || []
})
const localConfig = computed(() => {
return props.module?.config || {}
})
function getModuleIcon(type) {
const m = MODULE_LIBRARY.find(x => x.type === type)
const iconMap = {
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
'shopping-bag': 'fas fa-shopping-bag',
}
return iconMap[m?.icon] || 'fas fa-cube'
}
function getModuleLabel(type) {
const m = MODULE_LIBRARY.find(x => x.type === type)
return m?.label || type
}
function updateField(key, value) {
emit('update', props.moduleIndex, key, value)
}
function updateConfig(key, value) {
const newConfig = { ...(props.module?.config || {}), [key]: value }
emit('update', props.moduleIndex, 'config', newConfig)
}
// Image URL input state (replaces native prompt)
const showImageUrlInput = ref(false)
const imageUrlFieldKey = ref('')
const imageUrlValue = ref('')
function openMediaPicker(fieldKey) {
if (!window.wp || !window.wp.media) {
// Fallback: show inline input instead of native prompt
imageUrlFieldKey.value = fieldKey
imageUrlValue.value = localConfig.value[fieldKey] || ''
showImageUrlInput.value = true
return
}
const frame = wp.media({
title: '选择图片',
button: { text: '使用此图片' },
multiple: false,
library: { type: 'image' },
})
frame.on('select', function () {
const attachment = frame.state().get('selection').first().toJSON()
updateConfig(fieldKey, attachment.url)
})
frame.open()
}
function confirmImageUrl() {
if (imageUrlValue.value.trim()) {
updateConfig(imageUrlFieldKey.value, imageUrlValue.value.trim())
}
showImageUrlInput.value = false
}
function addRepeaterItem(fieldKey, subFields) {
const current = [...(props.module?.config?.[fieldKey] || [])]
const newItem = {}
;(subFields || []).forEach(f => { newItem[f.key] = f.default ?? '' })
current.push(newItem)
updateConfig(fieldKey, current)
}
function removeRepeaterItem(fieldKey, index) {
const current = [...(props.module?.config?.[fieldKey] || [])]
current.splice(index, 1)
updateConfig(fieldKey, current)
}
function updateRepeaterItem(fieldKey, index, subKey, value) {
const current = [...(props.module?.config?.[fieldKey] || [])]
if (current[index]) {
current[index] = { ...current[index], [subKey]: value }
updateConfig(fieldKey, current)
}
}
async function loadCategories() {
try {
const res = await api.getHomepageCategories()
const cats = res?.data || []
categoryOptions.value = cats.map(c => ({
label: `${c.name} (${c.count})`,
value: c.term_id,
}))
} catch { /* non-critical */ }
}
onMounted(loadCategories)
</script>
<style scoped>
.aksrt-module-inspector__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: #999;
gap: 12px;
}
.aksrt-module-inspector__empty p {
margin: 0;
font-size: 13px;
}
.aksrt-module-inspector__header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 12px;
}
.aksrt-module-inspector__type {
font-size: 15px;
font-weight: 600;
color: #333;
}
.aksrt-module-inspector__form {
padding: 0 4px;
}
.aksrt-module-inspector__media {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.aksrt-module-inspector__media-preview {
display: flex;
align-items: center;
gap: 8px;
}
.aksrt-module-inspector__media-preview img {
width: 80px;
height: 60px;
object-fit: cover;
border-radius: 4px;
border: 1px solid #e8e8e8;
}
.aksrt-module-inspector__repeater {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.aksrt-module-inspector__repeater-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 8px;
border-radius: 6px;
background: #fafafa;
border: 1px solid #f0f0f0;
}
.aksrt-module-inspector__repeater-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
</style>
@@ -1,143 +0,0 @@
<template>
<n-modal
:show="show"
title="应用预设模板"
preset="dialog"
style="width: 560px;"
:show-icon="false"
@close="$emit('close')"
>
<div class="aksrt-preset-picker">
<p class="aksrt-preset-picker__hint">
选择一个预设模板快速配置首页模块。应用预设将替换当前所有模块。
</p>
<div class="aksrt-preset-picker__list">
<div
v-for="preset in presets"
:key="preset.key"
class="aksrt-preset-picker__item"
:class="{ 'aksrt-preset-picker__item--selected': selectedKey === preset.key }"
@click="selectedKey = preset.key"
>
<div class="aksrt-preset-picker__item-header">
<strong>{{ preset.label }}</strong>
<n-tag size="small" type="info">{{ preset.modules.length }} 个模块</n-tag>
</div>
<p class="aksrt-preset-picker__item-desc">{{ preset.desc }}</p>
<div class="aksrt-preset-picker__item-modules">
<n-tag
v-for="(mod, i) in preset.modules"
:key="i"
size="small"
:bordered="false"
class="aksrt-preset-picker__module-tag"
>
{{ getModuleLabel(mod.type) }}
</n-tag>
</div>
</div>
</div>
</div>
<template #action>
<n-space>
<n-button @click="$emit('close')">取消</n-button>
<n-button
type="primary"
:disabled="!selectedKey"
@click="handleApply"
>
应用预设
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup>
import { ref } from 'vue'
import { PRESET_TEMPLATES, MODULE_LIBRARY } from '@/utils/home-module-schema'
defineProps({
show: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'apply'])
const selectedKey = ref('')
const presets = PRESET_TEMPLATES
function getModuleLabel(type) {
const m = MODULE_LIBRARY.find(x => x.type === type)
return m?.label || type
}
function handleApply() {
const preset = presets.find(p => p.key === selectedKey.value)
if (preset) {
emit('apply', preset)
selectedKey.value = ''
}
}
</script>
<style scoped>
.aksrt-preset-picker__hint {
margin: 0 0 16px;
font-size: 13px;
color: #666;
line-height: 1.6;
}
.aksrt-preset-picker__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.aksrt-preset-picker__item {
padding: 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.aksrt-preset-picker__item:hover {
border-color: #d0d0d0;
background: #fafafa;
}
.aksrt-preset-picker__item--selected {
border-color: #1890ff;
background: #e6f4ff;
}
.aksrt-preset-picker__item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.aksrt-preset-picker__item-header strong {
font-size: 14px;
color: #333;
}
.aksrt-preset-picker__item-desc {
margin: 0 0 8px;
font-size: 12px;
color: #888;
}
.aksrt-preset-picker__item-modules {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.aksrt-preset-picker__module-tag {
font-size: 11px;
}
</style>
+1 -1
View File
@@ -3,7 +3,7 @@ import { renderFaIcon, customPage } from './helpers'
export const docsSection = { export const docsSection = {
key: 'tools-compat', key: 'tools-compat',
label: '兼容&工具', label: '兼容&工具',
icon: renderFaIcon('fas fa-toolbox'), icon: renderFaIcon('fa fas fa-wrench'),
route: '/tools', route: '/tools',
hidden: true, hidden: true,
children: [ children: [
@@ -1,11 +1,21 @@
import { renderFaIcon, schemaPage, customPage } from './helpers' import { renderFaIcon, schemaPage } from './helpers'
export const extensionsSection = { export const extensionsSection = {
key: 'extensions', key: 'extensions',
label: '扩展&增强', label: '扩展&增强',
icon: renderFaIcon('fas fa-puzzle-piece'), icon: renderFaIcon('fa fas fa-puzzle-piece'),
route: '/extensions', route: '/extensions',
children: [ children: [
schemaPage({
key: 'extensions-system',
name: 'ExtensionsSystem',
label: '系统工具',
route: '/extensions/system',
title: '系统工具',
desc: 'WordPress 优化、上传优化与编辑器显示的系统级开关。',
canonicalDomain: 'extensions',
section: 'system',
}),
schemaPage({ schemaPage({
key: 'extensions-ip', key: 'extensions-ip',
name: 'ExtensionsIp', name: 'ExtensionsIp',
@@ -32,28 +42,19 @@ export const extensionsSection = {
label: '百度推送', label: '百度推送',
route: '/extensions/push', route: '/extensions/push',
title: '百度推送', title: '百度推送',
desc: '配置搜索引擎主动推送、批量提交和状态追踪。', desc: '配置百度自动推送、收录通道与触发规则;推送结果与「重新提交」在文章/分类编辑页查看。',
canonicalDomain: 'extensions', canonicalDomain: 'extensions',
section: 'push', section: 'push',
}), }),
customPage({ schemaPage({
key: 'extensions-tools', key: 'extensions-security',
name: 'ExtensionsTools', name: 'ExtensionsSecurity',
label: '系统工具', label: '网站安全',
route: '/extensions/tools', route: '/extensions/security',
component: 'ExtensionTools', title: '网站安全',
title: '系统工具', desc: '防刷限制、验证码发送限制与站点安全加固。',
desc: '额外运维工具与扩展能力。', canonicalDomain: 'extensions',
}), section: 'security',
customPage({
key: 'extensions-ai',
name: 'ExtensionAI',
label: 'AI 连接配置',
route: '/extensions/ai',
component: 'ExtensionAI',
status: 'available',
title: 'AI 连接配置',
desc: '配置 AI 生成连接;SEO 在文章独立 SEO 框,摘要/标签在前台投稿页。',
}), }),
], ],
} }
+11 -1
View File
@@ -3,7 +3,7 @@ import { renderFaIcon, schemaPage, customPage } from './helpers'
export const globalSection = { export const globalSection = {
key: 'global', key: 'global',
label: '全局&功能', label: '全局&功能',
icon: renderFaIcon('fas fa-globe'), icon: renderFaIcon('fa fas fa-globe'),
route: '/global', route: '/global',
children: [ children: [
schemaPage({ schemaPage({
@@ -84,6 +84,16 @@ export const globalSection = {
canonicalDomain: 'global', canonicalDomain: 'global',
section: 'i18n', section: 'i18n',
}), }),
schemaPage({
key: 'global-share',
name: 'GlobalShare',
label: '社交分享',
route: '/global/share',
title: '社交分享',
desc: '配置分享平台、微信 JSAPI 与海报分享。',
canonicalDomain: 'global',
section: 'share',
}),
schemaPage({ schemaPage({
key: 'global-email', key: 'global-email',
name: 'GlobalEmail', name: 'GlobalEmail',
+2 -2
View File
@@ -15,8 +15,8 @@ const SECTION_DEFAULT_CHILDREN = {
'post-list': 'post-list-page', 'post-list': 'post-list-page',
permissions: 'permissions-comment', permissions: 'permissions-comment',
users: 'users-auth', users: 'users-auth',
payment: 'payment-recharge', payment: 'payment-knowledge',
extensions: 'extensions-ip', extensions: 'extensions-system',
tools: 'tools-import-export', tools: 'tools-import-export',
'theme-info': 'theme-info-dashboard', 'theme-info': 'theme-info-dashboard',
docs: 'docs-dashboard', docs: 'docs-dashboard',
@@ -1,9 +1,9 @@
import { renderFaIcon, schemaPage, customPage } from './helpers' import { renderFaIcon, schemaPage } from './helpers'
export const pageDisplaySection = { export const pageDisplaySection = {
key: 'page-display', key: 'page-display',
label: '页面&显示', label: '页面&显示',
icon: renderFaIcon('fas fa-desktop'), icon: renderFaIcon('fa fas fa-desktop'),
route: '/page-display', route: '/page-display',
children: [ children: [
schemaPage({ schemaPage({
@@ -12,79 +12,35 @@ export const pageDisplaySection = {
label: '顶部导航栏', label: '顶部导航栏',
route: '/page-display/header-nav', route: '/page-display/header-nav',
title: '顶部导航栏', title: '顶部导航栏',
desc: '导航栏布局、菜单位置、LOGO 与搜索入口配置。',
canonicalDomain: 'page_display', canonicalDomain: 'page_display',
section: 'header-nav', section: 'header-nav',
}), }),
schemaPage({
key: 'page-display-background',
name: 'PageDisplayBackground',
label: '全局背景',
route: '/page-display/background',
title: '全局背景',
desc: '配置站点全局背景图、背景色、暗色模式背景和遮罩。',
canonicalDomain: 'page_display',
section: 'background',
}),
schemaPage({
key: 'page-display-menu-decoration',
name: 'PageDisplayMenuDecoration',
label: '菜单装饰',
route: '/page-display/menu-decoration',
title: '菜单装饰',
desc: '配置导航菜单的图标、徽章、下拉样式和图文卡片子菜单。',
canonicalDomain: 'page_display',
section: 'menu-decoration',
}),
schemaPage({ schemaPage({
key: 'page-display-header-components', key: 'page-display-header-components',
name: 'PageDisplayHeaderComponents', name: 'PageDisplayHeaderComponents',
label: '顶部多功能组件', label: '顶部多功能组件',
route: '/page-display/header-components', route: '/page-display/header-components',
title: '顶部多功能组件', title: '顶部多功能组件',
desc: '配置顶部组件显示范围、轮播参数、搜索覆盖层与图标卡片。幻灯片内容见「Hero 轮播」「Banner 轮播」。',
canonicalDomain: 'page_display', canonicalDomain: 'page_display',
section: 'header-components', section: 'header-components',
}), }),
customPage({ schemaPage({
key: 'page-display-hero',
name: 'PageDisplayHero',
label: 'Hero 轮播',
route: '/page-display/hero',
title: 'Hero 轮播管理',
desc: '管理首页顶部 Hero 幻灯片内容(图片/视频、文案、图层、跳转)。显示开关与搜索/卡片配置见「顶部多功能组件」。',
component: 'HeroManager',
canonicalDomain: 'page_display',
section: 'hero',
}),
customPage({
key: 'page-display-banner',
name: 'PageDisplayBanner',
label: 'Banner 轮播',
route: '/page-display/banner',
title: 'Banner 管理',
desc: '管理首页内容流 Banner 幻灯片(图片、文案、跳转、启用状态)。',
component: 'BannerManager',
canonicalDomain: 'page_display',
section: 'banner',
}),
customPage({
key: 'page-display-footer', key: 'page-display-footer',
name: 'PageDisplayFooter', name: 'PageDisplayFooter',
label: '底部页脚', label: '底部页脚',
route: '/page-display/footer', route: '/page-display/footer',
title: '底部页脚', title: '底部页脚',
desc: '页脚布局、友链、版权信息配置。', canonicalDomain: 'page_display',
component: 'FooterSettings', section: 'footer',
}), }),
customPage({ schemaPage({
key: 'page-display-homepage', key: 'page-display-homepage',
name: 'PageDisplayHomepage', name: 'PageDisplayHomepage',
label: '首页配置', label: '首页配置',
route: '/page-display/homepage', route: '/page-display/homepage',
title: '首页配置', title: '首页配置',
desc: '首页内容模型、主列表、分类 Tab 与首页交互配置。', canonicalDomain: 'page_display',
component: 'HomepageSettings', section: 'homepage',
}), }),
schemaPage({ schemaPage({
key: 'page-display-category', key: 'page-display-category',
@@ -92,7 +48,6 @@ export const pageDisplaySection = {
label: '分类页面', label: '分类页面',
route: '/page-display/category', route: '/page-display/category',
title: '分类页面', title: '分类页面',
desc: '分类归档页的头图、简介、列表模式、侧栏配置。',
canonicalDomain: 'page_display', canonicalDomain: 'page_display',
section: 'category', section: 'category',
}), }),
@@ -102,7 +57,6 @@ export const pageDisplaySection = {
label: '标签页面', label: '标签页面',
route: '/page-display/tag', route: '/page-display/tag',
title: '标签页面', title: '标签页面',
desc: '标签归档页的展示样式与配置。',
canonicalDomain: 'page_display', canonicalDomain: 'page_display',
section: 'tag', section: 'tag',
}), }),
@@ -112,7 +66,6 @@ export const pageDisplaySection = {
label: '用户主页', label: '用户主页',
route: '/page-display/user-home', route: '/page-display/user-home',
title: '用户主页', title: '用户主页',
desc: '用户公开主页的布局与展示配置。',
canonicalDomain: 'page_display', canonicalDomain: 'page_display',
section: 'user-home', section: 'user-home',
}), }),
@@ -122,7 +75,6 @@ export const pageDisplaySection = {
label: '其它页面', label: '其它页面',
route: '/page-display/other', route: '/page-display/other',
title: '其它页面', title: '其它页面',
desc: '404 页面、搜索结果页等其它页面配置。',
canonicalDomain: 'page_display', canonicalDomain: 'page_display',
section: 'other', section: 'other',
}), }),
+43 -39
View File
@@ -1,11 +1,27 @@
import { renderFaIcon, schemaPage, customPage } from './helpers' import { renderFaIcon, schemaPage } from './helpers'
/**
* 支付&付费 — 子菜单按参考主题划分:
* 知识付费 / 余额充值 / VIP会员 / 创作分成 / 推广返佣 / 收款接口 / 提现策略。
* 收款接口为 schema 表单页(双下拉式接口选择 + 凭据配置 + 前缀配置)。
*/
export const paymentSection = { export const paymentSection = {
key: 'payment', key: 'payment',
label: '支付&付费', label: '支付&付费',
icon: renderFaIcon('fas fa-wallet'), icon: renderFaIcon('fa fas fa-credit-card'),
route: '/payment', route: '/payment',
children: [ children: [
schemaPage({
key: 'payment-knowledge',
name: 'PaymentKnowledge',
label: '知识付费',
route: '/payment/knowledge',
status: 'available',
title: '知识付费',
desc: '免登陆购买、模块显示位置、商品优惠码、客户服务、付费下载与商品预置参数。',
canonicalDomain: 'payment',
section: 'knowledge',
}),
schemaPage({ schemaPage({
key: 'payment-recharge', key: 'payment-recharge',
name: 'PaymentRecharge', name: 'PaymentRecharge',
@@ -13,7 +29,7 @@ export const paymentSection = {
route: '/payment/recharge', route: '/payment/recharge',
status: 'available', status: 'available',
title: '余额充值', title: '余额充值',
desc: '余额充值策略、记录和手动确认配置。', desc: '余额功能、充值商品选项、卡密充值、充值卡与余额兑换积分。',
canonicalDomain: 'payment', canonicalDomain: 'payment',
section: 'recharge', section: 'recharge',
}), }),
@@ -24,41 +40,18 @@ export const paymentSection = {
route: '/payment/vip', route: '/payment/vip',
status: 'available', status: 'available',
title: 'VIP会员', title: 'VIP会员',
desc: '会员套餐、会员特权和免扣策略。', desc: '会员等级、会员权益、购买选项、卡密兑换与积分兑换。',
canonicalDomain: 'payment', canonicalDomain: 'payment',
section: 'vip', section: 'vip',
}), }),
customPage({
key: 'payment-gateway',
name: 'PaymentGateway',
label: '收款接口',
route: '/payment/gateway',
component: 'PaymentGateway',
status: 'available',
title: '收款接口',
desc: '支付网关适配器、测试网关和手动确认配置。',
canonicalDomain: 'payment',
section: 'gateway',
}),
schemaPage({
key: 'payment-order',
name: 'PaymentOrder',
label: '订单设置',
route: '/payment/order',
status: 'available',
title: '订单设置',
desc: '订单前缀、有效期、展示字段和状态文案。',
canonicalDomain: 'payment',
section: 'order',
}),
schemaPage({ schemaPage({
key: 'payment-revenue', key: 'payment-revenue',
name: 'PaymentRevenue', name: 'PaymentRevenue',
label: '创作中心', label: '创作分成',
route: '/payment/revenue', route: '/payment/revenue',
status: 'available', status: 'available',
title: '创作中心', title: '创作分成',
desc: '独立配置现金与积分分成比例、结算周期和结算备注文案。', desc: '现金与积分分成比例、结算周期、分成说明与详情页(分成工单在支付中心)。',
canonicalDomain: 'payment', canonicalDomain: 'payment',
section: 'revenue', section: 'revenue',
}), }),
@@ -69,20 +62,20 @@ export const paymentSection = {
route: '/payment/affiliate', route: '/payment/affiliate',
status: 'available', status: 'available',
title: '推广返佣', title: '推广返佣',
desc: '返佣规则、邀请链路和结算策略(返佣工单在支付中心)。', desc: '推广识别模式、分档返佣规则、文案与海报(返佣工单在支付中心)。',
canonicalDomain: 'payment', canonicalDomain: 'payment',
section: 'affiliate', section: 'affiliate',
}), }),
schemaPage({ schemaPage({
key: 'payment-card-pass', key: 'payment-gateway',
name: 'PaymentCardPass', name: 'PaymentGateway',
label: '卡密设置', label: '收款接口',
route: '/payment/card-pass', route: '/payment/gateway',
status: 'available', status: 'available',
title: '卡密设置', title: '收款接口',
desc: '充值卡类型、卡号规则与兑换文案(卡密工单在支付中心)。', desc: '微信/支付宝接口选择、PayPal/Stripe 开关、货币结算、显示顺序与订单支付时效。',
canonicalDomain: 'payment', canonicalDomain: 'payment',
section: 'card-pass', section: 'gateway',
}), }),
schemaPage({ schemaPage({
key: 'payment-withdrawal', key: 'payment-withdrawal',
@@ -91,9 +84,20 @@ export const paymentSection = {
route: '/payment/withdrawal', route: '/payment/withdrawal',
status: 'available', status: 'available',
title: '提现策略', title: '提现策略',
desc: '最低提现、手续费与说明文案(提现工单在支付中心)。', desc: '提现门槛、手续费、每日限制、收款渠道与提现协议(提现工单在支付中心)。',
canonicalDomain: 'payment', canonicalDomain: 'payment',
section: 'withdrawal', section: 'withdrawal',
}), }),
schemaPage({
key: 'payment-after-sale',
name: 'PaymentAfterSale',
label: '售后策略',
route: '/payment/after-sale',
status: 'available',
title: '售后策略',
desc: '售后功能开关、申请时效、超时效提示与申请表单(预置原因、所需材料、图片上传)配置。',
canonicalDomain: 'payment',
section: 'after_sale',
}),
], ],
} }
@@ -3,7 +3,7 @@ import { renderFaIcon, customPage, schemaPage } from './helpers'
export const permissionsSection = { export const permissionsSection = {
key: 'permissions', key: 'permissions',
label: '功能&权限', label: '功能&权限',
icon: renderFaIcon('fas fa-shield-alt'), icon: renderFaIcon('fa fas fa-shield-alt'),
route: '/permissions', route: '/permissions',
children: [ children: [
customPage({ customPage({
@@ -37,14 +37,13 @@ export const permissionsSection = {
canonicalDomain: 'permissions', canonicalDomain: 'permissions',
section: 'moderation', section: 'moderation',
}), }),
customPage({ schemaPage({
key: 'permissions-attachment', key: 'permissions-attachment',
name: 'PermissionsAttachment', name: 'PermissionsAttachment',
label: '附件配置', label: '附件配置',
route: '/permissions/attachment', route: '/permissions/attachment',
component: 'AttachmentPermissions',
title: '附件配置', title: '附件配置',
desc: '管理文件大小、类型、安全限制和审核策略。', desc: '头像图片、上传大小、上传格式与上传行为配置。',
canonicalDomain: 'permissions', canonicalDomain: 'permissions',
section: 'attachment', section: 'attachment',
}), }),
@@ -3,7 +3,7 @@ import { renderFaIcon, schemaPage, customPage } from './helpers'
export const postListSection = { export const postListSection = {
key: 'post-list', key: 'post-list',
label: '文章&列表', label: '文章&列表',
icon: renderFaIcon('fas fa-file-alt'), icon: renderFaIcon('fa fas fa-file-alt'),
route: '/post-list', route: '/post-list',
children: [ children: [
schemaPage({ schemaPage({
@@ -57,16 +57,6 @@ export const postListSection = {
canonicalDomain: 'post_list', canonicalDomain: 'post_list',
section: 'feature', section: 'feature',
}), }),
schemaPage({
key: 'post-list-share',
name: 'PostListShare',
label: '分享设置',
route: '/post-list/share',
title: '分享设置',
desc: '配置微信分享凭据、海报分享和社交分享选项。',
canonicalDomain: 'post_list',
section: 'share',
}),
schemaPage({ schemaPage({
key: 'post-list-comment', key: 'post-list-comment',
name: 'PostListComment', name: 'PostListComment',
@@ -3,7 +3,7 @@ import { renderFaIcon, customPage } from './helpers'
export const themeInfoSection = { export const themeInfoSection = {
key: 'tools-compat', key: 'tools-compat',
label: '兼容&工具', label: '兼容&工具',
icon: renderFaIcon('fas fa-toolbox'), icon: renderFaIcon('fa fas fa-wrench'),
route: '/tools', route: '/tools',
hidden: true, hidden: true,
children: [ children: [
+2 -21
View File
@@ -3,7 +3,7 @@ import { renderFaIcon, customPage, schemaPage } from './helpers'
export const toolsSection = { export const toolsSection = {
key: 'tools-compat', key: 'tools-compat',
label: '工具', label: '工具',
icon: renderFaIcon('fas fa-toolbox'), icon: renderFaIcon('fa fas fa-wrench'),
route: '/tools', route: '/tools',
children: [ children: [
customPage({ customPage({
@@ -25,34 +25,15 @@ export const toolsSection = {
canonicalDomain: 'tools', canonicalDomain: 'tools',
section: 'backup', section: 'backup',
}), }),
schemaPage({
key: 'tools-frontend-modal-rollout',
name: 'ToolsFrontendModalRollout',
label: '前台弹窗灰度',
route: '/tools/frontend-modal-rollout',
title: '前台弹窗灰度',
desc: '按领域控制前台弹窗新流程的分阶段发布。',
canonicalDomain: 'tools',
section: 'frontend-modal-rollout',
}),
customPage({ customPage({
key: 'tools-health', key: 'tools-health',
name: 'ToolsHealth', name: 'ToolsHealth',
label: '健康检查', label: '健康检查',
route: '/tools/health', route: '/tools/health',
title: '健康检查', title: '健康检查',
desc: '查看主题环境、数据表、邮箱验证迁移和系统诊断状态。', desc: '查看主题环境、数据表和系统诊断状态。',
component: 'ToolsHealth', component: 'ToolsHealth',
}), }),
customPage({
key: 'tools-email-rollback',
name: 'ToolsEmailRollback',
label: '邮件回滚',
route: '/tools/email-rollback',
title: '邮件回滚',
desc: '查看 14 天邮箱变更窗口内的账号并执行回滚。',
component: 'ToolsEmailRollback',
}),
customPage({ customPage({
key: 'tools-update', key: 'tools-update',
name: 'ToolsUpdate', name: 'ToolsUpdate',
+17 -27
View File
@@ -2,12 +2,14 @@ import { renderFaIcon, schemaPage, customPage } from './helpers'
/** /**
* Users SPA keeps config-shaped pages. * Users SPA keeps config-shaped pages.
* Message / referral-code / ban / report queues live under users.php native menus. * Message / referral-code queues live under users.php native menus;
* 禁封/举报 is a config page (schema section "report"); ticket queues
* (举报&禁封申诉) stay on the native WP users.php page.
*/ */
export const usersSection = { export const usersSection = {
key: 'users', key: 'users',
label: '用户&互动', label: '用户&互动',
icon: renderFaIcon('fas fa-users'), icon: renderFaIcon('fa fas fa-users'),
route: '/users', route: '/users',
children: [ children: [
schemaPage({ schemaPage({
@@ -30,28 +32,16 @@ export const usersSection = {
canonicalDomain: 'users', canonicalDomain: 'users',
section: 'oauth', section: 'oauth',
}), }),
customPage({ schemaPage({
key: 'users-features', key: 'users-features',
name: 'UsersFeatures', name: 'UsersFeatures',
label: '用户功能', label: '用户功能',
route: '/users/features', route: '/users/features',
component: 'UsersFeatures',
title: '用户功能', title: '用户功能',
desc: '用户中心与个人主页模块配置。', desc: '配置用户头像、封面和默认签名。',
canonicalDomain: 'users', canonicalDomain: 'users',
section: 'features', section: 'features',
}), }),
customPage({
key: 'users-signin',
name: 'UsersSignin',
label: '签到奖励',
route: '/users/signin',
component: 'UsersSignin',
title: '签到奖励',
desc: '签到规则、连续签到奖励和签到页面配置。',
canonicalDomain: 'users',
section: 'signin',
}),
customPage({ customPage({
key: 'users-credits', key: 'users-credits',
name: 'UsersCredits', name: 'UsersCredits',
@@ -70,7 +60,7 @@ export const usersSection = {
route: '/users/verify', route: '/users/verify',
component: 'UsersVerify', component: 'UsersVerify',
title: '身份认证', title: '身份认证',
desc: '配置认证展示、申请类型与申请门槛;审核、驳回和取消认证统一在 WordPress 用户菜单处理。', desc: '配置通用认证申请规则;申请审核和认证资料管理位于 WordPress 用户菜单。',
canonicalDomain: 'users', canonicalDomain: 'users',
section: 'verify', section: 'verify',
}), }),
@@ -118,16 +108,6 @@ export const usersSection = {
canonicalDomain: 'users', canonicalDomain: 'users',
section: 'sms', section: 'sms',
}), }),
schemaPage({
key: 'users-wechat',
name: 'UsersWechat',
label: '微信通知',
route: '/users/wechat',
title: '微信通知',
desc: '配置微信模板消息、订阅/解绑和站外通知策略。',
canonicalDomain: 'users',
section: 'wechat',
}),
customPage({ customPage({
key: 'users-wechat-template', key: 'users-wechat-template',
name: 'UsersWechatTemplate', name: 'UsersWechatTemplate',
@@ -140,5 +120,15 @@ export const usersSection = {
canonicalDomain: 'users', canonicalDomain: 'users',
section: 'wechat-template', section: 'wechat-template',
}), }),
schemaPage({
key: 'users-ban',
name: 'UsersBan',
label: '禁封/举报',
route: '/users/ban',
title: '禁封/举报',
desc: '禁封功能、举报功能、预置原因、申诉资料与举报说明配置;举报与申诉工单在 WordPress 用户菜单处理。',
canonicalDomain: 'users',
section: 'report',
}),
], ],
} }
@@ -1,28 +0,0 @@
import { ALL_SCHEMAS, CANONICAL_SCHEMA_DOMAINS, normalizeSchemaDomain } from '@/schema/index.js'
function cloneDefaults(defaults = {}) {
return JSON.parse(JSON.stringify(defaults))
}
export function getSectionDefaults(domain, section) {
const domainKey = normalizeSchemaDomain(domain)
const sectionSchema = ALL_SCHEMAS[domainKey]?.[section]
return cloneDefaults(sectionSchema?.defaults || {})
}
export function getDomainDefaults(domain) {
const domainKey = normalizeSchemaDomain(domain)
const sections = Object.values(ALL_SCHEMAS[domainKey] || {})
return sections.reduce((defaults, schema) => {
return {
...defaults,
...cloneDefaults(schema?.defaults || {}),
}
}, {})
}
export const SETTINGS_DEFAULTS_BY_DOMAIN = CANONICAL_SCHEMA_DOMAINS.reduce((defaults, domain) => {
defaults[domain] = getDomainDefaults(domain)
return defaults
}, {})
+106 -5
View File
@@ -1,8 +1,8 @@
<template> <template>
<div class="aksrt-page-container"> <div class="aksrt-page-container">
<h2 class=”aksrt-page-title”>积分配置</h2> <h2 class="aksrt-page-title">积分配置</h2>
<p class=”aksrt-page-desc”> <p class="aksrt-page-desc">
配置积分规则与发放策略。可自定义货币名称(如”金币”、”魔石”等)。用户余额与手工调整已迁移到 WordPress 原生”用户”菜单中管理。 配置积分规则与发放策略。可自定义货币名称(如”金币”、”魔石”等)。用户余额与手工调整在 WordPress 原生”用户”菜单中管理。
</p> </p>
<n-card class="aksrt-section-card"> <n-card class="aksrt-section-card">
@@ -23,6 +23,33 @@
</n-form> </n-form>
</n-card> </n-card>
<n-card title="用户近期资产记录" class="aksrt-section-card">
<n-space vertical :size="12">
<n-space wrap>
<n-input v-model:value="ledgerFilters.user_id" placeholder="用户 ID" clearable style="width: 130px" />
<n-select v-model:value="ledgerFilters.asset" :options="assetOptions" placeholder="资产" style="width: 130px" />
<n-input v-model:value="ledgerFilters.date_from" type="date" placeholder="开始日期" style="width: 160px" />
<n-input v-model:value="ledgerFilters.date_to" type="date" placeholder="结束日期" style="width: 160px" />
<n-button type="primary" :loading="ledgerLoading" @click="searchLedger">查询</n-button>
<n-button @click="resetLedger">重置</n-button>
</n-space>
<n-data-table
:columns="ledgerColumns"
:data="ledgerRows"
:loading="ledgerLoading"
:row-key="row => row.id"
:scroll-x="1100"
size="small"
/>
<n-pagination
v-model:page="ledgerPage"
:page-count="ledgerPageCount"
:page-size="10"
@update:page="loadLedger"
/>
</n-space>
</n-card>
<n-card class="aksrt-section-card"> <n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>每日签到</h3></div> <div class="aksrt-section-header"><h3>每日签到</h3></div>
<n-form :model="cfg.daily_signin" label-placement="left" label-width="200" class="aksrt-form"> <n-form :model="cfg.daily_signin" label-placement="left" label-width="200" class="aksrt-form">
@@ -97,7 +124,7 @@
<n-form-item :label="`默认积分价参考(${cfg.currency_name || '积分'})`"> <n-form-item :label="`默认积分价参考(${cfg.currency_name || '积分'})`">
<n-input-number v-model:value="cfg.download_default_cost" :min="0" :max="100000" /> <n-input-number v-model:value="cfg.download_default_cost" :min="0" :max="100000" />
<template #feedback> <template #feedback>
<span class="aksrt-hint-text">兼容字段。实际价格以文章「付费内容设置」的积分价/现金价为准;下载解锁走收银台订单,不按次扣费。</span> <span class="aksrt-hint-text">实际价格以文章「付费内容设置」的积分价或现金价为准;下载解锁走收银台订单,不按次扣费。</span>
</template> </template>
</n-form-item> </n-form-item>
</n-form> </n-form>
@@ -135,10 +162,84 @@ const cfg = reactive({
}) })
const streakJson = ref('{}') const streakJson = ref('{}')
const saving = ref(false) const saving = ref(false)
const ledgerLoading = ref(false)
const ledgerRows = ref([])
const ledgerPage = ref(1)
const ledgerPageCount = ref(1)
const ledgerFilters = reactive({
user_id: '',
asset: 'points',
date_from: '',
date_to: '',
})
const assetOptions = [
{ label: '积分', value: 'points' },
{ label: '余额', value: 'balance' },
]
const ledgerColumns = [
{ title: '类型', key: 'reason', width: 150 },
{ title: '订单号', key: 'ref_key', width: 200 },
{ title: '变动 / 变动后', key: 'delta_label', width: 190 },
{ title: '说明', key: 'remark', width: 240, ellipsis: { tooltip: true } },
{ title: '时间', key: 'created_at', width: 170 },
]
function formatAssetValue(asset, value) {
const raw = Number(value ?? 0)
if (!Number.isFinite(raw)) return asset === 'balance' ? '0.00' : '0'
if (asset !== 'balance') return String(raw)
const negative = raw < 0
const digits = String(Math.abs(Math.trunc(raw))).padStart(3, '0')
const whole = digits.slice(0, -2).replace(/^0+(?=\d)/, '')
return (negative ? '-' : '') + whole + '.' + digits.slice(-2)
}
async function loadLedger() {
ledgerLoading.value = true
try {
if (!/^\d+$/.test(String(ledgerFilters.user_id).trim())) {
message.warning('请输入用户 ID 后查询该用户近期记录')
return
}
const params = { page: ledgerPage.value, per_page: 10 }
Object.entries(ledgerFilters).forEach(([key, value]) => {
if (value !== null && String(value).trim() !== '') params[key] = value
})
const response = await api.getCreditLog(params)
const data = response?.data || response || {}
ledgerRows.value = (data.items || []).map((row) => {
const entries = Array.isArray(row.entries) ? row.entries : []
const entry = entries[0] || {}
return {
...row,
delta_label: `${formatAssetValue(entry.asset, entry.delta)} / ${formatAssetValue(entry.asset, entry.balance_after)}`,
remark: row.remark || '—',
}
})
ledgerPageCount.value = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.per_page || 10)))
} catch (e) {
message.error(e.message || '加载资产记录失败')
} finally {
ledgerLoading.value = false
}
}
function searchLedger() {
ledgerPage.value = 1
loadLedger()
}
function resetLedger() {
Object.assign(ledgerFilters, { user_id: '', asset: 'points', date_from: '', date_to: '' })
ledgerRows.value = []
ledgerPage.value = 1
ledgerPageCount.value = 1
}
async function loadCfg() { async function loadCfg() {
try { try {
const data = await api.getCreditSettings() const response = await api.getCreditSettings()
const data = response?.data || response || {}
Object.assign(cfg, data) Object.assign(cfg, data)
streakJson.value = JSON.stringify(cfg.daily_signin?.streak || {}, null, 0) streakJson.value = JSON.stringify(cfg.daily_signin?.streak || {}, null, 0)
} catch (e) { } catch (e) {
+199 -96
View File
@@ -6,7 +6,7 @@
:icon="pageMeta.icon" :icon="pageMeta.icon"
:schema="pageMeta.groups" :schema="pageMeta.groups"
:form-data="formData" :form-data="formData"
:dirty="dirty" :dirty="isSmsPage ? isSmsSettingsDirty : dirty"
:loading="loading" :loading="loading"
:saving="saving" :saving="saving"
:load-error="loadError" :load-error="loadError"
@@ -14,33 +14,33 @@
@update:form-data="formData = $event" @update:form-data="formData = $event"
@save="handleSave" @save="handleSave"
@reload="handleReload" @reload="handleReload"
@field-action="handleFieldAction"
> >
<template <template
v-if="isPermissionsLogin" v-if="isPermissionsLogin"
#field-tencent="{ value, setValue }" #group-providers
> >
<TencentConfig :model-value="value || {}" @update:model-value="setValue" /> <n-tabs
</template> v-model:value="captchaProviderTab"
type="segment"
<template animated
v-if="isPermissionsLogin" class="aksrt-tabs aksrt-captcha-provider-tabs"
#field-aliyun="{ value, setValue }" >
> <n-tab-pane
<AliyunConfig :model-value="value || {}" @update:model-value="setValue" /> v-for="provider in captchaProviders"
</template> :key="provider.key"
:name="provider.key"
<template :tab="provider.label"
v-if="isPermissionsLogin" >
#field-cloudflare="{ value, setValue }" <component
> :is="provider.component"
<CloudflareConfig :model-value="value || {}" @update:model-value="setValue" /> :model-value="getCaptchaProviderConfig(provider.key)"
</template> @update:model-value="setCaptchaProviderConfig(provider.key, $event)"
@clear-secret="markCaptchaSecretForClear(provider.key, $event)"
<template @restore-secret="unmarkCaptchaSecretForClear(provider.key, $event)"
v-if="isPermissionsLogin" />
#field-geetest="{ value, setValue }" </n-tab-pane>
> </n-tabs>
<GeetestConfig :model-value="value || {}" @update:model-value="setValue" />
</template> </template>
<template <template
@@ -80,7 +80,7 @@
<n-spin /> <n-spin />
<p style="margin-top:10px;color:#999;">加载中...</p> <p style="margin-top:10px;color:#999;">加载中...</p>
</div> </div>
<div v-if="testResult && !testLoading" :style="{color: testResult.success ? '#18a058' : '#d03050', textAlign:'center', padding:'20px'}"> <div v-if="testResult && !testLoading" :style="{color: testResult.success ? 'var(--aksrt-color-primary)' : '#d03050', textAlign:'center', padding:'20px'}">
<p style="font-size:16px;margin:0;">{{ testResult.message }}</p> <p style="font-size:16px;margin:0;">{{ testResult.message }}</p>
</div> </div>
<div v-if="!testLoading && !testResult" id="captcha-test-box" style="min-height:150px;"></div> <div v-if="!testLoading && !testResult" id="captcha-test-box" style="min-height:150px;"></div>
@@ -130,12 +130,32 @@ const message = useMessage()
const showTestModal = ref(false) const showTestModal = ref(false)
const testLoading = ref(false) const testLoading = ref(false)
const testResult = ref(null) const testResult = ref(null)
const serverSchema = ref(null)
const smsSavedSettings = ref('')
const captchaSecretsToClear = ref(new Set())
const captchaProviders = [
{ key: 'tencent', label: '腾讯云 Captcha', component: TencentConfig },
{ key: 'aliyun', label: '阿里云 CAPTCHA2.0', component: AliyunConfig },
{ key: 'cloudflare', label: 'Cloudflare Turnstile', component: CloudflareConfig },
{ key: 'geetest', label: '极验 Geetest', component: GeetestConfig },
]
const captchaProviderTab = ref('tencent')
/** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */ /** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path))) const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path)) const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
const isEmailPage = computed(() => resolvedDomain.value === 'global' && resolvedSection.value === 'email')
const isSmsPage = computed(() => resolvedDomain.value === 'users' && resolvedSection.value === 'sms')
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login') const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation') const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
const isSmsSettingsDirty = computed(() => smsSavedSettings.value !== '' && smsSavedSettings.value !== smsSettingsSnapshot(formData.value))
function smsSettingsSnapshot(data) {
const settings = { ...(data || {}) }
delete settings.sms_test_phone
delete settings.sms_test_send
return JSON.stringify(settings)
}
function resolveDomainFromPath(path) { function resolveDomainFromPath(path) {
const parts = path.split('/').filter(Boolean) const parts = path.split('/').filter(Boolean)
@@ -150,7 +170,9 @@ function resolveSectionFromPath(path) {
/** Load schema from the Vue-side registry */ /** Load schema from the Vue-side registry */
const pageMeta = computed(() => { const pageMeta = computed(() => {
const schema = getSchema(resolvedDomain.value, resolvedSection.value) const schema = (isEmailPage.value || resolvedDomain.value === 'page_display')
? serverSchema.value
: getSchema(resolvedDomain.value, resolvedSection.value)
if (schema && schema.groups && schema.groups.length > 0) { if (schema && schema.groups && schema.groups.length > 0) {
return { return {
title: schema.title || '', title: schema.title || '',
@@ -170,7 +192,9 @@ const pageMeta = computed(() => {
/** Default values from schema */ /** Default values from schema */
const defaults = computed(() => { const defaults = computed(() => {
const schema = getSchema(resolvedDomain.value, resolvedSection.value) const schema = (isEmailPage.value || resolvedDomain.value === 'page_display')
? serverSchema.value
: getSchema(resolvedDomain.value, resolvedSection.value)
if (!schema) return {} if (!schema) return {}
const d = {} const d = {}
for (const group of schema.groups || []) { for (const group of schema.groups || []) {
@@ -188,14 +212,80 @@ const {
formData, loading, saving, loadError, dirty, formData, loading, saving, loadError, dirty,
save: doSave, reload, save: doSave, reload,
} = useSectionForm({ } = useSectionForm({
fetch: () => api.getSchemaPage(resolvedDomain.value, resolvedSection.value), fetch: async () => {
submit: (data) => api.saveSchemaPage(resolvedDomain.value, resolvedSection.value, data), const response = await api.getSchemaPage(resolvedDomain.value, resolvedSection.value)
serverSchema.value = response?.schema || serverSchema.value
return response
},
submit: async (data) => {
const payload = { ...data }
const clearSecrets = []
Object.keys(payload).forEach((key) => {
if (payload[key] === '__AKSRT_CLEAR_SECRET__') {
clearSecrets.push(key)
delete payload[key]
}
})
if (isSmsPage.value) {
delete payload.sms_test_phone
delete payload.sms_test_send
}
if (clearSecrets.length) payload.__clear_secrets = clearSecrets
if (isPermissionsLogin.value && captchaSecretsToClear.value.size) {
payload.__clear_secrets = [...(payload.__clear_secrets || []), ...captchaSecretsToClear.value]
}
const response = await api.saveSchemaPage(resolvedDomain.value, resolvedSection.value, payload)
clearSecrets.forEach((key) => { data[key] = '' })
if (isPermissionsLogin.value) captchaSecretsToClear.value = new Set()
return response
},
defaults: defaults.value, defaults: defaults.value,
onLoaded: (data) => {
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(data)
},
onSaved: () => { onSaved: () => {
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(formData.value)
message.success('设置已保存') message.success('设置已保存')
}, },
}) })
async function handleFieldAction(key) {
if (key === 'sms_test_send' && isSmsPage.value) {
const phone = String(formData.value?.sms_test_phone || '').trim()
if (!phone) {
message.warning('请先填写测试手机号')
return
}
if (isSmsSettingsDirty.value) {
message.warning('请先保存当前配置,再发送测试短信')
return
}
try {
const response = await api.testSms(phone)
message.success(response?.message || '测试短信已发送')
} catch (error) {
message.error(error?.message || '测试短信发送失败')
}
return
}
if (key !== 'email_test_send' || !isEmailPage.value) return
const to = String(formData.value?.email_test_to || '').trim()
if (!to) {
message.warning('请先填写测试收件地址')
return
}
if (dirty.value) {
message.warning('请先保存当前配置,再发送测试邮件')
return
}
try {
const response = await api.testMail(to)
message.success(response?.message || '测试邮件已发送')
} catch (error) {
message.error(error?.message || '测试邮件发送失败')
}
}
async function handleSave() { async function handleSave() {
try { try {
await doSave() await doSave()
@@ -204,12 +294,38 @@ async function handleSave() {
} }
} }
function handleReload() { async function handleReload() {
closeTestModal() closeTestModal()
reload() captchaSecretsToClear.value = new Set()
await reload()
syncCaptchaProviderTab()
message.info('已放弃更改') message.info('已放弃更改')
} }
function markCaptchaSecretForClear(provider, field) {
const path = `${provider}.${field}`
const next = new Set(captchaSecretsToClear.value)
next.add(path)
captchaSecretsToClear.value = next
}
function unmarkCaptchaSecretForClear(provider, field) {
const path = `${provider}.${field}`
if (!captchaSecretsToClear.value.has(path)) return
const next = new Set(captchaSecretsToClear.value)
next.delete(path)
captchaSecretsToClear.value = next
}
function setCaptchaProviderConfig(provider, value) {
formData.value = { ...formData.value, [provider]: value || {} }
}
function syncCaptchaProviderTab() {
const provider = formData.value?.active_provider
captchaProviderTab.value = captchaProviders.some(({ key }) => key === provider) ? provider : 'tencent'
}
function getCaptchaProviderConfig(provider) { function getCaptchaProviderConfig(provider) {
if (!formData.value || !provider) return {} if (!formData.value || !provider) return {}
const cfg = formData.value[provider] const cfg = formData.value[provider]
@@ -232,9 +348,8 @@ function handleCaptchaTest() {
message.warning(`请先填写必填字段: ${missing.join(', ')}`) message.warning(`请先填写必填字段: ${missing.join(', ')}`)
return return
} }
if (dirty.value) {
if (provider === 'tencent') { message.warning('请先保存当前配置,再执行真实服务端验签。')
loadTencentTest(cfg)
return return
} }
@@ -243,52 +358,20 @@ function handleCaptchaTest() {
testLoading.value = true testLoading.value = true
nextTick(() => { nextTick(() => {
createTestIframe(provider, cfg) loadProviderSDK(provider, cfg)
}) })
} }
function loadTencentTest(cfg) { async function submitCaptchaTest(provider, token) {
const sdkSrc = 'https://turing.captcha.qcloud.com/TJCaptcha.js' testLoading.value = true
try {
if (typeof window.TencentCaptcha !== 'undefined') { const response = await api.testCaptcha(provider, token)
try { testResult.value = { success: true, message: response?.message || '服务端同源验签通过' }
new window.TencentCaptcha(cfg.app_id, function(res) { } catch (error) {
if (res.ret === 0) message.success('腾讯云验证码测试成功!配置正确。') testResult.value = { success: false, message: error?.message || '服务端验签失败' }
else message.warning('验证已取消。') } finally {
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
message.error('初始化失败: ' + error.message)
}
return
}
const existingScript = document.querySelector('script[src="' + sdkSrc + '"]')
if (existingScript) {
existingScript.addEventListener('load', function() { loadTencentTest(cfg) }, { once: true })
return
}
const script = document.createElement('script')
script.src = sdkSrc
script.onload = function () {
try {
new window.TencentCaptcha(cfg.app_id, function(res) {
if (res.ret === 0) message.success('腾讯云验证码测试成功!')
else message.warning('验证已取消。')
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
message.error('初始化失败: ' + error.message)
}
}
script.onerror = function () { message.error('加载腾讯验证码SDK失败') }
document.head.appendChild(script)
}
function createTestIframe(provider, cfg) {
setTimeout(() => {
testLoading.value = false testLoading.value = false
loadProviderSDK(provider, cfg) }
}, 100)
} }
function loadProviderSDK(provider, cfg) { function loadProviderSDK(provider, cfg) {
@@ -307,28 +390,37 @@ function loadProviderSDK(provider, cfg) {
case 'tencent': case 'tencent':
script.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js' script.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
script.onload = function () { script.onload = function () {
testLoading.value = false
try { try {
new window.TencentCaptcha(cfg.app_id, function(res) { new window.TencentCaptcha(cfg.app_id, function(res) {
testResult.value = { success: res.ret === 0, message: res.ret === 0 ? '验证成功!' : '验证已取消。' } if (res.ret === 0) {
submitCaptchaTest('tencent', {
tencent_ticket: res.ticket || '',
tencent_randstr: res.randstr || '',
})
} else {
testResult.value = { success: false, message: '验证已取消。' }
}
}, { userLanguage: 'zh-cn' }).show() }, { userLanguage: 'zh-cn' }).show()
} catch (error) { } catch (error) {
testResult.value = { success: false, message: '初始化失败: ' + error.message } testResult.value = { success: false, message: '初始化失败: ' + error.message }
} }
} }
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } } script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
break break
case 'cloudflare': case 'cloudflare':
window.onTurnstileTestLoad = function() { window.onTurnstileTestLoad = function() {
testLoading.value = false
try { try {
window.turnstile.render('#captcha-test-box', { window.turnstile.render('#captcha-test-box', {
sitekey: cfg.site_key, sitekey: cfg.site_key,
theme: 'auto', theme: cfg.theme || 'auto',
size: cfg.size || 'normal', size: cfg.size || 'normal',
callback: function() { action: 'aksrt-admin-test',
testResult.value = { success: true, message: '验证成功!' } cdata: 'admin-captcha-test',
message.success('Cloudflare Turnstile 测试成功!') callback: function(token) {
setTimeout(closeTestModal, 1500) submitCaptchaTest('cloudflare', { cf_turnstile_response: token })
}, },
'error-callback': function() { 'error-callback': function() {
testResult.value = { success: false, message: '验证失败' } testResult.value = { success: false, message: '验证失败' }
@@ -339,19 +431,28 @@ function loadProviderSDK(provider, cfg) {
} }
} }
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileTestLoad' script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileTestLoad'
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } } script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
break break
case 'geetest': case 'geetest':
script.src = 'https://static.geetest.com/v4/gt4.js' script.src = 'https://static.geetest.com/v4/gt4.js'
script.onload = function () { script.onload = function () {
testLoading.value = false
try { try {
window.initGeetest4({ captchaId: cfg.captcha_id, product: cfg.product || 'float' }, function(captchaObj) { window.initGeetest4({ captchaId: cfg.captcha_id, product: cfg.product || 'float' }, function(captchaObj) {
window.__gtTestCaptcha = captchaObj window.__gtTestCaptcha = captchaObj
const onSuccess = function() { const onSuccess = function() {
testResult.value = { success: true, message: '验证成功!' } const result = captchaObj.getValidate ? captchaObj.getValidate() : null
message.success('极验 GeeTest 测试成功!') if (!result) {
setTimeout(closeTestModal, 1500) testResult.value = { success: false, message: '未取得极验验证参数' }
return
}
submitCaptchaTest('geetest', {
geetest_lot_number: result.lot_number || '',
geetest_captcha_output: result.captcha_output || '',
geetest_pass_token: result.pass_token || '',
geetest_gen_time: result.gen_time || '',
})
} }
const onError = function(error) { const onError = function(error) {
testResult.value = { success: false, message: '出错: ' + (error.msg || '?') } testResult.value = { success: false, message: '出错: ' + (error.msg || '?') }
@@ -369,22 +470,21 @@ function loadProviderSDK(provider, cfg) {
testResult.value = { success: false, message: '初始化失败: ' + error.message } testResult.value = { success: false, message: '初始化失败: ' + error.message }
} }
} }
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } } script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
break break
case 'aliyun': case 'aliyun':
window.AliyunCaptchaConfig = { region: cfg.region || 'cn', prefix: cfg.prefix } window.AliyunCaptchaConfig = { region: cfg.region || 'cn', prefix: cfg.prefix }
script.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js' script.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
script.onload = function () { script.onload = function () {
testLoading.value = false
try { try {
window.initAliyunCaptcha({ window.initAliyunCaptcha({
SceneId: cfg.scene_id, SceneId: cfg.scene_id,
mode: 'embed', mode: 'embed',
element: '#captcha-test-box', element: '#captcha-test-box',
success: function() { success: function(captchaVerifyParam) {
testResult.value = { success: true, message: '验证成功!' } submitCaptchaTest('aliyun', { aliyun_captcha_verify_param: String(captchaVerifyParam || '') })
message.success('阿里云测试成功!')
setTimeout(closeTestModal, 1500)
}, },
fail: function(result) { fail: function(result) {
testResult.value = { success: false, message: String(result) } testResult.value = { success: false, message: String(result) }
@@ -397,7 +497,7 @@ function loadProviderSDK(provider, cfg) {
testResult.value = { success: false, message: '初始化失败: ' + error.message } testResult.value = { success: false, message: '初始化失败: ' + error.message }
} }
} }
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } } script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
break break
default: default:
@@ -423,9 +523,12 @@ function closeTestModal() {
} }
} }
watch(() => formData.value?.active_provider, syncCaptchaProviderTab)
watch([resolvedDomain, resolvedSection], ([nextDomain, nextSection], [prevDomain, prevSection]) => { watch([resolvedDomain, resolvedSection], ([nextDomain, nextSection], [prevDomain, prevSection]) => {
if (nextDomain !== prevDomain || nextSection !== prevSection) { if (nextDomain !== prevDomain || nextSection !== prevSection) {
closeTestModal() closeTestModal()
serverSchema.value = null
reload() reload()
} }
}) })
@@ -437,8 +540,8 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.aksrt-schema-page { .aksrt-schema-page {
max-width: 1100px; max-width: none;
margin: 0 auto; margin: 0;
} }
.aksrt-schema-page__tool-actions { .aksrt-schema-page__tool-actions {
@@ -450,7 +553,7 @@ onBeforeUnmount(() => {
.aksrt-schema-page__field-hint { .aksrt-schema-page__field-hint {
margin: 8px 0 0; margin: 8px 0 0;
font-size: 12px; font-size: 12px;
color: #8a909c; color: var(--aksrt-color-muted);
} }
.captcha-test-overlay { .captcha-test-overlay {
@@ -1,403 +0,0 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader
title="Banner 管理"
desc="管理首页内容流 Banner 幻灯片(图片、文案、跳转、启用状态)。"
/>
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header>
<div class="aksrt-card-header-row">
<strong>Banner 显示开关</strong>
<n-button size="small" secondary @click="goHeaderComponents">顶部多功能组件(轮播参数)</n-button>
</div>
</template>
<n-form label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="显示首页 Banner">
<div class="aksrt-inline-setting">
<n-switch v-model:value="bannerEnabled" :disabled="settingsLoading || settingsSaving" />
<span class="aksrt-inline-setting__hint">关闭后,首页 Banner 区块将不再渲染。</span>
</div>
</n-form-item>
</n-form>
<div class="aksrt-inline-setting__actions">
<n-button type="primary" :loading="settingsSaving" :disabled="settingsLoading" @click="saveBannerSettings">保存 Banner 开关</n-button>
</div>
</n-card>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px" aria-hidden="true">
<rect width="18" height="14" x="3" y="5" rx="2"/>
<path d="M3 10h18"/>
</svg>
</span>
<strong class="aksrt-card-header-row__title">Banner 列表 ({{ banners.length }})</strong>
</div>
<n-button type="primary" size="small" @click="openCreate">+ 添加 Banner</n-button>
</div>
</template>
<n-data-table
v-if="banners.length"
:columns="columns"
:data="banners"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无 Banner,添加后即可在首页使用。" />
</n-card>
</n-spin>
<n-modal
v-model:show="modalShow"
:mask-closable="false"
preset="card"
:title="editing.id ? '编辑 Banner' : '添加 Banner'"
class="aksrt-modal-card"
>
<n-form :model="editing" label-placement="top" class="aksrt-form">
<n-form-item label="标题">
<n-input v-model:value="editing.title" placeholder="开启文字后显示的主标题" />
</n-form-item>
<n-form-item label="副标题">
<n-input v-model:value="editing.subtitle" placeholder="开启文字后显示的副标题" />
</n-form-item>
<n-form-item label="Banner 图片">
<n-input-group>
<n-input v-model:value="editing.image_url" placeholder="https://..." />
<n-button secondary @click="pickImage">媒体库</n-button>
</n-input-group>
</n-form-item>
<div v-if="editing.image_url" class="aksrt-modal-preview">
<n-image :src="editing.image_url" width="100%" height="160" object-fit="cover" :preview-src="editing.image_url" />
</div>
<n-form-item label="跳转链接">
<n-input v-model:value="editing.link_url" placeholder="点击后跳转地址,可留空" />
</n-form-item>
<div class="aksrt-modal-grid">
<n-form-item label="显示文字">
<n-switch v-model:value="editing.show_text" />
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="editing.enabled" />
</n-form-item>
<n-form-item label="显示元信息">
<n-switch v-model:value="editing.show_meta" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
</n-form-item>
</div>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="modalShow = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup>
import { h, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import { openMediaPicker } from '@/utils/media'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
function goHeaderComponents() {
router.push('/page-display/header-components')
}
const loading = ref(true)
const saving = ref(false)
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const banners = ref([])
const bannerEnabled = ref(true)
const modalShow = ref(false)
const editing = ref(emptyForm())
function emptyForm() {
return {
id: 0,
title: '',
subtitle: '',
image_url: '',
link_url: '',
show_text: true,
enabled: true,
show_meta: false,
sort_order: 0,
}
}
const columns = [
{
title: '预览',
key: 'image_url',
width: 90,
render(row) {
if (!row.image_url) {
return h('span', { style: 'color:#bbb;font-size:12px;' }, '无')
}
return h(NImage, {
src: row.image_url,
width: 64,
height: 36,
objectFit: 'cover',
previewSrc: row.image_url,
style: 'border-radius:4px;display:block;',
})
},
},
{
title: '标题',
key: 'title',
ellipsis: { tooltip: true },
render(row) {
return row.title || h('span', { style: 'color:#bbb;' }, '未命名')
},
},
{
title: '文字',
key: 'show_text',
width: 70,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_text ? 'info' : 'default' }, {
default: () => (row.show_text ? '显示' : '隐藏'),
})
},
},
{
title: '排序',
key: 'sort_order',
width: 64,
className: 'aksrt-table-mobile-hide',
},
{
title: '元信息',
key: 'show_meta',
width: 78,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_meta ? 'info' : 'default' }, {
default: () => (row.show_meta ? '显示' : '隐藏'),
})
},
},
{
title: '状态',
key: 'enabled',
width: 84,
render(row) {
return h(NTag, { size: 'small', type: row.enabled ? 'success' : 'default' }, {
default: () => (row.enabled ? '启用' : '禁用'),
})
},
},
{
title: '操作',
key: 'actions',
width: 130,
render(row) {
return h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => handleDelete(row) }, { default: () => '删除' }),
])
},
},
]
async function loadBanners() {
try {
loading.value = true
const res = await api.getHomepageBanners()
if (res.code === 'success') {
banners.value = res.data || []
}
} catch (e) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
async function loadBannerSettings() {
settingsLoading.value = true
try {
const res = await api.getHomepageSettings()
const data = res?.data || {}
bannerEnabled.value = data.homepage_module_banner_enabled !== false
} catch (e) {
message.error(e.message || '加载 Banner 开关失败')
} finally {
settingsLoading.value = false
}
}
async function saveBannerSettings() {
settingsSaving.value = true
try {
const res = await api.saveHomepageSettings({
homepage_module_banner_enabled: bannerEnabled.value,
})
if (res?.code === 'success') {
message.success('Banner 开关已保存')
return
}
throw new Error(res?.message || '保存 Banner 开关失败')
} catch (e) {
message.error(e.message || '保存 Banner 开关失败')
} finally {
settingsSaving.value = false
}
}
function openCreate() {
editing.value = emptyForm()
modalShow.value = true
}
function openEdit(row) {
editing.value = {
id: row.id,
title: row.title || '',
subtitle: row.subtitle || '',
image_url: row.image_url || '',
link_url: row.link_url || '',
show_text: row.show_text !== false,
enabled: !!row.enabled,
show_meta: row.show_meta === true,
sort_order: Number(row.sort_order) || 0,
}
modalShow.value = true
}
async function pickImage() {
try {
const media = await openMediaPicker({
title: '选择 Banner 图片',
buttonText: '使用这张 Banner 图',
})
if (media?.url) {
editing.value.image_url = media.url
}
} catch (e) {
message.error(e.message || '打开媒体库失败')
}
}
async function handleSave() {
if (!editing.value.image_url) {
message.warning('请填写 Banner 图片')
return
}
saving.value = true
try {
if (editing.value.id) {
await api.updateHomepageBanner(editing.value.id, editing.value)
message.success('已保存')
} else {
await api.createHomepageBanner(editing.value)
message.success('已添加')
}
modalShow.value = false
await loadBanners()
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '确认删除',
content: `确定删除 Banner「${row.title || '未命名'}」?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteHomepageBanner(row.id)
message.success('已删除')
await loadBanners()
} catch (e) {
message.error(e.message || '删除失败')
}
},
})
}
onMounted(() => {
loadBanners()
loadBannerSettings()
})
</script>
<style scoped>
.aksrt-inline-setting {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.aksrt-inline-setting__hint {
color: #7a7f8a;
font-size: 13px;
}
.aksrt-inline-setting__actions {
display: flex;
justify-content: flex-end;
}
.aksrt-modal-preview {
margin: -8px 0 18px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #eef1f6;
}
.aksrt-modal-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
}
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 768px) {
.aksrt-modal-grid {
grid-template-columns: 1fr;
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -1,986 +0,0 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader title="页脚配置" desc="管理页脚区域的联系方式、快速链接、更多链接、友情链接和版权信息。">
<template #actions>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</template>
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<n-tabs
v-model:value="activeTab"
type="line"
animated
class="aksrt-tabs"
>
<!-- ============== 品牌与Logo ============== -->
<n-tab-pane name="brand" tab="品牌与Logo">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span>
<h3>品牌列(第一列)</h3>
</div>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="显示设备">
<n-checkbox-group v-model:value="form.footer_t1_m_s">
<n-space>
<n-checkbox value="pc" label="PC" />
<n-checkbox value="mobile" label="移动端" />
</n-space>
</n-checkbox-group>
</n-form-item>
<n-form-item label="页脚 Logo(日间)">
<div class="aksrt-media-row">
<n-input v-model:value="form.footer_t1_img" placeholder="图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMedia('footer_t1_img')">选择</n-button>
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img" @click="form.footer_t1_img = ''">清除</n-button>
</div>
<img v-if="form.footer_t1_img" :src="form.footer_t1_img" class="aksrt-media-preview" alt="footer logo day" />
</n-form-item>
<n-form-item label="页脚 Logo(夜间)">
<div class="aksrt-media-row">
<n-input v-model:value="form.footer_t1_img_dark" placeholder="图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMedia('footer_t1_img_dark')">选择</n-button>
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img_dark" @click="form.footer_t1_img_dark = ''">清除</n-button>
</div>
<img v-if="form.footer_t1_img_dark" :src="form.footer_t1_img_dark" class="aksrt-media-preview" alt="footer logo night" />
</n-form-item>
<n-form-item label="品牌标题">
<n-input v-model:value="form.footer_t1_t" placeholder="留空则使用站点名称" />
</n-form-item>
<n-form-item label="品牌列自定义代码">
<n-input v-model:value="form.fcode_t1_code" type="textarea" :rows="3" placeholder="可选,显示在品牌区域底部" />
</n-form-item>
</n-form>
</n-card>
</n-tab-pane>
<!-- ============== 联系方式 ============== -->
<n-tab-pane name="contacts" tab="联系方式">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg></span>
<strong class="aksrt-card-header-row__title">联系方式图标</strong>
</div>
<n-button type="primary" size="small" @click="addContact">+ 添加图标</n-button>
</div>
</template>
<p class="section-desc">页脚中的社交平台或联系入口。</p>
<n-data-table
v-if="form.contacts.length"
:columns="contactColumns"
:data="form.contacts"
:row-key="(row) => row._uid"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无联系方式图标" />
<n-divider>标准联系字段</n-divider>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="显示设备">
<n-checkbox-group v-model:value="form.footer_contact_m_s">
<n-space>
<n-checkbox value="pc" label="PC" />
<n-checkbox value="mobile" label="移动端" />
</n-space>
</n-checkbox-group>
</n-form-item>
<n-form-item label="微信二维码">
<div class="aksrt-media-row">
<n-input v-model:value="form.footer_contact_wechat_img" placeholder="图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMedia('footer_contact_wechat_img')">选择</n-button>
<n-button size="small" quaternary type="error" :disabled="!form.footer_contact_wechat_img" @click="form.footer_contact_wechat_img = ''">清除</n-button>
</div>
<img v-if="form.footer_contact_wechat_img" :src="form.footer_contact_wechat_img" class="aksrt-media-preview" alt="wechat qr" />
</n-form-item>
<n-form-item label="QQ">
<n-input v-model:value="form.footer_contact_qq" placeholder="QQ 号或链接" />
</n-form-item>
<n-form-item label="微博">
<n-input v-model:value="form.footer_contact_weibo" placeholder="微博链接" />
</n-form-item>
<n-form-item label="邮箱">
<n-input v-model:value="form.footer_contact_email" placeholder="邮箱地址" />
</n-form-item>
<n-form-item label="链接 HTML(fcode_t2_code_1)">
<n-input v-model:value="form.fcode_t2_code_1" type="textarea" :rows="3" placeholder="推荐直接写 <a href=&quot;/&quot;>首页</a> 等链接 HTML;为空时回退快速链接/友链/热门分类" />
</n-form-item>
<n-form-item label="链接下方自定义代码">
<n-input v-model:value="form.fcode_t2_code_2" type="textarea" :rows="2" placeholder="备案号等 HTML" />
</n-form-item>
</n-form>
<n-divider>更多联系项</n-divider>
<n-card class="aksrt-section-card" embedded>
<template #header>
<div class="aksrt-card-header-row">
<strong class="aksrt-card-header-row__title">扩展联系项</strong>
<n-button type="primary" size="small" @click="addContactMore">+ 添加</n-button>
</div>
</template>
<p class="section-desc">每项含描述、图标、链接、图片(hover 显示),用于补充标准联系方式。</p>
<n-data-table
v-if="form.footer_contact_more.length"
:columns="contactMoreColumns"
:data="form.footer_contact_more"
:row-key="(_, i) => i"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无扩展联系项" />
</n-card>
</n-card>
</n-tab-pane>
<!-- ============== 快速链接 ============== -->
<n-tab-pane name="quick" tab="快速链接">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
<strong class="aksrt-card-header-row__title">快速链接</strong>
</div>
<n-button type="primary" size="small" @click="openLinkEditor('quick_links')">+ 添加链接</n-button>
</div>
</template>
<p class="section-desc">页脚第二列的常用导航链接。</p>
<n-data-table
v-if="form.quick_links.length"
:columns="createLinkColumns('quick_links')"
:data="form.quick_links"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无快速链接" />
</n-card>
</n-tab-pane>
<!-- ============== 更多链接 ============== -->
<n-tab-pane name="more" tab="更多链接">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
<strong class="aksrt-card-header-row__title">更多链接</strong>
</div>
<n-button type="primary" size="small" @click="openLinkEditor('more_links')">+ 添加链接</n-button>
</div>
</template>
<p class="section-desc">页脚补充链接区域。</p>
<n-data-table
v-if="form.more_links.length"
:columns="createLinkColumns('more_links')"
:data="form.more_links"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无更多链接" />
</n-card>
</n-tab-pane>
<!-- ============== 友情链接 ============== -->
<n-tab-pane name="friends" tab="友情链接">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
<strong class="aksrt-card-header-row__title">友情链接 ({{ friendLinks.length }})</strong>
</div>
<n-button type="primary" size="small" @click="openFriendEditor()">+ 添加友链</n-button>
</div>
</template>
<p class="section-desc">站点友情链接列表,支持 Logo 头像、名称、URL 与简短描述。</p>
<n-data-table
v-if="friendLinks.length"
:columns="friendColumns"
:data="friendLinks"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无友情链接" />
</n-card>
</n-tab-pane>
<!-- ============== 二维码图片组 ============== -->
<n-tab-pane name="mini_qr" tab="二维码图片组">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
<strong class="aksrt-card-header-row__title">二维码图片组(最多 4 个)</strong>
</div>
<n-button type="primary" size="small" :disabled="form.footer_mini_img.length >= 4" @click="addMiniImg">+ 添加</n-button>
</div>
</template>
<p class="section-desc">显示在页脚第三列区域,每项含文字描述和二维码图片。</p>
<n-form :model="form" label-placement="left" label-width="120" class="aksrt-form">
<n-form-item label="显示设备">
<n-checkbox-group v-model:value="form.footer_mini_img_m_s">
<n-space>
<n-checkbox value="pc" label="PC" />
<n-checkbox value="mobile" label="移动端" />
</n-space>
</n-checkbox-group>
</n-form-item>
</n-form>
<div v-for="(item, idx) in form.footer_mini_img" :key="idx" class="aksrt-mini-img-row">
<n-space align="center" :size="12" style="width: 100%;">
<n-input v-model:value="item.text" placeholder="文字描述" style="width: 160px;" />
<div class="aksrt-media-row" style="flex: 1;">
<n-input v-model:value="item.img" placeholder="二维码图片 URL" readonly style="flex: 1;" />
<n-button size="small" @click="pickMiniImg(idx)">选择</n-button>
</div>
<n-button size="small" quaternary type="error" @click="removeMiniImg(idx)">删除</n-button>
</n-space>
<img v-if="item.img" :src="item.img" class="aksrt-media-preview aksrt-media-preview--sm" :alt="item.text || 'qr'" />
</div>
<n-empty v-if="!form.footer_mini_img.length" description="暂无二维码图片" />
</n-card>
</n-tab-pane>
<!-- ============== 自定义代码 ============== -->
<n-tab-pane name="custom_code" tab="自定义代码">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></span>
<h3>底部自定义代码</h3>
</div>
<p class="section-desc">插入在页脚底部区域(版权信息之后),支持 HTML。</p>
<n-form :model="form" label-placement="top" class="aksrt-form">
<n-form-item label="fcode_customize_code">
<n-input v-model:value="form.fcode_customize_code" type="textarea" :rows="6" placeholder="自定义 HTML 代码,显示在页脚最底部" />
</n-form-item>
</n-form>
</n-card>
</n-tab-pane>
<!-- ============== 版权与杂项 ============== -->
<n-tab-pane name="misc" tab="版权与杂项">
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<h3>其他设置</h3>
</div>
<n-form :model="form" label-placement="left" label-width="140" class="aksrt-form">
<n-form-item label="页脚布局模板">
<n-radio-group v-model:value="form.layout_template">
<n-radio-button value="template_1">模板一(Zibll)</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="热门分类数量">
<n-space align="center" :size="10">
<n-input-number v-model:value="form.hot_categories" :min="1" :max="20" style="width: 120px;" />
<span class="aksrt-hint-text">范围 1 - 20</span>
</n-space>
</n-form-item>
<n-form-item label="自定义版权文案">
<n-input
v-model:value="form.copyright_text"
type="textarea"
:rows="3"
placeholder="留空则使用默认版权信息"
/>
</n-form-item>
</n-form>
</n-card>
</n-tab-pane>
</n-tabs>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存页脚配置</n-button>
</div>
</n-spin>
<!-- ===== 联系方式 Modal ===== -->
<n-modal
v-model:show="showContactModal"
:mask-closable="false"
preset="card"
title="编辑联系方式图标"
class="aksrt-modal-card"
>
<n-form :model="editingContact" label-placement="top" class="aksrt-form">
<n-form-item label="图标类型">
<n-select v-model:value="editingContact.icon" :options="iconOptions" placeholder="选择图标" />
</n-form-item>
<n-form-item label="链接地址">
<n-input v-model:value="editingContact.url" placeholder="https://..." />
</n-form-item>
<n-form-item label="标题提示">
<n-input v-model:value="editingContact.title" placeholder="鼠标悬停提示文案" />
</n-form-item>
<n-form-item label="启用状态">
<n-switch v-model:value="editingContact.enabled" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showContactModal = false">取消</n-button>
<n-button type="primary" @click="saveContact">保存</n-button>
</div>
</template>
</n-modal>
<!-- ===== 链接 Modal ===== -->
<n-modal
v-model:show="showLinkModal"
:mask-closable="false"
preset="card"
:title="editingLinkType === 'quick_links' ? '编辑快速链接' : '编辑更多链接'"
class="aksrt-modal-card"
>
<n-form :model="editingLink" label-placement="top" class="aksrt-form">
<n-form-item label="链接名称">
<n-input v-model:value="editingLink.name" placeholder="例如:首页" />
</n-form-item>
<n-form-item label="链接地址">
<n-input v-model:value="editingLink.url" placeholder="https:// 或 /path" />
</n-form-item>
<n-form-item label="启用状态">
<n-switch v-model:value="editingLink.enabled" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showLinkModal = false">取消</n-button>
<n-button type="primary" @click="saveLink">保存</n-button>
</div>
</template>
</n-modal>
<!-- ===== 友情链接 Modal ===== -->
<n-modal
v-model:show="showFriendModal"
:mask-closable="false"
preset="card"
title="添加友情链接"
class="aksrt-modal-card"
>
<n-form :model="editingFriend" label-placement="top" class="aksrt-form">
<n-form-item label="站点名称">
<n-input v-model:value="editingFriend.name" placeholder="如:某人的博客" />
</n-form-item>
<n-form-item label="站点 URL">
<n-input v-model:value="editingFriend.url" placeholder="https://" />
</n-form-item>
<n-form-item label="Logo 图片 URL">
<n-input v-model:value="editingFriend.logo" placeholder="图标 URL(可选,方形 PNG 推荐)" />
</n-form-item>
<n-form-item label="描述">
<n-input v-model:value="editingFriend.description" placeholder="一句话简介(可选)" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showFriendModal = false">取消</n-button>
<n-button type="primary" :loading="addingFriend" @click="saveFriend">保存</n-button>
</div>
</template>
</n-modal>
<!-- ===== 扩展联系项 Modal ===== -->
<n-modal
v-model:show="showContactMoreModal"
:mask-closable="false"
preset="card"
title="编辑扩展联系项"
class="aksrt-modal-card"
>
<n-form :model="editingContactMore" label-placement="top" class="aksrt-form">
<n-form-item label="描述">
<n-input v-model:value="editingContactMore.desc" placeholder="如:微信公众号" />
</n-form-item>
<n-form-item label="图标类名">
<n-input v-model:value="editingContactMore.icon" placeholder="如:fab fa-weixin" />
</n-form-item>
<n-form-item label="链接地址">
<n-input v-model:value="editingContactMore.link" placeholder="https://..." />
</n-form-item>
<n-form-item label="Hover 图片">
<n-input v-model:value="editingContactMore.img" placeholder="图片 URL(可选,hover 显示)" />
</n-form-item>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="showContactMoreModal = false">取消</n-button>
<n-button type="primary" @click="saveContactMore">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup>
import { h, onMounted, reactive, ref } from 'vue'
import { NAvatar, NButton, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import { openMediaPicker } from '@/utils/media'
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const saving = ref(false)
const activeTab = ref('contacts')
const form = reactive({
contacts: [],
quick_links: [],
more_links: [],
hot_categories: 5,
copyright_text: '',
layout_template: 'template_1',
// §2.3 三列结构化字段
footer_t1_m_s: ['pc', 'mobile'],
footer_t1_img: '',
footer_t1_img_dark: '',
footer_t1_t: '',
fcode_t1_code: '',
footer_contact_wechat_img: '',
footer_contact_qq: '',
footer_contact_weibo: '',
footer_contact_email: '',
footer_contact_m_s: ['pc', 'mobile'],
footer_contact_more: [],
fcode_t2_code_1: '',
fcode_t2_code_2: '',
footer_mini_img_m_s: ['pc', 'mobile'],
footer_mini_img: [],
fcode_customize_code: '',
})
// ============== 联系方式 ==============
const showContactModal = ref(false)
const editingContactIndex = ref(-1)
const editingContact = reactive({
icon: '',
url: '',
title: '',
enabled: true,
})
let contactUid = 0
const iconOptions = [
{ label: 'GitHub', value: 'github' },
{ label: 'X (Twitter)', value: 'x' },
{ label: 'Email', value: 'email' },
{ label: '微信', value: 'wechat' },
{ label: 'QQ', value: 'qq' },
{ label: '微博', value: 'weibo' },
{ label: 'Telegram', value: 'telegram' },
{ label: 'Discord', value: 'discord' },
{ label: 'Bilibili', value: 'bilibili' },
{ label: 'LinkedIn', value: 'linkedin' },
{ label: 'Facebook', value: 'facebook' },
{ label: 'YouTube', value: 'youtube' },
{ label: 'Instagram', value: 'instagram' },
{ label: 'RSS / 网站', value: 'web' },
]
function addContact() {
Object.assign(editingContact, {
icon: '',
url: '',
title: '',
enabled: true,
})
editingContactIndex.value = -1
showContactModal.value = true
}
function editContact(row, index) {
Object.assign(editingContact, {
icon: row.icon,
url: row.url,
title: row.title,
enabled: row.enabled,
})
editingContactIndex.value = index
showContactModal.value = true
}
function saveContact() {
if (!editingContact.icon || !editingContact.url) {
message.warning('请填写图标类型和链接地址')
return
}
const item = {
_uid: editingContactIndex.value >= 0 ? form.contacts[editingContactIndex.value]._uid : ++contactUid,
icon: editingContact.icon,
url: editingContact.url,
title: editingContact.title,
enabled: editingContact.enabled,
}
if (editingContactIndex.value >= 0) {
form.contacts[editingContactIndex.value] = item
} else {
form.contacts.push(item)
}
showContactModal.value = false
}
function deleteContact(index) {
dialog.warning({
title: '确认删除',
content: '确定删除这个联系方式图标吗?',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
form.contacts.splice(index, 1)
},
})
}
const contactColumns = [
{
title: '平台',
key: 'icon',
width: 120,
render: (row) => iconOptions.find((item) => item.value === row.icon)?.label || row.icon,
},
{
title: '链接',
key: 'url',
ellipsis: { tooltip: true },
},
{
title: '提示',
key: 'title',
ellipsis: { tooltip: true },
className: 'aksrt-table-mobile-hide',
},
{
title: '状态',
key: 'enabled',
width: 80,
render: (row) => h(
NTag,
{ size: 'small', type: row.enabled ? 'success' : 'default' },
{ default: () => (row.enabled ? '启用' : '禁用') }
),
},
{
title: '操作',
key: 'actions',
width: 130,
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => editContact(form.contacts[index], index) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteContact(index) }, { default: () => '删除' }),
]),
},
]
// ============== 快速 / 更多链接 ==============
const showLinkModal = ref(false)
const editingLinkType = ref('quick_links')
const editingLinkIndex = ref(-1)
const editingLink = reactive({
id: '',
name: '',
url: '',
enabled: true,
sort_order: 0,
})
function openLinkEditor(type, row = null, index = -1) {
editingLinkType.value = type
editingLinkIndex.value = index
Object.assign(editingLink, row || {
id: '',
name: '',
url: '',
enabled: true,
sort_order: type === 'quick_links' ? form.quick_links.length : form.more_links.length,
})
showLinkModal.value = true
}
function saveLink() {
if (!editingLink.name || !editingLink.url) {
message.warning('请填写链接名称和链接地址')
return
}
const target = form[editingLinkType.value]
const item = {
id: editingLink.id || `link_${Date.now()}`,
name: editingLink.name,
url: editingLink.url,
enabled: editingLink.enabled,
sort_order: editingLink.sort_order ?? target.length,
}
if (editingLinkIndex.value >= 0) {
target[editingLinkIndex.value] = item
} else {
target.push(item)
}
showLinkModal.value = false
}
function deleteLink(type, index) {
dialog.warning({
title: '确认删除',
content: '确定删除这个链接吗?',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
form[type].splice(index, 1)
},
})
}
function createLinkColumns(type) {
return [
{
title: '名称',
key: 'name',
width: 160,
},
{
title: 'URL',
key: 'url',
ellipsis: { tooltip: true },
},
{
title: '状态',
key: 'enabled',
width: 80,
render: (row) => h(
NTag,
{ size: 'small', type: row.enabled ? 'success' : 'default' },
{ default: () => (row.enabled ? '启用' : '禁用') }
),
},
{
title: '操作',
key: 'actions',
width: 130,
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => openLinkEditor(type, form[type][index], index) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteLink(type, index) }, { default: () => '删除' }),
]),
},
]
}
// ============== 友情链接 ==============
const friendLinks = ref([])
const showFriendModal = ref(false)
const addingFriend = ref(false)
const editingFriend = reactive({
name: '',
url: '',
logo: '',
description: '',
})
const friendColumns = [
{
title: 'Logo',
key: 'logo',
width: 60,
render: (row) => row.logo ? h(NAvatar, { src: row.logo, size: 32, round: true }) : h('span', { style: 'color:#bbb;font-size:12px;' }, '—'),
},
{ title: '名称', key: 'name', width: 140, ellipsis: { tooltip: true } },
{ title: 'URL', key: 'url', ellipsis: { tooltip: true } },
{ title: '描述', key: 'description', ellipsis: { tooltip: true }, className: 'aksrt-table-mobile-hide' },
{
title: '操作',
key: 'actions',
width: 90,
render: (row) => h(NButton, {
quaternary: true,
type: 'error',
size: 'tiny',
onClick: () => deleteFriend(row),
}, { default: () => '删除' }),
},
]
function openFriendEditor() {
Object.assign(editingFriend, {
name: '',
url: '',
logo: '',
description: '',
})
showFriendModal.value = true
}
async function loadFriendLinks() {
try {
const res = await api.getFriendLinks()
if (res.code === 'success') friendLinks.value = res.data || []
} catch (e) {
console.error('[FriendLinks] load failed:', e)
}
}
async function saveFriend() {
if (!editingFriend.name || !editingFriend.url) {
message.warning('请填写站点名称和 URL')
return
}
addingFriend.value = true
try {
const res = await api.addFriendLink({
name: editingFriend.name,
url: editingFriend.url,
logo: editingFriend.logo,
description: editingFriend.description,
sort_order: friendLinks.value.length,
})
if (res.code === 'success') {
message.success(res.message || '已添加')
showFriendModal.value = false
await loadFriendLinks()
}
} catch (e) {
message.error(e.message || '添加失败')
} finally {
addingFriend.value = false
}
}
function deleteFriend(row) {
dialog.warning({
title: '确认删除',
content: `确定删除友链「${row.name}」?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteFriendLink(row.id)
message.success('已删除')
await loadFriendLinks()
} catch (e) {
message.error(e.message || '删除失败')
}
},
})
}
// ============== 加载 / 保存 ==============
async function loadSettings() {
loading.value = true
try {
const [footerRes] = await Promise.all([
api.getFooterSettings(),
loadFriendLinks(),
])
if (footerRes.code === 'success' && footerRes.data) {
const data = footerRes.data
form.contacts = (data.contacts || []).map((item) => ({ ...item, _uid: ++contactUid }))
form.quick_links = data.quick_links || []
form.more_links = data.more_links || []
form.hot_categories = data.hot_categories ?? 5
form.copyright_text = data.copyright_text || ''
form.layout_template = 'template_1'
// §2.3 三列结构化字段
form.footer_t1_m_s = Array.isArray(data.footer_t1_m_s) ? data.footer_t1_m_s : ['pc', 'mobile']
form.footer_t1_img = data.footer_t1_img || ''
form.footer_t1_img_dark = data.footer_t1_img_dark || ''
form.footer_t1_t = data.footer_t1_t || ''
form.fcode_t1_code = data.fcode_t1_code || ''
form.footer_contact_wechat_img = data.footer_contact_wechat_img || ''
form.footer_contact_qq = data.footer_contact_qq || ''
form.footer_contact_weibo = data.footer_contact_weibo || ''
form.footer_contact_email = data.footer_contact_email || ''
form.footer_contact_m_s = Array.isArray(data.footer_contact_m_s) ? data.footer_contact_m_s : ['pc', 'mobile']
form.footer_contact_more = Array.isArray(data.footer_contact_more) ? data.footer_contact_more : []
form.fcode_t2_code_1 = data.fcode_t2_code_1 || ''
form.fcode_t2_code_2 = data.fcode_t2_code_2 || ''
form.footer_mini_img_m_s = Array.isArray(data.footer_mini_img_m_s) ? data.footer_mini_img_m_s : ['pc', 'mobile']
form.footer_mini_img = Array.isArray(data.footer_mini_img) ? data.footer_mini_img : []
form.fcode_customize_code = data.fcode_customize_code || ''
}
} catch (error) {
console.error('[Footer] load failed:', error)
message.error(error?.message || '加载失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const payload = {
contacts: form.contacts.map(({ _uid, ...rest }) => rest),
quick_links: form.quick_links,
more_links: form.more_links,
hot_categories: form.hot_categories,
copyright_text: form.copyright_text,
layout_template: form.layout_template,
// §2.3 三列结构化字段
footer_t1_m_s: form.footer_t1_m_s,
footer_t1_img: form.footer_t1_img,
footer_t1_img_dark: form.footer_t1_img_dark,
footer_t1_t: form.footer_t1_t,
fcode_t1_code: form.fcode_t1_code,
footer_contact_wechat_img: form.footer_contact_wechat_img,
footer_contact_qq: form.footer_contact_qq,
footer_contact_weibo: form.footer_contact_weibo,
footer_contact_email: form.footer_contact_email,
footer_contact_m_s: form.footer_contact_m_s,
footer_contact_more: form.footer_contact_more,
fcode_t2_code_1: form.fcode_t2_code_1,
fcode_t2_code_2: form.fcode_t2_code_2,
footer_mini_img_m_s: form.footer_mini_img_m_s,
footer_mini_img: form.footer_mini_img,
fcode_customize_code: form.fcode_customize_code,
}
const res = await api.saveFooterSettings(payload)
if (res.code === 'success') {
message.success(res.message || '保存成功')
} else {
message.error(res.message || '保存失败')
}
} catch (error) {
console.error('[Footer] save failed:', error)
message.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
// ============== 媒体选择 ==============
async function pickMedia(field) {
try {
const media = await openMediaPicker({ title: '选择图片' })
if (media?.url) {
form[field] = media.url
}
} catch (e) {
if (e.message) message.warning(e.message)
}
}
// ============== 扩展联系项 ==============
function addContactMore() {
form.footer_contact_more.push({ desc: '', icon: '', link: '', img: '' })
}
function removeContactMore(index) {
form.footer_contact_more.splice(index, 1)
}
const showContactMoreModal = ref(false)
const editingContactMoreIndex = ref(-1)
const editingContactMore = reactive({ desc: '', icon: '', link: '', img: '' })
function editContactMore(row, index) {
Object.assign(editingContactMore, row)
editingContactMoreIndex.value = index
showContactMoreModal.value = true
}
function saveContactMore() {
const item = { ...editingContactMore }
if (editingContactMoreIndex.value >= 0) {
form.footer_contact_more[editingContactMoreIndex.value] = item
}
showContactMoreModal.value = false
}
const contactMoreColumns = [
{ title: '描述', key: 'desc', width: 120 },
{ title: '图标', key: 'icon', width: 100 },
{ title: '链接', key: 'link', ellipsis: { tooltip: true } },
{ title: '图片', key: 'img', width: 60, render: (row) => row.img ? h('img', { src: row.img, style: 'width:32px;height:32px;object-fit:cover;border-radius:4px;' }) : h('span', { style: 'color:#bbb;' }, '—') },
{
title: '操作',
key: 'actions',
width: 130,
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => editContactMore(form.footer_contact_more[index], index) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => removeContactMore(index) }, { default: () => '删除' }),
]),
},
]
// ============== 二维码图片组 ==============
function addMiniImg() {
if (form.footer_mini_img.length < 4) {
form.footer_mini_img.push({ text: '', img: '' })
}
}
function removeMiniImg(idx) {
form.footer_mini_img.splice(idx, 1)
}
async function pickMiniImg(idx) {
try {
const media = await openMediaPicker({ title: '选择二维码图片' })
if (media?.url) {
form.footer_mini_img[idx].img = media.url
}
} catch (e) {
if (e.message) message.warning(e.message)
}
}
onMounted(loadSettings)
</script>
<style scoped>
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.aksrt-media-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.aksrt-media-preview {
display: block;
max-width: 200px;
max-height: 80px;
margin-top: 8px;
border-radius: 6px;
border: 1px solid var(--n-border-color, #e0e0e6);
object-fit: contain;
}
.aksrt-media-preview--sm {
max-width: 64px;
max-height: 64px;
}
.aksrt-mini-img-row {
padding: 10px 0;
border-bottom: 1px solid var(--n-border-color, #e0e0e6);
}
.aksrt-mini-img-row:last-child {
border-bottom: none;
}
</style>
@@ -12,7 +12,7 @@
<template #header> <template #header>
<div class="aksrt-card-header-row"> <div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left"> <div class="aksrt-card-header-row__left">
<span class="header-icon"><i class="fas fa-cog"></i></span> <span class="header-icon"><i class="fa fas fa-cog"></i></span>
<strong class="aksrt-card-header-row__title">基础设置</strong> <strong class="aksrt-card-header-row__title">基础设置</strong>
</div> </div>
</div> </div>
@@ -46,11 +46,11 @@
<template #header> <template #header>
<div class="aksrt-card-header-row"> <div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left"> <div class="aksrt-card-header-row__left">
<span class="header-icon"><i class="fas fa-list-ul"></i></span> <span class="header-icon"><i class="fa fas fa-list-ul"></i></span>
<strong class="aksrt-card-header-row__title">Tab按钮配置 ({{ form.items.length }})</strong> <strong class="aksrt-card-header-row__title">Tab按钮配置 ({{ form.items.length }})</strong>
</div> </div>
<n-button type="primary" size="small" @click="addItem"> <n-button type="primary" size="small" @click="addItem">
<template #icon><i class="fas fa-plus"></i></template> <template #icon><i class="fa fas fa-plus"></i></template>
添加按钮 添加按钮
</n-button> </n-button>
</div> </div>
@@ -72,10 +72,10 @@
<div class="tabbar-sort__item"> <div class="tabbar-sort__item">
<div class="tabbar-sort__main"> <div class="tabbar-sort__main">
<button type="button" class="tabbar-sort__handle" aria-label="拖拽排序"> <button type="button" class="tabbar-sort__handle" aria-label="拖拽排序">
<i class="fas fa-grip-vertical"></i> <i class="fa fas fa-bars"></i>
</button> </button>
<span class="tabbar-sort__icon"> <span class="tabbar-sort__icon">
<i v-if="element.icon" :class="element.icon"></i> <IconValue v-if="element.icon" :value="element.icon" />
</span> </span>
<div class="tabbar-sort__meta"> <div class="tabbar-sort__meta">
<div class="tabbar-sort__title-row"> <div class="tabbar-sort__title-row">
@@ -90,11 +90,11 @@
</div> </div>
<div class="tabbar-sort__actions"> <div class="tabbar-sort__actions">
<n-button size="tiny" quaternary type="primary" @click="editItem(index)"> <n-button size="tiny" quaternary type="primary" @click="editItem(index)">
<template #icon><i class="fas fa-edit" style="font-size:12px;" /></template> <template #icon><i class="fa fas fa-edit" style="font-size:12px;" /></template>
编辑 编辑
</n-button> </n-button>
<n-button size="tiny" quaternary type="error" @click="deleteItem(index)"> <n-button size="tiny" quaternary type="error" @click="deleteItem(index)">
<template #icon><i class="fas fa-trash-alt" style="font-size:12px;" /></template> <template #icon><i class="fa fas fa-trash-alt" style="font-size:12px;" /></template>
删除 删除
</n-button> </n-button>
</div> </div>
@@ -128,16 +128,13 @@
/> />
</n-form-item> </n-form-item>
<n-form-item label="图标 (Font Awesome)"> <n-form-item label="图标 (Font Awesome / 内置 SVG)">
<n-input v-model:value="editingItem.icon" placeholder="例如: fas fa-home" /> <FaIconPicker v-model="editingItem.icon" placeholder="选择图标" />
<template #feedback> <template #feedback>
<div class="icon-preview-row"> <div class="icon-preview-row">
<span>预览:</span> <span>预览:</span>
<i v-if="editingItem.icon" :class="editingItem.icon" style="font-size: 20px;"></i> <IconValue v-if="editingItem.icon" :value="editingItem.icon" style="font-size: 20px;" />
<span v-else class="muted-text">无图标</span> <span v-else class="muted-text">无图标</span>
<a href="https://fontawesome.com/search?m=free" target="_blank" rel="noopener" style="margin-left: 12px;">
<i class="fas fa-external-link-alt"></i> Font Awesome 图标库
</a>
</div> </div>
</template> </template>
</n-form-item> </n-form-item>
@@ -198,7 +195,7 @@
<div class="add-items-editor__head"> <div class="add-items-editor__head">
<strong>发布菜单项</strong> <strong>发布菜单项</strong>
<n-button size="small" type="primary" @click="appendAddItem"> <n-button size="small" type="primary" @click="appendAddItem">
<template #icon><i class="fas fa-plus" /></template> <template #icon><i class="fa fas fa-plus" /></template>
添加子项 添加子项
</n-button> </n-button>
</div> </div>
@@ -227,13 +224,13 @@
<template #item="{ element, index: subIndex }"> <template #item="{ element, index: subIndex }">
<div class="add-items-editor__row"> <div class="add-items-editor__row">
<button type="button" class="add-items-editor__drag" aria-label="拖拽排序"> <button type="button" class="add-items-editor__drag" aria-label="拖拽排序">
<i class="fas fa-grip-vertical"></i> <i class="fa fas fa-bars"></i>
</button> </button>
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" /> <n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
<n-input v-model:value="element.icon" placeholder="图标,例如:fas fa-pen" /> <FaIconPicker v-model="element.icon" placeholder="选择图标" />
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" /> <n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
<n-button quaternary type="error" @click="removeAddItem(subIndex)"> <n-button quaternary type="error" @click="removeAddItem(subIndex)">
<template #icon><i class="fas fa-trash-alt" /></template> <template #icon><i class="fa fas fa-trash-alt" /></template>
</n-button> </n-button>
</div> </div>
</template> </template>
@@ -268,6 +265,8 @@ import { NButton, NTag, useDialog, useMessage } from 'naive-ui'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import api from '@/api' import api from '@/api'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
import FaIconPicker from '@/components/form/FaIconPicker.vue'
import IconValue from '@/components/form/IconValue.vue'
const message = useMessage() const message = useMessage()
const dialog = useDialog() const dialog = useDialog()
@@ -327,15 +326,15 @@ const typeLabelMap = {
} }
const typeDefaults = { const typeDefaults = {
home: { icon: 'fas fa-home', text: '首页' }, home: { icon: 'fa fas fa-home', text: '首页' },
category: { icon: 'fas fa-th-large', text: '分类' }, category: { icon: 'fa fas fa-th-large', text: '分类' },
link: { icon: 'fas fa-link', text: '链接' }, link: { icon: 'fa fas fa-link', text: '链接' },
add: { icon: 'fas fa-plus', text: '发布' }, add: { icon: 'fa fas fa-plus', text: '发布' },
user: { icon: 'fas fa-user', text: '用户中心' }, user: { icon: 'fa fas fa-user', text: '用户中心' },
auth: { icon: 'fas fa-user', text: '我的' }, auth: { icon: 'fa fas fa-user', text: '我的' },
search: { icon: 'fas fa-search', text: '搜索' }, search: { icon: 'fa fas fa-search', text: '搜索' },
msg: { icon: 'fas fa-bell', text: '消息' }, msg: { icon: 'fa fas fa-bell', text: '消息' },
pay_vip: { icon: 'fas fa-crown', text: '会员' }, pay_vip: { icon: 'fa fas fa-gem', text: '会员' },
} }
const enabledPreviewItems = computed(() => form.items.filter((item) => item.enabled)) const enabledPreviewItems = computed(() => form.items.filter((item) => item.enabled))
@@ -361,10 +360,10 @@ function getItemSummary(item) {
} }
const addPresets = [ const addPresets = [
{ label: '写文章', icon: 'fas fa-pen-fancy', url: '/submit' }, { label: '写文章', icon: 'fa fas fa-pen', url: '/submit' },
{ label: '投稿页', icon: 'fas fa-file-alt', url: '/submit' }, { label: '投稿页', icon: 'fa fas fa-file-alt', url: '/submit' },
{ label: '发布资源', icon: 'fas fa-cloud-upload-alt', url: '/resource-new' }, { label: '发布资源', icon: 'fa fas fa-cloud-upload-alt', url: '/resource-new' },
{ label: '创作中心', icon: 'fas fa-palette', url: '/creator' }, { label: '创作中心', icon: 'fa fas fa-paint-brush', url: '/creator' },
] ]
function appendAddPreset(preset) { function appendAddPreset(preset) {
@@ -381,7 +380,7 @@ function appendAddItem() {
editingItem.add_items.push({ editingItem.add_items.push({
id: `add_${Date.now()}_${editingItem.add_items.length}`, id: `add_${Date.now()}_${editingItem.add_items.length}`,
label: '', label: '',
icon: 'fas fa-pen', icon: 'fa fas fa-pen',
url: '', url: '',
}) })
} }
@@ -421,7 +420,7 @@ function addItem() {
Object.assign(editingItem, { Object.assign(editingItem, {
id: `tab_${Date.now()}`, id: `tab_${Date.now()}`,
type: 'link', type: 'link',
icon: 'fas fa-link', icon: 'fa fas fa-link',
text: '链接', text: '链接',
link: '', link: '',
badge: '', badge: '',
@@ -489,7 +488,7 @@ function saveItem() {
? editingItem.add_items.filter((sub) => sub.label && sub.url).map((sub) => ({ ? editingItem.add_items.filter((sub) => sub.label && sub.url).map((sub) => ({
id: sub.id || `add_${Date.now()}`, id: sub.id || `add_${Date.now()}`,
label: sub.label, label: sub.label,
icon: sub.icon || 'fas fa-pen', icon: sub.icon || 'fa fas fa-pen',
url: sub.url, url: sub.url,
})) }))
: [], : [],
@@ -733,7 +732,7 @@ onMounted(loadSettings)
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(24, 160, 88, 0.12); background: rgba(24, 160, 88, 0.12);
color: #18a058; color: var(--aksrt-color-primary);
font-size: 18px; font-size: 18px;
flex: 0 0 auto; flex: 0 0 auto;
} }
@@ -1,518 +0,0 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader title="Hero 轮播管理" desc="管理首页 Hero 幻灯片内容(图片/视频、文案、图层、跳转)。标题、副标题可留空。" />
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header>
<div class="aksrt-card-header-row">
<strong>显示范围</strong>
<n-button size="small" secondary @click="goHeaderComponents">顶部多功能组件(页面类型/搜索/卡片)</n-button>
</div>
</template>
<p class="aksrt-inline-setting__hint" style="margin:0;">
幻灯片内容在此维护。页面类型启用、设备规则、搜索覆盖层与图标卡片请到「顶部多功能组件」配置;全不选页面类型即为关闭。
</p>
</n-card>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<span class="header-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="9" cy="9" r="2"/>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
</svg>
</span>
<strong class="aksrt-card-header-row__title">Hero 列表 ({{ slides.length }})</strong>
</div>
<n-button type="primary" size="small" @click="openCreate">+ 添加 Hero</n-button>
</div>
</template>
<n-data-table
v-if="slides.length"
:columns="columns"
:data="slides"
:row-key="(row) => row.id"
:bordered="false"
size="small"
/>
<n-empty v-else description="暂无 Hero,添加后首页即可使用。" />
</n-card>
</n-spin>
<!-- 编辑/新增 弹窗 -->
<n-modal
v-model:show="modalShow"
:mask-closable="false"
preset="card"
:title="editing.id ? '编辑 Hero' : '添加 Hero'"
class="aksrt-modal-card"
>
<n-form :model="editing" label-placement="top" class="aksrt-form">
<n-form-item label="标题">
<n-input v-model:value="editing.title" placeholder="可选,主标题" />
</n-form-item>
<n-form-item label="副标题">
<n-input v-model:value="editing.subtitle" placeholder="可选,副标题" />
</n-form-item>
<n-form-item label="背景图 URL">
<n-input-group>
<n-input v-model:value="editing.image_url" placeholder="https://..." />
<n-button secondary @click="pickImage">媒体库</n-button>
</n-input-group>
</n-form-item>
<div v-if="editing.image_url" class="aksrt-modal-preview">
<n-image :src="editing.image_url" width="100%" height="160" object-fit="cover" :preview-src="editing.image_url" />
</div>
<n-form-item label="跳转链接">
<n-input v-model:value="editing.link_url" placeholder="点击后跳转地址,可留空" />
</n-form-item>
<n-form-item label="描述文字">
<n-input v-model:value="editing.description" type="textarea" :rows="2" placeholder="可选,显示在副标题下方" />
</n-form-item>
<n-form-item label="视频背景 URL">
<n-input v-model:value="editing.video_url" placeholder="可选,MP4 视频地址替代背景图" />
</n-form-item>
<div class="aksrt-modal-grid">
<n-form-item label="链接打开方式">
<n-radio-group v-model:value="editing.link_target">
<n-radio-button value="_self">当前窗口</n-radio-button>
<n-radio-button value="_blank">新窗口</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="文字对齐">
<n-radio-group v-model:value="editing.text_align">
<n-radio-button value="left">左</n-radio-button>
<n-radio-button value="center">居中</n-radio-button>
<n-radio-button value="right">右</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="设备可见性">
<n-radio-group v-model:value="editing.device_visibility">
<n-radio-button value="all">全部</n-radio-button>
<n-radio-button value="pc">仅电脑</n-radio-button>
<n-radio-button value="mobile">仅移动</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="文字视差">
<n-switch v-model:value="editing.text_parallax" />
</n-form-item>
<n-form-item label="文字大小(PC)">
<n-input v-model:value="editing.text_size_pc" placeholder="如 48px,留空默认" />
</n-form-item>
<n-form-item label="文字大小(移动)">
<n-input v-model:value="editing.text_size_m" placeholder="如 28px,留空默认" />
</n-form-item>
</div>
<div class="aksrt-modal-grid">
<n-form-item label="显示文字">
<n-switch v-model:value="editing.show_text" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="editing.enabled" />
</n-form-item>
<n-form-item label="显示元信息">
<n-switch v-model:value="editing.show_meta" />
</n-form-item>
</div>
<!-- 图片图层 -->
<n-divider title-placement="left" style="margin:16px 0 12px;">
图片图层(视差叠加)
</n-divider>
<div v-for="(layer, idx) in editing.layers" :key="idx" class="aksrt-layer-item">
<div class="aksrt-layer-item__header">
<strong>图层 {{ idx + 1 }}</strong>
<n-button size="tiny" type="error" quaternary @click="removeLayer(idx)">删除</n-button>
</div>
<n-form-item label="图片">
<n-input-group>
<n-input v-model:value="layer.image" placeholder="图片 URL" />
<n-button secondary @click="pickLayerImage(idx)">媒体库</n-button>
</n-input-group>
</n-form-item>
<div class="aksrt-modal-grid">
<n-form-item label="原始尺寸">
<n-switch v-model:value="layer.free_size" />
</n-form-item>
<n-form-item label="对齐方式">
<n-select v-model:value="layer.align" :options="[
{ label: '居中', value: 'center' },
{ label: '左上', value: 'left top' },
{ label: '右上', value: 'right top' },
{ label: '左下', value: 'left bottom' },
{ label: '右下', value: 'right bottom' },
]" />
</n-form-item>
<n-form-item label="视差">
<n-switch v-model:value="layer.parallax" />
</n-form-item>
<n-form-item label="视差强度" v-if="layer.parallax">
<n-slider v-model:value="layer.parallax_scale" :min="0" :max="1" :step="0.05" />
</n-form-item>
<n-form-item label="视差透明度" v-if="layer.parallax">
<n-slider v-model:value="layer.parallax_opacity" :min="0" :max="1" :step="0.05" />
</n-form-item>
</div>
</div>
<n-button size="small" dashed block @click="addLayer" style="margin-top:8px;">+ 添加图层</n-button>
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<n-button @click="modalShow = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup>
import { h, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import { openMediaPicker } from '@/utils/media'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
function goHeaderComponents() {
router.push('/page-display/header-components')
}
const loading = ref(true)
const saving = ref(false)
const slides = ref([])
const modalShow = ref(false)
const editing = ref(emptyForm())
function emptyForm() {
return {
id: 0,
title: '',
subtitle: '',
description: '',
image_url: '',
video_url: '',
link_url: '',
link_target: '_self',
text_align: 'left',
device_visibility: 'all',
enabled: true,
show_text: true,
show_meta: false,
sort_order: 0,
text_size_pc: '',
text_size_m: '',
text_parallax: false,
layers: [],
}
}
const columns = [
{
title: '预览',
key: 'image_url',
width: 90,
render(row) {
if (!row.image_url) {
return h('span', { style: 'color:#bbb;font-size:12px;' }, '无')
}
return h(NImage, {
src: row.image_url,
width: 64,
height: 36,
objectFit: 'cover',
previewSrc: row.image_url,
style: 'border-radius:4px;display:block;',
})
},
},
{
title: '标题',
key: 'title',
ellipsis: { tooltip: true },
render(row) {
return row.title || h('span', { style: 'color:#bbb;' }, '未命名')
},
},
{
title: '文字',
key: 'show_text',
width: 70,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_text ? 'info' : 'default' }, {
default: () => (row.show_text ? '显示' : '隐藏'),
})
},
},
{
title: '元信息',
key: 'show_meta',
width: 78,
className: 'aksrt-table-mobile-hide',
render(row) {
return h(NTag, { size: 'small', type: row.show_meta ? 'info' : 'default' }, {
default: () => (row.show_meta ? '显示' : '隐藏'),
})
},
},
{
title: '跳转',
key: 'link_url',
ellipsis: { tooltip: true },
className: 'aksrt-table-mobile-hide',
render(row) {
return row.link_url || h('span', { style: 'color:#bbb;font-size:12px;' }, '—')
},
},
{
title: '排序',
key: 'sort_order',
width: 64,
className: 'aksrt-table-mobile-hide',
},
{
title: '状态',
key: 'enabled',
width: 84,
render(row) {
return h(NTag, { size: 'small', type: row.enabled ? 'success' : 'default' }, {
default: () => (row.enabled ? '启用' : '禁用'),
})
},
},
{
title: '操作',
key: 'actions',
width: 130,
render(row) {
return h('div', { style: 'display:flex;gap:6px;' }, [
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => handleDelete(row) }, { default: () => '删除' }),
])
},
},
]
async function loadSlides() {
try {
loading.value = true
const res = await api.getHeroes()
if (res.code === 'success') {
slides.value = res.data || []
}
} catch (e) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
editing.value = emptyForm()
modalShow.value = true
}
function openEdit(row) {
editing.value = {
id: row.id,
title: row.title || '',
subtitle: row.subtitle || '',
description: row.description || '',
image_url: row.image_url || '',
video_url: row.video_url || '',
link_url: row.link_url || '',
link_target: row.link_target || '_self',
text_align: row.text_align || 'left',
device_visibility: row.device_visibility || 'all',
show_text: row.show_text !== false,
enabled: !!row.enabled,
show_meta: row.show_meta === true,
sort_order: Number(row.sort_order) || 0,
text_size_pc: row.text_size_pc || '',
text_size_m: row.text_size_m || '',
text_parallax: !!row.text_parallax,
layers: Array.isArray(row.layers) ? row.layers : [],
}
modalShow.value = true
}
async function pickImage() {
try {
const media = await openMediaPicker({
title: '选择 Hero 背景图',
buttonText: '使用这张 Hero 图',
})
if (media?.url) {
editing.value.image_url = media.url
}
} catch (e) {
message.error(e.message || '打开媒体库失败')
}
}
function addLayer() {
editing.value.layers.push({
image: '',
free_size: false,
align: 'center',
parallax: false,
parallax_scale: 0.3,
parallax_opacity: 0.6,
})
}
function removeLayer(idx) {
editing.value.layers.splice(idx, 1)
}
async function pickLayerImage(idx) {
try {
const media = await openMediaPicker({
title: '选择图层图片',
buttonText: '使用这张图',
})
if (media?.url) {
editing.value.layers[idx].image = media.url
}
} catch (e) {
message.error(e.message || '打开媒体库失败')
}
}
async function handleSave() {
if (!editing.value.image_url) {
message.warning('请填写 Hero 背景图')
return
}
saving.value = true
try {
if (editing.value.id) {
await api.updateHero(editing.value.id, editing.value)
message.success('已保存')
} else {
await api.createHero(editing.value)
message.success('已添加')
}
modalShow.value = false
await loadSlides()
} catch (e) {
message.error(e.message || '保存失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '确认删除',
content: `确定删除 Hero「${row.title || '未命名'}」?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteHero(row.id)
message.success('已删除')
await loadSlides()
} catch (e) {
message.error(e.message || '删除失败')
}
},
})
}
onMounted(() => {
loadSlides()
})
</script>
<style scoped>
.aksrt-inline-setting {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.aksrt-inline-setting__hint {
color: #7a7f8a;
font-size: 13px;
}
.aksrt-inline-setting__actions {
display: flex;
justify-content: flex-end;
}
.aksrt-modal-preview {
margin: -8px 0 18px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #eef1f6;
}
.aksrt-modal-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.aksrt-layer-item {
border: 1px solid #eef1f6;
border-radius: 8px;
padding: 14px;
margin-bottom: 10px;
background: #fafbfc;
}
.aksrt-layer-item__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 768px) {
.aksrt-modal-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,363 +0,0 @@
<template>
<div class="aksrt-page-container content-page">
<PageHeader
title="首页配置"
desc="配置首页主列表、分类 Tab 与基础交互。列表/卡片显示请在「文章列表」中设置。"
>
<template #actions>
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
</template>
</PageHeader>
<n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="stream" tab="主列表 & Tab">
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>首页说明</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="内容说明">
<n-alert type="info" :show-icon="false">
首页主列表只使用普通文章。下载/付费是文章能力(meta),不是第二种内容类型。当前为固定骨架模式,不再使用旧版自由模块画布。
</n-alert>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>主列表</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="启用主列表">
<n-switch v-model:value="form.home_posts_list_s" />
<template #feedback><span class="aksrt-hint-text">首页主内容区保留 Zibll 式内容流,不建议关闭。</span></template>
</n-form-item>
<n-form-item label="显示标题">
<n-input v-model:value="form.index_list_title" placeholder="最新发布" />
</n-form-item>
<n-form-item label="主栏目默认排序">
<n-select v-model:value="form.home_list1_orderby_option.orderby" :options="allSortOptions" />
</n-form-item>
<n-form-item label="显示排序方式按钮">
<n-switch v-model:value="form.home_list1_orderby_s" />
</n-form-item>
<n-form-item v-if="form.home_list1_orderby_s" label="排序栏样式">
<n-radio-group v-model:value="form.home_list_sort_dropdown">
<n-space size="large">
<n-radio-button :value="false">内联链接</n-radio-button>
<n-radio-button :value="true">下拉菜单</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item v-if="form.home_list1_orderby_s" label="排序方式选项">
<n-select
v-model:value="form.home_list1_orderby_option.options"
:options="allSortOptions"
multiple
placeholder="选择排序选项"
/>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>排除规则</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="排除文章">
<n-input
v-model:value="excludePostIdsText"
type="textarea"
placeholder="逗号分隔的文章 ID,如 12,34,56"
:rows="2"
/>
<template #feedback><span class="aksrt-hint-text">主列表和首页 Tab 查询均排除这些文章。</span></template>
</n-form-item>
<n-form-item label="排除分类">
<n-input
v-model:value="excludeCategoryIdsText"
type="textarea"
placeholder="逗号分隔的分类 ID,如 5,8"
:rows="2"
/>
<template #feedback><span class="aksrt-hint-text">主列表排除这些分类,首页 Tab 不能绑定已排除分类。</span></template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header>
<div style="display:flex; align-items:center; justify-content:space-between;">
<strong>更多栏目</strong>
<n-button size="small" @click="addTab">+ 添加栏目</n-button>
</div>
</template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form" style="margin-bottom:16px;">
<n-form-item label="Tab 数量上限">
<n-input-number v-model:value="form.homepage_tab_limit" :min="1" :max="12" />
</n-form-item>
<n-form-item label="每 Tab 文章数">
<n-input-number v-model:value="form.homepage_tab_posts_per_page" :min="1" :max="20" />
</n-form-item>
</n-form>
<div v-if="form.home_lists.length === 0" class="homepage-settings__empty-tabs">
<n-text depth="3">暂无更多栏目,点击“添加栏目”创建分类 Tab。</n-text>
</div>
<div v-for="(tab, index) in form.home_lists" :key="index" class="homepage-settings__tab-row">
<div class="homepage-settings__tab-fields">
<n-select
:value="tab.term_id"
:options="categoryOptions"
placeholder="分类"
filterable
style="flex:2;"
@update:value="(val) => updateTab(index, 'term_id', val)"
/>
<n-input
:value="tab.title"
placeholder="自定义标题"
style="flex:1;"
@update:value="(val) => updateTab(index, 'title', val)"
/>
<n-select
:value="tab.orderby"
:options="allSortOptions"
style="flex:1;"
@update:value="(val) => updateTab(index, 'orderby', val)"
/>
<n-switch
:value="tab.enabled"
@update:value="(val) => updateTab(index, 'enabled', val)"
/>
<n-button size="small" quaternary type="error" @click="removeTab(index)">删除</n-button>
</div>
</div>
</n-card>
</n-spin>
</n-tab-pane>
<n-tab-pane name="cards" tab="卡片 & 交互">
<n-spin :show="loading" style="width:100%; min-height:200px;">
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>列表显示</strong></template>
<n-alert type="info" :show-icon="false">
首页文章列表/卡片模式、样式变体与缩略图比例已统一到「文章&amp;列表 → 文章列表」设置(对齐 Zibll 全局 list_type / list_card_home)。此处不再单独维护 homepage_card_style / 默认视图。
</n-alert>
</n-card>
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
<template #header><strong>交互设置</strong></template>
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
<n-form-item label="排序栏 AJAX">
<n-switch v-model:value="form.homepage_sort_ajax_enabled" />
<template #feedback><span class="aksrt-hint-text">关闭后排序链接使用整页跳转。</span></template>
</n-form-item>
<n-form-item label="Tab 吸顶">
<n-switch v-model:value="form.homepage_sticky_tabs_enabled" />
</n-form-item>
<n-form-item label="迷你侧栏列表">
<n-switch v-model:value="form.homepage_sidebar_mini_enabled" />
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<template #header><strong>固定骨架说明</strong></template>
<n-space vertical>
<n-alert type="warning" :show-icon="false">
Hero 与 Banner 已作为固定首页骨架的一部分维护,不再由旧版首页模块编排驱动。
</n-alert>
<n-alert type="info" :show-icon="false">
如需调整首页首屏运营区,请前往对应管理页配置幻灯片内容或顶部组件参数。
</n-alert>
<n-space>
<n-button secondary type="primary" @click="router.push('/page-display/hero')">管理 Hero 轮播</n-button>
<n-button secondary type="primary" @click="router.push('/page-display/banner')">管理 Banner 轮播</n-button>
<n-button secondary @click="router.push('/page-display/header-components')">
顶部多功能组件
</n-button>
</n-space>
</n-space>
</n-card>
</n-spin>
</n-tab-pane>
</n-tabs>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const activeTab = ref('stream')
const categoryOptions = ref([])
const allSortOptions = [
{ label: '最近更新', value: 'modified' },
{ label: '发布时间', value: 'date' },
{ label: '浏览量', value: 'views' },
{ label: '点赞数', value: 'likes' },
{ label: '评论数', value: 'comments' },
{ label: '收藏数', value: 'favorites' },
{ label: '下载量', value: 'downloads' },
{ label: '随机', value: 'rand' },
]
const form = reactive({
home_posts_list_s: true,
home_list1_orderby_s: true,
home_list1_orderby_option: {
orderby: 'modified',
options: ['modified', 'date', 'views', 'likes', 'comments'],
},
index_list_title: '最新发布',
home_exclude_posts: [],
home_exclude_cats: [],
home_lists: [],
homepage_card_style: 'default',
homepage_multi_thumb_enabled: true,
homepage_multi_thumb_min_images: 3,
homepage_sidebar_mini_enabled: true,
homepage_default_view: 'list',
homepage_sort_ajax_enabled: true,
homepage_sticky_tabs_enabled: true,
home_list_sort_dropdown: false,
homepage_tab_posts_per_page: 6,
homepage_tab_limit: 4,
})
const excludePostIdsText = computed({
get: () => (form.home_exclude_posts || []).join(','),
set: (val) => {
form.home_exclude_posts = val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
},
})
const excludeCategoryIdsText = computed({
get: () => (form.home_exclude_cats || []).join(','),
set: (val) => {
form.home_exclude_cats = val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
},
})
function addTab() {
form.home_lists.push({ term_id: 0, title: '', orderby: 'date', enabled: true })
}
function removeTab(index) {
form.home_lists.splice(index, 1)
}
function updateTab(index, field, value) {
form.home_lists[index][field] = value
}
async function loadCategories() {
try {
const res = await api.getHomepageCategories()
const cats = res?.data || []
categoryOptions.value = cats.map(c => ({
label: `${c.name} (${c.count})`,
value: c.term_id || c.id,
}))
} catch {
// non-critical
}
}
async function loadSettings() {
loading.value = true
try {
const res = await api.getHomepageSettings()
const d = res?.data || {}
form.home_posts_list_s = d.home_posts_list_s !== false
form.home_list1_orderby_s = d.home_list1_orderby_s !== false
const orderOpt = d.home_list1_orderby_option && typeof d.home_list1_orderby_option === 'object' ? d.home_list1_orderby_option : {}
form.home_list1_orderby_option = {
orderby: orderOpt.orderby || 'date',
options: Array.isArray(orderOpt.options) && orderOpt.options.length
? orderOpt.options
: ['modified', 'date', 'views', 'likes', 'comments'],
}
form.index_list_title = d.index_list_title || '最新发布'
form.home_exclude_posts = Array.isArray(d.home_exclude_posts) ? d.home_exclude_posts : []
form.home_exclude_cats = Array.isArray(d.home_exclude_cats) ? d.home_exclude_cats : []
form.home_lists = Array.isArray(d.home_lists) ? d.home_lists : []
form.homepage_card_style = d.homepage_card_style || 'default'
form.homepage_multi_thumb_enabled = d.homepage_multi_thumb_enabled !== false
form.homepage_multi_thumb_min_images = d.homepage_multi_thumb_min_images || 3
form.homepage_sidebar_mini_enabled = d.homepage_sidebar_mini_enabled !== false
form.homepage_default_view = d.homepage_default_view || 'list'
form.homepage_sort_ajax_enabled = d.homepage_sort_ajax_enabled !== false
form.homepage_sticky_tabs_enabled = d.homepage_sticky_tabs_enabled !== false
form.home_list_sort_dropdown = !!d.home_list_sort_dropdown
form.homepage_tab_posts_per_page = d.homepage_tab_posts_per_page || 6
form.homepage_tab_limit = d.homepage_tab_limit || 4
} catch (error) {
message.error(error.message || '加载设置失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
const payload = {
...form,
}
const res = await api.saveHomepageSettings(payload)
if (res?.code === 'success') {
message.success('首页设置已保存')
return
}
throw new Error(res?.message || '保存失败')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
loadCategories()
loadSettings()
})
</script>
<style scoped>
.homepage-settings__empty-tabs {
padding: 24px;
text-align: center;
border: 1px dashed var(--n-border-color);
border-radius: 4px;
margin-bottom: 12px;
}
.homepage-settings__tab-row {
margin-bottom: 8px;
}
.homepage-settings__tab-fields {
display: flex;
gap: 8px;
align-items: center;
}
.aksrt-form-actions {
margin-top: 16px;
text-align: right;
}
</style>
@@ -21,12 +21,6 @@
<n-form-item label="资源下载就绪通知"> <n-form-item label="资源下载就绪通知">
<n-switch v-model:value="settings.enabled_types.download_ready" /> <n-switch v-model:value="settings.enabled_types.download_ready" />
</n-form-item> </n-form-item>
<n-form-item label="积分增加通知">
<n-switch v-model:value="settings.enabled_types.credits_added" />
</n-form-item>
<n-form-item label="积分扣减通知">
<n-switch v-model:value="settings.enabled_types.credits_deducted" />
</n-form-item>
<n-form-item label="邮箱待验证提醒"> <n-form-item label="邮箱待验证提醒">
<n-switch v-model:value="settings.enabled_types.verify_email" /> <n-switch v-model:value="settings.enabled_types.verify_email" />
</n-form-item> </n-form-item>
@@ -90,8 +84,6 @@ const settings = reactive({
comment_reply: true, comment_reply: true,
comment_approved: true, comment_approved: true,
download_ready: true, download_ready: true,
credits_added: true,
credits_deducted: true,
verify_email: true, verify_email: true,
pending_comments: true, pending_comments: true,
upload_moderation: true, upload_moderation: true,
+20 -20
View File
@@ -7,17 +7,17 @@
<n-grid :cols="2" :x-gap="12" :y-gap="12"> <n-grid :cols="2" :x-gap="12" :y-gap="12">
<n-grid-item> <n-grid-item>
<div class="aksrt-docs__action"> <div class="aksrt-docs__action">
<FaIcon icon="fas fa-palette" :size="16" /> <FaIcon icon="fa fas fa-paint-brush" :size="16" />
<div> <div>
<strong>全局设置</strong> <strong>全局设置</strong>
<p>Logo、背景、菜单装饰、自定义代码</p> <p>Logo、导航栏、自定义代码</p>
<router-link to="/global/common">前往配置 →</router-link> <router-link to="/global/common">前往配置 →</router-link>
</div> </div>
</div> </div>
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<div class="aksrt-docs__action"> <div class="aksrt-docs__action">
<FaIcon icon="fas fa-home" :size="16" /> <FaIcon icon="fa fas fa-home" :size="16" />
<div> <div>
<strong>首页配置</strong> <strong>首页配置</strong>
<p>首页模块、布局、排序和预设模板</p> <p>首页模块、布局、排序和预设模板</p>
@@ -27,7 +27,7 @@
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<div class="aksrt-docs__action"> <div class="aksrt-docs__action">
<FaIcon icon="fas fa-users" :size="16" /> <FaIcon icon="fa fas fa-users" :size="16" />
<div> <div>
<strong>用户管理</strong> <strong>用户管理</strong>
<p>登录注册、OAuth、签到、积分</p> <p>登录注册、OAuth、签到、积分</p>
@@ -37,10 +37,10 @@
</n-grid-item> </n-grid-item>
<n-grid-item> <n-grid-item>
<div class="aksrt-docs__action"> <div class="aksrt-docs__action">
<FaIcon icon="fas fa-tools" :size="16" /> <FaIcon icon="fa fas fa-wrench" :size="16" />
<div> <div>
<strong>系统工具</strong> <strong>工具</strong>
<p>导入导出、健康检查、迁移状态</p> <p>导入导出、健康检查、备份与更新</p>
<router-link to="/tools/import-export">前往配置 →</router-link> <router-link to="/tools/import-export">前往配置 →</router-link>
</div> </div>
</div> </div>
@@ -66,7 +66,7 @@
<h4 class="aksrt-docs__card-title">{{ doc.title }}</h4> <h4 class="aksrt-docs__card-title">{{ doc.title }}</h4>
<p class="aksrt-docs__card-desc">{{ doc.description }}</p> <p class="aksrt-docs__card-desc">{{ doc.description }}</p>
</div> </div>
<FaIcon icon="fas fa-external-link-alt" :size="12" class="aksrt-docs__card-arrow" /> <FaIcon icon="fa fas fa-external-link-alt" :size="12" class="aksrt-docs__card-arrow" />
</div> </div>
</n-card> </n-card>
</div> </div>
@@ -95,12 +95,12 @@ const themeVersion = ref(window.aksrtAdminData?.themeVersion || '3.4.0-dev')
const dbSchemaVersion = ref('—') const dbSchemaVersion = ref('—')
const docs = ref([ const docs = ref([
{ key: 'admin-ia-map', title: '后台 IA 蓝图', description: '完整的后台信息架构映射,12 个顶级分区及子页面清单。', icon: 'fas fa-sitemap', color: '#1890ff', file: 'admin-ia-map.md' }, { key: 'admin-ia-map', title: '后台 IA 蓝图', description: '完整的后台信息架构映射,12 个顶级分区及子页面清单。', icon: 'fa fas fa-sitemap', color: 'var(--aksrt-color-primary)', file: 'admin-ia-map.md' },
{ key: 'zibll-gap-matrix', title: '对标差异矩阵', description: 'AKSRT 当前 → Zibll 参考 → AKSRT 目标三层对照表。', icon: 'fas fa-columns', color: '#722ed1', file: 'zibll-gap-matrix.md' }, { key: 'legacy-gap-matrix', title: '对标差异矩阵', description: 'AKSRT 当前 → 原主题参考 → AKSRT 目标三层对照表。', icon: 'fa fas fa-columns', color: '#722ed1', file: 'legacy-gap-matrix.md' },
{ key: 'frontend-page-matrix', title: '前端页面矩阵', description: '前端模板、CSS、JS 资产映射与覆盖范围。', icon: 'fas fa-desktop', color: '#13c2c2', file: 'frontend-page-matrix.md' }, { key: 'frontend-page-matrix', title: '前端页面矩阵', description: '前端模板、CSS、JS 资产映射与覆盖范围。', icon: 'fa fas fa-desktop', color: '#13c2c2', file: 'frontend-page-matrix.md' },
{ key: 'manual-acceptance-baseline', title: '验收基线', description: '每个 Phase 的人工验收标准和检查清单。', icon: 'fas fa-clipboard-check', color: '#52c41a', file: 'manual-acceptance-baseline.md' }, { key: 'manual-acceptance-baseline', title: '验收基线', description: '每个 Phase 的人工验收标准和检查清单。', icon: 'fa fas fa-clipboard', color: '#52c41a', file: 'manual-acceptance-baseline.md' },
{ key: 'feature-domain-map', title: '功能域地图', description: '业务域划分、依赖关系和模块边界说明。', icon: 'fas fa-map', color: '#fa8c16', file: 'feature-domain-map.md' }, { key: 'feature-domain-map', title: '功能域地图', description: '业务域划分、依赖关系和模块边界说明。', icon: 'fa fas fa-map', color: '#fa8c16', file: 'feature-domain-map.md' },
{ key: 'migration-map', title: '迁移地图', description: '设置 key 迁移、旧选项兼容和 Schema 版本对照。', icon: 'fas fa-exchange-alt', color: '#eb2f96', file: 'migration-map.md' }, { key: 'migration-map', title: '迁移地图', description: '设置 key 迁移、旧选项兼容和 Schema 版本对照。', icon: 'fa fas fa-exchange-alt', color: '#eb2f96', file: 'migration-map.md' },
]) ])
onMounted(async () => { onMounted(async () => {
@@ -164,13 +164,13 @@ function openDoc(doc) {
margin: 0 0 2px; margin: 0 0 2px;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #1a1a2e; color: var(--aksrt-color-text);
} }
.aksrt-docs__card-desc { .aksrt-docs__card-desc {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;
color: #8a909c; color: var(--aksrt-color-muted);
line-height: 1.5; line-height: 1.5;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -196,26 +196,26 @@ function openDoc(doc) {
} }
.aksrt-docs__action i { .aksrt-docs__action i {
color: #1890ff; color: var(--aksrt-color-primary);
margin-top: 2px; margin-top: 2px;
} }
.aksrt-docs__action strong { .aksrt-docs__action strong {
display: block; display: block;
font-size: 14px; font-size: 14px;
color: #1a1a2e; color: var(--aksrt-color-text);
margin-bottom: 2px; margin-bottom: 2px;
} }
.aksrt-docs__action p { .aksrt-docs__action p {
margin: 0 0 4px; margin: 0 0 4px;
font-size: 12px; font-size: 12px;
color: #8a909c; color: var(--aksrt-color-muted);
} }
.aksrt-docs__action a { .aksrt-docs__action a {
font-size: 12px; font-size: 12px;
color: #1890ff; color: var(--aksrt-color-primary);
text-decoration: none; text-decoration: none;
} }
@@ -1,316 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="AI 连接配置" desc="配置 AI 生成能力的连接方式。优先使用 WordPress AI Connectors;也可填写 OpenAI 兼容接口。SEO 生成入口在文章「独立 SEO」框,摘要/标签入口在前台投稿页。" />
<div v-if="loadError" class="aksrt-ai__form-card aksrt-ai__load-error" role="alert">
<strong>加载失败:</strong>{{ loadError }}
</div>
<div class="aksrt-ai__status">
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--off'">
<i class="fas fa-robot"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">可用状态</div>
<div class="aksrt-ai__status-value">{{ status.available ? '可用' : '不可用' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.wp_ai_available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
<i class="fas fa-plug"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">WP AI Client</div>
<div class="aksrt-ai__status-value">{{ status.wp_ai_available ? '已就绪' : '不可用' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.theme_available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
<i class="fas fa-key"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">主题 Provider</div>
<div class="aksrt-ai__status-value">{{ status.theme_available ? '已配置' : '未配置' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon aksrt-ai__status-icon--info">
<i class="fas fa-route"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">当前后端</div>
<div class="aksrt-ai__status-value">{{ backendLabel }}</div>
</div>
</div>
</div>
<div class="aksrt-ai__form-card">
<h3>主题 Provider 配置(回退链路)</h3>
<p class="aksrt-form-hint">已配置主题 Provider 时优先走下方接口;未配置时才尝试 WordPress AI Connectors。NewAPI/兼容网关请填到 <code>/v1</code>(也可只填域名,保存时会自动补全)。</p>
<div class="aksrt-form-group">
<label>
<input type="checkbox" v-model="form.enabled"> 启用主题 AI Provider
</label>
</div>
<div class="aksrt-form-group">
<label>AI Provider</label>
<select v-model="form.provider" class="aksrt-select">
<option value="none">不使用</option>
<option value="openai">OpenAI 兼容接口</option>
</select>
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>API Key</label>
<input v-model="form.api_key" type="password" class="aksrt-input" placeholder="sk-...">
<p class="aksrt-form-hint">保存后不会回显完整密钥</p>
</div>
<div class="aksrt-form-group" v-if="form.provider === 'openai'">
<label>API Base URL</label>
<input v-model="form.api_base" type="text" class="aksrt-input" placeholder="https://llm.example.com/v1">
<p class="aksrt-form-hint">示例:https://api.openai.com/v1 或 https://你的网关/v1(不要填到 /chat/completions)</p>
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>模型</label>
<input v-model="form.model" type="text" class="aksrt-input" placeholder="gpt-3.5-turbo">
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>最大 Token 数</label>
<input v-model.number="form.max_tokens" type="number" min="100" max="4000" class="aksrt-input">
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>每分钟请求限制</label>
<input v-model.number="form.rate_limit" type="number" min="1" max="100" class="aksrt-input">
</div>
<div class="aksrt-form-actions">
<button class="aksrt-button aksrt-button--primary" :disabled="saving" @click="saveSettings">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
<div class="aksrt-ai__form-card">
<h3>已接通能力</h3>
<table class="aksrt-table aksrt-table--sm">
<thead>
<tr>
<th>能力</th>
<th>入口</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="feat in features" :key="feat.key">
<td>{{ feat.name }}</td>
<td>{{ feat.scene }}</td>
<td>
<span class="aksrt-badge" :class="feat.ready ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
{{ feat.ready ? '可用' : '未就绪' }}
</span>
</td>
</tr>
</tbody>
</table>
<p class="aksrt-form-hint">SEO 开关与提示词请到「全局 → SEO」配置(seo_ai_enabled / ai_seo_*_prompt)。</p>
</div>
<div class="aksrt-ai__form-card aksrt-ai__notice">
<h3><i class="fas fa-exclamation-triangle"></i> 重要说明</h3>
<ul>
<li>AI 输出仅作为<strong>建议</strong>,不会自动写库;需用户点「更新/提交」才保存</li>
<li>主题 API Key 已配置时优先使用;否则回退 WordPress AI Connectors</li>
<li>生成接口要求登录与编辑/投稿权限;配置接口仍需管理员</li>
</ul>
</div>
</div>
</template>
<script>
import { ref, reactive, computed, onMounted } from 'vue';
import PageHeader from '@/components/PageHeader.vue';
import api from '@/api';
export default {
name: 'ExtensionAI',
components: { PageHeader },
setup() {
const status = ref({
enabled: false,
provider: 'none',
configured: false,
model: '',
api_key: '',
rate_limit: 10,
available: false,
backend: 'none',
wp_ai_available: false,
theme_available: false,
seo_ai_enabled: false,
write_assist: false,
});
const form = reactive({
enabled: false,
provider: 'none',
api_key: '',
api_base: 'https://api.openai.com/v1',
model: 'gpt-3.5-turbo',
max_tokens: 1000,
rate_limit: 10,
});
const saving = ref(false);
const loadError = ref('');
const backendLabel = computed(() => {
const map = { wp_ai: 'WP AI Client', theme: '主题 Provider', none: '无' };
return map[status.value.backend] || status.value.backend || '无';
});
const features = computed(() => {
const avail = !!status.value.available;
const seoReady = avail && !!status.value.seo_ai_enabled;
return [
{ key: 'seo', name: 'AI 生成 SEO', scene: '文章/分类「独立 SEO」框', ready: seoReady },
{ key: 'summary', name: 'AI 生成摘要', scene: '前台投稿页', ready: avail },
{ key: 'tags', name: 'AI 建议标签', scene: '前台投稿页', ready: avail },
];
});
function hydrateFromStatus(data) {
if (!data || typeof data !== 'object') return;
status.value = {
enabled: !!data.enabled,
provider: data.provider || 'none',
configured: !!data.configured,
model: data.model || '',
api_key: '',
rate_limit: data.rate_limit || 10,
available: !!data.available,
backend: data.backend || 'none',
wp_ai_available: !!data.wp_ai_available,
theme_available: !!data.theme_available,
seo_ai_enabled: !!data.seo_ai_enabled,
write_assist: !!data.write_assist,
};
form.enabled = !!data.enabled;
form.provider = data.provider || 'none';
form.model = data.model || 'gpt-3.5-turbo';
form.rate_limit = data.rate_limit || 10;
if (data.api_base) form.api_base = data.api_base;
if (typeof data.max_tokens === 'number' && data.max_tokens > 0) {
form.max_tokens = data.max_tokens;
}
}
async function loadStatus() {
loadError.value = '';
try {
const json = await api.getAiStatus();
hydrateFromStatus(json.data || {});
} catch (e) {
console.error('Load AI status failed:', e);
loadError.value = e?.message || '加载 AI 状态失败';
}
}
async function saveSettings() {
saving.value = true;
try {
const payload = {
enabled: form.enabled,
provider: form.provider,
api_base: form.api_base,
model: form.model,
max_tokens: form.max_tokens,
rate_limit: form.rate_limit,
};
// Only send api_key when the user entered a new one.
if (form.api_key && form.api_key !== '***') {
payload.api_key = form.api_key;
}
const json = await api.updateAiSettings(payload);
alert(json.message || '设置已保存');
await loadStatus();
} catch (e) {
console.error('Save failed:', e);
alert(e?.message || '保存失败');
} finally {
saving.value = false;
}
}
onMounted(() => loadStatus());
return { status, form, saving, loadError, features, backendLabel, saveSettings };
},
};
</script>
<style scoped>
.aksrt-ai__status {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-ai__status-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.aksrt-ai__status-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.aksrt-ai__status-icon--ok { background: #d4edda; color: #155724; }
.aksrt-ai__status-icon--warn { background: #fff3cd; color: #856404; }
.aksrt-ai__status-icon--off { background: #f8d7da; color: #721c24; }
.aksrt-ai__status-icon--info { background: #d1ecf1; color: #0c5460; }
.aksrt-ai__status-value { font-size: 18px; font-weight: 600; color: #1a1a2e; }
.aksrt-ai__status-label { font-size: 12px; color: #888; }
.aksrt-ai__form-card {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.aksrt-ai__form-card h3 { margin: 0 0 16px; font-size: 16px; font-weight: 600; }
.aksrt-form-hint { font-size: 12px; color: #888; margin-top: 4px; }
.aksrt-ai__load-error {
background: #fdecea;
border-color: #f5c2c0;
color: #842029;
margin-bottom: 16px;
}
.aksrt-ai__notice { background: #fff8e1; border-color: #ffe082; }
.aksrt-ai__notice h3 { color: #e65100; }
.aksrt-ai__notice ul { margin: 0; padding-left: 20px; }
.aksrt-ai__notice li { margin-bottom: 8px; font-size: 14px; }
.aksrt-ai__notice code {
background: rgba(0,0,0,0.06);
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
}
.aksrt-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; }
.aksrt-badge--success { background: #d4edda; color: #155724; }
.aksrt-badge--warning { background: #fff3cd; color: #856404; }
.aksrt-table { width: 100%; border-collapse: collapse; }
.aksrt-table th, .aksrt-table td { border-bottom: 1px solid #eee; padding: 10px 8px; text-align: left; }
.aksrt-form-group { margin-bottom: 14px; }
.aksrt-input, .aksrt-select { width: 100%; max-width: 480px; padding: 8px 10px; }
.aksrt-button { padding: 8px 16px; border: 0; border-radius: 6px; cursor: pointer; }
.aksrt-button--primary { background: #1677ff; color: #fff; }
.aksrt-button:disabled { opacity: .6; cursor: not-allowed; }
</style>
@@ -1,270 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="系统工具" desc="额外运维工具与扩展能力。" />
<!-- Health Overview -->
<n-card title="系统健康" size="small" class="aksrt-tools__section">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in healthItems" :key="item.key">
<div class="aksrt-tools__health-card" :class="'aksrt-tools__health-card--' + item.status">
<FaIcon :icon="item.icon" :size="18" :color="item.color" />
<div>
<strong>{{ item.value }}</strong>
<p>{{ item.label }}</p>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Tool Categories -->
<n-card title="工具集合" size="small" class="aksrt-tools__section">
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="tool in tools" :key="tool.key">
<div class="aksrt-tools__tool-card" @click="navigateTo(tool.route)">
<div class="aksrt-tools__tool-icon" :style="{ background: tool.bg }">
<FaIcon :icon="tool.icon" :size="18" color="#fff" />
</div>
<div class="aksrt-tools__tool-body">
<strong>{{ tool.title }}</strong>
<p>{{ tool.desc }}</p>
</div>
<FaIcon icon="fas fa-chevron-right" :size="12" color="#c0c4cc" />
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Service Status -->
<n-card title="服务状态" size="small" class="aksrt-tools__section">
<n-list bordered size="small">
<n-list-item v-for="svc in services" :key="svc.key">
<n-thing :title="svc.name" :description="svc.description">
<template #avatar>
<FaIcon :icon="svc.icon" :size="16" :color="svc.color" />
</template>
<template #header-extra>
<n-tag :type="svc.available ? 'success' : 'warning'" size="small">
{{ svc.available ? '可用' : '未配置' }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-card>
<!-- Quick Maintenance -->
<n-card title="手动维护" size="small" class="aksrt-tools__section">
<n-space>
<n-button @click="handleFlushCache">
<template #icon><FaIcon icon="fas fa-broom" :size="12" /></template>
清除缓存
</n-button>
<n-button @click="handleOptimizeDb">
<template #icon><FaIcon icon="fas fa-database" :size="12" /></template>
优化数据表
</n-button>
<n-button @click="handleRebuildIndex">
<template #icon><FaIcon icon="fas fa-sync-alt" :size="12" /></template>
重建索引
</n-button>
</n-space>
</n-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const router = useRouter()
const message = useMessage()
const healthItems = ref([
{ key: 'php', icon: 'fas fa-code', color: '#1890ff', value: '—', label: 'PHP 版本', status: 'ok' },
{ key: 'wp', icon: 'fab fa-wordpress', color: '#722ed1', value: '—', label: 'WordPress', status: 'ok' },
{ key: 'db', icon: 'fas fa-database', color: '#52c41a', value: '—', label: '数据库', status: 'ok' },
{ key: 'uploads', icon: 'fas fa-folder-open', color: '#fa8c16', value: '—', label: '上传目录', status: 'ok' },
])
const tools = ref([
{ key: 'import-export', icon: 'fas fa-exchange-alt', bg: '#1890ff', title: '导入导出', desc: '设置导入、导出和重置', route: '/tools/import-export' },
{ key: 'health', icon: 'fas fa-heartbeat', bg: '#52c41a', title: '健康检查', desc: '系统状态检测和诊断', route: '/tools/health' },
{ key: 'email-rollback', icon: 'fas fa-undo', bg: '#fa8c16', title: '邮箱回滚', desc: '邮箱变更 14 天内回滚', route: '/tools/email-rollback' },
])
const services = ref([
{ key: 'sms', icon: 'fas fa-sms', color: '#1890ff', name: '短信服务', description: '验证码和通知短信', available: false },
{ key: 'wechat', icon: 'fab fa-weixin', color: '#52c41a', name: '微信通知', description: '微信模板消息和订阅通知', available: false },
{ key: 'captcha', icon: 'fas fa-shield-alt', color: '#722ed1', name: '验证码服务', description: '人机验证(极验/腾讯/阿里云)', available: false },
{ key: 'push', icon: 'fas fa-paper-plane', color: '#fa8c16', name: '搜索引擎推送', description: '百度/Google 主动推送', available: false },
{ key: 'audit', icon: 'fas fa-shield-virus', color: '#f5222d', name: '内容审核', description: '第三方内容安全审核 API', available: false },
])
onMounted(async () => {
try {
const res = await api.request('/tools/health')
if (res?.data) {
const d = res.data
if (d.php_version) healthItems.value[0].value = d.php_version
if (d.wp_version) healthItems.value[1].value = d.wp_version
if (d.db_status) {
healthItems.value[2].value = d.db_status
healthItems.value[2].status = d.db_ok ? 'ok' : 'warn'
}
if (d.upload_dir) {
healthItems.value[3].value = d.upload_dir
healthItems.value[3].status = d.upload_ok ? 'ok' : 'warn'
}
}
} catch {
// Endpoint may not exist yet
}
// Check service availability from structured status
try {
const settingsRes = await api.request('/settings/extensions')
if (settingsRes?.data) {
const s = settingsRes.data
// Prefer structured services if available
const svc = settingsRes.services || {}
services.value[0].available = !!(svc.sms?.available ?? s.sms_enabled)
services.value[0].status = svc.sms?.provider || (s.sms_enabled ? '已启用' : '未配置')
services.value[1].available = !!(svc.wechat?.available ?? s.wechat_enabled)
services.value[1].status = svc.wechat?.provider || (s.wechat_enabled ? '已启用' : '未配置')
services.value[2].available = !!(svc.captcha?.available ?? svc.captcha?.configured)
services.value[2].status = svc.captcha?.provider || (svc.captcha?.enabled ? '已启用' : '未配置')
services.value[3].available = !!(svc.push?.available ?? s.baidu_push_enabled)
services.value[3].status = svc.push?.provider || (s.baidu_push_enabled ? '已启用' : '未配置')
services.value[4].available = !!(svc.audit?.available ?? s.content_audit_enabled)
services.value[4].status = svc.audit?.provider || (s.content_audit_enabled ? '已启用' : '未配置')
}
} catch {
// Use defaults
}
})
function navigateTo(route) {
router.push(route)
}
async function handleFlushCache() {
message.info('正在清除缓存...')
try {
const res = await api.request('/tools/maintenance', {
method: 'POST',
body: JSON.stringify({ action: 'flush_cache' }),
})
message.success(res?.message || '缓存已清除')
} catch (err) {
message.error(err?.message || '清除缓存失败')
}
}
async function handleOptimizeDb() {
message.info('正在优化数据表...')
try {
const res = await api.request('/tools/maintenance', {
method: 'POST',
body: JSON.stringify({ action: 'optimize_db' }),
})
message.success(res?.message || '数据表已优化')
} catch (err) {
message.error(err?.message || '优化数据表失败')
}
}
async function handleRebuildIndex() {
message.info('正在重建索引...')
try {
const res = await api.request('/tools/maintenance', {
method: 'POST',
body: JSON.stringify({ action: 'rebuild_index' }),
})
message.success(res?.message || '索引重建完成')
} catch (err) {
message.error(err?.message || '重建索引失败')
}
}
</script>
<style scoped>
.aksrt-tools__section {
margin-bottom: 16px;
}
.aksrt-tools__health-card {
display: flex;
align-items: center;
gap: 10px;
padding: 14px;
border-radius: 8px;
background: #fafafa;
border: 1px solid #f0f0f0;
}
.aksrt-tools__health-card strong {
display: block;
font-size: 14px;
color: #1a1a2e;
}
.aksrt-tools__health-card p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
.aksrt-tools__health-card--warn {
border-color: #faad14;
background: #fffbe6;
}
.aksrt-tools__tool-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-radius: 8px;
background: #fafafa;
border: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.2s;
}
.aksrt-tools__tool-card:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
}
.aksrt-tools__tool-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.aksrt-tools__tool-body {
flex: 1;
min-width: 0;
}
.aksrt-tools__tool-body strong {
display: block;
font-size: 14px;
color: #1a1a2e;
}
.aksrt-tools__tool-body p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
</style>
@@ -1,530 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="充值卡管理" desc="按状态和时间筛选充值卡,支持单张创建、批量生成、详情查看和当前筛选导出。" />
<div class="aksrt-charge-card-page__stats">
<n-card v-for="card in statCards" :key="card.key" size="small">
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
</n-card>
</div>
<n-card size="small" title="操作台">
<n-space justify="space-between" wrap>
<n-space>
<n-button type="primary" @click="createVisible = true">创建充值卡</n-button>
<n-button @click="batchVisible = true">批量生成</n-button>
</n-space>
<n-button quaternary @click="handleExport">导出当前筛选</n-button>
</n-space>
</n-card>
<n-card class="aksrt-charge-card-page__card" size="small" title="充值卡台账">
<n-space vertical :size="12">
<n-grid cols="1 s:2 m:4" :x-gap="12" :y-gap="12" responsive="screen">
<n-grid-item>
<n-input v-model:value="filters.keyword" clearable placeholder="搜索卡号、备注、使用人" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
</n-grid-item>
<n-grid-item>
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
</n-grid-item>
<n-grid-item>
<n-space>
<n-button type="primary" @click="handleSearch">筛选</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-grid-item>
</n-grid>
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.id"
/>
</n-space>
</n-card>
<n-modal v-model:show="createVisible" preset="card" title="创建充值卡" style="max-width: 520px;">
<n-form :model="createForm" label-placement="top">
<n-form-item label="卡类型">
<n-select v-model:value="createForm.type" :options="typeOptions" />
</n-form-item>
<n-form-item label="卡密">
<n-input v-model:value="createForm.code" placeholder="手动填写唯一卡密" />
</n-form-item>
<n-form-item v-if="createForm.type === 'balance'" label="面额">
<n-input-number v-model:value="createForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
</n-form-item>
<n-grid v-if="createForm.type === 'vip'" cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="VIP 等级">
<n-select v-model:value="createForm.level" :options="levelOptions" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="天数(0=永久)">
<n-input-number v-model:value="createForm.days" :min="0" :max="3650" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item v-if="createForm.type === 'credits'" label="积分">
<n-input-number v-model:value="createForm.points" :min="1" style="width: 100%;" />
</n-form-item>
<n-form-item label="过期时间">
<n-date-picker v-model:value="createForm.expiresAt" type="datetime" clearable style="width: 100%;" />
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="createForm.note" type="textarea" :rows="3" />
</n-form-item>
<n-space justify="end">
<n-button @click="createVisible = false">取消</n-button>
<n-button type="primary" :loading="submittingCreate" @click="handleCreate">创建</n-button>
</n-space>
</n-form>
</n-modal>
<n-modal v-model:show="batchVisible" preset="card" title="批量生成充值卡" style="max-width: 520px;">
<n-form :model="batchForm" label-placement="top">
<n-form-item label="卡类型">
<n-select v-model:value="batchForm.type" :options="typeOptions" />
</n-form-item>
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="数量">
<n-input-number v-model:value="batchForm.count" :min="1" :max="100" style="width: 100%;" />
</n-form-item>
</n-grid-item>
<n-grid-item v-if="batchForm.type === 'balance'">
<n-form-item label="面额">
<n-input-number v-model:value="batchForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
</n-form-item>
</n-grid-item>
<n-grid-item v-if="batchForm.type === 'credits'">
<n-form-item label="积分">
<n-input-number v-model:value="batchForm.points" :min="1" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-grid v-if="batchForm.type === 'vip'" cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="VIP 等级">
<n-select v-model:value="batchForm.level" :options="levelOptions" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="天数(0=永久)">
<n-input-number v-model:value="batchForm.days" :min="0" :max="3650" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-grid cols="2" :x-gap="12">
<n-grid-item>
<n-form-item label="前缀">
<n-input v-model:value="batchForm.prefix" placeholder="AK" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="随机长度">
<n-input-number v-model:value="batchForm.length" :min="8" :max="32" style="width: 100%;" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="过期时间">
<n-date-picker v-model:value="batchForm.expiresAt" type="datetime" clearable style="width: 100%;" />
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="batchForm.note" type="textarea" :rows="3" />
</n-form-item>
<n-space justify="end">
<n-button @click="batchVisible = false">取消</n-button>
<n-button type="primary" :loading="submittingBatch" @click="handleBatchCreate">批量生成</n-button>
</n-space>
</n-form>
</n-modal>
<ChargeCardDetailDrawer v-model:show="drawerVisible" :card-id="currentCardId" />
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import ChargeCardDetailDrawer from './components/ChargeCardDetailDrawer.vue'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const rows = ref([])
const createVisible = ref(false)
const batchVisible = ref(false)
const drawerVisible = ref(false)
const currentCardId = ref(null)
const submittingCreate = ref(false)
const submittingBatch = ref(false)
const stats = reactive({
total: 0,
active: 0,
used: 0,
total_amount: 0,
used_amount: 0,
})
const filters = reactive({
keyword: '',
status: '',
dateRange: null,
})
const createForm = reactive({
type: 'balance',
code: '',
amount: 10,
level: 1,
days: 30,
points: 100,
expiresAt: null,
note: '',
})
const batchForm = reactive({
type: 'balance',
count: 20,
amount: 10,
level: 1,
days: 30,
points: 100,
prefix: 'AK',
length: 12,
expiresAt: null,
note: '',
})
const typeOptions = [
{ label: '余额卡', value: 'balance' },
{ label: '会员卡', value: 'vip' },
{ label: '积分卡', value: 'credits' },
]
const levelOptions = [
{ label: 'VIP1', value: 1 },
{ label: 'VIP2', value: 2 },
]
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
})
const statCards = [
{ key: 'total', label: '总卡数' },
{ key: 'active', label: '未使用' },
{ key: 'used', label: '已使用' },
{ key: 'total_amount', label: '总面额' },
{ key: 'used_amount', label: '已兑换金额' },
]
const statusOptions = computed(() => optionsState.statuses.length
? optionsState.statuses
: [
{ label: '全部状态', value: '' },
{ label: '未使用', value: 'active' },
{ label: '已使用', value: 'used' },
{ label: '已过期', value: 'expired' },
{ label: '已禁用', value: 'disabled' },
])
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: pager.itemCount,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
onUpdatePage: (page) => {
pager.page = page
loadData()
},
onUpdatePageSize: (pageSize) => {
pager.pageSize = pageSize
pager.page = 1
loadData()
},
}))
const columns = [
{ title: 'ID', key: 'id', width: 70 },
{ title: '卡密', key: 'code', minWidth: 170, ellipsis: { tooltip: true } },
{
title: '类型',
key: 'type_label',
width: 90,
render: (row) => row.type_label || row.type || '-',
},
{
title: '面额/权益',
key: 'amount',
width: 120,
render: (row) => {
if (row.type === 'vip') {
const days = Number(row.days || 0)
return `VIP${row.level || 1} / ${days > 0 ? `${days}天` : '永久'}`
}
if (row.type === 'credits') return `${row.points || row.amount || 0} 积分`
return row.amount
},
},
{
title: '状态',
key: 'status_label',
width: 100,
render: (row) => {
const typeMap = { active: 'success', used: 'warning', expired: 'default', disabled: 'error' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
},
},
{
title: '使用人',
key: 'used_by_user',
width: 160,
render: (row) => row.used_by_user?.display_name || '-',
},
{ title: '创建时间', key: 'created_at', width: 170 },
{
title: '过期时间',
key: 'expires_at',
width: 170,
render: (row) => row.expires_at || '-',
},
{
title: '备注',
key: 'note',
minWidth: 160,
ellipsis: { tooltip: true },
render: (row) => row.note || '-',
},
{
title: '操作',
key: 'actions',
width: 140,
render: (row) => h(NSpace, { size: 'small' }, () => {
const actions = [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openDrawer(row.id),
}, () => '详情'),
]
if (row.status === 'active') {
actions.push(h(NButton, {
size: 'tiny',
type: 'warning',
quaternary: true,
onClick: () => handleDisable(row),
}, () => '禁用'))
}
return actions
}),
},
]
onMounted(() => {
loadData()
})
function formatDateValue(value) {
const date = new Date(value)
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hours = `${date.getHours()}`.padStart(2, '0')
const minutes = `${date.getMinutes()}`.padStart(2, '0')
const seconds = `${date.getSeconds()}`.padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function buildParams(page = pager.page, pageSize = pager.pageSize) {
const params = {
page,
per_page: pageSize,
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status) params.status = filters.status
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
const [start, end] = filters.dateRange
params.date_from = formatDateValue(start).slice(0, 10)
params.date_to = formatDateValue(end).slice(0, 10)
}
return params
}
async function loadData() {
loading.value = true
try {
const res = await api.request('/payment/charge-cards', { params: buildParams() })
const data = res?.data || {}
rows.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(stats, data.stats || {})
optionsState.statuses = data.options?.statuses || []
} catch (error) {
message.error(error?.message || '加载充值卡台账失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pager.page = 1
loadData()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.dateRange = null
pager.page = 1
loadData()
}
async function handleCreate() {
submittingCreate.value = true
try {
await api.request('/payment/charge-cards', {
method: 'POST',
data: {
type: createForm.type,
code: createForm.code,
password: createForm.code,
amount: createForm.amount,
level: createForm.level,
days: createForm.days,
points: createForm.points,
expires_at: createForm.expiresAt ? formatDateValue(createForm.expiresAt) : '',
note: createForm.note,
},
})
message.success('充值卡已创建')
createVisible.value = false
createForm.code = ''
createForm.note = ''
await loadData()
} catch (error) {
message.error(error?.message || '创建失败')
} finally {
submittingCreate.value = false
}
}
async function handleBatchCreate() {
submittingBatch.value = true
try {
await api.request('/payment/charge-cards/batch', {
method: 'POST',
data: {
type: batchForm.type,
count: batchForm.count,
amount: batchForm.amount,
level: batchForm.level,
days: batchForm.days,
points: batchForm.points,
prefix: batchForm.prefix,
length: batchForm.length,
expires_at: batchForm.expiresAt ? formatDateValue(batchForm.expiresAt) : '',
note: batchForm.note,
},
})
message.success('充值卡已批量生成')
batchVisible.value = false
await loadData()
} catch (error) {
message.error(error?.message || '批量生成失败')
} finally {
submittingBatch.value = false
}
}
function handleDisable(row) {
dialog.warning({
title: '确认禁用',
content: `确定要禁用充值卡 ${row.code} 吗?`,
positiveText: '禁用',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.request(`/payment/charge-cards/${row.id}/disable`, { method: 'POST' })
message.success('充值卡已禁用')
await loadData()
} catch (error) {
message.error(error?.message || '禁用失败')
}
},
})
}
function openDrawer(id) {
currentCardId.value = id
drawerVisible.value = true
}
async function handleExport() {
try {
const res = await api.request('/payment/charge-cards', { params: buildParams(1, 200) })
const items = res?.data?.items || []
const header = ['ID', '卡号', '面额', '余额', '状态', '使用人', '创建时间', '过期时间', '备注']
const lines = items.map((row) => [
row.id,
row.code,
row.amount,
row.balance,
row.status_label,
row.used_by_user?.display_name || '',
row.created_at || '',
row.expires_at || '',
row.note || '',
])
downloadCsv('charge-cards.csv', [header, ...lines])
message.success('导出完成')
} catch (error) {
message.error(error?.message || '导出失败')
}
}
function downloadCsv(filename, rows) {
const csv = `\uFEFF${rows.map((row) => row.map((cell) => `"${String(cell ?? '').replaceAll('"', '""')}"`).join(',')).join('\n')}`
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.aksrt-charge-card-page__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-charge-card-page__card {
margin-top: 16px;
}
</style>
@@ -1,131 +0,0 @@
<template>
<PaymentSectionPage
section="gateway"
title="收款接口"
desc="管理余额、支付宝、微信、PayPal、Stripe、易支付与卡密兑换通道。"
note="以渠道开关与凭据是否齐全为准;异步回调由各网关验签。卡密为兑换入口,不进入收银台。"
:links="[
{ label: '余额充值', path: '/payment/recharge' },
{ label: '充值卡', path: '/payment/card-pass' },
]"
>
<template #before-form>
<n-spin :show="loading">
<n-card class="aksrt-section-card" title="网关状态">
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="gw in gateways" :key="gw.id">
<div class="aksrt-gateway__card">
<div class="aksrt-gateway__icon" :class="gw.configured ? 'aksrt-gateway__icon--active' : ''">
<i :class="iconFor(gw.id)"></i>
</div>
<div>
<strong>{{ gw.name }}</strong>
<p>{{ gw.configured ? '已配置' : '未配置' }}</p>
</div>
<n-tag :type="gw.configured ? 'success' : 'default'" size="small" round>
{{ gw.configured ? '可用' : '不可用' }}
</n-tag>
</div>
</n-grid-item>
</n-grid>
<n-empty v-if="!loading && gateways.length === 0" description="暂无已注册的支付网关" />
</n-card>
<n-card v-if="overview.help_text" class="aksrt-section-card" title="运行说明">
<p class="aksrt-gateway__help">{{ overview.help_text }}</p>
</n-card>
</n-spin>
</template>
</PaymentSectionPage>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useMessage } from 'naive-ui'
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
import api from '@/api'
const message = useMessage()
const gateways = ref([])
const overview = ref({})
const loading = ref(false)
function iconFor(id) {
if (id === 'balance') return 'fas fa-wallet'
if (id === 'alipay' || id === 'epay_alipay') return 'fab fa-alipay'
if (id === 'wechatpay' || id === 'epay_wxpay') return 'fab fa-weixin'
if (id === 'paypal') return 'fab fa-paypal'
if (id === 'stripe') return 'fab fa-stripe-s'
return 'fas fa-credit-card'
}
onMounted(async () => {
loading.value = true
try {
const [gwRes, ovRes] = await Promise.all([
api.request('/payment/overview').catch(() => null),
])
if (gwRes?.data?.gateway?.gateways) {
const gwList = Object.entries(gwRes.data.gateway.gateways).map(([id, enabled]) => ({ id, name: id, configured: !!enabled }))
gateways.value = gwList
}
if (ovRes?.data?.gateway) {
overview.value = ovRes.data.gateway
} else if (ovRes?.data) {
overview.value = ovRes.data
}
} catch {
message.warning('部分支付接口暂不可用')
} finally {
loading.value = false
}
})
</script>
<style scoped>
.aksrt-gateway__card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border-radius: 8px;
background: var(--surface-2, #fafafa);
border: 1px solid var(--color-line, #f0f0f0);
}
.aksrt-gateway__icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-3, #f0f0f0);
color: var(--color-ink-4, #8a909c);
font-size: 16px;
}
.aksrt-gateway__icon--active {
background: var(--color-primary-soft, #f0f5ff);
color: var(--color-primary, #1890ff);
}
.aksrt-gateway__card strong {
display: block;
font-size: 14px;
color: var(--color-ink, #1a1a2e);
}
.aksrt-gateway__card p {
margin: 2px 0 0;
font-size: 12px;
color: var(--color-ink-4, #8a909c);
}
.aksrt-gateway__help {
margin: 0;
font-size: 13px;
color: var(--color-ink-3, #5c6370);
line-height: 1.6;
}
</style>
@@ -1,315 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="返佣管理" desc="按状态、类型和时间筛选返佣记录,自定义列展示并在详情中查看处理历史。" />
<div class="aksrt-rebate-page__stats">
<n-card v-for="card in statCards" :key="card.key" size="small">
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
</n-card>
</div>
<n-card size="small" title="筛选与列控制">
<n-space vertical :size="12">
<n-grid cols="1 s:2 m:5" :x-gap="12" :y-gap="12" responsive="screen">
<n-grid-item>
<n-input v-model:value="filters.keyword" clearable placeholder="搜索订单号、用户、备注" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
</n-grid-item>
<n-grid-item>
<n-select v-model:value="filters.type" :options="typeOptions" placeholder="类型" />
</n-grid-item>
<n-grid-item>
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
</n-grid-item>
<n-grid-item>
<n-space>
<n-button type="primary" @click="handleSearch">筛选</n-button>
<n-button @click="resetFilters">重置</n-button>
</n-space>
</n-grid-item>
</n-grid>
<n-checkbox-group v-model:value="visibleColumnKeys">
<n-space wrap>
<n-checkbox v-for="column in columnChoices" :key="column.value" :value="column.value">
{{ column.label }}
</n-checkbox>
</n-space>
</n-checkbox-group>
</n-space>
</n-card>
<n-card class="aksrt-rebate-page__card" size="small" title="返佣台账">
<n-data-table
:columns="tableColumns"
:data="rows"
:loading="loading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.id"
/>
</n-card>
<RebateDetailDrawer
v-model:show="drawerVisible"
:rebate-id="currentRebateId"
@updated="loadData"
/>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import RebateDetailDrawer from './components/RebateDetailDrawer.vue'
const message = useMessage()
const loading = ref(false)
const rows = ref([])
const drawerVisible = ref(false)
const currentRebateId = ref(null)
const stats = reactive({
total: 0,
pending: 0,
settled: 0,
total_amount: 0,
settled_amount: 0,
})
const filters = reactive({
keyword: '',
status: '',
type: '',
dateRange: null,
})
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
types: [],
})
const statCards = [
{ key: 'total', label: '总记录' },
{ key: 'pending', label: '待结算' },
{ key: 'settled', label: '已结算' },
{ key: 'total_amount', label: '总返佣' },
{ key: 'settled_amount', label: '已结算金额' },
]
const statusOptions = computed(() => optionsState.statuses.length
? optionsState.statuses
: [
{ label: '全部状态', value: '' },
{ label: '待结算', value: 'pending' },
{ label: '已结算', value: 'settled' },
{ label: '已取消', value: 'cancelled' },
])
const typeOptions = computed(() => optionsState.types.length
? optionsState.types
: [
{ label: '全部类型', value: '' },
{ label: '订单返佣', value: 'order' },
])
const columnChoices = [
{ label: '来源用户', value: 'source_user' },
{ label: '创建时间', value: 'created_at' },
{ label: '结算时间', value: 'settled_at' },
{ label: '备注', value: 'note' },
{ label: '处理记录数', value: 'history_count' },
]
const visibleColumnKeys = ref(['source_user', 'created_at', 'settled_at', 'history_count'])
const baseColumns = computed(() => {
const columns = [
{ title: 'ID', key: 'id', width: 70 },
{ title: '订单号', key: 'order_number', minWidth: 180, ellipsis: { tooltip: true } },
{
title: '返佣对象',
key: 'user',
width: 160,
render: (row) => row.user?.display_name || '-',
},
{
title: '返佣金额',
key: 'amount',
width: 100,
},
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const typeMap = { pending: 'warning', settled: 'success', cancelled: 'default' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
},
},
{
title: '类型',
key: 'type',
width: 100,
render: (row) => row.type_label || row.type,
},
]
if (visibleColumnKeys.value.includes('source_user')) {
columns.push({
title: '来源用户',
key: 'source_user',
width: 160,
render: (row) => row.source_user?.display_name || '-',
})
}
if (visibleColumnKeys.value.includes('created_at')) {
columns.push({ title: '创建时间', key: 'created_at', width: 170 })
}
if (visibleColumnKeys.value.includes('settled_at')) {
columns.push({
title: '结算时间',
key: 'settled_at',
width: 170,
render: (row) => row.settled_at || '-',
})
}
if (visibleColumnKeys.value.includes('note')) {
columns.push({
title: '备注',
key: 'note',
minWidth: 160,
ellipsis: { tooltip: true },
render: (row) => row.note || '-',
})
}
if (visibleColumnKeys.value.includes('history_count')) {
columns.push({ title: '处理记录数', key: 'history_count', width: 100 })
}
columns.push({
title: '操作',
key: 'actions',
width: 120,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openDrawer(row.id),
}, () => '详情'),
]),
})
return columns
})
const tableColumns = computed(() => baseColumns.value)
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: pager.itemCount,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
onUpdatePage: (page) => {
pager.page = page
loadData()
},
onUpdatePageSize: (pageSize) => {
pager.pageSize = pageSize
pager.page = 1
loadData()
},
}))
onMounted(() => {
loadData()
})
function formatDateValue(value) {
const date = new Date(value)
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
function buildParams() {
const params = {
page: pager.page,
per_page: pager.pageSize,
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status) params.status = filters.status
if (filters.type) params.type = filters.type
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
params.date_from = formatDateValue(filters.dateRange[0])
params.date_to = formatDateValue(filters.dateRange[1])
}
return params
}
async function loadData() {
loading.value = true
try {
const res = await api.request('/payment/rebate/log', { params: buildParams() })
const data = res?.data || {}
rows.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(stats, data.stats || {})
optionsState.statuses = data.options?.statuses || []
optionsState.types = data.options?.types || []
} catch (error) {
message.error(error?.message || '加载返佣台账失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pager.page = 1
loadData()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.type = ''
filters.dateRange = null
pager.page = 1
loadData()
}
function openDrawer(id) {
currentRebateId.value = id
drawerVisible.value = true
}
</script>
<style scoped>
.aksrt-rebate-page__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-rebate-page__card {
margin-top: 16px;
}
</style>
@@ -1,291 +0,0 @@
<template>
<PaymentSectionPage
section="revenue"
title="创作中心"
desc="查看现金与积分收益统计及结算运营入口。"
:links="[
{ label: 'VIP 会员', path: '/payment/vip' },
{ label: '推广返佣', path: '/payment/affiliate' },
{ label: '创作分成运营', path: '/payment/income' },
]"
>
<template #before-form>
<n-card class="aksrt-section-card" title="收益统计">
<n-grid :cols="4" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in revenueSummary" :key="item.key">
<div class="aksrt-revenue__stat">
<strong>{{ item.value }}</strong>
<p>{{ item.label }}</p>
</div>
</n-grid-item>
</n-grid>
</n-card>
<n-card class="aksrt-section-card" title="收益趋势">
<template #header-extra>
<n-space size="small">
<n-button :type="granularity === 'day' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('day')">日</n-button>
<n-button :type="granularity === 'week' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('week')">周</n-button>
<n-button :type="granularity === 'month' ? 'primary' : 'default'" size="tiny" quaternary @click="setGranularity('month')">月</n-button>
</n-space>
</template>
<div v-if="hasTimeSeries" class="aksrt-revenue__charts">
<div v-for="group in timeSeriesGroups" :key="group.key" class="aksrt-revenue__chart">
<h4>{{ group.label }}</h4>
<div v-if="group.items.length > 0" class="aksrt-revenue__chart-bars">
<div
v-for="(item, index) in group.items"
:key="`${group.key}-${index}`"
class="aksrt-revenue__chart-bar-wrap"
:title="`${item.date}: ${formatAmount(item.amount, group.key)} (${item.count}笔)`"
>
<div
class="aksrt-revenue__chart-bar"
:style="{ height: getBarHeight(item.amount, group.items) + '%' }"
/>
<span class="aksrt-revenue__chart-label">{{ formatLabel(item.date) }}</span>
</div>
</div>
<n-empty v-else size="small" description="暂无数据" />
<div class="aksrt-revenue__chart-summary">
<span>期间总计: <strong>{{ formatAmount(group.total, group.key) }}</strong></span>
<span>共 {{ group.count }} 笔</span>
</div>
</div>
</div>
<n-empty v-else description="暂无趋势数据" />
</n-card>
<n-card class="aksrt-section-card" title="提现统计">
<n-grid :cols="6" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.total }}</strong>
<p>提现笔数</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.pending }}</strong>
<p>待处理</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.processing }}</strong>
<p>处理中</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.completed }}</strong>
<p>已完成</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.rejected }}</strong>
<p>已拒绝</p>
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-revenue__stat">
<strong>{{ withdrawalStats.total_paid.toFixed(2) }}</strong>
<p>已提现 (元)</p>
</div>
</n-grid-item>
</n-grid>
</n-card>
</template>
</PaymentSectionPage>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
import api from '@/api'
const emptyCurrencyStats = () => ({ total: 0, pending: 0, settled: 0, count: 0 })
const revenueStats = ref({ cash: emptyCurrencyStats(), points: emptyCurrencyStats() })
const withdrawalStats = ref({ total: 0, pending: 0, processing: 0, completed: 0, rejected: 0, total_paid: 0 })
const timeSeries = ref({ cash: [], points: [] })
const granularity = ref('day')
const revenueSummary = computed(() => [
{ key: 'cash-total', value: `${revenueStats.value.cash.total.toFixed(2)} 元`, label: '现金总收益' },
{ key: 'cash-pending', value: `${revenueStats.value.cash.pending.toFixed(2)} 元`, label: '现金待结算' },
{ key: 'cash-settled', value: `${revenueStats.value.cash.settled.toFixed(2)} 元`, label: '现金已结算' },
{ key: 'cash-count', value: revenueStats.value.cash.count, label: '现金收益笔数' },
{ key: 'points-total', value: `${formatAmount(revenueStats.value.points.total, 'points')}`, label: '积分总收益' },
{ key: 'points-pending', value: `${formatAmount(revenueStats.value.points.pending, 'points')}`, label: '积分待结算' },
{ key: 'points-settled', value: `${formatAmount(revenueStats.value.points.settled, 'points')}`, label: '积分已结算' },
{ key: 'points-count', value: revenueStats.value.points.count, label: '积分收益笔数' },
])
const timeSeriesGroups = computed(() => [
{
key: 'cash',
label: '现金收益',
items: Array.isArray(timeSeries.value.cash) ? timeSeries.value.cash : [],
},
{
key: 'points',
label: '积分收益',
items: Array.isArray(timeSeries.value.points) ? timeSeries.value.points : [],
},
].map((group) => ({
...group,
total: group.items.reduce((sum, item) => sum + Number(item.amount || 0), 0),
count: group.items.reduce((sum, item) => sum + Number(item.count || 0), 0),
})))
const hasTimeSeries = computed(() => timeSeriesGroups.value.some((group) => group.items.length > 0))
function formatAmount(amount, currency) {
return currency === 'points' ? `${Number(amount || 0).toFixed(0)} 积分` : `¥${Number(amount || 0).toFixed(2)}`
}
function getBarHeight(amount, items) {
const maxAmount = Math.max(1, ...items.map((item) => Number(item.amount || 0)))
return Math.max(2, (Number(amount || 0) / maxAmount) * 100)
}
function formatLabel(date) {
return String(date || '').slice(5)
}
function setGranularity(nextGranularity) {
granularity.value = nextGranularity
loadRevenue()
}
async function loadRevenue() {
try {
const params = new URLSearchParams({
time_series: '1',
granularity: granularity.value,
})
const res = await api.request(`/payment/revenue?${params}`)
if (res?.data) {
if (res.data.revenue) {
revenueStats.value = {
cash: { ...emptyCurrencyStats(), ...(res.data.revenue.cash || {}) },
points: { ...emptyCurrencyStats(), ...(res.data.revenue.points || {}) },
}
}
if (res.data.withdrawal) {
withdrawalStats.value = {
total: Number(res.data.withdrawal.total || 0),
pending: Number(res.data.withdrawal.pending || 0),
processing: Number(res.data.withdrawal.processing || 0),
completed: Number(res.data.withdrawal.completed || 0),
rejected: Number(res.data.withdrawal.rejected || 0),
total_paid: Number(res.data.withdrawal.total_paid || 0),
}
}
timeSeries.value = {
cash: res.data.time_series?.cash || [],
points: res.data.time_series?.points || [],
}
}
} catch {
// silent
}
}
onMounted(loadRevenue)
</script>
<style scoped>
.aksrt-revenue__stat {
text-align: center;
padding: 16px;
border-radius: 8px;
background: var(--surface-2, #fafafa);
}
.aksrt-revenue__stat strong {
display: block;
font-size: 24px;
font-weight: 600;
color: var(--color-ink, #1a1a2e);
}
.aksrt-revenue__stat p {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-ink-4, #8a909c);
}
.aksrt-revenue__charts {
display: grid;
gap: 24px;
}
.aksrt-revenue__chart {
min-height: 200px;
}
.aksrt-revenue__chart h4 {
margin: 0 0 10px;
color: var(--color-ink, #1a1a2e);
}
.aksrt-revenue__chart-bars {
display: flex;
align-items: flex-end;
gap: 2px;
height: 180px;
padding: 0 4px;
border-bottom: 1px solid var(--color-line, #e5e7eb);
}
.aksrt-revenue__chart-bar-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
min-width: 0;
cursor: default;
}
.aksrt-revenue__chart-bar {
width: 100%;
max-width: 32px;
min-height: 2px;
background: var(--color-primary, #88abda);
border-radius: 3px 3px 0 0;
transition: height 0.3s ease;
}
.aksrt-revenue__chart-bar-wrap:hover .aksrt-revenue__chart-bar {
background: var(--color-primary-hover, #7099cc);
}
.aksrt-revenue__chart-label {
font-size: 10px;
color: var(--color-ink-4, #8a909c);
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-align: center;
}
.aksrt-revenue__chart-summary {
display: flex;
justify-content: space-between;
margin-top: 12px;
font-size: 12px;
color: var(--color-ink-3, #525866);
}
.aksrt-revenue__chart-summary strong {
color: var(--color-ink, #1a1a2e);
font-weight: 600;
}
</style>
@@ -1,103 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader :title="title" :desc="desc">
<template #actions>
<n-space v-if="links.length">
<n-button v-for="link in links" :key="link.path" quaternary @click="go(link.path)">
{{ link.label }}
</n-button>
</n-space>
</template>
</PageHeader>
<n-card v-if="note" class="aksrt-section-card">
<n-alert type="info" :show-icon="false">
{{ note }}
</n-alert>
</n-card>
<slot name="before-form" />
<AksFormRenderer
:schema="pageMeta.groups"
:form-data="formData"
:dirty="dirty"
:loading="loading"
:saving="saving"
:load-error="loadError"
:show-search="false"
@update:form-data="formData = $event"
@save="handleSave"
@reload="handleReload"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
import { useSectionForm } from '@/composables/useSectionForm'
import { getSchema } from '@/schema'
const props = defineProps({
section: { type: String, required: true },
title: { type: String, required: true },
desc: { type: String, default: '' },
note: { type: String, default: '' },
links: { type: Array, default: () => [] },
})
const router = useRouter()
const message = useMessage()
const pageMeta = computed(() => getSchema('payment', props.section) || { groups: [] })
const defaults = computed(() => {
const result = {}
;(pageMeta.value.groups || []).forEach((group) => {
;(group.fields || []).forEach((field) => {
if (field.default !== undefined) {
result[field.key] = field.default
}
})
})
return result
})
const {
formData,
loading,
saving,
loadError,
dirty,
save: doSave,
reload,
} = useSectionForm({
fetch: () => api.getSchemaPage('payment', props.section),
submit: (data) => api.saveSchemaPage('payment', props.section, data),
defaults: defaults.value,
onSaved: () => {
message.success('设置已保存')
},
})
function go(path) {
router.push(path)
}
async function handleSave() {
try {
await doSave()
} catch (error) {
message.error(error.message || '保存失败')
}
}
function handleReload() {
reload()
message.info('已放弃更改')
}
</script>
@@ -1,89 +0,0 @@
<template>
<n-drawer :show="show" :width="680" @update:show="$emit('update:show', $event)">
<n-drawer-content title="充值卡详情" closable>
<n-spin :show="loading">
<template v-if="detail.card">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="卡号">{{ detail.card.code || '-' }}</n-descriptions-item>
<n-descriptions-item label="面额">{{ detail.card.amount || 0 }}</n-descriptions-item>
<n-descriptions-item label="余额">{{ detail.card.balance || 0 }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ detail.card.status_label || detail.card.status }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ detail.card.created_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="过期时间">{{ detail.card.expires_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="使用时间">{{ detail.card.used_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="使用者">
{{ detail.card.used_by_user?.display_name || '-' }}
</n-descriptions-item>
</n-descriptions>
<div class="aksrt-charge-drawer__section">
<div class="aksrt-charge-drawer__title">备注</div>
<n-alert type="info" :show-icon="false">
{{ detail.card.note || '暂无备注' }}
</n-alert>
</div>
</template>
<n-empty v-else description="暂无充值卡详情" />
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
const props = defineProps({
show: { type: Boolean, default: false },
cardId: { type: [Number, String], default: null },
})
defineEmits(['update:show'])
const message = useMessage()
const loading = ref(false)
const detail = ref({ card: null })
watch(
() => [props.show, props.cardId],
async ([visible, id]) => {
if (visible && id) {
await loadDetail()
}
if (!visible) {
detail.value = { card: null }
}
},
)
async function loadDetail() {
if (!props.cardId) {
return
}
loading.value = true
try {
const res = await api.request(`/payment/charge-cards/${props.cardId}`)
detail.value = res?.data || { card: null }
} catch (error) {
message.error(error?.message || '加载充值卡详情失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.aksrt-charge-drawer__section {
margin-top: 20px;
}
.aksrt-charge-drawer__title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
</style>
@@ -1,175 +0,0 @@
<template>
<n-drawer :show="show" :width="720" @update:show="$emit('update:show', $event)">
<n-drawer-content title="返佣详情" closable>
<n-spin :show="loading">
<template v-if="detail.record">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="订单号">{{ detail.record.order_number || '-' }}</n-descriptions-item>
<n-descriptions-item label="返佣对象">{{ detail.record.user?.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="来源用户">{{ detail.record.source_user?.display_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="返佣金额">{{ detail.record.amount || 0 }}</n-descriptions-item>
<n-descriptions-item label="类型">{{ detail.record.type_label || detail.record.type }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ detail.record.status_label || detail.record.status }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ detail.record.created_at || '-' }}</n-descriptions-item>
<n-descriptions-item label="结算时间">{{ detail.record.settled_at || '-' }}</n-descriptions-item>
</n-descriptions>
<div class="aksrt-rebate-drawer__section">
<div class="aksrt-rebate-drawer__title">备注</div>
<n-alert type="info" :show-icon="false">
{{ detail.record.note || '暂无备注' }}
</n-alert>
</div>
<div class="aksrt-rebate-drawer__section">
<div class="aksrt-rebate-drawer__title">处理记录</div>
<div v-if="detail.history?.length" class="aksrt-rebate-drawer__timeline">
<div v-for="(entry, index) in detail.history" :key="index" class="aksrt-rebate-drawer__timeline-item">
<div class="aksrt-rebate-drawer__timeline-head">
<strong>{{ entry.event || '-' }}</strong>
<span>{{ entry.time || '-' }}</span>
</div>
<p>{{ entry.note || '无备注' }}</p>
<small>{{ entry.operator_name || '系统' }}</small>
</div>
</div>
<n-empty v-else description="暂无处理记录" />
</div>
<div v-if="detail.record.status === 'pending'" class="aksrt-rebate-drawer__section">
<div class="aksrt-rebate-drawer__title">处理返佣</div>
<n-space vertical :size="12">
<n-input
v-model:value="form.note"
type="textarea"
:rows="3"
placeholder="记录结算说明或取消原因"
/>
<n-space>
<n-button type="primary" :loading="submitting === 'settle'" @click="handleAction('settle')">
结算返佣
</n-button>
<n-button type="warning" secondary :loading="submitting === 'cancel'" @click="handleAction('cancel')">
取消返佣
</n-button>
</n-space>
</n-space>
</div>
</template>
<n-empty v-else description="暂无返佣详情" />
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
const props = defineProps({
show: { type: Boolean, default: false },
rebateId: { type: [Number, String], default: null },
})
const emit = defineEmits(['update:show', 'updated'])
const message = useMessage()
const loading = ref(false)
const submitting = ref('')
const detail = ref({ record: null, history: [] })
const form = reactive({ note: '' })
watch(
() => [props.show, props.rebateId],
async ([visible, id]) => {
if (visible && id) {
await loadDetail()
}
if (!visible) {
detail.value = { record: null, history: [] }
form.note = ''
}
},
)
async function loadDetail() {
if (!props.rebateId) {
return
}
loading.value = true
try {
const res = await api.request(`/payment/rebate/${props.rebateId}`)
detail.value = res?.data || { record: null, history: [] }
form.note = ''
} catch (error) {
message.error(error?.message || '加载返佣详情失败')
} finally {
loading.value = false
}
}
async function handleAction(action) {
if (!props.rebateId) {
return
}
submitting.value = action
try {
await api.request(`/payment/rebate/${props.rebateId}/${action}`, {
method: 'POST',
data: { note: form.note },
})
message.success(action === 'settle' ? '返佣已结算' : '返佣已取消')
await loadDetail()
emit('updated')
} catch (error) {
message.error(error?.message || '处理失败')
} finally {
submitting.value = ''
}
}
</script>
<style scoped>
.aksrt-rebate-drawer__section {
margin-top: 20px;
}
.aksrt-rebate-drawer__title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.aksrt-rebate-drawer__timeline {
display: grid;
gap: 12px;
}
.aksrt-rebate-drawer__timeline-item {
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #f8fafc;
}
.aksrt-rebate-drawer__timeline-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.aksrt-rebate-drawer__timeline-item p {
margin: 0 0 6px;
color: #111827;
white-space: pre-wrap;
}
.aksrt-rebate-drawer__timeline-item small {
color: #6b7280;
}
</style>
@@ -1,174 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="附件治理"
desc="管理各类上传文件的大小、安全限制和审核策略;准入权限请在“基本权限”中配置。"
>
<template #actions>
<n-space>
<n-button quaternary @click="goModeration">审核策略</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<!-- Summary -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>上传策略总览</h3>
</div>
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in summaryItems" :key="item.key">
<div class="aksrt-attach__summary">
<FaIcon :icon="item.icon" :size="16" :color="item.enabled ? '#52c41a' : '#8a909c'" />
<div>
<strong>{{ item.label }}</strong>
<p>{{ item.enabled ? '已启用' : '已关闭' }}</p>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Avatar & Cover -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>用户资料卡背景图</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="头像/用户资料卡背景图大小上限 (MB)">
<n-input-number v-model:value="form.avatar_max_size" :min="1" :max="10" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<!-- Content uploads -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>内容上传</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="投稿图片大小上限 (MB)">
<n-input-number v-model:value="form.submit_image_max_size" :min="1" :max="20" style="max-width:160px;" />
</n-form-item>
<n-form-item label="评论图片大小上限 (MB)">
<n-input-number v-model:value="form.comment_image_max_size" :min="1" :max="10" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<!-- Resource attachments -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>资源附件</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="资源附件大小上限 (MB)">
<n-input-number v-model:value="form.resource_max_size" :min="1" :max="200" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const form = reactive({
avatar_max_size: 2,
submit_image_max_size: 5,
comment_image_max_size: 2,
resource_max_size: 50,
})
const summaryItems = computed(() => [
{ key: 'profile', icon: 'fas fa-id-card', label: '资料卡图片', enabled: true },
{ key: 'submit', icon: 'fas fa-edit', label: '投稿图片', enabled: true },
{ key: 'comment', icon: 'fas fa-comment-dots', label: '评论图片', enabled: true },
{ key: 'resource', icon: 'fas fa-file-archive', label: '资源附件', enabled: true },
])
function go(path) {
router.push(path)
}
function goModeration() { router.push('/permissions/moderation') }
async function load() {
loading.value = true
try {
const permissionRes = await api.getPermissionsAttachmentSettings()
const permission = permissionRes?.data ?? permissionRes ?? {}
// Map permission fields
form.avatar_max_size = Number(permission.avatar_max_size || 2)
form.submit_image_max_size = Number(permission.submit_image_max_size || 5)
form.comment_image_max_size = Number(permission.comment_image_max_size || 2)
form.resource_max_size = Number(permission.resource_max_size || 50)
} catch (error) {
message.error(error.message || '加载附件权限失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.savePermissionsAttachmentSettings({
avatar_max_size: form.avatar_max_size,
submit_image_max_size: form.submit_image_max_size,
comment_image_max_size: form.comment_image_max_size,
resource_max_size: form.resource_max_size,
})
message.success('附件治理配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.aksrt-attach__summary {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 6px;
background: #fafafa;
}
.aksrt-attach__summary strong {
display: block;
font-size: 13px;
color: #1a1a2e;
}
.aksrt-attach__summary p {
margin: 2px 0 0;
font-size: 12px;
color: #8a909c;
}
</style>
@@ -224,7 +224,7 @@ async function handleSave() {
.aksrt-cap-matrix__desc { .aksrt-cap-matrix__desc {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 12px; font-size: 12px;
color: #8a909c; color: var(--aksrt-color-muted);
} }
.aksrt-cap-matrix__cap { .aksrt-cap-matrix__cap {
@@ -2,7 +2,7 @@
<div class="aks-native-ops-redirect"> <div class="aks-native-ops-redirect">
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
<p class="desc"> <p class="desc">
此运营功能已迁移到 WordPress 原生后台侧栏(对齐 zibll)。正在跳转… 此运营功能已迁移到 WordPress 原生后台侧栏(对齐 legacy)。正在跳转…
</p> </p>
<p v-if="nativeUrl"> <p v-if="nativeUrl">
<a class="button button-primary" :href="nativeUrl">立即打开原生页面</a> <a class="button button-primary" :href="nativeUrl">立即打开原生页面</a>
@@ -14,7 +14,7 @@
<n-card class="aksrt-section-card"> <n-card class="aksrt-section-card">
<n-alert type="info" title="按设计省略" :show-icon="true"> <n-alert type="info" title="按设计省略" :show-icon="true">
本主题当前不实现 zibll 风格的授权码校验、域名绑定、撤销授权、本地免授权等机制。 本主题当前不实现 原主题风格的授权码校验、域名绑定、撤销授权、本地免授权等机制。
`AKSRT_Update_Checker` 仅负责匿名版本检测与更新提示,不与授权系统耦合。 `AKSRT_Update_Checker` 仅负责匿名版本检测与更新提示,不与授权系统耦合。
</n-alert> </n-alert>
</n-card> </n-card>
Loaded 100 of 1317 files, more files were not shown because too many files have changed in this diff. Show more