Compare commits

..
336 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
584 changed files with 53707 additions and 29904 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
+7 -57
View File
@@ -4,67 +4,17 @@
/.compare/ /.compare/
/.zcode/ /.zcode/
/.mimosa/ /.mimosa/
/.workbuddy/
# Project planning, internal docs, and local-only development files # Project planning, internal docs, and local-only development files
/.aksrt-chrome-profile/
/.specs/ /.specs/
/docs/ /docs/
/tools/*
!/tools/build-public-assets.mjs
/tests/*
!/tools/wp-cli-visual-fixtures.php
!/tests/fixtures/
!/tests/visual/
/tests/fixtures/*
!/tests/fixtures/page-visual-matrix-v1.json
!/tests/widget-system/
/tests/widget-system/*
!/tests/widget-system/post-editor-reference-contract.test.cjs
!/tests/widget-system/post-visual-contract.test.cjs
!/tests/widget-system/lazyload-contract.test.cjs
!/tests/widget-system/toast-contract.test.cjs
!/tests/widget-system/page-display-contract.test.cjs
!/tests/widget-system/icon-card-contract.test.cjs
!/tests/widget-system/page-display-save-harness.php
!/tests/widget-system/admin-ui-contract.test.cjs
!/tests/widget-system/email-system-contract.test.cjs
!/tests/widget-system/auth-source-contract.test.cjs
!/tests/widget-system/captcha-business-contract.test.cjs
!/tests/widget-system/captcha-security-harness.php
!/tests/widget-system/captcha-runtime-harness.test.cjs
!/tests/widget-system/oauth-security-contract.test.cjs
!/tests/widget-system/oauth-security-harness.php
!/tests/widget-system/user-badges-contract.test.cjs
!/tests/widget-system/user-features-contract.test.cjs
!/tests/widget-system/zibll-modern-widgets-contract.test.cjs
!/tests/widget-system/user-card-runtime-contract.test.cjs
!/tests/widget-system/user-card-runtime-harness.php
!/tests/widget-system/notification-preferences-contract.test.cjs
!/tests/widget-system/message-center-contract.test.cjs
!/tests/widget-system/message-center-url-harness.php
!/tests/widget-system/message-center-pagination-harness.php
!/tests/widget-system/asset-ledger-contract.test.cjs
!/tests/widget-system/financial-flow-contract.test.cjs
!/tests/widget-system/user-assets-layout-contract.test.cjs
!/tests/widget-system/pay-orders-filter-contract.test.cjs
!/tests/widget-system/front-submit-editor-contract.test.cjs
!/tests/widget-system/front-submit-attachment-contract.test.cjs
!/tests/widget-system/comment-smilies-removal-contract.test.cjs
!/tests/widget-system/comment-children-list-reset-contract.test.cjs
!/tests/widget-system/comment-image-upload-contract.test.cjs
!/tests/widget-system/pay-card-contract.test.cjs
!/tests/widget-system/circular-button-icon-margin-contract.test.cjs
!/tests/widget-system/single-cover-height-contract.test.cjs
!/tests/widget-system/frontend-lifecycle-contract.test.cjs
!/tests/front-submit/
/tests/front-submit/*
!/tests/front-submit/video-cover-contract.test.cjs
!/tests/front-submit/featured-buttons-contract.test.cjs
!/tests/front-submit/editor-fullscreen-contract.test.cjs
!/tests/asset-ledger/
!/tests/asset-ledger/*.php
!/tests/visual/docker-compose.zibll.yml
!/tests/visual/zibll-Caddyfile
!/tests/visual/README.md
/CLAUDE.md /CLAUDE.md
/AGENTS.md /AGENTS.md
ask-*.md ask-*.md
@@ -1,60 +0,0 @@
# 用户公开主页通知按钮改为纯图标+未读角标
## 现状
- 目标按钮: `templates/parts/user/author-hero.php:144-149`(仅 `$is_own` 查看自己主页时输出),当前为铃铛图标 + "消息" 文字,无角标。
- 参照实现: 用户中心头部 `inc/helpers/user-center-renderers/profile-header.php:87-94` — `btn-ghost btn-sm btn-icon` 纯图标按钮,带 `aria-label`/`title`,未读数 > 0 时输出角标 `<span class="uc-header__action-badge">N</span>`(不封顶 99)。
- 未读数函数: `aksrt_get_user_unread_notification_count($user_id)`(`inc/modules/user-notifications.php:387`),模块经 `inc/bootstrap.php:78` 无条件加载,可直接调用。
- hero 卡片仅由 `page-user-home.php` 服务端静态渲染一次(DOM 无刷新只作用于 tab 面板),角标为静态服务端渲染,与用户中心行为一致。
- `.btn-icon` / `.btn-icon.btn-sm` 样式已存在于 `assets/css/core/buttons.css:129-136`,全局可用;但 `.uc-header__action-badge` 样式在 user-center 路由 CSS 中,不会在公开主页加载,需在 `assets/css/routes/user-home.css`(该页直接入队,无构建步骤)新增。
## 修改内容
### 1. `templates/parts/user/author-hero.php`
- 在第 58 行 `$messages_url` 之后新增:
```php
$unread_count = $is_own ? aksrt_get_user_unread_notification_count($user_id) : 0;
```
- 将消息按钮(144-149 行)整体替换为与用户中心一致的纯图标形式,类名沿用本文件 BEM 前缀:
```php
<a href="..." class="btn-ghost btn-sm btn-icon aksrt-author-hero__message-btn" aria-label="消息通知" title="消息通知">
<i class="fa fas fa-bell" aria-hidden="true"></i>
<?php if ($unread_count > 0) : ?>
<span class="aksrt-author-hero__action-badge"><?php echo esc_html((string) $unread_count); ?></span>
<?php endif; ?>
</a>
```
- 删除原 `<span>消息</span>` 文字输出,不保留旧结构。
### 2. `assets/css/routes/user-home.css`
在 `.aksrt-author-hero__follow-btn` 样式附近(约 167 行后)新增,视觉与用户中心角标(`user-center/shell.css:136-157`)完全一致:
```css
.aksrt-author-hero__message-btn {
position: relative;
}
.aksrt-author-hero__action-badge {
position: absolute;
top: -5px;
right: -5px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
margin: 0;
padding: 0 4px;
border-radius: 999px;
background: var(--color-error, #ef4444);
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
```
## 验证
- `php -l templates/parts/user/author-hero.php`
- 运行 `node --test tests/widget-system/page-display-contract.test.cjs tests/widget-system/post-visual-contract.test.cjs`(已确认两者均未断言该按钮标记,应保持通过)
- 桌面/移动端检查: 图标按钮为 28px 方形(`.btn-icon.btn-sm`),角标绝对定位右上角,移动端居中布局不受影响(角标不占布局空间)
不写 function_exists 兜底、不保留文字版本、不添加迁移逻辑。
@@ -1,31 +0,0 @@
修复文章详情页分割线右侧三点图标不居中的问题
## 根因
三点按钮(`inc/helpers/aksrt-single-components.php:131-146`)为 `<button class="but cir post-drop-meta__toggle"><i class="fa fas fa-ellipsis-h"></i></button>`,位于 `templates/parts/single-author-bar.php` 中 `.single-meta-row` 分割线右端。`.but.cir` 是 `inline-flex; justify-content: center`,flex 居中的是子元素的 margin box。`assets/css/core/buttons.css` 中存在特异性冲突:
- 第 183 行 `.but > i[class*="fa-"]:not(.fa-angle-right) { margin-right: 0.25em; }` 特异性 (0,3,1)
- 第 225 行 `.but.cir > .fa { margin: 0; }`(本意的重置规则)特异性 (0,3,0),被压掉
导致图标右侧残留 0.25em 外边距(15px 字号下约 3.75px),三点整体左偏约 1.9px。这是全局 bug,所有 `.but.cir` 图标按钮都受影响。
## 修改内容
1. **`assets/css/core/buttons.css:224-225`** — 加强重置规则的特异性到 (0,4,0),稳定压过 (0,3,1) 的 margin 规则(不依赖源码顺序):
```css
.but.cir svg.icon,
.but.cir > .fa,
.but.cir > .fa:not(.fa-angle-right),
.but.cir > i[class*="fa-"]:not(.fa-angle-right) { margin: 0; }
```
附一行注释说明 `:not()` 变体是为压过 `.but > i[class*="fa-"]:not(.fa-angle-right)` 的 margin 规则。保留原 `.but.cir > .fa` 选择器以继续覆盖 fa-angle-right 图标。不改 main.css(旧的手工捆绑副本):public 捆绑包中 core/buttons.css 副本排在 main.min.css 之后,高特异性重置必然胜出。
2. **重建捆绑包** — 运行 `node tools/build-public-assets.mjs`(esbuild 已就绪),重新生成 `assets/css/build/public.css/.min.css` 等捆绑文件。
3. **新增契约测试** `tests/widget-system/circular-button-icon-margin-contract.test.cjs`:断言源文件 buttons.css 与构建产物 public.min.css 中包含加强后的重置选择器(防止特异性回归),沿用现有 `*.test.cjs` 契约测试模式。
## 验证
- `node --test tests/widget-system/circular-button-icon-margin-contract.test.cjs` 通过
- 在构建后的 `public.min.css` 中确认重置规则存在且位于 margin 规则之后
- 刷新文章详情页目视确认三点图标在圆形按钮内水平居中(同时全站其他 `.but.cir` 图标按钮居中恢复正常)
@@ -1,163 +0,0 @@
## 目标与边界
将“全局功能 → Email邮件”收敛为一套真正可用的邮件系统:后台完整展示 SMTP、发件品牌、认证/账号、内容互动、审核、私信、身份/账号处理、举报,以及订单、支付、提现、返佣、分成、转账等商城/资金类邮件;模板主题和正文默认直接显示,不再用空值加 placeholder 代替;所有真实发送统一由同一服务渲染和发送。
明确排除:
- 不实现论坛、版主、论坛帖子审核、回答采纳等论坛专属邮件。
- 不实现邮箱回滚、撤销链接、回滚令牌或旧邮箱恢复流程;邮箱变更邮件只做安全通知。
- 不做旧 option 的数据迁移,不保留新旧双路,不写兼容 adapter。
- 不做浏览器或真实视觉验收,只做代码、契约、语法和构建验证。
## 1. 建立唯一邮件配置与模板目录
- 以 `aksrt_settings` 的 Email section 作为唯一配置来源;SMTP、品牌、通知开关和模板都通过 `/settings/schema/global/email` 读写。
- 在邮件域建立集中式模板目录,统一声明每个非论坛事件的事件键、后台名称、默认开关、默认主题、默认 HTML 正文、占位符及说明。
- 邮件运行时和后台 Schema 均从该目录生成,避免默认文案继续散落在业务调用点。
- 对每个事件同时声明“开关、主题模板、正文模板”;模板正文在后台直接显示为非空默认值。数据库中已有空主题/正文时,仅在读取响应和表单初始化时解析为目录默认值,不执行迁移。
- HTML 模板和 `mail_more_content` 使用 `wp_kses_post()` 清理;主题、SMTP host/username/name 等按文本清理;邮箱地址使用 `sanitize_email()`;端口限制到合法范围;加密方式只允许 `ssl`、`tls` 或空值;SMTP 密码使用 secret 掩码/清除机制。
## 2. 完整重做 Email 后台 Schema
在 `src/Admin/Schema/GlobalSchema.php` 中由集中目录生成 Email section;让 `SchemaPage.vue` 对 `global.email` 使用服务端 Schema,删除前端重复的静态 Email schema 定义,避免 PHP/JS 两份字段漂移。
页面分组与事件如下:
1. **管理员邮件推送**
- 友情链接提交
- 新订单
- 提现申请
- 投稿待审核
- 身份认证申请
- 账号封禁申诉
- 举报提交
2. **用户邮件推送**
- 注册验证码
- 注册成功
- 免密登录验证码
- 密码重置验证码
- 邮箱/敏感操作验证码
- 邮箱变更安全通知
- 新订单/支付结果
- 提现处理结果
- 返佣到账
- 创作分成到账
- 收到积分/余额转账
- 评论通过审核
- 评论回复
- 投稿发布/驳回结果
- 手机号修改通知
- 身份认证审核结果
- 账号封禁/解封通知
- 举报处理结果
- 私信通知
- 私信邮件用户组限制
- 所有用户
- 所有会员
- 二级会员及以上
- 仅管理员
3. **自定义发件人/品牌**
- `mail_showname`,默认站点名
- `mail_title_prefix`,默认 `[站点名]`
- `mail_description`,默认“此信为系统邮件,请不要直接回复。”
- `mail_more_content`,默认显示“访问网站 / 联系站长”等基础 HTML 链接
4. **SMTP 配置**(沿用参考主题语义,但使用当前主题唯一字段契约)
- `mail_smtps`
- `mail_name`
- `mail_passwd`
- `mail_smtp_name`
- `mail_host`
- `mail_port`
- `mail_smtpauth`
- `mail_smtpsecure`
- SMTP 风险提示和依赖显示
5. **测试邮件**
- Email 页面内提供测试收件地址和发送操作。
- 有未保存修改时禁止测试并提示先保存,确保测试当前生效配置。
- 测试邮件走统一 HTML 包装、品牌和 SMTP 主线。
每个事件组直接显示“启用开关 + 主题模板 + HTML 正文模板 + 可用占位符说明”。论坛事件不进入 Schema、模板目录、默认值或运行时注册。
## 3. 实现参考主题式统一邮件渲染
重构 `src/Settings/Mail.php` 和 `inc/core-hooks/part-2.php`:
- 集中服务完成事件查询、开关判断、占位符替换、主题前缀、HTML 安全处理、品牌包装和 `wp_mail()` 调用。
- 复刻参考主题邮件信封:站点 Logo、主体内容、站点名称、底部说明、附加链接及现有 `assets/img/mail-bg.png` 背景图。
- 主题前缀只添加一次,并清除 WordPress 默认重复 `[站点名]` 前缀。
- 发件人名称保留明确优先级:`mail_showname` → 站点名;SMTP 发件地址和 SMTPAuth 使用同一 Email section 的 `mail_*` 字段。
- 将 `send_event_email()` 统一为事件键、收件人、占位符(必要时允许结构化上下文),默认模板不再由调用方重复传入。
- 未知事件失败关闭;无效收件地址不发送;事件关闭时不发送。
- 统一管理员收件人获取、去重和有效邮箱过滤。
## 4. 接通所有非论坛真实业务发送点
新增聚合通知注册类,基于当前主题已有 WordPress/AKSRT action 注册邮件发送;不凭空添加论坛功能。每个事件都复用集中模板目录和统一发送服务。
### 认证与账号
- 改造 `UserControllerAuthPhoneOperations.php`:注册验证码、免密登录验证码、密码重置验证码按独立事件发送;失败时删除验证码 transient,维持现有频率限制和安全行为。
- 注册成功在用户创建成功且用户对象有效时发送,提供用户名、站点名、个人中心/站点链接等占位符。
- 改造 `account-ajax.php`:敏感操作验证码、邮箱变更通知走统一服务;删除直接 `wp_mail()` fallback;不增加邮箱回滚。
- 手机号绑定/换绑成功后向有效邮箱发送通知。
### 内容互动与审核
- 评论通过审核、评论回复:接入 WordPress 评论状态/回复钩子,避免重复状态流转、自己回复自己和无效邮箱。
- 投稿待审核与发布/驳回结果:接入现有 `aksrt_new_posts_pending` 与文章状态转换,只处理当前主题实际支持的投稿内容,防止重复通知。
- 友情链接提交:接入当前友情链接写入成功动作,向有效管理员邮箱发送。
### 身份、账号、举报和私信
- 身份认证申请/审核、封禁申诉/处理、举报提交/处理:接入现有成功动作;若成功路径缺少动作,只在事务成功点增加明确的 `aksrt_*` action。
- 私信通知:接入现有消息/通知主线,只处理 private 类型;实现参考主题的用户组限制,并过滤无效或占位邮箱。
### 订单与资金业务(保留)
- 订单创建/支付成功/支付失败或状态变化:接入当前支付订单状态 action,分别通知管理员、买家及适用的订单关联用户。
- 提现申请与处理结果:接入当前提现申请、审核/完成/拒绝路径,通知管理员和申请用户。
- 返佣通知:接入当前 referral/佣金到账 action,向推荐人发送订单和佣金信息。
- 创作分成通知:接入当前 ledger/收益分配成功 action,向分成作者发送到账明细。
- 积分/余额转账通知:接入当前资产转账完成 action,向收款用户发送转账信息。
- 上述商城/资金事件只使用仓库中已存在的订单、支付、ledger、referral、资产动作和数据结构;若某事件没有可直接监听的成功动作,在对应现有事务完成点补充 action,不复制业务逻辑、不新建第二套交易流程。
论坛/版主/回答采纳动作不注册任何邮件监听器,也不产生对应模板字段。
## 5. 删除旧接口、旧存储与绕路代码
- 删除 `GlobalController` 的 `/settings/mail` 路由和 `rest_handle_mail()`,不再使用 `aksrt_mail_config`。
- 保留 `/mail/test` 作为动作接口,但重写为统一邮件服务测试;配置读写只有 Schema 路由。
- 删除前端 `getMailSettings()` / `saveMailSettings()`,只保留测试邮件 API 方法。
- 删除 `UserController` 的 `/settings/mail-templates` 路由、`handle_mail_templates()` 和 `aksrt_mail_templates` 契约。
- 删除 `Settings::get_smtp()` 对 `aksrt_mail_smtp` 的读取,并从 standalone/secret option 注册表移除该旧 option;SMTP 只读 Email section 的统一字段。
- 删除认证、账号和各业务模块中邮件服务不存在时的直接 `wp_mail()` fallback;正常业务只允许集中邮件服务内部调用 `wp_mail()`。
- 删除旧六事件空模板回退参数、重复前端 Email Schema 及不再使用的旧字段;不读取、不转换旧 option 值。
- 不删除数据库中的旧 option 值,但代码完全停止消费它们;不执行迁移脚本或双写。
## 6. 契约测试与构建验证
新增邮件系统契约测试/harness,覆盖:
- 后端 Email Schema 包含认证、账号、内容互动、审核、私信、身份/账号处理、举报、订单、支付、提现、返佣、分成、转账等事件。
- Schema 明确不包含 `bbs`、moderator、answer-adopted 及其它论坛字段。
- 每个主题和正文默认值非空;空存储读取时后台仍返回可见模板;模板占位符替换后无未知占位符。
- HTML 邮件信封包含品牌说明、附加内容、Logo 和背景图;主题前缀不重复;发件人和 SMTP 字段优先级稳定。
- SMTP 开关、认证、host、port、encryption、secret 掩码/清除行为正确。
- 事件关闭、未知事件、无效收件地址均不发送。
- 注册/登录/重置/邮箱变更路径不再绕过集中服务,发送失败时验证码缓存处理正确;不出现邮箱回滚入口或令牌。
- 评论、投稿、友情链接、身份认证、封禁、举报、私信及所有订单/资金事件分别绑定真实 action,并覆盖去重、无效邮箱和边界条件。
- 论坛事件不注册、不发送、不出现在后台;订单/支付/提现/返佣/分成/转账事件必须存在并可配置。
- 旧 `/settings/mail`、`/settings/mail-templates`、`aksrt_mail_config`、`aksrt_mail_templates`、`aksrt_mail_smtp` 和旧前端 API 不再存在。
- 全仓库业务代码中的 `wp_mail()` 只剩集中邮件服务内部调用。
执行验证:
1. 对所有变更 PHP 文件运行 `php -l`。
2. 运行新增邮件契约测试及受影响的 admin/UI 契约测试。
3. 在 `assets/admin` 执行 `npm run build`,由 Vite 清理旧 chunk 并更新完整 `assets/admin/build/`。
4. 使用 `rg` 做最终静态检查:无论坛邮件字段、无旧 option/API、无散落直接 `wp_mail()`,且订单/支付/提现/返佣/分成/转账事件均有 Schema、模板目录和发送入口。
5. 不启动浏览器、不截图、不做真实视觉效果验收。
@@ -1,56 +0,0 @@
## 目标与口径
将 AKSRT 小工具系统中 12 个小工具对照 Zibll(仅参考"(新)"模块,忽略一切"旧版即将删除")做全量 1:1 复刻:后台配置项(字段、类型、选项、默认值、依赖、min/max/step、分组小标题)与前端 DOM/样式/状态全部对齐参考主题。已确认口径:
- **专题全部移除**:本项目无 topics 分类法,12 个小工具范围内所有"专题限制"字段与 `PostQuery` 的 topics 查询分支一并删除,测试同步更新。
- **修正参考源码明显 bug 的文案**(如排序 label 颠倒、最近评论 style 默认值 'card' 不在选项中),数据值与行为保持一致。
- **省略 AKSRT 无法支撑的排序选项**(销售价格/积分售价/销售数量),排序值域 = views/like/comment_count/favorite/modified/date/rand。
- **新增"搜索式多选+拖拽排序"分类选择字段**(terms 类型)。
- 不写双路/迁移;清理被替换的死代码;不做 Docker 视觉验收;不改 37 个 slug 与 REST 路由形态。
## 复用基础(已核实)
- 文章 item DOM 契约已在 `inc/helpers/posts-renderer/markup.php`(posts-item/posts-mini/badges/meta,9.0 契约);`main.css` 已含 `.zci-*`、`.zps-*`、`.graphic*` 等 Zibll 同款 CSS(76 处命中);`frontend.css:179-228` 已有 tab/slider 的 Zibll 契约块;runtime `main.js` 已有 `.swiper-scroll`/`.new-swiper` 初始化。复刻 = 补齐缺失部分并对齐 markup,而非从零移植。
- 前次复刻先例:tab_posts/slider/buttons/notice 走 `FrontendRenderer` "特殊壳"路径(before_widget + reference_title + 内容自闭环),本次 12 个中需要 Zibll 壳的一并加入该列表。
## 实施步骤
### Phase A 基础设施
1. `FieldSchema.php`/`FormRenderer.php`:新增 `terms` 字段类型(动态 get_terms 分类选项、搜索过滤、拖拽排序、按选择顺序保存 int 数组;支持 taxonomy 参数以供合作伙伴的链接分类复用);spinner/slider 渲染 `unit` 后缀(此前 unit 是死键)。
2. `PostQuery.php`:删除 topics 分支;tabs 段支持 `zibpay_type`(数组,沿用 apply_pay_type 映射 1/2/5/6);orderby 值域与 allowlist 对齐。
3. `PostRenderer.php`:分页三态对齐 Zibll —— `''`→隐藏 `.ajax-pag.hide` 占位、`ajax`→"加载更多" `.ajax-pag .next-page.ajax-next`、`number`→`.pagenav.ajax-pag` 数字页码(全部带 `ajaxpager-target=".widget-ajaxpager"` 契约,REST 数据属性保留);`mini_opt` 应用于 posts;热榜从通用分支拆出专用渲染。
4. `FrontendRenderer.php`:特殊壳列表加入 `posts, oneline_posts, hot_posts, term_cards, term_aggregation, media_cards, combo_info, partners, recent_comments, posts_toc`。
5. `frontend.js`:移植/新增 ①IAS IntersectionObserver 懒加载(列表 `load_mode=ajax` 与加载更多自动触发,替代 Zibll lazysizes ias);②合作伙伴滚动初始化(测量 seed 组宽→克隆→`--zps-duration`,resize 重算,Zibll main.js:4204-4296);③幻灯片组合卡 footer 重算 `--cover-ratio`(widget-slider.php:848-875);④目录树逻辑(section-navs 移植:h1-h4 抓取、排除 `.item-heading`/`.no-nav`、`wznav_{i}`、scrollspy offset 105、>380px 折叠、hash 定位);保持 role=status/aria-busy/reduced-motion a11y 契约。
6. `admin.js`/`admin-core.js`/`admin.css`:terms 字段交互(搜索、拖拽排序、选序同步)。
### Phase B 逐个小工具(schema+render+CSS+JS+空态,均以参考文件为准逐字段移植)
| # | AKSRT slug | 参考定义(行号) | 要点 |
|---|---|---|---|
| 1 | posts 文章列表 | widget-posts.php:1881-2006 | load_mode radio(detail/ajax 默认 ajax)、cat(reference_ids)、zibpay_type checkbox、limit_day、orderby、order、style(list/card/mini)+mini_opt、count 4..20 默认 12、paginate radio ''/ajax/number;壳 `widget-main-post mb20 style-X`(mini 加 `zib-widget posts-mini-lists`);删旧 tag_ids/paginate select |
| 2 | tab_posts 多栏目文章 | widget-posts.php:2008-2155 | tabs 子字段 title/cat/zibpay_type/limit_day/orderby/order;移除 topics;**删除现有 legacy `post_ajax_trigger` 双路**,翻页三态;mini 骨架含缩略图变体 |
| 3 | oneline_posts 横向滚动文章 | widget-posts.php:2157-2234 | cat/zibpay_type/orderby/order/style 默认 card/count;移除 limit_day/paginate/autoplay(参考无);swiper-scroll DOM 已对齐,清 data-autoplay |
| 4 | hot_posts 热榜文章 | widget-posts.php:1803-1851 + zib-posts-list.php:769-888 | orderby radio 4 榜单、limit_day、count 默认 6、target_blank;`zib-widget hot-posts`、TOP1 大图+linear-mask+TOP 徽章(jb-red/jb-yellow/b-gray)、行布局与计数文案(人收藏/人点赞/条讨论/人已阅读);删 show_thumbnail/通用样式字段 |
| 5 | term_cards 分类图文卡片 | widget-posts.php:1565-1694 + functions.php:1939-2046 | 模块级 obs_animation/animation_repeat(common.animation 关)、terms 选择器、type style-1..4、height_scale、pc_row(1/2/3/4/6/12 默认 2)/m_row、mask_opacity 默认 10、target_blank;`row gutters-5` + `graphic hover-zoom-img` + `--cover-height-scale` + graphic-mask,style-2/3/4 文案差异与 tooltip;新建共享助手 `aksrt_graphic_card()`(inc/helpers),term 分类默认封面回退 |
| 6 | term_aggregation → 改名"分类聚合卡片" | widget-posts.php:1696-1801 + zib-category.php:606-751 | 仅分类(去标签/专题):模块级动画、pc_row slider 1..2 默认 2、terms、orderby/order、count 1..20 默认 4、target_blank;移植 `zib-widget term-aggregation`(term-img+img-badge 浏览合计、term-title/meta/更多文章、按 orderby 的行内 meta、--delay 交错);Catalog 名称/描述/共同字段更新;服务端仅 category |
| 7 | media_cards 图文视频封面卡片 | widget-more.php:2005-2191 | 模块级动画、pc_row/m_row(1..12,默认 4/2)、mask_opacity、height_scale 默认 30、font_size_pc/m、font_bold、font_color、covers group{image,video,hide,title,link};`widget-graphic-cover` + this-font 变量 + 移动端回退图片;删旧 description/image_dark 卡片字段 |
| 8 | slider_media_combo 幻灯片加图文视频卡片 | widget-slider.php:361-887 | 模块级动画;补 5 个 subheading 分组;option 默认值逐项核对(源文件为准);m_layout_type=default 时 m_row=pc_row;clamp 上限 4/6 修正;footer 重算 JS 接入 |
| 9 | partners 横向滚动合作伙伴 | widget-more.php:2317-2581 | rows/m_rows 1..6 默认 3、style_type card/logo/chip、card_width/height(依赖)、card_bg/card_radius、speed slider 0.1..10 步 0.1、direction/pause_hover/show_widget_bg/go_link/end_fade、link_type(link 类别多选用 terms 类型 taxonomy=link_category/link_limit/link_orderby/page_links_order)、partners group{title,desc,image,link};zps-* DOM、round-robin 行分配、get_bookmarks 来源、go_link 走 aksrt_go_link、CSS 动画(已有)+JS 初始化 |
| 10 | combo_info 复合信息卡片 | widget-more.php:2583-2833 | 模块级动画、pc_row 2/3/4/6 默认 4、m_row 1/2、pc_gap/m_gap(gutters)、card_radius、top_band/top_band_height、go_link、cta_text/cta_color、cards group{title,icon,icon_image,tags 嵌套 group{text,color},desc,link,cta_text,cta_color,band_color auto/7色};zci-* DOM + band auto 循环 + --delay 交错;markup 改挂到已有 `.aksrt-combo-info-card` CSS;删 aksrt-combo-card 死代码 |
| 11 | recent_comments 最近评论 | widget-more.php:3315-3363 + zib-comments-list.php:340-407 | limit 1..50 默认 8、outer/outpost、show_source 默认 false、style radio(''简约/style_2 风格2,默认修正为 '');`box-body comment-mini-lists zib-widget` + comment-widget-item(avatar box、昵称+icon-spot 时间、点赞、con-link text-ellipsis-5、style_2 盒装、来源行);复用 inc/helpers/aksrt-comments.php 时间/头像,内容过滤图片→[图片] |
| 12 | posts_toc 文章目录树 | widget-more.php:445-532 | 改旧式字段:title 默认"文章目录"、mini_title、in_affix(删 min_headings/collapsed/offset);渲染空占位 `.posts-nav-box[data-title]`;目录构建/scrollspy/折叠全部移入 frontend.js;`.posts-nav-lists` CSS 若缺则从 main.css:4835-4946 补 |
### Phase C 死代码清理
- 删除被替换的渲染分支与 CSS:aksrt-term-cards、aksrt-term-aggregation、aksrt-combo-card、aksrt-widget-partners 旧轨、aksrt-widget-media-cards/comments/toc/pagination/load-more 旧标记、tab_posts legacy trigger、hot_posts 的 show_thumbnail、oneline 的 autoplay;`media_card_fields()` 仍被 knowledge_product 组使用故保留,仅解除 12 个目标对它的引用。
### Phase D 测试与校验
- `tests/widget-system/run.php`:query_contract 移除 topics、加 tab zibpay_type;modern_widget_contract 扩充 12 个的 schema/DOM 断言;保持既有断言全绿。
- 新增 `tests/widget-system/zibll-parity-widgets-contract.test.cjs`(node --test 源码 token 契约:每 widget 的字段 id 清单、关键 DOM 类、CSS token、frontend.js 行为、死类负向断言)。
- 更新受影响的 zibll-modern-widgets / post-visual / a11y 契约测试(保持 aria-busy、role=status、reduced-motion)。
- 重新生成 `tests/fixtures/widget-golden-v1.json`(php tools/generate-widget-golden-fixture.php)并跑 php tools/validate-widget-golden.php(不带 --require-results)。
- 验证:`php -l` 全部改动 PHP;`node --test tests/widget-system/` 全量;`composer dump-autoload -o`(优先不新增类,仅改现有文件)。
### 明确不做
不做旧设置迁移与双路渲染;不做真实视觉/Docker 验收;不参考"(旧版即将删除)"模块;不改 37 slug 顺序、REST 路由形态、其他 25 个小工具。
### 主要参考文件
zibll:`inc/widgets/widget-posts.php`、`widget-more.php`、`widget-slider.php`、`inc/functions/{zib-posts-list,zib-category,functions}.php`、`js/section-navs.js`、`js/main.js`、`css/main.css`;AKSRT:`src/WidgetSystem/*`、`inc/helpers/*`、`assets/widgets/*`、`tests/widget-system/*`。
@@ -1,16 +0,0 @@
诊断结论(已核实):
`video-player.js:177` 是 `toggle()` 中的 `this.video.play()`。播放器进入“无可播放源”状态后,点击封面/遮罩触发 `toggle()`,该 Promise 没有 catch,于是抛出 `Uncaught (in promise) NotSupportedError: The element has no supported sources`。播放器进入无源状态有四条已确认路径:
1. `.m3u8` 在 Chrome:hls.js 从 `cdn.jsdelivr.net` 懒加载(video-player.js:22),CDN 不可达时 `loadScript` 静默失败(promise 也未 catch),`<video>` 始终没有源;
2. `.flv` 同理(flv.js,video-player.js:23);
3. 直链 mp4 地址 404/编码不支持,源加载失败后 `currentSrc` 为空;
4. `.switch-iframe` 剧集按钮被 video-player 的委托处理器(video-player.js:225)捕获,把 iframe 嵌入页 URL 当 mp4 塞给 `video.src`,必然无源。`.switch-iframe` 本应由 `runtime/main.js:1351` 的 iframe 链路处理。
修复方案(单一路径,不引入双路/迁移):
1. `assets/js/video-player.js`:
- `toggle()`:播放前守卫无源状态(`video.currentSrc`/`video.src`/`this.hls`/`this.flvPlayer` 均为空时直接返回),并对 `play()` 返回的 Promise 加 `.catch()`,播放失败不再产生未捕获异常;
- 两处 `loadScript()`(hls/flv)加 `.catch()`,CDN 失败被收敛为“无源”状态,不再产生未处理 rejection;
- 委托处理器限定为 `.switch-video`,移除对 `.switch-iframe` 的错误接管(iframe 剧集继续由 main.js 既有链路处理,属于删除错误重叠而非新增实现)。
2. 契约测试 `tests/widget-system/single-cover-height-contract.test.cjs` 的 video cover 用例追加断言:toggle 守卫 + play catch、loadScript catch、处理器不含 `.switch-iframe`。
3. 验证:`node --check` 播放器文件、运行 `single-cover-height-contract` 及相关契约测试、`rg` 复查、`git diff --check`;不做真实视觉效果验收。
@@ -1,142 +0,0 @@
## 目标与边界
将 AKSRT 前台 DOM 架构一次性硬切换为 `C:\Users\Xing\Desktop\zibll` 的真实架构:
```text
WordPress 配置/_win
→ jQuery → Bootstrap → runtime/loader.js
→ tbquire(['main'])
→ main.js 顶层全局委托 + window.auto_fun()
→ 局部 AJAX:post_ajax.ed
→ JSON AJAX:zib_ajax.success
→ 远程 modal:loaded.bs.modal → auto_fun()
```
不保留 `AksrtFrontend`、不写兼容适配器、不做双路初始化、不保留“迁移中”分支。保留 AKSRT 的业务能力和安全处理,但全部挂到参考主题的唯一 `main/auto_fun` owner 上。按用户要求只做静态、契约、VM、语法和构建验证,不做真实视觉验收。
## 一、运行时入口硬切换
1. **重写 `assets/js/runtime/loader.js`**
- 以参考主题 loader 为行为基准,恢复 RequireJS 改名运行时、`tbquire.config()` 和最后的 `tbquire(['main'])`。
- 根据 AKSRT 实际目录调整 `baseUrl` 与 URL 版本参数,但保持参考主题的加载语义和 module mapping 结构。
- 将当前仍有业务消费者的模块纳入 mapping:main、imgbox、page-navs、section-navs、comment、message、input-expand、mini-upload、page-template、page-edit、author、poster-share、captcha、pay、swiper、dplayer、lazySizes、clipboard、svg-icon、enlighter、translate 等;每个 mapping 只指向一个真实文件,不复制第二份实现。
- 删除当前仅为 `AksrtFrontend`/`aksrt.tbquire` 服务的映射和 fallback。
2. **重写 `assets/js/runtime/main.js`**
- 以参考主题 `zibll/js/main.js` 的全局顺序为骨架:顶层建立 `_win.bd`/`_win.window`,注册 body/document 委托,定义全局 API 与插件,定义 `z_auto_fun`,暴露 `window.auto_fun = debounce(z_auto_fun, 100)`,顶层首次调用 `auto_fun()`,最后执行单一 `$(document).ready(...)` 首屏逻辑。
- 将 AKSRT 当前 `aksrt-runtime.js` 中仍需要的 declarative action、确认、通知、上传/安全/站点功能直接并入该唯一运行时或对应 `tbquire` 模块,不再通过第二个 runtime 注册表调用。
- 将所有按 DOM 条件加载逻辑集中到 `z_auto_fun()`:支付、消息、输入扩展、上传、海报分享、验证码、复制、tooltip、popover、affix、Swiper、DPlayer、SVG、代码高亮、灯箱等,严格按参考主题调用 `tbquire()`。
- 删除 `AksrtFrontend.register('aksrt-legacy-main', ...)`、`window.auto_fun` 到 `AksrtFrontend.rehydrate()` 的 wrapper、`z_auto_fun()` 中对新 registry 的依赖。
- 将原先局部更新后的 `AksrtFrontend.rehydrate(root, 'ajax'/'modal')` 全部改为参考主题调用顺序:注入完成后触发 `post_ajax.ed`(列表场景)或 `loaded.bs.modal`(远程 modal),随后调用防抖 `auto_fun()`。
- 保留必要的幂等 class/data 标记(如 `is-on`、`swiper-container-initialized`、`dplayer-initialized`),但删除无法再被调用的 registry 保护代码。
3. **删除新生命周期核心**
- 删除 `assets/js/rehydrate.js`。
- 将 `assets/js/aksrt-runtime.js` 中仍需的实际业务行为迁移到 `runtime/main.js` 或对应按需模块后,删除该文件及其作为公共入口的职责;不得留下空壳 `aksrt-runtime` 适配层。
- 清理所有 `AksrtFrontend.register/boot/rehydrate`、`aksrt.autoFun`、`aksrt:ready`、`aksrt:rehydrated` 引用和注释。
## 二、公共构建与 PHP 入队重构
1. **调整 `tools/build-public-assets.mjs`**
- 不再把整个 canonical 模块集合拼成一个 `public.js` 再依赖 `AksrtFrontend`。
- 以参考主题的独立 loader + 独立动态模块为构建图;对仍需生成的压缩文件只从唯一源码生成,避免 source/min 双 owner。
- 保留 CSS bundle 构建;JS 构建只生成 loader/main 及实际 mapping 所需的 min 产物。
- 生成后执行静态顺序检查,确保 loader 先于 main 动态加载,且没有 `tbquire(['main'])` 之外的第二个 boot。
2. **调整 `src/Core/Scripts.php`、`ScriptsPart1b.php`、`ScriptsPart1c.php` 及 WidgetSystem AssetManager**
- 前台基础依赖改为 jQuery → Bootstrap → `runtime/loader.js`,不再将 `build/public.js` 作为全局生命周期入口。
- 删除所有 routed script 对 `aksrt-runtime` 的依赖;路由模块改由 loader/main 按 DOM 条件加载,直接遵循参考主题的条件加载方式。
- 将 `_win` 作为唯一前台运行时配置对象;把必须保留的 AKSRT 数据字段合并进去,避免同时维护 `_win`、`aksrtData` 两套 DOM 生命周期配置。
- WidgetSystem 只保留一个前端 owner:将 widget 的初始化接入 `auto_fun`/对应动态模块,禁止 `assets/widgets/frontend.js` 与旧 widget-set/main 重复初始化同一节点;同步调整第三方资源注册和依赖。
- 清理已迁移到 loader/main 的独立 enqueue、重复 inline boot、重复条件判断和无实际动作的 Swiper 注册。
## 三、局部更新协议统一为参考主题行为
1. **列表 AJAX:`assets/js/core/ajax-list.js`、`homepage-fixed.js`、`pagination.js`、`comments.js`、`routes/user-center.js`、`author.js`、`widgets/frontend.js` 等**
- 保留现有请求、安全、错误提示和业务响应格式。
- 删除直接 `AksrtFrontend.rehydrate()` 的调用。
- 按场景统一为:before 状态 → HTML parse/replace/append → 触发容器上的 `post_ajax.ed` → `auto_fun()`;保留参考主题的 `ing`/loading/分页/route 顺序。
- 对复杂 panel 继续使用现有 destroy 能力(例如订单中心),但 destroy 只是业务实例清理,不再扩展成第二套全局 lifecycle。
- 明确旧 `.ajax-next` 与新 `data-aksrt-*` 的唯一 ownership;最终删除重复 click handler,控制只由一个 `post_ajax` 实现处理。
2. **JSON AJAX:`comment-ajax.js`、`auth.js`、`follow.js`、`credits.js`、`reward.js`、`messages.js` 等**
- 统一使用参考主题的 `zib_ajax()` 语义:按钮状态、错误/成功提示、`zib_ajax.success` 事件、业务 callback、可选 modal/reload。
- 删除以 `aksrt:rehydrated` 或 registry 事件作为成功信号的路径;只有真正 HTML 注入后才调用 `auto_fun()`。
3. **远程 modal 与历史路由**
- modal HTML 更新改为 Bootstrap `loaded.bs.modal`,由 main 的全局监听调用 `auto_fun()`。
- 保留 `history.pushState/replaceState` 与 Tab `popstate` 行为,但删除 `AksrtFrontend` 路由 API 和独立页面 boot;不引入 PJAX、Turbolinks、InstantClick 或全局 MutationObserver 重初始化。
## 四、灯箱、懒加载和 DOM 标记恢复为参考主题契约
1. **灯箱硬切换**
- 删除 `AksrtLightbox`、`data-aksrt-lightbox*`、`aksrt-lightbox` overlay 和其专用 CSS/配置桥接。
- 从参考主题恢复单一 `imgbox` owner:新增/恢复 `assets/js/runtime/imgbox.js`(压缩产物由构建生成),使用 `Miniimgbox`、`imgbox-container`、`a[data-imgbox]`、`imgbox_data` 和参考主题的幂等标记。
- `main.js` 只在 `_win.imgbox` 且存在目标 DOM 时 `tbquire(['imgbox'])`;不得再有第二个灯箱监听器。
- 更新 `single.php`、`page.php`、gallery/content renderer、图片 helper、相关 CSS 和设置输出,使 DOM 属性、分组、缩略图、zoom/full/play/down 开关与参考主题一致。
2. **懒加载单一 owner**
- 删除 `lazy-image-attr.js` bridge 及其 enqueue/build/test 依赖;lazySizes、`lazybeforeunveil`、`data-src`/`data-srcset`/`data-bg`、兼容属性处理全部由 main/lazySizes owner 负责。
- 保留必要的 PHP 图片 URL 生成和占位图能力,但统一输出参考主题可识别的 DOM,不再并行维护第二套 lazy switch 或 IntersectionObserver 初始化。
- 保留 lazySizes 自有 MutationObserver;禁止把它扩展成全局组件 rehydrate 机制。
## 五、所有业务模块去 registry 化
逐一处理当前已注册的 public/routed 文件(article、comments、comment-fixed-input、pagination、archive、footer-tabbar、misc、follow、enhancements、enhancements2、color-extract、effects、captcha、auth、author、page-navs、section-navs、messages、front-submit、mini-upload、page-template、page-edit、post-share、poster-share、pay-vip、video-player、download-status、ban-appeal、docs-navigation、input-expand、waterfall、yiyan、site-notice、user-badge-actions、widgets 等):
- 删除 `AksrtFrontend.register()` 包装和对 registry API 的防御性分支。
- 将脚本改成参考主题式:被 `tbquire` 加载后立即定义/绑定,或暴露单一模块初始化函数供 main 调用;依赖 DOM 委托和 `is-on`/class/data 标记实现重复调用安全。
- 任何需要窗口/文档监听的模块只绑定一次,并使用参考主题的全局 owner;不新增 destroy registry、root 广播或双入口 ready。
- 保留 AKSRT 的 nonce、REST 权限、输出转义和业务接口,不改变安全边界。
## 六、死代码清理
在改造前后用 `rg/git grep` 建立引用图,仅删除已确认无入口的代码,避免把仍被动态 loader 使用的资源误删:
- 删除 `assets/js/revenue.js`(确认无 PHP、JS、构建和模板引用后)。
- 删除当前 `rehydrate.js`、`aksrt-runtime.js` 生命周期专用代码及其测试夹具。
- 删除 `runtime/main.js` 中已迁移功能的 registry 适配、注释掉的 waterfall 分支、未使用 `auto_fun` 转发代码和重复事件派发。
- 删除确认无 mapping、enqueue、模板或第三方引用的 `runtime/widget-set.*`、`runtime/mini-touch.*`、`waterfall.*` 等 orphan;若仍被参考主题式 loader 使用,则保留唯一映射,不凭文件名删除。
- 删除 `AksrtLightbox`、旧 overlay、runtime/imgbox 的冲突副本和无消费者的 `aksrt.*` API;`_win`、`tbquire`、main、loader、Swiper/DPlayer/lazySizes 只在确认所有消费者迁移后保留。
- 同步清理文档、注释、PHP handles、构建清单、测试中的旧 token,确保仓库不存在“旧入口还能被重新接回”的死配置。
## 七、测试与非视觉验证改造
1. 删除/重写锁定旧架构的测试:
- `global-dom-init-contract.test.cjs` → `legacy-loader-runtime-contract.test.cjs`:验证 loader config、mapping、`tbquire(['main'])`、唯一 boot owner。
- `rehydrate-runtime-contract.test.cjs` → `legacy-auto-fun-contract.test.cjs`:VM 验证 `auto_fun` 防抖、重复调用、`auto_fun` 事件、异常隔离和 DOM 标记幂等。
- `public-bundle-parity-contract.test.cjs`:改为验证 loader/main 构建图,不再断言 `AksrtFrontend`。
- `homepage-dom-contract.test.cjs`:改为验证 HTML 注入后 `post_ajax.ed → auto_fun` 顺序和唯一 delegated owner。
- `lightbox-contract.test.cjs`:改为 `Miniimgbox`/`data-imgbox`/`imgbox` mapping 契约。
- `lazyload-contract.test.cjs`:改为 main/lazySizes 单 owner 契约。
- `legacy-navigation-contract.test.cjs`:验证 loader 的 page-navs/section-navs mapping 和 `auto_fun` 条件加载。
2. 新增/保留 VM 与静态契约:
- loader URL 解析和模块缓存只发生一次。
- 同一 DOM 多次 `auto_fun()` 不重复绑定 tooltip、Swiper、DPlayer、imgbox、上传和 widget。
- `post_ajax.ed`、`zib_ajax.success`、`loaded.bs.modal` 三种事件不混用。
- 全仓库禁止 `AksrtFrontend`、`aksrt.autoFun`、`aksrt:ready`、`aksrt:rehydrated`、第二套 `DOMContentLoaded` 页面 boot。
- 静态检查没有 PJAX/InstantClick/全局 MutationObserver reinit 新入口。
3. 继续运行与改造后的验证:
- Node widget/navigation 契约测试。
- `tools/test-credits-rehydrate.mjs`(改名或改断言为 auto_fun 重入 harness)。
- WidgetSystem PHP audit、asset ledger、业务安全/支付/评论相关测试。
- `node --check` 检查所有改动后的 JS 和构建脚本。
- `php -l` 检查所有改动后的 PHP。
- `node tools/build-public-assets.mjs` 后再次运行 bundle/source parity、旧入口扫描和 `git diff --check`。
## 八、实施顺序与完成标准
1. 先完成只读引用图和测试基线,记录当前失败/通过集合。
2. 先改 loader/main/构建/PHP 入队,建立唯一运行时入口。
3. 再批量去掉所有 registry 调用并统一 AJAX/modal 事件顺序。
4. 再恢复参考主题灯箱和 lazySizes owner,更新模板/CSS/配置。
5. 最后删除确认死代码与旧测试,重建生成产物。
6. 完成标准:
- 前台只有 `loader → main → auto_fun` 一条初始 DOM 路径。
- 没有任何 `AksrtFrontend` 或其双路兼容代码。
- 列表、JSON、modal 三类更新分别遵循参考主题事件协议。
- Swiper、DPlayer、lazySizes、imgbox、WidgetSystem 各只有一个 owner。
- 所有已确认死代码和旧契约被删除或改写。
- 非视觉测试、语法检查、构建和静态引用审计通过;不执行真实视觉验收。
@@ -1,95 +0,0 @@
# 第三方人机验证优化改造计划
约束:不写双路/兼容迁移代码;顺手清理死代码;不做真实视觉效果验收(用语法检查、契约测试、构建产物校验代替)。
已确认决策:范围全做(P0+P1+P2);下载场景 pass 放行 10 分钟;凭据不齐时仅加后台警告、前台保持放行。
## 改动文件总览
| 文件 | 动作 |
|---|---|
| `inc/modules/captcha.php` | 重写校验层 + 瘦身渲染 + 后台警告 + 静态缓存 |
| `assets/js/captcha-front.js` | 新建(从内联脚本迁移,~340 行) |
| `assets/css/shared/captcha.css` | 保留不动(改为进 bundle) |
| `tools/build-public-assets.mjs` | publicScripts 加 captcha-front.js;public.css 加 captcha.css |
| `src/Admin/Schema/SchemaPermissions.php` | 删除 'mode' 死字段(L32-34) |
| `assets/admin/src/schema/sections/permissions/index.js` | 删除 'mode' 死字段(L18 defaults、L50-56) |
| `src/Admin/SchemaProvider.php` | L403 白名单删 'mode';保存后 flush 配置静态缓存 |
| `tests/widget-system/captcha-cloud-contract.test.cjs` | 新建契约测试 |
不动的文件及原因:`aksrt-header-userbox.php`(toast-contract 测试锁定 signin 接线)、`data-aksrt-download-captcha` 挂载点(`templates/parts/resource-download-box.php:467`、`inc/helpers/aksrt-pay.php:1586`)、所有业务 JS(auth/signin/comments/cms-download/user-center/front-submit 只消费 `AKSRTCaptchaState.scenes[scene]` 与 mount 内 token input,接口不变即零改动)。
## 1. inc/modules/captcha.php — 校验层
**P0-1 腾讯云改走 WP HTTP API**:`aksrt_verify_tencent()` 删除 curl 块(L374-390,含 `CURLOPT_SSL_VERIFYPEER => false`),改用 `wp_remote_post('https://captcha.tencentcloudapi.com/', ['timeout'=>5, 'headers'=>aksrt_tencent_tc3_sign(...)生成的一维数组, 'body'=>$payload])`,与另外三家一致。签名函数不动。
**P1-5 错误信息收敛**:四家服务商的传输层错误(wp_remote_post 返回 WP_Error、响应不可解析)统一 `error_log('[AKSRT Captcha] {provider}: ' . 详情)` 后返回通用文案 `__('人机验证服务暂不可用,请稍后重试', 'aksrt')`;保留对用户有行动意义的映射文案(腾讯 CaptchaCode 表、阿里 F0xx 表)。腾讯 API Error 分支(签名错误等)也走 log+通用文案。
**P1-4**:阿里云 `SignatureNonce` 由 `uniqid('', true)` 改 `wp_generate_uuid4()`。
**P1-8**:四家 `'timeout'` 统一 5 秒(现 10 秒)。
**P1-6 Turnstile 绑定场景**:`aksrt_verify_captcha($post_data, $scene = '')` 增加第二参数(唯一调用方是 `aksrt_require_captcha`,无散落调用点);Cloudflare 成功响应校验:`action !== 'aksrt-verify'` 或 `cdata !== $scene`(存在才比对,lenient)→ 失败。客户端配套见 §2。
**P0-2 下载 pass 放行(10 分钟)**:`aksrt_require_captcha()` 内,scene 命中放行名单时先查 transient、验证通过后写 transient:
- `aksrt_captcha_pass_ttl($scene)`:仅 `download` 返回 `10 * MINUTE_IN_SECONDS`,其余返回 0(单一代码路径,无散落分支)
- key = `'aksrt_captcha_pass_' . md5($scene . '|' . get_current_user_id() . '|' . aksrt_get_client_ip() . '|' . md5(UA))`,仿 `aksrt_auth_rate_` 既有模式
## 2. assets/js/captcha-front.js — 新建(P2-9 + P1-7 客户端)
把 `aksrt_render_captcha()` 内联 `<script>`(L632-969)整体迁移为静态文件,逻辑不改名不重写:`loadScriptOnce`、四家 `load*`、`AKSRTCaptchaState.scenes[scene].verify/reset` 注册、`setField/resetVerificationState/syncDownloadSiblings` 等原样搬移。仅两处行为修正:
- 极验 `onClose` 空回调改为 `cancelPending('已取消验证')`(P1-7,与腾讯/CF 取消体验对齐)
- Turnstile render 配置加 `cdata: C.scene`(P1-6 客户端配套)
激活模型:扫描 `document.querySelectorAll('[data-aksrt-captcha-config]')`,`JSON.parse(el.getAttribute(...))` 逐个 init;DOMContentLoaded 后再补扫一次并暴露 `window.AKSRTCaptchaState.rescan()` 供未来 ajax 注入使用。保持 vanilla JS、无新依赖。
对应 PHP 渲染端瘦身:`aksrt_render_captcha()` 只输出
1. 原 wrapper 结构不变(`.aksrt-captcha-wrap` + `#uid` box + `#vid` captcha_verified 隐藏域),
2. wrapper 上挂 `data-aksrt-captcha-config`(`esc_attr(wp_json_encode($js_config))`),
3. 删除内联 `<script>` 与逐次 `<link rel="stylesheet">`。
TOKEN_FIELDS 常量、i18n 字符串原样进 JS 文件(与仓库其它 bundle 内硬编码中文一致)。
## 3. 构建与后台(P2-10 + bundle 接线)
- `tools/build-public-assets.mjs`:`publicScripts` 追加 `'assets/js/captcha-front.js'`;`cssBundles['public.css']` 追加 `'assets/css/shared/captcha.css'`。走既有 bundle 版本机制,无需新增 enqueue 代码。
- `SchemaPermissions.php`:删除 'mode' 字段定义(L32-34,单选项 always 的死字段)。
- `assets/admin/src/schema/sections/permissions/index.js`:删除 `mode` 的 defaults(L18)与字段定义(L50-56)。
- `SchemaProvider.php` L403:归一化白名单去掉 `'mode'`;`write_section_settings()` 更新 `aksrt_captcha_config` 后调用新助手 `aksrt_captcha_config_reset()` 清静态缓存(function_exists 守卫,与现有 captcha 专用块并排)。
- DB 里残留的 `mode` 键不做迁移清洗(读路径白名单已忽略它,符合"不做迁移"约束)。
## 4. inc/modules/captcha.php — 后台警告与缓存
- **P0-3**:新增顶层 `add_action('admin_notices', ...)`:`manage_options` 权限 + `enabled` 为真 + `active_provider` 非空 + `aksrt_captcha_provider_is_configured()` 为假时,输出 `notice-error` 横幅,文案指向「功能&权限 → 人机验证」补全配置(复用现有 misconfig 提示文案)。前台行为不变(保持放行)。
- **P2-11**:`aksrt_get_captcha_config()` 加 `static $cache`;新增 `aksrt_captcha_config_reset()`;写入路径(SchemaProvider)调用 reset。`aksrt_get_captcha_status_summary()` 的 12 场景循环随之受益。
## 5. 新契约测试 tests/widget-system/captcha-cloud-contract.test.cjs
仿 toast-contract 的正则断言风格:
- `captcha.php`:不匹配 `curl_init|CURLOPT_SSL_VERIFYPEER|machine-verification|slidercaptcha|imagecaptcha`;匹配 `wp_remote_post`(≥4 处)、`wp_generate_uuid4`、`'timeout'\s*=>\s*5`、`aksrt_captcha_pass_`;渲染函数不匹配 `/<script|<link/`(内联抽取完成)
- `captcha-front.js` 存在且含 `AKSRTCaptchaState`、`data-aksrt-captcha-config`
- `build-public-assets.mjs` 的 publicScripts 含 captcha-front.js、public.css 含 shared/captcha.css
- `SchemaPermissions.php` 与 admin `index.js` 的 captcha 段不匹配 `mode`
- `SchemaProvider.php` 不匹配 `'mode'`
## 6. 验证(不做视觉验收)
1. `php -l` 全部改动的 PHP 文件
2. `node --check` captcha-front.js
3. `node tools/build-public-assets.mjs` 重建 bundle
4. `grep` 全仓清扫:无 `curl_init`、无 `'mode'`(captcha 相关文件)、无 legacy 标记
5. `node --test tests/widget-system/*.test.cjs` 全量跑,预期仅存量 page-display-contract 一项失败(与本次无关,已验证在 HEAD 上同样失败)
## 明确不做
- 不做视觉验收/浏览器实测(按要求)
- 不写新旧配置迁移、不清洗 DB 残留 `mode` 键
- 不动腾讯 trerror 容灾票据策略(维持拒绝)
- 不做阿里云签名 V3 升级、Turnstile appearance/retry 配置项(列为后续可选项)
- 不动 `ExtensionController` 的 extensions 状态端点(SPA 未消费属另一话题)
## 建议提交拆分(如需提交)
1. `fix(captcha): use WP HTTP API with TLS verification for tencent`
2. `feat(captcha): short-lived download pass + turnstile binding + error hardening`
3. `refactor(captcha): extract inline runtime to captcha-front.js bundle`
4. `chore(captcha): drop dead mode field, config cache, contract tests`
@@ -1,47 +0,0 @@
# 侧栏空槽/挤压修复(全站,单一决策源)
## 根因
"是否渲染侧栏列"有两套脱节的决策:
- **body 布局类**(`site-layout-1/2/3`,决定 CSS 留白)来自 `aksrt_is_show_sidebar_filter()`,但"小工具是否为空"检查只对 `is_single()` 生效(`inc/helpers/template-tags/site-layout.php:150-152`)。
- **aside 渲染**各自为政:固定首页 `aksrt_homepage_render_sidebar()` 额外检查小工具(`inc/helpers/homepage/content.php:146-162`),空时整个不渲染;`sidebar.php`(page/search/archive 用)却渲染空列 div("legacy parity")。
于是"开关开 + 无小工具":首页 body 仍是 `site-layout-2` → `layout.css:156-162` 保留 `margin-right: 侧栏宽+15px` → 主内容挤压、右侧 326px 空槽;其他页面则是空列占位同样挤压。后台关开关的路径本身自洽(layout-1),但因上述不一致,任何"列被保留而无内容"的场景都出现挤压。
参考 Zibll(`zib-theme.php` 判断 + `sidebar.php:14-16` 早退 + `main.css` 留白只挂 `site-layout-2/3`):判断集中一处,body class 承载几何,渲染与判定同源。AKSRT 骨架已具备,只缺"小工具为空"一维的全局化。
## 修改
### 1. `inc/helpers/template-tags/site-layout.php` — 核心(覆盖所有页面)
- `aksrt_is_show_sidebar_filter()`:把 `is_single() && !aksrt_has_current_sidebar_widgets()` 提升为无条件 `if (!aksrt_has_current_sidebar_widgets()) { return false; }`(空侧栏列 = 空槽,全部场景折叠为 layout-1;single 既有行为不变,home/category/tag/search/page/日期归档一并对齐)。
- `aksrt_resolve_sidebar_id()`:把 `is_home() || is_front_page()` 分支移到 `is_page()` 之前——静态首页目前解析成 `page_sidebar_{id}` 但渲染器输出 `home_sidebar`,判定/渲染不匹配;重排后一致(该函数仅 sidebar.php 与小工具检查两处调用,重排安全)。
### 2. `inc/helpers/homepage/content.php` — 删双路
- 删除 `aksrt_homepage_has_sidebar()`(修复后与 `aksrt_is_show_sidebar()` 完全等价);`aksrt_homepage_render_sidebar()` 守卫直接用 `!aksrt_is_show_sidebar()`。
### 3. `sidebar.php` — Zibll 式早退
- 顶部加 `if (!function_exists('aksrt_is_show_sidebar') || !aksrt_is_show_sidebar()) { return; }`(覆盖 `search.php:126` 的无条件 `get_sidebar()`)。
- 删除"Keep the column even if widgets are empty (legacy parity)"注释与空列行为(此后不可能渲染空列:is_show_sidebar 为真 ⇔ 至少一个 zone 有小工具);删除 `elseif ((is_home() || is_front_page()) && ...home_sidebar...)` 死分支与 `$sidebar_id` 兜底。
### 4. `inc/helpers/aksrt-archive.php` + `category.php` / `tag.php` — 死参数清理
- `aksrt_archive_has_sidebar(string $preferred = '', bool $enabled = true)` → `(bool $enabled = true)`(`$preferred` 自注释 "compat only" 后即 unset,死参数);删除 `return !wp_is_mobile();` 死兜底(主题已明确弃用 wp_is_mobile 布局门控,见 site-layout.php:141-143)。
- 更新调用点:`category.php:49`、`tag.php:30` 改为 `aksrt_archive_has_sidebar(!empty($archive_settings['show_sidebar']))`。
### 5. 死类/死代码清理
- `single.php:45`:删除无任何 CSS/JS 引用的 `container--no-sidebar` 输出。
- `front-page.php:14-29`:删除不可达的 `function_exists('aksrt_homepage_render_layout')` 兜底双路(`inc/bootstrap.php:33` 无条件加载),模板直接 `get_header(); aksrt_homepage_render_layout(); get_footer();`。
- `index.php:23-25`:注释"widget activity never changes the body layout after render"与新契约矛盾,更新为单一决策源描述。
- 保留:`layout.css:149-153` 的 `content-layout--no-sidebar` compat 规则(go.php/archive-resources.php/page-ban-appeal.php/page-user-home.php 仍在用)。
### 6. 测试
- `tests/widget-system/post-visual-contract.test.cjs:359-367`:更新测试名与 :364 正则为全局检查 `if \(!aksrt_has_current_sidebar_widgets\(\)\) \{\s*return false;`;新增断言钉住:homepage 渲染守卫用 `aksrt_is_show_sidebar()` 且 `aksrt_homepage_has_sidebar` 已不存在、sidebar.php 早退、resolver 中 home 分支先于 page 分支、archive helper 无 `$preferred` 参数。
- 运行 `node --test tests/widget-system/`(至少 post-visual-contract)与 `php -l` 全部改动 PHP 文件。
## 修复后行为矩阵(每场景验证推演)
| 状态 | body 类 | aside |
|---|---|---|
| 开关开 + 有小工具 | layout-2/3 | 渲染,留白匹配 |
| 开关开 + 无小工具 | **layout-1** | 不渲染,主内容占满(首页/分类/标签/页面/搜索/归档统一) |
| 开关关 | layout-1 | 不渲染 |
## 不改动
SPA schema(`sidebar_home_enabled` 等字段已齐全)、`get_sidebar_config()` 缺省语义、主题的响应式 CSS 隐藏策略(弃用 wp_is_mobile 的既有决定)、single 页既有判定(已被测试钉住,保持原样)。
@@ -1,53 +0,0 @@
## 现状结论(影响方案的既有事实)
- 下载系统集中在 `src/Content/Download.php`:路由前缀硬编码为常量 `ENDPOINT='aksrt-dl'`(同时用于 rewrite 正则 :68 和 URL 拼接 :226)、TTL 硬编码 `TOKEN_TTL=600`(:12)、`max_uses` 固定写死 1(:422)、`expires_in` 返回固定值(:242)。
- 扣次在"点击兑换"时发生(`Download.php:286-292`),无 HEAD 特判、无断开检测,需按需求重构。
- 过期/耗尽已是 `fail_410()`(HTTP 410 + wp_die 提示"回到资源页重新获取"),**不自动生成新链接** —— 该行为现状已满足,保持不变。
- `aksest_dl_token` 表(`src/Core/DBMigratorPart2.php:133-150`)的 token 列已是 `PRIMARY KEY`(即唯一索引),重复密钥不可能入库;需补的是 issue_token 插入失败时的换号重试。
- 设置体系是双端 schema:PHP 权威在 `src/Admin/Schema/*.php`,Vue 镜像在 `assets/admin/src/schema/sections/**`,菜单在 `assets/admin/src/constants/sections/permissions.js`,REST 用泛型路由自动生效。"功能&权限"= SPA 内 permissions 域。
- `Auth\Router::maybe_flush_rewrite_rules()`(:78-84) 已有"版本标记式 flush_rewrite_rules()"先例,可直接套用。
## 一、新增设置分区(permissions 域 → "下载配置")
存储为独立 option `aksrt_download_config`(沿用 captcha/moderation 的 standalone 模式),三个字段:
| key | 类型 | 默认 | 校验 |
|---|---|---|---|
| `url_alias` | input | `download` | 必填;trim 后非空;仅允许 `[A-Za-z0-9_-]`(禁中文/空格// \ ? # & 等);≤48 字符;拒绝保留字黑名单(wp-json、wp-admin、wp-content、wp-includes、admin、login、logout、register、lostpassword、feed、search、author、page、comments、sitemap、sign 等,防路由冲突/404) |
| `default_max_uses` | number | `1` | min 1 / max 100 |
| `ttl_minutes` | number | `10` | min 1 / max 1440 |
修改文件:
1. **`src/Admin/Schema/SchemaPermissions.php`**:新增 `download` section(version 1.0.0,option_key `aksrt_download_config`,单组 basic 三字段,含中文标签/help 说明"完整下载才计次、到期或耗尽后 410 需重新获取")。
2. **`src/Admin/SchemaProvider.php` `validate()`**:仿照 payment/withdrawal 特例先例(:269-292),对 `permissions/download` 增加中文错误消息校验块(空值/非法字符/过长/保留字)——422 错误经前端 `message.error('保存失败: …')` 弹窗呈现,满足"后台弹窗提示"。
3. **`src/Settings/Settings.php`**:`STANDALONE_OPTION_KEYS` 追加 `aksrt_download_config`(导入导出完整性 + 缓存失效覆盖)。
4. **`assets/admin/src/schema/sections/permissions/index.js`**:镜像新增 `download` 分区(defaults 与 PHP 完全一致)。
5. **`assets/admin/src/constants/sections/permissions.js`**:追加子页 `schemaPage({ key:'permissions-download', route:'/permissions/download', label:'下载配置', section:'download' })`,置于附件配置之后。泛型 REST 路由与 SchemaPage 无需改动。
## 二、Download.php 改造(`src/Content/Download.php`)
1. **动态前缀**:删掉 `const ENDPOINT`;新增私有 `endpoint()` 读配置(无效/被污染时回落 `download`);`register_rewrite()` 用 `preg_quote($endpoint)` 拼正则(:66-72);URL 拼接(:226)同源。
2. **别名变更刷新路由**:仿 `Auth\Router` 版本标记法——`init` 尾部挂 `maybe_flush_rewrite()`(priority 99):比较 `AKSRT_VERSION + 当前别名` 与 option `aksrt_dl_rewrite_version`,不一致则 `flush_rewrite_rules(false)` 并回写。导入/重置/直接改库都覆盖,无双路兼容逻辑。
3. **动态 TTL / 次数**:删掉 `TOKEN_TTL` 常量引用;`issue_token()` 写入 `max_uses = 配置.default_max_uses`、`expires_at = now + 配置.ttl_minutes*60`;`rest_request` 的 `expires_in` 改为动态秒数。配置读取做 clamp 兜底(别名非法回落 download、次数 1..100、分钟 1..1440)。
4. **完整下载才扣次**:
- 将扣次(UPDATE used_count)与文章计数 `_aksrt_res_download_count`(:286-300) 移到交付成功之后的新方法 `consume_token()`;UPDATE 加原子条件 `WHERE token=%s AND used_count < max_uses`,并发下计数封顶不超发。
- `stream_local_file()` 改为返回是否完整送达:输出循环中 `connection_aborted()` 为真立即中断并返回 false;收尾再核验一次。仅当完整送达且非 HEAD 才 `consume_token()`——网络波动中断不消耗次数,用户可重新点同一链接续下。
- **HEAD 探测**:`REQUEST_METHOD === 'HEAD'` 时照常走校验/输出 headers 但不扣次。
- **外链 302 源**(远端主机无法感知"是否下完"):把 302 交接视为一次交付,重定向发出后扣次(HEAD 时同样跳过)。此为唯一可实现语义,计划中明示。
- 过期/耗尽仍走现有 `fail_410()`(改动前后行为一致)。
5. **密钥冲突重试**:`issue_token()` 插入失败时用全新随机串重试至多 3 次(PRIMARY KEY 已保证唯一,此为极低概率兜底;表缺失仍在入口提前返回 WP_Error 不进循环)。
## 三、清理与文档
- 删除不再使用的 `Download::ENDPOINT` / `Download::TOKEN_TTL` 常量及全部残留引用(实现时全库 grep 复核;已知其余命中仅为 CSS 类名巧合,不动)。
- 更新 `CLAUDE.md:132` 文档行:rewrite 前缀改为"可配置别名(默认 download)",并补充"完整送达才计次"契约。
- 不写任何迁移/双路:现有表结构不动,存量过期 token 由每日 cron 自然清理。
- **关于唯一索引**:PRIMARY KEY(token) 本身即唯一索引且已存在(不加冗余 UNIQUE KEY,也不做表结构变更——与"不写迁移"一致),以插入重试落实需求的防冲突意图。
## 四、验证
1. 新增 Node 契约测试 `tests/widget-system/download-config-contract.test.cjs`:断言双端 schema 的字段/默认值/option_key 一致、菜单子项注册、Download.php 关键契约(动态 endpoint、流式完成后才条件 UPDATE 计次、HEAD 跳过、410 不自动续签、无 ENDPOINT/TOKEN_TTL 常量残留)。
2. `node --test tests/widget-system/download-config-contract.test.cjs` + 跑现有相关契约测试确认无回归。
3. `php -l` 全部改动的 PHP 文件;`php tools/audit-admin-contracts.php` 通过双端合同审计。
4. `cd assets/admin && npm ci && npm run build`,确认 build 产物完整(PHP 端有 chunk 缺失探测)。
5. 视觉不做真实验收(按约定跳过 Docker 对比环境)。
@@ -1,72 +0,0 @@
# 评论区 Zibll 1:1 全量复刻计划
目标:将 AKSRT 评论区渲染层(DOM、类名、布局、CSS、交互行为)替换为与 Zibll 完全一致,不保留旧类名、不写兼容/迁移代码。现有 REST 后端(`src/Core/CommentActions.php`)保留,仅前端选择器随之更新。
## 参考源(Zibll 侧,只读对照)
- `zibll/template/comments.php`(整体结构 L21–210)
- `zibll/inc/functions/zib-comments-list.php`(`zib_comments_list` 回调 L14、分页 L569–678)
- `zibll/inc/functions/functions.php` L1775 `zib_get_input_expand_but()`(表情/代码/图片/链接/快捷回复工具栏)
- `zibll/css/main.css` L9411–9660(桌面端)+ L12470–12560(移动端)
- `zibll/js/comment.js`(交互行为参照)
- `zibll/img/smilies/*.gif`(约 100 个表情资源)
## 改动清单
### 1. 评论列表项 DOM — `inc/helpers/comments/item.php` 重写
输出 Zibll 原始结构(去掉所有 `aksrt-comment__*` 类):
```
li.comment#comment-{id}(用 WP comment_class(),含 depth-N)
└ ul.list-inline > li.comt-main#div-comment-{id}
├ div.comment-header.mb10 > div.author-box.flex.ac
│ ├ span.avatar-img.comt-avatar > img.avatar(含嵌套 ul.avatar-icontag 徽章位)
│ ├ <name>(置顶 badge.badge-topping.jb-blue + a.text-ellipsis.font-bold / b.mr6 + 作者 badg)
│ └ a.action.action-comment-like.muted-2-color.flex0.ml10(svg like + <count>)
└ div.comment-footer
├ div.mb10.comment-content#comment-content-{id}
└ div.comt-meta.muted-2-color
├ span.comt-author(time-ago,title=完整时间)· IP 属地 badg · span @父级作者
├ span.badge-approve > span.badg.c-red.badg-sm「待审核」
├ span.reply-link > a.comment-reply-link(data-commentid / data-postid)
└ span.dropdown.drop-fixed-sm.padding-6(menu_2 ⋮ 菜单:置顶/编辑/通过/删除,wp-ajax-submit 类)
子级:li.comment > ul.children > li.comment…
```
保留 Zibll 的自定义语义标签 `<name>` `<badge>` `<text>` `<count>` 以确保 CSS 选择器 1:1 命中。置顶/点赞/神评等数据仍取自现有 `inc/helpers/comments/actions.php`。
### 2. 评论区外壳与表单 — `inc/helpers/aksrt-comments.php` + `comments/form-parts.php` 重写
- 外壳:`div.theme-box#comments` > `div.box-body.notop > div.title-theme`(+ `<small>共N条</small>`/抢沙发)> `div.no_webshot.main-bg.theme-box.box-body.radius8.main-shadow`
- 登录门槛:`div.comment-signarea.text-center` > h3 + 登录/注册按钮 + 社交登录(复用现有 oauth 输出,仅换外壳类名)
- 移动端假输入:`div.flex.ac.jsb.virtual-input`(`fixed-input="#respond"`)
- 表单 `div#respond.mobile-fixed > form#commentform`:
- `div.flex.ac > div.comt-title.text-center.flex0.mr10`(comt-avatar 60px + 昵称)+ `div.comt-box.grow1`
- `div.action-text.mb10.em09.muted-2-color`(回复@xxx)
- `textarea.form-control.grin#comment`(Ctrl+Enter 提交)
- `div.comt-ctrl.relative`:comt-tips(隐藏字段+nonce)、comt-tips-right(`a.but.c-red#cancel-comment-reply-link` 取消 + `button.but.c-blue.pw-1em.comment-send#submit`)、comt-tips-left(`span.dropup#comment-user-info` 游客姓名/邮箱/网址 line-form 下拉 + 工具栏)
- 工具栏按 Zibll `zib_get_input_expand_but()` 结构:`span.dropup.relative > a.but.btn-input-expand.input-{smilie|code|image|link|quick}.mr6` + `div.dropdown-menu`(表情面板 `div.dropdown-smilie.scroll-y.mini-scrollbar > a.smilie-icon[data-smilie] > img`),按现有设置项开关
- 列表:`div#postcomments > ol.commentlist.list-unstyled`,过滤器 `div.comment-filter.tab-nav-theme.flex.ac.jsb`(最新/最热 + 只看作者)放在 commentlist 之前、#postcomments 内
- 分页:AJAX 模式 `div.theme-pagination.lazyload.ias-pagenav.pagenav > div.order-ajax-next > a.ias-btn 加载更多`;数字模式 `div.pagenav` + paginate_links(>8 页带 `a.pag-jump` 跳页 input)
- 空状态:`zib_get_null()` 同款 `img/null.svg` 结构
### 3. CSS 全量移植 — `assets/css/frontend/comments/`
- 把 `zibll/css/main.css` L9411–9660 与移动端 L12470–12560 规则逐条移植为新文件(按现有 shell/list/form/mobile 拆分或单文件 `zibll-comment.css`),选择器保持原样(`#comments .comt-title`、`#postcomments .commentlist > .comment > .list-inline .comment-footer {margin-left:65px; margin-top:-25px}`、`.children {margin-left:86px}`、`.children .children {margin-left:23px}`、`.smilie-icon {width:30px!important}` 等)
- 依赖的通用变量(`--main-shadow/--main-border-color/--main-radius/--main-bg-color`)与基础类(`.avatar-img`、`.but`、`.badg`、`.line-form`、`.pagenav`)核对 AKSRT 是否已有;缺的补进对应基础样式文件,值与 Zibll 一致
- **删除** 现有 `comments/shell.css`、`comments/mobile-item.css`、`comment-tools.css` 中所有旧评论样式与 `--aksrt-comment-*` 变量,无兼容层
- 保持 enqueue 结构(`src/Core/ScriptsPart1b.php` / `ScriptsPart1c.php`)指向新文件
### 4. JS 选择器对齐
- `assets/js/core/comments.js`、`comment-ajax.js`、`comment-tools.js`、`comment-fixed-input.js`、`comment-move-form.js`:全部选择器改为 Zibll 目标(`.commentlist`、`.comment-reply-link`、`#div-comment-{id}`、`.comment-send`、`#respond.mobile-fixed`、`[fixed-input]` 等),提交后的局部插入逻辑改为 Zibll 同款(prepend 到 `.children` 或 `.commentlist`,`slideDown`,15s 冷却倒计时按钮禁用)
- REST 端点与 nonce 机制不变;AJAX 分页/排序沿用现有 fragment 管线,仅换容器选择器
### 5. 资源(按用户确认)
- **删除 `assets/img/smilies/` 中现有全部旧表情包资源**,从 `zibll/img/smilies/` 拷贝全套 gif 替换;`content.php` 的 `[g=]` 转换与 data-smilie 表情面板统一指向该目录的 Zibll 文件名列表
- 空状态 `null.svg`、点赞/置顶/菜单等 svg 图标按 Zibll 形状补齐到 `assets/img/`
### 6. 清理与验证
- 全库 grep `aksrt-comment__`、`--aksrt-comment-`、`.comments-area`(评论语境)确保零残留(用户中心页的评论条目不在本次范围)
- 检查 `tests/widget-system/post-visual-contract.test.cjs` 是否引用旧评论类名,如有则同步更新契约
- `php -l` 校验全部改动 PHP 文件;运行 `node --test tests/widget-system/post-visual-contract.test.cjs`
- 桌面+移动端断点目测核对 Zibll 截图/本地对照环境(`tests/visual/README.md`)
## 不做的事
- 不写任何双路渲染、旧类名兼容或数据迁移代码
- 不动 `src/Core/CommentActions.php` REST 逻辑、管理后台 Vue 设置页(除非契约字段需对齐)
- 不动论坛/商铺评论(Zibll 中亦为独立模块)
@@ -1,51 +0,0 @@
## 修复方案
### 1. 删除废弃的静态 action 入口
修改 `assets/js/runtime/main.js`:
- 完整删除全局 `_win.bd.on('click', '[data-action]', ...)` 委托。
- 完整删除仅被该委托调用的 `action_ajax()` 函数。
- 保留紧随其后的独立 `[ajax-action]` 处理器以及其他合法的 `data-action` 属性读取。
- 不创建 `assets/action/action.php`,不增加兼容代理,不把旧协议映射到新接口。
仓库内文章互动、评论点赞、关注等已有 WordPress AJAX/REST 实现,因此这是单一路径硬切换。
### 2. 精确同步运行时压缩产物
修改 `assets/js/runtime/main.min.js`:
- 从唯一 `_win.bd.on("click","[data-action]"` 起点,精确删除到下一个 `_win.bd.on("click","[ajax-action]"` 起点之前的旧压缩块。
- 不执行全量前端构建,以免覆盖或重新格式化其他已有运行时产物。
- 删除后确认 `[ajax-action]` 处理器仍存在,`action_ajax` 和 `/action/action.php` 均消失。
当前该文件虽然出现在状态列表中,但内容与 HEAD 完全一致,因此可安全形成仅针对旧块的内容差异。
### 3. 增加硬切换契约测试
新增 `tests/widget-system/runtime-action-hard-cut-contract.test.cjs`:
- 同时检查 `main.js` 与 `main.min.js`。
- 断言旧全局 `[data-action]` 委托不存在。
- 断言 `action_ajax` 不存在。
- 断言 `/action/action.php` 不存在。
- 断言独立 `[ajax-action]` 处理器仍保留且只有一份。
- 不禁止整个仓库使用 `data-action`,避免误伤下载、弹窗和其他新业务属性。
### 4. 验证
- 运行新增的 runtime action 契约测试。
- 运行全部 `tests/widget-system/*.test.cjs`。
- 使用 `rg` 检查源码和压缩产物中不存在 `action/action.php`、`action_ajax` 和旧委托。
- 执行 `git diff --check`,确认没有格式问题。
- 不做真实视觉验收,不恢复静态 PHP 直连入口。
### 5. 提交
从当前 `origin/main` 创建 `worktree/fix-legacy-action-404` 分支,只提交:
- `assets/js/runtime/main.js`
- `assets/js/runtime/main.min.js`
- `tests/widget-system/runtime-action-hard-cut-contract.test.cjs`
现有其他 CSS、JavaScript 状态项和 `.zcode/plans` 文件不进入提交。验证通过后推送该分支到远端。
@@ -1,51 +0,0 @@
## 目标
在后台「AKSRT → 功能&权限 → 下载配置」中新增总开关 `enabled`(switch,**默认开启**)。开启 = 现有本站 token 中转下载(`/download/{token}`,别名/次数/有效期生效);关闭 = 直连模式,下载按钮直接渲染原始下载地址,不再生成本站中转链接。**权限门控保留**(未登录/未购买/非会员仍显示解锁提示,已确认)。不写迁移、不留死代码、不做真实视觉效果验收。
## 现状(调研结论)
- 「下载配置」是 SPA 通用 schema 页(`SchemaPage.vue`),PHP schema 在 `src/Admin/Schema/SchemaPermissions.php:73-85`(option `aksrt_download_config`),前端镜像在 `assets/admin/src/schema/sections/permissions/index.js:246-269`
- 下载链路:按钮 `data-action="aksrt-download"` → `POST /aksrt/v1/download/request`(`src/Content/Download.php:215`)签发 token → 返回 `home_url('/别名/{token}')` → rewrite + `maybe_serve_download()` 中转(本地流式/302 外链)
- 发现死代码:`rest_policy()` + `/download/policy` 路由(全库无调用方)、`aksrt_is_download_alone_page_enabled()`(`inc/helpers/aksrt-pay.php:921`,无调用方)
## 变更清单
**1. `src/Admin/Schema/SchemaPermissions.php`(PHP 权威 schema)**
- download section version 1.0.0 → 1.1.0
- groups 重构(仿 login/moderation 模式):新 `basic` 组「下载总开关」,字段 `enabled`(switch,default true,help 说明「关闭后下载采用直连,下方链接规则不生效」);原 `url_alias`/`default_max_uses`/`ttl_minutes` 移入 `rules` 组「链接规则」,各加 `dependsOn: {key: 'enabled', value: true}`
**2. `assets/admin/src/schema/sections/permissions/index.js`(前端镜像)**
- `download.defaults` 增加 `enabled: true`;groups 同步 PHP 双组结构(switch + dependsOn);更新 description
**3. `src/Content/Download.php`**
- 新增 `public function is_enabled(): bool`:读 `aksrt_download_config`,`enabled` 键缺失视为开启(默认 true),否则 `!empty()` 判定(与 PostDisplay 等现有默认 true 惯用法一致)
- 关闭时提前 return:`register_rewrite()`(不注册路由)、`register_routes()`(不注册 `/download/request`)、`maybe_serve_download()`(旧 token 链接失效,含 `?aksrt_dl_token=` 直查)
- **删除死代码**:`rest_policy()` 方法与 `/download/policy` 路由注册
- token 签发/流式输出等开启路径逻辑不动(现有契约测试正则不受影响)
**4. `templates/parts/resource-download-box.php`**
- 顶部计算 `$direct_download`(`class_exists` 守卫 + `is_enabled()` 取反)
- 下载源循环:直连模式且非 need_login 时,渲染 `<a href="{原始url}" target="_blank" rel="nofollow noopener">`(保留网盘识别徽章/着色),提取码旁复用全局 clipboard 委托渲染复制按钮(`data-clipboard-text`,main.min.js 已全局处理);need_login 分支不变
- 直连模式下 `$captcha_active` 置 false(验证码容器成死 UI)
- 独立下载页入口(`down_alone_page`)是独立功能,不与本开关耦合
**5. `inc/helpers/aksrt-pay.php`**
- `aksrt_get_paid_download_actions_html()`:直连模式渲染 `<a class="but ...">` 直链替代 token 按钮;提取码/复制按钮逻辑不变
- **删除死代码** `aksrt_is_download_alone_page_enabled()`
**6. `tests/widget-system/download-config-contract.test.cjs`(扩展契约测试)**
- 字段对齐测试加入 `enabled => true`(PHP)/ `enabled: true`(JS)
- 新增断言:Download.php 含 `is_enabled` 守卫;`/download/policy` 已移除;直连模式下盒模板与付费卡函数含直链 `<a>` 渲染分支
## 行为语义(写入代码注释与测试预期)
- 默认开启,存量站点零感知(不迁移 option,键缺失即 true)
- 关闭后:无下载计次、无免费额度扣减、无下载就绪通知、无验证码——直连语义的自然结果
- 关闭后旧 `/download/{token}` 链接全部失效
## 验证
- `php -l` 检查所有改动的 PHP 文件
- `node --test tests/widget-system/download-config-contract.test.cjs`,并跑 pay-card-contract、post-visual-contract 确认无回归
- `cd assets/admin && npm run build` 重建 SPA(前端 schema 变更需构建生效);若 `assets/admin/build/` 被 git 跟踪则一并纳入变更
- 按要求跳过真实视觉效果验收
@@ -1,33 +0,0 @@
## 目标
把"点关注"从同步跑完整结算链(积分+勋章×3遍+等级,约几十条 SQL)优化为:接口只写关注表立即返回,结算在响应送达客户端之后执行。
## 改动内容(3 个文件)
### 1. `src/User/Metrics.php` — 删除冗余订阅
`on_user_followed()`(Metrics.php:93-98)只做 `reevaluate_medals(following_id)`,而 `BadgeAutoAward::on_follow` 已经评估过该用户——这是同一请求内的第 3 遍重复评估。删除该方法和它的 `add_action` 注册(Metrics.php:34)。`reevaluate_medals` 方法本身保留(点赞/浏览等其他钩子仍在用)。
### 2. `src/User/BadgeAutoAward.php` — 删除对"关注者"的无效评估
`on_follow()`(BadgeAutoAward.php:61-68)同时评估关注者和被关注者,但 `Badges::metric_value` 的全部指标分支(registration_time/checkin/new_post/comment/**followed(被关注数)**/like/views/pay_income/pay_rebate)没有任何一个依赖"关注了多少人"——关注者评估必然零产出。改为只评估 `$following_id`(保留双参数签名以兼容钩子,`unset($follower_id)`)。
### 3. `src/User/Follow.php` — 结算钩子延迟到响应之后
`follow()` 里的 `do_action('aksrt_user_followed', ...)`(Follow.php:50)改为经 `register_shutdown_function` 延迟执行:关闭回调里先调 `fastcgi_finish_request()`(PHP-FPM 下存在)把响应立即送达客户端,再触发钩子跑积分/勋章/等级结算。新增一个 `private static function defer_action(string $hook, int ...$args)` 辅助方法。
- 非-FPM 环境(无 `fastcgi_finish_request`):退化为 shutdown 时机同步执行,行为与现状等价,无回归。
- 不用 `wp_schedule_single_event`:WP-Cron 粒度不可控(禁用循环回执的站点要等系统 cron,积分勋章到账会延迟数分钟),shutdown 方案在所有环境都能即时结算。
- `aksrt_user_followed` 仅 Follow.php:50 一处触发,4 个订阅者全部按传入 ID 工作、不依赖请求上下文,延迟执行安全。
- 效果:关注接口响应路径只剩 WP REST 启动 + 1 次 SELECT + 1 次 INSERT + 1 次 COUNT(全部命中索引),勋章评估从 3 遍降为 1 遍且不再阻塞响应。
## 不在本次范围
前端乐观更新(点击瞬间翻转按钮、失败回滚)——上轮标注为可选项,用户未明确要求;异步化后反馈延迟已大幅下降,且与全站点赞/收藏的交互模式保持一致。需要可后续单独做。
## 验证
- `php -l` 检查 3 个改动文件。
- 浏览器实测:关注点击后按钮/Toast 应明显变快(仅剩接口往返);稍后刷新可见积分/勋章/等级照常到账(结算仍在同一请求生命周期内完成,只是后置)。
- 取消关注路径不受影响(本就无钩子)。
## 提交
单条提交:`perf(user): 关注结算异步化并去除冗余勋章评估`。
@@ -1,48 +0,0 @@
# 修复前台文章卡片无刷新加载出现空位的问题
## 根因
规范分页控件(`aksrt_render_pagination()` 输出的 `.ajax-pag[data-aksrt-pagination]`,内含 `.next-page.ajax-next`)携带了遗留引擎的触发类 `ajax-next`。全站加载的 `main.min.js` 在 body 上委托监听 `.ajax-next` 点击(另有 IAS 无限滚动路径用 jQuery 模拟点击 `.ajax-next/.ias-btn`),导致每次"加载更多"被两套引擎同时处理:
1. **规范引擎**(pagination.js):POST `aksrt_load_more_posts`,把卡片正确插入 `.ajax-pag` 之前;
2. **遗留引擎**(main.min.js `post_ajax()`):抓取整页 `?paged=2` HTML,**先删除 `.ajax-pag`**,把占位加载器、重复的一批卡片和一个新的**块级** `.ajax-pag` div 插到最后一张卡之后。
卡片网格是 inline-block 布局(`width: calc(33.333% - 16px)`),块级分页 div / 重复批次插入流中会强制换行 → 网格中出现空位(即"隔出一个卡片位置"),且第 2 页内容被加载两次。评论分页(`inc/helpers/template-tags/search-meta.php:533`)已用 `order-ajax-next` + `ias-btn` 避开遗留引擎,文章分页是唯一例外。
另经确认:生产环境实际生效的懒加载运行时是 main.min.js 内置版(`assets/js/lazysizes.js` 无任何 enqueue),其 IAS 自动加载走 jQuery 模拟点击,只到得了遗留引擎——所以无限滚动模式目前完全由遗留引擎执行。若只摘掉 `ajax-next` 类而不接管 IAS 触发,无限滚动会失效,因此两处改动必须配套。
## 修改内容(4 个文件)
### 1. `inc/helpers/template-tags/pagination-permissions.php:123`
`.next-page` 包装类的 `ajax-next` → `order-ajax-next ias-btn`:
- 移除遗留引擎的点击触发 token(手动点击与 IAS 模拟点击双双失效于遗留引擎);
- `order-ajax-next` 复用现有按钮样式 `.theme-pagination .order-ajax-next a`(main.css:9659+),**零 CSS 改动**;
- `ias-btn` 保持 main.min.js 的 IAS 匹配器仍能找到元素(其后续 jQuery 点击成为无害空操作),且 pagination.js 的 clickTarget 选择器 `.next-page.ajax-next, .ias-btn` 原生支持,**零 JS 选择器改动**;
- 与评论分页的既有模式完全一致。
### 2. `assets/js/core/pagination.js`
在点击绑定块后新增容器级 `lazybeforeunveil` 监听(仅当服务端输出了 `lazyload` 契约类时挂载):unveil 事件由 main.min.js 内置 lazySizes 原生派发(可冒泡),监听到后设置 `lazyAuto` 并原生 `click()`,把无限滚动自动加载**收归规范引擎**的既有点击管线(复用 loading 守卫、iasMax 上限、按钮状态机):
```js
if (container.classList.contains("lazyload") || clickTarget.classList.contains("lazyload")) {
container.addEventListener("lazybeforeunveil", function (e) {
if (e.target !== container && e.target !== clickTarget) return;
clickTarget.dataset.lazyAuto = "1";
clickTarget.click();
});
}
```
附带修复:评论分页的 IAS 自动加载此前同样到不了规范引擎(jQuery 模拟点击不可见),此监听一并接管。
### 3. `search.php:112-118` — 消除双层包裹
去掉外层冗余 `<div class="posts-row mtn6">`,把 `mtn6` 直接传入 `aksrt_render_post_list(['wrapper_class' => 'mtn6'])`。这样规范引擎的 `previousElementSibling` 目标解析落在真正的卡片网格上,追加的卡片与首页批在同一 inline-block 网格内连续排布(此前新卡片会追加为内层网格块的兄弟节点,卡片模式下在分页边界处出现空位)。ajax-list.js(搜索 tab 切换的实际引擎)用 `querySelectorAll('.ajax-item')` 提取、其替换流程本身就会把卡片拍平到容器层,结构兼容。
### 4. `tests/widget-system/post-visual-contract.test.cjs:238`
- 断言 `/class="next-page ajax-next/` → `/class="next-page order-ajax-next ias-btn/`;
- 新增 `assert.doesNotMatch(php, /next-page ajax-next/)` 锁定规范控件不再携带遗留触发类;
- 新增 `assert.match(js, /lazybeforeunveil/)` 锁定 IAS 接管。
## 验证
- `php -l inc/helpers/template-tags/pagination-permissions.php` 和 `php -l search.php`
- `node --test tests/widget-system/post-visual-contract.test.cjs`
- 浏览器实测(桌面 3 列卡片网格):首页点"加载更多"只发一个 `admin-ajax` 请求、无重复批次、无网格空位、`.ajax-pag` 始终位于网格下方;无限滚动模式每次自动加载一页并在 iasMax 后转手动按钮;搜索页卡片模式追加后与首批网格连续。
@@ -1,46 +0,0 @@
# 评论区硬切换:1:1 复刻 Zibll(不含表情包/快捷回复)
## 现状与结论
- AKSRT 评论区的 PHP 渲染(`inc/helpers/comments/item.php`、`form-parts.php`、`aksrt-comments.php`)与 JS(`assets/js/core/comments.js` 等走 `aksrt/v1` REST)已是自有代码,DOM 与 Zibll 基本一致(`li#comment-{id} > ul.list-inline > li.comt-main`、`.comment-header/.comment-content/.comt-meta`、`#commentform/.comt-title/.comt-box/.comt-ctrl` 均已对齐)。
- **样式仍由 vendored `assets/css/main.min.css` 提供**(拼进全站 `build/public.css`;`comments/shell.css` 头部明文"以 vendor 正版样式为准、禁止复制")——这是本次硬切换的主体:样式归属权切到自有文件,vendor 副本剥离。
- 快捷回复链路完整存在(schema/PHP/JS/CSS),需全链路删除;表情包已被此前的 commit 删除并有契约测试锁定,保持。
- 传输层不动:提交/点赞/编辑/删除/置顶仍走 AKSRT REST(含权限回调),不改回 Zibll 的 admin-ajax 协议;UI 与 DOM 对齐 Zibll。
## 1. CSS 硬切换(主体)
- 以参考主题 `C:\Users\Xing\Desktop\zibll\css\main.css` 为准移植评论区规则(与 AKSRT vendored 副本 diff 后以参考版为准):
- 新建 `assets/css/frontend/comments/layout.css`:桌面主段(AKSRT main.css 约 9273–9510 对应段:`#comments/#postcomments/#respond/#comment/.commentlist/.comt-*/.comment-*/.comment-signarea/.children/.comment-footer 缩进` 等)+ 散点规则(`.action-comment-like.action-animation`、`.comment-content .box-img`、`.comment-content a`、`.comment-content pre`)。
- 填充 `assets/css/frontend/comments/mobile-item.css`(现为空壳占位):移动端 `@media` 内评论规则(`.mobile-fixed/.virtual-input/头像缩进/分页` 等,main.css 约 12208 起)。
- `shell.css` 重写文件头教条(不再"vendor 提供"),保留 identity chip/medal 补充规则。
- **剔除**:`.dropdown-smilie/.smilie-icon/.dropdown-quick-often` 与 `img:not(.smilie-icon)` 类选择器。
- 从 vendored `assets/css/main.css`/`main.min.css` 剥离同批评论区域专属规则(死代码清理、消除双路)。保留:`.comment-widget-item/.comment-mini-lists`(侧栏小部件在非 singular 页使用)及共享选择器(`.pagenav/.theme-pagination/.line-form/.author-box/.avatar-img` 等别处仍用)。
- 先校验 `main.min.css ≙ minify(main.css)`(esbuild dry-run diff,防漂移),剥离后重新生成 `main.min.css`,再跑 `node tools/build-public-assets.mjs` 重出 bundle。
## 2. Markup 1:1 对齐(PHP 小增量)
- `item.php`:点赞按钮对齐 Zibll `zib_get_comment_like`(SVG 图标 + `<text></text><count>N</count>`,保留 AKSRT `data-comment-action` 接线);置顶/批准/菜单/注册图标由 FA 换成 Zibll SVG——新增小助手(如 `inc/helpers/comments/icons.php`)移植 `zib_get_svg` 所需 glyph(like/topping/approve/menu_2/signup);时间/回复链接 tooltip 属性对齐。
- `aksrt-comments.php` + `form-parts.php`:
- `!comments_open()` 时整体不渲染(Zibll 行为;当前会渲染标题+列表)。
- 工具栏渲染 `code→image→link`,补齐缺失的"链接"按钮(新设置 `comment_link`,默认关——Zibll 默认值即 false)。
- 游客 dropup 补"博客地址"字段(新设置 `comment_user_url_s`,默认关,同 Zibll)。
- `comt-title` 登录态昵称旁渲染 AKSRT 身份徽章(对应 Zibll VIP 图标位)。
## 3. 快捷回复全链路删除 + 死代码清理
- PHP:`form-parts.php` quick case 与工具栏分支;`aksrt-comments.php` context 的 `quick_s/quick_phrases`;`PostDisplay.php` 的 `quick_phrases` 字段与 `parse_comment_quick_phrases()`;`SchemaPostList.php` 镜像字段。
- Vue schema:`assets/admin/src/schema/sections/post-list/comment.js` 删 `comment_quick_s/comment_quick_often`。
- JS:`comment-tools.js` 快捷短语插入逻辑;`ScriptsPart1c.php` 删 `aksrtData.commentQuickPhrases`。
- 删除死文件:`assets/js/runtime/comment.js(+.min)`、`input-expand.js(+.min)`(已确认无代码引用)。
- `aksrt-runtime.js` 的 vendor comment 模块 stub 与委托解绑**保留**(vendor `main.min.js` 仍在全国 bundle,防回潮)。
## 4. 设置 schema(前后端镜像)
- 新增 `comment_link`、`comment_user_url_s` 开关(默认 false),镜像到 `SchemaPostList.php` 与 `PostDisplay::get_comment_display_settings()`;`npm run build` 重出 admin SPA。
## 5. 测试
- 更新 `comment-smilies-removal-contract.test.cjs`(文件清单移除已删 runtime 存档)。
- 新增 `tests/widget-system/comment-hard-cut-contract.test.cjs`:自有 comments CSS 含关键选择器清单;vendor main.css/min 不再含评论区选择器、仍含 `.comment-widget-item`;全库无快捷回复 token(`comment_quick_s/quick-reply-item/dropdown-quick-often/commentQuickPhrases/quick_phrases` 等);死文件不存在;markup 契约(`<count>`/SVG、工具栏 code/image/link 且无 quick/smilie)。
## 6. 构建与验证(不做视觉验收)
- `php -l` 全部改动 PHP;`node --test` 相关契约测试;`node tools/build-public-assets.mjs`;`npm run build`(admin)。
- 不启用 Docker 视觉对比环境(按用户要求)。
## 风险与对策
- vendor main.css 手术:先 min-sync 校验;剥离仅限评论专属选择器,逐个 grep AKSRT 模板确认无跨区使用;剥离后选择器清单复核。
- 评论区规则的 CSS 变量(`var(--…)`)与 bootstrap 类来自 bundle 中更早加载的 tokens.css/bootstrap,`aksrt-comments` 在其后加载,无影响。
@@ -1,51 +0,0 @@
# 公开用户主页去参数化 + 全链路无刷新加载
## 目标
`/author/demo/` 的 URL 永远干净(无 `?tab=`、`follow_sub`、`follow_page`、`posts_page` 等),Tab 切换、粉丝/关注子切换、各面板分页全部走 REST AJAX 无刷新加载。单路径实现,不写双路/兼容/迁移代码。
现状:主 Tab 已是 DOM 切换,但 `author.js` 会把 `?tab=` 写进 URL;follow 子切换和 comments/follow 分页是带参整页链接;posts/favorites 分页走 `aksrt_render_pagination()`(numbered 模式产出 `?posts_page=N` 链接;load_more/infinite 走 admin-ajax)。
## 服务端
### 1. 上下文构建 — `inc/helpers/aksrt-user-home.php`
- `aksrt_get_user_home_page_context()` 删除全部 `$_GET` 读取(第 104、134-136 行):`active_tab` 恒等于 `default_tab`,三个 page 恒为 1(保留键,供 REST 覆写);新增 `follow_page => 1`、`follow_sub => 'followers'` 键。
- 新增 `aksrt_render_user_home_pagination(int $total, int $current)`:输出 `<nav class="profile-pagination uh-pagination">` + `<button type="button" class="page-numbers" data-uh-page="N">`(当前页带 `current` 类,含 `‹ ›` 前后页),复用现有 `.profile-pagination` 样式(lists.css:331+)。
- 新增 `aksrt_capture_user_home_panel(string $tab, array $ctx): string`:`locate_template('templates/parts/user-home/panel-{$tab}.php')` + ob_start 捕获(仿 `aksrt_capture_user_center_panel`,aksrt-user-center.php:199)。
### 2. 面板模板重构 — `templates/parts/user-home/`
- 删除 `panels-core.php`,拆为按面板 body 模板:`panel-posts.php`、`panel-comments.php`、`panel-favorites.php`、`panel-credits.php`;`panel-follow.php` 改为纯 body(去掉外层 wrapper,第 21-23 行 `$_GET` 读取改为从 `$args` 取 `$follow_page`/`$follow_sub`)。
- `page-user-home.php`:dashboard 根节点加 `data-user-id="<?php echo (int) $user_id; ?>"`;循环 `$enabled_tabs`,对存在 body 模板的 tab 输出 `<div id="panel-{tab}" class="dashboard-panel…">` wrapper + `get_template_part('templates/parts/user-home/panel', $tab, $ctx)`。
- 各 body 分页统一改用 `aksrt_render_user_home_pagination()`:
- posts/favorites 不再用 `aksrt_render_pagination()`(本页退出全站三模式分页体系);
- comments 不再用 `aksrt_render_profile_pagination()`(该函数保留,用户中心仍在用)。
- `panel-follow.php` 子切换 `<a href="?tab=follow&…">`(第 40、47 行)→ `<button type="button" data-uh-sub="followers|following">`;`paginate_links`(第 63-69 行)→ 新分页渲染器。
### 3. REST 端点 — `src/User/Home.php`(仿 `src/User/Center.php:34-110`)
- `init()` 挂 `rest_api_init`;路由 `GET aksrt/v1/user-home/panel/(?P<user_id>\d+)/(?P<tab>[a-z0-9_-]+)`,query 参数 `page`(int ≥1)、`sub`(followers|following),`permission_callback => '__return_true'`(公开数据)。
- `rest_panel()`:校验用户存在 → `aksrt_get_user_home_page_context()` 重建上下文 → 用请求参数覆写 `active_tab`、对应 page、`follow_sub` → tab 不在 `enabled_tabs`(如 credits 非本人)返回 404 → 捕获 body → 返回 `{success:true, data:{tab, html}}`。
## 前端 — `assets/js/author.js`
- 删除 `updateTabUrl()`(第 15-24 行)与 `resolveInitialTab()`(第 70-73 行):不再读写任何 URL 参数,初始态以服务端渲染的 `is-active`/`data-default-tab` 为准。
- 新增 `loadPanelBody(tab, page, sub)`:`fetch(aksrtData.apiUrl + '/user-home/panel/{data-user-id}/{tab}?page=N(&sub=X)', {credentials:'same-origin', headers:{'X-WP-Nonce': aksrtData.restNonce}})` → `panel.innerHTML = data.html` → `AksrtFrontend.rehydrate(panel, 'ajax')`;per-panel 请求去重防竞态(仿 user-center.js `_aksrtPanelRequest`);`is-loading` 态 + 失败 notyf 提示。
- dashboard 根上事件委托(覆盖 AJAX 注入的新按钮):
- `[data-uh-sub]` → 加载 follow 面板 `sub=X, page=1`;
- `[data-uh-page]` → `btn.closest('.dashboard-panel')` 得 tab,`panel.dataset.uhSub`(默认 followers)得子项,加载对应页。
- URL 永不变更(无 pushState/replaceState)。`follow.js` 是 document 级委托,注入的 minicard 关注按钮自动可用,无需改动。
## 附带清理与 CSS
- `templates/parts/submit/editor-main.php:106-108`:「查看草稿」现指向公开主页 + `?tab=post&post_status=draft`(公开主页从未支持该参数,本就错位)。改为 `add_query_arg('post_status', 'draft', aksrt_get_user_center_tab_url('posts'))`(用户中心支持该过滤,posts-list.php:14-16)。
- `assets/css/routes/user-home.css`:`button.page-numbers` 复位 + hover 态(现 hover 选择器只覆盖 `a.page-numbers`);`.uh-follow__subtab` 改 button 后的复位;`.dashboard-panel.is-loading` 半透明过渡。
## 测试与验证
- 更新 `tests/widget-system/post-visual-contract.test.cjs`:移除 `panels-core.php`/`page_var` 断言(第 39、205、222-224、328-330 行),改为断言:各 `panel-*.php` body 模板存在且调用 `aksrt_render_user_home_pagination`;user-home 模板无 `$_GET`/`add_query_arg`;author.js 无 `searchParams`(防回归契约)。
- `php -l` 全部改动 PHP 文件;`node --test tests/widget-system/post-visual-contract.test.cjs`。
## 明确的行为变化(单路径的直接结果)
- 公开主页 posts/favorites 分页不再跟随后台「文章分页模式」(numbered/加载更多/无限滚动)设置,统一为本页 AJAX 数字分页。
- 刷新/分享/回退永远回到默认 Tab 第 1 页(URL 无状态,需求本身决定)。
- 旧外链 `?tab=follow` 不解析不重定向,按默认 Tab 加载(无兼容代码)。
@@ -1,42 +0,0 @@
# 复刻 Zibll 粉丝/关注用户列表(公开主页 follow TAB)
## 问题根源
`templates/parts/user-home/panel-follow.php:46-65` 手写的列表 markup(`.uh-follow__list/item/avatar/name`)在全仓库没有任何 CSS 定义,头像+用户名以内联文本堆叠显示。列表项也只有头像和用户名,没有 Zibll 的卡片结构。
关键事实:AKSRT 已 vendored Zibll 的 `main.css`(`.author-minicard` 卡片规则、工具类齐全),且 `inc/helpers/aksrt-graphic-card.php:215` 已有 `aksrt_author_minicard()` —— Zibll `zib_author_card()` 的移植版(42px 懒加载头像、用户名+勋章/认证/等级图标、逐项关注按钮、个性签名一行省略、默认文案"这家伙很懒,什么都没有写…"),搜索页在用。**修复 = 粉丝/关注列表改走这条既有链路**,不新写卡片样式。
## 改动清单
### 1. `templates/parts/user-home/panel-follow.php`(核心)
- 列表循环改为 `aksrt_author_minicard((int) $row['user_id'])`,删除手写的 `.uh-follow__item/avatar/name` markup(渲染链路与搜索页用户列表完全一致)。
- 子切换复刻 Zibll 结构(`zib-author.php:407-410`):居中文字链接「粉丝 N | 关注 N」(`list-inline splitters` + 激活 `focus-color` / 非激活 `muted-color`),**顺序改为粉丝在前、默认子列表改为粉丝**(Zibll 逻辑:TAB 名"粉丝"、默认显示粉丝列表,`zib_get_follow_user_list` 默认 `type='followed'`)。链接仍走现有 `?follow_sub=&follow_page=` 路由。
- 空状态图标换心形 SVG(见第 6 条),继续用 `aksrt_render_profile_empty` 组件(对齐 Zibll null-love 爱心语义)。
- 分页保持 `paginate_links` 服务端翻页:Zibll 的 AJAX"加载更多"框架(post_ajax/ajax-next)未移植进 AKSRT(main.js 仅 27 行),且公开主页所有 TAB 统一为服务端渲染 + URL 参数翻页,不为此引入独立 AJAX 体系。
### 2. `inc/helpers/aksrt-graphic-card.php` — `aksrt_author_minicard()`
- 关注按钮改为**服务端渲染真实状态**(复刻 Zibll 逻辑):已登录且 `\AKSRT\User\Follow::is_following(current, target)` 为真时输出 `is-following` class +「已关注」文案。消除每次进页"全部显示关注→异步闪变"的问题。搜索页同样受益。
### 3. `templates/parts/user/author-hero.php`
- hero 关注按钮(L151-163)同样服务端渲染真实初始状态(`is-following` +「已关注」),与卡片按钮同一逻辑。
### 4. `assets/js/follow.js`
- 删除逐按钮 `/follow/status` 轮询(L54-69):服务端已输出真值,不留双路。保留点击 toggle、`updateFollowButton` 状态切换、用户中心取关移除行、计数更新。
### 5. `assets/css/routes/user-home.css` — follow 区样式替换
- `.uh-follow__list { text-align: center; }`:对齐 Zibll pane 的 `text-center`(卡片为 inline-block,自动 3 列居中换行,移动端单列规则 main.css 已有)。
- 子切换:删除旧 pill 按钮样式块(L486-519),替换为 Zibll 风格的少量布局规则(居中、间距),颜色/激活态复用 vendored 工具类。
- 同步删除不再被引用的 `__subtab-count` 等旧规则。
### 6. `inc/helpers/aksrt-user-home.php`
- context 数组新增 `svg_empty_follow`(心形 stroke SVG,风格同现有 `svg_empty_post` 等)。
## 不做的事(明确排除)
- 不引入 AJAX"加载更多"(无框架基础,破坏公开主页统一服务端分页架构)。
- 不动数据层 `src/User/Follow.php`(`get_user_list` 已提供所需 user_id;徽章/签名由 minicard 自行取)。
- 不写任何双路、兼容、迁移代码:旧的手写列表 markup 和 pill 子切换样式直接替换删除。
## 验证
- `php -l` 检查全部改动的 PHP 文件。
- `node --test tests/widget-system/*.test.cjs` 确认不破坏既有契约(无该面板专属测试)。
- 桌面 + 移动宽度下目视核对:卡片 3 列/单列、关注按钮初始状态、子切换、空状态、分页。
@@ -1,34 +0,0 @@
## 目标
将 `inc/core-hooks/` 下的三个分片收口为唯一实现文件 `inc/core-hooks.php`,不保留兼容 loader、旧分片 stub 或双路加载;同时清理有充分证据确认无效的代码,不做真实浏览器/视觉效果验收。
## 实施步骤
1. **合并实现文件**
- 将 `inc/core-hooks/part-1.php`、`part-2.php`、`part-3.php` 的有效实现按现有加载顺序拼接到 `inc/core-hooks.php`。
- 保留现有 `defined('ABSPATH') || exit;` 保护和原有 hook 注册顺序,避免改变 WordPress 回调优先级、字符串回调和匿名闭包的行为。
- 在合并文件中保留评论过滤、SMTP/邮件、分类 term meta、资源归档/搜索、Enlighter 白名单、后台互动列、Gutenberg、付费内容等仍被运行时代码或测试覆盖的实现。
- 去除原文件的 “Shell loader / split into sub-files” 说明和三个 `require_once`,将文件头改为描述单一核心 hooks 文件的注释,避免留下已不存在的拆分结构说明。
2. **清理明确死代码**
- 删除 `part-2.php` 中注册 `wp_head` 调用未定义 `aksrt_print_win_var()` 的 legacy runtime bridge;仓库内只有该调用,没有定义、生产者或测试,且 `function_exists()` 使其当前为空操作。
- 删除 `part-3.php` 中原样返回模板的 `single_template` no-op 过滤器;它不改变模板选择,且没有其他实现依赖。
- 删除随之失去运行时意义的纯注释占位块(评论提交说明),不触碰实际由 `Moderation` 等模块实现的评论校验。
- 不删除后台互动列,尽管它读取旧 meta key;这是可见后台行为,且证据只支持“过时/可能失配”,不足以在本次合并中安全移除或重写。也不删除分类旧字段、资源路由、块注册等看似重复但仍有明确消费者的代码。
3. **删除旧分片**
- 删除 `inc/core-hooks/part-1.php`、`inc/core-hooks/part-2.php`、`inc/core-hooks/part-3.php`,使 `inc/core-hooks.php` 成为唯一实现,不做迁移层或双路兼容。
4. **同步测试引用**
- 将以下契约测试中对分片的直接读取改为 `inc/core-hooks.php`:
- `tests/widget-system/email-system-contract.test.cjs`
- `tests/widget-system/pay-card-contract.test.cjs`
- `tests/widget-system/post-visual-contract.test.cjs`
- 不改测试断言语义,仅修正因文件收口导致的路径。
5. **验证**
- 对新的 `inc/core-hooks.php` 运行 `php -l`。
- 运行上述三个 Node 契约测试,并运行 `tests/widget-system/run.php` 作为已有回归合同检查(若环境依赖导致失败,如实报告)。
- 用 `rg` 检查生产代码和测试中不再残留 `core-hooks/part-*.php` 路径;排除 `.git`、`vendor`、`node_modules` 和历史 `.zcode/plans` 文件。
- 不启动浏览器、不执行真实视觉效果验收,符合请求约束。
## 工作区安全边界
当前未提交修改仅涉及 `assets/css/build/*`、`assets/js/runtime/*` 和 `.zcode/plans/*`,不涉及本次目标文件;实施时只修改上述 core hooks 文件及三个测试文件,不覆盖或清理现有其他改动。
@@ -1,65 +0,0 @@
# assets/vendor 全量迁移 + 全仓去 zibll 化
## 目标布局(类型归位,`git mv` 保留历史)
| 源 | 目标 | 说明 |
|---|---|---|
| `assets/vendor/fontawesome5/` | `assets/fontawesome5/` | css/ + webfonts/ 原结构,`../webfonts` 相对引用不变 |
| `assets/vendor/zibll/css/`(22 文件) | `assets/css/` 顶层 | bootstrap/animate/swiper/main 等,无命名冲突;`main(.min).css` 的 `url(../img/playing.svg)` 迁后恰好仍指向 `assets/img`,无需改 |
| `assets/vendor/zibll/js/**` | `assets/js/runtime/**` | **必须子目录**:assets/js 已有 14 个同名自有文件(author/sign-register/page-navs…)和一个 enlighter/ 目录 |
| `assets/vendor/zibll/img/captcha/` | `assets/img/captcha/` | slidercaptcha.js 运行时按 `_win.uri + '/img/captcha/'` 拼接,新 uri=assets 根后自然命中 |
| `assets/vendor/zibll/img/{medal,smilies}` | 与 `assets/img/` 哈希比对 | 相同跳过、缺失补齐;**内容不同时以当前实际渲染源为准**(medal/smilies 保现有,user_t.jpg/slider-bg.jpg 以 vendor 版覆盖,因 PHP 默认值当前渲染的是 vendor 版) |
| `assets/vendor/zibll/img/playing.svg` 等散图 | `assets/img/` | |
| `assets/vendor/zibll/fonts/`(孤儿 2 文件) | `assets/fonts/` | 按"全部迁移"保留 |
| `js/libs/{DPlayer,dash.all,flv,hls,vanilla-tilt}.min.js` | **不迁移**(智能去重) | 先哈希验证与 `assets/widgets/third-party/` 字节一致(探索已确认大小一致);loader.js paths 改指 `'../widgets/third-party/DPlayer.min'` 等(相对 baseUrl);若任一哈希不符则该文件照常迁入 `runtime/libs/` |
完成后删除 `assets/vendor/`。
## 运行时切换(核心链路 `_win.uri`)
PHP 将 `'uri'` 从 `…/assets/vendor/zibll` 改为 `…/assets`,loader(改造版 RequireJS)以 `baseUrl = _win.uri + '/js'` 解析懒加载模块:
1. `assets/js/runtime/loader.js`:`baseUrl` 改为 `_win.uri + '/js/runtime'`(paths 表 `'libs/swiper.min'`、`'enlighter/enlighterjs.min'` 等相对值全部随之命中,无需逐条改,除 5 个去重库改 `../widgets/third-party/`)
2. `main.js` + `main.min.js`:enlighter CSS 注入 `_win.uri + '/js/enlighter/…'` → `'/js/runtime/enlighter/…'`(swiper CSS 注入 `/css/swiper.min.css` 迁后自然命中,不改)
3. 已核实的死引用(`/css/dplayer.min.css` 404、`/action/action.php`、`/yiyan/…`、paths 中 `shop—main`)保持原状不修不扩
## PHP 引用切换(7 个文件)
- `src/Core/ScriptsPart1b.php:16-17,23,38-41`、`src/Core/ScriptsPart1c.php:35-42,53`:新变量 `$runtime_uri = $base_uri . '/assets'`、`$fa5_uri = $base_uri . '/assets/fontawesome5'`;jquery/bootstrap/loader/main 路径改 `assets/js/runtime/…`
- `inc/core-hooks/part-3.php:52`、`src/Admin/Admin.php:116`:FA5 新路径
- `src/Admin/PageConfigMetaBox.php:275,277`、`src/Settings/DisplayPageGroups.php:96,98`、`src/Admin/Schema/SchemaPageDisplay.php:77,123,138,163,190`:`assets/vendor/zibll/img/` → `assets/img/`
## 全仓去 zibll 清扫(用户已确认含功能性标识符)
先跑全仓盘点(排除 .git、composer vendor、node_modules、tests/visual、assets/admin/build):`zibll/Zibll、zib__、zib_ajax、zibllblock、wp-block-zibllblock、.zib-、_zb、Qinver、zibll.com、子比`,产出替换映射并核对目标名无既有冲突(如 `aksrt_ajax`)。统一替换规则:
- **品牌头注释**:`@Author: Qinver / @Url: zibll.com / @Project: Zibll子比主题` → `@package AKSRT`;swiper 内 `//zibll : 修改内容` 等注释中性化;`main.js:4866` 的 `console.log(' Zibll Theme …')` 及 min 等价物删除
- **功能性标识符重命名**(生产端+消费端原子同步,含 min 文件 sed,改后 diff 抽查只允许替换点变化):
- `zibllblock/*` → `aksrtblock/*`;`zibll_block_cat/var` → `aksrt_block_cat/var`;`.wp-block-zibllblock-*` → `.wp-block-aksrtblock-*`(⚠️ 数据库已发布内容中的旧块名将无法渲染,用户已接受且明确不写兼容层)
- `'zibll' + type` localStorage 键 → `'aksrt' + type`(游客点赞记录重置,已接受)
- `zib__()` i18n 函数 → `aksrt__()`(先定位定义处,全量替换所有调用,含各 .min.js)
- `zib_ajax` 等运行时函数按盘点结果同步改名
- **AKSRT 自有资产**:`assets/css/frontend/zibll-utilities.css → legacy-utilities.css`、`zibll-compat-vars.css → compat-vars.css`、`footer/zibll-shell.css → footer/shell.css`(含 enqueue_module 调用、handle 派生、测试引用);`.zib-widget`(19处)/`.zib-slider` → `.aksrt-widget`/`.aksrt-slider`(CSS + PHP 输出标记 + vendor js 选择器同步)
- **PHP 变量/注释**:`$zibll_uri` 等改名;所触及文件注释中 Zibll 措辞改写为"legacy runtime"
- **admin SPA**:Vue 源内用户可见 zibll 文案改写后 `cd assets/admin && npm run build`
- **例外保留**:`tests/visual/`(docker-compose.zibll.yml、zibll-Caddyfile、baseline)指向外部真实 Zibll 对照主题,属产品名引用,不改
## 测试同步
- `tests/navigation/zibll-navigation-contract.test.cjs`(本地未跟踪):所有 vendor 路径 → 新路径;walk 根改 `assets/js/runtime`(js)与 `assets/css` 顶层(css);新增断言 `assets/vendor` 目录不存在;文件更名 `legacy-navigation-contract.test.cjs`
- `tests/widget-system/{post-visual,toast,page-display,lazyload}-contract.test.cjs`:loader/main 读取路径更新;toast 的 `$zibll_loader_version`/`$zibll_runtime_version` 正则同步新变量名;lazyload `sourceFiles` skip 列表把 `'vendor'` 换成/加上 `'runtime'` 保持语义
- 其他 widget-system 测试中引用 zibll 路径/字样的断言同步更新
## 验证与收尾
1. `php -l` 逐个检查所有改动 PHP
2. `node --test tests/widget-system/post-visual-contract.test.cjs` 等全部受影响 contract 测试 + 更名后的 navigation 测试
3. grep 审计:全仓(按上述排除项)`zibll|Qinver|子比|zib__` 零残留;`assets/vendor` 零引用
4. 手工 QA 清单:首页/文章页/评论区、FA 图标、滑块验证码图、勋章、enlighter 高亮、DPlayer 视频播放
5. 不主动提交;建议单原子 commit:`refactor(assets): vendor 资产迁入标准目录并全仓去除 zibll 标识`(移动+切换+清扫+测试必须同 commit 才能保持可用)
## 风险提示(用户已确认接受)
- 数据库中已存 `zibllblock/*` 块的旧文章渲染损坏(无兼容层)
- 游客 localStorage 点赞/收藏记录重置
- min 文件 sed 属精细操作,逐文件 diff 验证
@@ -1,23 +0,0 @@
1. 余额页改为单一路径 DOM 记录切换
- 以 `inc/helpers/user-center-renderers/profile-credits.php` 现有 `data-aksrt-asset-tab` / `data-aksrt-asset-pane` 结构为唯一 UI 状态源,保持积分记录、余额记录、积分任务都通过按钮事件切换,不再依赖 URL hash 或 query。
- 在 `assets/js/credits.js` 扩展现有 `initAssetTabs()`:拦截记录分页链接,阻止整页导航;读取分页链接的页码/资产类型,调用当前已有的 `/user-center/panel/credits` REST 面板接口,通过响应 HTML 更新 `#panel-credits` 的 DOM;更新后重新绑定 tab,并清理地址栏中 `wallet_page`、`credits_page`、`points_page` 等历史参数。
- 将 PHP 记录渲染改为读取由 REST 请求传入的分页参数,而不是直接读取 `$_GET`;移除 `aksrt_render_profile_pagination()` 生成的 query 链接,改为输出带 `data-aksrt-asset-page`、`data-aksrt-asset-key` 的按钮/链接,供 `credits.js` 处理。
- 保留服务端 REST 面板的单一 HTML 输出,不新增 JSON/HTML 双协议;分页请求只使用现有 credits panel endpoint,并在响应后触发既有 `post_ajax.ed → auto_fun` 生命周期。
- 用户中心初始化时不再把 `reward_tab`、`commission_page`、`user_page` 等记录参数写回用户地址;对 `/user/balance/` 初次加载清理历史记录参数,避免切换后地址继续携带旧参数。
2. 首页排序固定为 DOM/AJAX,不保留参数刷新分支
- `assets/js/homepage-fixed.js` 删除 `SORT_QUERY_KEY`、`getSortFromUrl()`、`normalizeHomepageUrl()`、`replaceUrlSort()` 及对 `window.location.search` 的依赖;当前排序只从活动排序控件或 `aksrtData.homepageDefaultSort` 读取。
- `initSortbar()` 点击排序项后只更新 active 状态并调用现有 `requestHomepagePanel()`,不再调用 `history.replaceState()`,因此地址栏保持无 `home_sort` 参数。
- `inc/helpers/aksrt-homepage.php` 排序控件改为无参数链接(或按钮化标记,沿用现有 `data-sort`),删除 `add_query_arg('home_sort', ...)`、`data-sort-ajax` 双路开关和服务端 query override;服务端首页排序只使用 canonical 默认/运行时 override,避免生成旧的参数刷新 URL。
- 清理 `src/Core/ScriptsPart1c.php`、`src/Settings/HomeCms.php`、`src/Settings/HomepageReader.php` 中仅服务于可选排序 AJAX 开关的 `homepageSortAjax` / `homepage_sort_ajax_enabled` 死代码及数据注入。
- 删除首页排序请求中仅为 URL 刷新服务的 `page_url` 参数,或将其固定为当前无 query 的页面路径;保留 REST 请求内部用于确定上下文的必要字段。
3. 清理历史 query 分支和测试
- 通过 `rg` 找到并删除余额记录的 `wallet_page` / `credits_page` 旧 `$_GET` 分支、首页 `home_sort` query 读取/写入分支,以及相关旧注释/配置字段。
- 增加/更新 `tests/widget-system` 契约:余额记录控件必须是 DOM data 事件、分页不得生成 `add_query_arg`/query href;首页排序必须调用 `requestHomepagePanel` 且不得使用 `home_sort`、`replaceState`、`location.search`、`data-sort-ajax`。
- 只保留真正需要的 `user-center` REST 参数(例如 revenue 内部分页若仍有明确业务需求则保留在请求体/URL 内部,不写浏览器地址);不把现有其他用户中心 tab 的必要内部请求参数误删。
4. 验证
- 仅使用 `rg`、Node 契约测试和 PHP 语法检查,不做真实视觉效果验收。
- 运行余额/用户中心、首页、生命周期、资产账本相关测试;对所有改动 PHP/JS 执行 `php -l` / `node --check`。
- 运行 `git diff --check`,并确认不覆盖工作树中已有的 VIP、runtime、CSS、imgbox 或计划文件改动。
+13 -5
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
- **响应式设计** - **响应式设计**
--- ---
@@ -167,6 +164,17 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
- `支付&付费`:下载/付费内容中心、余额充值、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 日上限、验证码等均依赖该函数)。
--- ---
## 开发说明 ## 开发说明
@@ -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 }) },
} }
-1
View File
@@ -11,7 +11,6 @@ export const globalApiMethods = {
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) }) },
-2
View File
@@ -2,7 +2,6 @@ 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'
@@ -13,7 +12,6 @@ export const apiMethods = {
...pageDisplayApiMethods, ...pageDisplayApiMethods,
...permissionsApiMethods, ...permissionsApiMethods,
...usersApiMethods, ...usersApiMethods,
...paymentApiMethods,
...extensionsApiMethods, ...extensionsApiMethods,
...toolsApiMethods, ...toolsApiMethods,
...schemaApiMethods, ...schemaApiMethods,
-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 }) }) },
} }
+1 -8
View File
@@ -5,6 +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 }) }) },
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) }) },
@@ -17,14 +18,6 @@ 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') },
saveIdentitySettings(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('/assets/settings') }, getCreditSettings() { return this.request('/assets/settings') },
saveCreditSettings(data) { return this.request('/assets/settings', { method: 'POST', body: JSON.stringify(data) }) }, saveCreditSettings(data) { return this.request('/assets/settings', { method: 'POST', body: JSON.stringify(data) }) },
listCreditUsers(params = {}) { listCreditUsers(params = {}) {
@@ -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
}
+442 -21
View File
@@ -60,8 +60,8 @@
/> />
<!-- Number --> <!-- Number -->
<div v-else-if="schema.type === 'number' || schema.type === 'spinner'" class="aksrt-field__number">
<n-input-number <n-input-number
v-else-if="schema.type === 'number' || schema.type === 'spinner'"
:value="modelValue" :value="modelValue"
:min="schema.min" :min="schema.min"
:max="schema.max" :max="schema.max"
@@ -71,6 +71,8 @@
style="width: 100%" style="width: 100%"
@update:value="emit('update:modelValue', $event)" @update:value="emit('update:modelValue', $event)"
/> />
<span v-if="schema.unit" class="aksrt-field__unit">{{ schema.unit }}</span>
</div>
<!-- Select --> <!-- Select -->
<div v-else-if="schema.type === 'select'" class="aksrt-field__ordered-select"> <div v-else-if="schema.type === 'select'" class="aksrt-field__ordered-select">
@@ -142,6 +144,22 @@
@update:value="emit('update:modelValue', $event)" @update:value="emit('update:modelValue', $event)"
/> />
<!-- Page Select (async page list) -->
<n-select
v-else-if="schema.type === 'page_select'"
:value="modelValue"
:options="pageOptions"
:loading="pageLoading"
:placeholder="schema.placeholder || '选择页面'"
:multiple="schema.multiple === true"
:filterable="true"
:remote="true"
:clearable="schema.clearable !== false"
:disabled="schema.disabled"
@search="handlePageSearch"
@update:value="emit('update:modelValue', $event)"
/>
<!-- Radio group --> <!-- Radio group -->
<n-radio-group <n-radio-group
v-else-if="schema.type === 'radio'" v-else-if="schema.type === 'radio'"
@@ -332,6 +350,32 @@
/> />
</div> </div>
<!-- Between number (min/max pair, Zibll between_number) -->
<div v-else-if="schema.type === 'between_number'" class="aksrt-field__between">
<n-input-number
:value="betweenValue('min')"
:min="schema.min"
:max="schema.max"
:step="schema.step || 1"
:placeholder="schema.minPlaceholder || '最小'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="updateBetween('min', $event)"
/>
<span class="aksrt-field__between-sep">—</span>
<n-input-number
:value="betweenValue('max')"
:min="schema.min"
:max="schema.max"
:step="schema.step || 1"
:placeholder="schema.maxPlaceholder || '最大'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="updateBetween('max', $event)"
/>
<span v-if="schema.unit" class="aksrt-field__between-unit">{{ schema.unit }}</span>
</div>
<!-- Spacing (top right bottom left) --> <!-- Spacing (top right bottom left) -->
<div v-else-if="schema.type === 'spacing'" class="aksrt-field__spacing"> <div v-else-if="schema.type === 'spacing'" class="aksrt-field__spacing">
<div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[0] || '上' }}</div> <div class="aksrt-field__spacing-label">{{ schema.spacingLabels?.[0] || '上' }}</div>
@@ -432,13 +476,43 @@
<!-- Fieldset (single nested object) --> <!-- Fieldset (single nested object) -->
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset"> <div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
<template v-if="fieldsetSections.length">
<div
v-for="(section, sIndex) in fieldsetSections"
:key="section.title"
class="aksrt-field__accordion-section"
:class="{ 'is-collapsed': !isFieldsetSectionOpen(sIndex) }"
>
<div class="aksrt-field__accordion-section-head" @click="toggleFieldsetSection(sIndex)">
<span class="aksrt-field__accordion-section-title">{{ section.title }}</span>
<FaIcon
:icon="isFieldsetSectionOpen(sIndex) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
:size="12"
class="aksrt-field__accordion-section-toggle"
/>
</div>
<div v-show="isFieldsetSectionOpen(sIndex)" class="aksrt-field__accordion-section-body">
<AksField
v-for="field in sectionFields(section)"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
:form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
</div>
</div>
</template>
<template v-if="!fieldsetSections.length">
<AksField <AksField
v-for="field in visibleNestedFields" v-for="field in visibleNestedFields"
:key="field.key" :key="field.key"
:schema="field" :schema="field"
:model-value="fieldsetValue(field)" :model-value="fieldsetValue(field)"
:form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)" @update:model-value="updateFieldsetValue(field.key, $event)"
/> />
</template>
</div> </div>
<div v-else-if="schema.type === 'link'" class="aksrt-field__link"> <div v-else-if="schema.type === 'link'" class="aksrt-field__link">
@@ -456,21 +530,42 @@
<n-button text size="tiny" :disabled="index === sortableItems.length - 1" @click="moveSortable(index, 1)">↓</n-button> <n-button text size="tiny" :disabled="index === sortableItems.length - 1" @click="moveSortable(index, 1)">↓</n-button>
</span> </span>
</div> </div>
<div class="aksrt-field__sortable-body"> <div v-if="item.fields && item.fields.length" class="aksrt-field__sortable-body">
<AksField v-for="field in item.fields || []" :key="field.key" :schema="field" :model-value="modelValue?.[item.key]?.[field.key] ?? field.default" @update:model-value="updateSortable(item.key, field.key, $event)" /> <AksField v-for="field in item.fields" :key="field.key" :schema="field" :model-value="modelValue?.[item.key]?.[field.key] ?? field.default" :form-data="formData" @update:model-value="updateSortable(item.key, field.key, $event)" />
</div> </div>
</div> </div>
</div> </div>
<!-- Repeater (array of objects) — inline expand (default) --> <!-- Repeater (array of objects) — inline expand (default, draggable) -->
<div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui !== 'list-modal'" class="aksrt-field__repeater"> <div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui !== 'list-modal'" class="aksrt-field__repeater">
<div <draggable
v-for="(item, index) in (modelValue || [])" v-model="repeaterDragList"
:key="item._id || index" :item-key="repeaterDragKey"
class="aksrt-field__repeater-item" handle=".aksrt-field__repeater-handle"
ghost-class="aksrt-field__repeater-ghost"
animation="180"
> >
<div class="aksrt-field__repeater-header"> <template #item="{ element, index }">
<div class="aksrt-field__repeater-item" :class="{ 'is-collapsible': repeaterCollapsible, 'is-collapsed': repeaterCollapsible && !isRepeaterItemOpen(index) }">
<div
class="aksrt-field__repeater-header"
:class="{ 'is-clickable': repeaterCollapsible }"
@click="repeaterCollapsible ? toggleRepeaterItem(index) : undefined"
>
<div class="aksrt-field__repeater-title">
<i class="fa fas fa-grip-vertical aksrt-field__repeater-handle" aria-hidden="true" @click.stop></i>
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span> <span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
<span v-if="repeaterCollapsible && repeaterItemSummary(element)" class="aksrt-field__repeater-summary">
{{ repeaterItemSummary(element) }}
</span>
</div>
<div class="aksrt-field__repeater-header-actions" @click.stop>
<FaIcon
v-if="repeaterCollapsible"
:icon="isRepeaterItemOpen(index) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
:size="12"
class="aksrt-field__repeater-toggle"
/>
<n-button <n-button
size="tiny" size="tiny"
type="error" type="error"
@@ -481,18 +576,22 @@
<FaIcon icon="fa fas fa-trash-alt" :size="12" /> <FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button> </n-button>
</div> </div>
<div class="aksrt-field__repeater-fields"> </div>
<div v-show="!repeaterCollapsible || isRepeaterItemOpen(index)" class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key"> <template v-for="field in (schema.fields || [])" :key="field.key">
<AksField <AksField
v-if="matchDependency(field.dependsOn, item)" v-if="matchDependency(field.dependsOn, element)"
:schema="field" :schema="field"
:model-value="item[field.key]" :model-value="element[field.key]"
:form-data="formData"
:disabled="schema.disabled" :disabled="schema.disabled"
@update:model-value="updateRepeaterItem(index, field.key, $event)" @update:model-value="updateRepeaterItem(index, field.key, $event)"
/> />
</template> </template>
</div> </div>
</div> </div>
</template>
</draggable>
<n-button <n-button
size="small" size="small"
dashed dashed
@@ -506,9 +605,9 @@
<!-- Repeater list-modal: compact list + modal editor --> <!-- Repeater list-modal: compact list + modal editor -->
<div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui === 'list-modal'" class="aksrt-field__repeater aksrt-field__repeater--list-modal"> <div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui === 'list-modal'" class="aksrt-field__repeater aksrt-field__repeater--list-modal">
<div v-if="(modelValue || []).length" class="aksrt-field__repeater-list"> <div v-if="repeaterItems.length" class="aksrt-field__repeater-list">
<div <div
v-for="(item, index) in (modelValue || [])" v-for="(item, index) in repeaterItems"
:key="item._id || index" :key="item._id || index"
class="aksrt-field__repeater-list-item" class="aksrt-field__repeater-list-item"
> >
@@ -523,7 +622,7 @@
<FaIcon icon="fa 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 < repeaterItems.length - 1"
size="tiny" size="tiny"
quaternary quaternary
:disabled="schema.disabled" :disabled="schema.disabled"
@@ -602,6 +701,7 @@
:key="field.key" :key="field.key"
:schema="field" :schema="field"
:model-value="repeaterDraft[field.key]" :model-value="repeaterDraft[field.key]"
:form-data="formData"
@update:model-value="updateRepeaterDraft(field.key, $event)" @update:model-value="updateRepeaterDraft(field.key, $event)"
/> />
</div> </div>
@@ -641,6 +741,8 @@ import FaIconPicker from './FaIconPicker.vue'
import AksMediaField from './AksMediaField.vue' import AksMediaField from './AksMediaField.vue'
import AksGalleryField from './AksGalleryField.vue' import AksGalleryField from './AksGalleryField.vue'
import AksCodeEditor from './AksCodeEditor.vue' import AksCodeEditor from './AksCodeEditor.vue'
import { buildFieldsetSections } from './fieldset-sections.js'
import draggable from 'vuedraggable'
import api from '@/api' import api from '@/api'
const props = defineProps({ const props = defineProps({
@@ -652,6 +754,8 @@ const props = defineProps({
}, },
/** Current field value */ /** Current field value */
modelValue: { default: null }, modelValue: { default: null },
/** Whole-form data — used to resolve dynamic options (schema.options as string = formData key). */
formData: { type: Object, default: () => ({}) },
/** Error message to display */ /** Error message to display */
errorMessage: { type: String, default: '' }, errorMessage: { type: String, default: '' },
}) })
@@ -666,9 +770,90 @@ function toggleSecretClear() {
} }
const repeaterLength = computed(() => Array.isArray(props.modelValue) ? props.modelValue.length : 0) const repeaterLength = computed(() => Array.isArray(props.modelValue) ? props.modelValue.length : 0)
/** 数组安全视图:非数组(如旧版字符串值)一律按空列表渲染,避免逐字符假条目。 */
const repeaterItems = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0)) const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0))
const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max)) const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max))
/* ------------------------------------------------------------------ *
* 手风琴模式:schema.collapsible 开启后,每一项默认收起,仅显示标题行,
* 点击标题行展开/收起。defaultCollapsed 为 false 时改为默认全部展开。
* ------------------------------------------------------------------ */
const repeaterCollapsible = computed(() => !!props.schema.collapsible)
const repeaterDefaultOpen = computed(() => props.schema.defaultCollapsed === false)
// 只记录「与默认状态相反」的下标,新增/删除项时其余项的开合状态不受影响。
const toggledRepeaterItems = ref(new Set())
function isRepeaterItemOpen(index) {
if (!repeaterCollapsible.value) return true
return repeaterDefaultOpen.value !== toggledRepeaterItems.value.has(index)
}
function toggleRepeaterItem(index) {
const next = new Set(toggledRepeaterItems.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
toggledRepeaterItems.value = next
}
/** 收起态标题行上的一行摘要,取自 listPreview 配置。 */
function repeaterItemSummary(item) {
if (!repeaterCollapsible.value || !item) return ''
const cfg = getRepeaterPreviewConfig()
const titleKey = cfg.titleKey || 'title'
const title = String(item[titleKey] ?? '').trim()
const prefix = String(cfg.prefix || '')
if (title === '') return prefix
const desc = repeaterItemDuration(item)
return desc ? `${prefix}${title} · ${desc}` : `${prefix}${title}`
}
/** time/unit 形态的时长摘要:0 视为永久,其余按 day/month 补单位。 */
function repeaterItemDuration(item) {
const cfg = getRepeaterPreviewConfig()
const descKey = cfg.descKey
if (!descKey) return ''
const raw = item[descKey]
if (raw === '' || raw == null) return ''
const amount = Number(raw)
if (!Number.isFinite(amount)) return String(raw)
if (amount <= 0) return '永久'
const unitKey = cfg.descUnitKey
if (!unitKey) return String(raw)
const unit = String(item[unitKey] || '')
if (unit === 'month') return `${amount} 个月`
if (unit === 'day') return `${amount} 天`
return String(raw)
}
/** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */
const repeaterDragList = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
set: (value) => emit('update:modelValue', Array.isArray(value) ? value : []),
})
/**
* 行 key:优先用行自身 _id;服务端历史数据没有 _id 时按对象引用派生稳定 key,
* 避免每次输入都重算 key 导致子字段重挂载、输入焦点丢失。
*/
const dragRowKeys = new WeakMap()
let dragRowKeySeed = 0
function repeaterDragKey(item) {
if (item && typeof item === 'object') {
if (item._id) return item._id
let key = dragRowKeys.get(item)
if (!key) {
key = `rpt_drag_${++dragRowKeySeed}`
dragRowKeys.set(item, key)
}
return key
}
return String(item)
}
function fieldsetValue(field) { function fieldsetValue(field) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue) const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue ? props.modelValue
@@ -676,6 +861,35 @@ function fieldsetValue(field) {
return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default
} }
/* ------------------------------------------------------------------ *
* accordion/fieldset 的分块手风琴:schema.sections 声明每块的标题与字段
* key,默认全部收起,点击块标题展开。未声明 sections 时保持平铺渲染。
* ------------------------------------------------------------------ */
const fieldsetSections = computed(() => {
return buildFieldsetSections(props.schema)
})
const openFieldsetSections = ref(new Set())
function isFieldsetSectionOpen(index) {
return openFieldsetSections.value.has(index)
}
/** 分块内字段同样遵循 dependsOn,数据源是本 fieldset 的值对象。 */
function sectionFields(section) {
return (section.fields || []).filter((field) => matchDependency(field.dependsOn, props.modelValue))
}
function toggleFieldsetSection(index) {
const next = new Set(openFieldsetSections.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
openFieldsetSections.value = next
}
function updateFieldsetValue(key, value) { function updateFieldsetValue(key, value) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue) const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue ? props.modelValue
@@ -683,10 +897,22 @@ function updateFieldsetValue(key, value) {
emit('update:modelValue', { ...current, [key]: value }) emit('update:modelValue', { ...current, [key]: value })
} }
/** Normalize options: support both string[] and {label, value}[] */ /** Normalize options: support string[], {label, value}[], and dynamic formData-key references */
const normalizedOptions = computed(() => { const normalizedOptions = computed(() => {
const opts = props.schema.options || [] const opts = props.schema.options
return opts.map(opt => { if (typeof opts === 'string') {
// 动态选项:引用 formData 中同名字段(如 custom_filter 维度定义 [{key,name,vals}])。
const source = props.formData?.[opts]
if (!Array.isArray(source)) return []
return source
.map(item => {
const value = String(item?.key ?? '')
if (value === '') return null
return { label: String(item?.name ?? item?.key ?? value), value }
})
.filter(Boolean)
}
return (Array.isArray(opts) ? opts : []).map(opt => {
if (typeof opt === 'string') return { label: opt, value: opt } if (typeof opt === 'string') return { label: opt, value: opt }
if (typeof opt === 'object' && opt !== null) { if (typeof opt === 'object' && opt !== null) {
return { ...opt, label: opt.label ?? opt.name ?? opt.value, value: opt.value ?? opt.label ?? opt.name } return { ...opt, label: opt.label ?? opt.name ?? opt.value, value: opt.value ?? opt.label ?? opt.name }
@@ -807,6 +1033,9 @@ onMounted(() => {
if (props.schema.type === 'async_select') { if (props.schema.type === 'async_select') {
loadAsyncOptions() loadAsyncOptions()
} }
if (props.schema.type === 'page_select') {
loadPages()
}
}) })
// ============ User Select (async search) ============ // ============ User Select (async search) ============
@@ -850,6 +1079,31 @@ function handleUserSearch(query) {
}, 300) }, 300)
} }
// ============ Page Select (async page list) ============
const pageOptions = ref([])
const pageLoading = ref(false)
let pageSearchTimer = null
async function loadPages(query = '') {
pageLoading.value = true
try {
const selected = props.modelValue ? [props.modelValue] : []
const res = await api.searchPageDisplayOptions('pages', query, query ? [] : selected)
const items = res?.data || res || []
pageOptions.value = Array.isArray(items) ? items : []
} catch (e) {
console.warn('[AksField] page load failed:', e)
pageOptions.value = []
} finally {
pageLoading.value = false
}
}
function handlePageSearch(query) {
clearTimeout(pageSearchTimer)
pageSearchTimer = setTimeout(() => loadPages(query), 250)
}
const fieldClasses = computed(() => ({ const fieldClasses = computed(() => ({
'aksrt-field--disabled': props.schema.disabled, 'aksrt-field--disabled': props.schema.disabled,
'aksrt-field--inline': props.schema.inline, 'aksrt-field--inline': props.schema.inline,
@@ -870,6 +1124,21 @@ function updateDimension(part, value) {
emit('update:modelValue', { ...current, [part]: value }) emit('update:modelValue', { ...current, [part]: value })
} }
// ============ Between Number (min/max pair) ============
function betweenValue(part) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
: {}
return current[part] !== undefined ? current[part] : (props.schema.default?.[part] ?? 0)
}
function updateBetween(part, value) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
: {}
emit('update:modelValue', { ...current, [part]: value })
}
// ============ Spacing ============ // ============ Spacing ============
function updateSpacing(part, value) { function updateSpacing(part, value) {
const current = props.modelValue || {} const current = props.modelValue || {}
@@ -912,6 +1181,8 @@ const repeaterDraft = ref({})
function createRepeaterItem() { function createRepeaterItem() {
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` } const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
for (const field of (props.schema.fields || [])) { for (const field of (props.schema.fields || [])) {
// UI-only chrome rows (notice / alert) are presentation only.
if (field.ui_only) continue
if (field.default !== undefined) { if (field.default !== undefined) {
newItem[field.key] = field.default newItem[field.key] = field.default
} else if (field.type === 'switch') { } else if (field.type === 'switch') {
@@ -941,7 +1212,16 @@ function removeRepeaterItem(index) {
function updateRepeaterItem(index, fieldKey, value) { function updateRepeaterItem(index, fieldKey, value) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : [] const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current[index] = { ...current[index], [fieldKey]: value } const row = current[index]
if (row && typeof row === 'object') {
// 必须原地写,不能展开成新对象:drag key 存在以「对象身份」为键的 WeakMap
// 里,而行数据是 reactive 的——换一个新对象后,渲染时读到的是它的一个新
// reactive 代理,WeakMap 认不出它便生成新 key,整行重挂载、输入框失焦,
// 表现为打一个字就退出编辑。经代理原地写既保持身份,也照样触发响应式。
row[fieldKey] = value
} else {
current[index] = { [fieldKey]: value }
}
emit('update:modelValue', current) emit('update:modelValue', current)
} }
@@ -998,7 +1278,7 @@ function openRepeaterModal(index) {
if (index < 0 && reachedRepeaterMaximum.value) return if (index < 0 && reachedRepeaterMaximum.value) return
if (props.schema.disabled) return if (props.schema.disabled) return
if (index >= 0) { if (index >= 0) {
const item = (props.modelValue || [])[index] || {} const item = (Array.isArray(props.modelValue) ? props.modelValue : [])[index] || {}
repeaterDraft.value = { ...createRepeaterItem(), ...item } repeaterDraft.value = { ...createRepeaterItem(), ...item }
repeaterEditingIndex.value = index repeaterEditingIndex.value = index
} else { } else {
@@ -1043,6 +1323,15 @@ function confirmRepeaterModal() {
margin-bottom: 0; margin-bottom: 0;
} }
/* 结构化分组(会员商品/续费商品/积分兑换/升级价格)与相邻字段之间留出更明确的间隔,
* 避免两个分组上下贴在一起,和参考主题的留白一致。 */
.aksrt-field--group,
.aksrt-field--repeater,
.aksrt-field--accordion,
.aksrt-field--fieldset {
margin-bottom: 26px;
}
.aksrt-field__input-wrap { .aksrt-field__input-wrap {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -1115,6 +1404,38 @@ function confirmRepeaterModal() {
font-size: 14px; font-size: 14px;
} }
/* Between number (min/max) */
.aksrt-field__between {
display: flex;
align-items: center;
gap: 8px;
max-width: 360px;
}
.aksrt-field__number {
display: flex;
align-items: center;
gap: 8px;
max-width: 360px;
}
.aksrt-field__unit {
color: var(--aksrt-color-muted);
font-size: 13px;
white-space: nowrap;
}
.aksrt-field__between-sep {
color: var(--aksrt-color-border-strong);
font-size: 14px;
}
.aksrt-field__between-unit {
color: var(--aksrt-color-muted);
font-size: 13px;
white-space: nowrap;
}
/* Spacing field */ /* Spacing field */
.aksrt-field__spacing { .aksrt-field__spacing {
display: grid; display: grid;
@@ -1259,7 +1580,6 @@ function confirmRepeaterModal() {
justify-content: space-between; justify-content: space-between;
padding: 10px 12px; padding: 10px 12px;
background: var(--aksrt-color-canvas); background: var(--aksrt-color-canvas);
cursor: move;
} }
.aksrt-field__sortable-body { .aksrt-field__sortable-body {
@@ -1277,6 +1597,48 @@ function confirmRepeaterModal() {
background: var(--aksrt-color-canvas); background: var(--aksrt-color-canvas);
} }
.aksrt-field__accordion-section + .aksrt-field__accordion-section {
margin-top: 14px;
}
.aksrt-field__accordion-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--aksrt-color-border);
border-radius: 6px;
background: var(--aksrt-color-surface);
cursor: pointer;
user-select: none;
font-size: 13px;
font-weight: 600;
color: var(--aksrt-color-text);
transition: border-color 0.15s, color 0.15s;
}
.aksrt-field__accordion-section-head:hover {
border-color: var(--aksrt-color-primary);
color: var(--aksrt-color-primary);
}
.aksrt-field__accordion-section-title {
min-width: 0;
}
.aksrt-field__accordion-section-toggle {
flex-shrink: 0;
color: var(--aksrt-color-text-secondary);
}
.aksrt-field__accordion-section-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 2px 2px;
}
.aksrt-field__repeater { .aksrt-field__repeater {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1294,9 +1656,68 @@ function confirmRepeaterModal() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.aksrt-field__repeater-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.aksrt-field__repeater-header.is-clickable {
cursor: pointer;
user-select: none;
border-radius: 6px;
margin: -4px -4px 0;
padding: 4px 4px 0;
}
.aksrt-field__repeater-header.is-clickable:hover .aksrt-field__repeater-index {
color: var(--aksrt-color-primary-hover);
}
.aksrt-field__repeater-header-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.aksrt-field__repeater-toggle {
color: var(--aksrt-color-text-secondary);
transition: transform 0.15s;
}
.aksrt-field__repeater-summary {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--aksrt-color-text-secondary);
}
.aksrt-field__repeater-item.is-collapsible {
padding: 10px 12px;
}
.aksrt-field__repeater-item.is-collapsed .aksrt-field__repeater-header {
margin-bottom: 0;
}
.aksrt-field__repeater-handle {
cursor: grab;
color: var(--aksrt-color-text-tertiary);
}
.aksrt-field__repeater-ghost {
opacity: 0.45;
border-color: var(--aksrt-color-primary);
}
.aksrt-field__repeater-index { .aksrt-field__repeater-index {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@@ -56,14 +56,47 @@
<template v-if="group.fields && !hasGroupSlot(group)"> <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)" @action="emit('field-action', $event)"
@@ -73,6 +106,7 @@
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)" @action="emit('field-action', $event)"
@@ -94,26 +128,7 @@
</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="fa 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="fa fas fa-save" :size="12" />
保存设置
</n-button>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -131,8 +146,10 @@
*/ */
import { ref, computed, useSlots } 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 */
@@ -214,6 +231,18 @@ 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)
@@ -268,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)
@@ -318,6 +356,10 @@ const filteredSchema = computed(() => {
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;
@@ -347,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: 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;
}
}
/* Mobile */ /* Mobile */
@media (max-width: 768px) { @media (max-width: 768px) {
.aksrt-form-renderer__header { .aksrt-form-renderer__header {
@@ -405,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,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>
@@ -17,7 +17,7 @@
<script setup> <script setup>
/** /**
* IconValue — renders any icon-picker value ("fa fas fa-x", "zibsvg-key", * IconValue — renders any icon-picker value ("fa fas fa-star", "zibsvg-key",
* "customsvg-<svg>") as inline preview markup. * "customsvg-<svg>") as inline preview markup.
*/ */
import { computed } from 'vue' import { computed } from 'vue'
@@ -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,4 +1,4 @@
import { renderFaIcon, schemaPage, customPage } from './helpers' import { renderFaIcon, schemaPage } from './helpers'
export const extensionsSection = { export const extensionsSection = {
key: 'extensions', key: 'extensions',
@@ -6,6 +6,16 @@ export const extensionsSection = {
icon: renderFaIcon('fa 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,19 +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-ai', key: 'extensions-security',
name: 'ExtensionAI', name: 'ExtensionsSecurity',
label: 'AI 连接配置', label: '网站安全',
route: '/extensions/ai', route: '/extensions/security',
component: 'ExtensionAI', title: '网站安全',
status: 'available', desc: '防刷限制、验证码发送限制与站点安全加固。',
title: 'AI 连接配置', canonicalDomain: 'extensions',
desc: '配置 AI 生成连接;SEO 在文章独立 SEO 框,摘要/标签在前台投稿页。', section: 'security',
}), }),
], ],
} }
@@ -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',
+42 -38
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('fa fas fa-credit-card'), 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',
}),
], ],
} }
@@ -37,26 +37,15 @@ 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',
}), }),
schemaPage({
key: 'permissions-download',
name: 'PermissionsDownload',
label: '下载配置',
route: '/permissions/download',
title: '下载配置',
desc: '下载链接别名、默认使用次数与有效期限规则。',
canonicalDomain: 'permissions',
section: 'download',
}),
], ],
} }
@@ -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',
+14 -12
View File
@@ -2,7 +2,9 @@ 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',
@@ -58,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',
}), }),
@@ -106,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',
@@ -128,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
}, {})
+37 -44
View File
@@ -23,15 +23,13 @@
</n-form> </n-form>
</n-card> </n-card>
<n-card title="统一资产账本" class="aksrt-section-card"> <n-card title="用户近期资产记录" class="aksrt-section-card">
<n-space vertical :size="12"> <n-space vertical :size="12">
<n-space wrap> <n-space wrap>
<n-select v-model:value="ledgerFilters.asset" :options="assetOptions" clearable placeholder="资产" style="width: 130px" /> <n-input v-model:value="ledgerFilters.user_id" placeholder="用户 ID" clearable style="width: 130px" />
<n-input v-model:value="ledgerFilters.transaction_id" placeholder="交易号" clearable style="width: 130px" /> <n-select v-model:value="ledgerFilters.asset" :options="assetOptions" placeholder="资产" style="width: 130px" />
<n-input v-model:value="ledgerFilters.operation_key" placeholder="幂等键" clearable style="width: 240px" /> <n-input v-model:value="ledgerFilters.date_from" type="date" placeholder="开始日期" style="width: 160px" />
<n-input v-model:value="ledgerFilters.ref_type" placeholder="引用类型" clearable style="width: 130px" /> <n-input v-model:value="ledgerFilters.date_to" type="date" placeholder="结束日期" style="width: 160px" />
<n-input v-model:value="ledgerFilters.ref_key" placeholder="引用键" clearable style="width: 180px" />
<n-input v-model:value="ledgerFilters.actor_user_id" placeholder="操作者 ID" clearable style="width: 130px" />
<n-button type="primary" :loading="ledgerLoading" @click="searchLedger">查询</n-button> <n-button type="primary" :loading="ledgerLoading" @click="searchLedger">查询</n-button>
<n-button @click="resetLedger">重置</n-button> <n-button @click="resetLedger">重置</n-button>
</n-space> </n-space>
@@ -40,13 +38,13 @@
:data="ledgerRows" :data="ledgerRows"
:loading="ledgerLoading" :loading="ledgerLoading"
:row-key="row => row.id" :row-key="row => row.id"
:scroll-x="1600" :scroll-x="1100"
size="small" size="small"
/> />
<n-pagination <n-pagination
v-model:page="ledgerPage" v-model:page="ledgerPage"
:page-count="ledgerPageCount" :page-count="ledgerPageCount"
:page-size="20" :page-size="10"
@update:page="loadLedger" @update:page="loadLedger"
/> />
</n-space> </n-space>
@@ -169,40 +167,41 @@ const ledgerRows = ref([])
const ledgerPage = ref(1) const ledgerPage = ref(1)
const ledgerPageCount = ref(1) const ledgerPageCount = ref(1)
const ledgerFilters = reactive({ const ledgerFilters = reactive({
asset: null, user_id: '',
transaction_id: '', asset: 'points',
operation_key: '', date_from: '',
ref_type: '', date_to: '',
ref_key: '',
actor_user_id: '',
}) })
const assetOptions = [ const assetOptions = [
{ label: '积分', value: 'points' }, { label: '积分', value: 'points' },
{ label: '余额', value: 'balance' }, { label: '余额', value: 'balance' },
] ]
const ledgerColumns = [ const ledgerColumns = [
{ title: '交易号', key: 'transaction_label', width: 100, fixed: 'left' }, { title: '类型', key: 'reason', width: 150 },
{ title: '幂等键', key: 'operation_key', width: 260, ellipsis: { tooltip: true } }, { title: '订单号', key: 'ref_key', width: 200 },
{ title: '资产', key: 'assets_label', width: 100 }, { title: '变动 / 变动后', key: 'delta_label', width: 190 },
{ title: '原因', key: 'reason', width: 150, ellipsis: { tooltip: true } }, { title: '说明', key: 'remark', width: 240, ellipsis: { tooltip: true } },
{ title: '业务引用', key: 'reference_label', width: 200, ellipsis: { tooltip: true } }, { title: '时间', key: 'created_at', width: 170 },
{ title: '操作者', key: 'actor_label', width: 100 },
{ title: '增减 / 手续费', key: 'delta_label', width: 180, ellipsis: { tooltip: true } },
{ title: '变动后余额', key: 'balance_label', width: 150, ellipsis: { tooltip: true } },
{ title: '备注', key: 'remark', width: 180, ellipsis: { tooltip: true } },
{ title: 'UTC 时间', key: 'created_at', width: 190 },
] ]
function formatAssetValue(asset, value) { function formatAssetValue(asset, value) {
if (asset === 'balance') return typeof value === 'string' && /^[+-]?\d+\.\d{2}$/.test(value) ? value : '0.00' const raw = Number(value ?? 0)
const raw = String(value ?? '0') if (!Number.isFinite(raw)) return asset === 'balance' ? '0.00' : '0'
return /^-?\d+$/.test(raw) ? raw : '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() { async function loadLedger() {
ledgerLoading.value = true ledgerLoading.value = true
try { try {
const params = { page: ledgerPage.value, per_page: 20 } 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]) => { Object.entries(ledgerFilters).forEach(([key, value]) => {
if (value !== null && String(value).trim() !== '') params[key] = value if (value !== null && String(value).trim() !== '') params[key] = value
}) })
@@ -210,23 +209,16 @@ async function loadLedger() {
const data = response?.data || response || {} const data = response?.data || response || {}
ledgerRows.value = (data.items || []).map((row) => { ledgerRows.value = (data.items || []).map((row) => {
const entries = Array.isArray(row.entries) ? row.entries : [] const entries = Array.isArray(row.entries) ? row.entries : []
const entry = entries[0] || {}
return { return {
...row, ...row,
transaction_label: `#${row.id}`, delta_label: `${formatAssetValue(entry.asset, entry.delta)} / ${formatAssetValue(entry.asset, entry.balance_after)}`,
assets_label: (row.assets || []).join(' / '), remark: row.remark || '—',
reference_label: [row.ref_type, row.ref_key].filter(Boolean).join(':') || '—',
actor_label: row.actor_user_id ? `#${row.actor_user_id}` : '系统',
delta_label: entries.map((entry) => {
const value = formatAssetValue(entry.asset, entry.delta)
const signed = entry.asset === 'points' && !value.startsWith('-') && value !== '0' ? `+${value}` : value
return `${entry.asset}:${signed}`
}).join(';'),
balance_label: entries.map((entry) => `${entry.asset}:${formatAssetValue(entry.asset, entry.balance_after)}`).join(';'),
} }
}) })
ledgerPageCount.value = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.per_page || 20))) ledgerPageCount.value = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.per_page || 10)))
} catch (e) { } catch (e) {
message.error(e.message || '加载账本失败') message.error(e.message || '加载资产记录失败')
} finally { } finally {
ledgerLoading.value = false ledgerLoading.value = false
} }
@@ -238,8 +230,10 @@ function searchLedger() {
} }
function resetLedger() { function resetLedger() {
Object.assign(ledgerFilters, { asset: null, transaction_id: '', operation_key: '', ref_type: '', ref_key: '', actor_user_id: '' }) Object.assign(ledgerFilters, { user_id: '', asset: 'points', date_from: '', date_to: '' })
searchLedger() ledgerRows.value = []
ledgerPage.value = 1
ledgerPageCount.value = 1
} }
async function loadCfg() { async function loadCfg() {
@@ -270,6 +264,5 @@ async function saveCfg() {
onMounted(() => { onMounted(() => {
loadCfg() loadCfg()
loadLedger()
}) })
</script> </script>
+37 -1
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"
@@ -131,6 +131,7 @@ 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 serverSchema = ref(null)
const smsSavedSettings = ref('')
const captchaSecretsToClear = ref(new Set()) const captchaSecretsToClear = ref(new Set())
const captchaProviders = [ const captchaProviders = [
{ key: 'tencent', label: '腾讯云 Captcha', component: TencentConfig }, { key: 'tencent', label: '腾讯云 Captcha', component: TencentConfig },
@@ -144,8 +145,17 @@ const captchaProviderTab = ref('tencent')
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 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)
@@ -216,6 +226,10 @@ const {
delete payload[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 (clearSecrets.length) payload.__clear_secrets = clearSecrets
if (isPermissionsLogin.value && captchaSecretsToClear.value.size) { if (isPermissionsLogin.value && captchaSecretsToClear.value.size) {
payload.__clear_secrets = [...(payload.__clear_secrets || []), ...captchaSecretsToClear.value] payload.__clear_secrets = [...(payload.__clear_secrets || []), ...captchaSecretsToClear.value]
@@ -226,12 +240,34 @@ const {
return response 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) { 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 if (key !== 'email_test_send' || !isEmailPage.value) return
const to = String(formData.value?.email_test_to || '').trim() const to = String(formData.value?.email_test_to || '').trim()
if (!to) { if (!to) {
File diff suppressed because it is too large. Load diff
@@ -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="fa fab fa-android"></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="fa 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="fa 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="fa fas fa-road"></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="fa 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: var(--aksrt-color-text); }
.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,529 +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.password" placeholder="手动填写唯一卡密" />
</n-form-item>
<n-form-item v-if="createForm.type === 'balance'" label="面额">
<n-input v-model:value="createForm.amount" inputmode="decimal" placeholder="例如 10.00" />
</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 v-model:value="batchForm.amount" inputmode="decimal" placeholder="例如 10.00" />
</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',
password: '',
amount: '10.00',
level: 1,
days: 30,
points: 100,
expiresAt: null,
note: '',
})
const batchForm = reactive({
type: 'balance',
count: 20,
amount: '10.00',
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: 'password', 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,
password: createForm.password,
amount: String(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.password = ''
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: String(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.password} 吗?`,
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.password,
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 'fa fas fa-credit-card'
if (id === 'alipay' || id === 'epay_alipay') return 'fa fas fa-credit-card'
if (id === 'wechatpay' || id === 'epay_wxpay') return 'fa fab fa-weixin'
if (id === 'paypal') return 'fa fab fa-paypal'
if (id === 'stripe') return 'fa fab fa-cc-stripe'
return 'fa 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(--aksrt-color-muted);
font-size: 16px;
}
.aksrt-gateway__icon--active {
background: var(--color-primary-soft, #f0f5ff);
color: var(--aksrt-color-primary);
}
.aksrt-gateway__card strong {
display: block;
font-size: 14px;
color: var(--aksrt-color-text);
}
.aksrt-gateway__card p {
margin: 2px 0 0;
font-size: 12px;
color: var(--aksrt-color-muted);
}
.aksrt-gateway__help {
margin: 0;
font-size: 13px;
color: var(--color-ink-3, #5c6370);
line-height: 1.6;
}
</style>
@@ -1,319 +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>{{ formatAmount(withdrawalStats.total_paid, 'cash') }}</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 = (currency) => ({
total: currency === 'cash' ? '0.00' : 0,
pending: currency === 'cash' ? '0.00' : 0,
settled: currency === 'cash' ? '0.00' : 0,
count: 0,
})
const revenueStats = ref({ cash: emptyCurrencyStats('cash'), points: emptyCurrencyStats('points') })
const withdrawalStats = ref({ total: 0, pending: 0, processing: 0, completed: 0, rejected: 0, total_paid: '0.00' })
const timeSeries = ref({ cash: [], points: [] })
const granularity = ref('day')
const revenueSummary = computed(() => [
{ key: 'cash-total', value: `${formatAmount(revenueStats.value.cash.total, 'cash')} 元`, label: '现金总收益' },
{ key: 'cash-pending', value: `${formatAmount(revenueStats.value.cash.pending, 'cash')} 元`, label: '现金待结算' },
{ key: 'cash-settled', value: `${formatAmount(revenueStats.value.cash.settled, 'cash')} 元`, 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) => {
const totalMinor = group.items.reduce((sum, item) => sum + amountMinor(item.amount, group.key), 0n)
return {
...group,
total: formatMinor(totalMinor, group.key),
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'
? `${formatMinor(amountMinor(amount, currency), currency)} 积分`
: `¥${formatMinor(amountMinor(amount, currency), currency)}`
}
function amountMinor(amount, currency) {
const raw = String(amount ?? '').trim()
if (currency === 'points') return /^\d+$/.test(raw) ? BigInt(raw) : 0n
const match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(raw)
return match ? (BigInt(match[1]) * 100n) + BigInt((match[2] || '').padEnd(2, '0') || '0') : 0n
}
function formatMinor(minor, currency) {
if (currency === 'points') return String(minor)
return `${minor / 100n}.${String(minor % 100n).padStart(2, '0')}`
}
function getBarHeight(amount, items) {
const currency = items === timeSeries.value.points ? 'points' : 'cash'
const maxAmount = items.reduce((max, item) => {
const current = amountMinor(item.amount, currency)
return current > max ? current : max
}, 0n)
if (maxAmount <= 0n) return 2
const scaled = Number((amountMinor(amount, currency) * 10000n) / maxAmount) / 100
return Math.max(2, scaled)
}
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('cash'), ...(res.data.revenue.cash || {}) },
points: { ...emptyCurrencyStats('points'), ...(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: String(res.data.withdrawal.total_paid || '0.00'),
}
}
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(--aksrt-color-text);
}
.aksrt-revenue__stat p {
margin: 4px 0 0;
font-size: 12px;
color: var(--aksrt-color-muted);
}
.aksrt-revenue__charts {
display: grid;
gap: 24px;
}
.aksrt-revenue__chart {
min-height: 200px;
}
.aksrt-revenue__chart h4 {
margin: 0 0 10px;
color: var(--aksrt-color-text);
}
.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(--aksrt-color-muted);
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(--aksrt-color-text);
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.password || '-' }}</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,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' : 'var(--aksrt-color-muted)'" />
<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: 'fa fas fa-id-card', label: '资料卡图片', enabled: true },
{ key: 'submit', icon: 'fa fas fa-edit', label: '投稿图片', enabled: true },
{ key: 'comment', icon: 'fa fas fa-comment-dots', label: '评论图片', enabled: true },
{ key: 'resource', icon: 'fa 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: var(--aksrt-color-text);
}
.aksrt-attach__summary p {
margin: 2px 0 0;
font-size: 12px;
color: var(--aksrt-color-muted);
}
</style>
-210
View File
@@ -1,210 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="禁封管理"
desc="查看和管理用户封禁、限制状态,支持按用户 ID 查询和解除限制。"
/>
<n-grid cols="1 s:2 m:3" :x-gap="16" :y-gap="16" responsive="screen">
<n-grid-item>
<n-statistic label="总限制记录" :value="stats.total" />
</n-grid-item>
<n-grid-item>
<n-statistic label="当前生效" :value="stats.active">
<template #suffix>
<n-tag v-if="stats.active > 0" type="error" size="small">{{ stats.active }}</n-tag>
</template>
</n-statistic>
</n-grid-item>
<n-grid-item>
<n-statistic label="封禁账号" :value="stats.banned">
<template #suffix>
<n-tag v-if="stats.banned > 0" type="error" size="small">严重</n-tag>
</template>
</n-statistic>
</n-grid-item>
</n-grid>
<!-- Search by user ID -->
<n-card size="small" title="查询用户限制" style="margin-top:16px;">
<n-space>
<n-input-number v-model:value="searchUserId" placeholder="用户 ID" :min="1" style="width:160px;" />
<n-button type="primary" @click="searchRestrictions" :loading="searching">查询</n-button>
</n-space>
</n-card>
<!-- User restrictions list -->
<n-card v-if="userRestrictions.length" size="small" :title="`用户 #${searchUserId} 的限制记录`" style="margin-top:16px;">
<n-data-table
:columns="columns"
:data="userRestrictions"
:loading="searching"
:bordered="false"
/>
</n-card>
<!-- Add restriction form -->
<n-card size="small" title="添加限制" style="margin-top:16px;">
<n-form :model="addForm" label-placement="left" label-width="100" class="aksrt-form">
<n-form-item label="用户 ID">
<n-input-number v-model:value="addForm.user_id" :min="1" style="width:200px;" />
</n-form-item>
<n-form-item label="限制类型">
<n-select v-model:value="addForm.type" :options="restrictionTypes" style="width:200px;" />
</n-form-item>
<n-form-item label="原因">
<n-input v-model:value="addForm.reason" type="textarea" :rows="2" placeholder="限制原因(必填)" />
</n-form-item>
<n-form-item label="持续时间">
<n-select v-model:value="addForm.duration" :options="durationOptions" style="width:200px;" />
</n-form-item>
<n-form-item>
<n-button type="error" @click="addRestriction" :loading="adding" :disabled="!addForm.user_id || !addForm.reason">
添加限制
</n-button>
</n-form-item>
</n-form>
</n-card>
</div>
</template>
<script setup>
import { h, reactive, ref, onMounted } from 'vue'
import { NButton, NTag, NSpace, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api'
const message = useMessage()
const searching = ref(false)
const adding = ref(false)
const searchUserId = ref(null)
const userRestrictions = ref([])
const stats = reactive({ total: 0, active: 0, banned: 0 })
const addForm = reactive({
user_id: null,
type: 'muted',
reason: '',
duration: '7d',
})
const restrictionTypes = [
{ label: '禁言', value: 'muted' },
{ label: '限制', value: 'restricted' },
{ label: '封禁', value: 'banned' },
]
const durationOptions = [
{ label: '1 天', value: '1d' },
{ label: '3 天', value: '3d' },
{ label: '7 天', value: '7d' },
{ label: '30 天', value: '30d' },
{ label: '永久', value: 'permanent' },
]
const columns = [
{ title: 'ID', key: 'id', width: 60 },
{
title: '类型', key: 'type', width: 80,
render: (row) => h(NTag, { type: getTypeTag(row.type), size: 'small' }, () => getTypeLabel(row.type)),
},
{ title: '原因', key: 'reason', ellipsis: { tooltip: true } },
{ title: '生效时间', key: 'created_at', width: 160 },
{ title: '到期时间', key: 'expires_at', width: 160 },
{
title: '状态', key: 'status', width: 80,
render: (row) => {
const active = !row.expires_at || new Date(row.expires_at) > new Date()
return h(NTag, { type: active ? 'error' : 'default', size: 'small' }, () => active ? '生效中' : '已过期')
},
},
{
title: '操作', key: 'actions', width: 100,
render: (row) => h(NButton, { size: 'tiny', type: 'error', onClick: () => removeRestriction(row) }, () => '解除'),
},
]
function getTypeTag(type) {
const map = { muted: 'warning', restricted: 'error', banned: 'error' }
return map[type] || 'default'
}
function getTypeLabel(type) {
const map = { muted: '禁言', restricted: '限制', banned: '封禁' }
return map[type] || type
}
async function loadStats() {
try {
const res = await api.request('/users/restrictions/stats')
const data = res.data || res
stats.total = data.total || 0
stats.active = data.active || 0
stats.banned = data.banned || 0
} catch (e) {
// Stats may not be available yet
}
}
async function searchRestrictions() {
if (!searchUserId.value) {
message.warning('请输入用户 ID')
return
}
searching.value = true
try {
const res = await api.request(`/users/${searchUserId.value}/restrictions`)
const data = res.data || res
userRestrictions.value = data.restrictions || []
if (userRestrictions.value.length === 0) {
message.info('该用户无限制记录')
}
} catch (e) {
message.error('查询失败: ' + (e.message || '未知错误'))
} finally {
searching.value = false
}
}
async function addRestriction() {
if (!addForm.user_id || !addForm.reason) {
message.warning('请填写用户 ID 和原因')
return
}
adding.value = true
try {
await api.request(`/users/${addForm.user_id}/restrictions`, {
method: 'POST',
data: {
type: addForm.type,
reason: addForm.reason,
duration: addForm.duration,
},
})
message.success('限制已添加')
addForm.reason = ''
if (searchUserId.value === addForm.user_id) {
await searchRestrictions()
}
await loadStats()
} catch (e) {
message.error('添加失败: ' + (e.message || '未知错误'))
} finally {
adding.value = false
}
}
async function removeRestriction(row) {
try {
await api.request(`/users/restrictions/${row.id}`, { method: 'DELETE' })
message.success('限制已解除')
userRestrictions.value = userRestrictions.value.filter((r) => r.id !== row.id)
await loadStats()
} catch (e) {
message.error('解除失败: ' + (e.message || '未知错误'))
}
}
onMounted(loadStats)
</script>
@@ -1,304 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="消息中心"
desc="管理站内通知、系统消息和用户消息数据。"
/>
<n-grid cols="1 s:2 m:4" :x-gap="16" :y-gap="16" responsive="screen">
<n-grid-item>
<n-statistic label="全部消息" :value="stats.total" />
</n-grid-item>
<n-grid-item>
<n-statistic label="未读消息" :value="stats.unread">
<template #suffix>
<n-tag v-if="stats.unread > 0" type="warning" size="small">{{ stats.unread }}</n-tag>
</template>
</n-statistic>
</n-grid-item>
<n-grid-item>
<n-statistic label="今日新增" :value="stats.today" />
</n-grid-item>
<n-grid-item>
<n-statistic label="系统通知" :value="stats.system" />
</n-grid-item>
</n-grid>
<n-card size="small" title="消息列表" style="margin-top:16px;">
<template #header-extra>
<n-space>
<n-select
v-model:value="filters.type"
placeholder="消息类型"
:options="typeOptions"
clearable
style="width:160px;"
@update:value="handleFilterChange"
/>
<n-select
v-model:value="filters.read"
placeholder="已读状态"
:options="readOptions"
clearable
style="width:120px;"
@update:value="handleFilterChange"
/>
<n-button @click="loadMessages" :loading="loading">刷新</n-button>
<n-button type="primary" @click="markAllRead" :loading="markingAll">全部已读</n-button>
</n-space>
</template>
<n-data-table
:columns="columns"
:data="messages"
:loading="loading"
:pagination="pagination"
:row-class-name="rowClassName"
remote
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</n-card>
<n-drawer v-model:show="showDetail" :width="480">
<n-drawer-content :title="detailMessage?.title || '消息详情'">
<template v-if="detailMessage">
<n-descriptions bordered :column="1" label-placement="left">
<n-descriptions-item label="类型">
<n-tag :type="getTypeTag(detailMessage.type)" size="small">{{ getTypeLabel(detailMessage.type) }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="状态">
<n-tag :type="detailMessage.is_read ? 'success' : 'warning'" size="small">
{{ detailMessage.is_read ? '已读' : '未读' }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="用户">{{ detailMessage.user_name || '-' }}</n-descriptions-item>
<n-descriptions-item label="时间">{{ detailMessage.created_at || '-' }}</n-descriptions-item>
</n-descriptions>
<div class="aksrt-message-detail">
<h4>{{ detailMessage.title }}</h4>
<p>{{ detailMessage.description || detailMessage.content || '暂无详细内容' }}</p>
<a
v-if="detailMessage.target_url"
:href="detailMessage.target_url"
target="_blank"
rel="noreferrer"
>
查看目标
</a>
</div>
</template>
<template #footer>
<n-space>
<n-button @click="showDetail = false">关闭</n-button>
<n-button v-if="detailMessage && !detailMessage.is_read" type="primary" @click="markCurrentRead">
标记已读
</n-button>
</n-space>
</template>
</n-drawer-content>
</n-drawer>
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace, NTag, useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
import api from '@/api'
const message = useMessage()
const loading = ref(false)
const markingAll = ref(false)
const showDetail = ref(false)
const detailMessage = ref(null)
const messages = ref([])
const typeStats = ref([])
const stats = reactive({ total: 0, unread: 0, today: 0, system: 0 })
const filters = reactive({ type: null, read: null })
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, pageCount: 1, showSizePicker: true, pageSizes: [20, 50, 100] })
const readOptions = [
{ label: '未读', value: '0' },
{ label: '已读', value: '1' },
]
const typeOptions = computed(() => typeStats.value.map((item) => ({
label: getTypeLabel(item.type),
value: item.type,
})))
const columns = [
{
title: '类型',
key: 'type',
width: 120,
render: (row) => h(NTag, { type: getTypeTag(row.type), size: 'small' }, () => getTypeLabel(row.type)),
},
{ title: '标题', key: 'title', ellipsis: { tooltip: true } },
{ title: '用户', key: 'user_name', width: 140, ellipsis: { tooltip: true } },
{ title: '时间', key: 'created_at', width: 180 },
{
title: '状态',
key: 'is_read',
width: 90,
render: (row) => h(NTag, { type: row.is_read ? 'success' : 'warning', size: 'small' }, () => (row.is_read ? '已读' : '未读')),
},
{
title: '操作',
key: 'actions',
width: 140,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, { size: 'tiny', onClick: () => openDetail(row) }, () => '查看'),
!row.is_read ? h(NButton, { size: 'tiny', type: 'primary', onClick: () => markOneRead(row) }, () => '已读') : null,
]),
},
]
function getTypeTag(type) {
const map = {
notice: 'info',
system: 'info',
comment_reply: 'success',
comment_approved: 'success',
download_ready: 'primary',
verify_email: 'info',
pending_comments: 'warning',
}
return map[type] || 'default'
}
function getTypeLabel(type) {
const map = {
notice: '系统通知',
system: '系统通知',
comment_reply: '评论回复',
comment_approved: '评论审核',
download_ready: '下载就绪',
verify_email: '邮箱验证',
pending_comments: '待审评论',
}
return map[type] || type || '通知'
}
function rowClassName(row) {
return row.is_read ? '' : 'aksrt-row--unread'
}
async function loadMessages() {
loading.value = true
try {
const response = await api.getNotificationOverview({
page: pagination.page,
per_page: pagination.pageSize,
...(filters.type ? { type: filters.type } : {}),
...(filters.read !== null ? { is_read: filters.read } : {}),
})
const payload = response.data || {}
const summary = payload.summary || {}
const pager = payload.pagination || {}
messages.value = Array.isArray(payload.recent) ? payload.recent : []
typeStats.value = Array.isArray(payload.types) ? payload.types : []
stats.total = Number(summary.total || payload.total || 0)
stats.unread = Number(summary.unread || payload.unread || 0)
stats.today = Number(summary.today || payload.today || 0)
stats.system = Number(summary.system || payload.system || 0)
pagination.itemCount = Number(pager.total_items || messages.value.length)
pagination.pageCount = Number(pager.total_pages || 1)
pagination.page = Number(pager.page || pagination.page)
pagination.pageSize = Number(pager.per_page || pagination.pageSize)
} catch (error) {
message.error('加载消息失败: ' + (error.message || '未知错误'))
} finally {
loading.value = false
}
}
function openDetail(row) {
detailMessage.value = row
showDetail.value = true
}
async function markOneRead(row) {
try {
await api.markNotificationRead(row.id)
row.is_read = true
stats.unread = Math.max(0, stats.unread - 1)
if (detailMessage.value && detailMessage.value.id === row.id) {
detailMessage.value.is_read = true
}
message.success('已标记为已读')
} catch (error) {
message.error(error.message || '操作失败')
}
}
async function markCurrentRead() {
if (detailMessage.value) {
await markOneRead(detailMessage.value)
}
}
async function markAllRead() {
markingAll.value = true
try {
await api.markAllNotificationsRead()
messages.value = messages.value.map((item) => ({ ...item, is_read: true }))
if (detailMessage.value) {
detailMessage.value = { ...detailMessage.value, is_read: true }
}
stats.unread = 0
message.success('全部已读')
} catch (error) {
message.error(error.message || '操作失败')
} finally {
markingAll.value = false
}
}
function handleFilterChange() {
pagination.page = 1
loadMessages()
}
function handlePageChange(page) {
pagination.page = page
loadMessages()
}
function handlePageSizeChange(pageSize) {
pagination.pageSize = pageSize
pagination.page = 1
loadMessages()
}
onMounted(loadMessages)
</script>
<style scoped>
.aksrt-row--unread {
font-weight: 600;
}
.aksrt-message-detail {
margin-top: 16px;
}
.aksrt-message-detail h4 {
margin: 0 0 8px;
font-size: 14px;
}
.aksrt-message-detail p {
margin: 0 0 12px;
color: #666;
line-height: 1.7;
white-space: pre-wrap;
}
</style>
@@ -1,357 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="举报治理"
desc="统一维护公开举报入口与后台工单台,支持证据查看、状态流转和处罚语义联动。"
/>
<n-grid cols="1 s:1 m:2" :x-gap="16" :y-gap="16" responsive="screen">
<n-grid-item>
<n-card size="small" title="治理开关">
<n-space wrap>
<n-tag round :type="form.user_report_enabled ? 'success' : 'default'">
举报入口:{{ form.user_report_enabled ? '已启用' : '已关闭' }}
</n-tag>
<n-tag round :type="form.user_report_public_entry ? 'info' : 'default'">
公开入口:{{ form.user_report_public_entry ? '显示' : '隐藏' }}
</n-tag>
<n-tag round :type="moderationSummary.enabled ? 'success' : 'default'">
审核系统:{{ moderationSummary.enabled ? '已启用' : '未启用' }}
</n-tag>
<n-tag round type="warning">
待处理:{{ counts.pending }}
</n-tag>
<n-tag round type="success">
已解决:{{ counts.resolved }}
</n-tag>
</n-space>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small" title="前台说明">
<n-form :model="form" label-placement="top">
<n-form-item label="限制提示文案">
<n-input v-model:value="form.user_report_ban_notice_text" maxlength="80" />
</n-form-item>
<n-form-item label="举报分类字典">
<n-input
v-model:value="form.user_report_categories"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="spam|垃圾信息&#10;abuse|人身攻击"
/>
</n-form-item>
</n-form>
</n-card>
</n-grid-item>
</n-grid>
<n-card size="small" title="入口配置" class="aksrt-report-page__card">
<n-form :model="form" label-placement="left" label-width="180" class="aksrt-form">
<n-form-item label="启用举报/禁封结构">
<n-switch v-model:value="form.user_report_enabled" />
</n-form-item>
<n-form-item label="公开主页展示举报入口">
<n-switch v-model:value="form.user_report_public_entry" :disabled="!form.user_report_enabled" />
</n-form-item>
<n-form-item label="公开显示限制提示">
<n-switch v-model:value="form.user_report_public_notice" :disabled="!form.user_report_enabled" />
</n-form-item>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-form>
</n-card>
<n-card size="small" title="举报工单台" class="aksrt-report-page__card">
<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.target_type" :options="targetTypeOptions" 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-button type="primary" block @click="handleSearch">筛选工单</n-button>
</n-grid-item>
</n-grid>
<n-data-table
:columns="columns"
:data="reports"
:loading="reportLoading"
:bordered="false"
:pagination="pagination"
:row-key="(row) => row.id"
/>
</n-space>
</n-card>
<ReportDetailDrawer
v-model:show="drawerVisible"
:report-id="currentReportId"
@updated="loadReports"
/>
</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 ReportDetailDrawer from './components/ReportDetailDrawer.vue'
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const reportLoading = ref(false)
const reports = ref([])
const drawerVisible = ref(false)
const currentReportId = ref(null)
const form = reactive({
user_report_enabled: false,
user_report_public_entry: true,
user_report_public_notice: true,
user_report_ban_notice_text: '当前账号处于限制状态,部分互动能力已关闭。',
user_report_categories: 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉',
})
const moderationSummary = reactive({
enabled: false,
})
const filters = reactive({
keyword: '',
status: '',
target_type: '',
dateRange: null,
})
const counts = reactive({
pending: 0,
reviewing: 0,
resolved: 0,
dismissed: 0,
total: 0,
})
const pager = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
})
const optionsState = reactive({
statuses: [],
targets: [],
})
const statusOptions = computed(() => optionsState.statuses.length
? [{ label: '全部状态', value: '' }, ...optionsState.statuses]
: [
{ label: '全部状态', value: '' },
{ label: '待处理', value: 'pending' },
{ label: '处理中', value: 'reviewing' },
{ label: '已解决', value: 'resolved' },
{ label: '已驳回', value: 'dismissed' },
])
const targetTypeOptions = computed(() => optionsState.targets.length
? [{ label: '全部类型', value: '' }, ...optionsState.targets]
: [
{ label: '全部类型', value: '' },
{ label: '文章', value: 'post' },
{ label: '评论', value: 'comment' },
{ label: '用户', value: 'user' },
])
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: pager.itemCount,
showSizePicker: true,
pageSizes: [10, 20, 50],
onUpdatePage: (page) => {
pager.page = page
loadReports()
},
onUpdatePageSize: (pageSize) => {
pager.pageSize = pageSize
pager.page = 1
loadReports()
},
}))
const columns = [
{ title: 'ID', key: 'id', width: 70 },
{
title: '举报人',
key: 'reporter',
width: 160,
render: (row) => row.reporter?.display_name || '-',
},
{
title: '目标',
key: 'target',
minWidth: 220,
ellipsis: { tooltip: true },
render: (row) => row.target?.title || row.target?.display_name || row.target?.excerpt || `目标 #${row.target_id}`,
},
{
title: '状态',
key: 'status',
width: 100,
render: (row) => {
const typeMap = { pending: 'warning', reviewing: 'info', resolved: 'success', dismissed: 'default' }
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status)
},
},
{
title: '分类',
key: 'category',
width: 120,
render: (row) => row.category || '-',
},
{
title: '处理记录',
key: 'note_count',
width: 90,
},
{
title: '提交时间',
key: 'created_at',
width: 170,
},
{
title: '操作',
key: 'actions',
width: 120,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
onClick: () => openDrawer(row.id),
}, () => '查看'),
]),
},
]
onMounted(async () => {
await load()
})
function extractData(response) {
return response?.data ?? response ?? {}
}
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 buildReportParams() {
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.target_type) params.target_type = filters.target_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 load() {
loading.value = true
try {
const [schemaRes, moderationRes] = await Promise.all([
api.getSchemaPage('users', 'report'),
api.getPermissionsModerationSettings(),
])
const schemaData = extractData(schemaRes)
const moderationData = extractData(moderationRes)
form.user_report_enabled = !!schemaData.user_report_enabled
form.user_report_public_entry = schemaData.user_report_public_entry !== false
form.user_report_public_notice = schemaData.user_report_public_notice !== false
form.user_report_ban_notice_text = schemaData.user_report_ban_notice_text || '当前账号处于限制状态,部分互动能力已关闭。'
form.user_report_categories = schemaData.user_report_categories || 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉'
moderationSummary.enabled = !!moderationData.enabled
await loadReports()
} catch (error) {
message.error(error?.message || '加载举报治理页失败')
} finally {
loading.value = false
}
}
async function loadReports() {
reportLoading.value = true
try {
const res = await api.request('/users/reports', { params: buildReportParams() })
const data = res?.data || {}
reports.value = data.items || []
pager.itemCount = data.total || 0
Object.assign(counts, data.counts || {})
optionsState.statuses = data.options?.statuses || []
optionsState.targets = data.options?.targets || []
} catch (error) {
message.error(error?.message || '加载举报工单失败')
} finally {
reportLoading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.saveSchemaPage('users', 'report', {
user_report_enabled: form.user_report_enabled,
user_report_public_entry: form.user_report_public_entry,
user_report_public_notice: form.user_report_public_notice,
user_report_ban_notice_text: form.user_report_ban_notice_text,
user_report_categories: form.user_report_categories,
})
message.success('举报治理配置已保存')
} catch (error) {
message.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
function handleSearch() {
pager.page = 1
loadReports()
}
function openDrawer(reportId) {
currentReportId.value = reportId
drawerVisible.value = true
}
</script>
<style scoped>
.aksrt-report-page__card {
margin-top: 16px;
}
</style>
+34 -298
View File
@@ -1,220 +1,52 @@
<template> <template>
<div class="aksrt-page-container"> <div class="aksrt-page-container">
<PageHeader <PageHeader title="身份认证" desc="配置通用认证申请规则;申请审核和用户认证资料管理位于 WordPress 用户菜单。" />
title="身份认证"
desc="统一管理认证申请规则与云服务商凭证;审核、驳回和取消认证仍在 WordPress 用户菜单处理。"
/>
<n-spin :show="loading" style="width: 100%; min-height: 240px"> <n-spin :show="loading" style="width: 100%; min-height: 240px">
<n-grid :cols="3" :x-gap="12" :y-gap="12" responsive="screen" item-responsive> <n-card class="aksrt-section-card" title="认证申请设置">
<n-grid-item span="3 m:1"> <n-form :model="form" label-placement="left" label-width="190">
<n-card class="aksrt-status-card" size="small"> <n-form-item label="启用身份认证"><n-switch v-model:value="form.user_auth_s" /></n-form-item>
<n-statistic label="身份认证功能"> <n-form-item label="允许用户前台申请"><n-switch v-model:value="form.user_auth_apply_s" :disabled="!form.user_auth_s" /></n-form-item>
<template #default>{{ verifyForm.user_verify_enabled ? '已启用' : '已停用' }}</template> <n-form-item v-if="!form.user_auth_apply_s" label="停用申请说明">
</n-statistic> <n-input v-model:value="form.user_auth_apply_disable_desc" type="textarea" :rows="2" />
<n-tag :type="verifyForm.user_verify_enabled ? 'success' : 'default'" round size="small">
{{ verifyForm.user_verify_enabled ? '运行中' : '未运行' }}
</n-tag>
</n-card>
</n-grid-item>
<n-grid-item span="3 m:1" v-for="key in providerKeys" :key="key">
<n-card class="aksrt-status-card" size="small">
<n-statistic :label="providerLabel(key) + '凭证'">
<template #default>{{ providerState(key).configured ? '已配置' : '未配置' }}</template>
</n-statistic>
<n-tag :type="providerState(key).configured ? 'success' : 'warning'" round size="small">
{{ identityForm.provider === key ? '当前服务商' : '备用服务商' }}
</n-tag>
</n-card>
</n-grid-item>
</n-grid>
<n-card class="aksrt-section-card" title="通用认证设置">
<n-form :model="verifyForm" label-placement="left" label-width="190">
<n-form-item label="启用身份认证"><n-switch v-model:value="verifyForm.user_verify_enabled" /></n-form-item>
<n-form-item label="公开页显示认证徽标"><n-switch v-model:value="verifyForm.user_verify_public_display" /></n-form-item>
<n-form-item label="允许用户前台申请"><n-switch v-model:value="verifyForm.user_verify_allow_apply" /></n-form-item>
<n-form-item label="云失败转人工"><n-switch v-model:value="verifyForm.user_verify_allow_manual_fallback" /></n-form-item>
<n-form-item label="认证会话有效期(分钟)"><n-input-number v-model:value="verifyForm.user_verify_session_ttl_minutes" :min="5" :max="1440" /></n-form-item>
<n-form-item label="结果轮询间隔(秒)"><n-input-number v-model:value="verifyForm.user_verify_poll_interval_seconds" :min="3" :max="60" /></n-form-item>
<n-form-item label="个人认证费用"><n-input v-model:value="verifyForm.user_verify_personal_fee" placeholder="0.00" /></n-form-item>
<n-form-item label="企业认证费用"><n-input v-model:value="verifyForm.user_verify_enterprise_fee" placeholder="0.00" /></n-form-item>
<n-form-item label="个人认证徽标文案">
<n-input v-model:value="verifyForm.user_verify_personal_badge_text" placeholder="个人认证" />
</n-form-item> </n-form-item>
<n-form-item label="企业认证徽标文案"> <n-form-item v-if="form.user_auth_apply_s" label="申请最低用户等级">
<n-input v-model:value="verifyForm.user_verify_enterprise_badge_text" placeholder="企业认证" /> <n-input-number v-model:value="form.user_auth_apply_limit_level" :min="0" :max="30" />
<template #feedback>设置为 0 时不限制用户等级。</template>
</n-form-item> </n-form-item>
<n-form-item label="停用申请说明"> <n-form-item v-if="form.user_auth_apply_s" label="申请介绍">
<n-input v-model:value="verifyForm.user_verify_disable_desc" type="textarea" :rows="2" /> <n-input v-model:value="form.user_auth_apply_desc" type="textarea" :rows="3" />
</n-form-item>
<n-form-item label="申请介绍">
<n-input v-model:value="verifyForm.user_verify_desc" type="textarea" :rows="3" />
</n-form-item> </n-form-item>
</n-form> </n-form>
</n-card> </n-card>
<n-card class="aksrt-section-card" title="云服务商">
<n-form :model="identityForm" label-placement="left" label-width="190">
<n-form-item label="当前服务商">
<n-radio-group v-model:value="identityForm.provider">
<n-space>
<n-radio-button value="aliyun">阿里云</n-radio-button>
<n-radio-button value="tencent">腾讯云</n-radio-button>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="回调地址">
<n-input-group>
<n-input :value="callbackUrlForCurrentProvider" readonly />
<n-button @click="copyCallback">复制</n-button>
</n-input-group>
<template #feedback>将此地址填入服务商控制台需要的回调地址配置。</template>
</n-form-item>
</n-form>
</n-card>
<n-tabs v-model:value="activeTab" type="segment" animated class="aksrt-provider-tabs">
<n-tab-pane v-for="key in providerKeys" :key="key" :name="key" :tab="providerLabel(key)">
<n-card class="aksrt-section-card">
<template #header>
<n-space align="center">
<span>{{ providerLabel(key) }}凭证</span>
<n-tag :type="providerState(key).configured ? 'success' : 'warning'" size="small" round>
{{ providerState(key).configured ? '已配置' : '未配置' }}
</n-tag>
</n-space>
</template>
<n-alert type="info" :show-icon="false" style="margin-bottom: 18px">
已保存凭证不会回显。留空表示保留原值;输入新值并保存会替换对应凭证。
</n-alert>
<n-form label-placement="left" label-width="190">
<n-form-item v-if="key === 'aliyun'" label="Region">
<n-input v-model:value="verifyForm.user_verify_aliyun_region" placeholder="cn-hangzhou" />
</n-form-item>
<n-form-item v-if="key === 'aliyun'" label="个人认证 SceneId">
<n-input v-model:value="verifyForm.user_verify_aliyun_personal_flow" placeholder="从阿里云实人认证控制台获取 SceneId" />
</n-form-item>
<n-form-item v-if="key === 'aliyun'" label="个人认证 ProductCode">
<n-select v-model:value="verifyForm.user_verify_aliyun_product_code" :options="aliyunProductOptions" />
</n-form-item>
<n-form-item v-if="key === 'aliyun'" label="企业认证业务标识">
<n-input v-model:value="verifyForm.user_verify_aliyun_enterprise_flow" placeholder="可留空,默认复用个人认证 SceneId" />
</n-form-item>
<n-form-item v-if="key === 'tencent'" label="Region">
<n-input v-model:value="verifyForm.user_verify_tencent_region" placeholder="ap-guangzhou" />
</n-form-item>
<n-form-item v-if="key === 'tencent'" label="个人认证 RuleId">
<n-input v-model:value="verifyForm.user_verify_tencent_personal_flow" placeholder="从腾讯云实名核身控制台获取 RuleId" />
</n-form-item>
<n-form-item v-if="key === 'tencent'" label="企业认证流程标识">
<n-input v-model:value="verifyForm.user_verify_tencent_enterprise_flow" placeholder="可留空,企业四要素后复用个人认证 RuleId" />
</n-form-item>
<n-form-item v-for="field in credentialFields[key]" :key="field.key" :label="field.label">
<n-input
v-model:value="credentials[key][field.key]"
type="password"
show-password-on="click"
:placeholder="credentialPlaceholder(key, field.key)"
autocomplete="new-password"
/>
</n-form-item>
<n-form-item label="显式清除凭证">
<n-checkbox v-model:checked="clearCredentials[key]">
保存时删除 {{ providerLabel(key) }}全部凭证
</n-checkbox>
<template #feedback>清除后该服务商立即变为未配置;此操作不会因输入框留空而触发。</template>
</n-form-item>
<n-form-item label="配置测试">
<n-button :loading="testing === key" :disabled="saving" @click="testProvider(key)">
测试 {{ providerLabel(key) }}配置
</n-button>
</n-form-item>
</n-form>
</n-card>
</n-tab-pane>
</n-tabs>
<div class="aksrt-form-actions"> <div class="aksrt-form-actions">
<n-button :disabled="!dirty" @click="load">放弃更改</n-button> <n-button :disabled="!dirty || saving" @click="load">放弃更改</n-button>
<n-button type="primary" :loading="saving" @click="save">保存全部配置</n-button> <n-button type="primary" :disabled="!dirty" :loading="saving" @click="save">保存设置</n-button>
</div> </div>
</n-spin> </n-spin>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useDialog, useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
import api from '@/api' import api from '@/api'
const defaults = {
user_auth_s: true,
user_auth_apply_s: true,
user_auth_apply_disable_desc: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
user_auth_apply_limit_level: 0,
user_auth_apply_desc: '请填写认证身份、简介和申请说明,提交后由管理员审核。',
}
const message = useMessage() const message = useMessage()
const dialog = useDialog() const form = reactive({ ...defaults })
const providerKeys = ['aliyun', 'tencent'] const initial = ref('')
const aliyunProductOptions = [
{ label: '实人认证(ID_PRO)', value: 'ID_PRO' },
{ label: '活体人脸验证(PV_FV)', value: 'PV_FV' },
{ label: '活体检测(LR_FR)', value: 'LR_FR' },
]
const credentialFields = {
aliyun: [
{ key: 'access_key_id', label: 'AccessKey ID' },
{ key: 'access_key_secret', label: 'AccessKey Secret' },
],
tencent: [
{ key: 'secret_id', label: 'SecretId' },
{ key: 'secret_key', label: 'SecretKey' },
],
}
const verifyDefaults = {
user_verify_enabled: true,
user_verify_public_display: true,
user_verify_allow_apply: true,
user_verify_allow_manual_fallback: true,
user_verify_session_ttl_minutes: 30,
user_verify_poll_interval_seconds: 5,
user_verify_personal_fee: '0.00',
user_verify_enterprise_fee: '0.00',
user_verify_personal_badge_text: '个人认证',
user_verify_enterprise_badge_text: '企业认证',
user_verify_disable_desc: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
user_verify_desc: '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。',
user_verify_aliyun_region: 'cn-hangzhou',
user_verify_aliyun_personal_flow: '',
user_verify_aliyun_product_code: 'ID_PRO',
user_verify_aliyun_enterprise_flow: '',
user_verify_tencent_region: 'ap-guangzhou',
user_verify_tencent_personal_flow: '',
user_verify_tencent_enterprise_flow: '',
}
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const testing = ref('') const snapshot = computed(() => JSON.stringify(form))
const activeTab = ref('aliyun') const dirty = computed(() => initial.value !== '' && snapshot.value !== initial.value)
const callbackUrls = reactive({ aliyun: '', tencent: '' })
const callbackUrlForCurrentProvider = computed(() => callbackUrls[identityForm.provider] || '')
const initialSnapshot = ref('')
const states = reactive({ aliyun: {}, tencent: {} })
const verifyForm = reactive({ ...verifyDefaults })
const identityForm = reactive({ provider: 'aliyun' })
const credentials = reactive({ aliyun: {}, tencent: {} })
const clearCredentials = reactive({ aliyun: false, tencent: false })
const snapshot = computed(() => JSON.stringify({ verifyForm, identityForm, credentials, clearCredentials }))
const dirty = computed(() => initialSnapshot.value !== '' && snapshot.value !== initialSnapshot.value)
watch(() => identityForm.provider, value => { activeTab.value = value })
function providerLabel(key) {
return key === 'aliyun' ? '阿里云' : '腾讯云'
}
function providerState(key) {
return states[key] || {}
}
function credentialPlaceholder(provider, field) {
return providerState(provider).configured_fields?.[field] ? '已配置,留空则保留' : '尚未配置'
}
function extractData(response) { function extractData(response) {
return response?.data ?? response ?? {} return response?.data ?? response ?? {}
} }
@@ -222,123 +54,27 @@ function extractData(response) {
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const [verifyResponse, identityResponse] = await Promise.all([ Object.assign(form, defaults, extractData(await api.getSchemaPage('users', 'verify')))
api.getSchemaPage('users', 'verify'), initial.value = snapshot.value
api.getIdentitySettings(),
])
Object.assign(verifyForm, verifyDefaults, extractData(verifyResponse))
const identity = extractData(identityResponse)
identityForm.provider = providerKeys.includes(identity.provider) ? identity.provider : 'aliyun'
Object.assign(callbackUrls, identity.callback_url || {})
providerKeys.forEach(key => {
states[key] = identity.providers?.[key] || {}
credentials[key] = {}
clearCredentials[key] = false
})
activeTab.value = identityForm.provider
initialSnapshot.value = snapshot.value
} catch (error) { } catch (error) {
message.error(error.message || '加载身份认证配置失败') message.error(error.message || '加载身份认证设置失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function buildProviderPayload(key) {
const replacement = {}
credentialFields[key].forEach(({ key: field }) => {
const value = String(credentials[key]?.[field] || '').trim()
if (value) replacement[field] = value
})
return {
credentials: replacement,
clear_credentials: clearCredentials[key],
}
}
async function persist() {
const [verifyResponse, identityResponse] = await Promise.all([
api.saveSchemaPage('users', 'verify', { ...verifyForm }),
api.saveIdentitySettings({
provider: identityForm.provider,
providers: {
aliyun: buildProviderPayload('aliyun'),
tencent: buildProviderPayload('tencent'),
},
}),
])
const identity = extractData(identityResponse)
Object.assign(verifyForm, extractData(verifyResponse))
providerKeys.forEach(key => {
states[key] = identity.providers?.[key] || states[key]
credentials[key] = {}
clearCredentials[key] = false
})
Object.assign(callbackUrls, identity.callback_url || callbackUrls)
initialSnapshot.value = snapshot.value
}
async function save() { async function save() {
const hasClear = providerKeys.some(key => clearCredentials[key])
const run = async () => {
saving.value = true saving.value = true
try { try {
await persist() Object.assign(form, defaults, extractData(await api.saveSchemaPage('users', 'verify', { ...form })))
message.success('身份认证配置已保存') initial.value = snapshot.value
message.success('身份认证设置已保存')
} catch (error) { } catch (error) {
message.error(error.message || '保存失败') message.error(error.message || '保存身份认证设置失败')
} finally { } finally {
saving.value = false saving.value = false
} }
} }
if (!hasClear) return run()
dialog.warning({
title: '确认清除凭证',
content: '已勾选的服务商凭证将被永久删除,是否继续保存?',
positiveText: '清除并保存',
negativeText: '取消',
onPositiveClick: run,
})
}
async function testProvider(provider) {
testing.value = provider
try {
const response = await api.testIdentitySettings({ provider })
message.success(response?.message || `${providerLabel(provider)}配置测试通过`)
} catch (error) {
message.error(error.message || `${providerLabel(provider)}配置测试失败`)
} finally {
testing.value = ''
}
}
async function copyCallback() {
if (!callbackUrlForCurrentProvider.value) return message.warning('回调地址暂不可用')
try {
await navigator.clipboard.writeText(callbackUrlForCurrentProvider.value)
message.success('回调地址已复制')
} catch {
message.error('复制失败,请手动复制')
}
}
onMounted(load) onMounted(load)
</script> </script>
<style scoped>
.aksrt-status-card :deep(.n-card__content) {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.aksrt-section-card { margin-top: 16px; }
.aksrt-provider-tabs { margin-top: 16px; }
.aksrt-form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
</style>
@@ -1,242 +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.report">
<n-descriptions bordered :column="1" size="small" label-placement="left">
<n-descriptions-item label="举报人">
{{ detail.report.reporter?.display_name || `用户 #${detail.report.reporter?.id || 0}` }}
</n-descriptions-item>
<n-descriptions-item label="举报目标">
{{ targetTitle }}
</n-descriptions-item>
<n-descriptions-item label="目标类型">
{{ detail.report.target_type }}
</n-descriptions-item>
<n-descriptions-item label="分类">
{{ detail.report.category || '-' }}
</n-descriptions-item>
<n-descriptions-item label="当前状态">
{{ detail.report.status }}
</n-descriptions-item>
<n-descriptions-item label="提交时间">
{{ detail.report.created_at || '-' }}
</n-descriptions-item>
</n-descriptions>
<div class="aksrt-report-drawer__section">
<div class="aksrt-report-drawer__title">证据链</div>
<n-alert type="info" :show-icon="false">
{{ detail.report.reason || '暂无举报原因' }}
</n-alert>
<n-space class="aksrt-report-drawer__meta" wrap>
<n-tag size="small" round :bordered="false">
目标 ID:{{ detail.report.target_id }}
</n-tag>
<n-tag
v-if="detail.report.target?.status"
size="small"
type="warning"
round
:bordered="false"
>
目标状态:{{ detail.report.target.status }}
</n-tag>
<a
v-if="detail.report.target?.url"
:href="detail.report.target.url"
target="_blank"
rel="noreferrer"
class="aksrt-report-drawer__link"
>
打开目标内容
</a>
</n-space>
</div>
<div class="aksrt-report-drawer__section">
<div class="aksrt-report-drawer__title">处理记录</div>
<div v-if="detail.report.notes?.length" class="aksrt-report-drawer__timeline">
<div v-for="(entry, index) in detail.report.notes" :key="index" class="aksrt-report-drawer__timeline-item">
<div class="aksrt-report-drawer__timeline-head">
<strong>{{ entry.status || '-' }}</strong>
<span>{{ entry.timestamp || '-' }}</span>
</div>
<p>{{ entry.note || '无备注' }}</p>
<small>{{ entry.operator_name || '系统' }} {{ entry.effect || '' }}</small>
</div>
</div>
<n-empty v-else description="暂无处理记录" />
</div>
<div class="aksrt-report-drawer__section">
<div class="aksrt-report-drawer__title">处理工单</div>
<n-space vertical :size="12">
<n-select v-model:value="form.status" :options="detail.options?.statuses || []" />
<n-select v-model:value="form.action_taken" :options="detail.options?.actions || []" />
<n-input
v-model:value="form.note"
type="textarea"
:rows="4"
placeholder="记录处理依据、证据补充或处罚说明"
/>
<n-space>
<n-button type="primary" :loading="submitting" @click="handleSubmit">提交处理</n-button>
<n-button @click="loadDetail">刷新详情</n-button>
</n-space>
</n-space>
</div>
</template>
<n-empty v-else description="暂无举报详情" />
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import api from '@/api'
const props = defineProps({
show: { type: Boolean, default: false },
reportId: { type: [Number, String], default: null },
})
const emit = defineEmits(['update:show', 'updated'])
const message = useMessage()
const loading = ref(false)
const submitting = ref(false)
const detail = ref({ report: null, options: { statuses: [], actions: [] } })
const form = reactive({
status: 'reviewing',
action_taken: 'none',
note: '',
})
const targetTitle = computed(() => {
const target = detail.value?.report?.target
if (!target) {
return '-'
}
return target.title || target.display_name || target.excerpt || `目标 #${target.id || 0}`
})
watch(
() => [props.show, props.reportId],
async ([visible, id]) => {
if (visible && id) {
await loadDetail()
}
if (!visible) {
detail.value = { report: null, options: { statuses: [], actions: [] } }
resetForm()
}
},
)
function resetForm() {
form.status = 'reviewing'
form.action_taken = 'none'
form.note = ''
}
async function loadDetail() {
if (!props.reportId) {
return
}
loading.value = true
try {
const res = await api.request(`/users/reports/${props.reportId}`)
detail.value = res?.data || { report: null, options: { statuses: [], actions: [] } }
form.status = detail.value?.report?.status || 'reviewing'
form.action_taken = 'none'
form.note = ''
} catch (error) {
message.error(error?.message || '加载举报详情失败')
} finally {
loading.value = false
}
}
async function handleSubmit() {
if (!props.reportId) {
return
}
submitting.value = true
try {
await api.request(`/users/reports/${props.reportId}/process`, {
method: 'POST',
data: {
status: form.status,
action_taken: form.action_taken,
note: form.note,
},
})
message.success('举报工单已处理')
await loadDetail()
emit('updated')
} catch (error) {
message.error(error?.message || '处理失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.aksrt-report-drawer__section {
margin-top: 20px;
}
.aksrt-report-drawer__title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.aksrt-report-drawer__meta {
margin-top: 12px;
}
.aksrt-report-drawer__link {
color: #2563eb;
text-decoration: none;
}
.aksrt-report-drawer__timeline {
display: grid;
gap: 12px;
}
.aksrt-report-drawer__timeline-item {
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #f8fafc;
}
.aksrt-report-drawer__timeline-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
color: #374151;
}
.aksrt-report-drawer__timeline-item p {
margin: 0 0 6px;
color: #111827;
white-space: pre-wrap;
}
.aksrt-report-drawer__timeline-item small {
color: #6b7280;
}
</style>
@@ -7,7 +7,6 @@ export const corePageLoaders = {
PostPageSettings: () => import('@/pages/post-list/PostPageSettings.vue'), PostPageSettings: () => import('@/pages/post-list/PostPageSettings.vue'),
CommentSettings: () => import('@/pages/post-list/CommentSettings.vue'), CommentSettings: () => import('@/pages/post-list/CommentSettings.vue'),
PermissionsCapabilities: () => import('@/pages/permissions/PermissionsCapabilities.vue'), PermissionsCapabilities: () => import('@/pages/permissions/PermissionsCapabilities.vue'),
AttachmentPermissions: () => import('@/pages/permissions/AttachmentPermissions.vue'),
CmsCredits: () => import('@/pages/CmsCredits.vue'), CmsCredits: () => import('@/pages/CmsCredits.vue'),
ThemeInfoPage: () => import('@/pages/theme/ThemeInfoPage.vue'), ThemeInfoPage: () => import('@/pages/theme/ThemeInfoPage.vue'),
ThemeAuthorizationPage: () => import('@/pages/theme/ThemeAuthorizationPage.vue'), ThemeAuthorizationPage: () => import('@/pages/theme/ThemeAuthorizationPage.vue'),
@@ -1,3 +0,0 @@
export const extensionsPageLoaders = {
ExtensionAI: () => import('@/pages/extensions/ExtensionAI.vue'),
}
@@ -1,13 +1,9 @@
import { corePageLoaders } from './core' import { corePageLoaders } from './core'
import { usersPageLoaders } from './users' import { usersPageLoaders } from './users'
import { paymentPageLoaders } from './payment'
import { toolsPageLoaders } from './tools' import { toolsPageLoaders } from './tools'
import { extensionsPageLoaders } from './extensions'
export const componentLoaders = { export const componentLoaders = {
...corePageLoaders, ...corePageLoaders,
...usersPageLoaders, ...usersPageLoaders,
...paymentPageLoaders,
...toolsPageLoaders, ...toolsPageLoaders,
...extensionsPageLoaders,
} }
@@ -1,3 +0,0 @@
export const paymentPageLoaders = {
PaymentGateway: () => import('@/pages/payment/PaymentGateway.vue'),
}
+3 -6
View File
@@ -72,16 +72,13 @@ function createSectionRedirectRoutes() {
} }
/** /**
* Old SPA ops routes → native WP admin pages (Phase 4 transition). * Old SPA ops routes → native WP admin pages.
* Bookmarks under order-related paths land on NativeOpsRedirect. * Bookmarks under order-related paths land on NativeOpsRedirect.
* (封禁/举报 as a sidebar entry lives in constants/sections/users.js → /users/ban.)
*/ */
const NATIVE_OPS_REDIRECTS = [ const NATIVE_OPS_REDIRECTS = [
{ path: '/payment/charge-card', title: '充值卡', nativeAdminPath: 'admin.php?page=aksrt_charge_cards' },
{ path: '/payment/income', title: '创作分成运营', nativeAdminPath: 'admin.php?page=aksrt_income' },
{ path: '/users/messages', title: '消息中心', nativeAdminPath: 'users.php?page=aksrt_messages' }, { path: '/users/messages', title: '消息中心', nativeAdminPath: 'users.php?page=aksrt_messages' },
{ path: '/users/referral-codes', title: '推广码运营', nativeAdminPath: 'users.php?page=aksrt_referral_codes' }, { path: '/users/report', title: '禁封/举报', nativeAdminPath: 'users.php?page=aksrt_user_ban' },
{ path: '/users/ban', title: '禁封管理', nativeAdminPath: 'users.php?page=aksrt_user_ban&tab=restrictions' },
{ path: '/users/report', title: '禁封/举报', nativeAdminPath: 'users.php?page=aksrt_user_ban&tab=reports' },
] ]
const routes = [ const routes = [
-39
View File
@@ -43,17 +43,6 @@ export function normalizeSchemaDomain(domain = '') {
return value.replace(/-/g, '_') return value.replace(/-/g, '_')
} }
export const CANONICAL_SCHEMA_DOMAINS = [
'global',
'page_display',
'post_list',
'permissions',
'users',
'payment',
'extensions',
'tools',
]
/** All schemas organized by domain */ /** All schemas organized by domain */
export const ALL_SCHEMAS = Object.freeze({ export const ALL_SCHEMAS = Object.freeze({
global: globalSchemas, global: globalSchemas,
@@ -77,31 +66,3 @@ export function getSchema(domain, section) {
if (!domainSchemas) return null if (!domainSchemas) return null
return domainSchemas[section] || null return domainSchemas[section] || null
} }
/**
* Get all schemas for a domain.
* @param {string} domain
* @returns {Object}
*/
export function getDomainSchemas(domain) {
return ALL_SCHEMAS[normalizeSchemaDomain(domain)] || {}
}
export function getSchemaSectionKeys(domain) {
return Object.keys(getDomainSchemas(domain))
}
export function getSchemaDefaults(domain, section = '') {
const domainKey = normalizeSchemaDomain(domain)
if (section) {
return { ...(getSchema(domainKey, section)?.defaults || {}) }
}
return Object.values(getDomainSchemas(domainKey)).reduce((defaults, schema) => {
return {
...defaults,
...(schema?.defaults || {}),
}
}, {})
}
@@ -3,12 +3,16 @@
* Each key corresponds to a sub-page under the "扩展&增强" section. * Each key corresponds to a sub-page under the "扩展&增强" section.
*/ */
import { systemSchema } from './system.js'
import { ipSchema } from './ip.js' import { ipSchema } from './ip.js'
import { auditSchema } from './audit.js' import { auditSchema } from './audit.js'
import { pushSchema } from './push.js' import { pushSchema } from './push.js'
import { securitySchema } from './security.js'
export const extensionsSchemas = { export const extensionsSchemas = {
system: systemSchema,
ip: ipSchema, ip: ipSchema,
audit: auditSchema, audit: auditSchema,
push: pushSchema, push: pushSchema,
security: securitySchema,
} }
@@ -2,21 +2,16 @@ export const pushSchema = {
key: 'extensions.push', key: 'extensions.push',
title: '百度推送', title: '百度推送',
icon: 'fa fas fa-paper-plane', icon: 'fa fas fa-paper-plane',
description: '配置搜索引擎主动推送、批量提交和状态追踪。', description: '配置百度主动推送、收录通道和推送状态。',
defaults: { defaults: {
baidu_push_enabled: false, baidu_push_enabled: false,
baidu_push_token: '', baidu_push_token: '',
baidu_push_auto: true, baidu_push_auto: true,
baidu_push_on_publish: true, baidu_push_on_publish: true,
baidu_push_on_update: false, baidu_push_on_update: false,
baidu_push_batch_size: 50, baidu_push_normal: true,
baidu_push_fast: false,
baidu_push_log_enabled: true, baidu_push_log_enabled: true,
xzh_on: false,
xzh_appid: '',
xzh_render_tail: true,
xzh_jsonld_single: true,
xzh_jsonld_page: false,
xzh_jsonld_img: false,
}, },
groups: [ groups: [
{ {
@@ -25,7 +20,7 @@ export const pushSchema = {
icon: 'fa fas fa-rocket', icon: 'fa fas fa-rocket',
fields: [ fields: [
{ key: 'baidu_push_enabled', label: '启用百度推送', type: 'switch', default: false, help: '开启后将自动向百度搜索推送页面链接' }, { key: 'baidu_push_enabled', label: '启用百度推送', type: 'switch', default: false, help: '开启后将自动向百度搜索推送页面链接' },
{ key: 'baidu_push_token', label: '推送 Token', type: 'input', default: '', dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在百度搜索资源平台获取的推送准入 Token' }, { key: 'baidu_push_token', label: '推送 Token', type: 'input', default: '', dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在百度搜索资源平台获取的推送准入 Token(仅填 Token,勿填完整链接),并确保平台中的站点为本站' },
], ],
}, },
{ {
@@ -34,21 +29,17 @@ export const pushSchema = {
icon: 'fa fas fa-bolt', icon: 'fa fas fa-bolt',
fields: [ fields: [
{ key: 'baidu_push_auto', label: '自动推送 (页面加载时)', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在前台页面中嵌入百度自动推送 JS 代码' }, { key: 'baidu_push_auto', label: '自动推送 (页面加载时)', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在前台页面中嵌入百度自动推送 JS 代码' },
{ key: 'baidu_push_on_publish', label: '发布时主动推送', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } }, { key: 'baidu_push_on_publish', label: '发布时主动推送', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '文章 / 分类首次发布时推送链接;推送成功后会记录,不再重复提交' },
{ key: 'baidu_push_on_update', label: '更新时推送', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true } }, { key: 'baidu_push_on_update', label: '更新时推送', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '已发布内容再次保存时,若上次推送未成功则重试;需主动再次提交请在编辑页勾选「重新提交」' },
], ],
}, },
{ {
key: 'xzh', key: 'channel',
title: '熊掌号 / JSON-LD', title: '收录通道',
icon: 'fa fas fa-paw', icon: 'fa fas fa-inbox',
fields: [ fields: [
{ key: 'xzh_on', label: '启用熊掌号', type: 'switch', default: false }, { key: 'baidu_push_normal', label: '普通收录', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '每天可提交最多10万条有价值内容,收录速度较慢' },
{ key: 'xzh_appid', label: '百度 AppID', type: 'input', default: '', dependsOn: { key: 'xzh_on', value: true } }, { key: 'baidu_push_fast', label: '快速收录', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '百度新推出的高效收录接口,目前仅对部分优质站点开放,请确保您的站点已开放快速收录功能' },
{ key: 'xzh_render_tail', label: '文章底部显示熊掌号尾部', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } },
{ key: 'xzh_jsonld_single', label: '文章页 JSON-LD', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } },
{ key: 'xzh_jsonld_page', label: '页面 JSON-LD', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } },
{ key: 'xzh_jsonld_img', label: '不添加图片', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } },
], ],
}, },
{ {
@@ -56,8 +47,7 @@ export const pushSchema = {
title: '高级设置', title: '高级设置',
icon: 'fa fas fa-cog', icon: 'fa fas fa-cog',
fields: [ fields: [
{ key: 'baidu_push_batch_size', label: '批量推送条数', type: 'number', default: 50, min: 1, max: 500, dependsOn: { key: 'baidu_push_enabled', value: true } }, { key: 'baidu_push_log_enabled', label: '记录推送日志', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: 'WP_DEBUG 开启时,将推送结果写入错误日志' },
{ key: 'baidu_push_log_enabled', label: '记录推送日志', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } },
], ],
}, },
], ],
@@ -0,0 +1,105 @@
export const securitySchema = {
key: 'extensions.security',
title: '网站安全',
icon: 'fa fas fa-umbrella',
description: '防刷限制、验证码发送限制与站点安全加固。',
defaults: {
security_brush_post_10min: 20,
security_brush_post_day: 50,
security_brush_comment_10min: 20,
security_brush_comment_day: 100,
security_brush_upload_10min: 50,
security_brush_upload_day: 200,
security_brush_register_10min: 20,
security_brush_register_day: 100,
security_brush_order_10min: 30,
security_brush_order_day: 100,
security_captcha_send_cooldown: 60,
security_captcha_to_hour: 15,
security_captcha_to_day: 30,
security_captcha_ip_10min: 20,
security_captcha_ip_day: 50,
security_error_frequency_limit: true,
security_disabled_pingback: true,
security_disabled_xmlrpc: true,
security_user_disable_admin: true,
close_attachment_page: true,
},
groups: [
{
key: 'brush_post',
title: '[防刷机制]发布限制',
icon: 'fa fas fa-pen',
description: '限制单个用户发布文章、帖子、板块的总数量(为0则不限制)。',
fields: [
{ key: 'security_brush_post_10min', label: '10分钟内最多发布 (篇)', type: 'number', default: 20, min: 0, max: 200 },
{ key: 'security_brush_post_day', label: '一天内最多发布 (篇)', type: 'number', default: 50, min: 0, max: 2000 },
],
},
{
key: 'brush_comment',
title: '[防刷机制]评论限制',
icon: 'fa fas fa-comments',
description: '限制单个用户发布评论的总数量(为0则不限制)。',
fields: [
{ key: 'security_brush_comment_10min', label: '10分钟内最多评论 (条)', type: 'number', default: 20, min: 0, max: 200 },
{ key: 'security_brush_comment_day', label: '一天内最多评论 (条)', type: 'number', default: 100, min: 0, max: 2000, help: '如果启用了免登陆可评论,则根据用户IP地址判断' },
],
},
{
key: 'brush_upload',
title: '[防刷机制]上传限制',
icon: 'fa fas fa-upload',
description: '限制单个用户上传内容的总数量(为0则不限制)。防刷机制主要为了防止用户恶意多次提交内容,请根据网站的用户量合理设置,此机制不会限制管理员。',
fields: [
{ key: 'security_brush_upload_10min', label: '10分钟内最多上传 (个)', type: 'number', default: 50, min: 0, max: 200 },
{ key: 'security_brush_upload_day', label: '一天内最多上传 (个)', type: 'number', default: 200, min: 0, max: 2000 },
],
},
{
key: 'brush_register',
title: '[防刷机制]注册限制',
icon: 'fa fas fa-user-plus',
description: '限制每个IP地址用户注册数量,此项无法判断管理员身份,所以管理员也会受到限制。',
fields: [
{ key: 'security_brush_register_10min', label: '10分钟内最多注册 (个)', type: 'number', default: 20, min: 0, max: 200 },
{ key: 'security_brush_register_day', label: '一天内最多注册 (个)', type: 'number', default: 100, min: 0, max: 2000, help: '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)' },
],
},
{
key: 'brush_order',
title: '[防刷机制]下单限制',
icon: 'fa fas fa-shopping-cart',
description: '限制每个用户下单数量,未登录用户按IP地址判断。',
fields: [
{ key: 'security_brush_order_10min', label: '10分钟内最多下单 (个订单)', type: 'number', default: 30, min: 0, max: 200 },
{ key: 'security_brush_order_day', label: '一天内最多下单 (个订单)', type: 'number', default: 100, min: 0, max: 2000, help: '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)' },
],
},
{
key: 'brush_captcha',
title: '[防刷机制]验证码发送限制',
icon: 'fa fas fa-paper-plane',
description: '限制验证码邮件/短信的发送频率,防止恶意程序批量轰炸特定邮箱或手机号。',
fields: [
{ key: 'security_captcha_send_cooldown', label: '同一收件人/IP发送冷却 (秒)', type: 'number', default: 60, min: 0, max: 600, help: '同一邮箱或手机号、同一IP地址发送验证码后,需间隔此时间才可再次发送(0为不限制)' },
{ key: 'security_captcha_to_hour', label: '同一收件人1小时内最多 (次)', type: 'number', default: 15, min: 0, max: 500 },
{ key: 'security_captcha_to_day', label: '同一收件人一天内最多 (次)', type: 'number', default: 30, min: 0, max: 1000 },
{ key: 'security_captcha_ip_10min', label: '同一IP 10分钟内最多 (次)', type: 'number', default: 20, min: 0, max: 500 },
{ key: 'security_captcha_ip_day', label: '同一IP一天内最多 (次)', type: 'number', default: 50, min: 0, max: 2000, help: '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)' },
],
},
{
key: 'harden',
title: '安全加固',
icon: 'fa fas fa-shield-alt',
fields: [
{ key: 'security_error_frequency_limit', label: '错误频率限制', type: 'switch', default: true, help: '用户进行密码、邮箱、手机号、验证码等验证时,验证错误次数超过5次后限制30秒验证一次,错误超过10次后,限制3分钟验证一次,有效防止暴力破解' },
{ key: 'security_disabled_pingback', label: '防pingback攻击', type: 'switch', default: true },
{ key: 'security_disabled_xmlrpc', label: '禁用XML-RPC', type: 'switch', default: true, help: '如果无需使用xmlrpc功能,建议直接删除WordPress目录下的xmlrpc.php文件' },
{ key: 'security_user_disable_admin', label: '非管理员禁止进入后台', type: 'switch', default: true, help: '为了安全,如非必要请勿允许非管理员进入后台' },
{ key: 'close_attachment_page', label: '非管理员屏蔽附件页面', type: 'switch', default: true, help: '非管理员前台不显示附件(图片、文件等)页面,直接显示404,能有效提高文件的安全性' },
],
},
],
}
@@ -0,0 +1,91 @@
export const systemSchema = {
key: 'extensions.system',
title: '系统工具',
icon: 'fa fas fa-gavel',
description: 'WordPress 优化、上传优化与编辑器显示的系统级开关。',
defaults: {
hide_admin_bar: true,
remove_emoji: true,
remove_open_sans: true,
remove_more_wp_head: true,
remove_dashboard_widgets: true,
display_wp_update: true,
newfilename: false,
newfilename_type: 'time',
split_upload_s: true,
split_minimum_size: 20,
disabled_large_thumbnail: true,
admin_user_del_fields: true,
admin_frontend_set: true,
close_gutenberg: false,
disabled_autoembed: true,
mce_float_toolbar: ['pc_s', 'm_s'],
time_ago_s: true,
time_format: 'n月j日 H:i',
},
groups: [
{
key: 'optimize',
title: 'WordPress 优化',
icon: 'fa fas fa-bolt',
description: '精简WordPress自带的冗余功能,提升前台与后台的加载速度。',
fields: [
{ key: 'hide_admin_bar', label: '前台关闭顶部admin_bar', type: 'switch', default: true, help: '开启后前台不显示WordPress顶部黑条' },
{ key: 'remove_emoji', label: '删除WordPress自带Emoji表情', type: 'switch', default: true },
{ key: 'remove_open_sans', label: '禁用Google字体', type: 'switch', default: true },
{ key: 'remove_more_wp_head', label: '清理多余的头部Meta标签', type: 'switch', default: true },
{ key: 'remove_dashboard_widgets', label: 'WordPress 后台加速', type: 'switch', default: true, help: '移除仪表盘的站点健康和WordPress活动等不必要功能,并禁止检测浏览器和PHP版本(这两个非常耗时),优化后台加载速度' },
{ key: 'display_wp_update', label: '禁止WordPress检测更新', type: 'switch', default: true, help: '开启后将禁用WordPress核心、插件和主题的自动更新及更新检测' },
],
},
{
key: 'upload',
title: '上传优化',
icon: 'fa fas fa-cloud-upload-alt',
description: '上传文件的命名与缩略图裁剪优化,减少硬盘占用并提高上传效率。',
fields: [
{ key: 'split_upload_s', label: '大文件分片上传', type: 'switch', default: true, help: '分片上传大文件,优化大文件上传效率(支持断点续传)' },
{ key: 'split_minimum_size', label: '分片上传最小文件', type: 'number', default: 20, min: 4, max: 100, unit: 'M', dependsOn: { key: 'split_upload_s', value: true }, help: '大于此大小的文件才会分片上传,请根据服务器性能合理设置;较大文件直接上传会比较耗时且无法突破系统上传大小限制,分片上传可以有效解决此问题;前端上传大小限制在「功能权限 → 上传权限」中设置。注意:能否顺利上传大文件还取决于服务器性能、带宽、CDN等因素,如果网站使用了OSS/COS等第三方云储存,则不建议在网站上传大文件' },
{ key: 'newfilename', label: '上传文件自动重命名', type: 'switch', default: false, help: '上传文件时系统会查询重名文件,开启此功能后可减少重名文件,从而提高上传效率' },
{
key: 'newfilename_type',
label: '重命名格式',
type: 'radio',
buttonStyle: false,
default: 'time',
dependsOn: { key: 'newfilename', value: true },
options: [
{ label: '随机字串符', value: 'random' },
{ label: '时间+原文件名', value: 'time' },
],
},
{ key: 'disabled_large_thumbnail', label: '禁止WP大尺寸缩略图裁剪', type: 'switch', default: true, help: '开启后将禁用WordPress自动裁剪medium_large、1536x1536、2048x2048的缩略图,可减少服务器硬盘压力,同时提高上传效率;如果您使用的插件必须要这些尺寸的缩略图,或使用了云储存的图片处理功能,则请关闭' },
],
},
{
key: 'editor',
title: '编辑器与显示',
icon: 'fa fas fa-edit',
description: '编辑器模式、用户资料页与前台时间显示格式的定制。',
fields: [
{ key: 'admin_user_del_fields', label: '关闭后台用户编辑多余的选项', type: 'switch', default: true, help: '开启后在后台编辑用户资料时将不显示无用的多余选项' },
{ key: 'admin_frontend_set', label: '前端页面编辑', type: 'switch', default: true, help: '管理员可在前台快速修改页面、文章参数' },
{ key: 'close_gutenberg', label: '禁用古腾堡编辑器', type: 'switch', default: false, help: '后台文章编辑器将退回使用经典编辑器;禁用后主题的编辑器增强功能将不能使用,请酌情禁用!' },
{ key: 'disabled_autoembed', label: '禁用Auto Embed', type: 'switch', default: true, help: 'Auto Embed是WordPress自动检测并嵌入链接的一种功能,主要适配海外媒体网站,国内几乎用不上,建议禁用,可大幅度提高页面渲染速度' },
{
key: 'mce_float_toolbar',
label: '经典编辑器增强浮动工具栏',
type: 'checkbox',
default: ['pc_s', 'm_s'],
options: [
{ label: 'PC端开启', value: 'pc_s' },
{ label: '移动端开启', value: 'm_s' },
],
help: '开启后前端编辑器会显示浮动的工具栏,更加方便书写',
},
{ key: 'time_ago_s', label: '倒计时显示', type: 'switch', default: true, help: '时间格式化为:X分钟前,X小时前,X天前....' },
{ key: 'time_format', label: '自定义时间格式', type: 'input', default: 'n月j日 H:i', dependsOn: { key: 'time_ago_s', operator: 'falsy' }, help: '关闭倒计时显示后生效,时间格式接受标准PHP时间格式,请注意控制长度!' },
],
},
],
}
@@ -1,8 +0,0 @@
export const floatingSchema = {
key: 'global.floating',
title: '右侧悬浮按钮',
icon: 'fa fas fa-ellipsis-v',
description: '页面右侧浮动按钮的显示与配置。',
defaults: {},
groups: [],
}
@@ -9,12 +9,12 @@ import { layoutSchema } from './layout.js'
import { searchSchema } from './search.js' import { searchSchema } from './search.js'
// Email schema is served by the PHP template catalog to keep defaults and fields canonical. // Email schema is served by the PHP template catalog to keep defaults and fields canonical.
import { tabbarSchema } from './tabbar.js' import { tabbarSchema } from './tabbar.js'
import { floatingSchema } from './floating.js'
import { i18nSchema } from './i18n.js' import { i18nSchema } from './i18n.js'
import { customCodeSchema } from './custom-code.js' import { customCodeSchema } from './custom-code.js'
import { seoSchema } from './seo.js' import { seoSchema } from './seo.js'
import { codeHighlightSchema } from './code-highlight.js' import { codeHighlightSchema } from './code-highlight.js'
import { systemNoticeSchema } from './system-notice.js' import { systemNoticeSchema } from './system-notice.js'
import { shareSchema } from './share.js'
export const globalSchemas = { export const globalSchemas = {
logo: logoSchema, logo: logoSchema,
@@ -23,10 +23,10 @@ export const globalSchemas = {
search: searchSchema, search: searchSchema,
email: null, email: null,
tabbar: tabbarSchema, tabbar: tabbarSchema,
floating: floatingSchema,
i18n: i18nSchema, i18n: i18nSchema,
'custom-code': customCodeSchema, 'custom-code': customCodeSchema,
seo: seoSchema, seo: seoSchema,
'code-highlight': codeHighlightSchema, 'code-highlight': codeHighlightSchema,
'system-notice': systemNoticeSchema, 'system-notice': systemNoticeSchema,
share: shareSchema,
} }
+2 -17
View File
@@ -2,7 +2,7 @@ export const seoSchema = {
key: 'global.seo', key: 'global.seo',
title: 'SEO 优化', title: 'SEO 优化',
icon: 'fa fas fa-search', icon: 'fa fas fa-search',
description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框;连接配置在「扩展 → AI 连接配置」。优先使用 WordPress AI Connectors。', description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框,连接由 WordPress AI Connectors 提供。',
defaults: { defaults: {
seo_enabled: true, seo_enabled: true,
seo_ai_enabled: false, seo_ai_enabled: false,
@@ -13,10 +13,6 @@ export const seoSchema = {
ai_seo_title_prompt: '', ai_seo_title_prompt: '',
ai_seo_keywords_prompt: '', ai_seo_keywords_prompt: '',
ai_seo_description_prompt: '', ai_seo_description_prompt: '',
seo_baidu_push: false,
seo_baidu_normal: false,
seo_baidu_fast: false,
seo_baidu_token: '',
seo_image_optimize: true, seo_image_optimize: true,
seo_image_default: '', seo_image_default: '',
seo_image_always_default: false, seo_image_always_default: false,
@@ -48,7 +44,7 @@ export const seoSchema = {
label: '启用 AI SEO 生成', label: '启用 AI SEO 生成',
type: 'switch', type: 'switch',
default: false, default: false,
help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先配置 AI 连接(WP Connectors 或扩展 → AI 连接配置)。', help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先在 WordPress 配置 AI Connectors。',
dependsOn: { key: 'seo_enabled', value: true }, dependsOn: { key: 'seo_enabled', value: true },
}, },
{ key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |', help: 'AI 生成标题时会用此连接符追加站点名。' }, { key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |', help: 'AI 生成标题时会用此连接符追加站点名。' },
@@ -95,17 +91,6 @@ export const seoSchema = {
}, },
], ],
}, },
{
key: 'baidu',
title: '百度推送',
icon: 'fa fas fa-paper-plane',
fields: [
{ key: 'seo_baidu_push', label: '启用百度自动推送', type: 'switch', default: false },
{ key: 'seo_baidu_normal', label: '普通收录推送', type: 'switch', default: false },
{ key: 'seo_baidu_fast', label: '快速收录推送', type: 'switch', default: false },
{ key: 'seo_baidu_token', label: '百度推送 Token', type: 'input', default: '', placeholder: '百度资源平台 Token' },
],
},
{ {
key: 'image', key: 'image',
title: '图片 SEO', title: '图片 SEO',
@@ -0,0 +1,98 @@
const themeRoot = typeof window !== 'undefined' && window.aksrtAdminData?.themeUri
? String(window.aksrtAdminData.themeUri).replace(/\/$/, '')
: ''
const shareOptions = [
{ label: 'QQ空间', value: 'qzone', icon: 'fa fab fa-qq' },
{ label: '微博', value: 'weibo', icon: 'fa fab fa-weibo' },
{ label: 'QQ好友', value: 'qq', icon: 'fa fab fa-qq' },
{ label: 'Facebook', value: 'facebook', icon: 'fa fab fa-facebook-f' },
{ label: 'X', value: 'twitter', icon: 'fa fab fa-twitter' },
{ label: 'Telegram', value: 'telegram', icon: 'fa fab fa-telegram-plane' },
{ label: 'WhatsApp', value: 'whatsapp', icon: 'fa fab fa-whatsapp' },
{ label: 'LinkedIn', value: 'linkedin', icon: 'fa fab fa-linkedin-in' },
{ label: 'Reddit', value: 'reddit', icon: 'fa fab fa-reddit-alien' },
{ label: 'LINE', value: 'line', icon: 'fa fab fa-line' },
{ label: 'VK', value: 'vk', icon: 'fa fab fa-vk' },
{ label: '邮件', value: 'email', icon: 'fa fas fa-envelope' },
{ label: '海报分享', value: 'poster', icon: 'fa fas fa-image' },
{ label: '复制链接', value: 'copy', icon: 'fa fas fa-link' },
]
const defaultShareItems = {
enabled: { qzone: 'QQ空间', weibo: '微博', qq: 'QQ好友', poster: '海报分享', copy: '复制链接' },
disabled: { facebook: 'Facebook', twitter: 'X', telegram: 'Telegram', whatsapp: 'WhatsApp', linkedin: 'LinkedIn', reddit: 'Reddit', line: 'LINE', vk: 'VK', email: '邮件' },
}
export const shareSchema = {
key: 'global.share',
title: '社交分享',
icon: 'fa fas fa-share-alt',
description: '配置文章分享平台、微信 JSAPI 分享和海报分享。',
defaults: {
share_s: true,
share_items: defaultShareItems,
wechat_share_s: false,
wechat_share_option: { appid: '', app_secret: '', only_logo: false },
share_img: true,
share_img_compatible_s: false,
share_img_byimg: themeRoot ? `${themeRoot}/assets/img/slider-bg.jpg` : '',
share_logo: themeRoot ? `${themeRoot}/assets/img/logo.png` : '',
share_desc: '扫一扫 立即查看',
},
groups: [
{
key: 'share',
title: '社交分享',
icon: 'fa fas fa-share-alt',
fields: [
{ key: 'share_s', label: '文章分享功能', type: 'switcher', default: true, help: '关闭后,在文章页面将不显示分享按钮。' },
{
key: 'share_items',
label: '分享平台',
type: 'custom',
renderer: 'sorter',
sanitize_type: 'array',
default: defaultShareItems,
options: shareOptions,
description: '左侧启用并拖拽排序,右侧为未启用项(同时启用的数量不建议超过 6 个)。若开启海外平台,用户网络必须能正常访问对应服务。',
},
{ key: 'wechat_share_s', label: '微信分享有图', type: 'switcher', default: false, help: '使用微信 JSAPI 在微信 App 内分享。' },
{
key: 'wechat_share_hint',
label: '',
type: 'content',
ui_only: true,
dependsOn: { key: 'wechat_share_s', value: true },
content: '<p><strong>微信分享有图配置</strong></p><p>此功能用于用户从公众号进入页面后调用微信默认分享能力,并不会额外显示分享按钮。请在公众号后台将当前站点域名配置为 JS 接口安全域名。</p><p><a href="https://mp.weixin.qq.com/" target="_blank" rel="noopener noreferrer">前往微信公众平台</a></p>',
},
{
key: 'wechat_share_option',
label: '微信分享有图配置',
type: 'fieldset',
default: { appid: '', app_secret: '', only_logo: false },
dependsOn: { key: 'wechat_share_s', value: true },
fields: [
{ key: 'appid', label: 'AppID', type: 'input', default: '' },
{ key: 'app_secret', label: 'AppSecret', type: 'input', default: '' },
{ key: 'only_logo', label: '全部使用网站图标', type: 'switcher', default: false, help: '默认自动使用页面封面图;开启后所有页面均使用网站图标。图片启用防盗链时建议开启。' },
],
},
{ key: 'share_img', label: '生成海报分享', type: 'switcher', default: true, help: '网站图片使用 OSS 等云存储时,请先配置允许本站 Canvas 读取图片的跨域规则。' },
{
key: 'share_img_warning',
label: '',
type: 'notice',
ui_only: true,
alertType: 'warning',
content: '海报图片加载失败时,请检查图片 CDN/OSS 的跨域响应头。',
dependsOn: { key: 'share_img', value: true },
},
{ key: 'share_img_compatible_s', label: '海报分享兼容模式', type: 'switcher', default: false, dependsOn: { key: 'share_img', value: true }, help: '通过服务端代理读取海报图片,改善跨域兼容性;会增加服务器图片处理开销。' },
{ key: 'share_img_byimg', label: '海报分享默认图片', type: 'media', default: themeRoot ? `${themeRoot}/assets/img/slider-bg.jpg` : '', dependsOn: { key: 'share_img', value: true }, help: '文章没有图片时使用,建议尺寸 800×500。' },
{ key: 'share_logo', label: '海报分享 LOGO', type: 'media', default: themeRoot ? `${themeRoot}/assets/img/logo.png` : '', dependsOn: { key: 'share_img', value: true }, help: '显示在海报底部,建议尺寸 300×100、大小 50KB 以内。' },
{ key: 'share_desc', label: '海报分享底部文案', type: 'input', default: '扫一扫 立即查看', dependsOn: { key: 'share_img', value: true } },
],
},
],
}
@@ -1,38 +1,199 @@
/** 返佣订单类型(与运行时 membership/content/credits 对齐)。 */
const rebateTypeOptions = [
{ label: '全部订单', value: 'all' },
{ label: '购买会员', value: 'membership' },
{ label: '付费内容', value: 'content' },
{ label: '购买积分', value: 'credits' },
]
/**
* 推广返佣 — 参考主题结构:开关、识别模式、自己推广自己、按用户类型分档的
* 返佣规则(tab)、文案、推广海报与推广让利标签。
*/
export const affiliateSchema = { export const affiliateSchema = {
key: 'payment.affiliate', key: 'payment.affiliate',
title: '推广返佣', title: '推广返佣',
icon: 'fa fas fa-share-alt', icon: 'fa fas fa-btc',
description: '配置推广归因、分级佣金比例和结算规则。', description: '推广识别模式、分档返佣规则、返佣文案、推广海报与让利标签。',
defaults: { defaults: {
affiliate_enabled: false, pay_rebate_s: false,
affiliate_ratio_user: 5, pay_rebate_judgment: 'all',
affiliate_ratio_vip_1: 5, pay_rebate_show_users: true,
affiliate_ratio_vip_2: 5, pay_rebate_self_s: true,
affiliate_order_types: ['membership', 'content'], pay_rebate_user_s: [],
affiliate_settlement_days: 7, pay_rebate_ratio: 5,
affiliate_promotion_text: '', pay_rebate_user_s_1: ['all'],
pay_rebate_ratio_vip_1: 10,
pay_rebate_user_s_2: ['all'],
pay_rebate_ratio_vip_2: 20,
affiliate_disabled_code_stops_commission: false,
referral_attribution_model: 'first_touch',
pay_rebate_text_desc: '加入分享计划,获得高额奖励',
pay_rebate_text_details_title: '返佣详解',
pay_rebate_text_details: '<p>通过您的推广链接打开本站后,在本站购买商品即可获得佣金</p><p>通过您的推广链接注册后的用户今后购买的商品均可获得佣金</p><p>申请提现后,需后台人工处理,一般2-3小时,请耐心等待</p>',
pay_rebate_poster_s: true,
pay_rebate_poster_img: '',
pay_rebate_poster_title: '发现一个精品网站,快来看一看',
pay_rebate_poster_desc: '网站有超多精品资源和优惠福利,扫码进入网站还可享受诸多优惠哦',
pay_rebate_poster_banner: '',
pay_rebate_poster_desc_bottom: '',
pay_rebate_text_discount: '推荐购买 下单再减%discount%',
}, },
groups: [ groups: [
{ {
key: 'policy', key: 'notice',
title: '返佣策略', title: '说明',
icon: 'fa fas fa-sitemap', icon: 'fa fas fa-info-circle',
fields: [ fields: [
{ key: 'affiliate_enabled', label: '启用推广返佣', type: 'switch', default: false }, {
{ key: 'affiliate_ratio_user', label: '普通会员佣金比例(%)', type: 'number', default: 5, min: 0, max: 100, dependsOn: { key: 'affiliate_enabled', value: true } }, key: 'affiliate_notice',
{ key: 'affiliate_ratio_vip_1', label: '一级会员佣金比例(%)', type: 'number', default: 5, min: 0, max: 100, dependsOn: { key: 'affiliate_enabled', value: true } }, label: '',
{ key: 'affiliate_ratio_vip_2', label: '二级会员佣金比例(%)', type: 'number', default: 5, min: 0, max: 100, dependsOn: { key: 'affiliate_enabled', value: true } }, type: 'content',
{ key: 'affiliate_order_types', label: '适用订单类型', type: 'checkbox', default: ['membership', 'content'], sanitize_type: 'array', options: [{ label: '会员订单', value: 'membership' }, { label: '付费内容', value: 'content' }, { label: '购买积分', value: 'credits' }], dependsOn: { key: 'affiliate_enabled', value: true } }, ui_only: true,
{ key: 'affiliate_settlement_days', label: '结算观察期(天)', type: 'number', default: 7, min: 0, max: 90, dependsOn: { key: 'affiliate_enabled', value: true } }, content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>识别模式「绑定注册」:用户只要是通过任意推广链接注册的,今后此用户消费均会给推荐人返佣</li><li>识别模式「仅推广链接」:只有通过推广链接购买的才会给推荐人返佣,不会考虑是否是推荐注册的</li><li>开启的订单类型如果一个都不勾选,则代表该用户类型不参与此功能</li><li>您可以根据网站的VIP设置相互配合设置相应的规则</li><li>下面的设置为全局设置,还可以单独为每一个用户设置规则和比例</li></ul>',
},
],
},
{
key: 'switch',
title: '推广返佣',
icon: 'fa fas fa-toggle-on',
fields: [
{ key: 'pay_rebate_s', label: '启用推广返佣', type: 'switch', default: false },
],
},
{
key: 'mode',
title: '推广识别模式',
icon: 'fa fas fa-link',
fields: [
{
key: 'pay_rebate_judgment',
label: '推广识别模式',
type: 'radio',
default: 'all',
dependsOn: { key: 'pay_rebate_s', value: true },
options: [
{ label: '绑定注册', value: 'all' },
{ label: '仅推广链接', value: 'link' },
],
},
{ key: 'pay_rebate_show_users', label: '用户中心显示我推荐的用户', type: 'switch', default: true, dependsOn: { key: 'pay_rebate_judgment', value: 'all' } },
{ key: 'pay_rebate_self_s', label: '自己推广自己', type: 'switch', default: true, dependsOn: { key: 'pay_rebate_s', value: true }, help: '允许使用自己的推广链接购买商品并获得佣金' },
{
key: 'referral_attribution_model',
label: '推广归因模式',
type: 'radio',
default: 'first_touch',
options: [
{ label: '首次触点(首触优先)', value: 'first_touch' },
{ label: '最近触点(末触覆盖)', value: 'last_touch' },
],
help: '首触:访客首次通过推广链接进入后,归因固定且不被后续链接覆盖;末触:最近一次点击的推广码覆盖之前的归因',
},
],
},
{
key: 'rules',
title: '返佣规则',
icon: 'fa fas fa-table',
fields: [
{
key: 'rebate_rule',
label: '为不同用户类型设置返佣规则',
type: 'tabbed',
tabs: [
{
key: 'ordinary',
title: '普通用户',
fields: [
{
key: 'pay_rebate_user_s',
label: '返佣订单',
type: 'checkbox',
default: [],
dependsOn: { key: 'pay_rebate_s', value: true },
options: rebateTypeOptions,
help: '普通用户返利的订单类型,全部关闭,则代表普通用户不参与推广返佣',
},
{ key: 'pay_rebate_ratio', label: '普通用户返佣比例(%)', type: 'number', default: 5, min: 0, max: 100, dependsOn: { key: 'pay_rebate_s', value: true } },
],
},
{
key: 'vip1',
title: '一级会员',
fields: [
{
key: 'pay_rebate_user_s_1',
label: '返佣订单',
type: 'checkbox',
default: ['all'],
dependsOn: { key: 'pay_rebate_s', value: true },
options: rebateTypeOptions,
help: '一级会员返利的订单类型,全部关闭,则代表一级会员用户不参与推广返佣',
},
{ key: 'pay_rebate_ratio_vip_1', label: '一级会员返佣比例(%)', type: 'number', default: 10, min: 0, max: 100, dependsOn: { key: 'pay_rebate_s', value: true } },
],
},
{
key: 'vip2',
title: '二级会员',
fields: [
{
key: 'pay_rebate_user_s_2',
label: '返佣订单',
type: 'checkbox',
default: ['all'],
dependsOn: { key: 'pay_rebate_s', value: true },
options: rebateTypeOptions,
help: '二级会员返利的订单类型,全部关闭,则代表二级会员用户不参与推广返佣',
},
{ key: 'pay_rebate_ratio_vip_2', label: '二级会员返佣比例(%)', type: 'number', default: 20, min: 0, max: 100, dependsOn: { key: 'pay_rebate_s', value: true } },
],
},
],
},
{ key: 'affiliate_disabled_code_stops_commission', label: '推广码禁用后停止返佣', type: 'switch', default: false, dependsOn: { key: 'pay_rebate_s', value: true }, help: '开启后,推广者的推广码被禁用(或缺失)时,其名下已绑定用户的新订单不再产生佣金;关闭则绑定关系继续生效' },
], ],
}, },
{ {
key: 'text', key: 'text',
title: '文案设置', title: '返佣文案',
icon: 'fa fas fa-pen', icon: 'fa fas fa-file-alt',
fields: [ fields: [
{ key: 'affiliate_promotion_text', label: '推广说明', type: 'textarea', default: '', dependsOn: { key: 'affiliate_enabled', value: true } }, { key: 'pay_rebate_text_desc', label: '返佣文案(一句话简介)', type: 'input', default: '加入分享计划,获得高额奖励', dependsOn: { key: 'pay_rebate_s', value: true } },
{ key: 'pay_rebate_text_details_title', label: '返佣详情标题', type: 'input', default: '返佣详解', dependsOn: { key: 'pay_rebate_s', value: true } },
{ key: 'pay_rebate_text_details', label: '返佣详情内容', type: 'textarea', sanitize_type: 'html', default: '<p>通过您的推广链接打开本站后,在本站购买商品即可获得佣金</p><p>通过您的推广链接注册后的用户今后购买的商品均可获得佣金</p><p>申请提现后,需后台人工处理,一般2-3小时,请耐心等待</p>', rows: 6, dependsOn: { key: 'pay_rebate_s', value: true }, help: '返佣详情介绍,建议为规则介绍或者其他说明,支持HTML代码' },
],
},
{
key: 'poster',
title: '推广海报',
icon: 'fa fas fa-image',
fields: [
{ key: 'pay_rebate_poster_s', label: '推广海报', type: 'switch', default: true, dependsOn: { key: 'pay_rebate_s', value: true }, help: '在用户中心显示包含二维码的推广海报' },
{ key: 'pay_rebate_poster_img', label: '推广海报顶部图片', type: 'media', default: '', dependsOn: { key: 'pay_rebate_poster_s', value: true } },
{ key: 'pay_rebate_poster_title', label: '推广海报标题(20-30个字)', type: 'input', default: '发现一个精品网站,快来看一看', dependsOn: { key: 'pay_rebate_poster_s', value: true } },
{ key: 'pay_rebate_poster_desc', label: '推广海报简介(40-60个字)', type: 'input', default: '网站有超多精品资源和优惠福利,扫码进入网站还可享受诸多优惠哦', dependsOn: { key: 'pay_rebate_poster_s', value: true } },
{ key: 'pay_rebate_poster_banner', label: '海报顶部备用图片', type: 'media', default: '', dependsOn: { key: 'pay_rebate_poster_s', value: true }, help: '顶部主图加载失败时依次回退到这里,再回退到主题内置图,避免海报顶部出现空白。' },
{ key: 'pay_rebate_poster_desc_bottom', label: '海报底部文案', type: 'input', default: '扫描二维码阅读全文', dependsOn: { key: 'pay_rebate_poster_s', value: true }, help: '画在二维码下方居中位置,留空则使用默认文案。' },
],
},
{
key: 'discount',
title: '推广让利标签',
icon: 'fa fas fa-tags',
fields: [
{
key: 'discount_notice',
label: '',
type: 'content',
ui_only: true,
dependsOn: { key: 'pay_rebate_s', value: true },
content: '<p><b>推广让利标签配置详解:</b></p><ul style="margin:6px 0 0 18px;line-height:1.9"><li>可使用的变量:折扣 <code>%discount%</code>、推荐人姓名 <code>%referrer_name%</code></li><li>使用变量示例:<code>%referrer_name%推荐购买 下单再减%discount%元</code></li></ul>',
},
{ key: 'pay_rebate_text_discount', label: '推广让利标签', type: 'input', default: '推荐购买 下单再减%discount%', dependsOn: { key: 'pay_rebate_s', value: true }, help: '显示在购买模块,推广让利的标签文案' },
], ],
}, },
], ],
}; }
@@ -0,0 +1,80 @@
/**
* 售后策略 — 售后开关、申请时效、超时效提示与申请表单配置。
*
* 字段键、顺序、类型与默认值需与 src/Admin/Schema/SchemaPayment.php 的
* after_sale 分区保持 1:1(tests/widget-system/after-sale-policy-contract.test.cjs 守护)。
*/
export const afterSaleSchema = {
key: 'payment.after_sale',
title: '售后策略',
icon: 'fa fas fa-undo-alt',
description: '售后功能开关、申请时效、超时效提示与申请表单(预置原因、申请说明、所需材料、图片上传)配置。',
defaults: {
after_sale_s: true,
after_sale_time_limit: 7,
after_sale_expired_text: '<div>抱歉,您的订单已超过售后申请时效</div>',
after_sale_reasons: [
{ t: '质量问题' },
{ t: '假冒品牌' },
{ t: '其他原因' },
],
after_sale_desc: '',
after_sale_materials: [
{ t: '联系邮箱' },
{ t: '联系电话' },
],
after_sale_reason_placeholder: '必填,请您详细填写申请原因',
after_sale_img_s: true,
},
groups: [
{
key: 'main',
title: '售后功能',
icon: 'fa fas fa-undo-alt',
fields: [
{ key: 'after_sale_s', label: '售后功能', type: 'switch', default: true, help: '开启后用户可以申请售后;关闭后不再处理售后,前台「我的订单」不再显示申请售后按钮' },
{ key: 'after_sale_time_limit', label: '申请售后时效(天)', type: 'number', default: 7, min: 0, max: 3650, help: '订单支付后多少天内可以申请售后,超过时效将无法申请;0 表示不限制' },
{ key: 'after_sale_expired_text', label: '超过时效提示', type: 'textarea', sanitize_type: 'html', default: '<div>抱歉,您的订单已超过售后申请时效</div>', rows: 3, help: '订单超过售后时效后,用户点击申请售后时弹窗提示的内容(支持HTML代码,请注意代码规范)' },
],
},
{
key: 'form',
title: '申请表单',
icon: 'fa fas fa-file-alt',
fields: [
{
key: 'after_sale_reasons',
label: '预置原因',
type: 'group',
button_title: '添加原因',
help: '用户申请售后时可选择的原因',
default: [
{ t: '质量问题' },
{ t: '假冒品牌' },
{ t: '其他原因' },
],
fields: [
{ key: 't', label: '原因', type: 'input', default: '' },
],
},
{ key: 'after_sale_desc', label: '申请售后说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 5, help: '用户申请售后时显示的内容(支持HTML代码,请注意代码规范)' },
{
key: 'after_sale_materials',
label: '申请所需材料',
type: 'group',
button_title: '添加材料',
help: '用户提交售后申请时需要提供的资料,如联系邮箱、联系电话;此处的信息收集,仅用于售后联系与审核',
default: [
{ t: '联系邮箱' },
{ t: '联系电话' },
],
fields: [
{ key: 't', label: '资料项', type: 'input', default: '' },
],
},
{ key: 'after_sale_reason_placeholder', label: '申请说明占位文案', type: 'input', default: '必填,请您详细填写申请原因', help: '前台申请售后弹窗中「申请说明」输入框的提示文字' },
{ key: 'after_sale_img_s', label: '申请售后可上传图片', type: 'switch', default: true, help: '开启后申请售后弹窗中出现图片上传入口(最多上传3张)' },
],
},
],
}
@@ -1,59 +0,0 @@
export const cardPassSchema = {
key: 'payment.card-pass',
title: '充值卡',
icon: 'fa fas fa-ticket-alt',
description: '配置充值卡类型、卡号规则、批量生成和兑换逻辑。',
defaults: {
card_pass_enabled: false,
card_pass_desc: '',
card_pass_type: 'balance',
card_pass_prefix: 'AK',
card_pass_length: 16,
card_pass_batch_size: 50,
card_pass_max_redeem: 1,
card_pass_expiry: 0,
card_pass_amount: '10.00',
card_pass_vip_days: 30,
card_pass_credits: 0,
card_pass_redeem_page: '',
card_pass_success_msg: '兑换成功!',
card_pass_fail_msg: '卡号无效或已过期',
},
groups: [
{
key: 'basic',
title: '充值卡设置',
icon: 'fa fas fa-id-card',
fields: [
{ key: 'card_pass_enabled', label: '启用充值卡', type: 'switch', default: false },
{ key: 'card_pass_desc', label: '充值卡说明', type: 'textarea', default: '', dependsOn: { key: 'card_pass_enabled', value: true } },
{ key: 'card_pass_type', label: '充值卡类型', type: 'select', default: 'balance', options: [{ label: '余额卡', value: 'balance' }, { label: 'VIP卡', value: 'vip' }, { label: '积分卡', value: 'credits' }], dependsOn: { key: 'card_pass_enabled', value: true } },
{ key: 'card_pass_prefix', label: '卡号前缀', type: 'input', default: 'AK', dependsOn: { key: 'card_pass_enabled', value: true } },
{ key: 'card_pass_length', label: '卡号长度', type: 'number', default: 16, min: 8, max: 32, dependsOn: { key: 'card_pass_enabled', value: true } },
{ key: 'card_pass_batch_size', label: '批量生成数量', type: 'number', default: 50, min: 1, max: 500, dependsOn: { key: 'card_pass_enabled', value: true } },
],
},
{
key: 'redeem',
title: '兑换设置',
icon: 'fa fas fa-exchange-alt',
fields: [
{ key: 'card_pass_max_redeem', label: '单卡最大兑换次数', type: 'number', default: 1, min: 1, max: 100, dependsOn: { key: 'card_pass_enabled', value: true } },
{ key: 'card_pass_expiry', label: '有效期(天)', type: 'number', default: 0, min: 0, help: '0为永久有效', dependsOn: { key: 'card_pass_enabled', value: true } },
{ key: 'card_pass_amount', label: '面额(元)', type: 'input', default: '10.00', min: 1, max: 9999, dependsOn: { key: 'card_pass_enabled', value: true } },
{ key: 'card_pass_vip_days', label: 'VIP天数', type: 'number', default: 30, min: 1, max: 3650, dependsOn: { key: 'card_pass_enabled', value: true } },
{ key: 'card_pass_credits', label: '赠送积分', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'card_pass_enabled', value: true } },
],
},
{
key: 'display',
title: '展示设置',
icon: 'fa fas fa-desktop',
fields: [
{ key: 'card_pass_redeem_page', label: '兑换页面', type: 'input', default: '', dependsOn: { key: 'card_pass_enabled', value: true } },
{ key: 'card_pass_success_msg', label: '兑换成功提示', type: 'input', default: '兑换成功!', dependsOn: { key: 'card_pass_enabled', value: true } },
{ key: 'card_pass_fail_msg', label: '兑换失败提示', type: 'input', default: '卡号无效或已过期', dependsOn: { key: 'card_pass_enabled', value: true } },
],
},
],
}
@@ -1,42 +1,83 @@
/** PayPal / Stripe 结算货币选项(参考主题同款 26 种)。 */
const currencyOptions = [
{ label: '澳元 (AUD)', value: 'AUD' },
{ label: '巴西雷亚尔 (BRL)', value: 'BRL' },
{ label: '加拿大元 (CAD)', value: 'CAD' },
{ label: '人民币 (CNY)', value: 'CNY' },
{ label: '克朗 (CZK)', value: 'CZK' },
{ label: '丹麦克朗 (DKK)', value: 'DKK' },
{ label: '欧元 (EUR)', value: 'EUR' },
{ label: '港币 (HKD)', value: 'HKD' },
{ label: '匈牙利福林 (HUF)', value: 'HUF' },
{ label: '印度卢比 (INR)', value: 'INR' },
{ label: '以色列新谢克尔 (ILS)', value: 'ILS' },
{ label: '日元 (JPY)', value: 'JPY' },
{ label: '马来西亚林吉特 (MYR)', value: 'MYR' },
{ label: '墨西哥比索 (MXN)', value: 'MXN' },
{ label: '新台币 (TWD)', value: 'TWD' },
{ label: '新西兰元 (NZD)', value: 'NZD' },
{ label: '挪威克朗 (NOK)', value: 'NOK' },
{ label: '菲律宾比索 (PHP)', value: 'PHP' },
{ label: '波兰兹罗提 (PLN)', value: 'PLN' },
{ label: '英镑 (GBP)', value: 'GBP' },
{ label: '俄罗斯卢布 (RUB)', value: 'RUB' },
{ label: '新加坡元 (SGD)', value: 'SGD' },
{ label: '瑞典克朗 (SEK)', value: 'SEK' },
{ label: '瑞士法郎 (CHF)', value: 'CHF' },
{ label: '泰铢 (THB)', value: 'THB' },
{ label: '美元 (USD)', value: 'USD' },
]
/**
* 收款接口 — 参考主题双下拉式重构:微信/支付宝接口选择(官方/易支付/关闭)、
* PayPal/Stripe 独立开关、货币与结算、订单与编号、
* 收款接口显示顺序,以及 5 个凭据配置块(支付宝/微信/PayPal/Stripe/易支付)。
*/
export const gatewaySchema = { export const gatewaySchema = {
key: 'payment.gateway', key: 'payment.gateway',
title: '收款接口', title: '收款接口',
icon: 'fa fas fa-credit-card', icon: 'fa fas fa-credit-card',
description: '管理已支持的收款通道:余额、支付宝、微信、PayPal、Stripe、易支付与卡密兑换。开启渠道后展开填写凭据。', description: '配置收款通道:微信与支付宝可在官方/易支付之间选择,PayPal 与 Stripe 独立开关。订单、资金流水与提现统一使用同一套自动流水号,无需配置前缀。',
defaults: { defaults: {
payment_wechat_enabled: false, pay_wechat_sdk_options: 'null',
payment_alipay_enabled: false, pay_alipay_sdk_options: 'null',
payment_paypal_enabled: false, pay_paypal_sdk_s: false,
payment_stripe_enabled: false, pay_stripe_sdk_s: false,
payment_epay_enabled: false, pay_currency_mark: '¥',
payment_balance_enabled: true, pay_currency_unit: '元',
payment_card_pass_enabled: false, pay_wechat_settle: { settle_rate: '1', settle_mark: '¥', settle_unit: '元' },
wechat_merchantid: '', pay_alipay_settle: { settle_rate: '1', settle_mark: '¥', settle_unit: '元' },
wechat_appid: '', pay_sdk_custom_title_s: false,
wechat_payment_type: 'native', pay_sdk_custom_title: '',
wechat_api_v3_key: '', payment_order_expiry: 30,
wechat_private_key_path: '', pay_wait_float_s: true,
wechat_cert_path: '', pay_sdk_order: {},
alipay_appid: '', alipay_appid: '',
alipay_privatekey: '', alipay_privatekey: '',
alipay_payment_type: 'page', alipay_payment_type: 'page',
alipay_app_public_cert_path: '', alipay_app_public_cert_path: '',
alipay_public_cert_path: '', alipay_public_cert_path: '',
alipay_root_cert_path: '', alipay_root_cert_path: '',
wechat_merchantid: '',
wechat_appid: '',
wechat_payment_type: 'native',
wechat_api_v3_key: '',
wechat_private_key_path: '',
wechat_cert_path: '',
paypal_client_id: '', paypal_client_id: '',
paypal_client_secret: '', paypal_client_secret: '',
paypal_currency: 'USD', paypal_currency: 'USD',
paypal_sandbox: true, paypal_rates: '0.14',
paypal_sandbox: false,
stripe_secret_key: '', stripe_secret_key: '',
stripe_publishable_key: '', stripe_publishable_key: '',
stripe_webhook_secret: '', stripe_webhook_secret: '',
stripe_currency: 'usd', stripe_currency: 'USD',
stripe_test_mode: true, stripe_rates: '0.14',
stripe_test_mode: false,
epay_apiurl: '', epay_apiurl: '',
epay_pid: '', epay_pid: '',
epay_key: '', epay_key: '',
epay_channel_alipay: true,
epay_channel_wxpay: true,
epay_device: 'auto', epay_device: 'auto',
}, },
groups: [ groups: [
@@ -46,121 +87,259 @@ export const gatewaySchema = {
icon: 'fa fas fa-info-circle', icon: 'fa fas fa-info-circle',
fields: [ fields: [
{ {
key: 'payment_gateway_notice', key: 'gateway_notice',
label: '通道说明', label: '',
type: 'notice', type: 'content',
alertType: 'info', ui_only: true,
alertContent: '以渠道开关 + 凭据是否齐全为准。异步回调由各网关验签(支付宝/微信/Stripe/易支付等),无需额外口令。卡密为兑换入口,不出现在收银台支付方式列表。', content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>以下收款接口,主题仅提供API接入服务,收款平台的可靠性请自行斟酌!</li><li>涉及到资金及信息安全,有相关执照的商家推荐使用官方接口</li><li>微信 / 支付宝接口可在「官方」与「易支付」之间选择;PayPal 与 Stripe 独立开关</li><li>异步回调由各网关验签,无需额外口令;回调地址固定为 /wp-json/aksrt/v1/payment/{gateway}/{notify|return}</li></ul>',
}, },
], ],
}, },
{ {
key: 'gateways', key: 'channels',
title: '支付渠道开关', title: '收款接口选择',
icon: 'fa fas fa-toggle-on', icon: 'fa fas fa-toggle-on',
description: '先开启渠道,下方对应配置卡片会展开;填写凭据后网关状态变为可用。余额与卡密无第三方凭据,仅开关即可。', description: '微信与支付宝各选一种接入方式;选「易支付」后需在下方易支付配置块填写接口信息。',
fields: [ fields: [
{ key: 'payment_balance_enabled', label: '余额支付', type: 'switch', default: true, help: '需同时在「余额充值」启用余额功能。关闭后用户中心仍可有钱包,但结账不可用余额支付。' }, {
{ key: 'payment_alipay_enabled', label: '支付宝', type: 'switch', default: false }, key: 'pay_wechat_sdk_options',
{ key: 'payment_wechat_enabled', label: '微信支付', type: 'switch', default: false }, label: '微信收款接口',
{ key: 'payment_paypal_enabled', label: 'PayPal', type: 'switch', default: false }, type: 'select',
{ key: 'payment_stripe_enabled', label: 'Stripe', type: 'switch', default: false }, default: 'null',
{ key: 'payment_epay_enabled', label: '易支付', type: 'switch', default: false, help: '兼容彩虹易支付协议的聚合收银台,可同时开启支付宝/微信子通道。' }, options: [
{ key: 'payment_card_pass_enabled', label: '卡密充值', type: 'switch', default: false, help: '仅控制卡密兑换入口是否开放;卡密规则与批量发卡在「充值卡」页。不进入结账支付方式列表。' }, { label: '微信官方', value: 'official_wechat' },
{ label: '易支付-微信', value: 'epay' },
{ label: '关闭微信收款', value: 'null' },
],
},
{
key: 'pay_alipay_sdk_options',
label: '支付宝收款接口',
type: 'select',
default: 'null',
options: [
{ label: '支付宝企业支付/当面付', value: 'official_alipay' },
{ label: '易支付-支付宝', value: 'epay' },
{ label: '关闭支付宝收款', value: 'null' },
],
},
{ key: 'pay_paypal_sdk_s', label: 'PayPal支付(贝宝)', type: 'switch', default: false, help: '启用后请在下方配置参数' },
{ key: 'pay_stripe_sdk_s', label: 'Stripe支付', type: 'switch', default: false, help: '启用后请在下方配置参数' },
],
},
{
key: 'currency',
title: '货币与结算',
icon: 'fa fas fa-coins',
fields: [
{ key: 'pay_currency_mark', label: '本地货币符号', type: 'input', default: '¥', help: '显示在金额前面(例如 $ 或 ¥)' },
{ key: 'pay_currency_unit', label: '本地货币单位名称', type: 'input', default: '元', help: '金额后缀,例如:人民币填「元」、美元填「dollars」' },
{
key: 'pay_wechat_settle',
label: '微信结算配置',
type: 'fieldset',
default: { settle_rate: '1', settle_mark: '¥', settle_unit: '元' },
fields: [
{ key: 'settle_rate', label: '结算汇率', type: 'input', default: '1' },
{ key: 'settle_mark', label: '结算货币符号', type: 'input', default: '¥' },
{ key: 'settle_unit', label: '结算货币单位', type: 'input', default: '元' },
],
},
{
key: 'pay_alipay_settle',
label: '支付宝结算配置',
type: 'fieldset',
default: { settle_rate: '1', settle_mark: '¥', settle_unit: '元' },
fields: [
{ key: 'settle_rate', label: '结算汇率', type: 'input', default: '1' },
{ key: 'settle_mark', label: '结算货币符号', type: 'input', default: '¥' },
{ key: 'settle_unit', label: '结算货币单位', type: 'input', default: '元', help: '如果接入的收款方式与本地的货币类型不同,则在此配置结算汇率及单位' },
],
},
{ key: 'pay_sdk_custom_title_s', label: '自定义收款标题', type: 'switch', default: false, help: '默认为付款类型加网站名,如需自定义请开启' },
{ key: 'pay_sdk_custom_title', label: '自定义收款标题', type: 'input', default: '', dependsOn: { key: 'pay_sdk_custom_title_s', value: true }, help: '可使用变量:订单类型 %order_type%,例如:%order_type%-AKSRT;注意控制总长度,太长会导致部分接口报错' },
],
},
{
key: 'order',
title: '订单与编号',
icon: 'fa fas fa-hashtag',
description: '订单支付时效与浮窗提示;订单、资金流水与提现的流水号由系统统一自动分配,无需配置。',
fields: [
{ key: 'payment_order_expiry', label: '订单支付时效(分钟)', type: 'number', default: 30, min: 5, max: 1440, help: '用户下单后,最大支付时间,超过后订单将自动关闭(不能低于5分钟)' },
{ key: 'pay_wait_float_s', label: '浮窗显示待支付订单', type: 'switch', default: true, help: '将用户待支付订单一直显示在右上角,提醒用户付款' },
],
},
{
key: 'sdk-order',
title: '收款接口显示顺序',
icon: 'fa fas fa-sort-amount-down',
fields: [
{
key: 'pay_sdk_order',
label: '显示顺序',
type: 'sortable',
default: {},
items: [
{ key: 'wechat', label: '微信' },
{ key: 'alipay', label: '支付宝' },
{ key: 'paypal', label: 'PayPal(贝宝)' },
{ key: 'stripe', label: 'Stripe' },
{ key: 'balance', label: '余额' },
{ key: 'card_pass', label: '卡密' },
],
help: '收银台支付方式显示顺序;仅显示已开启的接口',
},
],
},
{
key: 'alipay',
title: '支付宝官方',
icon: 'fa fas fa-credit-card',
description: '支付宝官方接口(当面付 / 网站应用)。',
collapsible: true,
expandWhen: 'pay_alipay_sdk_options',
dependsOn: { key: 'pay_alipay_sdk_options', value: 'official_alipay' },
fields: [
{
key: 'alipay_notice',
label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>支付宝公钥为必填项目(公钥错误可以成功付款,但会回调失败)</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/alipay/{notify|return}</li><li>同时填写了企业支付以及当面付参数,则优先使用当面付</li></ul>',
},
{ key: 'alipay_appid', label: '当面付:APPID', type: 'input', default: '' },
{ key: 'alipay_privatekey', label: '当面付:应用私钥', type: 'textarea', sanitize_type: 'code', default: '', rows: 4 },
{
key: 'alipay_payment_type',
label: '支付方式',
type: 'select',
default: 'page',
options: [
{ label: '电脑网站支付(web)', value: 'page' },
{ label: '手机网站支付(wap)', value: 'wap' },
],
},
{ key: 'alipay_app_public_cert_path', label: '应用公钥证书路径', type: 'input', default: '', help: 'appCertPublicKey.crt 绝对路径。' },
{ key: 'alipay_public_cert_path', label: '支付宝公钥证书路径', type: 'input', default: '', help: 'alipayCertPublicKey_RSA2.crt 绝对路径。' },
{ key: 'alipay_root_cert_path', label: '支付宝根证书路径', type: 'input', default: '', help: 'alipayRootCert.crt 绝对路径。' },
], ],
}, },
{ {
key: 'wechat', key: 'wechat',
title: '微信支付配置', title: '微信官方',
icon: 'fa fab fa-weixin', icon: 'fa fab fa-weixin',
description: '开启「微信支付」后填写商户号与密钥。', description: '微信企业支付官方接口。',
collapsible: true, collapsible: true,
expandWhen: 'payment_wechat_enabled', expandWhen: 'pay_wechat_sdk_options',
dependsOn: { key: 'payment_wechat_enabled', value: true }, dependsOn: { key: 'pay_wechat_sdk_options', value: 'official_wechat' },
fields: [ fields: [
{ key: 'wechat_merchantid', label: '商户号', type: 'input', default: '' }, {
{ key: 'wechat_appid', label: 'AppID', type: 'input', default: '' }, key: 'wechat_notice',
{ key: 'wechat_payment_type', label: '支付方式', type: 'select', default: 'native', options: [ label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>微信企业支付:官方接口,商家可申请,有年费,申请较为复杂</li><li>回调地址固定为 /wp-json/aksrt/v1/payment/wechatpay/{notify|return},请配置 Native 支付回调链接</li></ul>',
},
{ key: 'wechat_merchantid', label: '商户号 PartnerID', type: 'input', default: '' },
{ key: 'wechat_appid', label: '授权绑定的AppID', type: 'input', default: '' },
{
key: 'wechat_payment_type',
label: '支付方式',
type: 'select',
default: 'native',
options: [
{ label: '扫码支付(Native)', value: 'native' }, { label: '扫码支付(Native)', value: 'native' },
{ label: 'H5支付', value: 'h5' }, { label: 'H5支付', value: 'h5' },
] }, ],
{ key: 'wechat_api_v3_key', label: 'APIv3密钥', type: 'input', default: '', help: '微信商户平台设置的 APIv3 密钥(32位)。' }, },
{ key: 'wechat_api_v3_key', label: '支付APIv3密钥', type: 'input', default: '', help: '微信商户平台设置的 APIv3 密钥(32位)。' },
{ key: 'wechat_private_key_path', label: '商户私钥路径', type: 'input', default: '', help: 'apiclient_key.pem 绝对路径,用于签名。' }, { key: 'wechat_private_key_path', label: '商户私钥路径', type: 'input', default: '', help: 'apiclient_key.pem 绝对路径,用于签名。' },
{ key: 'wechat_cert_path', label: '商户API证书路径', type: 'input', default: '', help: 'apiclient_cert.pem 绝对路径。' }, { key: 'wechat_cert_path', label: '商户API证书路径', type: 'input', default: '', help: 'apiclient_cert.pem 绝对路径。' },
], ],
}, },
{
key: 'alipay',
title: '支付宝配置',
icon: 'fa fas fa-credit-card',
description: '开启「支付宝」后填写应用与证书。',
collapsible: true,
expandWhen: 'payment_alipay_enabled',
dependsOn: { key: 'payment_alipay_enabled', value: true },
fields: [
{ key: 'alipay_appid', label: 'AppID', type: 'input', default: '' },
{ key: 'alipay_privatekey', label: '应用私钥', type: 'textarea', rows: 3, default: '' },
{ key: 'alipay_payment_type', label: '支付方式', type: 'select', default: 'page', options: [
{ label: '电脑网站支付(web)', value: 'page' },
{ label: '手机网站支付(wap)', value: 'wap' },
] },
{ key: 'alipay_app_public_cert_path', label: '应用公钥证书路径', type: 'input', default: '', help: 'appCertPublicKey.crt 绝对路径。' },
{ key: 'alipay_public_cert_path', label: '支付宝公钥证书路径', type: 'input', default: '', help: 'alipayCertPublicKey_RSA2.crt 绝对路径。' },
{ key: 'alipay_root_cert_path', label: '支付宝根证书路径', type: 'input', default: '', help: 'alipayRootCert.crt 绝对路径。' },
{ key: 'alipay_notify_help', label: '回调地址说明', type: 'notice', alertType: 'info', alertContent: '异步通知 / 同步返回已固定为 /wp-json/aksrt/v1/payment/alipay/{notify|return},无需在此填写。' },
],
},
{ {
key: 'paypal', key: 'paypal',
title: 'PayPal 配置', title: 'PayPal(贝宝)',
icon: 'fa fab fa-paypal', icon: 'fa fab fa-paypal',
description: '开启「PayPal」后填写 Client 凭据。', description: '常见的海外支付接口。',
collapsible: true, collapsible: true,
expandWhen: 'payment_paypal_enabled', expandWhen: 'pay_paypal_sdk_s',
dependsOn: { key: 'payment_paypal_enabled', value: true }, dependsOn: { key: 'pay_paypal_sdk_s', value: true },
fields: [ fields: [
{
key: 'paypal_notice',
label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>PayPal(贝宝)是常见的海外支付接口</li><li>如果您的收款账户为中国大陆地区,则账户类型必须是企业,且只能收取非中国大陆区的PayPal付款</li><li>REST 应用申请:https://developer.paypal.com/dashboard/applications</li></ul>',
},
{ key: 'paypal_client_id', label: 'Client ID', type: 'input', default: '' }, { key: 'paypal_client_id', label: 'Client ID', type: 'input', default: '' },
{ key: 'paypal_client_secret', label: 'Client Secret', type: 'input', default: '' }, { key: 'paypal_client_secret', label: 'Client Secret', type: 'input', default: '' },
{ key: 'paypal_currency', label: '货币', type: 'input', default: 'USD' }, { key: 'paypal_currency', label: '结算货币', type: 'select', case_insensitive: true, default: 'USD', options: currencyOptions },
{ key: 'paypal_sandbox', label: '沙箱模式', type: 'switch', default: true }, { key: 'paypal_rates', label: '货币汇率', type: 'input', default: '0.14', help: '根据你选择的货币类型与您定价货币的汇率进行填写,例如1元人民币兑换0.137美元(USD),则此处填0.137' },
{ key: 'paypal_sandbox', label: '沙盒调试模式', type: 'switch', default: false, help: '沙箱模式(Sandbox)是PayPal支付的测试环境。开启后请使用沙箱环境生成的收款账户和付款账户进行测试。' },
], ],
}, },
{ {
key: 'stripe', key: 'stripe',
title: 'Stripe 配置', title: 'Stripe',
icon: 'fa fab fa-cc-stripe', icon: 'fa fab fa-cc-stripe',
description: '开启「Stripe」后填写 API Key 与 Webhook。', description: '常见的海外信用卡收款接口。',
collapsible: true, collapsible: true,
expandWhen: 'payment_stripe_enabled', expandWhen: 'pay_stripe_sdk_s',
dependsOn: { key: 'payment_stripe_enabled', value: true }, dependsOn: { key: 'pay_stripe_sdk_s', value: true },
fields: [ fields: [
{ key: 'stripe_secret_key', label: 'Secret Key', type: 'input', default: '' }, {
key: 'stripe_notice',
label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>Stripe 是常见的海外信用卡收款接口</li><li>API 密钥申请:https://dashboard.stripe.com/apikeys</li><li>Webhook 事件:checkout.session.completed</li><li>测试环境请使用 sk_test_ 开头的 Secret Key</li></ul>',
},
{ key: 'stripe_secret_key', label: '私钥Secret Key', type: 'input', default: '', help: 'Stripe Dashboard → Developers → API keys → Secret key' },
{ key: 'stripe_publishable_key', label: 'Publishable Key', type: 'input', default: '' }, { key: 'stripe_publishable_key', label: 'Publishable Key', type: 'input', default: '' },
{ key: 'stripe_webhook_secret', label: 'Webhook Secret', type: 'input', default: '', help: 'Stripe Dashboard 配置 Webhook 后获得,用于验签。' }, { key: 'stripe_webhook_secret', label: '签名密钥Webhook Secret', type: 'input', default: '', help: 'Developers → Webhooks → 端点 → Signing secret(whsec_ 开头)' },
{ key: 'stripe_currency', label: '货币', type: 'input', default: 'usd' }, { key: 'stripe_currency', label: '结算货币', type: 'select', case_insensitive: true, default: 'USD', options: currencyOptions },
{ key: 'stripe_test_mode', label: '测试模式', type: 'switch', default: true }, { key: 'stripe_rates', label: '货币汇率', type: 'input', default: '0.14', help: '根据你选择的货币类型与您定价货币的汇率进行填写,例如1元人民币兑换0.137美元(USD),则此处填0.137' },
{ key: 'stripe_test_mode', label: '测试模式', type: 'switch', default: false, help: '如果使用的测试开放模式,则请使用 sk_test_ 测试密钥与 Stripe 测试卡进行配置' },
], ],
}, },
{ {
key: 'epay', key: 'epay',
title: '易支付配置', title: '易支付',
icon: 'fa fas fa-university', icon: 'fa fas fa-university',
description: '开启「易支付」后填写接口地址与商户信息,并选择支付宝/微信子通道。', description: '易支付(彩虹协议)聚合收银台;通道由上方微信/支付宝接口选择控制。',
collapsible: true, collapsible: true,
expandWhen: 'payment_epay_enabled', expandWhen: 'pay_wechat_sdk_options',
dependsOn: { key: 'payment_epay_enabled', value: true }, dependsOn: {
any: [
{ key: 'pay_wechat_sdk_options', value: 'epay' },
{ key: 'pay_alipay_sdk_options', value: 'epay' },
],
},
fields: [ fields: [
{ key: 'epay_apiurl', label: '接口地址', type: 'input', default: '', help: '易支付站点根地址,如 https://pay.example.com/(末尾斜杠可选)。' }, {
{ key: 'epay_pid', label: '商户ID', type: 'input', default: '' }, key: 'epay_notice',
{ key: 'epay_key', label: '商户密钥', type: 'input', default: '', help: 'MD5 通信密钥。' }, label: '',
{ key: 'epay_channel_alipay', label: '启用支付宝通道', type: 'switch', default: true }, type: 'content',
{ key: 'epay_channel_wxpay', label: '启用微信通道', type: 'switch', default: true }, ui_only: true,
{ key: 'epay_device', label: '下单终端', type: 'select', default: 'auto', options: [ content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>易支付是一个常见的支付系统源码,任何人都可以下载并搭建</li><li>同时市面上多数的小规模支付平台都是由易支付修改而来,例如部分码支付、源支付等等</li><li>由于此接口服务商众多,主题只负责技术接入,平台可靠性请自行斟酌</li></ul>',
},
{ key: 'epay_apiurl', label: 'API接口网址', type: 'input', default: '', help: '请填写接口地址,例如:https://pay.xxxxxxx.cn/' },
{ key: 'epay_pid', label: '商户号', type: 'input', default: '' },
{ key: 'epay_key', label: '商户秘钥', type: 'input', default: '', help: 'MD5 通信密钥。' },
{
key: 'epay_device',
label: '下单终端',
type: 'select',
default: 'auto',
options: [
{ label: '自动(按 UA)', value: 'auto' }, { label: '自动(按 UA)', value: 'auto' },
{ label: '电脑端 submit', value: 'pc' }, { label: '电脑端 submit', value: 'pc' },
{ label: '移动端 mapi', value: 'mobile' }, { label: '移动端 mapi', value: 'mobile' },
] }, ],
{ key: 'epay_notify_help', label: '回调地址说明', type: 'notice', alertType: 'info', alertContent: '异步/同步回调固定为 /wp-json/aksrt/v1/payment/epay_alipay/{notify|return} 与 /payment/epay_wxpay/{notify|return},请在易支付后台放行对应 URL。' }, },
], ],
}, },
], ],
@@ -2,23 +2,22 @@
* Payment & Paid domain schemas. * Payment & Paid domain schemas.
*/ */
import { knowledgeSchema } from './knowledge.js'
import { rechargeSchema } from './recharge.js' import { rechargeSchema } from './recharge.js'
import { vipSchema } from './vip.js' import { vipSchema } from './vip.js'
import { revenueSchema } from './revenue.js' import { revenueSchema } from './revenue.js'
import { affiliateSchema } from './affiliate.js' import { affiliateSchema } from './affiliate.js'
import { gatewaySchema } from './gateway.js' import { gatewaySchema } from './gateway.js'
import { orderSchema } from './order.js'
import { cardPassSchema } from './cardPass.js'
import { withdrawalSchema } from './withdrawal.js' import { withdrawalSchema } from './withdrawal.js'
import { afterSaleSchema } from './afterSale.js'
export const paymentSchemas = { export const paymentSchemas = {
knowledge: knowledgeSchema,
recharge: rechargeSchema, recharge: rechargeSchema,
vip: vipSchema, vip: vipSchema,
revenue: revenueSchema, revenue: revenueSchema,
affiliate: affiliateSchema, affiliate: affiliateSchema,
gateway: gatewaySchema, gateway: gatewaySchema,
order: orderSchema,
'card-pass': cardPassSchema,
cardPass: cardPassSchema,
withdrawal: withdrawalSchema, withdrawal: withdrawalSchema,
after_sale: afterSaleSchema,
} }
@@ -0,0 +1,232 @@
/**
* 知识付费 — 1:1 复刻参考主题对应页面(免登陆购买 / 模块位置 / 优惠码 /
* 客户服务 / 付费下载 / 商品预置参数),并保留 AKSRT 订单展示与状态文案。
*/
export const knowledgeSchema = {
key: 'payment.knowledge',
title: '知识付费',
icon: 'fa fas fa-shopping-bag',
description: '付费内容总开关、免登陆购买、模块显示、优惠码与商品预置参数配置。',
defaults: {
pay_no_logged_in: true,
pay_cookie_day: 15,
pay_no_logged_remind: '您当前未登录!建议登陆后购买,可保存购买订单',
pay_box_position: 'top',
pay_free_logged_show: true,
pay_show_user: true,
pay_show_paycount: true,
coupon_post_s: false,
coupon_desc: '',
pay_mark: '付费',
payment_show_ip: false,
payment_show_device: false,
payment_show_order_no: true,
payment_show_create_time: true,
payment_show_pay_time: true,
payment_order_status_pending: '待支付',
payment_order_status_paid: '已支付',
payment_order_status_refunded: '已退款',
payment_order_status_closed: '已关闭',
pay_service: [],
down_alone_page: false,
down_remote_to_local_s: true,
down_remote_to_local_size: 2048,
down_remote_to_local_ext: 'zip,rar,7z,text,png,jpg,jpeg,gif,mp3,mp4,avi,wmv,flv,swf,doc,docx,xls,xlsx,ppt,pptx,pdf',
down_verify_nonce: true,
pay_modo_default: '0',
points_price_default: 0,
vip_1_points_default: 0,
vip_2_points_default: 0,
pay_price_default: '0.01',
pay_original_price_default: '',
pay_promotion_tag_default: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠',
vip_1_price_default: '0',
vip_2_price_default: '0',
pay_rebate_discount: '0',
pay_cuont_default: { min: 0, max: 0 },
pay_details_default: '',
pay_extra_hide_default: '',
pay_attributes_default: [],
pay_gallery_show_default: 1,
},
groups: [
{
key: 'notice',
title: '说明',
icon: 'fa fas fa-info-circle',
fields: [
{
key: 'knowledge_notice',
label: '',
type: 'content',
ui_only: true,
content: '<p><b>知识付费</b></p><ul style="margin:6px 0 0 18px;line-height:1.9"><li>知识付费功能主要分为文章付费、付费会员、余额系统、推广返佣、创作分成等功能组成,您可以根据您的需要开启对应功能</li><li>使用支付功能请先在「收款接口」页配置好收款通道</li></ul>',
},
],
},
{
key: 'guest-buy',
title: '免登陆购买',
icon: 'fa fas fa-user-clock',
fields: [
{ key: 'pay_no_logged_in', label: '免登陆购买', type: 'switch', default: true, help: '开启后如果用户未登录则使用浏览器缓存验证是否购买' },
{ key: 'pay_cookie_day', label: 'Cookie时间(天)', type: 'number', default: 15, min: 1, max: 31, dependsOn: { key: 'pay_no_logged_in', value: true }, help: '免登陆购买的浏览器缓存有效时间' },
{ key: 'pay_no_logged_remind', label: '未登录提醒', type: 'textarea', default: '您当前未登录!建议登陆后购买,可保存购买订单', rows: 2, dependsOn: { key: 'pay_no_logged_in', value: true } },
],
},
{
key: 'box-position',
title: '模块显示位置',
icon: 'fa fas fa-arrows-alt',
fields: [
{
key: 'pay_box_position',
label: '购买模块位置',
type: 'radio',
default: 'top',
options: [
{ label: '文章模块上方', value: 'box_top' },
{ label: '文章内容顶部', value: 'top' },
{ label: '文章内容底部', value: 'bottom' },
{ label: '文章模块下方', value: 'box_bottom' },
],
help: '在文章页面中购买模块的显示位置',
},
],
},
{
key: 'display-basic',
title: '基础显示',
icon: 'fa fas fa-eye',
fields: [
{ key: 'pay_free_logged_show', label: '免费资源登录可见', type: 'switch', default: true, help: '免费资源必须登录后才能查看' },
{ key: 'pay_show_user', label: '订单数据', type: 'switch', default: true, help: '在用户中心显示订单数据' },
{ key: 'pay_show_paycount', label: '销量显示', type: 'switch', default: true, help: '商品详情显示销售数量' },
],
},
{
key: 'coupon',
title: '商品优惠码',
icon: 'fa fas fa-ticket-alt',
fields: [
{ key: 'coupon_post_s', label: '为所有商品启用优惠券功能', type: 'switch', default: false, help: '开启后将为所有商品显示输入优惠码的功能;也可以单独给任意一个商品开启' },
{ key: 'coupon_desc', label: '优惠码默认说明', type: 'textarea', default: '', rows: 1, dependsOn: { key: 'coupon_post_s', value: true }, help: '用户填写优惠码时展示的提醒内容,支持HTML代码' },
],
},
{
key: 'display',
title: '展示设置',
icon: 'fa fas fa-list-alt',
fields: [
{ key: 'pay_mark', label: '付费标记', type: 'input', default: '付费' },
{ key: 'payment_show_ip', label: '显示用户IP', type: 'switch', default: false },
{ key: 'payment_show_device', label: '显示设备信息', type: 'switch', default: false },
{ key: 'payment_show_order_no', label: '显示订单号', type: 'switch', default: true },
{ key: 'payment_show_create_time', label: '显示创建时间', type: 'switch', default: true },
{ key: 'payment_show_pay_time', label: '显示支付时间', type: 'switch', default: true },
],
},
{
key: 'status',
title: '状态设置',
icon: 'fa fas fa-tags',
fields: [
{ key: 'payment_order_status_pending', label: '待支付状态文案', type: 'input', default: '待支付' },
{ key: 'payment_order_status_paid', label: '已支付状态文案', type: 'input', default: '已支付' },
{ key: 'payment_order_status_refunded', label: '已退款状态文案', type: 'input', default: '已退款' },
{ key: 'payment_order_status_closed', label: '已关闭状态文案', type: 'input', default: '已关闭' },
],
},
{
key: 'service',
title: '客户服务',
icon: 'fa fas fa-headphones',
fields: [
{
key: 'pay_service',
label: '用户服务内容',
type: 'group',
button_title: '添加服务项',
default: [],
fields: [
{ key: 'value', label: '服务内容', type: 'input', default: '' },
{ key: 'icon', label: '自定义图标', type: 'icon', default: '' },
],
},
],
},
{
key: 'feature',
title: '商品功能配置',
icon: 'fa fas fa-cubes',
fields: [
{
key: 'pay_type_option',
label: '商品功能配置',
type: 'tabbed',
tabs: [
{
key: 'download',
title: '付费下载',
fields: [
{ key: 'down_alone_page', label: '独立下载页面', type: 'switch', default: false, help: '开启后,付费资源的下载链接会跳转独立页面显示' },
{ key: 'down_remote_to_local_s', label: '远程文件转本地文件下载', type: 'switch', default: true, help: '当下载地址为外链文件时,先将远程文件下载到本地服务器,再提供给用户下载,可完全隐藏远程下载地址' },
{ key: 'down_remote_to_local_size', label: '文件大小限制(Kb)', type: 'number', default: 2048, min: 0 },
{ key: 'down_remote_to_local_ext', label: '文件格式限制', type: 'input', default: 'zip,rar,7z,text,png,jpg,jpeg,gif,mp3,mp4,avi,wmv,flv,swf,doc,docx,xls,xlsx,ppt,pptx,pdf', help: '限制使用此功能的文件格式,多个用英文逗号隔开' },
{ key: 'down_verify_nonce', label: '下载前检测用户环境', type: 'switch', default: true, help: '防止机器批量扫描下载,有效防止跨站攻击' },
],
},
],
},
],
},
{
key: 'presets',
title: '商品预置参数配置',
icon: 'fa fas fa-sliders-h',
fields: [
{
key: 'presets_notice',
label: '',
type: 'content',
ui_only: true,
content: '在下方配置的参数新建文章时候会自动填入,方便新建文章的设置。最终以文章配置为准',
},
{
key: 'pay_modo_default',
label: '支付类型',
type: 'radio',
default: '0',
options: [
{ label: '普通商品(金钱购买)', value: '0' },
{ label: '积分商品(积分购买,依赖于用户积分功能)', value: 'points' },
],
},
{ key: 'points_price_default', label: '积分售价(积分)', type: 'number', default: 0, min: 0 },
{ key: 'vip_1_points_default', label: '一级会员积分售价(积分)', type: 'number', default: 0, min: 0 },
{ key: 'vip_2_points_default', label: '二级会员积分售价(积分)', type: 'number', default: 0, min: 0 },
{ key: 'pay_price_default', label: '执行价', type: 'input', sanitize_type: 'money_decimal', default: '0.01', suffix: '元' },
{ key: 'pay_original_price_default', label: '原价', type: 'input', sanitize_type: 'money_decimal', default: '', suffix: '元', help: '显示在执行价格前面,并划掉' },
{ key: 'pay_promotion_tag_default', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', rows: 1 },
{ key: 'vip_1_price_default', label: '一级会员价格', type: 'input', sanitize_type: 'money_decimal', default: '0', suffix: '元', help: '填0则为一级会员免费' },
{ key: 'vip_2_price_default', label: '二级会员价格', type: 'input', sanitize_type: 'money_decimal', default: '0', suffix: '元', help: '填0则为二级会员免费' },
{ key: 'pay_rebate_discount', label: '推广折扣', type: 'input', sanitize_type: 'money_decimal', default: '0', suffix: '元', help: '通过推广链接购买,额外优惠的金额;需开启推广返佣功能,且不能超过实际购买价' },
{ key: 'pay_cuont_default', label: '销量浮动', type: 'between_number', default: { min: 0, max: 0 }, help: '为真实销量增加或减少的数量,系统会根据此处设置的区间获取随机值作为预置参数' },
{ key: 'pay_details_default', label: '更多详情', type: 'textarea', sanitize_type: 'html', default: '', rows: 3, help: '可插入任意的HTML代码' },
{ key: 'pay_extra_hide_default', label: '额外隐藏内容', type: 'textarea', sanitize_type: 'html', default: '', rows: 3, help: '可插入任意的HTML代码' },
{
key: 'pay_attributes_default',
label: '资源属性',
type: 'group',
button_title: '添加属性',
default: [],
fields: [
{ key: 'key', label: '属性名称', type: 'input', default: '' },
{ key: 'value', label: '属性内容', type: 'input', default: '' },
],
},
{ key: 'pay_gallery_show_default', label: '付费图片免费查看数量(张)', type: 'number', default: 1, min: 0, help: '设置付费图片可免费查看前几张图片的数量的默认值' },
],
},
],
}
@@ -1,94 +0,0 @@
export const orderSchema = {
key: 'payment.order',
title: '订单设置',
icon: 'fa fas fa-file-alt',
description: '配置订单前缀、有效期、展示字段和状态文案。',
defaults: {
payment_order_prefix: 'AKSRT',
payment_flow_prefix: 'AK',
payment_withdrawal_prefix: 'TX',
payment_order_expiry: 30,
pay_show_user: true,
pay_show_paycount: true,
pay_no_logged_in: true,
pay_cookie_day: 15,
pay_no_logged_remind: '您当前未登录!建议登陆后购买,可保存购买订单',
pay_wait_float_s: true,
payment_order_max_minutes: 30,
pay_mark: '付费',
pay_box_position: 'top',
payment_show_ip: false,
payment_show_device: false,
payment_show_order_no: true,
payment_show_create_time: true,
payment_show_pay_time: true,
payment_order_status_pending: '待支付',
payment_order_status_paid: '已支付',
payment_order_status_refunding: '退款中',
payment_order_status_refunded: '已退款',
payment_order_status_closed: '已关闭',
pay_service: '',
pay_service_icon: '',
pay_service_value: '',
pay_service_qq: '',
pay_service_wechat: '',
},
groups: [
{
key: 'basic',
title: '订单基础',
icon: 'fa fas fa-cog',
fields: [
{ key: 'payment_order_prefix', label: '订单前缀', type: 'input', default: 'AKSRT' },
{ key: 'payment_flow_prefix', label: '流水号前缀', type: 'input', sanitize_type: 'business_prefix', default: 'AK', maxlength: 12, help: '仅允许字母、数字、下划线和短横线,最多 12 位。' },
{ key: 'payment_withdrawal_prefix', label: '提现单号前缀', type: 'input', sanitize_type: 'business_prefix', default: 'TX', maxlength: 12, help: '仅允许字母、数字、下划线和短横线,最多 12 位。' },
{ key: 'payment_order_expiry', label: '订单有效期(分钟)', type: 'number', default: 30, min: 5, max: 1440 },
{ key: 'pay_show_user', label: '显示用户信息', type: 'switch', default: true },
{ key: 'pay_show_paycount', label: '显示购买次数', type: 'switch', default: true },
{ key: 'pay_no_logged_in', label: '未登录提示', type: 'switch', default: true },
{ key: 'pay_cookie_day', label: 'Cookie有效期(天)', type: 'number', default: 15, min: 1, max: 31, help: '未登录购买 Cookie 有效时间,1-31 天' },
{ key: 'pay_no_logged_remind', label: '未登录提醒文案', type: 'textarea', default: '您当前未登录!建议登陆后购买,可保存购买订单', help: '未登录用户查看付费内容时的提醒文案' },
{ key: 'pay_wait_float_s', label: '订单等待浮动', type: 'switch', default: true },
{ key: 'payment_order_max_minutes', label: '订单最大支付时间(分钟)', type: 'number', default: 30, min: 5, max: 1440 },
],
},
{
key: 'display',
title: '展示设置',
icon: 'fa fas fa-eye',
fields: [
{ key: 'pay_mark', label: '付费标记', type: 'input', default: '付费' },
{ key: 'pay_box_position', label: '付费框位置', type: 'radio', default: 'top', options: [{ label: '文章框顶部(封面后)', value: 'box_top' }, { label: '正文顶部', value: 'top' }, { label: '正文底部', value: 'bottom' }, { label: '文章框底部(相关前)', value: 'box_bottom' }] },
{ key: 'payment_show_ip', label: '显示用户IP', type: 'switch', default: false },
{ key: 'payment_show_device', label: '显示设备信息', type: 'switch', default: false },
{ key: 'payment_show_order_no', label: '显示订单号', type: 'switch', default: true },
{ key: 'payment_show_create_time', label: '显示创建时间', type: 'switch', default: true },
{ key: 'payment_show_pay_time', label: '显示支付时间', type: 'switch', default: true },
],
},
{
key: 'status',
title: '状态设置',
icon: 'fa fas fa-tags',
fields: [
{ key: 'payment_order_status_pending', label: '待支付状态文案', type: 'input', default: '待支付' },
{ key: 'payment_order_status_paid', label: '已支付状态文案', type: 'input', default: '已支付' },
{ key: 'payment_order_status_refunding', label: '退款中状态文案', type: 'input', default: '退款中' },
{ key: 'payment_order_status_refunded', label: '已退款状态文案', type: 'input', default: '已退款' },
{ key: 'payment_order_status_closed', label: '已关闭状态文案', type: 'input', default: '已关闭' },
],
},
{
key: 'service',
title: '服务设置',
icon: 'fa fas fa-headphones',
fields: [
{ key: 'pay_service', label: '客服设置', type: 'textarea', default: '' },
{ key: 'pay_service_icon', label: '客服图标', type: 'icon', default: '' },
{ key: 'pay_service_value', label: '客服内容', type: 'input', default: '' },
{ key: 'pay_service_qq', label: 'QQ客服', type: 'input', default: '' },
{ key: 'pay_service_wechat', label: '微信客服', type: 'media', default: '' },
],
},
],
}
@@ -1,24 +1,36 @@
/**
* 余额充值 — 参考主题结构:用户余额(含余额提现)、转账、充值商品选项、
* 卡密兑换与余额兑换积分。卡密的生成/管理在后台「卡密管理」页完成,
* 此处不再保留「充值卡」配置项。
*/
export const rechargeSchema = { export const rechargeSchema = {
key: 'payment.recharge', key: 'payment.recharge',
title: '余额充值', title: '余额充值',
icon: 'fa fas fa-bolt', icon: 'fa fas fa-bolt',
description: '配置余额充值套餐、转账与积分兑换。', description: '配置余额功能、充值商品选项、卡密兑换与余额兑换积分。',
defaults: { defaults: {
pay_balance_s: true, pay_balance_s: true,
pay_balance_withdraw_s: false,
pay_balance_desc: '', pay_balance_desc: '',
pay_balance_product: '30|30|入门包\n98|108|推荐包',
pay_balance_product_name: '',
pay_balance_price: 0,
pay_balance_tag: '元',
pay_balance_tag_class: '',
pay_balance_product_custom_s: true,
pay_balance_product_custom_limit: 9999,
pay_balance_transfer_s: false, pay_balance_transfer_s: false,
pay_balance_transfer_service_charge: 0, pay_balance_transfer_service_charge: 0,
pay_balance_transfer_desc: '', pay_balance_transfer_desc: '',
pay_balance_product: [
{ price: 50, pay_price: 0, tag: '', tag_class: '' },
{ price: 100, pay_price: 98, tag: '', tag_class: '' },
{ price: 200, pay_price: 188, tag: '推荐', tag_class: 'jb-cyan' },
],
pay_balance_product_custom_s: true,
pay_balance_product_custom_limit: 9999,
pay_balance_pass_charge_s: false, pay_balance_pass_charge_s: false,
pay_balance_pass_charge_desc: '', pay_balance_pass_charge_desc: '',
pay_balance_pass_charge_only_password: false, pay_balance_pass_charge_only_password: false,
points_pass_exchange_s: false,
points_pass_exchange_desc: '',
points_pass_exchange_only_password: false,
pay_vip_pass_charge_s: false,
pay_vip_pass_charge_desc: '',
pay_vip_pass_charge_only_password: false,
pay_balance_points_exchange_s: false, pay_balance_points_exchange_s: false,
pay_balance_points_exchange_rate: 100, pay_balance_points_exchange_rate: 100,
pay_balance_points_exchange_min_points: 100, pay_balance_points_exchange_min_points: 100,
@@ -26,34 +38,28 @@ export const rechargeSchema = {
pay_balance_points_exchange_desc: '', pay_balance_points_exchange_desc: '',
}, },
groups: [ groups: [
{
key: 'notice',
title: '说明',
icon: 'fa fas fa-info-circle',
fields: [
{
key: 'recharge_notice',
label: '',
type: 'content',
ui_only: true,
content: '<p><b>用户余额功能:</b></p><ul style="margin:6px 0 0 18px;line-height:1.9"><li>开启后用户可充值到余额,余额可用于全站消费</li><li>在下方设置默认的分成比例,同时支持单独为每一个用户设置独立的分成比例</li><li>可以在用户管理中,为某一个用户手动赠送余额</li></ul>',
},
],
},
{ {
key: 'basic', key: 'basic',
title: '充值开关', title: '用户余额',
icon: 'fa fas fa-credit-card', icon: 'fa fas fa-credit-card',
fields: [ fields: [
{ key: 'pay_balance_s', label: '启用余额功能', type: 'switch', default: true, help: '开启后用户中心显示现金钱包,并默认允许余额支付(可在收款接口单独关闭)。充值需另配支付宝/微信等外部网关。' }, { key: 'pay_balance_s', label: '启用余额充值/支付功能', type: 'switch', default: true, help: '开启后用户中心显示现金钱包,并可作为收银台支付方式' },
{ key: 'pay_balance_desc', label: '充值说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_s', value: true } }, { key: 'pay_balance_withdraw_s', label: '余额提现', type: 'switch', default: false, dependsOn: { key: 'pay_balance_s', value: true }, help: '允许将余额提现' },
], { key: 'pay_balance_desc', label: '充值说明', type: 'textarea', default: '', rows: 3, dependsOn: { key: 'pay_balance_s', value: true }, help: '用户充值时显示的内容,建议为提醒事项、充值协议等,支持HTML代码' },
},
{
key: 'packages',
title: '充值套餐',
icon: 'fa fas fa-folder-open',
fields: [
{ key: 'pay_balance_product', label: '套餐列表', type: 'textarea', default: '30|30|入门包\n98|108|推荐包', help: '每行一个套餐:支付金额|到账金额|标题', dependsOn: { key: 'pay_balance_s', value: true } },
],
},
{
key: 'product',
title: '充值产品',
icon: 'fa fas fa-archive',
fields: [
{ key: 'pay_balance_product_name', label: '充值产品', type: 'input', default: '', dependsOn: { key: 'pay_balance_s', value: true } },
{ key: 'pay_balance_price', label: '充值价格', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'pay_balance_s', value: true } },
{ key: 'pay_balance_tag', label: '价格标签', type: 'input', default: '元', dependsOn: { key: 'pay_balance_s', value: true } },
{ key: 'pay_balance_tag_class', label: '标签样式', type: 'input', default: '', dependsOn: { key: 'pay_balance_s', value: true } },
{ key: 'pay_balance_product_custom_s', label: '自定义金额', type: 'switch', default: true, dependsOn: { key: 'pay_balance_s', value: true } },
{ key: 'pay_balance_product_custom_limit', label: '自定义金额限制', type: 'number', default: 9999, min: 1, max: 99999, dependsOn: { key: 'pay_balance_s', value: true } },
], ],
}, },
{ {
@@ -61,23 +67,56 @@ export const rechargeSchema = {
title: '余额转账', title: '余额转账',
icon: 'fa fas fa-exchange-alt', icon: 'fa fas fa-exchange-alt',
fields: [ fields: [
{ key: 'pay_balance_transfer_s', label: '启用余额转账', type: 'switch', default: false }, { key: 'pay_balance_transfer_s', label: '启用余额转账', type: 'switch', default: false, help: '允许用户转账给其他用户' },
{ key: 'pay_balance_transfer_service_charge', label: '转账手续费(%)', type: 'number', default: 0, min: 0, max: 50, dependsOn: { key: 'pay_balance_transfer_s', value: true } }, { key: 'pay_balance_transfer_service_charge', label: '转账手续费(%)', type: 'number', default: 0, min: 0, max: 50, dependsOn: { key: 'pay_balance_transfer_s', value: true } },
{ key: 'pay_balance_transfer_desc', label: '转账说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_transfer_s', value: true } }, { key: 'pay_balance_transfer_desc', label: '转账说明', type: 'textarea', default: '', rows: 2, dependsOn: { key: 'pay_balance_transfer_s', value: true }, help: '用户转账时候显示的内容,建议为提醒事项、转账协议等,支持HTML代码' },
],
},
{
key: 'products',
title: '充值商品选项',
icon: 'fa fas fa-archive',
fields: [
{
key: 'pay_balance_product',
label: '充值金额选项',
type: 'group',
button_title: '添加充值金额选项',
default: [
{ price: 50, pay_price: 0, tag: '', tag_class: '' },
{ price: 100, pay_price: 98, tag: '', tag_class: '' },
{ price: 200, pay_price: 188, tag: '推荐', tag_class: 'jb-cyan' },
],
help: '用户充值时候,可快速选择的充值金额,同时支持设置折扣',
fields: [
{ key: 'price', label: '充值金额', type: 'number', default: 100, min: 0, suffix: '元' },
{ key: 'pay_price', label: '销售金额', type: 'number', default: 0, min: 0, suffix: '元', help: '选填,设置不同的销售金额实现充值折扣的功能,设置为0则与充值金额一致' },
{ key: 'tag', label: '小标签', type: 'input', default: '', help: '支持HTML,请注意控制长度' },
{ key: 'tag_class', label: '标签颜色类', type: 'input', default: '', help: '主题标签颜色类,如 jb-cyan / jb-pink' },
],
},
{ key: 'pay_balance_product_custom_s', label: '自定义充值金额', type: 'switch', default: true, help: '允许用户手动输入充值金额' },
{ key: 'pay_balance_product_custom_limit', label: '自定义充值金额上限', type: 'number', default: 9999, min: 1, max: 99999, dependsOn: { key: 'pay_balance_product_custom_s', value: true } },
], ],
}, },
{ {
key: 'pass-charge', key: 'pass-charge',
title: '充值卡免扣', title: '卡密兑换',
icon: 'fa fas fa-ticket-alt', icon: 'fa fas fa-ticket-alt',
fields: [ fields: [
{ key: 'pay_balance_pass_charge_s', label: '启用余额卡密兑换', type: 'switch', default: false }, { key: 'pay_balance_pass_charge_s', label: '启用卡密兑换余额功能', type: 'switch', default: false, help: '开启后用户可在余额充值弹窗选择「卡密」渠道,使用卡密直接兑换余额' },
{ key: 'pay_balance_pass_charge_desc', label: '卡密兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_balance_pass_charge_s', value: true } }, { key: 'pay_balance_pass_charge_desc', label: '卡密兑换余额说明', type: 'textarea', default: '', rows: 2, dependsOn: { key: 'pay_balance_pass_charge_s', value: true }, help: '用户使用卡密兑换余额时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
{ key: 'pay_balance_pass_charge_only_password', label: '仅密码兑换', type: 'switch', default: false, dependsOn: { key: 'pay_balance_pass_charge_s', value: true } }, { key: 'pay_balance_pass_charge_only_password', label: '余额卡密单密码模式', type: 'switch', default: false, dependsOn: { key: 'pay_balance_pass_charge_s', value: true }, help: '余额卡密仅需输入密码,不需要卡号' },
{ key: 'points_pass_exchange_s', label: '启用卡密兑换积分功能', type: 'switch', default: false, help: '开启后用户可在购买积分弹窗选择「卡密」渠道,使用卡密直接兑换积分' },
{ key: 'points_pass_exchange_desc', label: '卡密兑换积分说明', type: 'textarea', default: '', rows: 2, dependsOn: { key: 'points_pass_exchange_s', value: true }, help: '用户使用卡密兑换积分时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
{ key: 'points_pass_exchange_only_password', label: '积分卡密单密码模式', type: 'switch', default: false, dependsOn: { key: 'points_pass_exchange_s', value: true }, help: '积分卡密仅需输入密码,不需要卡号' },
{ key: 'pay_vip_pass_charge_s', label: '启用卡密兑换会员功能', type: 'switch', default: false, help: '开启后用户可在会员购买/续费弹窗选择「卡密」渠道,使用会员卡密直接兑换会员' },
{ key: 'pay_vip_pass_charge_desc', label: '卡密兑换会员说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 2, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '用户使用卡密兑换会员时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
{ key: 'pay_vip_pass_charge_only_password', label: '会员卡密单密码模式', type: 'switch', default: false, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '会员卡密仅需输入密码,不需要卡号;默认关闭,会员卡需输入卡号和密码(与其他卡密兑换一致)' },
], ],
}, },
{ {
key: 'exchange', key: 'points-exchange',
title: '余额兑换积分', title: '余额兑换积分',
icon: 'fa fas fa-database', icon: 'fa fas fa-database',
fields: [ fields: [
@@ -1,6 +1,9 @@
/**
* 创作分成 — 现金/积分分成比例 + 创作分成说明与详情页配置。
*/
export const revenueSchema = { export const revenueSchema = {
key: 'payment.revenue', key: 'payment.revenue',
title: '创作中心', title: '创作分成',
icon: 'fa fas fa-chart-line', icon: 'fa fas fa-chart-line',
description: '分别配置现金收益和积分收益的会员分成比例,结算周期结束后自动入账。', description: '分别配置现金收益和积分收益的会员分成比例,结算周期结束后自动入账。',
defaults: { defaults: {
@@ -13,8 +16,25 @@ export const revenueSchema = {
payment_revenue_points_ratio_user: 0, payment_revenue_points_ratio_user: 0,
payment_revenue_points_ratio_vip_1: 0, payment_revenue_points_ratio_vip_1: 0,
payment_revenue_points_ratio_vip_2: 0, payment_revenue_points_ratio_vip_2: 0,
pay_income_desc_details: '<p>在本站发布付费内容,获得的收益将参与收入分成</p><p>普通用户分成比例为:60%</p><p>一级会员分成比例为:70%</p><p>二级会员分成比例为:80%</p><p>分成比例的计算方式为:(实收现金-推广佣金)x 分成比例</p><p>申请提现后,需后台人工处理,一般2-3小时,请耐心等待</p>',
pay_income_desc_page_s: false,
pay_income_desc_page: 0,
}, },
groups: [ groups: [
{
key: 'notice',
title: '说明',
icon: 'fa fas fa-info-circle',
fields: [
{
key: 'revenue_notice',
label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>开启创作分成后,用户发布的付费内容则按设置的比例收入与用户分成</li><li>在下方设置默认的分成比例,同时支持单独为每一个用户设置独立的分成比例</li><li>用户分成比例是按照订单实收现金并扣除推广佣金后,再按设置的比例分成</li><li>禁用此功能后,所有收款将不会与作者分成</li></ul>',
},
],
},
{ {
key: 'basic', key: 'basic',
title: '创作中心设置', title: '创作中心设置',
@@ -30,7 +50,7 @@ export const revenueSchema = {
title: '现金收益分成', title: '现金收益分成',
icon: 'fa fas fa-credit-card', icon: 'fa fas fa-credit-card',
fields: [ fields: [
{ key: 'payment_revenue_cash_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 60, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } }, { key: 'payment_revenue_cash_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 60, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true }, help: '为0则不参与分成' },
{ key: 'payment_revenue_cash_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 70, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } }, { key: 'payment_revenue_cash_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 70, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_cash_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 80, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } }, { key: 'payment_revenue_cash_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 80, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
], ],
@@ -40,10 +60,20 @@ export const revenueSchema = {
title: '积分收益分成', title: '积分收益分成',
icon: 'fa fas fa-database', icon: 'fa fas fa-database',
fields: [ fields: [
{ key: 'payment_revenue_points_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } }, { key: 'payment_revenue_points_ratio_user', label: '普通用户分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true }, help: '为0则不参与分成(用户采用积分支付的订单给与作者的分成比例)' },
{ key: 'payment_revenue_points_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } }, { key: 'payment_revenue_points_ratio_vip_1', label: '一级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
{ key: 'payment_revenue_points_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } }, { key: 'payment_revenue_points_ratio_vip_2', label: '二级会员分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'pay_income_s', value: true } },
], ],
}, },
{
key: 'detail',
title: '分成说明',
icon: 'fa fas fa-file-alt',
fields: [
{ key: 'pay_income_desc_details', label: '创作分成说明', type: 'textarea', sanitize_type: 'html', default: '<p>在本站发布付费内容,获得的收益将参与收入分成</p><p>普通用户分成比例为:60%</p><p>一级会员分成比例为:70%</p><p>二级会员分成比例为:80%</p><p>分成比例的计算方式为:(实收现金-推广佣金)x 分成比例</p><p>申请提现后,需后台人工处理,一般2-3小时,请耐心等待</p>', rows: 8, dependsOn: { key: 'pay_income_s', value: true }, help: '显示在用户中心的说明,建议为发布协议或者其他说明(必填项,内容不易太少),支持HTML代码' },
{ key: 'pay_income_desc_page_s', label: '创作分成详情页面', type: 'switch', default: false, dependsOn: { key: 'pay_income_s', value: true }, help: '您可以新建一个页面写入更加详细的创作分成说明及协议内容,再开启此项并选择对应的页面' },
{ key: 'pay_income_desc_page', label: '选择页面', type: 'page_select', default: 0, dependsOn: { key: 'pay_income_desc_page_s', value: true } },
],
},
], ],
} }
+443 -93
View File
@@ -1,46 +1,190 @@
/**
* VIP会员 — 结构 1:1 对照参考主题 Zibll:会员等级(开关)、会员权益
* (普通/一级/二级 tab)、会员购买选项(一级/二级/续费/升级 tab)、
* 积分兑换会员与界面文案。
*
* 会员商品 / 续费商品 / 升级价格 / 积分兑换明细均为结构化 group 或
* accordion,不再使用 `|` 分隔的 textarea。等级 Tab 标题通过 titleKey
* 绑定「会员显示名称」,改名即改 Tab 名。
*/
const VIP_EQUITY_DEFAULT = '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>';
const vipTagPalette = [
{ label: '#f74b3d', value: 'b-red' },
{ label: '#f3920a', value: 'b-yellow' },
{ label: '#08c4c1', value: 'b-cyan' },
{ label: '#0a8cf3', value: 'b-blue' },
{ label: '#1fd05a', value: 'b-green' },
{ label: '#c133f5', value: 'b-purple' },
{ label: '#121517', value: 'b-black' },
{ label: 'linear-gradient(135deg, #ffbeb4 10%, #f61a1a 100%)', value: 'jb-red' },
{ label: 'linear-gradient(135deg, #ff5e7f 30%, #ff967e 100%)', value: 'jb-pink' },
{ label: 'linear-gradient(135deg, #ffd6b2 10%, #ff651c 100%)', value: 'jb-yellow' },
{ label: 'linear-gradient(140deg, #039ab3 10%, #58dbcf 90%)', value: 'jb-cyan' },
{ label: 'linear-gradient(135deg, #b6e6ff 10%, #198aff 100%)', value: 'jb-blue' },
{ label: 'linear-gradient(135deg, #ccffcd 10%, #52bb51 100%)', value: 'jb-green' },
{ label: 'linear-gradient(135deg, #fec2ff 10%, #d000de 100%)', value: 'jb-purple' },
{ label: 'linear-gradient(25deg, #eab869 10%, #fbecd4 60%, #ffe0ae 100%)', value: 'jb-vip1' },
{ label: 'linear-gradient(317deg, #4d4c4c 30%, #878787 70%, #5f5c5c 100%)', value: 'jb-vip2' },
];
const vipUnitOptions = [
{ label: '天', value: 'day' },
{ label: '月', value: 'month' },
];
// 会员商品/续费商品的手风琴配置:默认收起,标题行显示价格与时长摘要。
const vipProductGroupOptions = {
collapsible: true,
defaultCollapsed: true,
listPreview: { prefix: '价格:', titleKey: 'price', descKey: 'time', descUnitKey: 'unit' },
};
// 积分兑换明细的手风琴配置:默认收起,标题行显示积分与时长摘要。
const vipPointsGroupOptions = {
collapsible: true,
defaultCollapsed: true,
listPreview: { prefix: '积分:', titleKey: 'points', descKey: 'time', descUnitKey: 'unit' },
};
/**
* 会员商品字段集(参考主题 CFS_Module::vip_product)。
*/
function vipProductFields() {
return [
{ key: 'price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '699.00', min: 0, suffix: '元', help: '会员的实际购买价格' },
{ key: 'show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '999.00', min: 0, suffix: '元', help: '显示在执行价格前面,并划掉' },
{ key: 'tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '', rows: 1, help: '支持HTML,请注意控制长度' },
{ key: 'tag_class', label: '标签颜色', type: 'palette', default: 'jb-yellow', dependsOn: { key: 'tag', operator: 'not_empty' }, options: vipTagPalette },
{ key: 'time', label: '会员有效时间', type: 'spinner', default: 3, min: 0, max: 3600, step: 1, help: '开通会员的时长。填0则为永久会员' },
{ key: 'unit', label: '时长单位', type: 'radio', default: 'month', inline: true, dependsOn: { key: 'time', operator: 'truthy' }, options: vipUnitOptions },
{ key: '_time_permanent_hint', label: '', type: 'alert', alertType: 'success', ui_only: true, dependsOn: { key: 'time', operator: 'eq', value: 0 }, content: '会员有效时间已设置为:永久会员' },
];
}
/**
* 会员等级 Tab 字段集(参考主题 CFS_Module::vip_tab,显示名称并入 Tab)。
*/
function vipLevelTabFields(level) {
const dep = { key: `pay_user_vip_${level}_s`, value: true };
return [
{ key: `pay_user_vip_${level}_name`, label: '会员显示名称', type: 'input', sanitize_type: 'html', default: level === 1 ? '黄金会员' : '钻石会员', dependsOn: dep, help: '支持HTML,请注意控制长度' },
{ key: `pay_user_vip_${level}_desc`, label: '一句话简介', type: 'input', sanitize_type: 'html', default: '尊享专属特权', dependsOn: dep, help: '支持HTML' },
{ key: `pay_user_vip_${level}_equity`, label: '会员权益 HTML', type: 'textarea', sanitize_type: 'html', default: VIP_EQUITY_DEFAULT, rows: 4, dependsOn: dep, help: '支持安全HTML,建议使用 <li> 列表项' },
{ key: `vip_${level}_product_s`, label: '购买会员', type: 'switch', default: true, dependsOn: dep, help: '关闭后则不能付费购买会员,但可通过积分兑换会员,请确保已开启积分以及积分兑换会员功能' },
{
key: `vip_${level}_product`,
label: '会员商品',
type: 'group',
max: 8,
button_title: '添加会员商品',
default: [
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
...vipProductGroupOptions,
dependsOn: { all: [dep, { key: `vip_${level}_product_s`, value: true }] },
help: '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
fields: vipProductFields(),
},
{ key: `vip_${level}_img_icon`, label: '会员图标', type: 'upload', default: '', dependsOn: dep, help: `留空使用内置 vip-${level}.svg 图标;建议尺寸300x300` },
];
}
export const vipSchema = { export const vipSchema = {
key: 'payment.vip', key: 'payment.vip',
title: 'VIP会员', title: 'VIP会员',
icon: 'fa fas fa-gem', icon: 'fa fas fa-gem',
description: '对齐 legacy 双级会员:分档套餐、权益、续费/升级与积分兑换。', description: '双级会员:等级开关、权益(下载次数与限速)、会员商品、续费/升级与积分兑换。',
defaults: { defaults: {
nav_pay_vip: true, nav_pay_vip: true,
nav_user_pay_vip: true, nav_user_pay_vip: true,
pay_user_vip_1_s: true, pay_user_vip_1_s: true,
pay_user_vip_1_name: '一级会员', pay_user_vip_1_name: '黄金会员',
pay_user_vip_1_desc: '尊享一级会员特权', pay_user_vip_1_desc: '尊享专属特权',
pay_user_vip_1_equity: '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>', pay_user_vip_1_equity: VIP_EQUITY_DEFAULT,
vip_1_product_s: true, vip_1_product_s: true,
payment_vip_1_products: '30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡', vip_1_product: [
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_1_img_icon: '', vip_1_img_icon: '',
pay_user_vip_2_s: true, pay_user_vip_2_s: true,
pay_user_vip_2_name: '二级会员', pay_user_vip_2_name: '钻石会员',
pay_user_vip_2_desc: '尊享二级会员特权', pay_user_vip_2_desc: '尊享专属特权',
pay_user_vip_2_equity: '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>', pay_user_vip_2_equity: VIP_EQUITY_DEFAULT,
vip_2_product_s: true, vip_2_product_s: true,
payment_vip_2_products: '58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡', vip_2_product: [
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_2_img_icon: '', vip_2_img_icon: '',
pay_user_vip_desc: '', pay_user_vip_desc: '开通VIP会员,享受会员专属折扣以及多项特权',
pay_user_vip_more: '', pay_user_vip_more: '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>',
pay_download_limit: 0, pay_download_limit: 0,
pay_download_speed: 0,
pay_download_limit_vip_1: 0, pay_download_limit_vip_1: 0,
pay_download_speed_vip_1: 0,
pay_download_limit_vip_2: 0, pay_download_limit_vip_2: 0,
pay_vip_pass_charge_s: false, pay_download_speed_vip_2: 0,
pay_vip_pass_charge_desc: '', vip_1_product_s: true,
pay_vip_pass_charge_only_password: true,
pay_vip_points_exchange_s: false,
pay_vip_points_exchange_desc: '',
payment_vip_points_packages: '1|500|30|积分月卡\n1|5000|0|积分永久',
vip_renew: true, vip_renew: true,
vip_renew_desc: '', vip_renew_desc: '立即续费会员,畅享VIP权益',
vip_renew_price_type: 'discount', vip_renew_price_type: 'discount',
vip_renew_discount: 8, vip_renew_discount: 8,
vip_renew_reduce: '20.00', vip_renew_reduce: '20.00',
vip_1_renew_product: [
{ price: 69, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 169, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_2_renew_product: [
{ price: 269, show_price: 599, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 369, show_price: 899, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_2_product_s: true,
vip_renew: true,
vip_renew_discount: 8,
vip_renew_reduce: '20.00',
vip_upgrade: true, vip_upgrade: true,
vip_upgrade_desc: '', vip_upgrade_desc: '升级VIP会员,享更多会员权益',
vip_upgrade_product: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级', vip_upgrade_product: {
month_to_month_s: true,
unit_price: '10.00',
unit_tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐',
unit_tag_class: 'jb-yellow',
jump_s: false,
jump_price: '199.00',
jump_show_price: '299.00',
jump_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
jump_tag_class: 'jb-red',
permanent_price: '199.00',
permanent_show_price: '299.00',
permanent_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
permanent_tag_class: 'jb-red',
},
pay_vip_points_exchange_s: false,
pay_vip_points_exchange_product: [
{ points: 500, level: 1, time: 30, unit: 'day' },
{ points: 5000, level: 1, time: 'Permanent' },
],
pay_vip_points_exchange_desc: '',
}, },
groups: [ groups: [
{
key: 'notice',
title: '说明',
icon: 'fa fas fa-info-circle',
fields: [
{
key: 'vip_notice',
label: '',
type: 'content',
ui_only: true,
content: '<ul style="margin:0 0 0 18px;line-height:1.9"><li>开启付费会员功能之前,请先配置好收款接口,确保网站收款正常</li><li>会员功能的设置项目较多,请仔细核对,避免出现价格、时间的问题</li><li>配合设置名称、有效期等可搭配出不同类型的会员</li><li>管理员可在后台为用户单独开启会员</li></ul>',
},
],
},
{ {
key: 'basic', key: 'basic',
title: '会员等级', title: '会员等级',
@@ -49,100 +193,306 @@ export const vipSchema = {
{ key: 'nav_pay_vip', label: '导航栏购买按钮', type: 'switch', default: true, help: '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。' }, { key: 'nav_pay_vip', label: '导航栏购买按钮', type: 'switch', default: true, help: '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。' },
{ key: 'nav_user_pay_vip', label: '用户框购买按钮', type: 'switch', default: true, help: '在导航栏用户框内显示开通会员按钮。' }, { key: 'nav_user_pay_vip', label: '用户框购买按钮', type: 'switch', default: true, help: '在导航栏用户框内显示开通会员按钮。' },
{ key: 'pay_user_vip_1_s', label: '启用一级会员', type: 'switch', default: true }, { key: 'pay_user_vip_1_s', label: '启用一级会员', type: 'switch', default: true },
{ key: 'pay_user_vip_1_name', label: '一级会员名称', type: 'input', default: '一级会员', dependsOn: { key: 'pay_user_vip_1_s', value: true } }, {
{ key: 'pay_user_vip_1_desc', label: '一级会员一句话简介', type: 'input', default: '尊享一级会员特权', dependsOn: { key: 'pay_user_vip_1_s', value: true } }, key: 'vip_level_order_notice',
{ key: 'pay_user_vip_1_equity', label: '一级会员权益 HTML', type: 'textarea', default: '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>', dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '建议使用 <li> 列表项' }, label: '',
{ key: 'vip_1_img_icon', label: '一级会员图标 URL', type: 'input', default: '', dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '留空使用内置 vip-1.svg 图标' }, type: 'notice',
ui_only: true,
alertType: 'danger',
dependsOn: { all: [{ key: 'pay_user_vip_2_s', value: true }, { key: 'pay_user_vip_1_s', operator: 'falsy' }] },
content: '<div style="text-align:center"><b>必须先开启一级会员后才能开启二级会员!</b></div>',
},
{ key: 'pay_user_vip_2_s', label: '启用二级会员', type: 'switch', default: true }, { key: 'pay_user_vip_2_s', label: '启用二级会员', type: 'switch', default: true },
{ key: 'pay_user_vip_2_name', label: '二级会员名称', type: 'input', default: '二级会员', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{ key: 'pay_user_vip_2_desc', label: '二级会员一句话简介', type: 'input', default: '尊享二级会员特权', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{ key: 'pay_user_vip_2_equity', label: '二级会员权益 HTML', type: 'textarea', default: '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{ key: 'vip_2_img_icon', label: '二级会员图标 URL', type: 'input', default: '', dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '留空使用内置 vip-2.svg 图标' },
{ key: 'pay_user_vip_desc', label: '弹窗顶部会员说明', type: 'textarea', default: '' },
{ key: 'pay_user_vip_more', label: '弹窗底部补充说明 HTML', type: 'textarea', default: '' },
],
},
{
key: 'packages',
title: '分档套餐',
icon: 'fa fas fa-gem',
fields: [
{ key: 'vip_1_product_s', label: '允许购买一级会员', type: 'switch', default: true, dependsOn: { key: 'pay_user_vip_1_s', value: true } },
{
key: 'payment_vip_1_products',
label: '一级会员套餐',
type: 'textarea',
default: '30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡',
dependsOn: { key: 'vip_1_product_s', value: true },
help: '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久',
},
{ key: 'vip_2_product_s', label: '允许购买二级会员', type: 'switch', default: true, dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{
key: 'payment_vip_2_products',
label: '二级会员套餐',
type: 'textarea',
default: '58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡',
dependsOn: { key: 'vip_2_product_s', value: true },
help: '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久',
},
],
},
{
key: 'points_exchange',
title: '积分兑换会员',
icon: 'fa fas fa-database',
fields: [
{ key: 'pay_vip_points_exchange_s', label: '启用积分兑换', type: 'switch', default: false },
{
key: 'payment_vip_points_packages',
label: '兑换套餐',
type: 'textarea',
default: '1|500|30|积分月卡\n1|5000|0|积分永久',
dependsOn: { key: 'pay_vip_points_exchange_s', value: true },
help: '每行:等级|积分|天数|标题(天数 0=永久;仅未开通会员可兑)',
},
{ key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_vip_points_exchange_s', value: true } },
], ],
}, },
{ {
key: 'benefit', key: 'benefit',
title: '下载权益', title: '会员权益',
icon: 'fa fas fa-gift', icon: 'fa fas fa-gift',
fields: [ fields: [
{ key: 'pay_download_limit', label: '普通用户下载限制', type: 'number', default: 0, min: 0, max: 9999, help: '0为不限制' }, {
{ key: 'pay_download_limit_vip_1', label: '一级会员下载限制', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_1_s', value: true } }, key: 'vip_benefit',
{ key: 'pay_download_limit_vip_2', label: '二级会员下载限制', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_2_s', value: true } }, label: '会员权益',
{ key: 'pay_vip_pass_charge_s', label: '会员卡密兑换', type: 'switch', default: false }, type: 'tabbed',
{ key: 'pay_vip_pass_charge_desc', label: '卡密兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_vip_pass_charge_s', value: true } }, tabs: [
{ key: 'pay_vip_pass_charge_only_password', label: '仅密码兑换', type: 'switch', default: true, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '开启后只需输入卡密;关闭则需卡号+卡密。' }, {
key: 'ordinary',
title: '普通用户',
fields: [
{ key: 'pay_download_limit', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, help: '普通用户每日最多可下载几个免费资源(为0则不限制)' },
{ key: 'pay_download_speed', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' },
],
},
{
key: 'vip1',
title: '一级会员',
titleKey: 'pay_user_vip_1_name',
titleFallback: '一级会员',
fields: [
{ key: 'pay_download_limit_vip_1', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '一级会员每日最多可下载几个免费资源(为0则不限制)' },
{ key: 'pay_download_speed_vip_1', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' },
],
},
{
key: 'vip2',
title: '二级会员',
titleKey: 'pay_user_vip_2_name',
titleFallback: '二级会员',
fields: [
{ key: 'pay_download_limit_vip_2', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '二级会员每日最多可下载几个免费资源(为0则不限制)' },
{ key: 'pay_download_speed_vip_2', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' },
],
},
],
},
],
},
{
key: 'opt',
title: '会员购买选项',
icon: 'fa fas fa-shopping-cart',
fields: [
{
key: 'vip_opt',
label: '会员购买选项',
type: 'tabbed',
tabs: [
{
key: 'vip1',
title: '一级会员',
titleKey: 'pay_user_vip_1_name',
titleFallback: '一级会员',
fields: [
...vipLevelTabFields(1),
],
},
{
key: 'vip2',
title: '二级会员',
titleKey: 'pay_user_vip_2_name',
titleFallback: '二级会员',
fields: [
...vipLevelTabFields(2),
], ],
}, },
{ {
key: 'renew', key: 'renew',
title: '续费设置', title: '会员续费',
icon: 'fa fas fa-sync-alt',
fields: [ fields: [
{ key: 'vip_renew', label: '允许续费', type: 'switch', default: true }, {
{ key: 'vip_renew_desc', label: '续费说明', type: 'textarea', default: '', dependsOn: { key: 'vip_renew', value: true } }, key: 'vip_renew',
{ key: 'vip_renew_price_type', label: '续费价格类型', type: 'radio', default: 'discount', options: [{ label: '相同价格', value: 'same' }, { label: '折扣价格', value: 'discount' }, { label: '优惠立减', value: 'reduce' }], dependsOn: { key: 'vip_renew', value: true } }, label: '允许续费',
{ key: 'vip_renew_discount', label: '续费折扣(折,如 8=八折)', type: 'number', default: 8, min: 1, max: 100, dependsOn: { key: 'vip_renew_price_type', value: 'discount' } }, type: 'switch',
{ key: 'vip_renew_reduce', label: '续费减免金额', type: 'input', sanitize_type: 'money_decimal', default: '20.00', min: 0, max: 9999, dependsOn: { key: 'vip_renew_price_type', value: 'reduce' } }, default: true,
help: '关闭后则不能续费,只能会员到期之后再购买。永久会员不需要续费',
},
{
key: 'vip_renew_desc',
label: '续费介绍',
type: 'input',
sanitize_type: 'html',
default: '立即续费会员,畅享VIP权益',
dependsOn: { key: 'vip_renew', value: true },
help: '续费会员的一句话简介,支持HTML',
},
{
key: 'vip_renew_price_type',
label: '续费价格类型',
type: 'select',
default: 'discount',
dependsOn: { key: 'vip_renew', value: true },
options: [
{ label: '相同价格', value: 'same' },
{ label: '折扣价格', value: 'discount' },
{ label: '优惠立减', value: 'reduce' },
{ label: '自定义商品', value: 'customize' },
],
help: '自定义商品时,按下方「一级会员续费」「二级会员续费」的商品选项计价',
},
{
key: 'vip_renew_discount',
label: '续费折扣',
type: 'slider',
sanitize_type: 'float',
default: 8,
min: 0.1,
max: 9.9,
step: 0.1,
unit: '折',
dependsOn: { key: 'vip_renew_price_type', value: 'discount' },
help: '在会员商品基础上打几折,如 8 = 八折',
},
{
key: 'vip_renew_reduce',
label: '续费减免金额',
type: 'spinner',
sanitize_type: 'money_decimal',
default: '20.00',
min: 0,
max: 500,
step: 5,
unit: '元',
dependsOn: { key: 'vip_renew_price_type', value: 'reduce' },
help: '在会员商品基础上优惠的金额,优惠后总金额不能为0元',
},
{
key: 'vip_1_renew_product',
label: '一级会员续费',
type: 'group',
max: 8,
button_title: '添加续费商品',
default: [
{ price: 69, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 169, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
help: '一级会员续费的商品选项',
...vipProductGroupOptions,
fields: vipProductFields(),
},
{
key: 'vip_2_renew_product',
label: '二级会员续费',
type: 'group',
max: 8,
button_title: '添加续费商品',
default: [
{ price: 269, show_price: 599, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 369, show_price: 899, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
help: '二级会员续费的商品选项',
...vipProductGroupOptions,
fields: vipProductFields(),
},
], ],
}, },
{ {
key: 'upgrade', key: 'upgrade',
title: '升级设置', title: '会员升级',
icon: 'fa fas fa-arrow-up',
fields: [ fields: [
{ key: 'vip_upgrade', label: '允许升级', type: 'switch', default: true }, { key: 'vip_upgrade', label: '允许升级', type: 'switch', default: true },
{ key: 'vip_upgrade_desc', label: '升级说明', type: 'textarea', default: '', dependsOn: { key: 'vip_upgrade', value: true } }, { key: 'vip_upgrade_desc', label: '升级介绍', type: 'input', sanitize_type: 'html', default: '升级VIP会员,享更多会员权益', dependsOn: { key: 'vip_upgrade', value: true }, help: '升级会员的一句话简介,支持HTML' },
{ {
key: 'vip_upgrade_product', key: 'vip_upgrade_product',
label: '升级产品', label: '升级价格',
type: 'textarea', type: 'accordion',
default: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级', default: {
month_to_month_s: true,
unit_price: '10.00',
unit_tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐',
unit_tag_class: 'jb-yellow',
jump_s: false,
jump_price: '199.00',
jump_show_price: '299.00',
jump_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
jump_tag_class: 'jb-red',
permanent_price: '199.00',
permanent_show_price: '299.00',
permanent_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
permanent_tag_class: 'jb-red',
},
dependsOn: { key: 'vip_upgrade', value: true }, dependsOn: { key: 'vip_upgrade', value: true },
help: '每行一条。永久VIP1升级:permanent|价格|标题;按剩余天:unit|每日单价|标题;限时跳转永久:jump|价格|标题', help: '会员升级的商品选项',
sections: [
{ title: '月费会员升级月费会员', keys: ['_upgrade_unit_notice', 'month_to_month_s', 'unit_price', 'unit_tag', 'unit_tag_class'] },
{ title: '月费会员升级永久会员', keys: ['_upgrade_jump_notice', 'jump_s', 'jump_price', 'jump_show_price', 'jump_tag', 'jump_tag_class'] },
{ title: '永久会员升级永久会员', keys: ['permanent_price', 'permanent_show_price', 'permanent_tag', 'permanent_tag_class'] },
],
fields: [
{ key: '_upgrade_unit_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '月费会员升级为月费会员,用户会员有效期不会改变;价格按照会员剩余天数计算升级价格,请在下方设置每天的单价' },
{ key: 'month_to_month_s', label: '月费会员升级月费会员', type: 'switch', default: true },
{ key: 'unit_price', label: '天单价', type: 'number', sanitize_type: 'money_decimal', default: '10.00', min: 0, unit: '元', dependsOn: { key: 'month_to_month_s', value: true } },
{ key: 'unit_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', rows: 1, dependsOn: { key: 'month_to_month_s', value: true }, help: '支持HTML,请注意控制长度' },
{ key: 'unit_tag_class', label: '标签颜色', type: 'palette', default: 'jb-yellow', dependsOn: { all: [{ key: 'month_to_month_s', value: true }, { key: 'unit_tag', operator: 'not_empty' }] }, options: vipTagPalette },
{ key: '_upgrade_jump_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '如果网站可以购买月费会员和永久会员,则可以开启此项;允许用户由月费会员直接升级到更高一级的永久会员,用户之前还未到期的部分将自动一起转为永久会员,可能会涉及到差价问题,请自行告知用户;如果网站没有永久会员的购买选项请务必关闭此项' },
{ key: 'jump_s', label: '月费会员升级永久会员', type: 'switch', default: false },
{ key: 'jump_price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '199.00', min: 0, unit: '元', dependsOn: { key: 'jump_s', value: true }, help: '月费会员升级永久会员的价格' },
{ key: 'jump_show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '299.00', min: 0, unit: '元', dependsOn: { key: 'jump_s', value: true }, help: '显示在执行价格前面,并划掉' },
{ key: 'jump_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', rows: 1, dependsOn: { key: 'jump_s', value: true }, help: '支持HTML,请注意控制长度' },
{ key: 'jump_tag_class', label: '标签颜色', type: 'palette', default: 'jb-red', dependsOn: { all: [{ key: 'jump_s', value: true }, { key: 'jump_tag', operator: 'not_empty' }] }, options: vipTagPalette },
{ key: 'permanent_price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '199.00', min: 0, unit: '元', help: '永久会员升级永久会员的价格' },
{ key: 'permanent_show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '299.00', min: 0, unit: '元', help: '显示在执行价格前面,并划掉' },
{ key: 'permanent_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', rows: 1, help: '支持HTML,请注意控制长度' },
{ key: 'permanent_tag_class', label: '标签颜色', type: 'palette', default: 'jb-red', dependsOn: { key: 'permanent_tag', operator: 'not_empty' }, options: vipTagPalette },
],
},
],
},
],
},
],
},
{
key: 'points-exchange',
title: '积分兑换会员',
icon: 'fa fas fa-database',
fields: [
{ key: 'pay_vip_points_exchange_s', label: '启用积分兑换', type: 'switch', default: false, help: '开启后用户可使用积分兑换会员;由于积分可免费获取,所以积分兑换会员功能不支持升级及续费' },
{
key: 'pay_vip_points_exchange_product',
label: '积分兑换明细',
type: 'group',
max: 16,
button_title: '添加积分兑换商品',
default: [
{ points: 500, level: 1, time: 30, unit: 'day' },
{ points: 5000, level: 1, time: 'Permanent' },
],
dependsOn: { key: 'pay_vip_points_exchange_s', value: true },
help: '每一行为一个可兑换的会员套餐',
...vipPointsGroupOptions,
fields: [
{ key: 'points', label: '使用积分', type: 'number', default: 100, min: 0, unit: '积分' },
{
key: 'level',
label: '兑换会员',
type: 'radio',
default: 1,
inline: true,
options: [
{ label: '一级会员', value: 1 },
{ label: '二级会员', value: 2 },
],
},
{ key: 'time', label: '兑换时长', type: 'input', default: '', help: '填 Permanent 为永久' },
{
key: 'unit',
label: '时长单位',
type: 'radio',
default: 'day',
inline: true,
dependsOn: { key: 'time', operator: 'neq', value: 'Permanent' },
options: vipUnitOptions,
},
],
},
{ key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 2, dependsOn: { key: 'pay_vip_points_exchange_s', value: true }, help: '显示在积分兑换弹窗底部,可以为提醒事项、兑换协议等,支持HTML代码' },
],
},
{
key: 'modal-copy',
title: '界面文案',
icon: 'fa fas fa-align-left',
fields: [
{
key: 'pay_user_vip_desc',
label: '会员介绍',
type: 'input',
sanitize_type: 'html',
default: '开通VIP会员,享受会员专属折扣以及多项特权',
dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] },
help: '显示在开通界面顶部一句话简介,可以为会员权益简介或者活动介绍,支持HTML',
},
{
key: 'pay_user_vip_more',
label: '开通会员更多内容',
type: 'textarea',
sanitize_type: 'html',
default: '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>',
rows: 4,
dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] },
help: '显示在开通界面底部位置,可以为提醒事项、用户协议等,支持HTML代码',
}, },
], ],
}, },
@@ -1,8 +1,11 @@
/**
* 提现策略 — 提现门槛、每日限制、收款渠道与提现协议。
*/
export const withdrawalSchema = { export const withdrawalSchema = {
key: 'payment.withdrawal', key: 'payment.withdrawal',
title: '提现策略', title: '提现策略',
icon: 'fa fas fa-money-bill', icon: 'fa fas fa-money-bill',
description: '配置提现门槛、手续费比例与每日限制。', description: '配置提现门槛、手续费比例、每日限制、收款渠道与提现协议。',
defaults: { defaults: {
affiliate_min_withdrawal: '50.00', affiliate_min_withdrawal: '50.00',
affiliate_withdrawal_fee: 5, affiliate_withdrawal_fee: 5,
@@ -10,20 +13,43 @@ export const withdrawalSchema = {
affiliate_daily_withdrawal_limit: '500.00', affiliate_daily_withdrawal_limit: '500.00',
affiliate_withdrawal_reset_time: '00:00', affiliate_withdrawal_reset_time: '00:00',
affiliate_payout_channels: ['alipay', 'wechat'], affiliate_payout_channels: ['alipay', 'wechat'],
pay_rebate_withdraw_text_details: '<div>可提现金额达到50元后即可发起提现</div><div>申请提现需后台人工处理,一般2-3小时,请耐心等待</div><div>如有其它疑问,请与客服联系</div>',
}, },
groups: [ groups: [
{ {
key: 'basic', key: 'basic',
title: '提现设置', title: '提现门槛',
icon: 'fa fas fa-sliders-h', icon: 'fa fas fa-sliders-h',
fields: [ fields: [
{ key: 'affiliate_min_withdrawal', label: '最低提现金额', type: 'input', sanitize_type: 'money_decimal', default: '50.00', help: '仅接受最多两位小数的十进制字符串。' }, { key: 'affiliate_min_withdrawal', label: '提现最低金额限制', type: 'input', sanitize_type: 'money_decimal', default: '50.00', help: '当用户可提现总金额(推广佣金、余额、创作分成)高于多少时候,才能发起提现' },
{ key: 'affiliate_withdrawal_fee', label: '提现手续费(%)', type: 'number', default: 5, min: 0, max: 100 }, { key: 'affiliate_withdrawal_fee', label: '提现手续费(%)', type: 'number', default: 5, min: 0, max: 100 },
],
},
{
key: 'limits',
title: '每日限制',
icon: 'fa fas fa-clock',
fields: [
{ key: 'affiliate_daily_withdrawal_count', label: '每日可提现次数', type: 'number', default: 3, min: 0, max: 99999 }, { key: 'affiliate_daily_withdrawal_count', label: '每日可提现次数', type: 'number', default: 3, min: 0, max: 99999 },
{ key: 'affiliate_daily_withdrawal_limit', label: '每日提现上限', type: 'input', sanitize_type: 'money_decimal', default: '500.00', help: '仅接受最多两位小数的十进制字符串。' }, { key: 'affiliate_daily_withdrawal_limit', label: '每日提现上限', type: 'input', sanitize_type: 'money_decimal', default: '500.00' },
{ key: 'affiliate_withdrawal_reset_time', label: '重置时间', type: 'input', default: '00:00', help: '按站点 WordPress 时区重置,格式 HH:MM。' }, { key: 'affiliate_withdrawal_reset_time', label: '重置时间', type: 'input', default: '00:00', help: '按站点 WordPress 时区重置,格式 HH:MM。' },
],
},
{
key: 'channels',
title: '收款渠道',
icon: 'fa fas fa-qrcode',
fields: [
{ key: 'affiliate_payout_channels', label: '可用收款渠道', type: 'checkbox', default: ['alipay', 'wechat'], sanitize_type: 'array', options: [{ label: '支付宝收款码', value: 'alipay' }, { label: '微信收款码', value: 'wechat' }] }, { key: 'affiliate_payout_channels', label: '可用收款渠道', type: 'checkbox', default: ['alipay', 'wechat'], sanitize_type: 'array', options: [{ label: '支付宝收款码', value: 'alipay' }, { label: '微信收款码', value: 'wechat' }] },
], ],
}, },
{
key: 'agreement',
title: '提现协议',
icon: 'fa fas fa-file-contract',
fields: [
{ key: 'pay_rebate_withdraw_text_details', label: '提现协议', type: 'textarea', sanitize_type: 'html', default: '<div>可提现金额达到50元后即可发起提现</div><div>申请提现需后台人工处理,一般2-3小时,请耐心等待</div><div>如有其它疑问,请与客服联系</div>', rows: 4, help: '用户申请提现时展示的内容,建议为提现须知等,支持HTML代码' },
],
},
], ],
} }
@@ -3,6 +3,30 @@
* Section keys must stay aligned with the router and PHP schema provider. * Section keys must stay aligned with the router and PHP schema provider.
*/ */
/**
* User type options shared by the upload limit repeaters.
* Mirrors AKSRT\Attachment\UploadLimits::user_type_options() (levels 2-10, VIP 1-2).
*/
const uploadUserTypeOptions = [
{ label: '最小默认值(所有人)', value: 'default' },
{ label: '已登录用户', value: 'logged' },
...[2, 3, 4, 5, 6, 7, 8, 9, 10].map((level) => ({ label: `等级达到 ${level} 级`, value: `level_${level}` })),
{ label: 'VIP1 会员', value: 'vip_1' },
{ label: 'VIP2 会员', value: 'vip_2' },
{ label: '已认证用户', value: 'auth' },
{ label: '超级管理员', value: 'admin' },
]
const uploadMimeUserTypeOptions = [
{ label: '所有用户', value: 'all' },
...uploadUserTypeOptions,
]
const uploadSizeFields = [
{ key: 'type', label: '用户类型', type: 'select', default: 'logged', options: uploadUserTypeOptions },
{ key: 'val', label: '可上传大小', type: 'number', default: 30, min: 1, max: 2048, unit: 'M' },
]
export const permissionsSchemas = { export const permissionsSchemas = {
/** /**
* Login / captcha / access guard settings * Login / captcha / access guard settings
@@ -60,6 +84,9 @@ export const permissionsSchemas = {
{ label: '资源下载', value: 'download' }, { label: '资源下载', value: 'download' },
{ label: '每日签到', value: 'signin' }, { label: '每日签到', value: 'signin' },
{ label: '前台投稿', value: 'front_submit' }, { label: '前台投稿', value: 'front_submit' },
{ label: '打赏用户', value: 'tip_user' },
{ label: '申请售后', value: 'after_sale_apply' },
{ label: '撤销售后', value: 'after_sale_cancel' },
], ],
}, },
], ],
@@ -188,80 +215,103 @@ export const permissionsSchemas = {
}, },
/** /**
* Attachment permissions * Attachment / upload governance
*/ */
attachment: { attachment: {
key: 'permissions.attachment', key: 'permissions.attachment',
title: '附件权限', title: '附件配置',
icon: 'fa fas fa-paperclip', icon: 'fa fas fa-paperclip',
description: '控制文件上传和附件下载权限。', description: '统一管理上传大小、上传格式与上传行为;准入权限请在“基本权限”中配置。',
defaults: { defaults: {
avatar_max_size: 2, upload_img_size: [
submit_image_max_size: 5, { type: 'default', val: 5 },
comment_image_max_size: 2, { type: 'admin', val: 20 },
resource_max_size: 50, ],
image_upload_multiple: 6,
upload_video_size: [
{ type: 'default', val: 30 },
{ type: 'admin', val: 2048 },
],
upload_file_size: [
{ type: 'default', val: 30 },
{ type: 'admin', val: 2048 },
],
upload_file_mimes: [
{ type: 'admin', pattern: 'allow', val: 'svg=image/svg+xml' },
{ type: 'all', pattern: 'allow', val: '' },
],
}, },
groups: [ groups: [
{ {
key: 'profile', key: 'size-limits',
title: '用户资料卡背景图', title: '上传大小限制',
icon: 'fa fas fa-user-circle', icon: 'fa fas fa-weight-hanging',
description: '设置上传大小,请考虑服务器负荷、硬盘,以及服务器最大能支持的范围。实际上传大小取「主题限制」与「PHP 环境限制」的较小值,超过 PHP 环境限制时请调整 php.ini 的 upload_max_filesize 与 post_max_size。',
fields: [ fields: [
{ key: 'avatar_max_size', label: '头像/用户资料卡背景图大小上限 (MB)', type: 'number', min: 1, max: 10 }, {
key: 'upload_img_size',
label: '前端图片上传限制',
type: 'repeater',
min: 1,
addText: '添加细分用户权限',
help: '投稿、评论、头像与资料卡背景图等前端场景允许上传的最大图片大小(单位 MB)。同一个用户满足多个条件则取最大值,「最小默认值」为所有人兜底。',
fields: uploadSizeFields.map((field) => ({ ...field, default: field.key === 'val' ? 5 : field.default })),
},
{ key: 'image_upload_multiple', label: '图片批量上传最大数量', type: 'number', min: 0, max: 100, unit: '张', help: '前端投稿、评论上传图片允许的单次批量上传数量(为 0 则不限制,为 1 则不能批量上传)。' },
{
key: 'upload_video_size',
label: '前端视频上传限制',
type: 'repeater',
min: 1,
addText: '添加细分用户权限',
help: '前端允许上传的最大视频大小(单位 MB)。同一个用户满足多个条件则取最大值,必须包含最低默认值。',
fields: uploadSizeFields,
},
{
key: 'upload_file_size',
label: '前端文件上传大小限制',
type: 'repeater',
min: 1,
addText: '添加细分用户权限',
help: '前端允许上传的其他文件(不含视频和图片,建议大于视频图片)最大大小(单位 MB)。同一个用户满足多个条件则取最大值,必须包含最低默认值。',
fields: uploadSizeFields,
},
], ],
}, },
{ {
key: 'content', key: 'mime-limits',
title: '内容上传', title: '上传格式限制',
icon: 'fa fas fa-image', icon: 'fa fas fa-file-code',
fields: [ fields: [
{ key: 'submit_image_max_size', label: '投稿图片大小上限 (MB)', type: 'number', min: 1, max: 20 }, {
{ key: 'comment_image_max_size', label: '评论图片大小上限 (MB)', type: 'number', min: 1, max: 10 }, key: 'mime_limit_notice',
], label: '安全提示',
type: 'notice',
alertType: 'warning',
alertContent: '请添加文件的扩展名和 MIME 类型,用英文逗号分割。格式示例:mp3=audio/mpeg,jpg=image/jpeg。禁止的优先级大于允许。郑重提示:请勿允许上传 php、sh、exe 等可执行文件,会极大增加服务器被入侵攻击的风险。',
}, },
{ {
key: 'resource', key: 'upload_file_mimes',
title: '资源附件', label: '前端上传文件格式限制',
icon: 'fa fas fa-file-archive', type: 'repeater',
min: 1,
addText: '添加细分用户权限',
help: '根据不同的用户类型,添加额外允许或禁止上传的文件格式(禁止的优先级大于允许)。',
fields: [ fields: [
{ key: 'resource_max_size', label: '资源附件大小上限 (MB)', type: 'number', min: 1, max: 200 }, { key: 'type', label: '用户类型', type: 'select', default: 'all', options: uploadMimeUserTypeOptions },
],
},
],
},
/**
* Download link configuration
*/
download: {
key: 'permissions.download',
title: '下载配置',
icon: 'fa fas fa-download',
description: '下载总开关与链接别名、默认使用次数、有效期限规则。',
defaults: {
enabled: true,
url_alias: 'download',
default_max_uses: 1,
ttl_minutes: 10,
},
groups: [
{ {
key: 'basic', key: 'pattern',
title: '下载总开关', label: '规则',
icon: 'fa fas fa-power-off', type: 'select',
fields: [ default: 'allow',
{ key: 'enabled', label: '启用本站下载链接', type: 'switch', help: '总开关:开启后下载通过本站中转链接;关闭后下载按钮直接使用原始下载地址(直连),下方链接规则全部不生效,旧的中转链接同步失效。' }, options: [
{ label: '允许', value: 'allow' },
{ label: '禁止', value: 'prohibit' },
],
},
{ key: 'val', label: '格式', type: 'textarea', default: '', rows: 1, placeholder: '如:svg=image/svg+xml,mp3=audio/mpeg' },
], ],
}, },
{
key: 'rules',
title: '链接规则',
icon: 'fa fas fa-link',
description: '文件完整送达才计次(HEAD 探测、传输中断不计次);到期或耗尽后访问旧地址返回 410,需重新获取,不会自动续签。',
fields: [
{ key: 'url_alias', label: '下载链接 URL 别名', type: 'input', placeholder: 'download', required: true, dependsOn: { key: 'enabled', value: true }, help: '仅允许英文字母、数字、连字符和下划线;禁止中文、空格和 / \\ ? # & 等特殊符号。修改后旧别名路由立即失效。' },
{ key: 'default_max_uses', label: '下载链接默认使用次数', type: 'number', min: 1, max: 100, dependsOn: { key: 'enabled', value: true }, help: '单个链接可完成下载的次数;耗尽后链接返回 410,需重新获取。' },
{ key: 'ttl_minutes', label: '下载链接默认有效期限(分钟)', type: 'number', min: 1, max: 1440, dependsOn: { key: 'enabled', value: true }, help: '链接签发后超过该时长即失效并返回 410,不自动续期。' },
], ],
}, },
], ],
@@ -2,102 +2,80 @@ export const commentSchema = {
key: 'post_list.comment', key: 'post_list.comment',
title: '评论设置', title: '评论设置',
icon: 'fa fas fa-comment-dots', icon: 'fa fas fa-comment-dots',
description: '配置评论区的排序、分页、权限、工具栏与互动方式。', description: '配置评论模块的显隐条件、翻页模式、输入工具栏与互动方式。',
defaults: { defaults: {
comment_order: 'asc', close_comments: false,
comment_per_page: 20, comment_box_hide: '',
comment_pagination_type: 'numbered', comment_paginate_type: 'default',
comment_paging_ajax_ias_s: true, comment_paging_ajax_ias_s: true,
comment_paging_ajax_ias_max: 3, comment_paging_ajax_ias_max: 3,
comment_nested_depth: 3,
comment_avatar_size: 48,
comment_close_comments: false,
comment_edit_window: 10,
comment_code: true, comment_code: true,
comment_img: true, comment_img: true,
comment_upload_img: false, comment_upload_img: false,
comment_link: false, comment_link: false,
comment_like_s: true,
comment_hot_threshold: 10,
comment_city_s: true,
comment_city_type: 'province_city',
comment_user_url_s: false,
comment_author_tag: true, comment_author_tag: true,
user_edit_comment: true,
comment_like_s: true,
comment_user_url_s: false,
comment_corderby: true, comment_corderby: true,
comment_empty_text: '暂无评论,来做第一个评论的人吧~',
comment_title: '评论', comment_title: '评论',
comment_submit_text: '提交评论', comment_submit_text: '提交评论',
comment_text: '欢迎您留下宝贵的见解!', comment_text: '欢迎您留下宝贵的见解!',
}, },
groups: [ groups: [
{ {
key: 'display', key: 'comment',
title: '评论展示', title: '',
icon: 'fa fas fa-comment',
fields: [ fields: [
{ key: 'comment_order', label: '评论排序', type: 'radio', default: 'asc', options: [ {
{ label: '正序 (旧→新)', value: 'asc' }, key: 'comment_discussion_notice',
{ label: '倒序 (新→旧)', value: 'desc' }, label: '',
]}, type: 'content',
{ key: 'comment_per_page', label: '每页评论数', type: 'number', default: 20, min: 5, max: 100 }, ui_only: true,
{ key: 'comment_pagination_type', label: '分页方式', type: 'radio', default: 'numbered', options: [ dependsOn: { key: 'close_comments', value: false },
{ label: '翻页', value: 'numbered' }, content: '<p style="margin:0;font-size:12px;color:var(--aksrt-color-muted);line-height:1.6;"><i class="fa fas fa-fw fa-info-circle"></i> WordPress默认关闭评论翻页功能,如需启用请按此进行设置:<br/>1. 进入WP讨论设置,勾选 <code>分页显示评论</code><br/>2. 设置默认显示 <code>最前</code>一页<br/>3. 设置在每个页面顶部显示 <code>旧的</code>评论<br/>4. 根据需要设置每一页显示数量<br/>5. 根据需要设置评论嵌套(推荐开启并设置为3层)<br/>6. wp默认开启免登陆评论,如需关闭则勾选<code>需要登录后才能发表评论</code><br/><a href="options-discussion.php">【去设置】</a></p>',
{ label: '加载更多', value: 'load_more' }, },
{ label: '无限滚动', value: 'infinite' }, { key: 'close_comments', label: '关闭文章评论功能', type: 'switch', default: false, description: '部分网站无需交互,或需备案审核,可在此关闭所有文章的评论功能。同时每一篇文章可单独关闭评论功能' },
]}, {
{ key: 'comment_paging_ajax_ias_s', label: '评论自动加载', type: 'switch', default: true, help: '评论无限滚动时自动触发加载' }, key: 'comment_box_hide',
{ key: 'comment_paging_ajax_ias_max', label: '评论最大加载页数', type: 'number', default: 3, min: 0, max: 10, help: '0 = 不限' }, label: '隐藏评论模块',
{ key: 'comment_nested_depth', label: '回复嵌套深度', type: 'number', default: 3, min: 1, max: 10 }, type: 'radio',
{ key: 'comment_avatar_size', label: '头像大小 (px)', type: 'number', default: 48, min: 24, max: 96 }, default: '',
dependsOn: { key: 'close_comments', value: false },
options: [
{ label: '不隐藏', value: '' },
{ label: '登录后显示', value: 'signin' },
{ label: '所有会员显示', value: 'vip' },
{ label: '二级会员显示', value: 'vip_2' },
{ label: '认证用户显示', value: 'auth' },
], ],
description: '当用户满足条件时,文章评论功能及模块才会显示',
}, },
{ {
key: 'permissions', key: 'comment_paginate_type',
title: '评论设置', label: '评论列表翻页模式',
icon: 'fa fas fa-lock', type: 'radio',
fields: [ default: 'default',
{ key: 'comment_close_comments', label: '关闭评论', type: 'switch', default: false }, dependsOn: { key: 'close_comments', value: false },
{ key: 'comment_edit_window', label: '作者自助编辑时限 (分钟)', type: 'number', default: 10, min: 0, max: 1440 }, options: [
{ label: 'AJAX追加列表翻页', value: 'ajax_lists' },
{ label: '数字翻页按钮', value: 'default' },
], ],
}, },
{ { key: 'comment_paging_ajax_ias_s', label: 'AJAX翻页自动加载', type: 'switch', default: true, dependsOn: { key: 'comment_paginate_type', value: 'ajax_lists' }, description: '页面滚动到列表尽头时,自动加载下一页' },
key: 'toolbar', { key: 'comment_paging_ajax_ias_max', label: '自动加载页数', type: 'number', default: 3, min: 0, max: 10, unit: '页', dependsOn: { all: [{ key: 'comment_paginate_type', value: 'ajax_lists' }, { key: 'comment_paging_ajax_ias_s', value: true }] }, description: 'AJAX翻页自动加载最多加载几页(为0则不限制,直到加载全部评论)' },
title: '评论工具栏', { key: 'comment_code', label: '允许插入代码', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false }, help: '为了防止恶意评论,建议在后台-设置-讨论:开启“用户必须登录后才能发表评论”' },
icon: 'fa fas fa-wrench', { key: 'comment_img', label: '允许插入图片', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false } },
fields: [ { key: 'comment_upload_img', label: '允许上传图片', type: 'switch', default: false, dependsOn: { all: [{ key: 'close_comments', value: false }, { key: 'comment_img', value: true }] } },
{ key: 'comment_code', label: '代码插入', type: 'switch', default: true }, { key: 'comment_link', label: '允许插入链接', type: 'switch', default: false },
{ key: 'comment_img', label: '图片按钮', type: 'switch', default: true }, { key: 'comment_author_tag', label: '显示“作者”标签', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false } },
{ key: 'comment_link', label: '链接按钮', type: 'switch', default: false }, { key: 'user_edit_comment', label: '允许用户编辑评论', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false } },
{ key: 'comment_upload_img', label: '评论图片上传', type: 'switch', default: false }, { key: 'comment_like_s', label: '评论点赞功能', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false } },
], { key: 'comment_user_url_s', label: '匿名评论允许输入链接', type: 'switch', default: false, dependsOn: { key: 'close_comments', value: false }, description: '用户未登录评论时,允许输入用户博客地址' },
}, { key: 'comment_corderby', label: '评论排序功能', type: 'switch', default: true, dependsOn: { all: [{ key: 'close_comments', value: false }, { key: 'comment_like_s', value: true }] }, description: '显示“最新、最热”排序按钮' },
{ { key: 'comment_title', label: '自定义评论标题', type: 'textarea', default: '评论', rows: 1, dependsOn: { key: 'close_comments', value: false } },
key: 'interaction', { key: 'comment_submit_text', label: '自定义评论提交按钮文案', type: 'textarea', default: '提交评论', rows: 1, dependsOn: { key: 'close_comments', value: false } },
title: '互动功能', { key: 'comment_text', label: '自定义评论框占位符文案', type: 'input', default: '欢迎您留下宝贵的见解!', dependsOn: { key: 'close_comments', value: false } },
icon: 'fa fas fa-heart',
fields: [
{ key: 'comment_hot_threshold', label: '神评点赞阈值', type: 'number', default: 10, min: 1, max: 999, help: '点赞数达到该值后显示「神评」徽章', dependsOn: { key: 'comment_like_s', value: true } },
{ key: 'comment_like_s', label: '评论点赞', type: 'switch', default: true },
{ key: 'comment_user_url_s', label: '匿名者网址', type: 'switch', default: false, help: '用户未登录评论时,允许输入用户博客地址' },
{ key: 'comment_city_s', label: '评论城市', type: 'switch', default: true },
{ key: 'comment_city_type', label: '城市显示格式', type: 'radio', default: 'province_city', options: [
{ label: '省份+城市', value: 'province_city' },
{ label: '仅省份', value: 'province' },
{ label: '国家+省份', value: 'country_province' },
], dependsOn: { key: 'comment_city_s', value: true } },
{ key: 'comment_author_tag', label: '作者标签', type: 'switch', default: true },
{ key: 'comment_corderby', label: '评论排序切换', type: 'switch', default: true, help: '前台显示最新/最早/最热切换按钮' },
],
},
{
key: 'text',
title: '文案配置',
icon: 'fa fas fa-font',
fields: [
{ key: 'comment_empty_text', label: '空评论提示', type: 'input', default: '暂无评论,来做第一个评论的人吧~' },
{ key: 'comment_title', label: '评论区标题', type: 'input', default: '评论' },
{ key: 'comment_submit_text', label: '提交按钮文案', type: 'input', default: '提交评论' },
{ key: 'comment_text', label: '评论框提示', type: 'input', default: '欢迎您留下宝贵的见解!' },
], ],
}, },
], ],
@@ -2,19 +2,121 @@ export const featureSchema = {
key: 'post_list.feature', key: 'post_list.feature',
title: '文章功能', title: '文章功能',
icon: 'fa fas fa-magic', icon: 'fa fas fa-magic',
description: '配置文章页的互动功能开关。', description: '配置文章目录树、图片灯箱、点赞与新建文章的预置参数。',
defaults: { defaults: {
feature_pdf_export: false, article_nav: true,
feature_print: true, article_nav_mobile_nav_s: true,
imagelightbox: true,
imagelightbox_type: 'group',
imagelightbox_thumbs_s: ['pc_s', 'm_s'],
imagelightbox_play_s: ['pc_s', 'm_s'],
imagelightbox_zoom_s: ['pc_s'],
imagelightbox_full_s: ['pc_s'],
imagelightbox_down_s: ['pc_s'],
post_like_s: true,
post_default_mate: { views: { min: 20, max: 50 }, like: { min: 5, max: 15 } },
}, },
groups: [ groups: [
{ {
key: 'tools', key: 'feature',
title: '工具功能', title: '',
icon: 'fa fas fa-wrench',
fields: [ fields: [
{ key: 'feature_print', label: '打印文章', type: 'switch', default: true }, { key: 'article_nav', label: '文章目录树', type: 'switch', default: true, description: '默认开关,每篇文章可单独设置。开启后请自行添加文章目录树模块到侧边栏(文章内标题超过3个才会显示)' },
{ key: 'feature_pdf_export', label: 'PDF 导出', type: 'switch', default: false, help: '实验性功能' }, {
key: 'article_nav_tutorial',
label: '',
type: 'content',
ui_only: true,
dependsOn: { key: 'article_nav', value: true },
content: '<p style="margin:0 0 12px;font-size:12px;"><a href="https://www.zibll.com/1717.html" target="_blank">【查看官网教程】</a></p>',
},
{ key: 'article_nav_mobile_nav_s', label: '在移动端弹出菜单内显示', type: 'switch', default: true, dependsOn: { key: 'article_nav', value: true }, description: '如需显示在侧边栏等其它位置,请使用小工具模块添加' },
{ key: 'imagelightbox', label: '图片灯箱', type: 'switch', default: true, description: '点击图片查看原图功能,共两种模式' },
{
key: 'imagelightbox_tutorial',
label: '',
type: 'content',
ui_only: true,
dependsOn: { key: 'imagelightbox', value: true },
content: '<p style="margin:0 0 12px;font-size:12px;"><a href="https://www.zibll.com/683.html" target="_blank">【查看官网教程】</a></p>',
},
{
key: 'imagelightbox_type',
label: '图片灯箱模式',
type: 'radio',
default: 'group',
dependsOn: { key: 'imagelightbox', value: true },
options: [
{ label: '组合模式(将文章的全部图片组合成一组灯箱)', value: 'group' },
{ label: '单张模式(点击某张图片仅显示该图片)', value: 'alone' },
],
},
{
key: 'imagelightbox_thumbs_s',
label: '缩略图导航',
type: 'checkbox',
default: ['pc_s', 'm_s'],
dependsOn: { all: [{ key: 'imagelightbox', value: true }, { key: 'imagelightbox_type', value: 'group' }] },
options: [
{ label: 'PC端开启', value: 'pc_s' },
{ label: '移动端开启', value: 'm_s' },
],
},
{
key: 'imagelightbox_play_s',
label: '播放按钮',
type: 'checkbox',
default: ['pc_s', 'm_s'],
dependsOn: { all: [{ key: 'imagelightbox', value: true }, { key: 'imagelightbox_type', value: 'group' }] },
options: [
{ label: 'PC端开启', value: 'pc_s' },
{ label: '移动端开启', value: 'm_s' },
],
},
{
key: 'imagelightbox_zoom_s',
label: '缩放按钮',
type: 'checkbox',
default: ['pc_s'],
dependsOn: { key: 'imagelightbox', value: true },
options: [
{ label: 'PC端开启', value: 'pc_s' },
{ label: '移动端开启', value: 'm_s' },
],
},
{
key: 'imagelightbox_full_s',
label: '全屏按钮',
type: 'checkbox',
default: ['pc_s'],
dependsOn: { key: 'imagelightbox', value: true },
options: [
{ label: 'PC端开启', value: 'pc_s' },
{ label: '移动端开启', value: 'm_s' },
],
},
{
key: 'imagelightbox_down_s',
label: '下载按钮',
type: 'checkbox',
default: ['pc_s'],
dependsOn: { key: 'imagelightbox', value: true },
options: [
{ label: 'PC端开启', value: 'pc_s' },
{ label: '移动端开启', value: 'm_s' },
],
},
{ key: 'post_like_s', label: '文章点赞', type: 'switch', default: true },
{
key: 'post_default_mate',
label: '文章预置参数',
type: 'fieldset',
default: { views: { min: 20, max: 50 }, like: { min: 5, max: 15 } },
fields: [
{ key: 'views', label: '阅读量', type: 'between_number', default: { min: 20, max: 50 } },
{ key: 'like', label: '点赞数', type: 'between_number', default: { min: 5, max: 15 }, description: '新建文章时系统会根据此处设置的区间获取随机值填入对应项' },
],
},
], ],
}, },
], ],
@@ -1,101 +1,128 @@
export const filterSchema = { export const filterSchema = {
key: 'post_list.filter', key: 'post_list.filter',
title: '高级筛选', title: '高级筛选',
icon: 'fa fas fa-filter', icon: 'fa fas fa-align-left',
description: '为文章列表配置筛选栏、排序方式和分类筛选功能。', description: '通过自定义筛选类型实现更精细化的内容筛选,可按分类绑定不同的筛选项目。',
defaults: { defaults: {
filter_enabled: false, custom_filter_show: [],
filter_categories: true,
filter_tags: false,
filter_sort: true,
filter_ajax_enabled: true,
filter_cat_show_cat: true,
filter_cat_show_tag: false,
filter_tag_show_cat: true,
filter_tag_show_tag: false,
custom_filter_show: ['cat', 'tag'],
custom_filter: [], custom_filter: [],
cat_custom_filter: [], cat_custom_filter: [],
}, },
groups: [ groups: [
{ {
key: 'basic', key: 'advanced_filter',
title: '筛选基础', title: '高级筛选',
icon: 'fa fas fa-filter', icon: 'fa fas fa-align-left',
fields: [ fields: [
{ key: 'filter_enabled', label: '启用筛选栏', type: 'switch', default: false }, {
{ key: 'custom_filter_show', label: '筛选显示页面', type: 'checkbox', default: ['cat', 'tag'], options: [{ label: '分类页', value: 'cat' }, { label: '标签页', value: 'tag' }], help: '选择在哪些页面显示筛选栏', dependsOn: { key: 'filter_enabled', value: true } }, key: 'filter_intro',
{ key: 'filter_categories', label: '分类筛选', type: 'switch', default: true, dependsOn: { key: 'filter_enabled', value: true } }, type: 'content',
{ key: 'filter_tags', label: '标签筛选', type: 'switch', default: false, dependsOn: { key: 'filter_enabled', value: true } }, ui_only: true,
{ key: 'filter_sort', label: '排序选项', type: 'switch', default: true, dependsOn: { key: 'filter_enabled', value: true } }, content: '<p style="margin:0;font-size:12px;color:var(--aksrt-color-muted);line-height:1.7;"><b>文章高级筛选功能:</b>通过此功能可实现更加复杂、精细化的内容筛选<br/>添加筛选类型和内容值之后,编写文章时候,可以进行选择,且在前台也可以实现精准筛选<br/>例如影视网站:[类型]有[剧情/喜剧/悬疑/惊悚/犯罪],[地区]有[大陆/美国/日韩/港台/印度],[年份]有[2022/2021/2020/10年代/00年代]等<br/><span style="color:#d48806;">注意事项:添加类型key时候,只能使用英文加下划线,不能有空格,且尽量复杂一点(不能为tag,category等系统已存在的类型),避免与其他mate_key重复!类型名称不能为分类、标签等系统已存在的类型</span><br/><a href="https://www.zibll.com/15886.html" target="_blank">查看官网教程</a></p>',
{ key: 'filter_ajax_enabled', label: 'AJAX 无刷新筛选', type: 'switch', default: true, help: '启用后分类/标签/排序切换将通过 AJAX 加载,无需刷新页面', dependsOn: { key: 'filter_enabled', value: true } },
],
}, },
{ {
key: 'ajax', key: 'custom_filter_show',
title: 'AJAX 筛选维度', label: '高级筛选默认显示',
icon: 'fa fas fa-clone', type: 'checkbox',
fields: [ default: [],
{ key: 'filter_cat_show_cat', label: '分类页:显示分类筛选', type: 'switch', default: true }, options: [
{ key: 'filter_cat_show_tag', label: '分类页:显示标签筛选', type: 'switch', default: false }, { label: '分类页面显示', value: 'cat' },
{ key: 'filter_tag_show_cat', label: '标签页:显示分类筛选', type: 'switch', default: true }, { label: '标签页面显示', value: 'tag' },
{ key: 'filter_tag_show_tag', label: '标签页:显示标签筛选', type: 'switch', default: false },
], ],
description: '勾选后将会在对应页面默认显示全部筛选,您可以在下方为单独的分类添加自定义筛选',
}, },
{ {
key: 'custom_filter', key: 'custom_filter',
title: '自定义筛选维度', label: '自定义高级筛选明细',
icon: 'fa fas fa-sliders-h',
fields: [
{
key: 'custom_filter',
label: '筛选维度定义',
type: 'repeater', type: 'repeater',
default: [], default: [],
addText: '添加筛选维度', addText: '添加筛选类别',
help: '每项定义一个筛选维度(如"类型""地区""年份"),及其可选值列表', description: '添加筛选类型及明细',
fields: [ fields: [
{ key: 'name', label: '维度名称', type: 'input', default: '', placeholder: '如:类型' }, { key: 'name', label: '类型名称', type: 'input', default: '' },
{ key: 'key', label: '维度标识', type: 'input', default: '', placeholder: '如:type(英文下划线)' }, { key: 'key', label: '类型key', type: 'input', default: '', description: '必填,只能使用英文加下划线,不能有空格!尽量复杂一点,且配置唯一前缀,以避免与其他mate_key重复,例如(axb_type,axb_year)' },
{ {
key: 'vals', key: 'vals',
label: '可选值', label: '可选择的值',
type: 'repeater', type: 'repeater',
default: [], default: [],
addText: '添加可选值', addText: '添加值',
fields: [ fields: [
{ key: 'key', label: '标识', type: 'input', default: '', placeholder: '如:action' }, { key: 'key', label: '值', type: 'input', default: '' },
{ key: 'name', label: '显示名', type: 'input', default: '', placeholder: '如:动作' }, { key: 'name', label: '显示名称', type: 'input', default: '' },
],
},
], ],
}, },
], ],
}, },
{ {
key: 'cat_custom_filter', key: 'cat_custom_filter',
title: '按分类绑定筛选', label: '自定义分类的高级筛选',
icon: 'fa fas fa-object-group',
fields: [
{
key: 'cat_custom_filter',
label: '分类筛选绑定',
type: 'repeater', type: 'repeater',
default: [], default: [],
addText: '添加绑定规则', addText: '添加自定义分类筛选',
help: '为指定分类绑定不同的筛选维度组合,实现分类级别的差异化筛选', description: '在此处为不同的分类添加不同的筛选项目,实现更加精细化的筛选。同时你也可以在页面显示/分类页面设置分类的默认筛选方式',
fields: [ fields: [
{ key: 'cats', label: '绑定分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [] }, { key: 'cats', label: '绑定分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [], description: '支持添加多个分类' },
{ key: 'cat_s', label: '显示分类筛选', type: 'switch', default: false }, { key: 'cat_s', label: '分类筛选', type: 'switch', default: false, description: '在所选分类页显示的分类筛选列表' },
{ key: 'tag_s', label: '显示标签筛选', type: 'switch', default: false }, {
{ key: 'orderby_s', label: '显示排序按钮', type: 'switch', default: true }, key: 'cat_lists',
label: ' ',
type: 'fieldset',
default: { lists: [], dropdown: false },
dependsOn: { key: 'cat_s', value: true },
fields: [
{ key: 'lists', label: '按钮列表', type: 'async_select', source: 'categories', multiple: true, sortable: true, default: [], description: '选择并排序需要显示的按钮,建议选择同级内容,会自动获取二级三级内容。所选按钮内没有文章则不会显示' },
{ key: 'dropdown', label: '下拉列表', type: 'switch', default: false, description: '用下拉框显示更多内容' },
],
},
{ key: 'tag_s', label: '标签筛选', type: 'switch', default: false, description: '在所选分类页显示的标签筛选列表' },
{
key: 'tag_lists',
label: ' ',
type: 'fieldset',
default: { lists: [], dropdown: false },
dependsOn: { key: 'tag_s', value: true },
fields: [
{ key: 'lists', label: '按钮列表', type: 'async_select', source: 'tags', multiple: true, sortable: true, default: [], description: '选择并排序需要显示的按钮' },
{ key: 'dropdown', label: '下拉列表', type: 'switch', default: false, description: '用下拉框显示更多内容' },
],
},
{ key: 'orderby_s', label: '排序方式', type: 'switch', default: true, description: '在所选分类页显示排序方式按钮' },
{
key: 'orderby_lists',
label: ' ',
type: 'fieldset',
default: { lists: ['modified', 'views', 'like', 'comment_count'], dropdown: false },
dependsOn: { key: 'orderby_s', value: true },
fields: [
{
key: 'lists',
label: '显示排序方式',
type: 'select',
multiple: true,
sortable: true,
default: ['modified', 'views', 'like', 'comment_count'],
options: [
{ label: '更新', value: 'modified' },
{ label: '发布', value: 'date' },
{ label: '浏览', value: 'views' },
{ label: '点赞', value: 'like' },
{ label: '评论', value: 'comment_count' },
{ label: '收藏', value: 'favorite' },
{ label: '随机', value: 'rand' },
],
description: '选择需要的排序方式按钮',
},
{ key: 'dropdown', label: '更多排序方式', type: 'switch', default: false, description: '用下拉框显示全部排序方式' },
],
},
{ {
key: 'custom_filters', key: 'custom_filters',
label: '启用的自定义维度', label: '自定义高级筛选',
type: 'input', type: 'checkbox',
default: '', default: [],
placeholder: '逗号分隔维度标识,如:type,region,year', options: 'custom_filter',
help: '留空表示使用全部已定义的筛选维度', description: '在所选分类页显示的自定义高级筛选项目。如果此处没有选项,或者您修改了上方的文章高级筛选明细,请先保存主题配置并刷新页面后再设置此处',
}, },
], ],
}, },
@@ -8,7 +8,6 @@ import { pageSchema } from './page.js'
import { filterSchema } from './filter.js' import { filterSchema } from './filter.js'
import { featureSchema } from './feature.js' import { featureSchema } from './feature.js'
import { paySchema } from './pay.js' import { paySchema } from './pay.js'
import { shareSchema } from './share.js'
import { commentSchema } from './comment.js' import { commentSchema } from './comment.js'
import { submitSchema } from './submit.js' import { submitSchema } from './submit.js'
@@ -19,7 +18,6 @@ export const postListSchemas = {
filter: filterSchema, filter: filterSchema,
feature: featureSchema, feature: featureSchema,
pay: paySchema, pay: paySchema,
share: shareSchema,
comment: commentSchema, comment: commentSchema,
submit: submitSchema, submit: submitSchema,
} }
+160 -148
View File
@@ -1,121 +1,183 @@
export const pageSchema = { export const pageSchema = {
key: 'post_list.page', key: 'post_list.page',
title: '文章页面', title: '文章页面',
icon: 'fa fas fa-file-alt', icon: 'fa fas fa-bookmark',
description: '配置单篇文章页面的布局、图片灯箱、版权区和互动功能。', description: '配置文章页面封面、面包屑导航、版权声明、插入内容与相关文章版块。',
defaults: { defaults: {
post_reading_progress: true, article_image_cover: true,
article_image_cover_option: { scale: 35 },
article_slide_cover: true,
article_slide_cover_option: {
direction: 'horizontal',
loop: true,
button: false,
pagination: true,
effect: 'slide',
scale_height: true,
scale: 35,
auto_height: false,
max_height: 500,
min_height: 180,
pc_height: 380,
m_height: 180,
spacebetween: 15,
speed: 0,
autoplay: true,
interval: 4,
},
article_video_cover: true,
article_video_cover_option: { scale: 0 },
post_single_hide_time_s: false,
post_p_indent_s: false,
breadcrumbs_single_s: true, breadcrumbs_single_s: true,
breadcrumbs_home_text: '首页', breadcrumbs_home_text: true,
post_prevnext_s: true, post_prevnext_s: true,
post_authordesc_s: true, post_authordesc_s: true,
post_single_hide_time_s: false, article_maxheight_kg: false,
post_lightbox: true, article_maxheight: 1000,
post_lightbox_thumbs: true, yiyan_single_content_header: false,
post_lightbox_zoom: true, yiyan_single_content_footer: false,
post_lightbox_full: true, yiyan_single_box: false,
post_lightbox_down: false, post_copyright_s: true,
post_lightbox_play: false, post_copyright: '文章版权归作者所有,未经允许请勿转载。',
post_button_toptext: '喜欢就支持一下吧',
post_front_content: '',
post_after_content: '',
post_related_s: true, post_related_s: true,
post_related_type: 'img', post_related_type: 'img',
related_title: '相关推荐', related_title: '相关推荐',
post_related_n: 6, post_related_n: 6,
post_related_orderby: 'views', post_related_orderby: 'views',
breadcrumbs_single_text: true,
post_copyright_enabled: true,
post_copyright_text: '',
share_s: true,
post_like_enabled: true,
post_favorite_enabled: true,
post_front_content: '',
post_after_content: '',
post_yiyan_header: false,
post_yiyan_footer: false,
post_yiyan_box: false,
post_default_views_min: 20,
post_default_views_max: 50,
post_default_likes_min: 5,
post_default_likes_max: 15,
post_article_image_cover: true,
post_article_image_cover_scale: 35,
post_article_slide_cover: true,
post_article_video_cover: true,
post_article_video_cover_scale: 56,
item_heading_bold: false,
post_button_toptext: '喜欢就支持一下吧',
post_rewards_s: false,
post_rewards_text: '赞赏',
post_author_card_limit: 6,
post_author_card_orderby: 'views',
post_author_card_style: 'card',
article_maxheight_kg: false,
article_maxheight: 1000,
}, },
groups: [ groups: [
{ {
key: 'navigation', key: 'post_page',
title: '文章导航', title: '文章页面',
icon: 'fa fas fa-compass', icon: 'fa fas fa-bookmark',
fields: [ fields: [
{ key: 'post_reading_progress', label: '阅读进度条', type: 'switch', default: true }, { key: 'article_image_cover', label: '图片封面', type: 'switch', default: true, description: '允许设置文章页顶部显示封面图' },
{
key: 'article_image_cover_option',
label: ' ',
type: 'fieldset',
default: { scale: 35 },
dependsOn: { key: 'article_image_cover', value: true },
description: '图片封面配置',
fields: [
{ key: 'scale', label: '图片长宽比例', type: 'number', default: 35, min: 20, max: 200, step: 5, unit: '%' },
],
},
{ key: 'article_slide_cover', label: '幻灯片封面', type: 'switch', default: true, description: '允许设置幻灯片为文章封面' },
{
key: 'article_slide_cover_option',
label: ' ',
type: 'fieldset',
default: {
direction: 'horizontal',
loop: true,
button: false,
pagination: true,
effect: 'slide',
scale_height: true,
scale: 35,
auto_height: false,
max_height: 500,
min_height: 180,
pc_height: 380,
m_height: 180,
spacebetween: 15,
speed: 0,
autoplay: true,
interval: 4,
},
dependsOn: { key: 'article_slide_cover', value: true },
fields: [
{ key: 'direction', label: '幻灯片方向', type: 'radio', default: 'horizontal', options: [{ label: '左右切换', value: 'horizontal' }, { label: '上下切换', value: 'vertical' }] },
{ key: 'loop', label: '循环切换', type: 'switch', default: true },
{ key: 'button', label: '显示翻页按钮', type: 'switch', default: false },
{ key: 'pagination', label: '显示指示器', type: 'switch', default: true },
{
key: 'effect',
label: '切换动画',
type: 'select',
default: 'slide',
options: [
{ label: '滑动', value: 'slide' },
{ label: '淡出淡入', value: 'fade' },
{ label: '3D方块', value: 'cube' },
{ label: '3D滑入', value: 'coverflow' },
{ label: '3D翻转', value: 'flip' },
],
},
{ key: 'scale_height', label: '按比例自动高度', type: 'switch', default: true, dependsOn: { key: 'direction', not: 'vertical' } },
{ key: 'scale', label: '长宽比例', type: 'number', default: 35, min: 10, max: 300, step: 5, unit: '%', dependsOn: { all: [{ key: 'direction', not: 'vertical' }, { key: 'scale_height', value: true }] } },
{ key: 'auto_height', label: '自动高度', type: 'switch', default: false, dependsOn: { all: [{ key: 'direction', not: 'vertical' }, { key: 'scale_height', value: false }] } },
{ key: 'max_height', label: '最大高度', type: 'number', default: 500, min: 120, max: 800, step: 20, unit: 'PX', dependsOn: { all: [{ key: 'auto_height', value: true }, { key: 'direction', not: 'vertical' }, { key: 'scale_height', value: false }] } },
{ key: 'min_height', label: '最小高度', type: 'number', default: 180, min: 100, max: 500, step: 20, unit: 'PX', dependsOn: { all: [{ key: 'auto_height', value: true }, { key: 'direction', not: 'vertical' }, { key: 'scale_height', value: false }] } },
{ key: 'pc_height', label: '电脑端高度', type: 'number', default: 380, min: 120, max: 800, step: 20, unit: 'PX' },
{ key: 'm_height', label: '移动端高度', type: 'number', default: 180, min: 100, max: 500, step: 20, unit: 'PX' },
{ key: 'spacebetween', label: '幻灯片间距', type: 'number', default: 15, min: 0, max: 500, step: 5, unit: 'PX' },
{ key: 'speed', label: '切换速度', type: 'number', default: 0, min: 0, max: 3000, step: 100, unit: '毫秒', description: '切换过程的时间(越小越快),设置为0则为自动模式' },
{ key: 'autoplay', label: '自动播放', type: 'switch', default: true },
{ key: 'interval', label: '停顿时间', type: 'number', default: 4, min: 0, max: 20, step: 1, unit: '秒', dependsOn: { key: 'autoplay', value: true }, description: '自动切换的时间间隔(越小越快)' },
],
},
{ key: 'article_video_cover', label: '视频封面', type: 'switch', default: true, description: '允许设置视频为文章封面' },
{
key: 'article_video_cover_option',
label: ' ',
type: 'fieldset',
default: { scale: 0 },
dependsOn: { key: 'article_video_cover', value: true },
description: '视频封面配置 <a href="https://www.zibll.com/1900.html" target="_blank">【查看官方封面配置教程】</a>',
fields: [
{ key: 'scale', label: '固定长宽比例', type: 'number', default: 0, min: 0, max: 200, step: 5, unit: '%', description: '为0则不固定长宽比例' },
],
},
{ key: 'post_single_hide_time_s', label: '隐藏时间', type: 'switch', default: false, description: '隐藏文章的更新及发布时间显示' },
{ key: 'post_p_indent_s', label: '内容段落缩进', type: 'switch', default: false, description: '开启后文章内容每一个段落首行将向右偏移2个文字距离' },
{ key: 'breadcrumbs_single_s', label: '面包屑导航', type: 'switch', default: true }, { key: 'breadcrumbs_single_s', label: '面包屑导航', type: 'switch', default: true },
{ key: 'breadcrumbs_home_text', label: '面包屑首页文字', type: 'input', default: '首页', dependsOn: { key: 'breadcrumbs_single_s', value: true } }, { key: 'breadcrumbs_home_text', label: '面包屑导航用“首页”替代网站名称', type: 'switch', default: true, dependsOn: { key: 'breadcrumbs_single_s', value: true } },
{ key: 'breadcrumbs_single_text', label: '末项显示「正文」', type: 'switch', default: true, dependsOn: { key: 'breadcrumbs_single_s', value: true }, help: '关闭后末项显示文章标题' }, { key: 'post_prevnext_s', label: '上一页、下一页版块', type: 'switch', default: true },
{ key: 'post_prevnext_s', label: '上一篇/下一篇', type: 'switch', default: true }, { key: 'post_authordesc_s', label: '作者信息版块', type: 'switch', default: true },
{ key: 'post_authordesc_s', label: '作者简介', type: 'switch', default: true }, { key: 'article_maxheight_kg', label: '内容高度限制', type: 'switch', default: false, description: '此处为全局设置,每篇文章可单独设置', help: '全局开关' },
{ key: 'post_author_card_limit', label: '作者卡文章数', type: 'number', default: 6, min: 0, max: 12, dependsOn: { key: 'post_authordesc_s', value: true } }, { key: 'article_maxheight', label: '限制的最大高度', type: 'slider', default: 1000, min: 600, max: 3000, step: 100, unit: 'px', dependsOn: { key: 'article_maxheight_kg', value: true }, description: '开启后如果文章高度超过设定值则会显示展开阅读全文的按钮。每篇文章可单独开启此功能' },
{ key: 'post_author_card_orderby', label: '作者卡文章排序', type: 'select', default: 'views', options: [ {
{ label: '浏览量', value: 'views' }, key: 'yiyan_intro',
{ label: '发布时间', value: 'date' }, type: 'content',
{ label: '更新时间', value: 'modified' }, ui_only: true,
{ label: '评论数', value: 'comment_count' }, content: '<p style="margin:0;font-size:12px;color:var(--aksrt-color-muted);line-height:1.7;"><b>精彩一言功能:</b>随机显示中英文的文案,在此支持一言内容插入到文章页位置,同时可以使用小工具模块调用。<br/>主题默认调用的文件为:<code>themes/aksrt/yiyan/qv-yiyan.txt</code><br/>您可以将以上文件复制到 <code>wp-content</code> 文件夹,并重命名为 <code>yiyan.txt</code>,再进行修改或添加内容,则今后更新主题就无需二次修改</p>',
], dependsOn: { key: 'post_authordesc_s', value: true } }, },
{ key: 'post_author_card_style', label: '作者卡文章样式', type: 'radio', default: 'card', options: [ { key: 'yiyan_single_content_header', label: '文章内容头部显示一言', type: 'switch', default: false },
{ label: '卡片', value: 'card' }, { key: 'yiyan_single_content_footer', label: '文章内容尾部显示一言', type: 'switch', default: false },
{ label: '列表', value: 'list' }, { key: 'yiyan_single_box', label: '文章页面下方独立一言版块', type: 'switch', default: false },
], dependsOn: { key: 'post_authordesc_s', value: true } }, { key: 'post_copyright_s', label: '版权声明', type: 'switch', default: true },
{ key: 'post_single_hide_time_s', label: '隐藏文章时间', type: 'switch', default: false }, { key: 'post_copyright', label: '版权提示内容', type: 'textarea', default: '文章版权归作者所有,未经允许请勿转载。', rows: 2, dependsOn: { key: 'post_copyright_s', value: true }, description: '支持HTML代码,请注意代码规范及标签闭合' },
{ key: 'post_button_toptext', label: '文章页脚文案', type: 'input', default: '喜欢就支持一下吧', description: '文章底部打赏、分享按钮上面的文字' },
{ key: 'post_front_content', label: '在文章内容前-插入内容', type: 'textarea', default: '', rows: 3 },
{ key: 'post_after_content', label: '在文章内容后-插入内容', type: 'textarea', default: '', rows: 3, description: '在每篇文章顶部和尾部插入内容,可以插入广告或者文章说明等内容。支持HTML代码,请注意代码规范及标签闭合' },
{ key: 'post_related_s', label: '相关文章版块', type: 'switch', default: true },
{
key: 'post_related_type',
label: '显示样式',
type: 'image_select',
default: 'img',
dependsOn: { key: 'post_related_s', value: true },
options: [
{ label: '图文卡片', value: 'img', icon: 'fa fas fa-image' },
{ label: '列表', value: 'list', icon: 'fa fas fa-list' },
{ label: '纯文字', value: 'text', icon: 'fa fas fa-align-left' },
], ],
}, },
{ key: 'related_title', label: '版块标题', type: 'input', default: '相关推荐', dependsOn: { key: 'post_related_s', value: true } },
{ key: 'post_related_n', label: '显示数量', type: 'number', default: 6, min: 4, max: 12, step: 2, unit: '篇', dependsOn: { key: 'post_related_s', value: true } },
{ {
key: 'cover', key: 'post_related_orderby',
title: '封面展示', label: '排序方式',
icon: 'fa fas fa-image', type: 'select',
fields: [ default: 'views',
{ key: 'post_article_image_cover', label: '图片封面', type: 'switch', default: true, help: '文章有特色图时自动展示封面' }, dependsOn: { key: 'post_related_s', value: true },
{ key: 'post_article_image_cover_scale', label: '封面高度比例(%)', type: 'number', default: 35, min: 10, max: 100, dependsOn: { key: 'post_article_image_cover', value: true } }, options: [
{ key: 'post_article_slide_cover', label: '幻灯片封面', type: 'switch', default: true, help: '文章有图集时以轮播形式展示封面' },
{ key: 'post_article_video_cover', label: '视频封面', type: 'switch', default: true, help: '文章有视频时以视频播放器作为封面' },
{ key: 'post_article_video_cover_scale', label: '视频封面高度比例(%)', type: 'number', default: 56, min: 20, max: 100, dependsOn: { key: 'post_article_video_cover', value: true } },
],
},
{
key: 'media',
title: '媒体功能',
icon: 'fa fas fa-image',
fields: [
{ key: 'post_lightbox', label: '图片灯箱', type: 'switch', default: true, help: '点击图片放大查看' },
{ key: 'post_lightbox_thumbs', label: '灯箱缩略图', type: 'switch', default: true, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱底部显示缩略图导航' },
{ key: 'post_lightbox_zoom', label: '灯箱缩放', type: 'switch', default: true, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示缩放按钮' },
{ key: 'post_lightbox_full', label: '灯箱全屏', type: 'switch', default: true, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示全屏按钮' },
{ key: 'post_lightbox_down', label: '灯箱下载', type: 'switch', default: false, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示下载按钮' },
{ key: 'post_lightbox_play', label: '灯箱播放', type: 'switch', default: false, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示播放按钮(自动轮播)' },
],
},
{
key: 'related',
title: '相关推荐',
icon: 'fa fas fa-random',
fields: [
{ key: 'post_related_s', label: '显示相关推荐', type: 'switch', default: true },
{ key: 'post_related_type', label: '展示样式', type: 'radio', default: 'img', options: [
{ label: '图文卡片', value: 'img' },
{ label: '列表', value: 'list' },
{ label: '纯文字', value: 'text' },
], dependsOn: { key: 'post_related_s', value: true }, help: '对齐 legacy 相关文章三种样式' },
{ key: 'related_title', label: '推荐标题', type: 'input', default: '相关推荐', dependsOn: { key: 'post_related_s', value: true } },
{ key: 'post_related_n', label: '推荐数量', type: 'number', default: 6, min: 4, max: 12, step: 2, dependsOn: { key: 'post_related_s', value: true } },
{ key: 'post_related_orderby', label: '推荐排序', type: 'select', default: 'views', options: [
{ label: '更新时间', value: 'modified' }, { label: '更新时间', value: 'modified' },
{ label: '发布时间', value: 'date' }, { label: '发布时间', value: 'date' },
{ label: '浏览量', value: 'views' }, { label: '浏览量', value: 'views' },
@@ -123,58 +185,8 @@ export const pageSchema = {
{ label: '评论量', value: 'comment_count' }, { label: '评论量', value: 'comment_count' },
{ label: '收藏数量', value: 'favorite' }, { label: '收藏数量', value: 'favorite' },
{ label: '随机', value: 'rand' }, { label: '随机', value: 'rand' },
], dependsOn: { key: 'post_related_s', value: true } },
], ],
}, },
{
key: 'interactions',
title: '互动功能',
icon: 'fa fas fa-heart',
fields: [
{ key: 'post_like_enabled', label: '点赞', type: 'switch', default: true },
{ key: 'post_favorite_enabled', label: '收藏', type: 'switch', default: true },
{ key: 'share_s', label: '分享按钮', type: 'switch', default: true },
{ key: 'post_copyright_enabled', label: '版权信息', type: 'switch', default: true },
{ key: 'post_copyright_text', label: '版权默认文案', type: 'textarea', default: '', help: '留空则自动生成"本文由 XX 原创发布"' },
{ key: 'post_button_toptext', label: '按钮上方文案', type: 'input', default: '喜欢就支持一下吧', help: '显示在点赞/打赏/分享按钮上方' },
{ key: 'post_rewards_s', label: '用户打赏', type: 'switch', default: false, help: '开启后,登录用户可使用站内余额赞赏作者,金额直接进入作者余额。' },
{ key: 'post_rewards_text', label: '打赏按钮文案', type: 'input', default: '赞赏', dependsOn: { key: 'post_rewards_s', value: true } },
{ key: 'post_rewards_amounts', label: '快捷金额', type: 'input', default: '5,10,20,50,100', help: '使用英文逗号分隔', dependsOn: { key: 'post_rewards_s', value: true } },
{ key: 'post_rewards_custom', label: '允许自定义金额', type: 'switch', default: true, dependsOn: { key: 'post_rewards_s', value: true } },
{ key: 'post_rewards_min', label: '最低金额', type: 'input', default: '1.00', min: 0.01, max: 1000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
{ key: 'post_rewards_max', label: '最高金额', type: 'input', default: '1000.00', min: 1, max: 100000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
{ key: 'article_maxheight_kg', label: '正文限高', type: 'switch', default: false, help: '开启后长文默认折叠,可展开全文' },
{ key: 'article_maxheight', label: '限高像素', type: 'number', default: 1000, min: 200, max: 5000, dependsOn: { key: 'article_maxheight_kg', value: true } },
],
},
{
key: 'content',
title: '内容插入',
icon: 'fa fas fa-pen-square',
fields: [
{ key: 'post_front_content', label: '文章头部内容', type: 'textarea', default: '', help: '显示在所有文章正文前方(支持 HTML)' },
{ key: 'post_after_content', label: '文章底部内容', type: 'textarea', default: '', help: '显示在所有文章正文后方(支持 HTML)' },
],
},
{
key: 'yiyan',
title: '一言',
icon: 'fa fas fa-quote-left',
fields: [
{ key: 'post_yiyan_header', label: '文章头部一言', type: 'switch', default: false, help: '在文章标题下方显示一言' },
{ key: 'post_yiyan_footer', label: '文章底部一言', type: 'switch', default: false, help: '在文章内容底部显示一言' },
{ key: 'post_yiyan_box', label: '一言盒子', type: 'switch', default: false, help: '在相关文章前显示一言盒子' },
],
},
{
key: 'meta',
title: '默认数值',
icon: 'fa fas fa-chart-bar',
fields: [
{ key: 'post_default_views_min', label: '默认浏览最小值', type: 'number', default: 20, min: 0, max: 9999 },
{ key: 'post_default_views_max', label: '默认浏览最大值', type: 'number', default: 50, min: 1, max: 9999 },
{ key: 'post_default_likes_min', label: '默认点赞最小值', type: 'number', default: 5, min: 0, max: 9999 },
{ key: 'post_default_likes_max', label: '默认点赞最大值', type: 'number', default: 15, min: 1, max: 9999 },
], ],
}, },
], ],
@@ -1,42 +0,0 @@
export const shareSchema = {
key: 'post_list.share',
title: '分享设置',
icon: 'fa fas fa-share-alt',
description: '配置微信分享凭据、海报分享和社交分享选项。',
defaults: {
share_wechat_s: false,
share_wechat_appid: '',
share_wechat_app_secret: '',
share_only_logo: false,
share_img_compatible_s: false,
share_img_byimg: '',
share_logo: '',
share_img: '',
share_desc: '扫一扫 立即查看',
},
groups: [
{
key: 'wechat',
title: '微信分享',
icon: 'fa fab fa-weixin',
fields: [
{ key: 'share_wechat_s', label: '启用微信分享', type: 'switch', default: false, help: '启用后文章页将支持微信 JSAPI 分享' },
{ key: 'share_wechat_appid', label: 'AppID', type: 'input', default: '', dependsOn: { key: 'share_wechat_s', value: true }, help: '微信公众号 AppID' },
{ key: 'share_wechat_app_secret', label: 'AppSecret', type: 'input', default: '', dependsOn: { key: 'share_wechat_s', value: true }, help: '微信公众号 AppSecret(请确保 JS 安全域名已配置)' },
{ key: 'share_only_logo', label: '仅显示 Logo', type: 'switch', default: false, help: '分享时仅显示站点 Logo' },
],
},
{
key: 'poster',
title: '海报分享',
icon: 'fa fas fa-image',
fields: [
{ key: 'share_img_compatible_s', label: '兼容模式', type: 'switch', default: false, help: '启用后使用兼容模式生成分享海报' },
{ key: 'share_img_byimg', label: '海报默认图', type: 'media', default: '', help: '海报分享使用的默认背景图' },
{ key: 'share_logo', label: '海报 LOGO', type: 'media', default: '', help: '海报上叠加的站点 LOGO' },
{ key: 'share_img', label: '分享图片', type: 'media', default: '', help: '社交平台分享预览图' },
{ key: 'share_desc', label: '分享描述', type: 'textarea', default: '扫一扫 立即查看', help: '海报或分享卡片的描述文案' },
],
},
],
}
@@ -2,123 +2,85 @@ export const submitSchema = {
key: 'post_list.submit', key: 'post_list.submit',
title: '前台投稿', title: '前台投稿',
icon: 'fa fas fa-paper-plane', icon: 'fa fas fa-paper-plane',
description: '配置前台投稿表单字段、审核流程、附件权限和封面策略。', description: '配置前台投稿的开关、可选分类、免登录发布用户、标题与付费限制。',
defaults: { defaults: {
front_submit_enabled: true, post_article_s: true,
front_submit_roles: 'author,editor,administrator',
front_submit_title: true,
front_submit_content: true,
front_submit_excerpt: true,
front_submit_cover: true,
front_submit_category: true,
post_article_cat: [],
front_submit_tags: true,
post_article_custom_filter_s: true,
front_submit_custom_fields: '',
front_submit_review_mode: 'pending',
front_submit_cover_required: false,
front_submit_max_images: 5,
front_submit_editor_mode: 'rich',
front_submit_draft_save: true,
front_submit_leave_warning: true,
front_submit_guest_enabled: false,
post_article_btn_txte: '发布文章', post_article_btn_txte: '发布文章',
post_article_title_strlen_limit_min: 5, post_article_cat: [],
post_article_title_strlen_limit_max: 30, post_article_custom_filter_s: true,
post_article_user: 0, post_article_tag: true,
post_article_user: 1,
post_article_title_strlen_limit: { min: 5, max: 30 },
post_article_pay_free_s: true, post_article_pay_free_s: true,
post_article_pay_price_min: '0.00', post_article_pay_price_limit: { price: { min: 0, max: 99999 }, points: { min: 0, max: 0 } },
post_article_pay_price_max: '100.00',
post_article_pay_vip_price_s: true, post_article_pay_vip_price_s: true,
post_article_pay_vip_1_discount: 80, post_article_pay_vip_1_discount: 100,
post_article_pay_vip_2_discount: 50, post_article_pay_vip_2_discount: 100,
post_t_placeholder: '请输入标题',
post_c_placeholder: '请输入内容',
}, },
groups: [ groups: [
{ {
key: 'basic', key: 'submit',
title: '基础配置', title: '',
icon: 'fa fas fa-cog',
fields: [ fields: [
{ key: 'front_submit_enabled', label: '开启前台投稿', type: 'switch', default: true, help: '允许用户在前台提交文章' }, { key: 'post_article_s', label: '启用前台投稿', type: 'switch', default: true },
{ key: 'front_submit_roles', label: '允许投稿的角色', type: 'input', default: 'author,editor,administrator', dependsOn: { key: 'front_submit_enabled', value: true }, help: '逗号分隔角色标识' },
{ key: 'post_article_btn_txte', label: '投稿按钮文案', type: 'input', default: '发布文章', dependsOn: { key: 'front_submit_enabled', value: true }, help: '前台投稿按钮显示的文字' },
{ key: 'post_article_title_strlen_limit_min', label: '标题最少字数', type: 'number', default: 5, min: 1, max: 100, dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'post_article_title_strlen_limit_max', label: '标题最多字数', type: 'number', default: 30, min: 5, max: 200, dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'post_t_placeholder', label: '标题占位符', type: 'input', default: '', placeholder: '请输入标题', dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'post_c_placeholder', label: '内容占位符', type: 'textarea', default: '', placeholder: '请输入内容', dependsOn: { key: 'front_submit_enabled', value: true } },
],
},
{ {
key: 'fields', key: 'submit_perm_notice',
title: '表单字段', label: '',
icon: 'fa fas fa-list-alt', type: 'content',
ui_only: true,
content: '<p style="margin:0;font-size:12px;color:var(--aksrt-color-muted);line-height:1.6;">此功能启用后,可以在<a href="admin.php?page=aksrt-settings#/permissions/capabilities">权限管理</a>中设置用户的发布、审核权限 <a href="admin.php?page=aksrt-settings#/permissions/capabilities">【去设置】</a></p>',
},
{ key: 'post_article_btn_txte', label: '按钮文字', type: 'input', default: '发布文章', dependsOn: { key: 'post_article_s', value: true }, description: '投稿按钮的显示的文字,推荐4个字更加协调' },
{ key: 'post_article_cat', label: '投稿允许选择的分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [], placeholder: '输入关键词以搜索分类', dependsOn: { key: 'post_article_s', value: true }, description: '投稿允许选择的分类,为空则允许选择全部分类,所选分类下如果有子分类则会自动显示,如果选择了上级分类,则不要再选该分类下的子分类。注意:请确保所选分类包含子分类总数不要超过50个,否则可能会引起页面崩溃' },
{ key: 'post_article_custom_filter_s', label: '允许选择高级筛选', type: 'switch', default: true, dependsOn: { key: 'post_article_s', value: true } },
{
key: 'submit_filter_notice',
label: '',
type: 'content',
ui_only: true,
content: '<p style="margin:0;font-size:12px;color:var(--aksrt-color-muted);line-height:1.6;">此功能启用后,请先在<a href="admin.php?page=aksrt-settings#/post-list/filter">文章列表/高级筛选</a>中添加高级筛选明细 <a href="admin.php?page=aksrt-settings#/post-list/filter">【去设置】</a></p>',
},
{ key: 'post_article_tag', label: '允许插入标签', type: 'switch', default: true, dependsOn: { key: 'post_article_s', value: true }, description: '前台投稿时候允许设置文章标签' },
{ key: 'post_article_user', label: '免登陆投稿发布用户', type: 'user_select', default: 1, dependsOn: { key: 'post_article_s', value: true }, description: '当选择投稿权限为无需登录就能投稿时,投稿文章的用户' },
{ key: 'post_article_title_strlen_limit', label: '标题字数限制', type: 'between_number', default: { min: 5, max: 30 }, unit: '字', description: '限制标题字数可有效的防止灌水等无意义内容(英文字符按0.5个字计算)' },
{ key: 'post_article_pay_free_s', label: '允许设置为免费资源', type: 'switch', default: true, dependsOn: { key: 'post_article_s', value: true }, description: '付费内容金额允许设置为 0' },
{
key: 'post_article_pay_price_limit',
label: '付费内容金额限制',
type: 'fieldset',
default: { price: { min: 0, max: 99999 }, points: { min: 0, max: 0 } },
description: '限制用户允许设置的金额,防止金额过高不合理(填0为不限制)',
fields: [ fields: [
{ key: 'front_submit_title', label: '标题', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, { key: 'price', label: '现金金额限制', type: 'between_number', default: { min: 0, max: 99999 } },
{ key: 'front_submit_content', label: '正文', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, { key: 'points', label: '积分金额限制', type: 'between_number', default: { min: 0, max: 0 } },
{ key: 'front_submit_excerpt', label: '摘要', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'front_submit_cover', label: '封面图', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'front_submit_category', label: '分类选择', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'post_article_cat', label: '允许投稿分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [], help: '为空则允许全部分类', dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'front_submit_guest_enabled', label: '允许免登录投稿', type: 'switch', default: false, help: '开启后访客可投稿;文章归属到下方用户', dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'post_article_user', label: '免登录归属用户', type: 'user_select', default: 0, help: '免登录投稿时文章归属的用户,搜索选择', dependsOn: { key: 'front_submit_guest_enabled', value: true } },
{ key: 'front_submit_tags', label: '标签输入', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'post_article_custom_filter_s', label: '高级筛选选择', type: 'switch', default: true, help: '投稿时可选择自定义筛选维度值', dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'front_submit_custom_fields', label: '自定义字段', type: 'textarea', default: '', placeholder: '每行一个:field_key|字段名称|类型(input/textarea/select)', dependsOn: { key: 'front_submit_enabled', value: true } },
], ],
}, },
{ key: 'post_article_pay_vip_price_s', label: '付费内容允许设置会员价', type: 'switch', default: true, dependsOn: { key: 'post_article_s', value: true }, description: '前台投稿时对拥有设置付费内容权限的用户,是否开启设置会员价格。开启此项,会直接在前台显示设置会员价的选项;关闭此项,则用户只能设置普通价格,会员价则按照下方设置的折扣自动计算' },
{ {
key: 'review', key: 'post_article_pay_vip_1_discount',
title: '审核策略', label: '一级会员折扣',
icon: 'fa fas fa-tasks', type: 'number',
fields: [ default: 100,
{ min: 0,
key: 'front_submit_review_mode', max: 100,
label: '审核模式', unit: '%',
type: 'radio', dependsOn: { all: [{ key: 'post_article_s', value: true }, { key: 'post_article_pay_vip_price_s', value: false }] },
default: 'pending',
options: [
{ label: '待审核', value: 'pending' },
{ label: '直接发布', value: 'publish' },
{ label: '自动草稿', value: 'draft' },
],
dependsOn: { key: 'front_submit_enabled', value: true },
},
{ key: 'front_submit_cover_required', label: '封面图必填', type: 'switch', default: false, dependsOn: { key: 'front_submit_cover', value: true } },
{ key: 'front_submit_max_images', label: '正文最大图片数', type: 'number', default: 5, min: 0, max: 50, dependsOn: { key: 'front_submit_enabled', value: true } },
],
}, },
{ {
key: 'editor', key: 'post_article_pay_vip_2_discount',
title: '编辑器设置', label: '二级会员折扣',
icon: 'fa fas fa-edit', type: 'number',
fields: [ default: 100,
{ min: 0,
key: 'front_submit_editor_mode', max: 100,
label: '编辑器模式', unit: '%',
type: 'radio', dependsOn: { all: [{ key: 'post_article_s', value: true }, { key: 'post_article_pay_vip_price_s', value: false }] },
default: 'rich', description: '执行价的百分之多少,0为免费,100为没有折扣,不能高于100',
options: [
{ label: '富文本', value: 'rich' },
{ label: 'Markdown', value: 'markdown' },
{ label: '纯文本', value: 'plain' },
],
dependsOn: { key: 'front_submit_content', value: true },
}, },
{ key: 'front_submit_draft_save', label: '自动保存草稿', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, { key: 'post_t_placeholder', label: '投稿标题占位符', type: 'input', default: '请输入标题' },
{ key: 'front_submit_leave_warning', label: '离开页面提醒', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, { key: 'post_c_placeholder', label: '投稿内容占位符', type: 'input', default: '请输入内容' },
],
},
{
key: 'paid',
title: '付费投稿权限',
icon: 'fa fas fa-database',
fields: [
{ key: 'post_article_pay_free_s', label: '允许设置免费', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'post_article_pay_price_min', label: '最低价格', type: 'input', sanitize_type: 'money_decimal', default: '0.00', min: 0, max: 9999, help: '投稿可设置的最低付费金额', dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'post_article_pay_price_max', label: '最高价格', type: 'input', sanitize_type: 'money_decimal', default: '100.00', min: 0, max: 9999, help: '投稿可设置的最高付费金额', dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'post_article_pay_vip_price_s', label: '允许设置会员价', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'post_article_pay_vip_1_discount', label: 'VIP1 折扣(%)', type: 'number', default: 80, min: 0, max: 100, help: 'VIP1 会员折扣百分比,80 即 8 折', dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'post_article_pay_vip_2_discount', label: 'VIP2 折扣(%)', type: 'number', default: 50, min: 0, max: 100, help: 'VIP2 会员折扣百分比,50 即 5 折', dependsOn: { key: 'front_submit_enabled', value: true } },
], ],
}, },
], ],
@@ -22,6 +22,9 @@ export const authSchema = {
invit_code_s: 'close', invit_code_s: 'close',
invit_code_open_desc: '如您受到邀请,可填写推广码。部分推广码会有额外积分赠送', invit_code_open_desc: '如您受到邀请,可填写推广码。部分推广码会有额外积分赠送',
invit_code_must_desc: '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>', invit_code_must_desc: '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>',
invite_code_s: 'close',
invite_code_open_desc: '请输入邀请码,注册成功后奖励将自动发放',
invite_code_must_desc: '<div>本站需凭邀请码注册</div><div>获取邀请码请与客服联系</div>',
user_signin_phone_s: false, user_signin_phone_s: false,
user_signin_nopas_s: false, user_signin_nopas_s: false,
user_signin_nopas_type: 'email', user_signin_nopas_type: 'email',
@@ -84,6 +87,16 @@ export const authSchema = {
{ key: 'invit_code_must_desc', label: '必填提示文案', type: 'textarea', default: '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>', dependsOn: { key: 'invit_code_s', value: 'must' }, help: '支持安全 HTML' }, { key: 'invit_code_must_desc', label: '必填提示文案', type: 'textarea', default: '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>', dependsOn: { key: 'invit_code_s', value: 'must' }, help: '支持安全 HTML' },
], ],
}, },
{
key: 'invite_code',
title: '邀请码注册',
icon: 'fa fas fa-envelope-open-text',
fields: [
{ key: 'invite_code_s', label: '邀请码注册模式', type: 'radio', default: 'close', options: [{ label: '关闭', value: 'close' }, { label: '选填邀请码', value: 'open' }, { label: '必须填写邀请码', value: 'must' }], help: '一次性邀请码与推广码相互独立:邀请码用于控制注册并按码发放奖励,在「用户 → 邀请码管理」批量生成' },
{ key: 'invite_code_open_desc', label: '选填提示文案', type: 'input', default: '请输入邀请码,注册成功后奖励将自动发放', dependsOn: { key: 'invite_code_s', value: 'open' } },
{ key: 'invite_code_must_desc', label: '必填提示文案', type: 'textarea', default: '<div>本站需凭邀请码注册</div><div>获取邀请码请与客服联系</div>', dependsOn: { key: 'invite_code_s', value: 'must' }, help: '支持安全 HTML' },
],
},
{ {
key: 'verification', key: 'verification',
title: '登录与验证', title: '登录与验证',
@@ -26,9 +26,6 @@ export const creditsSchema = {
points_deduction_s: false, points_deduction_s: false,
points_deduction_rate: 100, points_deduction_rate: 100,
points_deduction_max: 50, points_deduction_max: 50,
asset_points_card_enabled: false,
asset_points_card_description: '',
asset_points_card_password_only: true,
points_show_balance: true, points_show_balance: true,
points_show_log: true, points_show_log: true,
points_expire_days: 0, points_expire_days: 0,
@@ -90,16 +87,6 @@ export const creditsSchema = {
{ key: 'points_deduction_max', label: '最大抵扣比例(%)', type: 'number', default: 50, min: 1, max: 100, dependsOn: { key: 'points_deduction_s', value: true }, help: '预留:最多可抵订单金额的百分比。当前未参与下单计算。' }, { key: 'points_deduction_max', label: '最大抵扣比例(%)', type: 'number', default: 50, min: 1, max: 100, dependsOn: { key: 'points_deduction_s', value: true }, help: '预留:最多可抵订单金额的百分比。当前未参与下单计算。' },
], ],
}, },
{
key: 'card_pass',
title: '积分卡密兑换',
icon: 'fa fas fa-ticket-alt',
fields: [
{ key: 'asset_points_card_enabled', label: '启用积分卡密兑换', type: 'switch', default: false, help: '后台充值卡可生成积分卡,用户中心兑换入账。' },
{ key: 'asset_points_card_description', label: '兑换说明', type: 'textarea', default: '', dependsOn: { key: 'asset_points_card_enabled', value: true } },
{ key: 'asset_points_card_password_only', label: '仅密码兑换', type: 'switch', default: true, dependsOn: { key: 'asset_points_card_enabled', value: true }, help: '开启后只需输入卡密;关闭则需卡号+卡密。' },
],
},
{ {
key: 'display', key: 'display',
title: '展示设置', title: '展示设置',
@@ -14,6 +14,12 @@ export const featuresSchema = {
user_cover_upload_s: true, user_cover_upload_s: true,
user_cover_img: `${themeRoot}/assets/img/user_t.jpg`, user_cover_img: `${themeRoot}/assets/img/user_t.jpg`,
user_desc_std: '这家伙很懒,什么都没有写...', user_desc_std: '这家伙很懒,什么都没有写...',
post_rewards_s: true,
post_rewards_text: '赞赏',
post_rewards_amounts: '5,10,20,50,100',
post_rewards_custom: true,
post_rewards_min: '1.00',
post_rewards_max: '1000.00',
}, },
groups: [ groups: [
{ {
@@ -45,5 +51,18 @@ export const featuresSchema = {
{ key: 'user_desc_std', label: '用户默认签名', type: 'input', default: '这家伙很懒,什么都没有写...', help: '用户未设置个人简介时显示的默认文案。' }, { key: 'user_desc_std', label: '用户默认签名', type: 'input', default: '这家伙很懒,什么都没有写...', help: '用户未设置个人简介时显示的默认文案。' },
], ],
}, },
{
key: 'rewards',
title: '打赏',
icon: 'fa fas fa-hand-holding-heart',
fields: [
{ key: 'post_rewards_s', label: '用户打赏功能', type: 'switch', default: true, help: '开启后,登录用户可使用站内余额赞赏作者,金额直接进入作者余额。' },
{ key: 'post_rewards_text', label: '打赏按钮文案', type: 'input', default: '赞赏', dependsOn: { key: 'post_rewards_s', value: true }, help: '自定义打赏按钮文字' },
{ key: 'post_rewards_amounts', label: '快捷金额', type: 'input', default: '5,10,20,50,100', dependsOn: { key: 'post_rewards_s', value: true }, help: '使用英文逗号分隔' },
{ key: 'post_rewards_custom', label: '允许自定义金额', type: 'switch', default: true, dependsOn: { key: 'post_rewards_s', value: true } },
{ key: 'post_rewards_min', label: '最低金额', type: 'input', default: '1.00', min: 0.01, max: 1000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
{ key: 'post_rewards_max', label: '最高金额', type: 'input', default: '1000.00', min: 1, max: 100000, step: 0.01, dependsOn: { key: 'post_rewards_s', value: true } },
],
},
], ],
} }
@@ -10,7 +10,6 @@ import { featuresSchema } from './features.js'
import { verifySchema } from './verify.js' import { verifySchema } from './verify.js'
import { levelSchema } from './level.js' import { levelSchema } from './level.js'
import { badgeSchema } from './badge.js' import { badgeSchema } from './badge.js'
import { wechatSchema } from './wechat.js'
import { reportSchema } from './report.js' import { reportSchema } from './report.js'
import { smsSchema } from './sms.js' import { smsSchema } from './sms.js'
@@ -23,7 +22,6 @@ export const usersSchemas = {
verify: verifySchema, verify: verifySchema,
level: levelSchema, level: levelSchema,
badge: badgeSchema, badge: badgeSchema,
wechat: wechatSchema,
report: reportSchema, report: reportSchema,
sms: smsSchema, sms: smsSchema,
} }
@@ -1,57 +1,71 @@
// 字段集合、顺序、文案、默认值 1:1 对齐参考主题"用户&互动/禁封/举报"
export const reportSchema = { export const reportSchema = {
key: 'users.report', key: 'users.report',
title: '禁封/举报', title: '禁封/举报',
icon: 'fa fas fa-flag', icon: 'fa fas fa-ban',
description: '配置举报入口、公开限制提示和后续治理分类,为封禁/举报流程预留稳定结构。', description: '禁封功能、举报入口、预置原因、申诉资料与举报说明配置。',
defaults: { defaults: {
user_report_enabled: false, user_ban_s: true,
user_report_public_entry: true, user_report_s: true,
user_report_public_notice: true, ban_preset_reason: [
user_report_ban_notice_text: '当前账号处于限制状态,部分互动能力已关闭。', { t: '发布色情、违法内容' },
user_report_categories: 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉', { t: '存在欺诈骗钱行为' },
ban_preset_reason: 'spam|垃圾信息\nabuse|人身攻击\nviolation|违规操作', { t: '骚扰他人' },
ban_appeal_desc: '', { t: '涉嫌侵权' },
ban_appeal_keys: '', { t: '发布垃圾广告信息' },
],
ban_appeal_desc: '<div>为了良好的交流环境,申诉请阅读以下事项</div>\n<li>首次违规将拉入小黑屋7天</li>\n<li>3次违规后将直接禁封账号</li>\n<li>违规情节严重将无法提交申诉</li>\n<li>申诉提交后约2-3个工作日进行审核</li>\n<li>如有其它疑问请与客服联系</li>',
ban_appeal_keys: [
{ t: '真实姓名' },
{ t: '邮箱' },
{ t: '手机号' },
],
user_report_img_s: true, user_report_img_s: true,
user_report_desc: '', user_report_desc: '<div>举报说明:</div>\n<li>请确保举报的内容属实</li>\n<li>我们将会在收到举报后2个工作日内进行处理</li>\n<li>举报属实我们将根据社区管理条例对该用户进行封号处理</li>\n<li>如有其它疑问请与客服联系</li>',
}, },
groups: [ groups: [
{ {
key: 'entry', key: 'main',
title: '前台入口', title: '',
icon: 'fa fas fa-sign-in-alt',
fields: [
{ key: 'user_report_enabled', label: '启用举报/禁封结构', type: 'switch', default: false },
{ key: 'user_report_public_entry', label: '公开主页展示举报入口', type: 'switch', default: true, dependsOn: { key: 'user_report_enabled', value: true } },
{ key: 'user_report_public_notice', label: '公开显示限制提示', type: 'switch', default: true, dependsOn: { key: 'user_report_enabled', value: true } },
{ key: 'user_report_ban_notice_text', label: '限制提示文案', type: 'input', default: '当前账号处于限制状态,部分互动能力已关闭。', dependsOn: { key: 'user_report_public_notice', value: true } },
],
},
{
key: 'taxonomy',
title: '举报分类',
icon: 'fa fas fa-list',
fields: [
{ key: 'user_report_categories', label: '举报分类', type: 'textarea', default: 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉', help: '每行一个分类:slug|显示名称', dependsOn: { key: 'user_report_enabled', value: true } },
],
},
{
key: 'ban',
title: '禁封设置',
icon: 'fa fas fa-ban', icon: 'fa fas fa-ban',
fields: [ fields: [
{ key: 'ban_preset_reason', label: '预设封禁原因', type: 'textarea', default: 'spam|垃圾信息\nabuse|人身攻击\nviolation|违规操作', help: '每行一条:slug|显示名称', dependsOn: { key: 'user_report_enabled', value: true } }, { key: 'user_ban_s', label: '禁封功能', type: 'switch', default: true, help: '启用后可以设置用户禁封,即小黑屋和封号的功能' },
{ key: 'ban_appeal_desc', label: '申诉说明', type: 'textarea', default: '', dependsOn: { key: 'user_report_enabled', value: true } }, { key: 'user_report_s', label: '举报功能', type: 'switch', default: true, help: '前台显示举报按钮,用户可以举报其它不良信息', dependsOn: { key: 'user_ban_s', value: true } },
{ key: 'ban_appeal_keys', label: '申诉关键词', type: 'input', default: '', help: '逗号分隔', dependsOn: { key: 'user_report_enabled', value: true } }, {
key: 'ban_preset_reason',
label: '预置原因',
type: 'group',
button_title: '添加原因',
help: '举报/禁封的时候可选择的原因',
default: [
{ t: '发布色情、违法内容' },
{ t: '存在欺诈骗钱行为' },
{ t: '骚扰他人' },
{ t: '涉嫌侵权' },
{ t: '发布垃圾广告信息' },
],
fields: [
{ key: 't', label: '原因', type: 'input', default: '' },
], ],
}, },
{ key: 'ban_appeal_desc', label: '申诉说明', type: 'textarea', rows: 5, help: '用户申诉时候显示的内容(支持HTML代码,请注意代码规范)', default: '<div>为了良好的交流环境,申诉请阅读以下事项</div>\n<li>首次违规将拉入小黑屋7天</li>\n<li>3次违规后将直接禁封账号</li>\n<li>违规情节严重将无法提交申诉</li>\n<li>申诉提交后约2-3个工作日进行审核</li>\n<li>如有其它疑问请与客服联系</li>' },
{ {
key: 'image', key: 'ban_appeal_keys',
title: '图片举报', label: '申诉所需资料',
icon: 'fa fas fa-image', type: 'group',
button_title: '添加申诉资料',
help: '用户提交申诉时候需要提供的资料;此处的信息收集,仅用于记录和申诉审核',
default: [
{ t: '真实姓名' },
{ t: '邮箱' },
{ t: '手机号' },
],
fields: [ fields: [
{ key: 'user_report_img_s', label: '启用图片举报', type: 'switch', default: true, dependsOn: { key: 'user_report_enabled', value: true } }, { key: 't', label: '资料项', type: 'input', default: '' },
{ key: 'user_report_desc', label: '举报说明', type: 'textarea', default: '', dependsOn: { key: 'user_report_img_s', value: true } }, ],
},
{ key: 'user_report_img_s', label: '举报可上传图片', type: 'switch', default: true, help: '举报时允许上传图片进行举证(最多上传3张)', dependsOn: { key: 'user_report_s', value: true } },
{ key: 'user_report_desc', label: '举报说明', type: 'textarea', rows: 5, help: '举报时候显示的内容(支持HTML代码,请注意代码规范)', dependsOn: { key: 'user_report_s', value: true }, default: '<div>举报说明:</div>\n<li>请确保举报的内容属实</li>\n<li>我们将会在收到举报后2个工作日内进行处理</li>\n<li>举报属实我们将根据社区管理条例对该用户进行封号处理</li>\n<li>如有其它疑问请与客服联系</li>' },
], ],
}, },
], ],
Loaded 100 of 584 files, more files were not shown because too many files have changed in this diff. Show more