Compare commits

..
207 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
475 changed files with 30251 additions and 23612 deletions

No files matched your search

@@ -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 或计划文件改动。
+2 -5
View File
@@ -12,12 +12,9 @@ AKSRT 是一款现代化的 WordPress 主题,采用**原生 WordPress 主题
### 核心特性
- **自定义管理面板 (SPA)** — Vue3 + Vite + Naive UI 的 12 个一级业务域后台
- **统一设置契约** — Schema 驱动表单、域控制器、导入导出、按域重置、迁移状态与健康检查
- **支付/下载主线** — 下载/付费内容中心、充值/VIP/分成/返佣/网关结构、10 分钟下载 token
- **统一设置契约** — Schema 驱动表单、域控制器、导入导出
- **用户与增长** — 统一登录注册、OAuth 登录、签到奖励、邀请奖励、等级/徽章/认证/举报结构
- **内容与交互** — 首页模块化、归档/搜索/404、单篇资源页、评论审核与通知中心
- **运维工具** — 配置导出导入、dry-run 校验、备份、数据库与环境诊断
- **自定义页面模板**:About / Friends / Login / Profile / Register
- **内容与交互** — 首页模块化、归档/搜索/404、单篇资源页、评论审核与通知
- **响应式设计**
---
@@ -5,11 +5,4 @@ export const extensionsApiMethods = {
testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', data: { ip } }) },
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') },
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') },
addFriendLink(data) { return this.request('/friend-links', { method: 'POST', body: JSON.stringify(data) }) },
+1 -8
View File
@@ -5,6 +5,7 @@ export const usersApiMethods = {
getAuthSettings() { return this.request('/settings/auth') },
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') },
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' }) },
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') },
saveCreditSettings(data) { return this.request('/assets/settings', { method: 'POST', body: JSON.stringify(data) }) },
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
}
+342 -41
View File
@@ -476,13 +476,43 @@
<!-- Fieldset (single nested object) -->
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
<AksField
v-for="field in visibleNestedFields"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
<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
v-for="field in visibleNestedFields"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
:form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
</template>
</div>
<div v-else-if="schema.type === 'link'" class="aksrt-field__link">
@@ -501,42 +531,67 @@
</span>
</div>
<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>
<!-- 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-for="(item, index) in repeaterItems"
:key="item._id || index"
class="aksrt-field__repeater-item"
<draggable
v-model="repeaterDragList"
:item-key="repeaterDragKey"
handle=".aksrt-field__repeater-handle"
ghost-class="aksrt-field__repeater-ghost"
animation="180"
>
<div class="aksrt-field__repeater-header">
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
<n-button
size="tiny"
type="error"
text
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button>
</div>
<div class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key">
<AksField
v-if="matchDependency(field.dependsOn, item)"
:schema="field"
:model-value="item[field.key]"
:disabled="schema.disabled"
@update:model-value="updateRepeaterItem(index, field.key, $event)"
/>
</template>
</div>
</div>
<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 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
size="tiny"
type="error"
text
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button>
</div>
</div>
<div v-show="!repeaterCollapsible || isRepeaterItemOpen(index)" class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key">
<AksField
v-if="matchDependency(field.dependsOn, element)"
:schema="field"
:model-value="element[field.key]"
:form-data="formData"
:disabled="schema.disabled"
@update:model-value="updateRepeaterItem(index, field.key, $event)"
/>
</template>
</div>
</div>
</template>
</draggable>
<n-button
size="small"
dashed
@@ -646,6 +701,7 @@
:key="field.key"
:schema="field"
:model-value="repeaterDraft[field.key]"
:form-data="formData"
@update:model-value="updateRepeaterDraft(field.key, $event)"
/>
</div>
@@ -685,6 +741,8 @@ import FaIconPicker from './FaIconPicker.vue'
import AksMediaField from './AksMediaField.vue'
import AksGalleryField from './AksGalleryField.vue'
import AksCodeEditor from './AksCodeEditor.vue'
import { buildFieldsetSections } from './fieldset-sections.js'
import draggable from 'vuedraggable'
import api from '@/api'
const props = defineProps({
@@ -696,6 +754,8 @@ const props = defineProps({
},
/** Current field value */
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 */
errorMessage: { type: String, default: '' },
})
@@ -715,6 +775,85 @@ const repeaterItems = computed(() => Array.isArray(props.modelValue) ? props.mod
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))
/* ------------------------------------------------------------------ *
* 手风琴模式: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) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
@@ -722,6 +861,35 @@ function fieldsetValue(field) {
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) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
@@ -729,10 +897,22 @@ function updateFieldsetValue(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 opts = props.schema.options || []
return opts.map(opt => {
const opts = props.schema.options
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 === 'object' && opt !== null) {
return { ...opt, label: opt.label ?? opt.name ?? opt.value, value: opt.value ?? opt.label ?? opt.name }
@@ -1001,6 +1181,8 @@ const repeaterDraft = ref({})
function createRepeaterItem() {
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
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) {
newItem[field.key] = field.default
} else if (field.type === 'switch') {
@@ -1030,7 +1212,16 @@ function removeRepeaterItem(index) {
function updateRepeaterItem(index, fieldKey, value) {
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)
}
@@ -1132,6 +1323,15 @@ function confirmRepeaterModal() {
margin-bottom: 0;
}
/* 结构化分组(会员商品/续费商品/积分兑换/升级价格)与相邻字段之间留出更明确的间隔,
* 避免两个分组上下贴在一起,和参考主题的留白一致。 */
.aksrt-field--group,
.aksrt-field--repeater,
.aksrt-field--accordion,
.aksrt-field--fieldset {
margin-bottom: 26px;
}
.aksrt-field__input-wrap {
display: grid;
gap: 8px;
@@ -1397,6 +1597,48 @@ function confirmRepeaterModal() {
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 {
display: flex;
flex-direction: column;
@@ -1414,9 +1656,68 @@ function confirmRepeaterModal() {
display: flex;
align-items: center;
justify-content: space-between;
gap: 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 {
font-size: 12px;
font-weight: 600;
@@ -64,15 +64,16 @@
<n-tabs type="line" animated>
<n-tab-pane
v-for="tab in field.tabs"
:key="tab.title || tab.key"
:name="tab.title || tab.key"
:tab="tab.title || tab.key"
: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)"
@@ -81,6 +82,12 @@
</n-tab-pane>
</n-tabs>
</div>
<AksSorterField
v-else-if="field.type === 'custom' && field.renderer === 'sorter'"
:model-value="getFieldValue(field.key) || {}"
:options="field.options || []"
@update:model-value="setFieldValue(field.key, $event)"
/>
<slot
v-else-if="field.type === 'custom'"
:name="`field-${field.key}`"
@@ -89,15 +96,17 @@
<AksField
:schema="field"
:model-value="getFieldValue(field.key)"
:form-data="formData"
:error-message="errors?.[field.key]"
@update:model-value="setFieldValue(field.key, $event)"
@action="emit('field-action', $event)"
@action="emit('field-action', $event)"
/>
</slot>
<AksField
v-else
:schema="field"
:model-value="getFieldValue(field.key)"
:form-data="formData"
:error-message="errors?.[field.key]"
@update:model-value="setFieldValue(field.key, $event)"
@action="emit('field-action', $event)"
@@ -119,26 +128,7 @@
</div>
<!-- Sticky save bar -->
<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" @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>
<AksSaveBar :dirty="dirty" :saving="saving" @save="$emit('save')" @reload="$emit('reload')" />
</div>
</template>
@@ -156,8 +146,10 @@
*/
import { ref, computed, useSlots } from 'vue'
import FaIcon from './FaIcon.vue'
import AksSaveBar from './AksSaveBar.vue'
import AksFieldGroup from './AksFieldGroup.vue'
import AksField from './AksField.vue'
import AksSorterField from './AksSorterField.vue'
const props = defineProps({
/** Page title */
@@ -239,6 +231,18 @@ function isFieldVisible(field) {
return matchDependsOn(field.dependsOn)
}
/**
* Tab label. `titleKey` binds the label to a live form value (e.g. the VIP level
* display name) so renaming a level renames its tab; `titleFallback` is used
* while that value is still empty.
*/
function tabName(tab) {
if (!tab) return ''
const dynamic = tab.titleKey ? props.formData?.[tab.titleKey] : ''
const label = typeof dynamic === 'string' ? dynamic.trim() : ''
return label || tab.titleFallback || tab.title || tab.key || ''
}
/** Whole group can be hidden (e.g. optional advanced blocks). */
function isGroupVisible(group) {
return matchDependsOn(group.dependsOn)
@@ -385,53 +389,6 @@ const filteredSchema = computed(() => {
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 */
@media (max-width: 768px) {
.aksrt-form-renderer__header {
@@ -443,19 +400,5 @@ const filteredSchema = computed(() => {
max-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>
@@ -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>
@@ -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 = {
key: 'extensions',
@@ -6,6 +6,16 @@ export const extensionsSection = {
icon: renderFaIcon('fa fas fa-puzzle-piece'),
route: '/extensions',
children: [
schemaPage({
key: 'extensions-system',
name: 'ExtensionsSystem',
label: '系统工具',
route: '/extensions/system',
title: '系统工具',
desc: 'WordPress 优化、上传优化与编辑器显示的系统级开关。',
canonicalDomain: 'extensions',
section: 'system',
}),
schemaPage({
key: 'extensions-ip',
name: 'ExtensionsIp',
@@ -32,19 +42,19 @@ export const extensionsSection = {
label: '百度推送',
route: '/extensions/push',
title: '百度推送',
desc: '配置搜索引擎主动推送、批量提交和状态追踪。',
desc: '配置百度自动推送、收录通道与触发规则;推送结果与「重新提交」在文章/分类编辑页查看。',
canonicalDomain: 'extensions',
section: 'push',
}),
customPage({
key: 'extensions-ai',
name: 'ExtensionAI',
label: 'AI 连接配置',
route: '/extensions/ai',
component: 'ExtensionAI',
status: 'available',
title: 'AI 连接配置',
desc: '配置 AI 生成连接;SEO 在文章独立 SEO 框,摘要/标签在前台投稿页。',
schemaPage({
key: 'extensions-security',
name: 'ExtensionsSecurity',
label: '网站安全',
route: '/extensions/security',
title: '网站安全',
desc: '防刷限制、验证码发送限制与站点安全加固。',
canonicalDomain: 'extensions',
section: 'security',
}),
],
}
@@ -84,6 +84,16 @@ export const globalSection = {
canonicalDomain: 'global',
section: 'i18n',
}),
schemaPage({
key: 'global-share',
name: 'GlobalShare',
label: '社交分享',
route: '/global/share',
title: '社交分享',
desc: '配置分享平台、微信 JSAPI 与海报分享。',
canonicalDomain: 'global',
section: 'share',
}),
schemaPage({
key: 'global-email',
name: 'GlobalEmail',
+1 -1
View File
@@ -16,7 +16,7 @@ const SECTION_DEFAULT_CHILDREN = {
permissions: 'permissions-comment',
users: 'users-auth',
payment: 'payment-knowledge',
extensions: 'extensions-ip',
extensions: 'extensions-system',
tools: 'tools-import-export',
'theme-info': 'theme-info-dashboard',
docs: 'docs-dashboard',
@@ -88,5 +88,16 @@ export const paymentSection = {
canonicalDomain: 'payment',
section: 'withdrawal',
}),
schemaPage({
key: 'payment-after-sale',
name: 'PaymentAfterSale',
label: '售后策略',
route: '/payment/after-sale',
status: 'available',
title: '售后策略',
desc: '售后功能开关、申请时效、超时效提示与申请表单(预置原因、所需材料、图片上传)配置。',
canonicalDomain: 'payment',
section: 'after_sale',
}),
],
}
@@ -47,15 +47,5 @@ export const permissionsSection = {
canonicalDomain: 'permissions',
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',
section: 'feature',
}),
schemaPage({
key: 'post-list-share',
name: 'PostListShare',
label: '分享设置',
route: '/post-list/share',
title: '分享设置',
desc: '配置微信分享凭据、海报分享和社交分享选项。',
canonicalDomain: 'post_list',
section: 'share',
}),
schemaPage({
key: 'post-list-comment',
name: 'PostListComment',
+1 -11
View File
@@ -60,7 +60,7 @@ export const usersSection = {
route: '/users/verify',
component: 'UsersVerify',
title: '身份认证',
desc: '配置认证展示、申请类型与申请门槛;审核、驳回和取消认证统一在 WordPress 用户菜单处理。',
desc: '配置通用认证申请规则;申请审核和认证资料管理位于 WordPress 用户菜单。',
canonicalDomain: 'users',
section: 'verify',
}),
@@ -108,16 +108,6 @@ export const usersSection = {
canonicalDomain: 'users',
section: 'sms',
}),
schemaPage({
key: 'users-wechat',
name: 'UsersWechat',
label: '微信通知',
route: '/users/wechat',
title: '微信通知',
desc: '配置微信模板消息、订阅/解绑和站外通知策略。',
canonicalDomain: 'users',
section: 'wechat',
}),
customPage({
key: 'users-wechat-template',
name: 'UsersWechatTemplate',
+37 -44
View File
@@ -23,15 +23,13 @@
</n-form>
</n-card>
<n-card title="统一资产账本" class="aksrt-section-card">
<n-card title="用户近期资产记录" class="aksrt-section-card">
<n-space vertical :size="12">
<n-space wrap>
<n-select v-model:value="ledgerFilters.asset" :options="assetOptions" clearable placeholder="资产" style="width: 130px" />
<n-input v-model:value="ledgerFilters.transaction_id" placeholder="交易号" clearable style="width: 130px" />
<n-input v-model:value="ledgerFilters.operation_key" placeholder="幂等键" clearable style="width: 240px" />
<n-input v-model:value="ledgerFilters.ref_type" placeholder="引用类型" clearable style="width: 130px" />
<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-input v-model:value="ledgerFilters.user_id" placeholder="用户 ID" clearable style="width: 130px" />
<n-select v-model:value="ledgerFilters.asset" :options="assetOptions" placeholder="资产" style="width: 130px" />
<n-input v-model:value="ledgerFilters.date_from" type="date" placeholder="开始日期" style="width: 160px" />
<n-input v-model:value="ledgerFilters.date_to" type="date" placeholder="结束日期" style="width: 160px" />
<n-button type="primary" :loading="ledgerLoading" @click="searchLedger">查询</n-button>
<n-button @click="resetLedger">重置</n-button>
</n-space>
@@ -40,13 +38,13 @@
:data="ledgerRows"
:loading="ledgerLoading"
:row-key="row => row.id"
:scroll-x="1600"
:scroll-x="1100"
size="small"
/>
<n-pagination
v-model:page="ledgerPage"
:page-count="ledgerPageCount"
:page-size="20"
:page-size="10"
@update:page="loadLedger"
/>
</n-space>
@@ -169,40 +167,41 @@ const ledgerRows = ref([])
const ledgerPage = ref(1)
const ledgerPageCount = ref(1)
const ledgerFilters = reactive({
asset: null,
transaction_id: '',
operation_key: '',
ref_type: '',
ref_key: '',
actor_user_id: '',
user_id: '',
asset: 'points',
date_from: '',
date_to: '',
})
const assetOptions = [
{ label: '积分', value: 'points' },
{ label: '余额', value: 'balance' },
]
const ledgerColumns = [
{ title: '交易号', key: 'transaction_label', width: 100, fixed: 'left' },
{ title: '幂等键', key: 'operation_key', width: 260, ellipsis: { tooltip: true } },
{ title: '资产', key: 'assets_label', width: 100 },
{ title: '原因', key: 'reason', width: 150, ellipsis: { tooltip: true } },
{ title: '业务引用', key: 'reference_label', width: 200, ellipsis: { tooltip: true } },
{ 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 },
{ title: '类型', key: 'reason', width: 150 },
{ title: '订单号', key: 'ref_key', width: 200 },
{ title: '变动 / 变动后', key: 'delta_label', width: 190 },
{ title: '说明', key: 'remark', width: 240, ellipsis: { tooltip: true } },
{ title: '时间', key: 'created_at', width: 170 },
]
function formatAssetValue(asset, value) {
if (asset === 'balance') return typeof value === 'string' && /^[+-]?\d+\.\d{2}$/.test(value) ? value : '0.00'
const raw = String(value ?? '0')
return /^-?\d+$/.test(raw) ? raw : '0'
const raw = Number(value ?? 0)
if (!Number.isFinite(raw)) return asset === 'balance' ? '0.00' : '0'
if (asset !== 'balance') return String(raw)
const negative = raw < 0
const digits = String(Math.abs(Math.trunc(raw))).padStart(3, '0')
const whole = digits.slice(0, -2).replace(/^0+(?=\d)/, '')
return (negative ? '-' : '') + whole + '.' + digits.slice(-2)
}
async function loadLedger() {
ledgerLoading.value = true
try {
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]) => {
if (value !== null && String(value).trim() !== '') params[key] = value
})
@@ -210,23 +209,16 @@ async function loadLedger() {
const data = response?.data || response || {}
ledgerRows.value = (data.items || []).map((row) => {
const entries = Array.isArray(row.entries) ? row.entries : []
const entry = entries[0] || {}
return {
...row,
transaction_label: `#${row.id}`,
assets_label: (row.assets || []).join(' / '),
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(';'),
delta_label: `${formatAssetValue(entry.asset, entry.delta)} / ${formatAssetValue(entry.asset, entry.balance_after)}`,
remark: row.remark || '—',
}
})
ledgerPageCount.value = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.per_page || 20)))
ledgerPageCount.value = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.per_page || 10)))
} catch (e) {
message.error(e.message || '加载账本失败')
message.error(e.message || '加载资产记录失败')
} finally {
ledgerLoading.value = false
}
@@ -238,8 +230,10 @@ function searchLedger() {
}
function resetLedger() {
Object.assign(ledgerFilters, { asset: null, transaction_id: '', operation_key: '', ref_type: '', ref_key: '', actor_user_id: '' })
searchLedger()
Object.assign(ledgerFilters, { user_id: '', asset: 'points', date_from: '', date_to: '' })
ledgerRows.value = []
ledgerPage.value = 1
ledgerPageCount.value = 1
}
async function loadCfg() {
@@ -270,6 +264,5 @@ async function saveCfg() {
onMounted(() => {
loadCfg()
loadLedger()
})
</script>
+37 -1
View File
@@ -6,7 +6,7 @@
:icon="pageMeta.icon"
:schema="pageMeta.groups"
:form-data="formData"
:dirty="dirty"
:dirty="isSmsPage ? isSmsSettingsDirty : dirty"
:loading="loading"
:saving="saving"
:load-error="loadError"
@@ -131,6 +131,7 @@ const showTestModal = ref(false)
const testLoading = ref(false)
const testResult = ref(null)
const serverSchema = ref(null)
const smsSavedSettings = ref('')
const captchaSecretsToClear = ref(new Set())
const captchaProviders = [
{ 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 resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
const isEmailPage = computed(() => resolvedDomain.value === 'global' && resolvedSection.value === 'email')
const isSmsPage = computed(() => resolvedDomain.value === 'users' && resolvedSection.value === 'sms')
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
const 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) {
const parts = path.split('/').filter(Boolean)
@@ -216,6 +226,10 @@ const {
delete payload[key]
}
})
if (isSmsPage.value) {
delete payload.sms_test_phone
delete payload.sms_test_send
}
if (clearSecrets.length) payload.__clear_secrets = clearSecrets
if (isPermissionsLogin.value && captchaSecretsToClear.value.size) {
payload.__clear_secrets = [...(payload.__clear_secrets || []), ...captchaSecretsToClear.value]
@@ -226,12 +240,34 @@ const {
return response
},
defaults: defaults.value,
onLoaded: (data) => {
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(data)
},
onSaved: () => {
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(formData.value)
message.success('设置已保存')
},
})
async function handleFieldAction(key) {
if (key === 'sms_test_send' && isSmsPage.value) {
const phone = String(formData.value?.sms_test_phone || '').trim()
if (!phone) {
message.warning('请先填写测试手机号')
return
}
if (isSmsSettingsDirty.value) {
message.warning('请先保存当前配置,再发送测试短信')
return
}
try {
const response = await api.testSms(phone)
message.success(response?.message || '测试短信已发送')
} catch (error) {
message.error(error?.message || '测试短信发送失败')
}
return
}
if (key !== 'email_test_send' || !isEmailPage.value) return
const to = String(formData.value?.email_test_to || '').trim()
if (!to) {
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>
+36 -300
View File
@@ -1,220 +1,52 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="身份认证"
desc="统一管理认证申请规则与云服务商凭证;审核、驳回和取消认证仍在 WordPress 用户菜单处理。"
/>
<PageHeader title="身份认证" desc="配置通用认证申请规则;申请审核和用户认证资料管理位于 WordPress 用户菜单。" />
<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-grid-item span="3 m:1">
<n-card class="aksrt-status-card" size="small">
<n-statistic label="身份认证功能">
<template #default>{{ verifyForm.user_verify_enabled ? '已启用' : '已停用' }}</template>
</n-statistic>
<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-card class="aksrt-section-card" title="认证申请设置">
<n-form :model="form" label-placement="left" label-width="190">
<n-form-item label="启用身份认证"><n-switch v-model:value="form.user_auth_s" /></n-form-item>
<n-form-item label="允许用户前台申请"><n-switch v-model:value="form.user_auth_apply_s" :disabled="!form.user_auth_s" /></n-form-item>
<n-form-item v-if="!form.user_auth_apply_s" label="停用申请说明">
<n-input v-model:value="form.user_auth_apply_disable_desc" type="textarea" :rows="2" />
</n-form-item>
<n-form-item label="企业认证徽标文案">
<n-input v-model:value="verifyForm.user_verify_enterprise_badge_text" placeholder="企业认证" />
<n-form-item v-if="form.user_auth_apply_s" label="申请最低用户等级">
<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 label="停用申请说明">
<n-input v-model:value="verifyForm.user_verify_disable_desc" type="textarea" :rows="2" />
</n-form-item>
<n-form-item label="申请介绍">
<n-input v-model:value="verifyForm.user_verify_desc" type="textarea" :rows="3" />
<n-form-item v-if="form.user_auth_apply_s" label="申请介绍">
<n-input v-model:value="form.user_auth_apply_desc" type="textarea" :rows="3" />
</n-form-item>
</n-form>
</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">
<n-button :disabled="!dirty" @click="load">放弃更改</n-button>
<n-button type="primary" :loading="saving" @click="save">保存全部配置</n-button>
<n-button :disabled="!dirty || saving" @click="load">放弃更改</n-button>
<n-button type="primary" :disabled="!dirty" :loading="saving" @click="save">保存设置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useDialog, useMessage } from 'naive-ui'
import { computed, onMounted, reactive, ref } from 'vue'
import { useMessage } from 'naive-ui'
import PageHeader from '@/components/PageHeader.vue'
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 dialog = useDialog()
const providerKeys = ['aliyun', 'tencent']
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 form = reactive({ ...defaults })
const initial = ref('')
const loading = ref(true)
const saving = ref(false)
const testing = ref('')
const activeTab = ref('aliyun')
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(form))
const dirty = computed(() => initial.value !== '' && snapshot.value !== initial.value)
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) {
return response?.data ?? response ?? {}
}
@@ -222,123 +54,27 @@ function extractData(response) {
async function load() {
loading.value = true
try {
const [verifyResponse, identityResponse] = await Promise.all([
api.getSchemaPage('users', 'verify'),
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
Object.assign(form, defaults, extractData(await api.getSchemaPage('users', 'verify')))
initial.value = snapshot.value
} catch (error) {
message.error(error.message || '加载身份认证配置失败')
message.error(error.message || '加载身份认证设置失败')
} finally {
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() {
const hasClear = providerKeys.some(key => clearCredentials[key])
const run = async () => {
saving.value = true
try {
await persist()
message.success('身份认证配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
if (!hasClear) return run()
dialog.warning({
title: '确认清除凭证',
content: '已勾选的服务商凭证将被永久删除,是否继续保存?',
positiveText: '清除并保存',
negativeText: '取消',
onPositiveClick: run,
})
}
async function testProvider(provider) {
testing.value = provider
saving.value = true
try {
const response = await api.testIdentitySettings({ provider })
message.success(response?.message || `${providerLabel(provider)}配置测试通过`)
Object.assign(form, defaults, extractData(await api.saveSchemaPage('users', 'verify', { ...form })))
initial.value = snapshot.value
message.success('身份认证设置已保存')
} catch (error) {
message.error(error.message || `${providerLabel(provider)}配置测试失败`)
message.error(error.message || '保存身份认证设置失败')
} finally {
testing.value = ''
}
}
async function copyCallback() {
if (!callbackUrlForCurrentProvider.value) return message.warning('回调地址暂不可用')
try {
await navigator.clipboard.writeText(callbackUrlForCurrentProvider.value)
message.success('回调地址已复制')
} catch {
message.error('复制失败,请手动复制')
saving.value = false
}
}
onMounted(load)
</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,3 +0,0 @@
export const extensionsPageLoaders = {
ExtensionAI: () => import('@/pages/extensions/ExtensionAI.vue'),
}
@@ -1,11 +1,9 @@
import { corePageLoaders } from './core'
import { usersPageLoaders } from './users'
import { toolsPageLoaders } from './tools'
import { extensionsPageLoaders } from './extensions'
export const componentLoaders = {
...corePageLoaders,
...usersPageLoaders,
...toolsPageLoaders,
...extensionsPageLoaders,
}
@@ -3,12 +3,16 @@
* Each key corresponds to a sub-page under the "扩展&增强" section.
*/
import { systemSchema } from './system.js'
import { ipSchema } from './ip.js'
import { auditSchema } from './audit.js'
import { pushSchema } from './push.js'
import { securitySchema } from './security.js'
export const extensionsSchemas = {
system: systemSchema,
ip: ipSchema,
audit: auditSchema,
push: pushSchema,
security: securitySchema,
}
@@ -2,21 +2,16 @@ export const pushSchema = {
key: 'extensions.push',
title: '百度推送',
icon: 'fa fas fa-paper-plane',
description: '配置搜索引擎主动推送、批量提交和状态追踪。',
description: '配置百度主动推送、收录通道和推送状态。',
defaults: {
baidu_push_enabled: false,
baidu_push_token: '',
baidu_push_auto: true,
baidu_push_on_publish: true,
baidu_push_on_update: false,
baidu_push_batch_size: 50,
baidu_push_normal: true,
baidu_push_fast: false,
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: [
{
@@ -25,7 +20,7 @@ export const pushSchema = {
icon: 'fa fas fa-rocket',
fields: [
{ 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',
fields: [
{ 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_update', label: '更新时推送', type: 'switch', default: false, 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 }, help: '已发布内容再次保存时,若上次推送未成功则重试;需主动再次提交请在编辑页勾选「重新提交」' },
],
},
{
key: 'xzh',
title: '熊掌号 / JSON-LD',
icon: 'fa fas fa-paw',
key: 'channel',
title: '收录通道',
icon: 'fa fas fa-inbox',
fields: [
{ key: 'xzh_on', label: '启用熊掌号', type: 'switch', default: false },
{ key: 'xzh_appid', label: '百度 AppID', type: 'input', default: '', dependsOn: { key: 'xzh_on', value: true } },
{ 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 } },
{ key: 'baidu_push_normal', label: '普通收录', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '每天可提交最多10万条有价值内容,收录速度较慢' },
{ key: 'baidu_push_fast', label: '快速收录', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '百度新推出的高效收录接口,目前仅对部分优质站点开放,请确保您的站点已开放快速收录功能' },
],
},
{
@@ -56,8 +47,7 @@ export const pushSchema = {
title: '高级设置',
icon: 'fa fas fa-cog',
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 } },
{ key: 'baidu_push_log_enabled', label: '记录推送日志', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: 'WP_DEBUG 开启时,将推送结果写入错误日志' },
],
},
],
@@ -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'
// Email schema is served by the PHP template catalog to keep defaults and fields canonical.
import { tabbarSchema } from './tabbar.js'
import { floatingSchema } from './floating.js'
import { i18nSchema } from './i18n.js'
import { customCodeSchema } from './custom-code.js'
import { seoSchema } from './seo.js'
import { codeHighlightSchema } from './code-highlight.js'
import { systemNoticeSchema } from './system-notice.js'
import { shareSchema } from './share.js'
export const globalSchemas = {
logo: logoSchema,
@@ -23,10 +23,10 @@ export const globalSchemas = {
search: searchSchema,
email: null,
tabbar: tabbarSchema,
floating: floatingSchema,
i18n: i18nSchema,
'custom-code': customCodeSchema,
seo: seoSchema,
'code-highlight': codeHighlightSchema,
'system-notice': systemNoticeSchema,
share: shareSchema,
}
+2 -17
View File
@@ -2,7 +2,7 @@ export const seoSchema = {
key: 'global.seo',
title: 'SEO 优化',
icon: 'fa fas fa-search',
description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框;连接配置在「扩展 → AI 连接配置」。优先使用 WordPress AI Connectors。',
description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框,连接由 WordPress AI Connectors 提供。',
defaults: {
seo_enabled: true,
seo_ai_enabled: false,
@@ -13,10 +13,6 @@ export const seoSchema = {
ai_seo_title_prompt: '',
ai_seo_keywords_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_default: '',
seo_image_always_default: false,
@@ -48,7 +44,7 @@ export const seoSchema = {
label: '启用 AI SEO 生成',
type: 'switch',
default: false,
help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先配置 AI 连接(WP Connectors 或扩展 → AI 连接配置)。',
help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先在 WordPress 配置 AI Connectors。',
dependsOn: { key: 'seo_enabled', value: true },
},
{ 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',
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 } },
],
},
],
}
@@ -26,7 +26,6 @@ export const affiliateSchema = {
pay_rebate_ratio_vip_1: 10,
pay_rebate_user_s_2: ['all'],
pay_rebate_ratio_vip_2: 20,
affiliate_settlement_days: 7,
affiliate_disabled_code_stops_commission: false,
referral_attribution_model: 'first_touch',
pay_rebate_text_desc: '加入分享计划,获得高额奖励',
@@ -36,6 +35,8 @@ export const affiliateSchema = {
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: [
@@ -152,7 +153,6 @@ export const affiliateSchema = {
},
],
},
{ key: 'affiliate_settlement_days', label: '结算观察期(天)', type: 'number', default: 7, min: 0, max: 90, dependsOn: { key: 'pay_rebate_s', value: true }, help: '订单完成后经过观察期,佣金才会结算到可提现余额' },
{ key: 'affiliate_disabled_code_stops_commission', label: '推广码禁用后停止返佣', type: 'switch', default: false, dependsOn: { key: 'pay_rebate_s', value: true }, help: '开启后,推广者的推广码被禁用(或缺失)时,其名下已绑定用户的新订单不再产生佣金;关闭则绑定关系继续生效' },
],
},
@@ -175,6 +175,8 @@ export const affiliateSchema = {
{ 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: '画在二维码下方居中位置,留空则使用默认文案。' },
],
},
{
@@ -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张)' },
],
},
],
}
@@ -9,6 +9,7 @@ import { revenueSchema } from './revenue.js'
import { affiliateSchema } from './affiliate.js'
import { gatewaySchema } from './gateway.js'
import { withdrawalSchema } from './withdrawal.js'
import { afterSaleSchema } from './afterSale.js'
export const paymentSchemas = {
knowledge: knowledgeSchema,
@@ -18,4 +19,5 @@ export const paymentSchemas = {
affiliate: affiliateSchema,
gateway: gatewaySchema,
withdrawal: withdrawalSchema,
after_sale: afterSaleSchema,
}
@@ -25,13 +25,10 @@ export const knowledgeSchema = {
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: [],
down_alone_page: false,
down_user_log: 50,
down_post_log: 50,
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',
@@ -124,7 +121,7 @@ export const knowledgeSchema = {
{ 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_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 },
],
@@ -136,7 +133,6 @@ export const knowledgeSchema = {
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: '已关闭' },
],
@@ -174,8 +170,6 @@ export const knowledgeSchema = {
title: '付费下载',
fields: [
{ key: 'down_alone_page', label: '独立下载页面', type: 'switch', default: false, help: '开启后,付费资源的下载链接会跳转独立页面显示' },
{ key: 'down_user_log', label: '保存用户下载记录(条)', type: 'number', default: 50, min: 0, help: '填0则不保存,注意记录量太大可能会影响网站性能' },
{ key: 'down_post_log', label: '保存文章下载记录(条)', type: 'number', default: 50, min: 0, help: '填0则不保存,注意记录量太大可能会影响网站性能' },
{ 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: '限制使用此功能的文件格式,多个用英文逗号隔开' },
@@ -111,7 +111,7 @@ export const rechargeSchema = {
{ 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', default: '', rows: 2, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '用户使用卡密兑换会员时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
{ 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: '会员卡密仅需输入密码,不需要卡号;默认关闭,会员卡需输入卡号和密码(与其他卡密兑换一致)' },
],
},
+342 -63
View File
@@ -1,27 +1,127 @@
/**
* VIP会员 — 参考主题结构:会员等级、会员权益(普通/一级/二级 tab)、
* 会员购买选项(一级/二级/续费/升级 tab)、卡密兑换与积分兑换。
* 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 = {
key: 'payment.vip',
title: 'VIP会员',
icon: 'fa fas fa-gem',
description: '双级会员:等级名称、权益(下载次数与限速)、套餐、续费/升级与积分兑换。',
description: '双级会员:等级开关、权益(下载次数与限速)、会员商品、续费/升级与积分兑换。',
defaults: {
nav_pay_vip: true,
nav_user_pay_vip: true,
pay_user_vip_1_s: true,
pay_user_vip_1_name: '一级会员',
pay_user_vip_1_desc: '尊享一级会员特权',
pay_user_vip_1_equity: '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>',
pay_user_vip_1_name: '黄金会员',
pay_user_vip_1_desc: '尊享专属特权',
pay_user_vip_1_equity: VIP_EQUITY_DEFAULT,
vip_1_product_s: true,
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: '',
pay_user_vip_2_s: true,
pay_user_vip_2_name: '二级会员',
pay_user_vip_2_desc: '尊享二级会员特权',
pay_user_vip_2_equity: '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>',
pay_user_vip_2_name: '钻石会员',
pay_user_vip_2_desc: '尊享专属特权',
pay_user_vip_2_equity: VIP_EQUITY_DEFAULT,
vip_2_product_s: true,
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: '',
pay_user_vip_desc: '',
pay_user_vip_more: '',
pay_user_vip_desc: '开通VIP会员,享受会员专属折扣以及多项特权',
pay_user_vip_more: '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>',
pay_download_limit: 0,
pay_download_speed: 0,
pay_download_limit_vip_1: 0,
@@ -29,19 +129,45 @@ export const vipSchema = {
pay_download_limit_vip_2: 0,
pay_download_speed_vip_2: 0,
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_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_renew: true,
vip_renew_desc: '',
vip_renew_desc: '立即续费会员,畅享VIP权益',
vip_renew_price_type: 'discount',
vip_renew_discount: 8,
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_desc: '',
vip_upgrade_product: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级',
vip_upgrade_desc: '升级VIP会员,享更多会员权益',
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,
payment_vip_points_packages: '1|500|30|积分月卡\n1|5000|0|积分永久',
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: [
@@ -67,17 +193,16 @@ export const vipSchema = {
{ key: 'nav_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_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: '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> 列表项' },
{ key: 'vip_1_img_icon', label: '一级会员图标 URL', type: 'input', default: '', dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '留空使用内置 vip-1.svg 图标' },
{
key: 'vip_level_order_notice',
label: '',
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_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: '' },
],
},
{
@@ -101,6 +226,8 @@ export const vipSchema = {
{
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则不限制);只对本地服务器文件有效' },
@@ -109,6 +236,8 @@ export const vipSchema = {
{
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则不限制);只对本地服务器文件有效' },
@@ -131,53 +260,111 @@ export const vipSchema = {
{
key: 'vip1',
title: '一级会员',
titleKey: 'pay_user_vip_1_name',
titleFallback: '一级会员',
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=永久',
},
...vipLevelTabFields(1),
],
},
{
key: 'vip2',
title: '二级会员',
titleKey: 'pay_user_vip_2_name',
titleFallback: '二级会员',
fields: [
{ 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=永久',
},
...vipLevelTabFields(2),
],
},
{
key: 'renew',
title: '会员续费',
fields: [
{ key: 'vip_renew', label: '允许续费', type: 'switch', default: true, help: '关闭后则不能续费,只能会员到期之后再购买。永久会员不需要续费' },
{ key: 'vip_renew_desc', label: '续费说明', type: 'textarea', default: '', dependsOn: { key: 'vip_renew', value: true } },
{
key: 'vip_renew',
label: '允许续费',
type: 'switch',
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: 'radio',
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: 'vip_renew_discount', label: '续费折扣(折,如 8=八折)', type: 'number', default: 8, min: 1, max: 100, dependsOn: { key: 'vip_renew_price_type', value: 'discount' } },
{ 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' } },
],
},
{
@@ -185,14 +372,50 @@ export const vipSchema = {
title: '会员升级',
fields: [
{ 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',
label: '升级产品',
type: 'textarea',
default: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级',
label: '升级价格',
type: 'accordion',
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 },
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 },
],
},
],
},
@@ -205,16 +428,72 @@ export const vipSchema = {
title: '积分兑换会员',
icon: 'fa fas fa-database',
fields: [
{ key: 'pay_vip_points_exchange_s', label: '启用积分兑换', type: 'switch', default: false },
{ key: 'pay_vip_points_exchange_s', label: '启用积分兑换', type: 'switch', default: false, help: '开启后用户可使用积分兑换会员;由于积分可免费获取,所以积分兑换会员功能不支持升级及续费' },
{
key: 'payment_vip_points_packages',
label: '兑换套餐',
type: 'textarea',
default: '1|500|30|积分月卡\n1|5000|0|积分永久',
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: '每行:等级|积分|天数|标题(天数 0=永久;仅未开通会员可兑)',
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代码',
},
{ key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_vip_points_exchange_s', value: true } },
],
},
],
@@ -24,7 +24,7 @@ const uploadMimeUserTypeOptions = [
const uploadSizeFields = [
{ key: 'type', label: '用户类型', type: 'select', default: 'logged', options: uploadUserTypeOptions },
{ key: 'val', label: '可上传', type: 'number', default: 30, min: 1, max: 2048 },
{ key: 'val', label: '可上传大小', type: 'number', default: 30, min: 1, max: 2048, unit: 'M' },
]
export const permissionsSchemas = {
@@ -84,6 +84,9 @@ export const permissionsSchemas = {
{ label: '资源下载', value: 'download' },
{ label: '每日签到', value: 'signin' },
{ label: '前台投稿', value: 'front_submit' },
{ label: '打赏用户', value: 'tip_user' },
{ label: '申请售后', value: 'after_sale_apply' },
{ label: '撤销售后', value: 'after_sale_cancel' },
],
},
],
@@ -218,9 +221,8 @@ export const permissionsSchemas = {
key: 'permissions.attachment',
title: '附件配置',
icon: 'fa fas fa-paperclip',
description: '统一管理头像、上传大小、上传格式与上传行为;准入权限请在“基本权限”中配置。',
description: '统一管理上传大小、上传格式与上传行为;准入权限请在“基本权限”中配置。',
defaults: {
avatar_max_size: 2,
upload_img_size: [
{ type: 'default', val: 5 },
{ type: 'admin', val: 20 },
@@ -238,19 +240,8 @@ export const permissionsSchemas = {
{ type: 'admin', pattern: 'allow', val: 'svg=image/svg+xml' },
{ type: 'all', pattern: 'allow', val: '' },
],
newfilename: false,
newfilename_type: 'time',
close_attachment_page: true,
},
groups: [
{
key: 'profile',
title: '用户头像与资料卡图片',
icon: 'fa fas fa-id-card',
fields: [
{ key: 'avatar_max_size', label: '头像/用户资料卡背景图大小上限 (MB)', type: 'number', min: 1, max: 10 },
],
},
{
key: 'size-limits',
title: '上传大小限制',
@@ -263,7 +254,7 @@ export const permissionsSchemas = {
type: 'repeater',
min: 1,
addText: '添加细分用户权限',
help: '投稿、评论等前端场景允许上传的最大图片大小(单位 MB)。同一个用户满足多个条件则取最大值,「最小默认值」为所有人兜底。',
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 则不能批量上传)。' },
@@ -323,65 +314,7 @@ export const permissionsSchemas = {
},
],
},
{
key: 'behavior',
title: '上传行为',
icon: 'fa fas fa-cogs',
fields: [
{ key: 'newfilename', label: '上传文件自动重命名', type: 'switch', help: '开启后上传文件按规则重命名,减少重名文件查询,提高上传效率。' },
{
key: 'newfilename_type',
label: '重命名格式',
type: 'radio',
buttonStyle: false,
default: 'time',
dependsOn: { key: 'newfilename', value: true },
options: [
{ label: '时间 + 原文件名', value: 'time' },
{ label: '随机字符串', value: 'random' },
],
},
{ key: 'close_attachment_page', label: '非管理员屏蔽附件页面', type: 'switch', help: '开启后非管理员访问附件(图片、文件等)页面将直接显示 404,可有效提高文件的安全性。' },
],
},
],
},
/**
* 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',
title: '下载总开关',
icon: 'fa fas fa-power-off',
fields: [
{ key: 'enabled', label: '启用本站下载链接', type: 'switch', help: '总开关:开启后下载通过本站中转链接;关闭后下载按钮直接使用原始下载地址(直连),下方链接规则全部不生效,旧的中转链接同步失效。' },
],
},
{
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',
title: '评论设置',
icon: 'fa fas fa-comment-dots',
description: '配置评论区的排序、分页、权限、工具栏与互动方式。',
description: '配置评论模块的显隐条件、翻页模式、输入工具栏与互动方式。',
defaults: {
comment_order: 'asc',
comment_per_page: 20,
comment_pagination_type: 'numbered',
close_comments: false,
comment_box_hide: '',
comment_paginate_type: 'default',
comment_paging_ajax_ias_s: true,
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_img: true,
comment_upload_img: 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,
user_edit_comment: true,
comment_like_s: true,
comment_user_url_s: false,
comment_corderby: true,
comment_empty_text: '暂无评论,来做第一个评论的人吧~',
comment_title: '评论',
comment_submit_text: '提交评论',
comment_text: '欢迎您留下宝贵的见解!',
},
groups: [
{
key: 'display',
title: '评论展示',
icon: 'fa fas fa-comment',
key: 'comment',
title: '',
fields: [
{ key: 'comment_order', label: '评论排序', type: 'radio', default: 'asc', options: [
{ label: '正序 (旧→新)', value: 'asc' },
{ label: '倒序 (新→旧)', value: 'desc' },
]},
{ key: 'comment_per_page', label: '每页评论数', type: 'number', default: 20, min: 5, max: 100 },
{ key: 'comment_pagination_type', label: '分页方式', type: 'radio', default: 'numbered', options: [
{ label: '翻页', value: 'numbered' },
{ label: '加载更多', value: 'load_more' },
{ label: '无限滚动', value: 'infinite' },
]},
{ key: 'comment_paging_ajax_ias_s', label: '评论自动加载', type: 'switch', default: true, help: '评论无限滚动时自动触发加载' },
{ key: 'comment_paging_ajax_ias_max', label: '评论最大加载页数', type: 'number', default: 3, min: 0, max: 10, help: '0 = 不限' },
{ key: 'comment_nested_depth', label: '回复嵌套深度', type: 'number', default: 3, min: 1, max: 10 },
{ key: 'comment_avatar_size', label: '头像大小 (px)', type: 'number', default: 48, min: 24, max: 96 },
],
},
{
key: 'permissions',
title: '评论设置',
icon: 'fa fas fa-lock',
fields: [
{ key: 'comment_close_comments', label: '关闭评论', type: 'switch', default: false },
{ key: 'comment_edit_window', label: '作者自助编辑时限 (分钟)', type: 'number', default: 10, min: 0, max: 1440 },
],
},
{
key: 'toolbar',
title: '评论工具栏',
icon: 'fa fas fa-wrench',
fields: [
{ key: 'comment_code', label: '代码插入', type: 'switch', default: true },
{ key: 'comment_img', label: '图片按钮', type: 'switch', default: true },
{ key: 'comment_link', label: '链接按钮', type: 'switch', default: false },
{ key: 'comment_upload_img', label: '评论图片上传', type: 'switch', default: false },
],
},
{
key: 'interaction',
title: '互动功能',
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: '欢迎您留下宝贵的见解!' },
{
key: 'comment_discussion_notice',
label: '',
type: 'content',
ui_only: true,
dependsOn: { key: 'close_comments', value: false },
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>',
},
{ key: 'close_comments', label: '关闭文章评论功能', type: 'switch', default: false, description: '部分网站无需交互,或需备案审核,可在此关闭所有文章的评论功能。同时每一篇文章可单独关闭评论功能' },
{
key: 'comment_box_hide',
label: '隐藏评论模块',
type: 'radio',
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: 'comment_paginate_type',
label: '评论列表翻页模式',
type: 'radio',
default: 'default',
dependsOn: { key: 'close_comments', value: false },
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: '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则不限制,直到加载全部评论)' },
{ key: 'comment_code', label: '允许插入代码', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false }, help: '为了防止恶意评论,建议在后台-设置-讨论:开启“用户必须登录后才能发表评论”' },
{ key: 'comment_img', label: '允许插入图片', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false } },
{ key: 'comment_upload_img', label: '允许上传图片', type: 'switch', default: false, dependsOn: { all: [{ key: 'close_comments', value: false }, { key: 'comment_img', value: true }] } },
{ key: 'comment_link', label: '允许插入链接', type: 'switch', default: false },
{ key: 'comment_author_tag', label: '显示“作者”标签', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: false } },
{ key: 'user_edit_comment', label: '允许用户编辑评论', type: 'switch', default: true, dependsOn: { key: 'close_comments', value: 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: 'comment_submit_text', label: '自定义评论提交按钮文案', type: 'textarea', default: '提交评论', rows: 1, dependsOn: { key: 'close_comments', value: false } },
{ key: 'comment_text', label: '自定义评论框占位符文案', type: 'input', default: '欢迎您留下宝贵的见解!', dependsOn: { key: 'close_comments', value: false } },
],
},
],
@@ -2,19 +2,121 @@ export const featureSchema = {
key: 'post_list.feature',
title: '文章功能',
icon: 'fa fas fa-magic',
description: '配置文章页的互动功能开关。',
description: '配置文章目录树、图片灯箱、点赞与新建文章的预置参数。',
defaults: {
feature_pdf_export: false,
feature_print: true,
article_nav: 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: [
{
key: 'tools',
title: '工具功能',
icon: 'fa fas fa-wrench',
key: 'feature',
title: '',
fields: [
{ key: 'feature_print', label: '打印文章', type: 'switch', default: true },
{ key: 'feature_pdf_export', label: 'PDF 导出', type: 'switch', default: false, help: '实验性功能' },
{ key: 'article_nav', label: '文章目录树', type: 'switch', default: true, description: '默认开关,每篇文章可单独设置。开启后请自行添加文章目录树模块到侧边栏(文章内标题超过3个才会显示)' },
{
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 = {
key: 'post_list.filter',
title: '高级筛选',
icon: 'fa fas fa-filter',
description: '为文章列表配置筛选栏、排序方式和分类筛选功能。',
icon: 'fa fas fa-align-left',
description: '通过自定义筛选类型实现更精细化的内容筛选,可按分类绑定不同的筛选项目。',
defaults: {
filter_enabled: false,
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_show: [],
custom_filter: [],
cat_custom_filter: [],
},
groups: [
{
key: 'basic',
title: '筛选基础',
icon: 'fa fas fa-filter',
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_categories', label: '分类筛选', type: 'switch', default: true, dependsOn: { key: 'filter_enabled', value: true } },
{ key: 'filter_tags', label: '标签筛选', type: 'switch', default: false, dependsOn: { key: 'filter_enabled', value: true } },
{ key: 'filter_sort', label: '排序选项', type: 'switch', default: true, dependsOn: { key: 'filter_enabled', value: true } },
{ key: 'filter_ajax_enabled', label: 'AJAX 无刷新筛选', type: 'switch', default: true, help: '启用后分类/标签/排序切换将通过 AJAX 加载,无需刷新页面', dependsOn: { key: 'filter_enabled', value: true } },
],
},
{
key: 'ajax',
title: 'AJAX 筛选维度',
icon: 'fa fas fa-clone',
fields: [
{ key: 'filter_cat_show_cat', label: '分类页:显示分类筛选', type: 'switch', default: true },
{ key: 'filter_cat_show_tag', label: '分类页:显示标签筛选', type: 'switch', default: false },
{ key: 'filter_tag_show_cat', label: '标签页:显示分类筛选', type: 'switch', default: true },
{ key: 'filter_tag_show_tag', label: '标签页:显示标签筛选', type: 'switch', default: false },
],
},
{
key: 'custom_filter',
title: '自定义筛选维度',
icon: 'fa fas fa-sliders-h',
key: 'advanced_filter',
title: '高级筛选',
icon: 'fa fas fa-align-left',
fields: [
{
key: 'filter_intro',
type: 'content',
ui_only: 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: 'custom_filter_show',
label: '高级筛选默认显示',
type: 'checkbox',
default: [],
options: [
{ label: '分类页面显示', value: 'cat' },
{ label: '标签页面显示', value: 'tag' },
],
description: '勾选后将会在对应页面默认显示全部筛选,您可以在下方为单独的分类添加自定义筛选',
},
{
key: 'custom_filter',
label: '筛选维度定义',
label: '自定义高级筛选明细',
type: 'repeater',
default: [],
addText: '添加筛选维度',
help: '每项定义一个筛选维度(如"类型""地区""年份"),及其可选值列表',
addText: '添加筛选类别',
description: '添加筛选类型及明细',
fields: [
{ key: 'name', label: '维度名称', type: 'input', default: '', placeholder: '如:类型' },
{ key: 'key', label: '维度标识', type: 'input', default: '', placeholder: '如:type(英文下划线)' },
{ key: 'name', label: '类型名称', type: 'input', default: '' },
{ key: 'key', label: '类型key', type: 'input', default: '', description: '必填,只能使用英文加下划线,不能有空格!尽量复杂一点,且配置唯一前缀,以避免与其他mate_key重复,例如(axb_type,axb_year)' },
{
key: 'vals',
label: '可选值',
label: '可选择的值',
type: 'repeater',
default: [],
addText: '添加可选值',
addText: '添加值',
fields: [
{ key: 'key', label: '标识', type: 'input', default: '', placeholder: '如:action' },
{ key: 'name', label: '显示名', type: 'input', default: '', placeholder: '如:动作' },
{ key: 'key', label: '值', type: 'input', default: '' },
{ key: 'name', label: '显示名称', type: 'input', default: '' },
],
},
],
},
],
},
{
key: 'cat_custom_filter',
title: '按分类绑定筛选',
icon: 'fa fas fa-object-group',
fields: [
{
key: 'cat_custom_filter',
label: '分类筛选绑定',
label: '自定义分类的高级筛选',
type: 'repeater',
default: [],
addText: '添加绑定规则',
help: '为指定分类绑定不同的筛选维度组合,实现分类级别的差异化筛选',
addText: '添加自定义分类筛选',
description: '在此处为不同的分类添加不同的筛选项目,实现更加精细化的筛选。同时你也可以在页面显示/分类页面设置分类的默认筛选方式',
fields: [
{ key: 'cats', label: '绑定分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [] },
{ key: 'cat_s', label: '显示分类筛选', type: 'switch', default: false },
{ key: 'tag_s', label: '显示标签筛选', type: 'switch', default: false },
{ key: 'orderby_s', label: '显示排序按钮', type: 'switch', default: true },
{ key: 'cats', label: '绑定分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [], description: '支持添加多个分类' },
{ key: 'cat_s', label: '分类筛选', type: 'switch', default: false, description: '在所选分类页显示的分类筛选列表' },
{
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',
label: '启用的自定义维度',
type: 'input',
default: '',
placeholder: '逗号分隔维度标识,如:type,region,year',
help: '留空表示使用全部已定义的筛选维度',
label: '自定义高级筛选',
type: 'checkbox',
default: [],
options: 'custom_filter',
description: '在所选分类页显示的自定义高级筛选项目。如果此处没有选项,或者您修改了上方的文章高级筛选明细,请先保存主题配置并刷新页面后再设置此处',
},
],
},
@@ -8,7 +8,6 @@ import { pageSchema } from './page.js'
import { filterSchema } from './filter.js'
import { featureSchema } from './feature.js'
import { paySchema } from './pay.js'
import { shareSchema } from './share.js'
import { commentSchema } from './comment.js'
import { submitSchema } from './submit.js'
@@ -19,7 +18,6 @@ export const postListSchemas = {
filter: filterSchema,
feature: featureSchema,
pay: paySchema,
share: shareSchema,
comment: commentSchema,
submit: submitSchema,
}
+172 -160
View File
@@ -1,180 +1,192 @@
export const pageSchema = {
key: 'post_list.page',
title: '文章页面',
icon: 'fa fas fa-file-alt',
description: '配置单篇文章页面的布局、图片灯箱、版权区和互动功能。',
icon: 'fa fas fa-bookmark',
description: '配置文章页面封面、面包屑导航、版权声明、插入内容与相关文章版块。',
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_home_text: '首页',
breadcrumbs_home_text: true,
post_prevnext_s: true,
post_authordesc_s: true,
post_single_hide_time_s: false,
post_lightbox: true,
post_lightbox_thumbs: true,
post_lightbox_zoom: true,
post_lightbox_full: true,
post_lightbox_down: false,
post_lightbox_play: false,
article_maxheight_kg: false,
article_maxheight: 1000,
yiyan_single_content_header: false,
yiyan_single_content_footer: false,
yiyan_single_box: false,
post_copyright_s: true,
post_copyright: '文章版权归作者所有,未经允许请勿转载。',
post_button_toptext: '喜欢就支持一下吧',
post_front_content: '',
post_after_content: '',
post_related_s: true,
post_related_type: 'img',
related_title: '相关推荐',
post_related_n: 6,
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: [
{
key: 'navigation',
title: '文章导航',
icon: 'fa fas fa-compass',
key: 'post_page',
title: '文章页面',
icon: 'fa fas fa-bookmark',
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_home_text', label: '面包屑首页文字', type: 'input', default: '首页', 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_authordesc_s', label: '作者简介', type: 'switch', default: true },
{ key: 'post_author_card_limit', label: '作者卡文章数', type: 'number', default: 6, min: 0, max: 12, dependsOn: { key: 'post_authordesc_s', value: true } },
{ key: 'post_author_card_orderby', label: '作者卡文章排序', type: 'select', default: 'views', options: [
{ label: '浏览量', value: 'views' },
{ label: '发布时间', value: 'date' },
{ label: '更新时间', value: 'modified' },
{ label: '评论数', value: 'comment_count' },
], dependsOn: { key: 'post_authordesc_s', value: true } },
{ key: 'post_author_card_style', label: '作者卡文章样式', type: 'radio', default: 'card', options: [
{ label: '卡片', value: 'card' },
{ label: '列表', value: 'list' },
], dependsOn: { key: 'post_authordesc_s', value: true } },
{ key: 'post_single_hide_time_s', label: '隐藏文章时间', type: 'switch', default: false },
],
},
{
key: 'cover',
title: '封面展示',
icon: 'fa fas fa-image',
fields: [
{ key: 'post_article_image_cover', label: '图片封面', type: 'switch', default: true, help: '文章有特色图时自动展示封面' },
{ key: 'post_article_image_cover_scale', label: '封面高度比例(%)', type: 'number', default: 35, min: 10, max: 100, dependsOn: { key: 'post_article_image_cover', value: true } },
{ 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: 'date' },
{ label: '浏览量', value: 'views' },
{ label: '点赞量', value: 'like' },
{ label: '评论量', value: 'comment_count' },
{ label: '收藏数量', value: 'favorite' },
{ 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 },
{ key: 'breadcrumbs_home_text', label: '面包屑导航用“首页”替代网站名称', type: 'switch', default: true, dependsOn: { key: 'breadcrumbs_single_s', value: true } },
{ key: 'post_prevnext_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: 'article_maxheight', label: '限制的最大高度', type: 'slider', default: 1000, min: 600, max: 3000, step: 100, unit: 'px', dependsOn: { key: 'article_maxheight_kg', value: true }, description: '开启后如果文章高度超过设定值则会显示展开阅读全文的按钮。每篇文章可单独开启此功能' },
{
key: 'yiyan_intro',
type: 'content',
ui_only: true,
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>',
},
{ key: 'yiyan_single_content_header', label: '文章内容头部显示一言', type: 'switch', default: false },
{ key: 'yiyan_single_content_footer', label: '文章内容尾部显示一言', type: 'switch', default: false },
{ key: 'yiyan_single_box', label: '文章页面下方独立一言版块', type: 'switch', default: false },
{ key: 'post_copyright_s', label: '版权声明', type: 'switch', default: true },
{ 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: 'post_related_orderby',
label: '排序方式',
type: 'select',
default: 'views',
dependsOn: { key: 'post_related_s', value: true },
options: [
{ label: '更新时间', value: 'modified' },
{ label: '发布时间', value: 'date' },
{ label: '浏览量', value: 'views' },
{ label: '点赞量', value: 'like' },
{ label: '评论量', value: 'comment_count' },
{ label: '收藏数量', value: 'favorite' },
{ label: '随机', value: 'rand' },
],
},
],
},
],
@@ -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',
title: '前台投稿',
icon: 'fa fas fa-paper-plane',
description: '配置前台投稿表单字段、审核流程、附件权限和封面策略。',
description: '配置前台投稿的开关、可选分类、免登录发布用户、标题与付费限制。',
defaults: {
front_submit_enabled: 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_s: true,
post_article_btn_txte: '发布文章',
post_article_title_strlen_limit_min: 5,
post_article_title_strlen_limit_max: 30,
post_article_user: 0,
post_article_cat: [],
post_article_custom_filter_s: true,
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_price_min: '0.00',
post_article_pay_price_max: '100.00',
post_article_pay_price_limit: { price: { min: 0, max: 99999 }, points: { min: 0, max: 0 } },
post_article_pay_vip_price_s: true,
post_article_pay_vip_1_discount: 80,
post_article_pay_vip_2_discount: 50,
post_article_pay_vip_1_discount: 100,
post_article_pay_vip_2_discount: 100,
post_t_placeholder: '请输入标题',
post_c_placeholder: '请输入内容',
},
groups: [
{
key: 'basic',
title: '基础配置',
icon: 'fa fas fa-cog',
fields: [
{ key: 'front_submit_enabled', label: '开启前台投稿', type: 'switch', default: true, help: '允许用户在前台提交文章' },
{ 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',
title: '表单字段',
icon: 'fa fas fa-list-alt',
fields: [
{ key: 'front_submit_title', label: '标题', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'front_submit_content', label: '正文', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
{ 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: 'review',
title: '审核策略',
icon: 'fa fas fa-tasks',
key: 'submit',
title: '',
fields: [
{ key: 'post_article_s', label: '启用前台投稿', type: 'switch', default: true },
{
key: 'front_submit_review_mode',
label: '审核模式',
type: 'radio',
default: 'pending',
options: [
{ label: '待审核', value: 'pending' },
{ label: '直接发布', value: 'publish' },
{ label: '自动草稿', value: 'draft' },
],
dependsOn: { key: 'front_submit_enabled', value: true },
key: 'submit_perm_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#/permissions/capabilities">权限管理</a>中设置用户的发布、审核权限 <a href="admin.php?page=aksrt-settings#/permissions/capabilities">【去设置】</a></p>',
},
{ 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',
title: '编辑器设置',
icon: 'fa fas fa-edit',
fields: [
{ 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: 'front_submit_editor_mode',
label: '编辑器模式',
type: 'radio',
default: 'rich',
options: [
{ label: '富文本', value: 'rich' },
{ label: 'Markdown', value: 'markdown' },
{ label: '纯文本', value: 'plain' },
],
dependsOn: { key: 'front_submit_content', 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: 'front_submit_draft_save', label: '自动保存草稿', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
{ key: 'front_submit_leave_warning', label: '离开页面提醒', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } },
],
},
{
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 } },
{ 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: [
{ key: 'price', label: '现金金额限制', type: 'between_number', default: { min: 0, max: 99999 } },
{ key: 'points', label: '积分金额限制', type: 'between_number', default: { min: 0, max: 0 } },
],
},
{ key: 'post_article_pay_vip_price_s', label: '付费内容允许设置会员价', type: 'switch', default: true, dependsOn: { key: 'post_article_s', value: true }, description: '前台投稿时对拥有设置付费内容权限的用户,是否开启设置会员价格。开启此项,会直接在前台显示设置会员价的选项;关闭此项,则用户只能设置普通价格,会员价则按照下方设置的折扣自动计算' },
{
key: 'post_article_pay_vip_1_discount',
label: '一级会员折扣',
type: 'number',
default: 100,
min: 0,
max: 100,
unit: '%',
dependsOn: { all: [{ key: 'post_article_s', value: true }, { key: 'post_article_pay_vip_price_s', value: false }] },
},
{
key: 'post_article_pay_vip_2_discount',
label: '二级会员折扣',
type: 'number',
default: 100,
min: 0,
max: 100,
unit: '%',
dependsOn: { all: [{ key: 'post_article_s', value: true }, { key: 'post_article_pay_vip_price_s', value: false }] },
description: '执行价的百分之多少,0为免费,100为没有折扣,不能高于100',
},
{ key: 'post_t_placeholder', label: '投稿标题占位符', type: 'input', default: '请输入标题' },
{ key: 'post_c_placeholder', label: '投稿内容占位符', type: 'input', default: '请输入内容' },
],
},
],
@@ -14,6 +14,12 @@ export const featuresSchema = {
user_cover_upload_s: true,
user_cover_img: `${themeRoot}/assets/img/user_t.jpg`,
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: [
{
@@ -45,5 +51,18 @@ export const featuresSchema = {
{ 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 { levelSchema } from './level.js'
import { badgeSchema } from './badge.js'
import { wechatSchema } from './wechat.js'
import { reportSchema } from './report.js'
import { smsSchema } from './sms.js'
@@ -23,7 +22,6 @@ export const usersSchemas = {
verify: verifySchema,
level: levelSchema,
badge: badgeSchema,
wechat: wechatSchema,
report: reportSchema,
sms: smsSchema,
}
+63 -102
View File
@@ -2,143 +2,104 @@ export const smsSchema = {
key: 'users.sms',
title: '短信接口',
icon: 'fa fas fa-comment',
description: '配置短信验证码和短信通知服务的供应商、模板和限流策略。',
description: '设置手机验证码服务。验证码为 6 位数字,有效期 30 分钟。',
defaults: {
sms_enabled: false,
sms_provider: 'aliyun',
sms_sdk: 'null',
sms_number_limit_ch: true,
sms_access_key: '',
sms_access_secret: '',
sms_sign_name: '',
sms_template_code: '',
sms_code_length: 6,
sms_code_expire: 300,
sms_rate_limit: 60,
sms_daily_limit: 10,
sms_notify_bind: true,
sms_notify_login: false,
sms_notify_register: true,
sms_notify_reset_password: true,
sms_ali_api_type: 'sms',
sms_ali_keyid: '',
sms_ali_keysecret: '',
sms_ali_sign_name: '',
sms_ali_template_code: '',
sms_region_id: 'cn-hangzhou',
sms_tencent_app_id: '',
sms_tencent_app_key: '',
sms_tencent_secret_id: '',
sms_tencent_secret_key: '',
sms_tencent_region: 'ap-guangzhou',
sms_huawei_sender: '',
sms_huawei_signature: '',
sms_huawei_endpoint: '',
sms_custom_url: '',
sms_custom_method: 'POST',
sms_custom_auth_type: 'header',
sms_custom_auth_key: '',
sms_custom_auth_value: '',
sms_custom_headers: '',
sms_custom_body: '',
sms_custom_success_rule: '',
sms_tencent_sign_name: '',
sms_tencent_template_id: '',
sms_smsbao_username: '',
sms_smsbao_password: '',
sms_smsbao_api_key: '',
sms_smsbao_template: '',
},
groups: [
{
key: 'provider',
title: '供应商配置',
title: '短信接口',
icon: 'fa fas fa-plug',
fields: [
{ key: 'sms_enabled', label: '启用短信服务', type: 'switch', default: false, help: '开启后支持短信验证码和短信通知' },
{
key: 'sms_provider',
label: '短信供应商',
key: 'sms_sdk',
label: '设置短信接口',
type: 'select',
default: 'aliyun',
default: 'null',
options: [
{ label: '阿里云短信', value: 'aliyun' },
{ label: '不使用', value: 'null' },
{ label: '阿里云短信', value: 'ali' },
{ label: '腾讯云短信', value: 'tencent' },
{ label: '华为云短信', value: 'huawei' },
{ label: '自定义接口', value: 'custom' },
{ label: '短信宝', value: 'smsbao' },
],
dependsOn: { key: 'sms_enabled', value: true },
},
{ key: 'sms_number_limit_ch', label: '仅发国内', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true }, help: '开启后仅支持发送国内短信,关闭后可发送港澳台及国际短信' },
{ key: 'sms_access_key', label: 'AccessKey / SecretId / AppKey', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '阿里云填 AccessKey,腾讯云填 SecretId,华为云填 AppKey' },
{ key: 'sms_access_secret', label: 'AccessSecret / SecretKey / AppSecret', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_enabled', value: true } },
{
key: 'sms_number_limit_ch',
label: '限制国内手机号',
type: 'switch',
default: true,
help: '开启后只允许发送到中国大陆手机号。',
},
{ key: 'sms_test_phone', label: '测试手机号', type: 'input', default: '', ui_only: true, help: '保存接口配置后,发送固定验证码 888888 测试短信。' },
{ key: 'sms_test_send', label: '发送测试短信', type: 'button', buttonType: 'primary', buttonText: '发送测试短信', ui_only: true },
],
},
{
key: 'aliyun',
title: '阿里云配置',
title: '阿里云',
icon: 'fa fas fa-cloud',
collapsible: true,
defaultCollapsed: true,
fields: [
{ key: 'sms_ali_api_type', label: '接口类型', type: 'radio', default: 'sms', options: [{ label: '短信服务', value: 'sms' }, { label: '号码认证', value: 'verify' }], dependsOn: { key: 'sms_provider', value: 'aliyun' } },
{ key: 'sms_region_id', label: 'RegionId', type: 'input', default: 'cn-hangzhou', dependsOn: { key: 'sms_provider', value: 'aliyun' } },
{
key: 'sms_ali_api_type',
label: '产品',
type: 'radio',
default: 'sms',
options: [
{ label: '短信服务', value: 'sms' },
{ label: '号码认证:短信认证服务', value: 'verify' },
],
dependsOn: { key: 'sms_sdk', value: 'ali' },
},
{ key: 'sms_ali_keyid', label: 'AccessKey Id', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } },
{ key: 'sms_ali_keysecret', label: 'AccessKey Secret', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } },
{ key: 'sms_ali_sign_name', label: '签名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } },
{ key: 'sms_ali_template_code', label: '模板 CODE', type: 'input', default: '', dependsOn: { all: [{ key: 'sms_sdk', value: 'ali' }, { key: 'sms_ali_api_type', not: true, value: 'verify' }] }, help: '短信服务需要填写。号码认证产品固定使用平台模板。' },
{ key: 'sms_region_id', label: 'RegionId', type: 'input', default: 'cn-hangzhou', dependsOn: { key: 'sms_sdk', value: 'ali' } },
],
},
{
key: 'tencent',
title: '腾讯云配置',
title: '腾讯云',
icon: 'fa fas fa-cloud',
collapsible: true,
defaultCollapsed: true,
fields: [
{ key: 'sms_tencent_app_id', label: 'SdkAppId', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' }, help: '腾讯云短信应用 ID' },
{ key: 'sms_tencent_app_key', label: 'AppKey', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } },
{ key: 'sms_tencent_secret_id', label: 'SecretId', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } },
{ key: 'sms_tencent_secret_key', label: 'SecretKey', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } },
{ key: 'sms_tencent_region', label: '地域', type: 'input', default: 'ap-guangzhou', dependsOn: { key: 'sms_provider', value: 'tencent' } },
{ key: 'sms_tencent_app_id', label: 'SDK AppID', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } },
{ key: 'sms_tencent_app_key', label: 'App Key', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } },
{ key: 'sms_tencent_sign_name', label: '签名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } },
{ key: 'sms_tencent_template_id', label: '模板 ID', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' }, help: '模板包含验证码和有效分钟数两个参数。' },
],
},
{
key: 'huawei',
title: '华为云配置',
key: 'smsbao',
title: '短信宝',
icon: 'fa fas fa-cloud',
collapsible: true,
defaultCollapsed: true,
fields: [
{ key: 'sms_huawei_sender', label: '通道号 (sender)', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' } },
{ key: 'sms_huawei_signature', label: '短信签名', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' }, help: '华为云短信签名内容' },
{ key: 'sms_huawei_endpoint', label: 'API Endpoint', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' }, help: '留空使用默认 endpoint' },
],
},
{
key: 'custom',
title: '自定义接口配置',
icon: 'fa fas fa-plug',
fields: [
{ key: 'sms_custom_url', label: '请求 URL', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '支持 {phone} {code} {time} 变量' },
{ key: 'sms_custom_method', label: '请求方法', type: 'select', default: 'POST', options: [{ label: 'POST', value: 'POST' }, { label: 'GET', value: 'GET' }, { label: 'PUT', value: 'PUT' }], dependsOn: { key: 'sms_provider', value: 'custom' } },
{ key: 'sms_custom_auth_type', label: '鉴权方式', type: 'select', default: 'header', options: [{ label: 'Header', value: 'header' }], dependsOn: { key: 'sms_provider', value: 'custom' } },
{ key: 'sms_custom_auth_key', label: '鉴权 Header 名', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' } },
{ key: 'sms_custom_auth_value', label: '鉴权 Header 值', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '支持 {phone} {code} {time} 变量' },
{ key: 'sms_custom_headers', label: '自定义 Headers', type: 'textarea', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '每行一个,格式:Key: Value' },
{ key: 'sms_custom_body', label: '请求 Body', type: 'textarea', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: 'JSON 格式,支持 {phone} {code} {time} 变量' },
{ key: 'sms_custom_success_rule', label: '成功判定规则', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '格式:status:200 或 json:code=0 或 contains:success' },
],
},
{
key: 'template',
title: '短信模板',
icon: 'fa fas fa-comment',
fields: [
{ key: 'sms_sign_name', label: '短信签名', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '在短信服务商申请的短信签名(所有供应商共用此字段)' },
{ key: 'sms_template_code', label: '验证码模板 ID', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '所有供应商共用此模板 ID' },
{ key: 'sms_code_length', label: '验证码长度', type: 'number', default: 6, min: 4, max: 8, dependsOn: { key: 'sms_enabled', value: true } },
{ key: 'sms_code_expire', label: '验证码有效期 (秒)', type: 'number', default: 300, min: 60, max: 600, dependsOn: { key: 'sms_enabled', value: true } },
],
},
{
key: 'rate_limit',
title: '限流策略',
icon: 'fa fas fa-shield-alt',
fields: [
{ key: 'sms_rate_limit', label: '发送间隔 (秒)', type: 'number', default: 60, min: 10, max: 300, dependsOn: { key: 'sms_enabled', value: true }, help: '同一手机号两次发送的最小间隔' },
{ key: 'sms_daily_limit', label: '每日发送上限', type: 'number', default: 10, min: 1, max: 100, dependsOn: { key: 'sms_enabled', value: true }, help: '同一手机号每天最多发送条数' },
],
},
{
key: 'triggers',
title: '通知场景',
icon: 'fa fas fa-bell',
fields: [
{ key: 'sms_notify_bind', label: '绑定手机时验证', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } },
{ key: 'sms_notify_login', label: '短信登录', type: 'switch', default: false, dependsOn: { key: 'sms_enabled', value: true } },
{ key: 'sms_notify_register', label: '注册验证', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } },
{ key: 'sms_notify_reset_password', label: '找回密码', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } },
{ key: 'sms_smsbao_username', label: '用户名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' } },
{ key: 'sms_smsbao_password', label: '密码', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' } },
{ key: 'sms_smsbao_api_key', label: 'ApiKey', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' }, help: '可选;填写后以 ApiKey 认证,可与密码任选其一。' },
{ key: 'sms_smsbao_template', label: '模板内容', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' }, help: '必须包含 {code},有效时间变量为 {time}。' },
],
},
],
}
}
@@ -2,83 +2,52 @@ export const verifySchema = {
key: 'users.verify',
title: '身份认证',
icon: 'fa fas fa-id-card',
description: '配置公开认证徽标、申请类型与申请门槛;审核、驳回和取消认证请前往 WordPress 用户 → 身份认证。',
description: '设置通用身份认证的申请条件;用户提交资料后由管理员审核。',
defaults: {
user_verify_enabled: true,
user_verify_public_display: true,
user_verify_personal_badge_text: '个人认证',
user_verify_personal_fee: '0.00',
user_verify_personal_free_applications: 0,
user_verify_personal_require_real_name: false,
user_verify_enterprise_badge_text: '企业认证',
user_verify_enterprise_fee: '0.00',
user_verify_enterprise_free_applications: 0,
user_verify_enterprise_require_real_name: false,
user_verify_allow_apply: true,
user_verify_disable_desc:
'由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
user_verify_desc:
'认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。',
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: '请填写认证身份、简介和申请说明,提交后由管理员审核。',
},
groups: [
{
key: 'display',
title: '公开展示',
icon: 'fa fas fa-eye',
fields: [
{ key: 'user_verify_enabled', label: '启用身份认证', type: 'switch', default: true },
{
key: 'user_verify_public_display',
label: '公开页显示认证徽标',
type: 'switch',
default: true,
dependsOn: { key: 'user_verify_enabled', value: true },
},
{
key: 'user_verify_personal_badge_text', label: '个人认证徽标文案', type: 'input', default: '个人认证',
dependsOn: { key: 'user_verify_public_display', value: true },
},
{
key: 'user_verify_enterprise_badge_text', label: '企业认证徽标文案', type: 'input', default: '企业认证',
dependsOn: { key: 'user_verify_public_display', value: true },
},
],
},
{
key: 'workflow', title: '认证方式', icon: 'fa fas fa-tasks',
fields: [{ type: 'alert', content: '认证方式固定为个人认证和企业认证,申请人不可自行新增或修改类型。' }],
},
{
key: 'apply',
title: '申请设置',
title: '认证申请',
icon: 'fa fas fa-edit',
fields: [
{ key: 'user_auth_s', label: '启用身份认证', type: 'switch', default: true },
{
key: 'user_verify_allow_apply',
key: 'user_auth_apply_s',
label: '允许用户前台申请',
type: 'switch',
default: true,
dependsOn: { key: 'user_verify_enabled', value: true },
dependsOn: { key: 'user_auth_s', value: true },
},
{
key: 'user_verify_disable_desc',
key: 'user_auth_apply_disable_desc',
label: '停用申请说明',
type: 'textarea',
default: '由于系统正在维护,暂时无法申请身份认证\n详情请联系管理员',
dependsOn: { key: 'user_verify_allow_apply', value: false },
dependsOn: { key: 'user_auth_apply_s', value: false },
},
{ key: 'user_verify_personal_fee', label: '个人认证每次扣余额(元)', type: 'input', default: '0.00', help: '仅接受最多两位小数的十进制字符串。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
{ key: 'user_verify_personal_free_applications', label: '个人认证赠送初始次数', type: 'number', default: 0, min: 0, max: 9999, help: '每位用户前 N 次个人认证申请免费,之后按单次费用扣余额。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
{ key: 'user_verify_personal_require_real_name', label: '个人认证强制填写真实姓名', type: 'switch', default: false, dependsOn: { key: 'user_verify_allow_apply', value: true } },
{ key: 'user_verify_enterprise_fee', label: '企业认证每次扣余额(元)', type: 'input', default: '0.00', help: '仅接受最多两位小数的十进制字符串。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
{ key: 'user_verify_enterprise_free_applications', label: '企业认证赠送初始次数', type: 'number', default: 0, min: 0, max: 9999, help: '每位用户前 N 次企业认证申请免费,之后按单次费用扣余额。', dependsOn: { key: 'user_verify_allow_apply', value: true } },
{ key: 'user_verify_enterprise_require_real_name', label: '企业认证强制填写真实姓名', type: 'switch', default: false, dependsOn: { key: 'user_verify_allow_apply', value: true } },
{
key: 'user_verify_desc',
key: 'user_auth_apply_limit_level',
label: '申请最低用户等级',
type: 'number',
default: 0,
min: 0,
max: 30,
help: '设置为 0 时不限制用户等级。',
dependsOn: { key: 'user_auth_apply_s', value: true },
},
{
key: 'user_auth_apply_desc',
label: '申请介绍',
type: 'textarea',
default: '认证用户会在昵称后显示认证徽章,同时享受多种优先待遇。申请请确保信息属实。',
dependsOn: { key: 'user_verify_allow_apply', value: true },
sanitize_type: 'html',
default: '请填写认证身份、简介和申请说明,提交后由管理员审核。',
dependsOn: { key: 'user_auth_apply_s', value: true },
},
],
},
@@ -1,51 +0,0 @@
export const wechatSchema = {
key: 'users.wechat',
title: '微信通知',
icon: 'fa fab fa-weixin',
description: '配置微信模板消息、订阅/解绑和站外通知策略。',
defaults: {
wechat_notify_enabled: false,
wechat_app_id: '',
wechat_app_secret: '',
wechat_template_id: '',
wechat_token: '',
wechat_encoding_aes_key: '',
wechat_notify_comment: true,
wechat_notify_reply: true,
wechat_notify_system: true,
wechat_bind_entry: true,
},
groups: [
{
key: 'account',
title: '公众号配置',
icon: 'fa fab fa-weixin',
fields: [
{ key: 'wechat_notify_enabled', label: '启用微信通知', type: 'switch', default: false, help: '开启后用户可绑定微信接收通知' },
{ key: 'wechat_app_id', label: 'AppID', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true } },
{ key: 'wechat_app_secret', label: 'AppSecret', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true } },
{ key: 'wechat_template_id', label: '模板消息 ID', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true }, help: '在微信公众平台申请的模板消息 ID' },
],
},
{
key: 'verify',
title: '服务器验证',
icon: 'fa fas fa-check-circle',
fields: [
{ key: 'wechat_token', label: 'Token', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true }, help: '微信公众平台服务器配置中的 Token' },
{ key: 'wechat_encoding_aes_key', label: 'EncodingAESKey', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true } },
],
},
{
key: 'triggers',
title: '通知触发器',
icon: 'fa fas fa-bell',
fields: [
{ key: 'wechat_notify_comment', label: '新评论通知', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true } },
{ key: 'wechat_notify_reply', label: '评论回复通知', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true } },
{ key: 'wechat_notify_system', label: '系统通知', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true } },
{ key: 'wechat_bind_entry', label: '显示绑定入口', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true }, help: '在用户中心显示微信绑定/解绑按钮' },
],
},
],
}
+8 -98
View File
@@ -668,95 +668,6 @@ code {
/**---------------------通知结束-------------**/
/**模态框------*/
.aksrt-modal-backdrop,
.aksrt-modal {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 100050;
opacity: 0;
transition: 0.3s;
}
.aksrt-modal {
display: none;
}
.aksrt-modal-backdrop {
background: rgba(0, 0, 0, 0.4);
z-index: 0;
}
.aksrt-modal.in {
opacity: 1;
overflow-x: hidden;
overflow-y: auto;
}
.aksrt-modal.in .aksrt-modal-backdrop {
opacity: 1;
}
.aksrt-modal-dialog {
max-width: calc(100% - 20px);
margin: 0;
transform: scale(0.9);
transition: transform 0.5s cubic-bezier(0.32, 0.85, 0.45, 1.18), width 0.3s;
}
.aksrt-modal-dialog.modal-mini {
width: 400px;
}
.aksrt-modal.in .aksrt-modal-dialog {
transform: scale(1);
position: relative;
}
.aksrt-modal .close.abs-close {
display: none;
}
.aksrt-modal .border-title {
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.aksrt-modal-content {
max-height: calc(100vh - 20px);
overflow: hidden;
overflow-y: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
border-radius: 6px;
background: #fff;
border: none;
}
.aksrt-modal-body {
padding: 15px;
}
.aksrt-modal .loading-mask {
z-index: 1;
background: #fff;
border-radius: 6px;
}
.aksrt-modal .hide-btn {
position: absolute;
right: 10px;
top: 6px;
font-size: 25px;
opacity: 0.6;
cursor: pointer;
z-index: 9;
}
/**---------批量编辑------------**/
.aksrt-bulk-edit-box {
margin: -1px;
@@ -774,19 +685,18 @@ code {
margin-top: 6px;
}
/**---------------TAB-栏目------------**/
.aksrt-tab-pane {
display: none;
opacity: 0;
transition: 0.15s;
.aksrt-bulk-edit-box select,
.aksrt-bulk-edit-box input[type="text"] {
max-width: 100%;
margin-right: 4px;
}
.aksrt-tab-pane.active {
display: block;
.inline-edit-row fieldset .aksrt-bulk-edit-box {
min-width: 168px;
}
.aksrt-tab-pane.in {
opacity: 1;
.aksrt-bulk-edit-desc {
color: #787c82;
}
/**---------------后台设置项目-修改或隐藏-----------**/
+190 -5
View File
@@ -63,8 +63,7 @@
.aksrt-pay-meta__inline,
.aksrt-pay-meta__number,
.aksrt-pay-meta__upload,
.aksrt-pay-meta__buttons,
.aksrt-pay-meta__group--inline {
.aksrt-pay-meta__buttons {
display: flex;
align-items: center;
gap: 7px;
@@ -120,20 +119,202 @@
display: grid;
gap: 7px;
margin: 0 0 10px;
padding: 10px;
padding: 0;
overflow: hidden;
border: 1px solid #e3e5e8;
border-radius: 4px;
background: #f8f9fa;
}
.aksrt-pay-meta__group--inline {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
display: flex;
align-items: center;
gap: 7px;
margin: 0 0 10px;
padding: 10px;
border: 1px solid #e3e5e8;
border-radius: 4px;
background: #f8f9fa;
}
.aksrt-pay-meta__group > .button-link-delete {
.aksrt-pay-meta__group-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
margin: 0;
background: #fff;
border-bottom: 1px solid #e3e5e8;
}
.aksrt-pay-meta__group.is-open .aksrt-pay-meta__group-head {
border-bottom-color: #e3e5e8;
}
.aksrt-pay-meta__group-drag {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: #8c8f94;
cursor: move;
border-radius: 3px;
}
.aksrt-pay-meta__group-drag:hover {
color: #1d2327;
background: #f0f0f1;
}
.aksrt-pay-meta__group-drag .dashicons {
font-size: 18px;
width: 18px;
height: 18px;
}
.aksrt-pay-meta__group-toggle {
display: flex;
flex: 1;
align-items: center;
gap: 6px;
min-width: 0;
padding: 0;
font-size: 13px;
font-weight: 600;
color: #1d2327;
text-align: left;
background: none;
border: 0;
box-shadow: none;
cursor: pointer;
}
.aksrt-pay-meta__group-toggle:focus {
outline: 1px solid #2271b1;
outline-offset: 2px;
}
.aksrt-pay-meta__group-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aksrt-pay-meta__group-caret {
flex: 0 0 auto;
color: #8c8f94;
font-size: 16px;
width: 16px;
height: 16px;
transition: transform 0.15s ease;
}
.aksrt-pay-meta__group.is-open .aksrt-pay-meta__group-caret {
transform: rotate(180deg);
}
.aksrt-pay-meta__group-body {
display: none;
gap: 10px;
padding: 12px 10px;
}
.aksrt-pay-meta__group.is-open .aksrt-pay-meta__group-body {
display: grid;
}
.aksrt-pay-meta__group-body > .button-link-delete {
justify-self: start;
}
.aksrt-pay-meta__group-body > .description {
margin: -4px 0 0;
color: #777;
font-size: 12px;
line-height: 1.55;
}
.aksrt-pay-meta__group-field {
display: grid;
gap: 4px;
}
.aksrt-pay-meta__group-field > label {
color: #1d2327;
font-size: 12px;
font-weight: 600;
}
.aksrt-pay-meta__group-field--split {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
}
.aksrt-pay-meta__group-field--split > div {
display: grid;
gap: 4px;
}
.aksrt-pay-meta__group-field input[type="text"],
.aksrt-pay-meta__group-field input[type="url"],
.aksrt-pay-meta__group-field textarea {
width: 100%;
}
.aksrt-pay-meta__group-field .aksrt-icon-picker {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.aksrt-pay-meta__group-field .aksrt-icon-picker__preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
font-size: 16px;
color: #1d2327;
background: #fff;
border: 1px solid #dcdcde;
border-radius: 3px;
}
.aksrt-pay-meta__group-field .aksrt-icon-picker__value {
flex: 1;
min-width: 160px;
}
.aksrt-pay-meta__class-swatches {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.aksrt-pay-meta__class-swatch {
width: 26px;
height: 22px;
padding: 0;
border: 2px solid #fff;
border-radius: 3px;
box-shadow: 0 0 0 1px #dcdcde;
cursor: pointer;
}
.aksrt-pay-meta__class-swatch:hover {
box-shadow: 0 0 0 1px #8c8f94;
}
.aksrt-pay-meta__class-swatch.is-active {
box-shadow: 0 0 0 2px #2271b1;
}
.aksrt-pay-meta__group.is-dragging {
opacity: 0.5;
}
.aksrt-pay-meta__notice {
margin: 16px 18px;
padding: 12px 14px;
@@ -157,6 +338,10 @@
}
.aksrt-pay-meta__group--inline {
flex-wrap: wrap;
}
.aksrt-pay-meta__group-field--split {
grid-template-columns: 1fr;
}
}
+38 -2
View File
@@ -15,6 +15,17 @@
margin-left: 8px !important;
}
/* 骨架与真实卡片同宽:.posts-row 以 -8px 抵消卡片边距,loader 被上面的
:not() 排除在补偿外,会宽出 16px;这里补上等同的水平边距。
补偿落在骨架内层的 .posts-item 上(与真实卡片同一元素),不落外层
.post_ajax_loader —— 否则内层 .posts-item.card 的 margin:8px 会与外层
补偿叠成 16px,小工具 card 骨架因此窄出 8px。 */
.posts-row > .post_ajax_loader .posts-item,
.posts-row > .post_ajax_loader .posts-mini {
margin-right: 8px;
margin-left: 8px;
}
.posts-item {
margin: 15px 0;
padding: 20px;
@@ -448,6 +459,17 @@
margin-left: 8px;
}
/* 骨架与卡片同宽同轴:.widget-ajaxpager / .tab-pane 用负边距抵消卡片的水平
边距。补偿同样落在骨架内层的 .posts-item 上(与真实卡片同一元素),
不落外层 .post_ajax_loader —— 否则内层 .posts-item.card 自带的 margin:8px
会与外层补偿叠成 16px,card 骨架因此窄出 8px。 */
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader .posts-item {
margin-right: 8px;
margin-left: 8px;
}
.posts-mini:hover img,
.posts-item:hover .item-thumbnail img,
.posts-item.mult-thumb .thumb-items > span > img:hover {
@@ -518,6 +540,12 @@
margin-left: 5px !important;
}
.posts-row > .post_ajax_loader .posts-item,
.posts-row > .post_ajax_loader .posts-mini {
margin-right: 5px;
margin-left: 5px;
}
.posts-item .item-thumbnail {
width: 138px;
}
@@ -564,6 +592,13 @@
margin-left: 5px;
}
.widget-main-post.style-list .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-main-post.style-card .widget-ajaxpager > .post_ajax_loader .posts-item,
.widget-tab-post .tab-content > .tab-pane > .widget-ajaxpager > .post_ajax_loader .posts-item {
margin-right: 5px;
margin-left: 5px;
}
.swiper-scroll .posts-item {
margin-right: 10px !important;
}
@@ -674,8 +709,9 @@
color: var(--color-primary, #f04494);
}
/* .widget a 的链接色不得覆盖 .but 按钮自身配色(zibll 的 zib-widget 不设置链接色) */
.widget a.but {
/* .widget a 的链接色不得覆盖 .but / .img-badge 自身配色(zibll 的 zib-widget 不设置链接色) */
.widget a.but,
.widget a.img-badge {
color: var(--this-color, #fff);
}
+1 -1
View File
@@ -1 +1 @@
.posts-row{margin-right:-8px;margin-bottom:15px;margin-left:-8px}.posts-row>*:not(.post_ajax_loader){margin-right:8px!important;margin-left:8px!important}.posts-item{margin:15px 0;padding:20px;overflow:hidden;background:var(--main-bg-color, #fff);border-radius:var(--main-radius, 8px);box-shadow:0 0 10px var(--main-shadow, rgba(116, 116, 116, .08));transition:.2s}.posts-item.flex,.posts-item .flex,.posts-mini .flex{display:flex}.posts-item .flex.xx,.posts-mini .flex.xx{flex-direction:column}.posts-item .flex.ac,.posts-mini .flex.ac{align-items:center}.posts-item .flex.jsb,.posts-mini .flex.jsb{justify-content:space-between}.posts-item .flex1,.posts-mini .flex1{flex:1;overflow:hidden}.posts-item .post-graphic:not(.order1){margin-right:20px}.posts-item .order1.post-graphic{margin-right:0;margin-left:20px}.posts-item .item-thumbnail{position:relative;display:block;width:190px;height:0;padding-bottom:var(--posts-list-scale, 70%);overflow:hidden;border-radius:var(--main-radius, 8px)}.item-thumbnail.contain img{object-fit:contain}.swiper-slide.gradient-bg .dplayer-poster img,.swiper-slide.gradient-bg>*>img{object-fit:contain!important;border-radius:0!important}.item-thumbnail .swiper-wrapper{height:100%!important}.list .item-thumbnail .aksrt-slider,.list .item-thumbnail.new-swiper{position:absolute;width:100%;height:100%;margin:0!important}.item-thumbnail .dplayer.dplayer-thumb{position:absolute;inset:0;z-index:1;padding-bottom:var(--posts-list-scale, 70%);background:#000;opacity:0;transition:opacity .3s}.card .item-thumbnail .dplayer.dplayer-thumb{padding-bottom:var(--posts-card-scale, 70%)}.item-thumbnail .dplayer-thumb video,.item-thumbnail .swiper-wrapper,.item-thumbnail img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.item-thumbnail .swiper-pagination{right:0!important;bottom:0!important;left:0!important;padding:0 2px!important;text-align:center!important}.posts-item.thumb-dplayer-playing .dplayer-thumb{opacity:1}.posts-item.thumb-dplayer-playing .abs-center{opacity:0}.posts-item .item-heading{display:-webkit-box;max-height:2.8em;margin-top:0;margin-bottom:5px;overflow:hidden;font-size:18px;line-height:1.4em;white-space:normal;-webkit-box-orient:vertical;-webkit-line-clamp:2}.font-bold{font-weight:700}.list.style2 .item-thumbnail{width:130px}.list.style2 .item-heading{display:unset;max-height:1.4em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.posts-item .item-heading>a{color:var(--key-color, #333)}.posts-item .item-heading>a:hover{color:var(--focus-color, #f04494)}.posts-item .item-excerpt{margin:0 0 6px;overflow:hidden;color:var(--muted-color, #777);text-overflow:ellipsis;white-space:nowrap}.posts-item .item-meta item{margin-right:8px;font-size:13px}.posts-item .item-meta svg.icon,.posts-mini .item-meta svg.icon{display:inline-block;width:1em;height:1em;margin-right:2px;fill:currentColor;overflow:hidden;vertical-align:-.15em}.posts-item .meta-right item:last-child{margin-right:0}.item-meta .avatar-mini{width:20px;height:20px;border-radius:50%;transform:translateY(-1px)}.item-meta .meta-pay{padding:0 4px}.item-meta .meta-pay img{width:1em;height:1em;object-fit:contain;vertical-align:-.15em}.posts-item .item-meta a,.posts-mini .item-meta a{color:inherit}.posts-item .item-tags a{margin-right:5px;padding:2px 5px;font-size:11px}.posts-item .item-tags a .fa{font-size:1em}.posts-item.card{display:inline-block;width:calc(33.333% - 16px);margin:8px;padding:10px;vertical-align:top;transition:.3s}.posts-item.card:last-child{margin-bottom:20px}.posts-item.card .item-body{width:100%;margin:10px 0 0;padding:0}.posts-item.card .item-meta item{font-size:12px}.posts-mini-con .item-heading,.posts-item.card .item-heading{font-size:14px}.posts-item.card .item-heading{min-height:2.8em}.posts-item.card .item-tags{min-height:23px}.posts-item.card .item-thumbnail{width:100%;padding-bottom:var(--posts-card-scale, 70%)}.posts-item.card.style2{background:transparent;box-shadow:none}.posts-item.card.style2:hover{background:var(--main-bg-color, #fff);box-shadow:0 0 10px var(--main-shadow, rgba(116, 116, 116, .08))}.posts-item.card.style3{padding:0}.posts-item.card.style3 .item-thumbnail,.posts-item.card.style3 .item-thumbnail img{border-radius:var(--main-radius, 8px) var(--main-radius, 8px) 0 0}.posts-item.card.style3 .item-body{margin:0;padding:10px}.posts-item.mult-thumb .thumb-items{margin:0 -3px 6px}.posts-item.mult-thumb .thumb-items>span{position:relative;display:inline-block;width:calc(25% - 6px);height:0;margin:0 3px;padding-bottom:calc(var(--posts-list-scale, 70%) / 4)}.posts-item.mult-thumb .thumb-items>span>img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.posts-item.mult-thumb .thumb-items.contain>span{overflow:hidden;background:var(--main-border-color, rgba(50, 50, 50, .06));border-radius:var(--main-radius, 8px)}.posts-item.mult-thumb .thumb-items.contain>span>img{object-fit:contain;border-radius:0}.posts-item.no_margin{margin:0;border-radius:0;box-shadow:none}.postsLine truncated
.posts-row{margin-right:-8px;margin-bottom:15px;margin-left:-8px}.posts-row>*:not(.post_ajax_loader){margin-right:8px!important;margin-left:8px!important}.posts-row>.post_ajax_loader .posts-item,.posts-row>.post_ajax_loader .posts-mini{margin-right:8px;margin-left:8px}.posts-item{margin:15px 0;padding:20px;overflow:hidden;background:var(--main-bg-color, #fff);border-radius:var(--main-radius, 8px);box-shadow:0 0 10px var(--main-shadow, rgba(116, 116, 116, .08));transition:.2s}.posts-item.flex,.posts-item .flex,.posts-mini .flex{display:flex}.posts-item .flex.xx,.posts-mini .flex.xx{flex-direction:column}.posts-item .flex.ac,.posts-mini .flex.ac{align-items:center}.posts-item .flex.jsb,.posts-mini .flex.jsb{justify-content:space-between}.posts-item .flex1,.posts-mini .flex1{flex:1;overflow:hidden}.posts-item .post-graphic:not(.order1){margin-right:20px}.posts-item .order1.post-graphic{margin-right:0;margin-left:20px}.posts-item .item-thumbnail{position:relative;display:block;width:190px;height:0;padding-bottom:var(--posts-list-scale, 70%);overflow:hidden;border-radius:var(--main-radius, 8px)}.item-thumbnail.contain img{object-fit:contain}.swiper-slide.gradient-bg .dplayer-poster img,.swiper-slide.gradient-bg>*>img{object-fit:contain!important;border-radius:0!important}.item-thumbnail .swiper-wrapper{height:100%!important}.list .item-thumbnail .aksrt-slider,.list .item-thumbnail.new-swiper{position:absolute;width:100%;height:100%;margin:0!important}.item-thumbnail .dplayer.dplayer-thumb{position:absolute;inset:0;z-index:1;padding-bottom:var(--posts-list-scale, 70%);background:#000;opacity:0;transition:opacity .3s}.card .item-thumbnail .dplayer.dplayer-thumb{padding-bottom:var(--posts-card-scale, 70%)}.item-thumbnail .dplayer-thumb video,.item-thumbnail .swiper-wrapper,.item-thumbnail img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.item-thumbnail .swiper-pagination{right:0!important;bottom:0!important;left:0!important;padding:0 2px!important;text-align:center!important}.posts-item.thumb-dplayer-playing .dplayer-thumb{opacity:1}.posts-item.thumb-dplayer-playing .abs-center{opacity:0}.posts-item .item-heading{display:-webkit-box;max-height:2.8em;margin-top:0;margin-bottom:5px;overflow:hidden;font-size:18px;line-height:1.4em;white-space:normal;-webkit-box-orient:vertical;-webkit-line-clamp:2}.font-bold{font-weight:700}.list.style2 .item-thumbnail{width:130px}.list.style2 .item-heading{display:unset;max-height:1.4em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.posts-item .item-heading>a{color:var(--key-color, #333)}.posts-item .item-heading>a:hover{color:var(--focus-color, #f04494)}.posts-item .item-excerpt{margin:0 0 6px;overflow:hidden;color:var(--muted-color, #777);text-overflow:ellipsis;white-space:nowrap}.posts-item .item-meta item{margin-right:8px;font-size:13px}.posts-item .item-meta svg.icon,.posts-mini .item-meta svg.icon{display:inline-block;width:1em;height:1em;margin-right:2px;fill:currentColor;overflow:hidden;vertical-align:-.15em}.posts-item .meta-right item:last-child{margin-right:0}.item-meta .avatar-mini{width:20px;height:20px;border-radius:50%;transform:translateY(-1px)}.item-meta .meta-pay{padding:0 4px}.item-meta .meta-pay img{width:1em;height:1em;object-fit:contain;vertical-align:-.15em}.posts-item .item-meta a,.posts-mini .item-meta a{color:inherit}.posts-item .item-tags a{margin-right:5px;padding:2px 5px;font-size:11px}.posts-item .item-tags a .fa{font-size:1em}.posts-item.card{display:inline-block;width:calc(33.333% - 16px);margin:8px;padding:10px;vertical-align:top;transition:.3s}.posts-item.card:last-child{margin-bottom:20px}.posts-item.card .item-body{width:100%;margin:10px 0 0;padding:0}.posts-item.card .item-meta item{font-size:12px}.posts-mini-con .item-heading,.posts-item.card .item-heading{font-size:14px}.posts-item.card .item-heading{min-height:2.8em}.posts-item.card .item-tags{min-height:23px}.posts-item.card .item-thumbnail{width:100%;padding-bottom:var(--posts-card-scale, 70%)}.posts-item.card.style2{background:transparent;box-shadow:none}.posts-item.card.style2:hover{background:var(--main-bg-color, #fff);box-shadow:0 0 10px var(--main-shadow, rgba(116, 116, 116, .08))}.posts-item.card.style3{padding:0}.posts-item.card.style3 .item-thumbnail,.posts-item.card.style3 .item-thumbnail img{border-radius:var(--main-radius, 8px) var(--main-radius, 8px) 0 0}.posts-item.card.style3 .item-body{margin:0;padding:10px}.posts-item.mult-thumb .thumb-items{margin:0 -3px 6px}.posts-item.mult-thumb .thumb-items>span{position:relative;display:inline-block;width:calc(25% - 6px);height:0;margin:0 3px;padding-bottom:calc(var(--posts-list-scale, 70%) / 4)}.posts-item.mult-thumb .thumb-items>span>img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.posts-item.mult-thumb .thumb-items.contain>span{overflow:hidden;background:var(--main-border-color, rgba(50, 50, 50, .06));border-radius:var(--main-radius, 8px)}.posts-item.mult-thumb .thumb-items.contaiLine truncated
+744 -1548
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1 +1 @@
:root{--color-primary: #f04494;--color-primary-hover: #e0307e;--color-primary-active: #c9206d;--color-primary-soft: rgba(240, 68, 148, .1);--color-primary-softer: rgba(240, 68, 148, .05);--color-on-primary: #FFFFFF;--color-ink: #333333;--color-ink-2: #4e5358;--color-ink-3: #777777;--color-ink-4: #999999;--surface-page: #f5f6f7;--surface-1: #FFFFFF;--surface-2: #eeeeee;--surface-3: #e9ecef;--surface-4: #f8f8f8;--color-line: rgba(50, 50, 50, .06);--color-line-soft: rgba(0, 0, 0, .03);--color-line-stronger: rgba(50, 50, 50, .12);--color-success: #10B981;--color-warning: #F59E0B;--color-error: #EF4444;--color-info: #3B82F6;--color-accent: #18a058;--color-accent-soft: rgba(24, 160, 88, .12);--color-accent-shadow: rgba(24, 160, 88, .28);--font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;--text-xs: .75rem;--text-sm: .875rem;--text-base: .875rem;--text-md: 1.125rem;--text-lg: 1.25rem;--text-xl: 1.5rem;--text-2xl: 1.875rem;--text-3xl: 2.25rem;--text-4xl: 3rem;--text-5xl: 3.75rem;--leading-tight: 1.2;--leading-snug: 1.4;--leading-normal: 1.6;--leading-relaxed: 1.6;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-7: 32px;--space-8: 40px;--space-9: 48px;--space-10: 64px;--space-11: 80px;--space-12: 96px;--radius-xs: 3px;--radius-sm: 4px;--radius: 8px;--radius-lg: 8px;--radius-xl: 12px;--radius-2xl: 16px;--radius-pill: 100px;--radius-avatar: 50%;--shadow-xs: 0 1px 2px rgba(0, 0, 0, .03);--shadow-sm: 0 0 10px rgba(116, 116, 116, .08);--shadow: 0 0 10px rgba(116, 116, 116, .12);--shadow-lg: 0 12px 32px rgba(18, 18, 18, .1);--shadow-drawer: 0 18px 60px rgba(0, 0, 0, .2);--nav-height: 60px;--container: 1200px;--container-wide: 1440px;--container-read: 760px;--container-page: 880px;--sidebar-width: 311px;--content-gap: 24px;--section-gap: 24px;--sidebar-bp: 992px;--ease: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-spring: cubic-bezier(.34, 1.56, .64, 1);--dur-fast: .15s;--dur: .25s;--dur-slow: .4s;--focus-ring: 0 0 2px 3px rgba(253, 83, 161, .4);--z-dropdown: 100;--z-sticky: 200;--z-fixed: 300;--z-overlay: 400;--z-modal: 500;--z-popover: 600;--z-toast: 700;--z-tooltip: 800;--radius-btn: 4px;--btn-sm-h: 28px;--btn-md-h: 34px;--btn-lg-h: 44px;--card-padding: 15px;--card-padding-sm: 10px;--card-gap: 16px;--card-gap-lg: 20px;--skeleton-base: var(--surface-2);--skeleton-shine: var(--surface-1);--color-primary-light: var(--color-primary-soft);--color-surface: var(--surface-1);--color-line-strong: var(--color-line);--color-bg-hover: var(--surface-2);--color-border-light: var(--color-line-soft);--color-fill: var(--surface-2);--color-warning-bg: #fffbe6;--color-warning-text: #ad6800;--text-secondary: var(--color-ink-3);--text-tertiary: var(--color-ink-4);--surface-active: var(--surface-2);--surface-card: var(--surface-1);--surface-hover: var(--surface-2);--surface-secondary: var(--surface-2);--radius-code: var(--radius);--radius-full: 100px;--shadow-xl: 0 0 10px rgba(116, 116, 116, .12);--space-16: 64px;--z-floating: 900;--transition-fast: .15s ease;--transition-base: .25s ease;--header-bg: var(--blur-bg);--header-color: var(--color-ink-2);--footer-bg: var(--surface-1);--footer-color: var(--color-ink-4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--font-heading: var(--font-ui);--radius-card: var(--radius);--radius-md: var(--radius);--color-primary-50: var(--color-primary-soft);--color-primary-dark: var(--color-primary-active);--shadow-md: var(--shadow);--mini-radius: calc(var(--radius) / 1.4);--aksrt-avatar-size-xs: 24px;--aksrt-avatar-size-sm: 32px;--aksrt-avatar-size: 40px;--aksrt-avatar-size-lg: 56px;--aksrt-avatar-size-xl: 80px;--aksrt-card-thumb-scale: 56.25%;--aksrt-rose: #f43f5e;--aksrt-rose-soft: rgba(244, 63, 94, .12);--aksrt-blue: #3b82f6;--aksrt-blue-soft: rgba(59, 130, 246, .12);--aksrt-green: #10b981;--aksrt-green-soft: rgba(16, 185, 129, .12);--aksrt-orange: #f97316;--aksrt-orange-soft:rgba(249, 115, 22, .12);--aksrt-purple: #8b5cf6;--aksrt-purple-soft:rgba(139, 92, 246, .12);--aksrt-cyan: #06b6d4;--aksrt-cyan-soft: rgba(6, 182, 212, .12);--aksrt-yellow: #eab308;--aksrt-yellow-soft:rgba(234, 179, 8, .12);--aksrt-red: #ef4444;--aksrt-red-soft: rgba(239, 68, 68, .12);--aksrt-teal: #14b8a6;--aksrt-teal-soft: rgba(20, 184, 166, .12);--aksrt-indigo: #6366f1;--aksrt-indigo-soft:rgba(99, 102, 241, .12);--aksrt-pink: #ec4899;--aksrt-pink-soft: rgba(236, 72, 153, .12);--aksrt-vip-gold-1: #f6d365;--aksrt-vip-gold-2: #fda085;--akLine truncated
:root{--color-primary: #f04494;--color-primary-hover: #e0307e;--color-primary-active: #c9206d;--color-primary-soft: rgba(240, 68, 148, .1);--color-primary-softer: rgba(240, 68, 148, .05);--color-on-primary: #FFFFFF;--color-ink: #333333;--color-ink-2: #4e5358;--color-ink-3: #777777;--color-ink-4: #999999;--surface-page: #f5f6f7;--surface-1: #FFFFFF;--surface-2: #eeeeee;--surface-3: #e9ecef;--surface-4: #f8f8f8;--color-line: rgba(50, 50, 50, .06);--color-line-soft: rgba(0, 0, 0, .03);--color-line-stronger: rgba(50, 50, 50, .12);--color-success: #10B981;--color-warning: #F59E0B;--color-error: #EF4444;--color-info: #3B82F6;--color-accent: #18a058;--color-accent-soft: rgba(24, 160, 88, .12);--color-accent-shadow: rgba(24, 160, 88, .28);--font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;--text-xs: .75rem;--text-sm: .875rem;--text-base: .875rem;--text-md: 1.125rem;--text-lg: 1.25rem;--text-xl: 1.5rem;--text-2xl: 1.875rem;--text-3xl: 2.25rem;--text-4xl: 3rem;--text-5xl: 3.75rem;--leading-tight: 1.2;--leading-snug: 1.4;--leading-normal: 1.6;--leading-relaxed: 1.6;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-7: 32px;--space-8: 40px;--space-9: 48px;--space-10: 64px;--space-11: 80px;--space-12: 96px;--radius-xs: 3px;--radius-sm: 4px;--radius: 8px;--radius-lg: 8px;--radius-xl: 12px;--radius-2xl: 16px;--radius-pill: 100px;--radius-avatar: 50%;--shadow-xs: 0 1px 2px rgba(0, 0, 0, .03);--shadow-sm: 0 0 10px rgba(116, 116, 116, .08);--shadow: 0 0 10px rgba(116, 116, 116, .12);--shadow-lg: 0 12px 32px rgba(18, 18, 18, .1);--shadow-drawer: 0 18px 60px rgba(0, 0, 0, .2);--nav-height: 60px;--container: 1200px;--container-wide: 1440px;--container-read: 760px;--container-page: 880px;--sidebar-width: 311px;--content-gap: 24px;--section-gap: 24px;--sidebar-bp: 992px;--ease: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-spring: cubic-bezier(.34, 1.56, .64, 1);--dur-fast: .15s;--dur: .25s;--dur-slow: .4s;--focus-ring: 0 0 2px 3px rgba(253, 83, 161, .4);--z-dropdown: 100;--z-sticky: 200;--z-fixed: 300;--z-overlay: 400;--z-modal: 500;--z-popover: 600;--z-toast: 700;--z-tooltip: 800;--radius-btn: 4px;--btn-sm-h: 28px;--btn-md-h: 34px;--btn-lg-h: 44px;--card-padding: 15px;--card-padding-sm: 10px;--card-gap: 16px;--card-gap-lg: 20px;--color-primary-light: var(--color-primary-soft);--color-surface: var(--surface-1);--color-line-strong: var(--color-line);--color-bg-hover: var(--surface-2);--color-border-light: var(--color-line-soft);--color-fill: var(--surface-2);--color-warning-bg: #fffbe6;--color-warning-text: #ad6800;--text-secondary: var(--color-ink-3);--text-tertiary: var(--color-ink-4);--surface-active: var(--surface-2);--surface-card: var(--surface-1);--surface-hover: var(--surface-2);--surface-secondary: var(--surface-2);--radius-code: var(--radius);--radius-full: 100px;--shadow-xl: 0 0 10px rgba(116, 116, 116, .12);--space-16: 64px;--z-floating: 900;--transition-fast: .15s ease;--transition-base: .25s ease;--header-bg: var(--blur-bg);--header-color: var(--color-ink-2);--footer-bg: var(--surface-1);--footer-color: var(--color-ink-4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--font-heading: var(--font-ui);--radius-card: var(--radius);--radius-md: var(--radius);--color-primary-50: var(--color-primary-soft);--color-primary-dark: var(--color-primary-active);--shadow-md: var(--shadow);--mini-radius: calc(var(--radius) / 1.4);--aksrt-avatar-size-xs: 24px;--aksrt-avatar-size-sm: 32px;--aksrt-avatar-size: 40px;--aksrt-avatar-size-lg: 56px;--aksrt-avatar-size-xl: 80px;--aksrt-card-thumb-scale: 56.25%;--aksrt-rose: #f43f5e;--aksrt-rose-soft: rgba(244, 63, 94, .12);--aksrt-blue: #3b82f6;--aksrt-blue-soft: rgba(59, 130, 246, .12);--aksrt-green: #10b981;--aksrt-green-soft: rgba(16, 185, 129, .12);--aksrt-orange: #f97316;--aksrt-orange-soft:rgba(249, 115, 22, .12);--aksrt-purple: #8b5cf6;--aksrt-purple-soft:rgba(139, 92, 246, .12);--aksrt-cyan: #06b6d4;--aksrt-cyan-soft: rgba(6, 182, 212, .12);--aksrt-yellow: #eab308;--aksrt-yellow-soft:rgba(234, 179, 8, .12);--aksrt-red: #ef4444;--aksrt-red-soft: rgba(239, 68, 68, .12);--aksrt-teal: #14b8a6;--aksrt-teal-soft: rgba(20, 184, 166, .12);--aksrt-indigo: #6366f1;--aksrt-indigo-soft:rgba(99, 102, 241, .12);--aksrt-pink: #ec4899;--aksrt-pink-soft: rgba(236, 72, 153, .12);--aksrt-vip-gold-1: #f6d365;--aksrt-vip-gold-2: #fda085;--aksrt-grad-sunset-1: #f093fb;--aksrt-grad-sunset-2: #f5576c;--aksrt-graLine truncated
+5 -280
View File
@@ -476,6 +476,11 @@ article.article.box-body,
margin: 0 0 1.5em;
}
/* 段落首行缩进(参考主题 post_p_indent_s:开启后正文段落首行缩进 2em)。 */
.wp-posts-content.p-indent p {
text-indent: 2em;
}
:is(.entry-content, .wp-posts-content) h2 {
font-size: 20px;
font-weight: 700;
@@ -727,290 +732,10 @@ article.article.box-body,
margin-bottom: 16px;
}
/* legacy .post-actions — text/icon column buttons, not pill chips */
.post-actions-card,
.post-actions {
position: relative;
text-align: center;
margin-bottom: 16px;
padding-bottom: 15px;
display: block;
}
.post-actions-wrap > .post-actions {
margin-bottom: 0;
}
.post-actions-card__hint {
font-size: 13px;
color: var(--color-ink-4, var(--color-ink-4));
margin-bottom: 12px;
}
/* Legacy card-buttons flex only for non-legacy card variant */
.post-actions-card__buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 12px;
}
/* legacy: .article .action { width:80px; padding:10px 0; inline-block; opacity:.7 } */
.article .action,
.post-actions .action {
width: 80px;
padding: 10px 0;
display: inline-block;
opacity: 0.7;
vertical-align: top;
min-width: 0;
height: auto;
border-radius: 0;
border: 0;
background: transparent;
color: var(--muted-color, #777);
cursor: pointer;
font-size: 14px;
transition: opacity 0.15s ease, color 0.15s ease;
gap: 0;
text-align: center;
}
.article .action:hover,
.post-actions .action:hover,
.article .action.is-active,
.post-actions .action.is-active {
color: inherit;
opacity: 1;
background: transparent;
border-color: transparent;
}
.article .action i,
.post-actions .action i {
font-size: 20px;
display: block;
margin: 0 auto 4px;
line-height: 1;
}
.post-actions .action .action-btn__count,
.post-actions .action .action-btn__label,
.action-btn__count,
.action-btn__label {
display: block;
font-size: 12px;
line-height: 1.2;
font-weight: 500;
}
/* Keep pill style for non-article .action-btn if used elsewhere */
.action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.38em 1.1em;
border-radius: 50px;
border: 1px solid transparent;
background: var(--surface-4, rgba(136, 136, 136, 0.1));
color: var(--color-ink-3);
cursor: pointer;
font-size: 14px;
transition: all 0.15s ease;
}
.action-btn:hover {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.action-btn.is-active {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-primary-soft, rgba(240, 68, 148, 0.08));
}
.action-btn i { font-size: 1.2em; }
/* ===== 分享下拉面板 ===== */
/* 确保父容器作为定位参考 */
.share-dropdown {
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
transform: translateX(-50%);
z-index: 100;
/* 默认隐藏 — 最高优先级确保不被覆盖 */
display: none !important;
}
.share-dropdown[aria-hidden="false"] {
display: block !important;
animation: shareDropdownIn 0.2s ease-out forwards;
}
@keyframes shareDropdownIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.share-dropdown__grid {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--surface-page);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg, 8px);
box-shadow: var(--shadow-md, 0 4px 16px rgba(0,0,0,0.1));
}
.share-item {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: none;
border-radius: var(--radius-btn, 6px);
background: transparent;
color: var(--color-ink-2, var(--color-ink-3));
cursor: pointer;
font-size: 11px;
transition: background 0.15s, color 0.15s, transform 0.15s;
white-space: nowrap;
}
.share-item:hover {
transform: none;
}
.share-item i {
font-size: 18px;
}
.share-item[data-platform="qq"]:hover { background: #e8f4fd; color: #12b7f5; }
.share-item[data-platform="weibo"]:hover { background: #fceae9; color: #ff6a00; }
.share-item[data-platform="twitter"]:hover { background: #f0f0f0; color: #000; }
.share-item[data-platform="wechat"]:hover { background: #e6f7ec; color: #07c160; }
.share-item[data-platform="copy"]:hover { background: #f0f2f5; color: #555; }
.share-item[data-platform="poster"]:hover { background: #fff4e8; color: #f59e0b; }
/* 微信二维码浮层 */
#share-qrcode-box {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
padding: 14px;
background: var(--surface-page);
border: 1px solid var(--color-line);
border-radius: var(--radius-lg, 8px);
box-shadow: var(--shadow-md, 0 4px 16px rgba(0,0,0,0.12));
text-align: center;
z-index: 101;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
}
#share-qrcode-box.is-visible {
pointer-events: auto;
opacity: 1;
}
#share-qrcode-image {
width: 140px;
height: 140px;
display: block;
}
/* 暗色模式 */
.theme-dark .share-dropdown__grid,
html.dark .share-dropdown__grid {
background: var(--surface-1);
border-color: var(--color-line);
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
}
.theme-dark #share-qrcode-box,
html.dark #share-qrcode-box {
background: var(--surface-1);
border-color: var(--color-line);
}
.theme-dark .share-item,
html.dark .share-item {
color: var(--color-ink-2, #aaa);
}
.theme-dark .share-item[data-platform="qq"]:hover { background: rgba(18,183,245,0.12); color: #12b7f5; }
.theme-dark .share-item[data-platform="weibo"]:hover { background: rgba(255,106,0,0.12); color: #ff6a00; }
.theme-dark .share-item[data-platform="twitter"]:hover { background: rgba(255,255,255,0.08); color: #fff; }
.theme-dark .share-item[data-platform="wechat"]:hover { background: rgba(7,193,96,0.12); color: #07c160; }
.theme-dark .share-item[data-platform="copy"]:hover { background: rgba(255,255,255,0.06); color: #ccc; }
.theme-dark .share-item[data-platform="poster"]:hover { background: rgba(245,158,11,0.12); color: #f59e0b; }
/* Narrow screens: bottom sheet so dropdown never clips off-screen */
@media (max-width: 767px) {
.share-dropdown {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: auto;
transform: none;
width: 100%;
max-width: none;
z-index: 1200;
padding: 0 0 calc(12px + env(safe-area-inset-bottom, 0px));
background: transparent;
}
.share-dropdown[aria-hidden="false"] {
animation: shareSheetIn 0.22s ease-out forwards;
}
@keyframes shareSheetIn {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.share-dropdown__grid {
justify-content: space-around;
flex-wrap: wrap;
gap: 6px;
margin: 0 10px;
padding: 14px 12px 16px;
border-radius: 14px 14px 10px 10px;
box-shadow: 0 -6px 28px rgba(0, 0, 0, 0.16);
}
.share-item {
min-width: 64px;
flex: 1 1 28%;
max-width: 33%;
}
#share-qrcode-box {
position: static;
margin: 10px auto 0;
transform: none;
}
body.aksrt-share-sheet-open {
overflow: hidden;
}
body.aksrt-share-sheet-open::before {
content: '';
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 1190;
}
}
.theme-dark .post-drop-meta__menu,
html.dark .post-drop-meta__menu {
background: var(--surface-1, #323335);
+1 -1
View File
@@ -1 +1 @@
.relative{position:relative}.flex{display:flex}.flex.ac,.ac{align-items:center}.flex.jc,.jc{justify-content:center}.flex.jsb,.jsb{justify-content:space-between}.flex.at,.at{align-items:flex-start}.flex.ae,.ae{align-items:flex-end}.flex0{flex:0 0 auto}.flex1{flex:1 1 auto;min-width:0}.flex.column{flex-direction:column}.flex.wrap{flex-wrap:wrap}.flex.flex-row,.flex-row{flex-direction:row}.flex.jse,.jse{justify-content:space-evenly}.flex.jsa,.jsa{justify-content:space-around}.flex>.flex1,.flex .flex1{flex:1;overflow:hidden;min-width:0}.relative-h{position:relative;height:100%}.padding-10{padding:10px}.font-bold{font-weight:700}.mt6{margin-top:6px}.gutters-5{margin:-5px}.gutters-5>*{padding:5px}@media(max-width:767px){.flex.flex-row.flex-col-sm-2{flex-wrap:wrap}.flex.flex-row.flex-col-sm-2>*{flex-basis:50%}}.clearfix:before,.clearfix:after{content:"";display:table}.clearfix:after{clear:both}.abs-right{position:absolute;right:0;top:0}.abs-center{position:absolute;left:0;width:100%;top:50%;right:.7em;transform:translateY(-50%);z-index:1}.abs-center.left-top,.abs-center.right-top{transform:unset;top:0}.abs-center.right-top{left:auto;width:auto;text-align:right}.abs-center.left-top{text-align:left}.abs-center.conter-bottom,.abs-center.conter-conter{text-align:center}.abs-center.right-top,.abs-center.right-bottom,.abs-center.right-conter{text-align:right}.abs-center.conter-bottom,.abs-center.right-bottom,.abs-center.left-bottom{top:auto;transform:unset;bottom:0}.abs-center.left-bottom{left:0;right:auto;text-align:left}.abs-center.right-bottom,.abs-center.right-conter{left:auto;right:0;text-align:right}.graphic,.page-cover{text-shadow:0 0 5px rgba(0,0,0,.2);--main-color: #fff;color:#fff;position:relative;overflow:hidden;box-shadow:0 0 10px var(--main-shadow, rgba(116, 116, 116, .08));padding:0 0 70%;border-radius:var(--main-radius, var(--radius, 8px))}.graphic>.fit-cover,.graphic>img.fit-cover{position:absolute;width:100%;height:100%;object-fit:cover;inset:0}.fit-cover{object-fit:cover;max-width:100%}.title-h-left{position:relative;padding-bottom:8px}.title-h-left:before{transition:.4s;transform-origin:left;position:absolute;content:"";width:40px;height:3px;background:var(--theme-color, var(--focus-color, #f04494));left:0;bottom:3px;border-radius:5px;box-shadow:1px 1px 3px -1px var(--theme-color, var(--focus-color, #f04494))}.line-form-line,.line-form-line:before{position:absolute;bottom:0;left:0;height:1px;width:100%;transition:.8s;background:var(--main-border-color, rgba(50, 50, 50, .06))}.line-form-line:before{content:"";width:0;background:var(--focus-color, #f04494)}.avatar-img{position:relative;display:inline-block;flex-shrink:0;--this-size: 38px;width:var(--this-size);height:var(--this-size)}.avatar-img .avatar,.avatar-img img{width:100%;height:100%;border-radius:50%;object-fit:cover}.avatar-lg{--this-size: 80px}.avatar-md{--this-size: 55px}.avatar-mini{--this-size: 20px}.shrink0{flex-shrink:0}.opacity8{opacity:.8}.muted-color{color:var(--muted-color, var(--color-ink-3))}.muted-2-color{color:var(--muted-2-color, var(--color-ink-4))}.muted-3-color{color:var(--muted-3-color, #b1b1b1)}.muted-4-color{color:var(--muted-4-color, #d2d2d2)}.text-center{text-align:center}.em09{font-size:.9em}.em12{font-size:1.2em}.px12-sm{font-size:12px}.ml3{margin-left:3px}.ml6{margin-left:6px}.ml10{margin-left:10px}.mr3{margin-right:3px}.mr6{margin-right:6px}.mt10{margin-top:10px}.mt20{margin-top:20px}.mb10{margin-bottom:10px}.mb20{margin-bottom:20px}.text-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.text-ellipsis-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}.main-bg{background-color:var(--main-bg-color, var(--surface-1))}.separator{position:relative;letter-spacing:.08em}.icon-circle+.icon-circle:before{content:"·";display:inline-block;margin:0 .45em;opacity:.55}.article-avatar name,.article-avatar .name,.user-name{display:inline-flex;align-items:center;gap:4px;min-width:0;max-width:100%}.pull-right{float:right}.pull-left{float:left}body.single main.container .content-layout{display:block;min-width:0}body.single main.container .content-layout>*{min-width:0;max-width:100%}body.single main.container .content-layout>.breadcrumb,body.single main.container .content-layout>ul.breadcrumb,body.single main.container .content-layout>.breadcrumb-nav,body.single main.container .content-layout>.breadcrumbs-wrap{position:relative;z-index:1;margin-top:0;margin-bottom:12px;padding-top:2px}body.single .sidebar .aksrt-panel,body.single .sidebar .widget{margin-bottom:15px}body.single main.container{box-sizing:border-box}.article,article.article.box-body,.article.main-bg.box-body{padding:0 30px;overflow:hidden}@media(max-width:767px){.article,article.article.box-body,.article.main-bg.box-body{padding:0 15px}.article-header .article-title{font-size:18px}}.entry-header,.article-header{margin-bottom:10px;padding-bottom:0;border-bottom:none;background:tranLine truncated
.relative{position:relative}.flex{display:flex}.flex.ac,.ac{align-items:center}.flex.jc,.jc{justify-content:center}.flex.jsb,.jsb{justify-content:space-between}.flex.at,.at{align-items:flex-start}.flex.ae,.ae{align-items:flex-end}.flex0{flex:0 0 auto}.flex1{flex:1 1 auto;min-width:0}.flex.column{flex-direction:column}.flex.wrap{flex-wrap:wrap}.flex.flex-row,.flex-row{flex-direction:row}.flex.jse,.jse{justify-content:space-evenly}.flex.jsa,.jsa{justify-content:space-around}.flex>.flex1,.flex .flex1{flex:1;overflow:hidden;min-width:0}.relative-h{position:relative;height:100%}.padding-10{padding:10px}.font-bold{font-weight:700}.mt6{margin-top:6px}.gutters-5{margin:-5px}.gutters-5>*{padding:5px}@media(max-width:767px){.flex.flex-row.flex-col-sm-2{flex-wrap:wrap}.flex.flex-row.flex-col-sm-2>*{flex-basis:50%}}.clearfix:before,.clearfix:after{content:"";display:table}.clearfix:after{clear:both}.abs-right{position:absolute;right:0;top:0}.abs-center{position:absolute;left:0;width:100%;top:50%;right:.7em;transform:translateY(-50%);z-index:1}.abs-center.left-top,.abs-center.right-top{transform:unset;top:0}.abs-center.right-top{left:auto;width:auto;text-align:right}.abs-center.left-top{text-align:left}.abs-center.conter-bottom,.abs-center.conter-conter{text-align:center}.abs-center.right-top,.abs-center.right-bottom,.abs-center.right-conter{text-align:right}.abs-center.conter-bottom,.abs-center.right-bottom,.abs-center.left-bottom{top:auto;transform:unset;bottom:0}.abs-center.left-bottom{left:0;right:auto;text-align:left}.abs-center.right-bottom,.abs-center.right-conter{left:auto;right:0;text-align:right}.graphic,.page-cover{text-shadow:0 0 5px rgba(0,0,0,.2);--main-color: #fff;color:#fff;position:relative;overflow:hidden;box-shadow:0 0 10px var(--main-shadow, rgba(116, 116, 116, .08));padding:0 0 70%;border-radius:var(--main-radius, var(--radius, 8px))}.graphic>.fit-cover,.graphic>img.fit-cover{position:absolute;width:100%;height:100%;object-fit:cover;inset:0}.fit-cover{object-fit:cover;max-width:100%}.title-h-left{position:relative;padding-bottom:8px}.title-h-left:before{transition:.4s;transform-origin:left;position:absolute;content:"";width:40px;height:3px;background:var(--theme-color, var(--focus-color, #f04494));left:0;bottom:3px;border-radius:5px;box-shadow:1px 1px 3px -1px var(--theme-color, var(--focus-color, #f04494))}.line-form-line,.line-form-line:before{position:absolute;bottom:0;left:0;height:1px;width:100%;transition:.8s;background:var(--main-border-color, rgba(50, 50, 50, .06))}.line-form-line:before{content:"";width:0;background:var(--focus-color, #f04494)}.avatar-img{position:relative;display:inline-block;flex-shrink:0;--this-size: 38px;width:var(--this-size);height:var(--this-size)}.avatar-img .avatar,.avatar-img img{width:100%;height:100%;border-radius:50%;object-fit:cover}.avatar-lg{--this-size: 80px}.avatar-md{--this-size: 55px}.avatar-mini{--this-size: 20px}.shrink0{flex-shrink:0}.opacity8{opacity:.8}.muted-color{color:var(--muted-color, var(--color-ink-3))}.muted-2-color{color:var(--muted-2-color, var(--color-ink-4))}.muted-3-color{color:var(--muted-3-color, #b1b1b1)}.muted-4-color{color:var(--muted-4-color, #d2d2d2)}.text-center{text-align:center}.em09{font-size:.9em}.em12{font-size:1.2em}.px12-sm{font-size:12px}.ml3{margin-left:3px}.ml6{margin-left:6px}.ml10{margin-left:10px}.mr3{margin-right:3px}.mr6{margin-right:6px}.mt10{margin-top:10px}.mt20{margin-top:20px}.mb10{margin-bottom:10px}.mb20{margin-bottom:20px}.text-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.text-ellipsis-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}.main-bg{background-color:var(--main-bg-color, var(--surface-1))}.separator{position:relative;letter-spacing:.08em}.icon-circle+.icon-circle:before{content:"·";display:inline-block;margin:0 .45em;opacity:.55}.article-avatar name,.article-avatar .name,.user-name{display:inline-flex;align-items:center;gap:4px;min-width:0;max-width:100%}.pull-right{float:right}.pull-left{float:left}body.single main.container .content-layout{display:block;min-width:0}body.single main.container .content-layout>*{min-width:0;max-width:100%}body.single main.container .content-layout>.breadcrumb,body.single main.container .content-layout>ul.breadcrumb,body.single main.container .content-layout>.breadcrumb-nav,body.single main.container .content-layout>.breadcrumbs-wrap{position:relative;z-index:1;margin-top:0;margin-bottom:12px;padding-top:2px}body.single .sidebar .aksrt-panel,body.single .sidebar .widget{margin-bottom:15px}body.single main.container{box-sizing:border-box}.article,article.article.box-body,.article.main-bg.box-body{padding:0 30px;overflow:hidden}@media(max-width:767px){.article,article.article.box-body,.article.main-bg.box-body{padding:0 15px}.article-header .article-title{font-size:18px}}.entry-header,.article-header{margin-bottom:10px;padding-bottom:0;border-bottom:none;background:tranLine truncated
-454
View File
@@ -1,454 +0,0 @@
/* ============================================================
AKSRT – Floating UI & Site Footer
============================================================
Covers:
• .floating-toolbar – fixed action buttons (scroll-top,
font-size, sidebar toggle)
• .mobile-nav-overlay – full-screen drawer nav (mobile)
• .site-footer – footer grid + bottom bar
============================================================ */
/* ═══════════════════════════════════════════════════════════
FLOATING TOOLBAR
═══════════════════════════════════════════════════════════ */
.floating-toolbar {
position: fixed;
right: var(--space-5);
bottom: var(--space-8);
z-index: var(--z-floating);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* ── Individual button ────────────────────────────────────── */
.floating-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--blur-bg);
border: none;
border-radius: var(--radius-full);
box-shadow: var(--shadow-sm);
backdrop-filter: saturate(2) blur(12px);
-webkit-backdrop-filter: saturate(2) blur(12px);
cursor: pointer;
color: var(--color-ink-4);
transition:
background var(--transition-fast),
color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
padding: 0;
}
.floating-btn svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.floating-btn:hover,
.floating-btn:focus-visible {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
box-shadow: var(--shadow-lg);
transform: none;
}
.floating-btn:active {
transform: translateY(0);
box-shadow: var(--shadow-sm);
}
.floating-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* ── Scroll-to-top: hide until page is scrolled ───────────── */
.floating-btn--top {
opacity: 0;
pointer-events: none;
transition:
opacity var(--transition-base),
background var(--transition-fast),
color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.floating-btn--top.is-visible {
opacity: 1;
pointer-events: auto;
}
/* ── Mobile: smaller toolbar, stacked horizontally ───────── */
@media (max-width: 767px) {
.floating-toolbar {
right: var(--space-4);
bottom: var(--space-6);
flex-direction: row; /* horizontal row on mobile */
}
.floating-btn {
width: 36px;
height: 36px;
}
.floating-btn svg {
width: 16px;
height: 16px;
}
}
/* ═══════════════════════════════════════════════════════════
MOBILE NAV OVERLAY
═══════════════════════════════════════════════════════════ */
.mobile-nav-overlay {
position: fixed;
inset: 0;
z-index: var(--z-overlay);
background: rgba(0, 0, 0, 0.45);
opacity: 0;
visibility: hidden;
transition:
opacity var(--transition-base),
visibility var(--transition-base);
/* backdrop blur on supporting browsers */
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.mobile-nav-overlay.is-open {
opacity: 1;
visibility: visible;
}
/* ── Drawer panel ─────────────────────────────────────────── */
.mobile-nav-drawer {
position: absolute;
top: 0;
left: 0;
width: min(320px, 85vw);
height: 100%;
background: var(--color-surface);
box-shadow: var(--shadow-xl);
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
overflow-y: auto;
overscroll-behavior: contain;
transform: translateX(-100%);
transition: transform var(--transition-base);
}
.mobile-nav-overlay.is-open .mobile-nav-drawer {
transform: translateX(0);
}
/* ── Close button ─────────────────────────────────────────── */
.mobile-nav-close {
align-self: flex-end;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: var(--surface-page);
border: 1px solid var(--color-line);
border-radius: var(--radius-full);
cursor: pointer;
color: var(--color-ink-4);
transition: background var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
}
.mobile-nav-close:hover {
background: var(--color-danger);
color: #fff;
border-color: var(--color-danger);
}
.mobile-nav-close svg {
width: 18px;
height: 18px;
}
/* ── Brand ────────────────────────────────────────────────── */
.mobile-nav-brand {
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-line);
}
.mobile-nav-brand a {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-ink);
text-decoration: none;
}
.mobile-nav-brand a:hover {
color: var(--color-primary);
}
/* ── Nav menu (WordPress-generated ul) ────────────────────── */
.mobile-nav-menu {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.mobile-nav-menu li {
list-style: none;
}
.mobile-nav-menu a {
display: block;
padding: var(--space-2) var(--space-3);
font-size: 0.9375rem;
font-weight: 500;
color: var(--color-ink-4);
border-radius: var(--radius-md);
text-decoration: none;
transition: background var(--transition-fast), color var(--transition-fast);
}
.mobile-nav-menu a:hover,
.mobile-nav-menu .current-menu-item > a,
.mobile-nav-menu .current-page-item > a {
background: var(--color-primary-light);
color: var(--color-primary);
}
/* sub-menu indented */
.mobile-nav-menu .sub-menu {
padding-left: var(--space-4);
margin-top: var(--space-1);
}
/* ── Search form ──────────────────────────────────────────── */
.mobile-nav-search {
margin-top: auto;
padding-top: var(--space-4);
border-top: 1px solid var(--color-line);
}
.mobile-nav-search form {
display: flex;
gap: var(--space-2);
}
.mobile-nav-search input[type="search"] {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-line);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--surface-page);
color: var(--color-ink);
}
.mobile-nav-search input[type="search"]:focus {
outline: none;
border-color: var(--color-primary);
}
.mobile-nav-search button[type="submit"] {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-2) var(--space-3);
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.875rem;
transition: background var(--transition-fast);
}
.mobile-nav-search button[type="submit"]:hover {
background: var(--color-primary-dark);
}
/* ── Only mount overlay on mobile ─────────────────────────── */
@media (min-width: 769px) {
.mobile-nav-overlay {
display: none !important;
}
}
/* ═══════════════════════════════════════════════════════════
SITE FOOTER
═══════════════════════════════════════════════════════════ */
.site-footer {
margin-top: var(--space-16);
background: var(--color-surface);
border-top: 1px solid var(--color-line);
}
.site-footer__inner {
padding-top: var(--space-12);
padding-bottom: var(--space-10);
}
/* ── Grid of columns ──────────────────────────────────────── */
.site-footer__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-8);
align-items: start;
}
/* Give the about column a bit more real estate when there's room */
.site-footer__about {
grid-column: span 2;
}
@media (max-width: 900px) {
.site-footer__about {
grid-column: span 1;
}
}
@media (max-width: 600px) {
.site-footer__grid {
grid-template-columns: 1fr 1fr;
}
.site-footer__about {
grid-column: 1 / -1;
}
}
/* ── Column headings ──────────────────────────────────────── */
.site-footer__heading {
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-ink-4);
margin-bottom: var(--space-4);
}
/* ── About description ────────────────────────────────────── */
.site-footer__desc {
font-size: 0.875rem;
color: var(--color-ink-4);
line-height: 1.65;
margin-bottom: var(--space-4);
}
/* ── Contact icon row ─────────────────────────────────────── */
.site-footer__contacts {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.site-footer__contacts a {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-full);
border: 1px solid var(--color-line);
color: var(--color-ink-4);
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.site-footer__contacts a:hover {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.site-footer__contacts .dashicons {
font-size: 16px;
width: 16px;
height: 16px;
}
/* ── Link lists ───────────────────────────────────────────── */
.site-footer__links {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.site-footer__links li {
list-style: none;
}
.site-footer__links a {
font-size: 0.875rem;
color: var(--color-ink-4);
text-decoration: none;
transition: color var(--transition-fast);
}
.site-footer__links a:hover {
color: var(--color-primary);
}
/* ── Friend links: wrap horizontally ─────────────────────── */
.site-footer__links--friends {
flex-direction: row;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
}
/* ── Bottom copyright bar ─────────────────────────────────── */
.site-footer__bottom {
border-top: 1px solid var(--color-border-light);
background: var(--surface-page);
padding: var(--space-4) 0;
}
.site-footer__bottom .container {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-2);
}
.site-footer__copy,
.site-footer__icp {
font-size: 0.8125rem;
color: var(--color-ink-4);
}
.site-footer__icp {
display: flex;
align-items: center;
gap: var(--space-3);
}
.site-footer__icp a {
color: var(--color-ink-4);
transition: color var(--transition-fast);
}
.site-footer__icp a:hover {
color: var(--color-primary);
}
@media (max-width: 600px) {
.site-footer__bottom .container {
flex-direction: column;
text-align: center;
}
}
+4 -1
View File
@@ -137,6 +137,7 @@
/* === Full width === */
.btn-block {
display: block;
width: 100%;
}
@@ -182,6 +183,8 @@
.but > .fa:not(.fa-angle-right),
.but > i[class*="fa-"]:not(.fa-angle-right) { margin-right: 0.25em; }
.but.block { display: block; }
/* hover / active */
.but:focus,
.but:hover { opacity: 0.8; color: var(--this-color); }
@@ -313,7 +316,7 @@
/* gradient modifiers (legacy .jb-*) */
.jb-red { --this-bg: linear-gradient(135deg, #fd7a64 10%, #fb2d2d 100%); }
.jb-yellow { --this-bg: linear-gradient(135deg, #f99d4d 10%, #f7631d 100%); }
.jb-yellow { --this-bg: linear-gradient(135deg, #f59f54 10%, #ff6922 100%); }
.jb-blue { --this-bg: linear-gradient(135deg, #59c3fb 10%, #268df7 100%); }
.jb-green { --this-bg: linear-gradient(135deg, #60e464 10%, #5cb85b 100%); }
.jb-purple { --this-bg: linear-gradient(135deg, #f98dfb 10%, #ea00f9 100%); }
+16 -4
View File
@@ -424,6 +424,22 @@
.aksrt-avatar--lg { width: var(--aksrt-avatar-size-lg); height: var(--aksrt-avatar-size-lg); }
.aksrt-avatar--xl { width: var(--aksrt-avatar-size-xl); height: var(--aksrt-avatar-size-xl); }
/* ============================================================
* 8b. 头像形状统一 — 圆形基底
* --radius-avatar 是前端用户头像形状的唯一来源。任何带头像类名的
* <img>(含未包 .avatar-img 的裸 img)都必须是正圆,供后续头像
* 挂件/装扮叠加。特异性 0,1,1 高于 main.css 的 .avatar{100px},
* 同值为圆;路由 CSS 中显式声明圆角的头像框需各自对齐。
* ============================================================ */
img.avatar,
img.avatar-mini,
img.avatar-md,
img.avatar-lg,
.avatar-img img {
border-radius: var(--radius-avatar, 50%);
object-fit: cover;
}
/* ============================================================
* 9. 涟漪点击效果 — .aksrt-ripple
* 容器加此类,点击时 JS 注入 span.aksrt-ripple__wave
@@ -468,10 +484,6 @@
}
}
.nowave {
overflow: hidden;
}
/* ============================================================
* 10. 模态框堆叠动画 — 父模态缩小
* ============================================================ */
-7
View File
@@ -141,10 +141,6 @@
--card-gap: 16px;
--card-gap-lg: 20px;
/* ===== Skeleton loader ===== */
--skeleton-base: var(--surface-2);
--skeleton-shine: var(--surface-1);
/* ===== Extended tokens (used by components) ===== */
--color-primary-light: var(--color-primary-soft);
--color-surface: var(--surface-1);
@@ -372,9 +368,6 @@ body.dark-theme {
--footer-color: var(--color-ink-4);
--shadow-md: var(--shadow);
--skeleton-base: #3a3b3e;
--skeleton-shine: #444548;
/* Multi-color palette — dark mode: same hue, softer tint backgrounds */
--aksrt-rose-soft: rgba(244, 63, 94, 0.18);
--aksrt-blue-soft: rgba(59, 130, 246, 0.18);
-114
View File
@@ -1,114 +0,0 @@
/* ========== AJAX 加载指示器 — 对齐 legacy loading spinner ========== */
.aksrt-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--color-ink-4, #999);
font-size: 13px;
gap: 8px;
}
.aksrt-loading__spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--surface-3, #e8e8e8);
border-top-color: var(--color-primary, #f04494);
border-radius: 50%;
animation: aksrt-spin 0.6s linear infinite;
}
@keyframes aksrt-spin {
to { transform: rotate(360deg); }
}
/* 全屏加载遮罩 */
.aksrt-loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(2px);
}
.aksrt-loading-overlay__content {
text-align: center;
}
.aksrt-loading-overlay__spinner {
width: 36px;
height: 36px;
border: 3px solid var(--surface-3, #e8e8e8);
border-top-color: var(--color-primary, #f04494);
border-radius: 50%;
animation: aksrt-spin 0.6s linear infinite;
margin: 0 auto 12px;
}
.aksrt-loading-overlay__text {
font-size: 13px;
color: var(--color-ink-3, #777);
}
/* 行内加载(替换内容时) */
.aksrt-loading-inline {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--color-ink-4, #999);
font-size: 12px;
gap: 6px;
}
.aksrt-loading-inline__dot {
width: 6px;
height: 6px;
background: var(--color-primary, #f04494);
border-radius: 50%;
animation: aksrt-dot-pulse 1.2s ease-in-out infinite;
}
.aksrt-loading-inline__dot:nth-child(2) {
animation-delay: 0.2s;
}
.aksrt-loading-inline__dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes aksrt-dot-pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
/* 页面加载进度条 */
.aksrt-progress-bar {
position: fixed;
top: 0;
left: 0;
height: 2px;
background: var(--color-primary, #f04494);
z-index: 10000;
transition: width 0.3s ease;
}
/* 减弱动画偏好 */
@media (prefers-reduced-motion: reduce) {
.aksrt-loading__spinner,
.aksrt-loading-overlay__spinner {
animation-duration: 1.5s;
}
.aksrt-loading-inline__dot {
animation: none;
opacity: 0.6;
}
}
+2 -18
View File
@@ -195,7 +195,6 @@ main.container,
* All compact identity marks share one line-height baseline: VIP, level,
* certification, and worn medals remain visually aligned in every surface.
*/
.aksrt-vip-badge,
.aksrt-user-level-badge,
.aksrt-user-level-icon,
.aksrt-identity-medal,
@@ -212,17 +211,6 @@ main.container,
flex: 0 0 auto;
box-sizing: border-box;
}
.aksrt-vip-badge__icon {
display: block;
width: auto;
height: 1.1em;
max-width: 6em;
object-fit: contain;
}
.aksrt-vip-badge > i {
font-size: .9em;
line-height: 1;
}
.aksrt-user-level-badge {
padding: 0 .35em;
font-size: .78em;
@@ -236,11 +224,6 @@ main.container,
height: 1em;
vertical-align: -.12em;
}
.aksrt-vip-badge--sm {
height: 1.1em;
padding: 0;
font-size: inherit;
}
/* No-wrap inline name rows — keep the level icon on the same line as the name.
* The non-standard <name> element (legacy compat) plus .user-name / author-hero /
@@ -259,7 +242,8 @@ main.container,
.user-card.author .user-name > name {
overflow: visible;
}
.user-card.author .user-name > name > .display-name {
.user-card.author .user-name > name > .display-name,
.user-card.widget .user-name > name > .display-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
-95
View File
@@ -192,20 +192,6 @@
line-height: 1.65;
}
.aksrt-dl-modal__alert {
margin: 0 0 14px;
padding: 11px 13px;
font-size: 13px;
line-height: 1.6;
border-radius: var(--radius, 8px);
}
.aksrt-dl-modal__alert--warn {
color: var(--color-warning-text, #ad6800);
background: var(--color-warning-bg, #fffbe6);
border: 1px solid #fde68a;
}
/* 提取码卡片 */
.aksrt-dl-modal__code-card {
padding: 13px 15px;
@@ -223,14 +209,6 @@
letter-spacing: 0.08em;
}
.aksrt-dl-modal__code-row {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.aksrt-dl-modal__code {
display: block;
min-width: 0;
@@ -250,64 +228,6 @@
white-space: normal;
}
.aksrt-dl-modal__copy {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
height: 30px;
padding: 0 14px;
color: var(--focus-color, #f04494);
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1;
background: var(--focus-color-opacity1, rgba(240, 68, 148, 0.1));
border: 0;
border-radius: 999px;
cursor: pointer;
}
.aksrt-dl-modal__copy:hover {
background: var(--focus-color-opacity3, rgba(240, 68, 148, 0.3));
}
.aksrt-dl-modal__copy:focus-visible {
outline: 2px solid var(--focus-color, #f04494);
outline-offset: 1px;
}
.aksrt-dl-modal__copy.is-done {
color: var(--color-success, #10b981);
background: var(--aksrt-green-soft, rgba(16, 185, 129, 0.12));
}
/* 信息 chips 行 */
.aksrt-dl-modal__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 16px;
}
.aksrt-dl-modal__chip {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 5px 11px;
color: var(--muted-color, #777);
font-size: 12px;
font-weight: 600;
background: var(--surface-2, rgba(0, 0, 0, 0.04));
border-radius: 999px;
}
.aksrt-dl-modal__chip i {
color: var(--focus-color, #f04494);
font-size: 12px;
}
/* 操作按钮 */
.aksrt-dl-modal__actions {
display: flex;
@@ -352,16 +272,6 @@
outline-offset: 2px;
}
.aksrt-dl-modal__download {
min-height: 48px;
font-size: 15px;
box-shadow: 0 10px 20px -8px rgba(var(--color-primary-rgb, 240, 68, 148), 0.55);
}
.aksrt-dl-modal__download:hover {
box-shadow: 0 12px 24px -8px rgba(var(--color-primary-rgb, 240, 68, 148), 0.65);
}
/* ============================================================
* 收银台(参考实现契约结构)
* ============================================================ */
@@ -845,11 +755,6 @@ body.aksrt-dl-modal-open {
.aksrt-dl-modal__code {
font-size: 18px;
}
.aksrt-dl-modal__chip {
padding: 4px 10px;
font-size: 11.5px;
}
}
@media (prefers-reduced-motion: reduce) {
+2 -38
View File
@@ -282,47 +282,11 @@
color: #fff;
}
/* ============ VIP 徽标 ============ */
.aksrt-vip-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
height: 1.1em;
padding: 0;
border-radius: 0;
font: inherit;
line-height: 1;
background: transparent;
color: inherit;
vertical-align: -.15em;
}
.aksrt-vip-level-icon,
.aksrt-vip-badge__icon {
/* Configured paid-content tier icons. */
.aksrt-vip-level-icon {
display: block;
width: auto;
height: 1.1em;
max-width: 6em;
object-fit: contain;
}
.aksrt-vip-badge i {
font-size: .9em;
line-height: 1;
}
.aksrt-vip-badge--sm {
height: 1.1em;
padding: 0;
font-size: inherit;
}
.aksrt-vip-badge--lg {
padding: 4px 12px;
font-size: 13px;
}
.aksrt-vip-badge--lg i {
font-size: 12px;
}
@@ -1,3 +1 @@
@import url("components/base.css");
@import url("components/reward-amounts.css");
@import url("components/custom-amount.css");
@@ -1,155 +0,0 @@
/* ============================================================
* 8. 打赏按钮
* ============================================================ */
.action-btn--reward {
position: relative;
}
.action-btn--reward.is-active {
color: var(--color-warning, #F59E0B);
}
.reward-modal {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
}
.reward-modal.is-open {
opacity: 1;
visibility: visible;
}
.reward-modal__overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
}
.reward-modal__card {
position: relative;
background: var(--surface-1, #fff);
border-radius: var(--radius-lg, 8px);
padding: 32px;
max-width: 360px;
width: 90%;
text-align: center;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
z-index: 1;
}
.reward-modal__title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: var(--color-ink);
}
.reward-modal__desc {
margin: 0 0 20px;
font-size: 14px;
color: var(--color-ink-3);
}
.reward-modal__qr {
width: 200px;
height: 200px;
margin: 0 auto 16px;
border-radius: var(--radius, 10px);
overflow: hidden;
}
.reward-modal__qr img {
width: 100%;
height: 100%;
object-fit: contain;
}
.reward-modal__close {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--surface-2, #F4F5F7);
color: var(--color-ink-3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.reward-modal__close:hover {
background: var(--surface-3, #ECEEF1);
}
.reward-modal__content > p {
margin: -4px 0 18px;
}
.reward-amounts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 18px 0;
}
.reward-amount {
min-height: 42px;
border: 1px solid var(--color-line-soft, rgba(15, 23, 42, 0.1));
border-radius: 10px;
background: var(--surface-2);
color: var(--color-ink-2);
cursor: pointer;
font-weight: 700;
}
.reward-amount.is-active {
border-color: var(--color-primary);
background: var(--color-primary-soft);
color: var(--color-primary);
}
.reward-custom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
color: var(--color-ink-3);
font-size: 13px;
}
.reward-custom input {
width: 140px;
min-height: 40px;
border: 1px solid var(--color-line-soft, rgba(15, 23, 42, 0.1));
border-radius: 9px;
background: var(--surface-1);
color: var(--color-ink);
padding: 0 12px;
}
.reward-modal__submit {
width: 100%;
min-height: 42px;
}
.reward-modal__message {
min-height: 20px;
margin-top: 10px;
color: var(--color-primary);
font-size: 12px;
text-align: center;
}
@@ -1,54 +1,3 @@
/* ---- custom amount ---- */
.reward-custom {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.reward-custom__input {
flex: 1;
height: 40px;
padding: 0 12px;
border: 1px solid var(--color-line, #e5e7eb);
border-radius: var(--radius, 10px);
background: var(--surface-2, #F4F5F7);
color: var(--color-ink);
font-size: 14px;
text-align: center;
outline: none;
transition: border-color 0.2s;
-moz-appearance: textfield;
}
.reward-custom__input::-webkit-inner-spin-button,
.reward-custom__input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.reward-custom__input:focus {
border-color: var(--color-primary, #f04494);
}
.reward-custom__confirm {
flex-shrink: 0;
height: 40px;
padding: 0 20px;
border: none;
border-radius: var(--radius, 10px);
background: var(--color-primary, #f04494);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.reward-custom__confirm:hover {
opacity: 0.85;
}
/* ============================================================
* 9. 公告跑马灯 Widget
* ============================================================ */
@@ -355,34 +304,6 @@
transition: transform 0.1s linear;
}
/* ============================================================
* 18. VIP 等级标识
* ============================================================ */
.aksrt-vip-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-pill, 999px);
font-size: 11px;
font-weight: 600;
}
.aksrt-vip-badge--gold {
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #fff;
}
.aksrt-vip-badge--silver {
background: linear-gradient(135deg, #C0C0C0, #A8A8A8);
color: #fff;
}
.aksrt-vip-badge--bronze {
background: linear-gradient(135deg, #CD7F32, #A0522D);
color: #fff;
}
/* ============================================================
* 20. 用户等级进度条
* ============================================================ */
@@ -1,45 +0,0 @@
/* ---- reward amounts ---- */
.reward-amounts {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
}
.reward-amounts__btn {
display: inline-flex;
align-items: baseline;
gap: 2px;
padding: 8px 16px;
border: 1px solid var(--color-line, #e5e7eb);
border-radius: var(--radius, 10px);
background: var(--surface-2, #F4F5F7);
color: var(--color-ink);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.reward-amounts__btn:hover {
border-color: var(--color-primary, #f04494);
background: rgba(240, 68, 148, 0.06);
}
.reward-amounts__btn.is-active {
border-color: var(--color-primary, #f04494);
background: var(--color-primary, #f04494);
color: #fff;
}
.reward-amounts__currency {
font-size: 13px;
font-weight: 400;
}
.reward-amounts__value {
font-size: 15px;
}
+5 -74
View File
@@ -1,8 +1,11 @@
/**
* AKSRT Enhancements — 全局 UI 增强
*
* 包含:骨架屏、悬停动效、暗色过渡、滚动感知
* 包含:悬停动效、暗色过渡、滚动感知
* 所有选择器使用 aksrt- 前缀避免冲突
*
* 骨架屏不在此文件:唯一契约是 .placeholder 块 + .post_ajax_loader 容器
* (inc/helpers/posts-renderer/markup.php 的 aksrt_render_placeholder)。
*/
@keyframes aksrt-spin {
@@ -11,79 +14,7 @@
}
/* ============================================================
* 1. 骨架屏加载动画
* ============================================================ */
.aksrt-skeleton {
background: linear-gradient(
90deg,
rgba(121, 121, 121, 0.08) 25%,
rgba(121, 121, 121, 0.15) 37%,
rgba(121, 121, 121, 0.08) 63%
);
background-size: 400% 100%;
animation: aksrt-skeleton-shimmer 1.5s ease infinite;
border-radius: 4px;
}
.aksrt-skeleton--image {
height: 0;
padding-bottom: 66.67%; /* 3:2 aspect ratio */
}
.aksrt-skeleton--avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.aksrt-skeleton--title {
height: 22px;
width: 80%;
}
.aksrt-skeleton--text {
height: 16px;
width: 100%;
}
.aksrt-skeleton--text-short {
height: 16px;
width: 60%;
}
.aksrt-skeleton--button {
height: 36px;
width: 100px;
border-radius: 8px;
}
@keyframes aksrt-skeleton-shimmer {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
/* 骨架屏组合:文章卡片骨架 */
.aksrt-skeleton-card {
background: var(--surface-1, #fff);
border: 1px solid var(--color-line);
border-radius: 12px;
overflow: hidden;
padding: 0;
}
.aksrt-skeleton-card .aksrt-skeleton--image {
border-radius: 0;
}
.aksrt-skeleton-card__body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ============================================================
* 2. 悬停交互动效
* 1. 悬停交互动效
* ============================================================ */
/* 卡片放大 */
+127 -348
View File
@@ -1,404 +1,183 @@
/* ============================================================
* Floating Buttons
* 右侧悬浮按钮(结构/样式对齐参考主题 .float-right)
* 依赖 main.css 基础规则:.hover-show / .dropdown-menu(::before) /
* .drop-newadd / .fa-toggle-theme / badge
* ============================================================ */
.float-right {
--float-btn-size: 42px;
--float-btn-spacing: 8px;
--float-btn-opacity: 1;
--float-btn-color: var(--color-ink-3);
--float-btn-bg: transparent;
--float-btn-active-color: var(--color-primary);
position: fixed;
bottom: 60px;
right: 20px;
bottom: 96px;
z-index: 900;
display: flex;
flex-direction: column;
gap: var(--float-btn-spacing, 8px);
opacity: var(--float-btn-opacity, 1);
transition: transform var(--dur, 0.25s) var(--ease, ease), opacity var(--dur, 0.25s) var(--ease, ease);
padding-bottom: env(safe-area-inset-bottom, 0px);
z-index: 1030;
text-align: center;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
transition: 0.3s;
}
.float-right.no-shadow .float-btn {
/* 前台页面编辑齿轮的提示横条:参考主题共用 .abs-right(top:50% + right:.7em +
* translateY(-50%) 的垂直居中定位)。本站 legacy-utilities 的 .abs-right 是
* top:0/right:0,横条会贴到按钮顶角,故在此恢复参考几何。 */
.float-right .admin-set-page .admin-set-info {
position: absolute;
top: 50%;
right: 0.7em;
transform: translateY(-50%);
white-space: nowrap;
line-height: 1.5;
margin: 0;
box-shadow: none;
text-align: center;
z-index: 1;
}
.float-right.no-animation .float-btn {
transition: none;
body:has(.modal.in) .float-right,
.modal-open .float-right,
.imgbox-show .float-right,
.scroll-down .scroll-down-hide.float-right,
.scroll-ing .scrolling-hide.float-right {
transform: translateX(200%);
}
.float-right.no-animation .float-btn:hover {
transform: none;
body:has(.modal.in) .float-right.position-center,
.modal-open .float-right.position-center,
.imgbox-show .float-right.position-center,
.scroll-down .scroll-down-hide.float-right.position-center,
.scroll-ing .scrolling-hide.float-right.position-center {
transform: translate(200%, -50%);
}
.float-right.position-center {
top: 50%;
bottom: auto;
top: 50%;
transform: translateY(-50%);
}
.float-right.square {
gap: 0;
overflow: hidden;
border-radius: var(--radius-lg, 8px);
box-shadow: var(--shadow-md, 0 10px 30px rgba(17, 24, 39, 0.12));
}
.float-right .float-btn {
width: var(--float-btn-size);
min-height: var(--float-btn-size);
display: inline-flex;
align-items: center;
width: 40px;
min-height: 40px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
padding: 7px 0px;
font-size: 1.4em;
--this-color: var(--muted-2-color);
--this-bg: var(--float-btn-bg);
background: var(--this-bg);
position: relative;
color: var(--this-color, var(--float-btn-color));
background: var(--this-bg, var(--float-btn-bg));
border: 1px solid var(--color-line-soft, #f0f1f3);
text-decoration: none;
box-shadow: var(--shadow, 0 8px 24px rgba(17, 24, 39, 0.08));
transition: transform var(--dur-fast, 0.15s) var(--ease, ease), box-shadow var(--dur-fast, 0.15s) var(--ease, ease), color var(--dur-fast, 0.15s) var(--ease, ease), background var(--dur-fast, 0.15s) var(--ease, ease);
cursor: pointer;
outline: none;
overflow: visible;
color: var(--this-color) !important;
}
.float-right .float-btn text {
font-size: 12px;
line-height: 1;
margin-top: 5px;
}
.float-right badge {
transform: translate(-11px, -5px) scale(0.85);
margin-right: -50px;
right: auto;
left: 35px;
font-size: 12px;
position: absolute;
}
.filter .float-btn {
-webkit-backdrop-filter: saturate(2) blur(10px);
backdrop-filter: saturate(2) blur(10px);
}
.float-right.square .float-btn + .float-btn {
border-top: 1px solid var(--main-shadow);
}
.float-right.round .float-btn {
border-radius: 12px;
margin-top: 6px;
border-radius: 8px;
}
.float-right.square .float-btn {
border-radius: 0;
box-shadow: none;
border-top-width: 0;
}
.float-right.square .float-btn:first-child {
border-top-width: 1px;
}
.float-right .float-btn:hover {
transform: translateY(-2px);
color: var(--float-btn-active-color, var(--color-primary));
text-decoration: none;
box-shadow: var(--shadow-md, 0 10px 30px rgba(17, 24, 39, 0.12));
z-index: 2;
}
.float-right .float-btn:active {
transform: scale(0.96);
}
.float-right .float-btn::after {
content: "";
position: absolute;
inset: auto;
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
opacity: 0;
transform: scale(1);
pointer-events: none;
}
.float-right .float-btn.is-rippling::after {
animation: float-btn-ripple 0.55s ease-out;
}
.float-right .float-btn i {
font-size: 16px;
line-height: 1;
}
.float-right .float-btn.ontop {
.float-btn.ontop {
opacity: 0;
transition: 0.5s;
transform: translateY(10px);
visibility: hidden;
pointer-events: none;
}
.float-right .float-btn.ontop.is-visible {
.ontop.show {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
visibility: unset;
display: flex !important;
}
.float-right.has-blur-pc .float-btn {
backdrop-filter: saturate(1.5) blur(10px);
-webkit-backdrop-filter: saturate(1.5) blur(10px);
}
.float-right .hover-show-con {
position: absolute;
display: block;
right: calc(100% + 12px);
top: 50%;
transform: translateY(-50%);
min-width: 180px;
max-width: 280px;
padding: 12px;
border-radius: var(--radius-lg, 8px);
background: var(--surface-1, #fff);
border: 1px solid var(--color-line);
box-shadow: var(--shadow-lg, 0 18px 40px rgba(17, 24, 39, 0.18));
color: var(--color-ink-2);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity var(--dur-fast, 0.15s) var(--ease, ease), transform var(--dur-fast, 0.15s) var(--ease, ease), visibility var(--dur-fast, 0.15s) var(--ease, ease);
}
.float-right .hover-show-con::before {
content: "";
position: absolute;
top: 50%;
right: -8px;
width: 16px;
height: 16px;
background: inherit;
border-top: 1px solid var(--color-line);
border-right: 1px solid var(--color-line);
transform: translateY(-50%) rotate(45deg);
}
.float-right .hover-show-con img {
display: block;
width: 100%;
max-width: 160px;
height: auto;
margin: 0 auto 10px;
border-radius: var(--radius, 10px);
}
.float-right .hover-show-con p {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--color-ink-3);
text-align: center;
}
.float-btn-qrcode {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 120px;
margin: 0 auto 10px;
background: #fff;
border-radius: var(--radius, 10px);
}
.float-right .service-wechat .hover-show-con,
.float-right .service-qq .hover-show-con,
.float-right .qrcode-btn .hover-show-con {
padding: 14px;
text-align: center;
}
/* Page QR hover card: keep title/caption centered under the code */
.float-right .qrcode-btn .hover-show-con {
display: flex;
flex-direction: column;
align-items: center;
}
.float-right .qrcode-btn .hover-show-con p {
width: 100%;
text-align: center;
margin-top: 2px;
}
.float-right .qrcode-btn .hover-show-con .float-btn-qrcode__title {
width: 100%;
text-align: center;
margin: 2px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--color-ink-3);
}
.float-right .service-wechat .hover-show-con img,
.float-right .service-qq .hover-show-con img {
margin: 0 auto;
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.12);
}
.float-right .service-wechat .hover-show-con p,
.float-right .service-qq .hover-show-con p {
display: none;
}
.float-right .newadd-btns .hover-show-con {
min-width: 196px;
.float-btn .hover-show-con {
right: 35px;
left: auto;
width: 200px;
margin-right: 15px;
top: 0;
font-size: 14px;
line-height: 1.4;
color: var(--main-color);
padding: 10px;
margin-top: 0;
border-radius: var(--main-radius);
cursor: auto;
}
.float-add-menu {
display: flex;
flex-direction: column;
gap: 6px;
.float-btn .dropdown-menu::before {
left: auto;
top: 15px;
right: -5px;
}
.float-add-menu__item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
color: var(--color-ink-2);
text-decoration: none;
transition: background var(--dur-fast, 0.15s) var(--ease, ease), color var(--dur-fast, 0.15s) var(--ease, ease), transform var(--dur-fast, 0.15s) var(--ease, ease);
.float-btn.service-wechat .hover-show-con {
width: 120px;
top: -40px;
padding: 0;
}
.float-add-menu__item i {
width: 18px;
.float-btn.service-wechat .dropdown-menu::before {
top: 53px;
}
.float-btn.qrcode-btn .hover-show-con {
width: 150px;
top: -60px;
padding: 16px 5px 12px;
text-align: center;
color: var(--color-primary, var(--color-primary));
}
.float-add-menu__item:hover {
background: var(--color-primary-soft, rgba(240, 68, 148, 0.1));
color: var(--color-primary, var(--color-primary));
transform: translateX(-2px);
.float-btn.qrcode-btn .qrcode {
background: #fff;
display: inline-flex;
padding: 5px;
}
.float-btn-qrcode canvas,
.float-btn-qrcode img,
.float-btn-qrcode svg {
display: block;
margin: 0 auto;
.float-btn.qrcode-btn .dropdown-menu::before {
top: 73px;
}
.float-right .float-btn:hover .hover-show-con,
.float-right .float-btn:focus-visible .hover-show-con {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(-50%) translateX(-4px);
.float-btn.newadd-btns .drop-newadd {
width: 137px;
top: -74px;
}
.float-right.scrolling-hide.is-hidden,
.float-right.scroll-down-hide.is-hidden {
transform: translateX(calc(100% + 24px));
}
.float-right.position-center.scrolling-hide.is-hidden,
.float-right.position-center.scroll-down-hide.is-hidden {
transform: translateX(calc(100% + 24px)) translateY(-50%);
}
.float-right__html {
font-size: 13px;
line-height: 1.6;
}
.float-right__html a {
color: var(--color-primary, var(--color-primary));
}
.float-right__html a:hover {
color: var(--color-primary-hover, #7099CC);
}
@keyframes float-btn-ripple {
0% {
opacity: 0.22;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(14);
}
}
/*
* Theme float glyph: light → moon, dark → sun.
* Match nav toggle + theme.js contract. Class-driven only.
*/
.float-right .float-btn__icon-sun {
display: none !important;
}
.float-right .float-btn__icon-moon {
display: inline-block !important;
}
html.dark .float-right .float-btn__icon-moon,
body.theme-dark .float-right .float-btn__icon-moon,
body.dark-theme .float-right .float-btn__icon-moon {
display: none !important;
}
html.dark .float-right .float-btn__icon-sun,
body.theme-dark .float-right .float-btn__icon-sun,
body.dark-theme .float-right .float-btn__icon-sun {
display: inline-block !important;
}
/* VIP float rail entry */
.float-right .float-btn.pay-vip {
color: var(--this-color, #f5a623);
}
.float-right .float-btn.pay-vip:hover {
color: var(--this-color, #f5a623);
opacity: 0.9;
}
.float-right .float-btn.signin-loader {
cursor: pointer;
}
/* Dark: solid surface + brighter icon so transparent btn doesn't vanish */
html.dark .float-right,
body.theme-dark .float-right,
body.dark-theme .float-right {
--float-btn-color: var(--color-ink-2, #e5eef7);
--float-btn-bg: var(--surface-1, #323335);
}
html.dark .float-right .float-btn,
body.theme-dark .float-right .float-btn,
body.dark-theme .float-right .float-btn {
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
color: var(--this-color, var(--float-btn-color));
background: var(--this-bg, var(--float-btn-bg));
}
html.dark .float-right .hover-show-con,
body.theme-dark .float-right .hover-show-con,
body.dark-theme .float-right .hover-show-con {
background: var(--surface-2, #1f2430);
border-color: rgba(255, 255, 255, 0.08);
.float-btn.newadd-btns .drop-newadd::before {
left: auto;
top: 88px;
right: -5px;
}
@media (max-width: 767px) {
.float-btn .hover-show-con {
max-width: 240px;
}
}
@media (max-width: 640px) {
.float-right {
right: 12px;
bottom: 84px;
--float-btn-size: 40px;
}
.float-right.has-blur-mobile .float-btn {
backdrop-filter: saturate(1.5) blur(10px);
-webkit-backdrop-filter: saturate(1.5) blur(10px);
}
.float-right .hover-show-con {
max-width: min(240px, calc(100vw - 84px));
}
.float-right .float-btn.hide-on-mobile {
display: none;
}
}
@media (min-width: 768px) {
.float-right .float-btn.hide-on-pc {
display: none;
right: 10px;
}
}
-101
View File
@@ -18,101 +18,6 @@
.hide-on-pc { display: none !important; }
}
/* ============================================================
* 浮动右侧按钮组(模仿 legacy float-right)
* ============================================================ */
/* 对齐 legacy: bottom 60px, z-index 1030 */
.float-right-btns {
position: fixed;
right: 20px;
bottom: 60px;
z-index: 1030;
display: flex;
flex-direction: column;
gap: 8px;
transition: opacity 0.3s ease;
}
/* 对齐 legacy: 40px, 1.4em icon */
.float-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
min-height: 40px;
border-radius: 50%;
border: none;
background: var(--float-btn-bg);
color: var(--color-ink-3);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: transform var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
font-size: 1.4em;
text-decoration: none;
}
.float-btn:hover {
transform: translateY(-2px);
box-shadow: var(--shadow);
color: var(--color-primary);
background: var(--surface-page);
text-decoration: none;
}
.float-btn:active {
transform: scale(0.95);
}
/* 回到顶部按钮 — 滚动后显示 */
.float-btn--top {
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.2s ease;
}
.float-btn--top.is-visible {
opacity: 1;
visibility: visible;
display: flex;
}
/* 主题切换按钮 */
#float-theme-toggle .float-btn__icon-moon {
display: none !important;
}
#float-theme-toggle .float-btn__icon-sun {
display: inline-block !important;
}
#float-theme-toggle[aria-pressed="true"] .float-btn__icon-moon,
.theme-dark #float-theme-toggle .float-btn__icon-moon,
html.dark #float-theme-toggle .float-btn__icon-moon,
body.dark-theme #float-theme-toggle .float-btn__icon-moon {
display: inline-block !important;
}
#float-theme-toggle[aria-pressed="true"] .float-btn__icon-sun,
.theme-dark #float-theme-toggle .float-btn__icon-sun,
html.dark #float-theme-toggle .float-btn__icon-sun,
body.dark-theme #float-theme-toggle .float-btn__icon-sun {
display: none !important;
}
/* 移动端隐藏浮动按钮(用 tabbar 代替) */
@media (max-width: 767px) {
.float-right-btns {
right: 12px;
bottom: 80px;
}
.float-btn {
width: 38px;
height: 38px;
font-size: 14px;
}
}
/* ============================================================
* 移动端底部 Tab 栏(模仿 legacy footer-tabbar)
* ============================================================ */
@@ -603,12 +508,6 @@ html.dark .site-footer {
background: var(--surface-1);
}
.theme-dark .float-btn,
html.dark .float-btn {
background: var(--float-btn-bg);
box-shadow: var(--shadow-sm);
}
.theme-dark .footer-tabbar,
html.dark .footer-tabbar {
background: var(--blur-bg);
-12
View File
@@ -255,15 +255,3 @@
transform: translateX(-50%);
}
}
/* Float right buttons shell (kept from prior shell) */
#float-right-btns.float-right,
.float-right {
position: fixed;
right: 16px;
bottom: 60px;
z-index: 1030;
display: flex;
flex-direction: column;
gap: 8px;
}
+73 -347
View File
@@ -1,31 +1,35 @@
/**
* Header account popover.
*
* The shared legacy navigation styles provide the popover shell. This module
* owns AKSRT-specific account content such as check-in, wallet and VIP rows.
* The shared legacy navigation styles provide the popover shell, and every
* account row (avatar, VIP bar, check-in, wallet tiles, stats badges) renders
* through the reference legacy classes. This module only owns the navigation
* shell anchoring plus the account action-row geometry.
*/
/* The desktop action cluster owns one horizontal alignment context. */
.navbar-top .aksrt-navbar-actions {
display: flex;
align-items: center;
gap: 10px;
min-height: 50px;
margin-top: 0;
margin-bottom: 0;
padding: 0;
}
.navbar-top .aksrt-navbar-actions > *,
.navbar-top .aksrt-navbar-account,
.navbar-top .aksrt-navbar-account > li {
/* The reference keeps the right cluster as three floated .navbar-form groups.
* Bootstrap's base .navbar-form rule carries margin-top: 8px and the >=768px
* override only zeroes the horizontal margins, so every right-cluster group
* starts 8px below the top of .navbar-collapse. The 50px menu row and the logo
* put the navbar midline at 25px, so a 34px cross-size lands a group's midline
* at 8 + 17 = 25px. The publish/VIP chips already reach that line through their
* own 0.4em margin stacked on the group's 8px; the account row reaches it from
* its 34px icon tiles, and the theme group needs the same cross-size because
* its 29px toggle is otherwise 2.5px high. Nothing here may restate a 50px
* min-height: it would double-count the 8px and push that group's content 8px
* below the line while inflating the navbar from 50px to 58px. */
.navbar-top .aksrt-navbar-account {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 4px;
margin: 0;
}
.navbar-top .aksrt-navbar-account > li {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
padding: 0;
overflow: visible;
}
@@ -38,39 +42,6 @@
margin: 0;
}
.navbar-top .aksrt-navbar-account {
gap: 4px;
min-height: 50px;
margin: 0;
}
.navbar-top .aksrt-navbar-account.navbar-text {
float: none;
}
.navbar-top .aksrt-navbar-actions .navbar-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 29px;
margin: 0;
padding: .3em 1em !important;
border-radius: var(--radius-btn, 4px);
font-size: .8em;
line-height: 1.44;
}
.navbar-top .aksrt-navbar-actions .navbar-cta.radius {
border-radius: 50px;
}
.navbar-top .aksrt-navbar-actions .navbar-cta > .icon {
width: 1.2em;
height: 1.2em;
flex: 0 0 auto;
}
.navbar-top .aksrt-nav-icon,
.navbar-top .aksrt-nav-avatar {
display: inline-flex !important;
@@ -103,33 +74,34 @@
object-fit: cover;
}
.navbar-top .aksrt-navbar-actions .toggle-radius {
/* The account-row chips, the theme and the language toggles all want the 29px
* midline circle. The popover's quick-login buttons are .toggle-radius too and
* also live inside .navbar-form, but they must keep the .social_loginbar
* geometry from main.css: in a flex container flex-basis outranks width, so
* without this exemption the buttons collapse to 29px and lose the 5px side
* margins that space them apart. */
.navbar-top .navbar-form .toggle-radius:not(.social-login-item):not(.xh-social-item) {
flex: 0 0 29px;
margin: 0;
vertical-align: middle;
}
.navbar-top .aksrt-navbar-language {
/* Match the account row's 34px cross-size so the 29px toggle shares the 25px
* midline instead of sitting 2.5px above it. */
.navbar-top .navbar-form.flex.ac {
min-height: 34px;
}
.navbar-top .navbar-form .aksrt-navbar-language {
margin: 0;
}
/* Anchor the VIP preview to the full navigation row instead of relying on
* the absolute panel's static position beside the compact CTA. */
.navbar-top .aksrt-navbar-actions .navbar-vip-hover {
position: relative;
align-self: stretch;
align-items: center;
overflow: visible;
}
.navbar-top .aksrt-navbar-actions .navbar-vip-hover > .sub-vip-card {
top: 100%;
right: 0;
left: auto;
width: max-content;
max-width: calc(100vw - 24px);
margin: 0;
z-index: 1002;
/* The reference keeps the publish/VIP hover wrapper a plain inline-block, so the
* absolutely positioned .sub-vip-card takes its normal-flow static position.
* The shared action cluster otherwise forces inline-flex on every child, which
* would move that static position to the flex container's cross-start. */
.navbar-top .navbar-but > .hover-show {
display: inline-block;
}
/* Keep the drop-down search panel above a fixed header (z-index: 999). */
@@ -165,85 +137,41 @@
}
.aksrt-header-userbox {
width: 260px;
color: var(--color-ink-2);
line-height: 1.42857143;
}
.aksrt-header-userbox .user-info {
min-height: 44px;
padding: 2px;
/* public.css redeclares .muted-box after the legacy utility rules, which
* otherwise overrides the wallet tiles' padding-6 and radius4 utilities. */
.aksrt-header-userbox .muted-box.padding-6 {
background-color: var(--this-bg, var(--color-line-soft));
border-radius: 4px;
padding: 6px;
}
.aksrt-header-userbox .user-info > .avatar {
flex: 0 0 44px;
width: 44px;
height: 44px;
object-fit: cover;
/* core/buttons.css restates the legacy .but block after main.css, so its
* background / color / border / text-shadow declarations outrank .payvip-icon
* and replace the payvip surface with whatever .jb-* skin is on the element.
* The reference theme has no such compatibility layer, so its .payvip-icon
* declarations are the last word in the cascade. Re-assert them here, which
* loads after core/buttons.css, so every payvip surface (top-nav chip, user
* card status bar and CTA, guest CTA) keeps the legacy metallic gradient. */
.payvip-icon {
background: linear-gradient(300deg, #4c4d51, #2a2a31 15%, #85858a 40%, #393a3c 60%, #393838 80%, #5e5f62 100%);
color: #ece0e0;
--this-color: #ece0e0;
border: none;
text-shadow: none;
}
.aksrt-header-userbox .user-right,
.aksrt-header-userbox .user-right > .flex1 {
min-width: 0;
}
.aksrt-header-userbox .user-right b {
display: flex;
align-items: center;
gap: 4px;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.aksrt-header-userbox .display-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aksrt-header-userbox .user-right .aksrt-vip-badge,
.aksrt-header-userbox .user-right .aksrt-user-level-icon,
.aksrt-header-userbox .user-right .aksrt-identity-medal,
.aksrt-header-userbox .user-right .aksrt-header-user-medal,
.aksrt-header-userbox .user-right .aksrt-identity-certification-icon {
flex: 0 0 auto;
height: 1.1em;
}
.aksrt-header-userbox .user-info > .abs-right {
top: 50%;
right: 2px;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--surface-2);
color: var(--color-ink-3);
}
.aksrt-header-userbox .aksrt-notification-badge {
position: absolute;
top: -4px;
right: -4px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 17px;
height: 17px;
padding: 0 4px;
border: 2px solid var(--surface-1);
border-radius: 999px;
background: #e74c3c;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 1;
/* Keep the header check-in action flush with the VIP row's padded inner width. */
.aksrt-header-userbox > .mt6.em09 > .but.block {
display: block;
box-sizing: border-box;
width: 100%;
}
/* Top-nav bell badge (the card itself uses the legacy badge.top element). */
.aksrt-nav-notification .aksrt-notification-badge {
position: absolute;
top: 2px;
@@ -263,220 +191,18 @@
line-height: 1;
}
.aksrt-header-wallet {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 9px 10px;
border-radius: 8px;
background: var(--surface-2);
color: var(--color-ink-2);
font-size: 12px;
line-height: 1.35;
}
.aksrt-header-wallet:hover {
color: var(--color-ink);
}
.aksrt-header-wallet__item {
min-width: 0;
white-space: nowrap;
}
.aksrt-header-wallet__item i {
margin-right: 2px;
color: var(--color-ink-3);
}
.aksrt-header-wallet__item b {
margin-left: 2px;
color: var(--color-ink);
font-weight: 700;
}
.aksrt-header-checkin {
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 6px;
width: 100% !important;
min-height: 36px;
margin: 0;
padding: 8px 10px !important;
border: 0 !important;
border-radius: 6px !important;
background: #fff6db !important;
color: #8a5a00 !important;
font-size: 13px !important;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
}
.aksrt-header-checkin > i {
margin: 0 !important;
color: #b78103 !important;
}
.aksrt-header-checkin:hover {
background: #fff0c2 !important;
color: #8a5a00 !important;
}
/* 已签到态对齐参考主题:保留原配色(参考主题签到后仍为同一彩色按钮),
* 且按钮可点击(打开签到详情弹窗);仅请求进行中的禁用态降不透明度。 */
.aksrt-header-checkin:disabled,
.aksrt-header-checkin:disabled:hover {
cursor: default;
opacity: .85;
}
.aksrt-header-payvip__cta,
.aksrt-header-payvip:not(.is-vip) .payvip-icon,
.aksrt-header-payvip:not(.is-vip) .but {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
width: 100% !important;
min-width: 0 !important;
min-height: 36px;
margin: 0 !important;
padding: 8px 10px !important;
border: 0 !important;
border-radius: 6px !important;
background: linear-gradient(90deg, #ff6b6b, #f04494) !important;
color: #fff !important;
font-size: 13px !important;
font-weight: 600;
line-height: 1.3;
text-align: center;
white-space: nowrap;
}
.aksrt-header-payvip__cta > i,
.aksrt-header-payvip:not(.is-vip) .payvip-icon > i,
.aksrt-header-payvip:not(.is-vip) .but > i {
margin: 0 !important;
color: inherit !important;
}
.aksrt-header-payvip__cta:hover {
color: #fff !important;
opacity: .92;
}
.aksrt-header-payvip__active {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px !important;
width: 100%;
min-height: 42px;
padding: 7px 10px !important;
white-space: nowrap;
}
.aksrt-header-payvip__level {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.aksrt-header-payvip__level > i {
margin: 0 !important;
}
.aksrt-header-payvip__exp {
display: inline;
margin: 0;
padding: 0;
border: 0;
background: none !important;
box-shadow: none;
color: inherit;
font-size: .85em;
font-weight: 400;
line-height: 1.3;
opacity: .9;
white-space: nowrap;
}
/* Navigation links are globally capped at 50px. Account actions are taller
* icon tiles and must opt out or their labels overlap the following row. */
.navbar-top .aksrt-header-userbox .header-user-href {
align-items: flex-start;
gap: 4px;
}
* icon tiles and must opt out or their labels overlap the following row.
* The tile geometry itself stays with the shared legacy rules
* (.header-user-href > a and .header-user-href .toggle-radius in main.css)
* so the action row keeps the reference proportions. */
.navbar-top .aksrt-header-userbox .header-user-href > a,
.aksrt-header-userbox .header-user-href > a {
display: flex;
flex: 1 1 0;
flex-direction: column;
align-items: center;
min-width: 0;
max-height: none;
padding: 10px 2px 2px;
line-height: 1.35;
}
.aksrt-header-userbox .header-user-href > a:hover {
opacity: .75;
}
.aksrt-header-userbox .header-user-href .toggle-radius {
flex: 0 0 36px;
width: 36px;
height: 36px;
}
.aksrt-header-userbox .header-user-href > a > div:last-child {
max-width: 100%;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-navbar .aksrt-header-userbox {
width: 100%;
width: auto;
max-width: 400px;
margin-right: auto;
margin-left: auto;
}
body.dark-theme .aksrt-header-checkin {
background: rgba(255, 193, 7, .18) !important;
color: #f0c14b !important;
}
body.dark-theme .aksrt-header-checkin > i {
color: #f0c14b !important;
}
body.dark-theme .aksrt-header-wallet {
background: rgba(255, 255, 255, .06);
}
body.dark-theme .aksrt-header-wallet__item,
body.dark-theme .aksrt-header-wallet__item b {
color: inherit;
}
@media (max-width: 360px) {
.aksrt-header-userbox .header-user-href {
gap: 2px;
}
.aksrt-header-userbox .header-user-href .toggle-radius {
flex-basis: 34px;
width: 34px;
height: 34px;
}
margin: auto;
}
+4 -38
View File
@@ -483,44 +483,10 @@ body.site-layout-3 .sidebar,
overflow: hidden;
}
/* AJAX 筛选加载状态 */
.archive__ajax-wrap {
position: relative;
transition: opacity 0.2s ease;
}
.archive__ajax-wrap.is-loading {
opacity: 0.5;
pointer-events: none;
}
.archive__ajax-loading {
display: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
align-items: center;
justify-content: center;
z-index: 5;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(1px);
}
.archive__ajax-loading.is-loading {
display: flex;
}
.archive__ajax-spinner {
display: inline-block;
width: 28px;
height: 28px;
border: 3px solid var(--surface-3, #e8e8e8);
border-top-color: var(--color-primary, #f04494);
border-radius: 50%;
animation: aksrt-spin 0.6s linear infinite;
}
/* 归档面 AJAX 筛选的加载态与首页共用同一套骨架契约:
.post_ajax_loader 容器 + .is-loading 开关(见 frontend/modal-runtime.css)。
原先本文件另有一套独立 spinner 遮罩与 opacity 淡出,已删除 ——
全站只有一套加载语言。 */
@media (max-width: 767px) {
.term-page-header__body {
Loaded 100 of 475 files, more files were not shown because too many files have changed in this diff. Show more