Compare commits

...
43 Commits
Author SHA1 Message Date
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
98 changed files with 5157 additions and 2296 deletions

No files matched your search

@@ -5,11 +5,4 @@ export const extensionsApiMethods = {
testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', data: { ip } }) }, testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', data: { ip } }) },
testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', data: { text } }) }, testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', data: { text } }) },
/* AI 辅助 */
getAiStatus() { return this.request('/ai/status') },
updateAiSettings(data) { return this.request('/ai/settings', { method: 'POST', data }) },
generateSeo(data) { return this.request('/ai/seo-generate', { method: 'POST', data }) },
suggestSummary(data) { return this.request('/ai/summary-suggest', { method: 'POST', data }) },
suggestTags(data) { return this.request('/ai/tags-suggest', { method: 'POST', data }) },
} }
+236 -5
View File
@@ -476,6 +476,34 @@
<!-- Fieldset (single nested object) --> <!-- Fieldset (single nested object) -->
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset"> <div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
<template v-if="fieldsetSections.length">
<div
v-for="(section, sIndex) in fieldsetSections"
:key="section.title"
class="aksrt-field__accordion-section"
:class="{ 'is-collapsed': !isFieldsetSectionOpen(sIndex) }"
>
<div class="aksrt-field__accordion-section-head" @click="toggleFieldsetSection(sIndex)">
<span class="aksrt-field__accordion-section-title">{{ section.title }}</span>
<FaIcon
:icon="isFieldsetSectionOpen(sIndex) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
:size="12"
class="aksrt-field__accordion-section-toggle"
/>
</div>
<div v-show="isFieldsetSectionOpen(sIndex)" class="aksrt-field__accordion-section-body">
<AksField
v-for="field in sectionFields(section)"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
:form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
</div>
</div>
</template>
<template v-if="!fieldsetSections.length">
<AksField <AksField
v-for="field in visibleNestedFields" v-for="field in visibleNestedFields"
:key="field.key" :key="field.key"
@@ -484,6 +512,7 @@
:form-data="formData" :form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)" @update:model-value="updateFieldsetValue(field.key, $event)"
/> />
</template>
</div> </div>
<div v-else-if="schema.type === 'link'" class="aksrt-field__link"> <div v-else-if="schema.type === 'link'" class="aksrt-field__link">
@@ -517,12 +546,26 @@
animation="180" animation="180"
> >
<template #item="{ element, index }"> <template #item="{ element, index }">
<div class="aksrt-field__repeater-item"> <div class="aksrt-field__repeater-item" :class="{ 'is-collapsible': repeaterCollapsible, 'is-collapsed': repeaterCollapsible && !isRepeaterItemOpen(index) }">
<div class="aksrt-field__repeater-header"> <div
class="aksrt-field__repeater-header"
:class="{ 'is-clickable': repeaterCollapsible }"
@click="repeaterCollapsible ? toggleRepeaterItem(index) : undefined"
>
<div class="aksrt-field__repeater-title"> <div class="aksrt-field__repeater-title">
<i class="fa fas fa-grip-vertical aksrt-field__repeater-handle" aria-hidden="true"></i> <i class="fa fas fa-grip-vertical aksrt-field__repeater-handle" aria-hidden="true" @click.stop></i>
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span> <span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
<span v-if="repeaterCollapsible && repeaterItemSummary(element)" class="aksrt-field__repeater-summary">
{{ repeaterItemSummary(element) }}
</span>
</div> </div>
<div class="aksrt-field__repeater-header-actions" @click.stop>
<FaIcon
v-if="repeaterCollapsible"
:icon="isRepeaterItemOpen(index) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
:size="12"
class="aksrt-field__repeater-toggle"
/>
<n-button <n-button
size="tiny" size="tiny"
type="error" type="error"
@@ -533,7 +576,8 @@
<FaIcon icon="fa fas fa-trash-alt" :size="12" /> <FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button> </n-button>
</div> </div>
<div class="aksrt-field__repeater-fields"> </div>
<div v-show="!repeaterCollapsible || isRepeaterItemOpen(index)" class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key"> <template v-for="field in (schema.fields || [])" :key="field.key">
<AksField <AksField
v-if="matchDependency(field.dependsOn, element)" v-if="matchDependency(field.dependsOn, element)"
@@ -697,6 +741,7 @@ import FaIconPicker from './FaIconPicker.vue'
import AksMediaField from './AksMediaField.vue' import AksMediaField from './AksMediaField.vue'
import AksGalleryField from './AksGalleryField.vue' import AksGalleryField from './AksGalleryField.vue'
import AksCodeEditor from './AksCodeEditor.vue' import AksCodeEditor from './AksCodeEditor.vue'
import { buildFieldsetSections } from './fieldset-sections.js'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import api from '@/api' import api from '@/api'
@@ -730,6 +775,60 @@ const repeaterItems = computed(() => Array.isArray(props.modelValue) ? props.mod
const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0)) const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0))
const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max)) const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max))
/* ------------------------------------------------------------------ *
* 手风琴模式:schema.collapsible 开启后,每一项默认收起,仅显示标题行,
* 点击标题行展开/收起。defaultCollapsed 为 false 时改为默认全部展开。
* ------------------------------------------------------------------ */
const repeaterCollapsible = computed(() => !!props.schema.collapsible)
const repeaterDefaultOpen = computed(() => props.schema.defaultCollapsed === false)
// 只记录「与默认状态相反」的下标,新增/删除项时其余项的开合状态不受影响。
const toggledRepeaterItems = ref(new Set())
function isRepeaterItemOpen(index) {
if (!repeaterCollapsible.value) return true
return repeaterDefaultOpen.value !== toggledRepeaterItems.value.has(index)
}
function toggleRepeaterItem(index) {
const next = new Set(toggledRepeaterItems.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
toggledRepeaterItems.value = next
}
/** 收起态标题行上的一行摘要,取自 listPreview 配置。 */
function repeaterItemSummary(item) {
if (!repeaterCollapsible.value || !item) return ''
const cfg = getRepeaterPreviewConfig()
const titleKey = cfg.titleKey || 'title'
const title = String(item[titleKey] ?? '').trim()
const prefix = String(cfg.prefix || '')
if (title === '') return prefix
const desc = repeaterItemDuration(item)
return desc ? `${prefix}${title} · ${desc}` : `${prefix}${title}`
}
/** time/unit 形态的时长摘要:0 视为永久,其余按 day/month 补单位。 */
function repeaterItemDuration(item) {
const cfg = getRepeaterPreviewConfig()
const descKey = cfg.descKey
if (!descKey) return ''
const raw = item[descKey]
if (raw === '' || raw == null) return ''
const amount = Number(raw)
if (!Number.isFinite(amount)) return String(raw)
if (amount <= 0) return '永久'
const unitKey = cfg.descUnitKey
if (!unitKey) return String(raw)
const unit = String(item[unitKey] || '')
if (unit === 'month') return `${amount} 个月`
if (unit === 'day') return `${amount} 天`
return String(raw)
}
/** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */ /** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */
const repeaterDragList = computed({ const repeaterDragList = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []), get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
@@ -762,6 +861,35 @@ function fieldsetValue(field) {
return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default
} }
/* ------------------------------------------------------------------ *
* accordion/fieldset 的分块手风琴:schema.sections 声明每块的标题与字段
* key,默认全部收起,点击块标题展开。未声明 sections 时保持平铺渲染。
* ------------------------------------------------------------------ */
const fieldsetSections = computed(() => {
return buildFieldsetSections(props.schema)
})
const openFieldsetSections = ref(new Set())
function isFieldsetSectionOpen(index) {
return openFieldsetSections.value.has(index)
}
/** 分块内字段同样遵循 dependsOn,数据源是本 fieldset 的值对象。 */
function sectionFields(section) {
return (section.fields || []).filter((field) => matchDependency(field.dependsOn, props.modelValue))
}
function toggleFieldsetSection(index) {
const next = new Set(openFieldsetSections.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
openFieldsetSections.value = next
}
function updateFieldsetValue(key, value) { function updateFieldsetValue(key, value) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue) const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue ? props.modelValue
@@ -1053,6 +1181,8 @@ const repeaterDraft = ref({})
function createRepeaterItem() { function createRepeaterItem() {
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` } const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
for (const field of (props.schema.fields || [])) { for (const field of (props.schema.fields || [])) {
// UI-only chrome rows (notice / alert) are presentation only.
if (field.ui_only) continue
if (field.default !== undefined) { if (field.default !== undefined) {
newItem[field.key] = field.default newItem[field.key] = field.default
} else if (field.type === 'switch') { } else if (field.type === 'switch') {
@@ -1082,7 +1212,16 @@ function removeRepeaterItem(index) {
function updateRepeaterItem(index, fieldKey, value) { function updateRepeaterItem(index, fieldKey, value) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : [] const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current[index] = { ...current[index], [fieldKey]: value } const row = current[index]
if (row && typeof row === 'object') {
// 必须原地写,不能展开成新对象:drag key 存在以「对象身份」为键的 WeakMap
// 里,而行数据是 reactive 的——换一个新对象后,渲染时读到的是它的一个新
// reactive 代理,WeakMap 认不出它便生成新 key,整行重挂载、输入框失焦,
// 表现为打一个字就退出编辑。经代理原地写既保持身份,也照样触发响应式。
row[fieldKey] = value
} else {
current[index] = { [fieldKey]: value }
}
emit('update:modelValue', current) emit('update:modelValue', current)
} }
@@ -1184,6 +1323,15 @@ function confirmRepeaterModal() {
margin-bottom: 0; margin-bottom: 0;
} }
/* 结构化分组(会员商品/续费商品/积分兑换/升级价格)与相邻字段之间留出更明确的间隔,
* 避免两个分组上下贴在一起,和参考主题的留白一致。 */
.aksrt-field--group,
.aksrt-field--repeater,
.aksrt-field--accordion,
.aksrt-field--fieldset {
margin-bottom: 26px;
}
.aksrt-field__input-wrap { .aksrt-field__input-wrap {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -1449,6 +1597,48 @@ function confirmRepeaterModal() {
background: var(--aksrt-color-canvas); background: var(--aksrt-color-canvas);
} }
.aksrt-field__accordion-section + .aksrt-field__accordion-section {
margin-top: 14px;
}
.aksrt-field__accordion-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--aksrt-color-border);
border-radius: 6px;
background: var(--aksrt-color-surface);
cursor: pointer;
user-select: none;
font-size: 13px;
font-weight: 600;
color: var(--aksrt-color-text);
transition: border-color 0.15s, color 0.15s;
}
.aksrt-field__accordion-section-head:hover {
border-color: var(--aksrt-color-primary);
color: var(--aksrt-color-primary);
}
.aksrt-field__accordion-section-title {
min-width: 0;
}
.aksrt-field__accordion-section-toggle {
flex-shrink: 0;
color: var(--aksrt-color-text-secondary);
}
.aksrt-field__accordion-section-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 2px 2px;
}
.aksrt-field__repeater { .aksrt-field__repeater {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1477,6 +1667,47 @@ function confirmRepeaterModal() {
min-width: 0; 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 { .aksrt-field__repeater-handle {
cursor: grab; cursor: grab;
color: var(--aksrt-color-text-tertiary); color: var(--aksrt-color-text-tertiary);
@@ -64,9 +64,9 @@
<n-tabs type="line" animated> <n-tabs type="line" animated>
<n-tab-pane <n-tab-pane
v-for="tab in field.tabs" v-for="tab in field.tabs"
:key="tab.title || tab.key" :key="tab.key || tab.title"
:name="tab.title || tab.key" :name="tabName(tab)"
:tab="tab.title || tab.key" :tab="tabName(tab)"
> >
<template v-for="sub in (tab.fields || [])" :key="sub.key"> <template v-for="sub in (tab.fields || [])" :key="sub.key">
<AksField <AksField
@@ -231,6 +231,18 @@ function isFieldVisible(field) {
return matchDependsOn(field.dependsOn) return matchDependsOn(field.dependsOn)
} }
/**
* Tab label. `titleKey` binds the label to a live form value (e.g. the VIP level
* display name) so renaming a level renames its tab; `titleFallback` is used
* while that value is still empty.
*/
function tabName(tab) {
if (!tab) return ''
const dynamic = tab.titleKey ? props.formData?.[tab.titleKey] : ''
const label = typeof dynamic === 'string' ? dynamic.trim() : ''
return label || tab.titleFallback || tab.title || tab.key || ''
}
/** Whole group can be hidden (e.g. optional advanced blocks). */ /** Whole group can be hidden (e.g. optional advanced blocks). */
function isGroupVisible(group) { function isGroupVisible(group) {
return matchDependsOn(group.dependsOn) return matchDependsOn(group.dependsOn)
@@ -0,0 +1,24 @@
/**
* 把 accordion / fieldset 的 `schema.sections` 声明展开成「块标题 + 字段定义」。
*
* 参考主题把「升级价格」这类复合配置拆成若干可折叠块,每块用 keys 声明自己
* 包含哪些字段。这里是纯函数,方便在 Node 契约测试里直接用真实 schema 校验,
* 避免渲染层的一个小疏忽让整块手风琴静默退化成平铺。
*/
export function buildFieldsetSections(schema) {
const declared = schema && Array.isArray(schema.sections) ? schema.sections : []
if (!declared.length) return []
const byKey = {}
const nested = Array.isArray(schema.fields) ? schema.fields : []
for (const field of nested) {
if (field && field.key) byKey[field.key] = field
}
return declared.map((section) => ({
title: String((section && section.title) || ''),
fields: (Array.isArray(section && section.keys) ? section.keys : [])
.map((key) => byKey[key])
.filter(Boolean),
}))
}
@@ -1,4 +1,4 @@
import { renderFaIcon, schemaPage, customPage } from './helpers' import { renderFaIcon, schemaPage } from './helpers'
export const extensionsSection = { export const extensionsSection = {
key: 'extensions', key: 'extensions',
@@ -42,7 +42,7 @@ export const extensionsSection = {
label: '百度推送', label: '百度推送',
route: '/extensions/push', route: '/extensions/push',
title: '百度推送', title: '百度推送',
desc: '配置搜索引擎主动推送、批量提交和状态追踪。', desc: '配置百度自动推送、收录通道与触发规则;推送结果与「重新提交」在文章/分类编辑页查看。',
canonicalDomain: 'extensions', canonicalDomain: 'extensions',
section: 'push', section: 'push',
}), }),
@@ -56,15 +56,5 @@ export const extensionsSection = {
canonicalDomain: 'extensions', canonicalDomain: 'extensions',
section: 'security', section: 'security',
}), }),
customPage({
key: 'extensions-ai',
name: 'ExtensionAI',
label: 'AI 连接配置',
route: '/extensions/ai',
component: 'ExtensionAI',
status: 'available',
title: 'AI 连接配置',
desc: '配置 AI 生成连接;SEO 在文章独立 SEO 框,摘要/标签在前台投稿页。',
}),
], ],
} }
@@ -108,16 +108,6 @@ export const usersSection = {
canonicalDomain: 'users', canonicalDomain: 'users',
section: 'sms', section: 'sms',
}), }),
schemaPage({
key: 'users-wechat',
name: 'UsersWechat',
label: '微信通知',
route: '/users/wechat',
title: '微信通知',
desc: '配置微信模板消息、订阅/解绑和站外通知策略。',
canonicalDomain: 'users',
section: 'wechat',
}),
customPage({ customPage({
key: 'users-wechat-template', key: 'users-wechat-template',
name: 'UsersWechatTemplate', name: 'UsersWechatTemplate',
@@ -1,316 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader title="AI 连接配置" desc="配置 AI 生成能力的连接方式。优先使用 WordPress AI Connectors;也可填写 OpenAI 兼容接口。SEO 生成入口在文章「独立 SEO」框,摘要/标签入口在前台投稿页。" />
<div v-if="loadError" class="aksrt-ai__form-card aksrt-ai__load-error" role="alert">
<strong>加载失败:</strong>{{ loadError }}
</div>
<div class="aksrt-ai__status">
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--off'">
<i class="fa fab fa-android"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">可用状态</div>
<div class="aksrt-ai__status-value">{{ status.available ? '可用' : '不可用' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.wp_ai_available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
<i class="fa fas fa-plug"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">WP AI Client</div>
<div class="aksrt-ai__status-value">{{ status.wp_ai_available ? '已就绪' : '不可用' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon" :class="status.theme_available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
<i class="fa fas fa-key"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">主题 Provider</div>
<div class="aksrt-ai__status-value">{{ status.theme_available ? '已配置' : '未配置' }}</div>
</div>
</div>
<div class="aksrt-ai__status-card">
<div class="aksrt-ai__status-icon aksrt-ai__status-icon--info">
<i class="fa fas fa-road"></i>
</div>
<div class="aksrt-ai__status-info">
<div class="aksrt-ai__status-label">当前后端</div>
<div class="aksrt-ai__status-value">{{ backendLabel }}</div>
</div>
</div>
</div>
<div class="aksrt-ai__form-card">
<h3>主题 Provider 配置(回退链路)</h3>
<p class="aksrt-form-hint">已配置主题 Provider 时优先走下方接口;未配置时才尝试 WordPress AI Connectors。NewAPI/兼容网关请填到 <code>/v1</code>(也可只填域名,保存时会自动补全)。</p>
<div class="aksrt-form-group">
<label>
<input type="checkbox" v-model="form.enabled"> 启用主题 AI Provider
</label>
</div>
<div class="aksrt-form-group">
<label>AI Provider</label>
<select v-model="form.provider" class="aksrt-select">
<option value="none">不使用</option>
<option value="openai">OpenAI 兼容接口</option>
</select>
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>API Key</label>
<input v-model="form.api_key" type="password" class="aksrt-input" placeholder="sk-...">
<p class="aksrt-form-hint">保存后不会回显完整密钥</p>
</div>
<div class="aksrt-form-group" v-if="form.provider === 'openai'">
<label>API Base URL</label>
<input v-model="form.api_base" type="text" class="aksrt-input" placeholder="https://llm.example.com/v1">
<p class="aksrt-form-hint">示例:https://api.openai.com/v1 或 https://你的网关/v1(不要填到 /chat/completions)</p>
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>模型</label>
<input v-model="form.model" type="text" class="aksrt-input" placeholder="gpt-3.5-turbo">
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>最大 Token 数</label>
<input v-model.number="form.max_tokens" type="number" min="100" max="4000" class="aksrt-input">
</div>
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
<label>每分钟请求限制</label>
<input v-model.number="form.rate_limit" type="number" min="1" max="100" class="aksrt-input">
</div>
<div class="aksrt-form-actions">
<button class="aksrt-button aksrt-button--primary" :disabled="saving" @click="saveSettings">
{{ saving ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
<div class="aksrt-ai__form-card">
<h3>已接通能力</h3>
<table class="aksrt-table aksrt-table--sm">
<thead>
<tr>
<th>能力</th>
<th>入口</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="feat in features" :key="feat.key">
<td>{{ feat.name }}</td>
<td>{{ feat.scene }}</td>
<td>
<span class="aksrt-badge" :class="feat.ready ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
{{ feat.ready ? '可用' : '未就绪' }}
</span>
</td>
</tr>
</tbody>
</table>
<p class="aksrt-form-hint">SEO 开关与提示词请到「全局 → SEO」配置(seo_ai_enabled / ai_seo_*_prompt)。</p>
</div>
<div class="aksrt-ai__form-card aksrt-ai__notice">
<h3><i class="fa fas fa-exclamation-triangle"></i> 重要说明</h3>
<ul>
<li>AI 输出仅作为<strong>建议</strong>,不会自动写库;需用户点「更新/提交」才保存</li>
<li>主题 API Key 已配置时优先使用;否则回退 WordPress AI Connectors</li>
<li>生成接口要求登录与编辑/投稿权限;配置接口仍需管理员</li>
</ul>
</div>
</div>
</template>
<script>
import { ref, reactive, computed, onMounted } from 'vue';
import PageHeader from '@/components/PageHeader.vue';
import api from '@/api';
export default {
name: 'ExtensionAI',
components: { PageHeader },
setup() {
const status = ref({
enabled: false,
provider: 'none',
configured: false,
model: '',
api_key: '',
rate_limit: 10,
available: false,
backend: 'none',
wp_ai_available: false,
theme_available: false,
seo_ai_enabled: false,
write_assist: false,
});
const form = reactive({
enabled: false,
provider: 'none',
api_key: '',
api_base: 'https://api.openai.com/v1',
model: 'gpt-3.5-turbo',
max_tokens: 1000,
rate_limit: 10,
});
const saving = ref(false);
const loadError = ref('');
const backendLabel = computed(() => {
const map = { wp_ai: 'WP AI Client', theme: '主题 Provider', none: '无' };
return map[status.value.backend] || status.value.backend || '无';
});
const features = computed(() => {
const avail = !!status.value.available;
const seoReady = avail && !!status.value.seo_ai_enabled;
return [
{ key: 'seo', name: 'AI 生成 SEO', scene: '文章/分类「独立 SEO」框', ready: seoReady },
{ key: 'summary', name: 'AI 生成摘要', scene: '前台投稿页', ready: avail },
{ key: 'tags', name: 'AI 建议标签', scene: '前台投稿页', ready: avail },
];
});
function hydrateFromStatus(data) {
if (!data || typeof data !== 'object') return;
status.value = {
enabled: !!data.enabled,
provider: data.provider || 'none',
configured: !!data.configured,
model: data.model || '',
api_key: '',
rate_limit: data.rate_limit || 10,
available: !!data.available,
backend: data.backend || 'none',
wp_ai_available: !!data.wp_ai_available,
theme_available: !!data.theme_available,
seo_ai_enabled: !!data.seo_ai_enabled,
write_assist: !!data.write_assist,
};
form.enabled = !!data.enabled;
form.provider = data.provider || 'none';
form.model = data.model || 'gpt-3.5-turbo';
form.rate_limit = data.rate_limit || 10;
if (data.api_base) form.api_base = data.api_base;
if (typeof data.max_tokens === 'number' && data.max_tokens > 0) {
form.max_tokens = data.max_tokens;
}
}
async function loadStatus() {
loadError.value = '';
try {
const json = await api.getAiStatus();
hydrateFromStatus(json.data || {});
} catch (e) {
console.error('Load AI status failed:', e);
loadError.value = e?.message || '加载 AI 状态失败';
}
}
async function saveSettings() {
saving.value = true;
try {
const payload = {
enabled: form.enabled,
provider: form.provider,
api_base: form.api_base,
model: form.model,
max_tokens: form.max_tokens,
rate_limit: form.rate_limit,
};
// Only send api_key when the user entered a new one.
if (form.api_key && form.api_key !== '***') {
payload.api_key = form.api_key;
}
const json = await api.updateAiSettings(payload);
alert(json.message || '设置已保存');
await loadStatus();
} catch (e) {
console.error('Save failed:', e);
alert(e?.message || '保存失败');
} finally {
saving.value = false;
}
}
onMounted(() => loadStatus());
return { status, form, saving, loadError, features, backendLabel, saveSettings };
},
};
</script>
<style scoped>
.aksrt-ai__status {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.aksrt-ai__status-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.aksrt-ai__status-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.aksrt-ai__status-icon--ok { background: #d4edda; color: #155724; }
.aksrt-ai__status-icon--warn { background: #fff3cd; color: #856404; }
.aksrt-ai__status-icon--off { background: #f8d7da; color: #721c24; }
.aksrt-ai__status-icon--info { background: #d1ecf1; color: #0c5460; }
.aksrt-ai__status-value { font-size: 18px; font-weight: 600; color: var(--aksrt-color-text); }
.aksrt-ai__status-label { font-size: 12px; color: #888; }
.aksrt-ai__form-card {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.aksrt-ai__form-card h3 { margin: 0 0 16px; font-size: 16px; font-weight: 600; }
.aksrt-form-hint { font-size: 12px; color: #888; margin-top: 4px; }
.aksrt-ai__load-error {
background: #fdecea;
border-color: #f5c2c0;
color: #842029;
margin-bottom: 16px;
}
.aksrt-ai__notice { background: #fff8e1; border-color: #ffe082; }
.aksrt-ai__notice h3 { color: #e65100; }
.aksrt-ai__notice ul { margin: 0; padding-left: 20px; }
.aksrt-ai__notice li { margin-bottom: 8px; font-size: 14px; }
.aksrt-ai__notice code {
background: rgba(0,0,0,0.06);
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
}
.aksrt-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; }
.aksrt-badge--success { background: #d4edda; color: #155724; }
.aksrt-badge--warning { background: #fff3cd; color: #856404; }
.aksrt-table { width: 100%; border-collapse: collapse; }
.aksrt-table th, .aksrt-table td { border-bottom: 1px solid #eee; padding: 10px 8px; text-align: left; }
.aksrt-form-group { margin-bottom: 14px; }
.aksrt-input, .aksrt-select { width: 100%; max-width: 480px; padding: 8px 10px; }
.aksrt-button { padding: 8px 16px; border: 0; border-radius: 6px; cursor: pointer; }
.aksrt-button--primary { background: #1677ff; color: #fff; }
.aksrt-button:disabled { opacity: .6; cursor: not-allowed; }
</style>
@@ -1,3 +0,0 @@
export const extensionsPageLoaders = {
ExtensionAI: () => import('@/pages/extensions/ExtensionAI.vue'),
}
@@ -1,11 +1,9 @@
import { corePageLoaders } from './core' import { corePageLoaders } from './core'
import { usersPageLoaders } from './users' import { usersPageLoaders } from './users'
import { toolsPageLoaders } from './tools' import { toolsPageLoaders } from './tools'
import { extensionsPageLoaders } from './extensions'
export const componentLoaders = { export const componentLoaders = {
...corePageLoaders, ...corePageLoaders,
...usersPageLoaders, ...usersPageLoaders,
...toolsPageLoaders, ...toolsPageLoaders,
...extensionsPageLoaders,
} }
@@ -2,21 +2,16 @@ export const pushSchema = {
key: 'extensions.push', key: 'extensions.push',
title: '百度推送', title: '百度推送',
icon: 'fa fas fa-paper-plane', icon: 'fa fas fa-paper-plane',
description: '配置搜索引擎主动推送、批量提交和状态追踪。', description: '配置百度主动推送、收录通道和推送状态。',
defaults: { defaults: {
baidu_push_enabled: false, baidu_push_enabled: false,
baidu_push_token: '', baidu_push_token: '',
baidu_push_auto: true, baidu_push_auto: true,
baidu_push_on_publish: true, baidu_push_on_publish: true,
baidu_push_on_update: false, baidu_push_on_update: false,
baidu_push_batch_size: 50, baidu_push_normal: true,
baidu_push_fast: false,
baidu_push_log_enabled: true, baidu_push_log_enabled: true,
xzh_on: false,
xzh_appid: '',
xzh_render_tail: true,
xzh_jsonld_single: true,
xzh_jsonld_page: false,
xzh_jsonld_img: false,
}, },
groups: [ groups: [
{ {
@@ -25,7 +20,7 @@ export const pushSchema = {
icon: 'fa fas fa-rocket', icon: 'fa fas fa-rocket',
fields: [ fields: [
{ key: 'baidu_push_enabled', label: '启用百度推送', type: 'switch', default: false, help: '开启后将自动向百度搜索推送页面链接' }, { key: 'baidu_push_enabled', label: '启用百度推送', type: 'switch', default: false, help: '开启后将自动向百度搜索推送页面链接' },
{ key: 'baidu_push_token', label: '推送 Token', type: 'input', default: '', dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在百度搜索资源平台获取的推送准入 Token' }, { key: 'baidu_push_token', label: '推送 Token', type: 'input', default: '', dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在百度搜索资源平台获取的推送准入 Token(仅填 Token,勿填完整链接),并确保平台中的站点为本站' },
], ],
}, },
{ {
@@ -34,21 +29,17 @@ export const pushSchema = {
icon: 'fa fas fa-bolt', icon: 'fa fas fa-bolt',
fields: [ fields: [
{ key: 'baidu_push_auto', label: '自动推送 (页面加载时)', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在前台页面中嵌入百度自动推送 JS 代码' }, { key: 'baidu_push_auto', label: '自动推送 (页面加载时)', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在前台页面中嵌入百度自动推送 JS 代码' },
{ key: 'baidu_push_on_publish', label: '发布时主动推送', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } }, { key: 'baidu_push_on_publish', label: '发布时主动推送', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '文章 / 分类首次发布时推送链接;推送成功后会记录,不再重复提交' },
{ key: 'baidu_push_on_update', label: '更新时推送', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true } }, { key: 'baidu_push_on_update', label: '更新时推送', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '已发布内容再次保存时,若上次推送未成功则重试;需主动再次提交请在编辑页勾选「重新提交」' },
], ],
}, },
{ {
key: 'xzh', key: 'channel',
title: '熊掌号 / JSON-LD', title: '收录通道',
icon: 'fa fas fa-paw', icon: 'fa fas fa-inbox',
fields: [ fields: [
{ key: 'xzh_on', label: '启用熊掌号', type: 'switch', default: false }, { key: 'baidu_push_normal', label: '普通收录', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '每天可提交最多10万条有价值内容,收录速度较慢' },
{ key: 'xzh_appid', label: '百度 AppID', type: 'input', default: '', dependsOn: { key: 'xzh_on', value: true } }, { key: 'baidu_push_fast', label: '快速收录', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '百度新推出的高效收录接口,目前仅对部分优质站点开放,请确保您的站点已开放快速收录功能' },
{ key: 'xzh_render_tail', label: '文章底部显示熊掌号尾部', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } },
{ key: 'xzh_jsonld_single', label: '文章页 JSON-LD', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } },
{ key: 'xzh_jsonld_page', label: '页面 JSON-LD', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } },
{ key: 'xzh_jsonld_img', label: '不添加图片', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } },
], ],
}, },
{ {
@@ -56,8 +47,7 @@ export const pushSchema = {
title: '高级设置', title: '高级设置',
icon: 'fa fas fa-cog', icon: 'fa fas fa-cog',
fields: [ fields: [
{ key: 'baidu_push_batch_size', label: '批量推送条数', type: 'number', default: 50, min: 1, max: 500, dependsOn: { key: 'baidu_push_enabled', value: true } }, { key: 'baidu_push_log_enabled', label: '记录推送日志', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: 'WP_DEBUG 开启时,将推送结果写入错误日志' },
{ key: 'baidu_push_log_enabled', label: '记录推送日志', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } },
], ],
}, },
], ],
+2 -17
View File
@@ -2,7 +2,7 @@ export const seoSchema = {
key: 'global.seo', key: 'global.seo',
title: 'SEO 优化', title: 'SEO 优化',
icon: 'fa fas fa-search', icon: 'fa fas fa-search',
description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框;连接配置在「扩展 → AI 连接配置」。优先使用 WordPress AI Connectors。', description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框,连接由 WordPress AI Connectors 提供。',
defaults: { defaults: {
seo_enabled: true, seo_enabled: true,
seo_ai_enabled: false, seo_ai_enabled: false,
@@ -13,10 +13,6 @@ export const seoSchema = {
ai_seo_title_prompt: '', ai_seo_title_prompt: '',
ai_seo_keywords_prompt: '', ai_seo_keywords_prompt: '',
ai_seo_description_prompt: '', ai_seo_description_prompt: '',
seo_baidu_push: false,
seo_baidu_normal: false,
seo_baidu_fast: false,
seo_baidu_token: '',
seo_image_optimize: true, seo_image_optimize: true,
seo_image_default: '', seo_image_default: '',
seo_image_always_default: false, seo_image_always_default: false,
@@ -48,7 +44,7 @@ export const seoSchema = {
label: '启用 AI SEO 生成', label: '启用 AI SEO 生成',
type: 'switch', type: 'switch',
default: false, default: false,
help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先配置 AI 连接(WP Connectors 或扩展 → AI 连接配置)。', help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先在 WordPress 配置 AI Connectors。',
dependsOn: { key: 'seo_enabled', value: true }, dependsOn: { key: 'seo_enabled', value: true },
}, },
{ key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |', help: 'AI 生成标题时会用此连接符追加站点名。' }, { key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |', help: 'AI 生成标题时会用此连接符追加站点名。' },
@@ -95,17 +91,6 @@ export const seoSchema = {
}, },
], ],
}, },
{
key: 'baidu',
title: '百度推送',
icon: 'fa fas fa-paper-plane',
fields: [
{ key: 'seo_baidu_push', label: '启用百度自动推送', type: 'switch', default: false },
{ key: 'seo_baidu_normal', label: '普通收录推送', type: 'switch', default: false },
{ key: 'seo_baidu_fast', label: '快速收录推送', type: 'switch', default: false },
{ key: 'seo_baidu_token', label: '百度推送 Token', type: 'input', default: '', placeholder: '百度资源平台 Token' },
],
},
{ {
key: 'image', key: 'image',
title: '图片 SEO', title: '图片 SEO',
@@ -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_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: '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_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: '会员卡密仅需输入密码,不需要卡号;默认关闭,会员卡需输入卡号和密码(与其他卡密兑换一致)' }, { key: 'pay_vip_pass_charge_only_password', label: '会员卡密单密码模式', type: 'switch', default: false, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '会员卡密仅需输入密码,不需要卡号;默认关闭,会员卡需输入卡号和密码(与其他卡密兑换一致)' },
], ],
}, },
+340 -63
View File
@@ -1,27 +1,127 @@
/** /**
* VIP会员 — 参考主题结构:会员等级、会员权益(普通/一级/二级 tab)、 * VIP会员 — 结构 1:1 对照参考主题 Zibll:会员等级(开关)、会员权益
* 会员购买选项(一级/二级/续费/升级 tab)、卡密兑换与积分兑换。 * (普通/一级/二级 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', default: level === 1 ? '黄金会员' : '钻石会员', dependsOn: dep, help: '会员名称(例如“黄金会员”、“超级会员”)' },
{ key: `pay_user_vip_${level}_desc`, label: '一句话简介', type: 'input', default: '尊享专属特权', dependsOn: dep },
{ key: `pay_user_vip_${level}_equity`, label: '会员权益 HTML', type: 'textarea', sanitize_type: 'html', default: VIP_EQUITY_DEFAULT, rows: 4, dependsOn: dep, help: '建议使用 <li> 列表项' },
{ key: `vip_${level}_product_s`, label: '购买会员', type: 'switch', default: true, dependsOn: dep, help: '关闭后则不能付费购买会员,但可通过积分兑换会员,请确保已开启积分以及积分兑换会员功能' },
{
key: `vip_${level}_product`,
label: '会员商品',
type: 'group',
max: 8,
button_title: '添加会员商品',
default: [
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
...vipProductGroupOptions,
dependsOn: { all: [dep, { key: `vip_${level}_product_s`, value: true }] },
help: '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
fields: vipProductFields(),
},
{ key: `vip_${level}_img_icon`, label: '会员图标', type: 'upload', default: '', dependsOn: dep, help: `留空使用内置 vip-${level}.svg 图标;建议尺寸300x300` },
];
}
export const vipSchema = { export const vipSchema = {
key: 'payment.vip', key: 'payment.vip',
title: 'VIP会员', title: 'VIP会员',
icon: 'fa fas fa-gem', icon: 'fa fas fa-gem',
description: '双级会员:等级名称、权益(下载次数与限速)、套餐、续费/升级与积分兑换。', description: '双级会员:等级开关、权益(下载次数与限速)、会员商品、续费/升级与积分兑换。',
defaults: { defaults: {
nav_pay_vip: true, nav_pay_vip: true,
nav_user_pay_vip: true, nav_user_pay_vip: true,
pay_user_vip_1_s: true, pay_user_vip_1_s: true,
pay_user_vip_1_name: '一级会员', pay_user_vip_1_name: '黄金会员',
pay_user_vip_1_desc: '尊享一级会员特权', pay_user_vip_1_desc: '尊享专属特权',
pay_user_vip_1_equity: '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>', pay_user_vip_1_equity: VIP_EQUITY_DEFAULT,
vip_1_product_s: true,
vip_1_product: [
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_1_img_icon: '', vip_1_img_icon: '',
pay_user_vip_2_s: true, pay_user_vip_2_s: true,
pay_user_vip_2_name: '二级会员', pay_user_vip_2_name: '钻石会员',
pay_user_vip_2_desc: '尊享二级会员特权', pay_user_vip_2_desc: '尊享专属特权',
pay_user_vip_2_equity: '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>', pay_user_vip_2_equity: VIP_EQUITY_DEFAULT,
vip_2_product_s: true,
vip_2_product: [
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_2_img_icon: '', vip_2_img_icon: '',
pay_user_vip_desc: '', pay_user_vip_desc: '开通VIP会员,享受会员专属折扣以及多项特权',
pay_user_vip_more: '', pay_user_vip_more: '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>',
pay_download_limit: 0, pay_download_limit: 0,
pay_download_speed: 0, pay_download_speed: 0,
pay_download_limit_vip_1: 0, pay_download_limit_vip_1: 0,
@@ -29,19 +129,45 @@ export const vipSchema = {
pay_download_limit_vip_2: 0, pay_download_limit_vip_2: 0,
pay_download_speed_vip_2: 0, pay_download_speed_vip_2: 0,
vip_1_product_s: true, vip_1_product_s: true,
payment_vip_1_products: '30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡',
vip_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: true,
vip_renew_desc: '', vip_renew_desc: '立即续费会员,畅享VIP权益',
vip_renew_price_type: 'discount', vip_renew_price_type: 'discount',
vip_renew_discount: 8, vip_renew_discount: 8,
vip_renew_reduce: '20.00', vip_renew_reduce: '20.00',
vip_1_renew_product: [
{ price: 69, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 169, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_2_renew_product: [
{ price: 269, show_price: 599, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 369, show_price: 899, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_2_product_s: true,
vip_renew: true,
vip_renew_discount: 8,
vip_renew_reduce: '20.00',
vip_upgrade: true, vip_upgrade: true,
vip_upgrade_desc: '', vip_upgrade_desc: '升级VIP会员,享更多会员权益',
vip_upgrade_product: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级', vip_upgrade_product: {
month_to_month_s: true,
unit_price: '10.00',
unit_tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐',
unit_tag_class: 'jb-yellow',
jump_s: false,
jump_price: '199.00',
jump_show_price: '299.00',
jump_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
jump_tag_class: 'jb-red',
permanent_price: '199.00',
permanent_show_price: '299.00',
permanent_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
permanent_tag_class: 'jb-red',
},
pay_vip_points_exchange_s: false, pay_vip_points_exchange_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: '', pay_vip_points_exchange_desc: '',
}, },
groups: [ groups: [
@@ -67,17 +193,16 @@ export const vipSchema = {
{ key: 'nav_pay_vip', label: '导航栏购买按钮', type: 'switch', default: true, help: '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。' }, { key: 'nav_pay_vip', label: '导航栏购买按钮', type: 'switch', default: true, help: '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。' },
{ key: 'nav_user_pay_vip', label: '用户框购买按钮', type: 'switch', default: true, help: '在导航栏用户框内显示开通会员按钮。' }, { key: 'nav_user_pay_vip', label: '用户框购买按钮', type: 'switch', default: true, help: '在导航栏用户框内显示开通会员按钮。' },
{ key: 'pay_user_vip_1_s', label: '启用一级会员', type: 'switch', default: true }, { key: 'pay_user_vip_1_s', label: '启用一级会员', type: 'switch', default: true },
{ key: 'pay_user_vip_1_name', label: '一级会员显示名称', type: 'input', default: '一级会员', dependsOn: { key: 'pay_user_vip_1_s', value: true } }, {
{ key: 'pay_user_vip_1_desc', label: '一级会员一句话简介', type: 'input', default: '尊享一级会员特权', dependsOn: { key: 'pay_user_vip_1_s', value: true } }, key: 'vip_level_order_notice',
{ key: 'pay_user_vip_1_equity', label: '一级会员权益 HTML', type: 'textarea', default: '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>', dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '建议使用 <li> 列表项' }, label: '',
{ key: 'vip_1_img_icon', label: '一级会员图标 URL', type: 'input', default: '', dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '留空使用内置 vip-1.svg 图标' }, type: 'notice',
ui_only: true,
alertType: 'danger',
dependsOn: { all: [{ key: 'pay_user_vip_2_s', value: true }, { key: 'pay_user_vip_1_s', operator: 'falsy' }] },
content: '<div style="text-align:center"><b>必须先开启一级会员后才能开启二级会员!</b></div>',
},
{ key: 'pay_user_vip_2_s', label: '启用二级会员', type: 'switch', default: true }, { key: 'pay_user_vip_2_s', label: '启用二级会员', type: 'switch', default: true },
{ key: 'pay_user_vip_2_name', label: '二级会员显示名称', type: 'input', default: '二级会员', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{ key: 'pay_user_vip_2_desc', label: '二级会员一句话简介', type: 'input', default: '尊享二级会员特权', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{ key: 'pay_user_vip_2_equity', label: '二级会员权益 HTML', type: 'textarea', default: '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{ key: 'vip_2_img_icon', label: '二级会员图标 URL', type: 'input', default: '', dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '留空使用内置 vip-2.svg 图标' },
{ key: 'pay_user_vip_desc', label: '弹窗顶部会员说明', type: 'textarea', default: '' },
{ key: 'pay_user_vip_more', label: '弹窗底部补充说明 HTML', type: 'textarea', default: '' },
], ],
}, },
{ {
@@ -101,6 +226,8 @@ export const vipSchema = {
{ {
key: 'vip1', key: 'vip1',
title: '一级会员', title: '一级会员',
titleKey: 'pay_user_vip_1_name',
titleFallback: '一级会员',
fields: [ 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_limit_vip_1', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '一级会员每日最多可下载几个免费资源(为0则不限制)' },
{ key: 'pay_download_speed_vip_1', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' }, { key: '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', key: 'vip2',
title: '二级会员', title: '二级会员',
titleKey: 'pay_user_vip_2_name',
titleFallback: '二级会员',
fields: [ 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_limit_vip_2', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '二级会员每日最多可下载几个免费资源(为0则不限制)' },
{ key: 'pay_download_speed_vip_2', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' }, { key: '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,110 @@ export const vipSchema = {
{ {
key: 'vip1', key: 'vip1',
title: '一级会员', title: '一级会员',
titleKey: 'pay_user_vip_1_name',
titleFallback: '一级会员',
fields: [ fields: [
{ key: 'vip_1_product_s', label: '允许购买一级会员', type: 'switch', default: true, dependsOn: { key: 'pay_user_vip_1_s', value: true } }, ...vipLevelTabFields(1),
{
key: 'payment_vip_1_products',
label: '一级会员套餐',
type: 'textarea',
default: '30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡',
dependsOn: { key: 'vip_1_product_s', value: true },
help: '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久',
},
], ],
}, },
{ {
key: 'vip2', key: 'vip2',
title: '二级会员', title: '二级会员',
titleKey: 'pay_user_vip_2_name',
titleFallback: '二级会员',
fields: [ fields: [
{ key: 'vip_2_product_s', label: '允许购买二级会员', type: 'switch', default: true, dependsOn: { key: 'pay_user_vip_2_s', value: true } }, ...vipLevelTabFields(2),
{
key: 'payment_vip_2_products',
label: '二级会员套餐',
type: 'textarea',
default: '58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡',
dependsOn: { key: 'vip_2_product_s', value: true },
help: '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久',
},
], ],
}, },
{ {
key: 'renew', key: 'renew',
title: '会员续费', title: '会员续费',
fields: [ 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',
default: '立即续费会员,畅享VIP权益',
dependsOn: { key: 'vip_renew', value: true },
help: '续费会员的一句话简介',
},
{ {
key: 'vip_renew_price_type', key: 'vip_renew_price_type',
label: '续费价格类型', label: '续费价格类型',
type: 'radio', type: 'select',
default: 'discount', default: 'discount',
dependsOn: { key: 'vip_renew', value: true }, dependsOn: { key: 'vip_renew', value: true },
options: [ options: [
{ label: '相同价格', value: 'same' }, { label: '相同价格', value: 'same' },
{ label: '折扣价格', value: 'discount' }, { label: '折扣价格', value: 'discount' },
{ label: '优惠立减', value: 'reduce' }, { 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 +371,50 @@ export const vipSchema = {
title: '会员升级', title: '会员升级',
fields: [ fields: [
{ key: 'vip_upgrade', label: '允许升级', type: 'switch', default: true }, { key: 'vip_upgrade', label: '允许升级', type: 'switch', default: true },
{ key: 'vip_upgrade_desc', label: '升级说明', type: 'textarea', default: '', dependsOn: { key: 'vip_upgrade', value: true } }, { key: 'vip_upgrade_desc', label: '升级介绍', type: 'input', default: '升级VIP会员,享更多会员权益', dependsOn: { key: 'vip_upgrade', value: true }, help: '升级会员的一句话简介' },
{ {
key: 'vip_upgrade_product', key: 'vip_upgrade_product',
label: '升级产品', label: '升级价格',
type: 'textarea', type: 'accordion',
default: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级', default: {
month_to_month_s: true,
unit_price: '10.00',
unit_tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐',
unit_tag_class: 'jb-yellow',
jump_s: false,
jump_price: '199.00',
jump_show_price: '299.00',
jump_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
jump_tag_class: 'jb-red',
permanent_price: '199.00',
permanent_show_price: '299.00',
permanent_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
permanent_tag_class: 'jb-red',
},
dependsOn: { key: 'vip_upgrade', value: true }, dependsOn: { key: 'vip_upgrade', value: true },
help: '每行一条。永久VIP1升级:permanent|价格|标题;按剩余天:unit|每日单价|标题;限时跳转永久:jump|价格|标题', help: '会员升级的商品选项',
sections: [
{ title: '月费会员升级月费会员', keys: ['_upgrade_unit_notice', 'month_to_month_s', 'unit_price', 'unit_tag', 'unit_tag_class'] },
{ title: '月费会员升级永久会员', keys: ['_upgrade_jump_notice', 'jump_s', 'jump_price', 'jump_show_price', 'jump_tag', 'jump_tag_class'] },
{ title: '永久会员升级永久会员', keys: ['permanent_price', 'permanent_show_price', 'permanent_tag', 'permanent_tag_class'] },
],
fields: [
{ key: '_upgrade_unit_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '月费会员升级为月费会员,用户会员有效期不会改变;价格按照会员剩余天数计算升级价格,请在下方设置每天的单价' },
{ key: 'month_to_month_s', label: '月费会员升级月费会员', type: 'switch', default: true },
{ key: 'unit_price', label: '天单价', type: 'number', sanitize_type: 'money_decimal', default: '10.00', min: 0, unit: '元', dependsOn: { key: 'month_to_month_s', value: true } },
{ key: 'unit_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', rows: 1, dependsOn: { key: 'month_to_month_s', value: true }, help: '支持HTML,请注意控制长度' },
{ key: 'unit_tag_class', label: '标签颜色', type: 'palette', default: 'jb-yellow', dependsOn: { all: [{ key: 'month_to_month_s', value: true }, { key: 'unit_tag', operator: 'not_empty' }] }, options: vipTagPalette },
{ key: '_upgrade_jump_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '如果网站可以购买月费会员和永久会员,则可以开启此项;允许用户由月费会员直接升级到更高一级的永久会员,用户之前还未到期的部分将自动一起转为永久会员,可能会涉及到差价问题,请自行告知用户;如果网站没有永久会员的购买选项请务必关闭此项' },
{ key: 'jump_s', label: '月费会员升级永久会员', type: 'switch', default: false },
{ key: 'jump_price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '199.00', min: 0, unit: '元', dependsOn: { key: 'jump_s', value: true }, help: '月费会员升级永久会员的价格' },
{ key: 'jump_show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '299.00', min: 0, unit: '元', dependsOn: { key: 'jump_s', value: true }, help: '显示在执行价格前面,并划掉' },
{ key: 'jump_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', rows: 1, dependsOn: { key: 'jump_s', value: true }, help: '支持HTML,请注意控制长度' },
{ key: 'jump_tag_class', label: '标签颜色', type: 'palette', default: 'jb-red', dependsOn: { all: [{ key: 'jump_s', value: true }, { key: 'jump_tag', operator: 'not_empty' }] }, options: vipTagPalette },
{ key: 'permanent_price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '199.00', min: 0, unit: '元', help: '永久会员升级永久会员的价格' },
{ key: 'permanent_show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '299.00', min: 0, unit: '元', help: '显示在执行价格前面,并划掉' },
{ key: 'permanent_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', rows: 1, help: '支持HTML,请注意控制长度' },
{ key: 'permanent_tag_class', label: '标签颜色', type: 'palette', default: 'jb-red', dependsOn: { key: 'permanent_tag', operator: 'not_empty' }, options: vipTagPalette },
],
}, },
], ],
}, },
@@ -205,16 +427,71 @@ export const vipSchema = {
title: '积分兑换会员', title: '积分兑换会员',
icon: 'fa fas fa-database', icon: 'fa fas fa-database',
fields: [ 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', key: 'pay_vip_points_exchange_product',
label: '兑换套餐', label: '积分兑换明细',
type: 'textarea', type: 'group',
default: '1|500|30|积分月卡\n1|5000|0|积分永久', 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 }, 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',
default: '开通VIP会员,享受会员专属折扣以及多项特权',
dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] },
help: '显示在开通界面顶部一句话简介,可以为会员权益简介或者活动介绍',
},
{
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 } },
], ],
}, },
], ],
@@ -10,7 +10,6 @@ import { featuresSchema } from './features.js'
import { verifySchema } from './verify.js' import { verifySchema } from './verify.js'
import { levelSchema } from './level.js' import { levelSchema } from './level.js'
import { badgeSchema } from './badge.js' import { badgeSchema } from './badge.js'
import { wechatSchema } from './wechat.js'
import { reportSchema } from './report.js' import { reportSchema } from './report.js'
import { smsSchema } from './sms.js' import { smsSchema } from './sms.js'
@@ -23,7 +22,6 @@ export const usersSchemas = {
verify: verifySchema, verify: verifySchema,
level: levelSchema, level: levelSchema,
badge: badgeSchema, badge: badgeSchema,
wechat: wechatSchema,
report: reportSchema, report: reportSchema,
sms: smsSchema, sms: smsSchema,
} }
@@ -1,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: '在用户中心显示微信绑定/解绑按钮' },
],
},
],
}
+3 -2
View File
@@ -709,8 +709,9 @@
color: var(--color-primary, #f04494); color: var(--color-primary, #f04494);
} }
/* .widget a 的链接色不得覆盖 .but 按钮自身配色(zibll 的 zib-widget 不设置链接色) */ /* .widget a 的链接色不得覆盖 .but / .img-badge 自身配色(zibll 的 zib-widget 不设置链接色) */
.widget a.but { .widget a.but,
.widget a.img-badge {
color: var(--this-color, #fff); 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-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 .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
+78 -114
View File
@@ -916,6 +916,7 @@ strong, b {
/* === Full width === */ /* === Full width === */
.btn-block { .btn-block {
display: block;
width: 100%; width: 100%;
} }
@@ -961,6 +962,8 @@ strong, b {
.but > .fa:not(.fa-angle-right), .but > .fa:not(.fa-angle-right),
.but > i[class*="fa-"]:not(.fa-angle-right) { margin-right: 0.25em; } .but > i[class*="fa-"]:not(.fa-angle-right) { margin-right: 0.25em; }
.but.block { display: block; }
/* hover / active */ /* hover / active */
.but:focus, .but:focus,
.but:hover { opacity: 0.8; color: var(--this-color); } .but:hover { opacity: 0.8; color: var(--this-color); }
@@ -1092,7 +1095,7 @@ strong, b {
/* gradient modifiers (legacy .jb-*) */ /* gradient modifiers (legacy .jb-*) */
.jb-red { --this-bg: linear-gradient(135deg, #fd7a64 10%, #fb2d2d 100%); } .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-blue { --this-bg: linear-gradient(135deg, #59c3fb 10%, #268df7 100%); }
.jb-green { --this-bg: linear-gradient(135deg, #60e464 10%, #5cb85b 100%); } .jb-green { --this-bg: linear-gradient(135deg, #60e464 10%, #5cb85b 100%); }
.jb-purple { --this-bg: linear-gradient(135deg, #f98dfb 10%, #ea00f9 100%); } .jb-purple { --this-bg: linear-gradient(135deg, #f98dfb 10%, #ea00f9 100%); }
@@ -2711,27 +2714,29 @@ main.container,
* shell anchoring plus the account action-row geometry. * shell anchoring plus the account action-row geometry.
*/ */
/* The desktop action cluster owns one horizontal alignment context. */ /* The reference keeps the right cluster as three floated .navbar-form groups.
.navbar-top .aksrt-navbar-actions { * Bootstrap's base .navbar-form rule carries margin-top: 8px and the >=768px
display: flex; * override only zeroes the horizontal margins, so every right-cluster group
align-items: center; * starts 8px below the top of .navbar-collapse. The 50px menu row and the logo
gap: 10px; * put the navbar midline at 25px, so a 34px cross-size lands a group's midline
min-height: 50px; * at 8 + 17 = 25px. The publish/VIP chips already reach that line through their
margin-top: 0; * own 0.4em margin stacked on the group's 8px; the account row reaches it from
margin-bottom: 0; * its 34px icon tiles, and the theme group needs the same cross-size because
padding: 0; * 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-actions > *, .navbar-top .aksrt-navbar-account {
.navbar-top .aksrt-navbar-account,
.navbar-top .aksrt-navbar-account > li {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
flex: 0 0 auto; gap: 4px;
margin: 0;
} }
.navbar-top .aksrt-navbar-account > li { .navbar-top .aksrt-navbar-account > li {
position: relative; position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
padding: 0; padding: 0;
overflow: visible; overflow: visible;
} }
@@ -2744,39 +2749,6 @@ main.container,
margin: 0; 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-icon,
.navbar-top .aksrt-nav-avatar { .navbar-top .aksrt-nav-avatar {
display: inline-flex !important; display: inline-flex !important;
@@ -2809,33 +2781,34 @@ main.container,
object-fit: cover; 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; flex: 0 0 29px;
margin: 0; margin: 0;
vertical-align: middle; 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; margin: 0;
} }
/* Anchor the VIP preview to the full navigation row instead of relying on /* The reference keeps the publish/VIP hover wrapper a plain inline-block, so the
* the absolute panel's static position beside the compact CTA. */ * absolutely positioned .sub-vip-card takes its normal-flow static position.
.navbar-top .aksrt-navbar-actions .navbar-vip-hover { * The shared action cluster otherwise forces inline-flex on every child, which
position: relative; * would move that static position to the flex container's cross-start. */
align-self: stretch; .navbar-top .navbar-but > .hover-show {
align-items: center; display: inline-block;
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;
} }
/* Keep the drop-down search panel above a fixed header (z-index: 999). */ /* Keep the drop-down search panel above a fixed header (z-index: 999). */
@@ -2872,6 +2845,37 @@ main.container,
.aksrt-header-userbox { .aksrt-header-userbox {
color: var(--color-ink-2); color: var(--color-ink-2);
line-height: 1.42857143;
}
/* 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;
}
/* 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;
}
/* 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). */ /* Top-nav bell badge (the card itself uses the legacy badge.top element). */
@@ -2895,59 +2899,19 @@ main.container,
} }
/* Navigation links are globally capped at 50px. Account actions are taller /* Navigation links are globally capped at 50px. Account actions are taller
* icon tiles and must opt out or their labels overlap the following row. */ * icon tiles and must opt out or their labels overlap the following row.
.navbar-top .aksrt-header-userbox .header-user-href { * The tile geometry itself stays with the shared legacy rules
align-items: flex-start; * (.header-user-href > a and .header-user-href .toggle-radius in main.css)
gap: 4px; * so the action row keeps the reference proportions. */
}
.navbar-top .aksrt-header-userbox .header-user-href > a, .navbar-top .aksrt-header-userbox .header-user-href > a,
.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; 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 { .mobile-navbar .aksrt-header-userbox {
width: 100%; width: auto;
max-width: 400px; max-width: 400px;
margin-right: auto; margin: auto;
margin-left: auto;
}
@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;
}
} }
/* source: assets/css/frontend/card-primitives.css */ /* source: assets/css/frontend/card-primitives.css */
+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;--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 :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
+4 -1
View File
@@ -137,6 +137,7 @@
/* === Full width === */ /* === Full width === */
.btn-block { .btn-block {
display: block;
width: 100%; width: 100%;
} }
@@ -182,6 +183,8 @@
.but > .fa:not(.fa-angle-right), .but > .fa:not(.fa-angle-right),
.but > i[class*="fa-"]:not(.fa-angle-right) { margin-right: 0.25em; } .but > i[class*="fa-"]:not(.fa-angle-right) { margin-right: 0.25em; }
.but.block { display: block; }
/* hover / active */ /* hover / active */
.but:focus, .but:focus,
.but:hover { opacity: 0.8; color: var(--this-color); } .but:hover { opacity: 0.8; color: var(--this-color); }
@@ -313,7 +316,7 @@
/* gradient modifiers (legacy .jb-*) */ /* gradient modifiers (legacy .jb-*) */
.jb-red { --this-bg: linear-gradient(135deg, #fd7a64 10%, #fb2d2d 100%); } .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-blue { --this-bg: linear-gradient(135deg, #59c3fb 10%, #268df7 100%); }
.jb-green { --this-bg: linear-gradient(135deg, #60e464 10%, #5cb85b 100%); } .jb-green { --this-bg: linear-gradient(135deg, #60e464 10%, #5cb85b 100%); }
.jb-purple { --this-bg: linear-gradient(135deg, #f98dfb 10%, #ea00f9 100%); } .jb-purple { --this-bg: linear-gradient(135deg, #f98dfb 10%, #ea00f9 100%); }
+74 -113
View File
@@ -7,27 +7,29 @@
* shell anchoring plus the account action-row geometry. * shell anchoring plus the account action-row geometry.
*/ */
/* The desktop action cluster owns one horizontal alignment context. */ /* The reference keeps the right cluster as three floated .navbar-form groups.
.navbar-top .aksrt-navbar-actions { * Bootstrap's base .navbar-form rule carries margin-top: 8px and the >=768px
display: flex; * override only zeroes the horizontal margins, so every right-cluster group
align-items: center; * starts 8px below the top of .navbar-collapse. The 50px menu row and the logo
gap: 10px; * put the navbar midline at 25px, so a 34px cross-size lands a group's midline
min-height: 50px; * at 8 + 17 = 25px. The publish/VIP chips already reach that line through their
margin-top: 0; * own 0.4em margin stacked on the group's 8px; the account row reaches it from
margin-bottom: 0; * its 34px icon tiles, and the theme group needs the same cross-size because
padding: 0; * 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-actions > *, .navbar-top .aksrt-navbar-account {
.navbar-top .aksrt-navbar-account,
.navbar-top .aksrt-navbar-account > li {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
flex: 0 0 auto; gap: 4px;
margin: 0;
} }
.navbar-top .aksrt-navbar-account > li { .navbar-top .aksrt-navbar-account > li {
position: relative; position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
padding: 0; padding: 0;
overflow: visible; overflow: visible;
} }
@@ -40,39 +42,6 @@
margin: 0; 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-icon,
.navbar-top .aksrt-nav-avatar { .navbar-top .aksrt-nav-avatar {
display: inline-flex !important; display: inline-flex !important;
@@ -105,33 +74,34 @@
object-fit: cover; 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; flex: 0 0 29px;
margin: 0; margin: 0;
vertical-align: middle; 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; margin: 0;
} }
/* Anchor the VIP preview to the full navigation row instead of relying on /* The reference keeps the publish/VIP hover wrapper a plain inline-block, so the
* the absolute panel's static position beside the compact CTA. */ * absolutely positioned .sub-vip-card takes its normal-flow static position.
.navbar-top .aksrt-navbar-actions .navbar-vip-hover { * The shared action cluster otherwise forces inline-flex on every child, which
position: relative; * would move that static position to the flex container's cross-start. */
align-self: stretch; .navbar-top .navbar-but > .hover-show {
align-items: center; display: inline-block;
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;
} }
/* Keep the drop-down search panel above a fixed header (z-index: 999). */ /* Keep the drop-down search panel above a fixed header (z-index: 999). */
@@ -168,6 +138,37 @@
.aksrt-header-userbox { .aksrt-header-userbox {
color: var(--color-ink-2); color: var(--color-ink-2);
line-height: 1.42857143;
}
/* 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;
}
/* 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;
}
/* 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). */ /* Top-nav bell badge (the card itself uses the legacy badge.top element). */
@@ -191,57 +192,17 @@
} }
/* Navigation links are globally capped at 50px. Account actions are taller /* Navigation links are globally capped at 50px. Account actions are taller
* icon tiles and must opt out or their labels overlap the following row. */ * icon tiles and must opt out or their labels overlap the following row.
.navbar-top .aksrt-header-userbox .header-user-href { * The tile geometry itself stays with the shared legacy rules
align-items: flex-start; * (.header-user-href > a and .header-user-href .toggle-radius in main.css)
gap: 4px; * so the action row keeps the reference proportions. */
}
.navbar-top .aksrt-header-userbox .header-user-href > a, .navbar-top .aksrt-header-userbox .header-user-href > a,
.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; 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 { .mobile-navbar .aksrt-header-userbox {
width: 100%; width: auto;
max-width: 400px; max-width: 400px;
margin-right: auto; margin: auto;
margin-left: auto;
}
@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;
}
} }
-50
View File
@@ -166,56 +166,6 @@ html.dark .archive-timeline__badge {
font-size: 13px; font-size: 13px;
} }
/* 社交登录分隔线 */
.social-login-divider {
display: flex;
align-items: center;
margin: 24px 0 16px;
color: var(--color-ink-3, #94a3b8);
font-size: 13px;
}
.social-login-divider::before,
.social-login-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-line, #e2e8f0);
}
.social-login-divider span {
padding: 0 16px;
white-space: nowrap;
}
.oauth-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
margin-top: 16px;
}
.oauth-btn {
width: 44px;
height: 44px;
border-radius: var(--radius-btn);
border: 1px solid var(--color-line);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-ink-3);
transition: border-color 0.2s, color 0.2s;
}
.oauth-btn i {
font-size: 22px;
line-height: 1;
display: block;
}
.oauth-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.auth-footer-link { .auth-footer-link {
text-align: center; text-align: center;
margin-top: 20px; margin-top: 20px;
+3 -2
View File
@@ -50,8 +50,9 @@
color: var(--color-primary, #f04494); color: var(--color-primary, #f04494);
} }
/* .widget a 的链接色不得覆盖 .but 按钮自身配色(zibll 的 zib-widget 不设置链接色) */ /* .widget a 的链接色不得覆盖 .but / .img-badge 自身配色(zibll 的 zib-widget 不设置链接色) */
.widget a.but { .widget a.but,
.widget a.img-badge {
color: var(--this-color, #fff); color: var(--this-color, #fff);
} }
+81 -2
View File
@@ -102,6 +102,85 @@
cursor: wait; cursor: wait;
} }
.vip-expdate-tag { /* 会员弹窗骨架屏(pay-vip.js vipSkeleton()):仿真实弹窗布局——
font-size: 12px; 用户框 + 等级卡行 + 双列套餐面板。线条/色块与 shimmer 动画复用全局
.aksrt-modal-runtime__skeleton-line/-block 与 @keyframes
aksrt-modal-skeleton(build/public.min.css 已全局入队);
这里只定义 VIP 专属形状(头像圆、tab 卡、双列 grid、按钮条),
与 .pay-vip-form-row 的 640px 双列阈值保持一致。 */
.aksrt-vip-modal-skeleton {
display: flex;
flex-direction: column;
gap: 14px;
padding: 15px;
}
.aksrt-vip-modal-skeleton__user {
display: flex;
align-items: center;
gap: 10px;
}
.aksrt-vip-modal-skeleton__avatar {
width: 42px;
height: 42px;
border-radius: 50%;
flex: none;
}
.aksrt-vip-modal-skeleton__lines {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 0;
}
.aksrt-vip-modal-skeleton__tabs {
display: flex;
gap: 10px;
}
.aksrt-vip-modal-skeleton__tab {
flex: 1;
height: 56px;
}
.aksrt-vip-modal-skeleton__pane {
display: grid;
gap: 12px;
}
.aksrt-vip-modal-skeleton__info {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
}
.aksrt-vip-modal-skeleton__products {
display: flex;
flex-direction: column;
gap: 10px;
}
.aksrt-vip-modal-skeleton__button {
height: 38px;
}
/* 专属形状共用骨架底色与 shimmer(值与 modal-runtime.css 的线条/块一致)。 */
.aksrt-vip-modal-skeleton__avatar,
.aksrt-vip-modal-skeleton__tab,
.aksrt-vip-modal-skeleton__button {
display: block;
border-radius: 8px;
background: linear-gradient(90deg, rgba(148, 163, 184, 0.16), rgba(148, 163, 184, 0.3), rgba(148, 163, 184, 0.16));
background-size: 200% 100%;
animation: aksrt-modal-skeleton 1.2s ease-in-out infinite;
}
@media (min-width: 640px) {
.aksrt-vip-modal-skeleton__pane {
grid-template-columns: 1fr 1.2fr;
}
} }
+9 -14
View File
@@ -5936,7 +5936,7 @@ body:has(.modal.in) .float-btn,
} }
.payvip-icon:before { .payvip-icon:before {
content: '\f219'; content: '\f3a5';
font: normal normal 900 14px/1 "Font Awesome 5 Free"; font: normal normal 900 14px/1 "Font Awesome 5 Free";
font-size: 3.5em; font-size: 3.5em;
right: -0.2em !important; right: -0.2em !important;
@@ -5944,16 +5944,6 @@ body:has(.modal.in) .float-btn,
opacity: 0.06; opacity: 0.06;
} }
.vipbg-v1 {
background: linear-gradient(60deg, #f0bf81 0, #fdefd6 30%, #f2deb9 40%, #fdc277 80%, #f9e1b7 100%);
color: #8c5728;
--this-color: #8c5728;
}
.vipbg-v1:before {
content: '\f006';
}
.vip-texticon { .vip-texticon {
font-size: 0.85em; font-size: 0.85em;
background: transparent; background: transparent;
@@ -9665,11 +9655,16 @@ img.alignleft {
padding: 15px; padding: 15px;
} }
/* AKSRT 适配:站点行高(--leading-normal: 1.6)与参考主题 bootstrap /* AKSRT 适配:签到书签排版逐字对齐参考主题 bootstrap 的 body 计算值——
* 的 1.42857143 不同,用户卡片内的 img-badge(签到书签)钉住参考行高; * 行高 1.42857143(站点 --leading-normal 为 1.6)、字体族
* 字号已随 --text-base 对齐参考主题(14px),无需单独声明。 */ * "Helvetica Neue", Helvetica, Arial, sans-serif(站点 --font-body 为
* 现代系统栈),并还原浏览器默认的字体平滑与渲染(站点 html 重置为
* antialiased + optimizeLegibility);字号随 --text-base 已是参考的 14px。 */
.user-card .card-content .img-badge { .user-card .card-content .img-badge {
line-height: 1.42857143; line-height: 1.42857143;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: auto;
text-rendering: auto;
} }
.user-card name .display-name { .user-card name .display-name {
+1 -1
View File
@@ -1 +1 @@
body{--mian-max-width: 1200px;--posts-card-scale: 70%;--posts-list-scale: 70%;--theme-color: #f04494;--focus-color: var(--theme-color);--focus-shadow-color: rgba(253, 83, 161, .4);--key-color: #333;--main-color: #4e5358;--main-shadow: rgba(116, 116, 116, .08);--muted-color: #777;--muted-2-color: #999;--muted-3-color: #b1b1b1;--muted-4-color: #d2d2d2;--body-bg-color: #f5f6f7;--main-bg-color: #fff;--muted-bg-color: #eee;--muted-bg-2-color: #f6f7f8;--main-border-color: rgba(50, 50, 50, .06);--muted-border-color: rgba(0, 0, 0, .03);--main-radius: 8px;--mini-radius: calc(var(--main-radius) / 1.4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}.dark-theme{--key-color: #f8fafc;--main-color: #e5eef7;--main-shadow: rgba(24, 24, 24, .1);--muted-color: #b4b6bb;--muted-2-color: #888a8f;--muted-3-color: #636469;--muted-4-color: #43454a;--body-bg-color: #292a2d;--main-bg-color: #323335;--muted-bg-color: #2d2e31;--muted-bg-2-color: #37383a;--main-border-color: rgba(114, 114, 114, .1);--muted-border-color: rgba(184, 184, 184, .02);--blur-bg: rgba(50, 51, 53, .8);--float-btn-bg: rgba(70, 70, 70, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}.dark::view-transition-old(root){z-index:1}.dark::view-transition-new(root){z-index:2147483646}::view-transition-old(root){z-index:2147483646}::view-transition-new(root){z-index:1}@supports not ((-webkit-backdrop-filter: blur(20px)) or (backdrop-filter: blur(20px))){body{--blur-bg: #ffffff}body.dark-theme{--blur-bg: #37383a}}body:has(.modal.in),body.modal-open{--blur-bg: #ffffff}body.dark-theme:has(.modal.in),body.dark-theme.modal-open{--blur-bg: #37383a}body.fps-accelerat{--blur-bg: #ffffff}body.fps-accelerat.dark-theme{--blur-bg: #37383a}body.fps-accelerat *{-webkit-backdrop-filter:unset!important;backdrop-filter:unset!important}a[href],.relates-thumb li a>.post-info,.more-posts a>span{transition:color .2s,background .3s,opacity .3s,box-shadow .3s,transform .3s}.main-shadow{transition:box-shadow .3s}.but:focus,.but:hover,.progress,.navbar-nav>li:before{transition:.3s}.navbar-top .sub-menu,.theme-popover,.hover-show .hover-show-con{opacity:0;transform:translateY(6px);transition:.4s}.navbar-top li:hover>.sub-menu,.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{opacity:1;transform:translateY(0)}.hover-show{position:relative;cursor:pointer;z-index:10}.hover-show .hover-show-con{position:absolute;visibility:hidden}.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{visibility:unset}.match-ok,.navbar-top .sub-menu li>a,.ic-close{transition:.3s}.ic-close:hover{transform:rotate(270deg);color:inherit}.social_loginbar a,.item-thumbnail img,.dosc-nav .fa{transition:.2s ease-out}.hover-zoom:hover,.hover-zoom-img:hover img,.social_loginbar .xh-social-item:hover,.social_loginbar .social-login-item:hover{transform:scale(1.05)}.hover-zoom-sm:hover,.hover-zoom-img-sm:hover img,.posts-mini:hover img,.posts-item:hover .item-thumbnail img,.posts-item.mult-thumb .thumb-items>span>img:hover{transform:scale(1.02)}.main-min-height{min-height:calc(100vh - 280px)}.main-shadow{box-shadow:0 0 10px var(--main-shadow)}.key-color{color:var(--key-color)}.focus-color{--this-color: var(--focus-color);color:var(--focus-color)}.muted-color{color:var(--muted-color)}.muted-2-color{color:var(--muted-2-color)}.muted-3-color{color:var(--muted-3-color)}.main-bg{background:var(--main-bg-color)}.placeholder{background:linear-gradient(118deg,#7979791a 25%,#79797933 37%,#7979791a 63%);opacity:1;border-radius:4px;-webkit-animation:placeholder 1.5s ease infinite!important;animation:placeholder 1.5s ease infinite!important;background-size:400% 100%!important}.placeholder.img{height:119px}.placeholder.t1{height:24px;width:80%}.placeholder.k1{height:20px;width:60%}.placeholder.k2{height:20px}.placeholder.k3{height:15px}.placeholder.s1{width:70px;height:15px;display:inline-block}@-webkit-keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@-webkit-keyframes fade{0%{opacity:0}to{opacity:1}}@keyframes fade{0%{opacity:0}to{opacity:1}}.ani.shake{-webkit-animation:shake .3s ease-in!important;animation:shake .3s ease-in!important}@-webkit-keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}@keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}.obs-animate{opacity:0;transition:opacity .6sLine truncated body{--mian-max-width: 1200px;--posts-card-scale: 70%;--posts-list-scale: 70%;--theme-color: #f04494;--focus-color: var(--theme-color);--focus-shadow-color: rgba(253, 83, 161, .4);--key-color: #333;--main-color: #4e5358;--main-shadow: rgba(116, 116, 116, .08);--muted-color: #777;--muted-2-color: #999;--muted-3-color: #b1b1b1;--muted-4-color: #d2d2d2;--body-bg-color: #f5f6f7;--main-bg-color: #fff;--muted-bg-color: #eee;--muted-bg-2-color: #f6f7f8;--main-border-color: rgba(50, 50, 50, .06);--muted-border-color: rgba(0, 0, 0, .03);--main-radius: 8px;--mini-radius: calc(var(--main-radius) / 1.4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}.dark-theme{--key-color: #f8fafc;--main-color: #e5eef7;--main-shadow: rgba(24, 24, 24, .1);--muted-color: #b4b6bb;--muted-2-color: #888a8f;--muted-3-color: #636469;--muted-4-color: #43454a;--body-bg-color: #292a2d;--main-bg-color: #323335;--muted-bg-color: #2d2e31;--muted-bg-2-color: #37383a;--main-border-color: rgba(114, 114, 114, .1);--muted-border-color: rgba(184, 184, 184, .02);--blur-bg: rgba(50, 51, 53, .8);--float-btn-bg: rgba(70, 70, 70, .4);--header-bg: var(--blur-bg);--header-color: var(--main-color);--footer-bg: var(--main-bg-color);--footer-color: var(--muted-2-color)}::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}.dark::view-transition-old(root){z-index:1}.dark::view-transition-new(root){z-index:2147483646}::view-transition-old(root){z-index:2147483646}::view-transition-new(root){z-index:1}@supports not ((-webkit-backdrop-filter: blur(20px)) or (backdrop-filter: blur(20px))){body{--blur-bg: #ffffff}body.dark-theme{--blur-bg: #37383a}}body:has(.modal.in),body.modal-open{--blur-bg: #ffffff}body.dark-theme:has(.modal.in),body.dark-theme.modal-open{--blur-bg: #37383a}body.fps-accelerat{--blur-bg: #ffffff}body.fps-accelerat.dark-theme{--blur-bg: #37383a}body.fps-accelerat *{-webkit-backdrop-filter:unset!important;backdrop-filter:unset!important}a[href],.relates-thumb li a>.post-info,.more-posts a>span{transition:color .2s,background .3s,opacity .3s,box-shadow .3s,transform .3s}.main-shadow{transition:box-shadow .3s}.but:focus,.but:hover,.progress,.navbar-nav>li:before{transition:.3s}.navbar-top .sub-menu,.theme-popover,.hover-show .hover-show-con{opacity:0;transform:translateY(6px);transition:.4s}.navbar-top li:hover>.sub-menu,.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{opacity:1;transform:translateY(0)}.hover-show{position:relative;cursor:pointer;z-index:10}.hover-show .hover-show-con{position:absolute;visibility:hidden}.hover-show:hover .hover-show-con,.hover-show:hover~.hover-show-con,.hover-show:hover~.sub-menu{visibility:unset}.match-ok,.navbar-top .sub-menu li>a,.ic-close{transition:.3s}.ic-close:hover{transform:rotate(270deg);color:inherit}.social_loginbar a,.item-thumbnail img,.dosc-nav .fa{transition:.2s ease-out}.hover-zoom:hover,.hover-zoom-img:hover img,.social_loginbar .xh-social-item:hover,.social_loginbar .social-login-item:hover{transform:scale(1.05)}.hover-zoom-sm:hover,.hover-zoom-img-sm:hover img,.posts-mini:hover img,.posts-item:hover .item-thumbnail img,.posts-item.mult-thumb .thumb-items>span>img:hover{transform:scale(1.02)}.main-min-height{min-height:calc(100vh - 280px)}.main-shadow{box-shadow:0 0 10px var(--main-shadow)}.key-color{color:var(--key-color)}.focus-color{--this-color: var(--focus-color);color:var(--focus-color)}.muted-color{color:var(--muted-color)}.muted-2-color{color:var(--muted-2-color)}.muted-3-color{color:var(--muted-3-color)}.main-bg{background:var(--main-bg-color)}.placeholder{background:linear-gradient(118deg,#7979791a 25%,#79797933 37%,#7979791a 63%);opacity:1;border-radius:4px;-webkit-animation:placeholder 1.5s ease infinite!important;animation:placeholder 1.5s ease infinite!important;background-size:400% 100%!important}.placeholder.img{height:119px}.placeholder.t1{height:24px;width:80%}.placeholder.k1{height:20px;width:60%}.placeholder.k2{height:20px}.placeholder.k3{height:15px}.placeholder.s1{width:70px;height:15px;display:inline-block}@-webkit-keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@keyframes placeholder{0%{background-position:100% 50%}to{background-position:0 50%}}@-webkit-keyframes fade{0%{opacity:0}to{opacity:1}}@keyframes fade{0%{opacity:0}to{opacity:1}}.ani.shake{-webkit-animation:shake .3s ease-in!important;animation:shake .3s ease-in!important}@-webkit-keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}@keyframes shake{20%{transform:translate(2px)}40%{transform:translate(-4px)}60%{transform:translate(5px)}80%{transform:translate(3px)}to{transform:translate(0)}}.obs-animate{opacity:0;transition:opacity .6sLine truncated
-12
View File
@@ -478,18 +478,6 @@
border-color: var(--color-primary); border-color: var(--color-primary);
background: var(--color-primary-softer); background: var(--color-primary-softer);
} }
.binding-item__icon {
display: flex;
align-items: center;
width: 22px;
height: 22px;
color: var(--color-ink);
flex-shrink: 0;
}
.binding-item__icon i {
font-size: 22px;
line-height: 1;
}
.binding-status { .binding-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+22 -10
View File
@@ -304,12 +304,24 @@
body.uc-modal-open { body.uc-modal-open {
overflow: hidden; overflow: hidden;
} }
.btn-block { /* Scoped to the user-center content shell. These are the credits panel's own
display: block; * button skins, and every element they target renders inside .uc-content (the
width: 100%; * panels and their modals are all emitted through templates/parts/user-center/
} * panel.php, which templates/pages/page-profile.php wraps in .uc-content).
.but.jb-blue, *
.but.b-blue-2 { * Left unscoped they are (0,2,0) rules that load after public.css, so they
* out-rank the legacy .but.radius pill and flatten every .but.jb-blue on the
* page down to 4px. The top-nav publish chip is .but.jb-blue.radius, so on the
* user-center route it lost its 50px pill and no longer matched the homepage.
* Site-wide .but.jb-blue callers outside the panel (login, submit, badge and
* cashier buttons) also picked up the flat #3b82f6 skin instead of the legacy
* gradient.
*
* The .btn-block restatement that used to sit here was byte-identical to the
* one in core/buttons.css, which loads earlier on every page, so it could never
* win and is dropped rather than scoped. */
.uc-content .but.jb-blue,
.uc-content .but.b-blue-2 {
background: #3b82f6; background: #3b82f6;
color: #fff; color: #fff;
border: 0; border: 0;
@@ -317,18 +329,18 @@ body.uc-modal-open {
padding: 0.45em 1em; padding: 0.45em 1em;
cursor: pointer; cursor: pointer;
} }
.but.c-blue-2.hollow, .uc-content .but.c-blue-2.hollow,
.but.c-green.hollow { .uc-content .but.c-green.hollow {
background: transparent; background: transparent;
border: 1px solid currentColor; border: 1px solid currentColor;
border-radius: 4px; border-radius: 4px;
padding: 0.4em 0.9em; padding: 0.4em 0.9em;
cursor: pointer; cursor: pointer;
} }
.but.c-blue-2 { .uc-content .but.c-blue-2 {
color: #3b82f6; color: #3b82f6;
} }
.but.c-green { .uc-content .but.c-green {
color: #16a34a; color: #16a34a;
} }
@@ -264,8 +264,6 @@
.aksrt-account-settings__section--social .binding-captcha { margin-bottom: 12px; } .aksrt-account-settings__section--social .binding-captcha { margin-bottom: 12px; }
.aksrt-account-list--social .binding-item { border: 0; } .aksrt-account-list--social .binding-item { border: 0; }
.aksrt-account-list--social .aksrt-account-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto auto; text-decoration: none; } .aksrt-account-list--social .aksrt-account-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto auto; text-decoration: none; }
.aksrt-account-list--social .binding-item__icon { display: grid; place-items: center; width: 24px; height: 24px; color: var(--color-ink, #333); }
.aksrt-account-list--social .binding-item__icon i { font-size: 22px; line-height: 1; }
.aksrt-account-list--social .binding-status { display: inline-flex; align-items: center; gap: 4px; color: var(--color-success, #16a34a); font-size: 11px; white-space: nowrap; } .aksrt-account-list--social .binding-status { display: inline-flex; align-items: center; gap: 4px; color: var(--color-success, #16a34a); font-size: 11px; white-space: nowrap; }
.aksrt-account-list--social .binding-status--idle { color: var(--muted-2-color, var(--color-ink-4, #999)); } .aksrt-account-list--social .binding-status--idle { color: var(--muted-2-color, var(--color-ink-4, #999)); }
.aksrt-account-list--social .binding-unbind-btn { color: var(--this-color, #e4a11b); } .aksrt-account-list--social .binding-unbind-btn { color: var(--this-color, #e4a11b); }
+19 -66
View File
@@ -622,25 +622,6 @@ body.aksrt-user-center .uc-content,
} }
} }
.uc-service-grid__icon small {
position: absolute;
top: -5px;
right: -7px;
display: grid;
place-items: center;
min-width: 17px;
height: 17px;
padding: 0 4px;
border: 2px solid var(--surface-1);
border-radius: 99px;
background: var(--color-error, #ef4444);
color: #fff;
font-size: 9px;
font-style: normal;
font-weight: 700;
line-height: 1;
}
.uc-sidebar__pair { .uc-sidebar__pair {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -684,7 +665,8 @@ body.aksrt-user-center .uc-content,
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.uc-summary-card__body > i { .uc-summary-card__body > i,
.uc-summary-card__body > svg {
font-size: 18px; font-size: 18px;
} }
@@ -716,14 +698,26 @@ body.aksrt-user-center .uc-content,
.uc-center-card strong { font-size: 13px; } .uc-center-card strong { font-size: 13px; }
.uc-center-card small { margin-top: 3px; color: var(--color-ink-4); font-size: 10px; } .uc-center-card small { margin-top: 3px; color: var(--color-ink-4); font-size: 10px; }
.uc-center-card > i { color: var(--color-ink-4); font-size: 11px; } .uc-center-card > i,
.uc-center-card > svg { color: var(--color-ink-4); font-size: 11px; }
.uc-center-card--creation .uc-center-card__icon { color: #6650c7; background: rgba(124, 92, 220, 0.12); } .uc-center-card--creation .uc-center-card__icon { color: #6650c7; background: rgba(124, 92, 220, 0.12); }
.uc-center-card--revenue .uc-center-card__icon { color: #ce6b35; background: rgba(234, 112, 55, 0.12); } .uc-center-card--revenue .uc-center-card__icon { color: #ce6b35; background: rgba(234, 112, 55, 0.12); }
.uc-service-grid { /* Services / settings grids, ported from the reference theme sidebar:
display: grid; four per row, no tinted tile behind the icon. */
grid-template-columns: repeat(3, minmax(0, 1fr)); .user-icon-but-box > a {
gap: 8px; width: calc(25% - 10px);
margin: 5px;
min-width: 50px;
max-width: 100px;
color: var(--color-ink-3);
text-align: center;
text-decoration: none;
}
.user-icon-but-box > a:hover,
.user-icon-but-box > a.is-active {
color: var(--color-primary);
} }
.uc-follow-management__list { display: grid; gap: 10px; margin-top: 16px; } .uc-follow-management__list { display: grid; gap: 10px; margin-top: 16px; }
@@ -735,44 +729,3 @@ body.aksrt-user-center .uc-content,
.uc-follow-management__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; } .uc-follow-management__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
@media (max-width: 560px) { .uc-follow-management__item { align-items: flex-start; flex-wrap: wrap; } .uc-follow-management__actions { width: 100%; margin-left: 60px; } } @media (max-width: 560px) { .uc-follow-management__item { align-items: flex-start; flex-wrap: wrap; } .uc-follow-management__actions { width: 100%; margin-left: 60px; } }
.uc-service-grid__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
min-width: 0;
padding: 4px 2px;
color: var(--color-ink-3);
font-size: 11px;
text-align: center;
text-decoration: none;
}
.uc-service-grid__icon {
position: relative;
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 14px;
background: var(--surface-2);
font-size: 16px;
}
.uc-service-grid__item--cart .uc-service-grid__icon { color: #dd6941; background: rgba(238, 109, 61, 0.12); }
.uc-service-grid__item--level .uc-service-grid__icon { color: #9b6b0a; background: rgba(234, 179, 8, 0.13); }
.uc-service-grid__item--revenue .uc-service-grid__icon { color: #b8377d; background: rgba(219, 39, 119, 0.12); }
.uc-service-grid__item--auth .uc-service-grid__icon { color: #287bc4; background: rgba(14, 165, 233, 0.12); }
.uc-service-grid__item--orders .uc-service-grid__icon { color: #c05621; background: rgba(234, 88, 12, 0.12); }
.uc-service-grid__item--notice .uc-service-grid__icon { color: #d04b65; background: rgba(244, 63, 94, 0.11); }
.uc-service-grid__item--profile .uc-service-grid__icon { color: #6550c7; background: rgba(124, 92, 220, 0.12); }
.uc-service-grid__item--security .uc-service-grid__icon { color: #16816c; background: rgba(16, 185, 129, 0.12); }
.uc-service-grid__item:hover,
.uc-service-grid__item.is-active {
color: var(--color-primary);
}
.uc-service-grid__item.is-active .uc-service-grid__icon {
box-shadow: 0 0 0 2px var(--color-primary-soft), 0 5px 14px rgba(15, 23, 42, 0.08);
}
+14 -4
View File
@@ -38,6 +38,14 @@
return (BigInt(match[1]) * 100n) + BigInt((match[2] || '').padEnd(2, '0') || '0'); return (BigInt(match[1]) * 100n) + BigInt((match[2] || '').padEnd(2, '0') || '0');
} }
function formatCutCount(value) {
var number = Number(value);
if (!Number.isFinite(number)) return String(value);
if (number > 999999) return String(Math.round(number / 10000)) + 'W+';
if (number > 9999) return String(Math.round(number / 1000) / 10) + 'W+';
return String(number);
}
function api(path, options) { function api(path, options) {
return window.AksrtRequest.request(path, options || {}).then(function (response) { return window.AksrtRequest.request(path, options || {}).then(function (response) {
return { return {
@@ -64,16 +72,18 @@
var walletNumber = Number(wallet); var walletNumber = Number(wallet);
var walletText = Number.isFinite(walletNumber) ? walletNumber.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(wallet); var walletText = Number.isFinite(walletNumber) ? walletNumber.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(wallet);
document.querySelectorAll('[data-aksrt-wallet-value]').forEach(function (element) { document.querySelectorAll('[data-aksrt-wallet-value]').forEach(function (element) {
element.textContent = walletText; var text = element.dataset.aksrtCountFormat === 'cut-count' ? formatCutCount(wallet) : walletText;
element.title = walletText; element.textContent = text;
element.title = text;
}); });
} }
if (points !== undefined && points !== null && points !== '') { if (points !== undefined && points !== null && points !== '') {
var pointsNumber = Number(points); var pointsNumber = Number(points);
var pointsText = Number.isFinite(pointsNumber) ? pointsNumber.toLocaleString() : String(points); var pointsText = Number.isFinite(pointsNumber) ? pointsNumber.toLocaleString() : String(points);
document.querySelectorAll('[data-aksrt-points-value]').forEach(function (element) { document.querySelectorAll('[data-aksrt-points-value]').forEach(function (element) {
element.textContent = pointsText; var text = element.dataset.aksrtCountFormat === 'cut-count' ? formatCutCount(points) : pointsText;
element.title = pointsText; element.textContent = text;
element.title = text;
}); });
} }
} }
-62
View File
@@ -103,7 +103,6 @@
this.bindFeatured(); this.bindFeatured();
this.bindPay(); this.bindPay();
this.bindLeaveWarning(); this.bindLeaveWarning();
this.bindAi();
this.bindTagActive(); this.bindTagActive();
this.loadFeaturedFromDom(); this.loadFeaturedFromDom();
}, },
@@ -635,67 +634,6 @@
}); });
}, },
bindAi: function () {
var ai = cfg.ai || {};
if (!ai.available) {
return;
}
qsa('.aksrt-ai-assist-btn').forEach(function (btn) {
btn.hidden = false;
btn.addEventListener('click', function () {
var action = btn.getAttribute('data-ai-action');
var content = getEditorContent();
if (!content || content.replace(/<[^>]+>/g, '').trim().length < 10) {
notyf(i18n.needContent || '请先填写正文内容', 'warning');
return;
}
var toastId = action === 'tags'
? 'aksrt-front-submit-ai-tags-toast'
: 'aksrt-front-submit-ai-summary-toast';
var old = btn.textContent;
btn.disabled = true;
btn.textContent = i18n.aiGenerating || '...';
notyf(i18n.aiGenerating || '正在生成…', 'load', '', toastId);
fetch((cfg.restUrl || '').replace(/\/$/, '') + '/ai/' + action, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': cfg.restNonce || '',
},
body: JSON.stringify({ content: content }),
})
.then(function (r) {
return r.json();
})
.then(function (res) {
var text = (res && (res.data || res).text) || (res && res.result) || '';
if (!text) {
throw new Error(i18n.aiFailed || 'AI failed');
}
if (action === 'summary') {
var ex = qs('[name="excerpt"]');
if (ex) {
ex.value = text;
}
} else if (action === 'tags') {
var tags = qs('[name="tags"]');
if (tags) {
tags.value = text;
}
}
notyf(i18n.aiFilled || '已填入,可继续编辑', 'success', 5, toastId);
})
.catch(function (err) {
notyf(err.message || i18n.aiFailed || 'AI 生成失败', 'danger', 5, toastId);
})
.finally(function () {
btn.disabled = false;
btn.textContent = old;
});
});
});
},
}; };
jQuery(document).off('auto_fun.aksrt-front-submit').on('auto_fun.aksrt-front-submit', function () { App.init(); }); jQuery(document).off('auto_fun.aksrt-front-submit').on('auto_fun.aksrt-front-submit', function () { App.init(); });
+103 -7
View File
@@ -17,6 +17,15 @@
return el; return el;
} }
// 服务端已按 post 级 HTML 净化的富文本字段(more / 卡密说明)走 innerHTML;
// node() 的 textContent 会把标签当字面文本显示。
function htmlNode(tag, className, value) {
var el = document.createElement(tag);
if (className) el.className = className;
if (value !== undefined && value !== null) el.innerHTML = text(value);
return el;
}
function imageNode(src, className) { function imageNode(src, className) {
if (!src) return null; if (!src) return null;
var image = document.createElement("img"); var image = document.createElement("img");
@@ -115,7 +124,8 @@
// Zibll 套餐卡:zib-widget vip-product relative text-center product-box // Zibll 套餐卡:zib-widget vip-product relative text-center product-box
// (右上 tag + 价格行 + 时长 badge,无标题行)。 // (右上 tag + 价格行 + 时长 badge,无标题行)。
var product = node("div", "zib-widget vip-product relative text-center product-box"); var product = node("div", "zib-widget vip-product relative text-center product-box");
if (item.discount_tag) append(product, node("div", "abs-right vip-tag badg jb-yellow", item.discount_tag)); // 标签颜色取后台「标签颜色」配置,未配置时回退参考主题默认的 jb-yellow。
if (item.discount_tag) append(product, node("div", "abs-right vip-tag badg " + (item.tag_class || "jb-yellow"), item.discount_tag));
append(product, priceRow(mark, item.price_label || "", item.show_price_label || "")); append(product, priceRow(mark, item.price_label || "", item.show_price_label || ""));
if (item.days_label) append(product, node("div", "muted-2-color c-blue em09 p2-10 badg radius", item.days_label)); if (item.days_label) append(product, node("div", "muted-2-color c-blue em09 p2-10 badg radius", item.days_label));
append(label, product); append(label, product);
@@ -179,7 +189,7 @@
box.setAttribute("data-condition", "=="); box.setAttribute("data-condition", "==");
box.setAttribute("data-value", "card_pass"); box.setAttribute("data-value", "card_pass");
if (defaultMethod !== "card_pass") box.style.display = "none"; if (defaultMethod !== "card_pass") box.style.display = "none";
if (cardPass.desc) append(box, node("div", "muted-box muted-2-color padding-10 mb10 em09", cardPass.desc)); if (cardPass.desc) append(box, htmlNode("div", "muted-box muted-2-color padding-10 mb10 em09", cardPass.desc));
append(box, node("div", "muted-2-color em09 mb6", cardPass.only_password ? "请输入卡密" : "请输入卡号和密码")); append(box, node("div", "muted-2-color em09 mb6", cardPass.only_password ? "请输入卡密" : "请输入卡号和密码"));
if (!cardPass.only_password) { if (!cardPass.only_password) {
var cardInput = node("input", "form-control mb6"); var cardInput = node("input", "form-control mb6");
@@ -492,7 +502,7 @@
+ '<i class="fa fas fa-angle-right em12 ml10" aria-hidden="true"></i>'; + '<i class="fa fas fa-angle-right em12 ml10" aria-hidden="true"></i>';
append(products, pointsLink); append(products, pointsLink);
} }
if (catalog.more) append(products, node("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more)); if (catalog.more) append(products, htmlNode("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more));
renderMethods(products, methods, cardPass, wallet); renderMethods(products, methods, cardPass, wallet);
var actionName = actionKey(action); var actionName = actionKey(action);
var actionAllowed = allowed[actionName] === true; var actionAllowed = allowed[actionName] === true;
@@ -519,8 +529,6 @@
append(modal, tabs); append(modal, tabs);
append(modal, content); append(modal, content);
if (catalog.help_text) append(modal, node("div", "muted-2-color em09 mb6", catalog.help_text));
// 卡密渠道初始显隐(选中 card_pass 时隐藏套餐、显示卡密输入盒)。 // 卡密渠道初始显隐(选中 card_pass 时隐藏套餐、显示卡密输入盒)。
modal.querySelectorAll(".aksrt-vip-pane").forEach(function (pane) { modal.querySelectorAll(".aksrt-vip-pane").forEach(function (pane) {
applyVipMethodVisibility(pane); applyVipMethodVisibility(pane);
@@ -698,6 +706,46 @@
}); });
} }
// 骨架屏:仿弹窗真实布局(用户框 + 等级卡 + 双列套餐面板)。
// 线条/色块复用全局骨架类(build/public.min.css),VIP 专属形状见 vip-modal.css。
function vipSkeleton() {
var skeleton = node("div", "aksrt-vip-modal-skeleton");
skeleton.setAttribute("aria-live", "polite");
skeleton.setAttribute("aria-busy", "true");
// 用户框:头像圆 + 两行文字。
var user = node("div", "aksrt-vip-modal-skeleton__user");
append(user, node("span", "aksrt-vip-modal-skeleton__avatar"));
var userLines = node("span", "aksrt-vip-modal-skeleton__lines");
append(userLines, node("span", "aksrt-modal-runtime__skeleton-line is-wide"));
append(userLines, node("span", "aksrt-modal-runtime__skeleton-line is-short"));
append(user, userLines);
append(skeleton, user);
// 等级卡行(vip-cardminis):两张 tab 占位。
var tabs = node("div", "aksrt-vip-modal-skeleton__tabs");
append(tabs, node("span", "aksrt-vip-modal-skeleton__tab"));
append(tabs, node("span", "aksrt-vip-modal-skeleton__tab"));
append(skeleton, tabs);
// 双列面板:左权益信息 + 右套餐区(套餐卡、支付方式行、提交按钮条)。
var pane = node("div", "aksrt-vip-modal-skeleton__pane");
var info = node("div", "aksrt-vip-modal-skeleton__info");
append(info, node("span", "aksrt-modal-runtime__skeleton-line is-wide"));
append(info, node("span", "aksrt-modal-runtime__skeleton-line"));
append(info, node("span", "aksrt-modal-runtime__skeleton-line is-short"));
append(pane, info);
var products = node("div", "aksrt-vip-modal-skeleton__products");
append(products, node("span", "aksrt-modal-runtime__skeleton-block"));
append(products, node("span", "aksrt-modal-runtime__skeleton-block"));
append(products, node("span", "aksrt-modal-runtime__skeleton-line is-short"));
append(products, node("span", "aksrt-vip-modal-skeleton__button"));
append(pane, products);
append(skeleton, pane);
return skeleton.outerHTML;
}
function vipPay(trigger) { function vipPay(trigger) {
var level = trigger var level = trigger
? parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1 ? parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1
@@ -706,11 +754,11 @@
url: "/pay/vip/modal", url: "/pay/vip/modal",
query: { vip_level: level }, query: { vip_level: level },
className: "aksrt-vip-modal-shell", className: "aksrt-vip-modal-shell",
skeleton: '<div class="aksrt-vip-modal-panel"><div class="aksrt-vip-modal-content" data-aksrt-vip-modal-body><div class="text-center padding-20 muted-2-color">加载中…</div></div></div>', skeleton: vipSkeleton(),
telemetry: "vip", telemetry: "vip",
kind: "vip", kind: "vip",
responseType: "json", responseType: "json",
cacheTtl: 15000, cacheTtl: 60000,
cacheTags: ["vip"], cacheTags: ["vip"],
renderData: function (data, session) { renderData: function (data, session) {
renderModalView(session.content, data); renderModalView(session.content, data);
@@ -728,6 +776,53 @@
}); });
} }
var prefetchedLevels = Object.create(null);
// 与 openRemote 同一 cacheKey(method + AksrtRequest.url),命中同一份缓存/flight。
function vipModalCacheKey(level) {
return "GET:" + window.AksrtRequest.url("/pay/vip/modal", { vip_level: level });
}
// 意图预热:悬停/聚焦开通入口时按等级预热弹窗数据(不打开弹窗,失败静默)。
// 点击打开时若预热请求在途,cache.acquire 的 flight 去重会自动合并。
function prefetchVipModal(trigger) {
if (!trigger || !window.AksrtModalCache || !window.AksrtRequest) return;
var level = parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1;
if (level < 1 || level > 2) level = 1;
if (prefetchedLevels[level]) return;
prefetchedLevels[level] = true;
var cache = window.AksrtModalCache;
if (cache.get && cache.get(vipModalCacheKey(level)).hit) return;
try {
cache.acquire(vipModalCacheKey(level), function () {
return window.AksrtRequest.request("/pay/vip/modal", {
method: "GET",
query: { vip_level: level },
});
}, {
ttl: 60000,
tags: ["vip"],
});
} catch (error) {
// 预热失败不影响点击后的正常打开路径。
}
}
function vipTriggerFrom(event) {
var target = event.target;
if (!target || !target.closest) return null;
return target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]");
}
function bindPrefetch() {
// pointerenter 不冒泡但走捕获阶段;focusin 冒泡,两者互补覆盖鼠标与键盘。
["pointerenter", "focusin"].forEach(function (type) {
document.addEventListener(type, function (event) {
prefetchVipModal(vipTriggerFrom(event));
}, true);
});
}
function onDocClick(event) { function onDocClick(event) {
var trigger = event.target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]"); var trigger = event.target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]");
if (!trigger) return; if (!trigger) return;
@@ -768,6 +863,7 @@
if (window.__aksrtPayVipBound) return; if (window.__aksrtPayVipBound) return;
window.__aksrtPayVipBound = true; window.__aksrtPayVipBound = true;
document.addEventListener("click", onDocClick, true); document.addEventListener("click", onDocClick, true);
bindPrefetch();
bindPointsModal(); bindPointsModal();
bindPointsEntry(); bindPointsEntry();
} }
+3
View File
@@ -295,6 +295,9 @@ if (class_exists('\AKSRT\Extensions\MeiliSearch')) {
if (class_exists('\AKSRT\Extensions\SearchPush')) { if (class_exists('\AKSRT\Extensions\SearchPush')) {
\AKSRT\Extensions\SearchPush::get_instance()->init(); \AKSRT\Extensions\SearchPush::get_instance()->init();
} }
if (class_exists('\AKSRT\Extensions\SearchPushMetaBox')) {
\AKSRT\Extensions\SearchPushMetaBox::get_instance()->init();
}
if (class_exists('\AKSRT\Core\SEOHead')) { if (class_exists('\AKSRT\Core\SEOHead')) {
\AKSRT\Core\SEOHead::get_instance()->init(); \AKSRT\Core\SEOHead::get_instance()->init();
} }
+30 -107
View File
@@ -37,102 +37,33 @@ if (!function_exists('aksrt_get_new_add_actions')) :
} }
endif; endif;
if (!function_exists('aksrt_normalize_header_cta_label')) :
/**
* Normalize CTA label HTML: map FA5 classes and keep a single leading icon.
*/
function aksrt_normalize_header_cta_label(string $label, string $fallback_icon = 'fa fas fa-pen'): string {
$label = trim($label);
if ($label === '') {
return '<i class="' . esc_attr($fallback_icon) . '" aria-hidden="true"></i> ' . esc_html__('发布', 'aksrt');
}
if (stripos($label, '<i') === false) {
// No space between icon and label — spacing via CSS margin (legacy .but > .fa).
$label = '<i class="' . esc_attr($fallback_icon) . '" aria-hidden="true"></i><span class="navbar-cta-text">' . esc_html(wp_strip_all_tags($label)) . '</span>';
} else {
$label = preg_replace(
'/(<\/i>)\s*([^<].*)$/u',
'$1<span class="navbar-cta-text">$2</span>',
$label
) ?? $label;
}
return $label;
}
endif;
if (!function_exists('aksrt_render_new_add_btns')) : if (!function_exists('aksrt_render_new_add_btns')) :
/** /**
* Multi-type publish button (legacy newadd-btns). * Publish chip (legacy newadd-btns, single action).
*
* Mirrors the single-button branch of the reference zib_get_new_add_btns():
* the paint classes ride on the anchor itself and the label is emitted
* verbatim. Only the post action exists, so there is no dropdown branch.
* *
* @param string[] $types * @param string[] $types
*/ */
function aksrt_render_new_add_btns(array $types, string $class = '', string $label = ''): string { function aksrt_render_new_add_btns(array $types, string $class = '', string $label = ''): string {
if (class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::close_sign()) {
return '';
}
$actions = aksrt_get_new_add_actions($types); $actions = aksrt_get_new_add_actions($types);
if (!$actions) { if (!$actions) {
// Last resort: keep a write-post entry so the top-nav chip is never blank. return '';
$fallback = function_exists('aksrt_get_new_post_url') ? (string) aksrt_get_new_post_url() : '';
if ($fallback === '') {
$fallback = admin_url('post-new.php');
}
$actions = [[
'type' => 'post',
'url' => $fallback,
'label' => __('写文章', 'aksrt'),
'icon' => 'fa fas fa-file-alt',
]];
} }
$a = $actions[0];
if ($label === '') { if ($label === '') {
$label = __('发布', 'aksrt'); $label = __('发布', 'aksrt');
} }
$label_html = aksrt_normalize_header_cta_label((string) $label, 'fa fas fa-pen');
// Split paint classes vs wrapper tokens. return '<a rel="nofollow" class="newadd-btns ' . esc_attr($class) . ' btn-newadd" href="' . esc_url($a['url']) . '">'
$class_tokens = preg_split('/\s+/', trim($class), -1, PREG_SPLIT_NO_EMPTY) ?: []; . $label . '</a>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Saved through sanitize_type 'html' (wp_kses_post).
$cta_tokens = [];
$wrap_tokens = ['newadd-btns'];
foreach ($class_tokens as $token) {
if ($token === 'newadd-btns') {
continue;
}
if ($token === 'btn-primary' || $token === 'but' || $token === 'navbar-cta' || $token === 'radius' || $token === 'main-radius' || $token === 'mini-radius' || str_starts_with($token, 'jb-') || str_starts_with($token, 'b-')) {
$cta_tokens[] = $token;
continue;
}
$wrap_tokens[] = $token;
}
if (!$cta_tokens) {
$cta_tokens[] = 'jb-blue';
}
if (!in_array('but', $cta_tokens, true)) {
$cta_tokens[] = 'but';
}
$cta_class = trim(implode(' ', array_unique($cta_tokens)));
if (count($actions) === 1) {
$a = $actions[0];
return '<a rel="nofollow" class="' . esc_attr(trim(implode(' ', $wrap_tokens) . ' ' . $cta_class . ' navbar-cta')) . '" href="' . esc_url($a['url']) . '">'
. $label_html
. '</a>';
}
// legacy: span.newadd-btns.hover-show > toggle chip + .hover-show-con.drop-newadd
// Paint classes (jb-*, but, radius) MUST live on the toggle — wrapper is layout-only
// (background forced transparent). Bare navbar-but has no .navbar-but-cluster shell.
// data-aksrt-newadd: JS may portal menu to body so chip width cannot track menu.
$html = '<span class="' . esc_attr(trim(implode(' ', $wrap_tokens) . ' hover-show')) . '" data-aksrt-newadd="1">';
$html .= '<span class="newadd-btns__toggle navbar-cta ' . esc_attr($cta_class) . '">' . $label_html
. '<i class="fa fas fa-caret-down" aria-hidden="true"></i></span>';
$html .= '<div class="hover-show-con dropdown-menu drop-newadd" role="menu">';
foreach ($actions as $a) {
$html .= '<a rel="nofollow" class="btn-newadd newadd-btns__item" role="menuitem" href="' . esc_url($a['url']) . '">';
$html .= '<i class="' . esc_attr($a['icon']) . '" aria-hidden="true"></i> '
. '<text>' . esc_html($a['label']) . '</text>';
$html .= '</a>';
}
$html .= '</div></span>';
return $html;
} }
endif; endif;
@@ -142,26 +73,19 @@ if (!function_exists('aksrt_render_header_menu_buttons')) :
*/ */
function aksrt_render_header_menu_buttons(): string { function aksrt_render_header_menu_buttons(): string {
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::read_domain('page_display', 'header-nav') : []; $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::read_domain('page_display', 'header-nav') : [];
$cfg = [ $enabled = !isset($settings['nav_newposts']) || !empty($settings['nav_newposts']);
'enabled' => !isset($settings['nav_newposts']) || !empty($settings['nav_newposts']), $text = trim((string) ($settings['nav_newposts_text'] ?? ''));
'text' => trim((string) ($settings['nav_newposts_text'] ?? __('发布', 'aksrt'))), $pill = !isset($settings['nav_newposts_radius']) || !empty($settings['nav_newposts_radius']);
'radius_pill' => !isset($settings['nav_newposts_radius']) || !empty($settings['nav_newposts_radius']), $skin = sanitize_html_class((string) (($settings['nav_newposts_class'] ?? '') ?: 'jb-blue'));
'class' => sanitize_html_class((string) (($settings['nav_newposts_class'] ?? '') ?: 'jb-blue')),
];
$html = ''; $html = '';
if (!empty($cfg['enabled'])) { if ($enabled) {
// legacy default: but nowave jb-blue (+ radius). // legacy default: but nowave jb-blue (+ radius).
$class = 'but nowave navbar-newposts'; $class = 'but nowave ' . $skin;
$skin = trim((string) ($cfg['class'] ?? ''));
if ($skin === '') {
$skin = 'jb-blue';
}
$class .= ' ' . $skin;
$pill = !empty($cfg['radius_pill']);
if ($pill && !str_contains($class, 'radius')) { if ($pill && !str_contains($class, 'radius')) {
$class .= ' radius'; $class .= ' radius';
} }
$label = (string) ($cfg['text'] ?? __('发布', 'aksrt')); $label = $text !== '' ? $text : __('发布', 'aksrt');
$btn = aksrt_render_new_add_btns(['post'], $class, $label); $btn = aksrt_render_new_add_btns(['post'], $class, $label);
if ($btn !== '') { if ($btn !== '') {
$html .= $btn; $html .= $btn;
@@ -173,7 +97,8 @@ if (!function_exists('aksrt_render_header_menu_buttons')) :
$show_vip = false; $show_vip = false;
if (class_exists('\AKSRT\Settings\Settings')) { if (class_exists('\AKSRT\Settings\Settings')) {
$vs = \AKSRT\Settings\Settings::get_payment_vip_settings(); $vs = \AKSRT\Settings\Settings::get_payment_vip_settings();
$show_vip = !empty($vs['enabled']) && !empty($vs['nav_pay_vip']); $show_vip = !empty($vs['enabled']) && !empty($vs['nav_pay_vip'])
&& !\AKSRT\Settings\Settings::close_sign();
} }
if ($show_vip) { if ($show_vip) {
$is_vip = false; $is_vip = false;
@@ -188,17 +113,15 @@ if (!function_exists('aksrt_render_header_menu_buttons')) :
if (method_exists($svc, 'render_vip_card')) { if (method_exists($svc, 'render_vip_card')) {
$cards = $svc->render_vip_card(1, $user_id) . $svc->render_vip_card(2, $user_id); $cards = $svc->render_vip_card(1, $user_id) . $svc->render_vip_card(2, $user_id);
if ($cards !== '') { if ($cards !== '') {
$hover = '<div class="hover-show-con sub-menu sub-vip-card">' . $cards . '</div>'; $hover = '<div class="sub-menu hover-show-con sub-vip-card">' . $cards . '</div>';
} }
} }
} }
// legacy: span.hover-show wraps button + .hover-show-con.sub-vip-card // legacy: span.hover-show wraps button + .sub-menu.sub-vip-card
$html .= '<span class="hover-show inline-block navbar-vip-hover">'; $html .= '<span class="hover-show inline-block">';
$html .= '<a class="but jb-red radius payvip-icon ml10 navbar-cta ' . esc_attr($cls) . '" href="javascript:;"' $html .= '<a class="' . esc_attr($cls) . ' but jb-red radius payvip-icon ml10" href="javascript:;">'
. ($user_id > 0 ? ' vip-level="1"' : '') . '>'; . '<svg class="em12 mr3" aria-hidden="true"><use xlink:href="#icon-vip_1"></use></svg>'
$html .= '<svg class="icon em12 mr3" aria-hidden="true"><use xlink:href="#icon-vip_1"></use></svg>' . esc_html__('开通会员', 'aksrt') . '</a>' . $hover . '</span>';
. '<span class="navbar-cta-text">' . esc_html__('开通会员', 'aksrt') . '</span>';
$html .= '</a>' . $hover . '</span>';
} }
} }
+28 -24
View File
@@ -56,16 +56,16 @@ if (!function_exists('aksrt_header_user_checkin_btn')) :
// 已签到:与参考主题一致,入口按钮变为签到详情弹窗触发链接(可点击)。 // 已签到:与参考主题一致,入口按钮变为签到详情弹窗触发链接(可点击)。
if ($signed) { if ($signed) {
if ($class !== null) { if ($class !== null) {
// 用户卡片签到书签:保留书签类与 is-done 视觉,仅将行为切换为打开详情弹窗。 // 用户卡片签到书签:与参考主题一致,已签到态仅切换为详情弹窗触发链接,书签类名与文本不变。
return aksrt_get_user_checkin_details_link( return aksrt_get_user_checkin_details_link(
$class . ' is-done', $class,
'<i class="fa fas fa-calendar-check ml3 mr6" aria-hidden="true"></i>' '<i class="fa far fa-calendar-check ml3 mr6" aria-hidden="true"></i>'
. '<span>' . esc_html__('已签到', 'aksrt') . '</span>' . esc_html__('已签到', 'aksrt')
); );
} }
return aksrt_get_user_checkin_details_link( return aksrt_get_user_checkin_details_link(
'but block c-yellow is-done', 'but block c-yellow is-done',
'<i class="fa fas fa-calendar-check mr6" aria-hidden="true"></i>' '<i class="fa far fa-calendar-check" aria-hidden="true"></i> '
. esc_html__('今日已签到', 'aksrt') . esc_html__('今日已签到', 'aksrt')
); );
} }
@@ -77,7 +77,7 @@ if (!function_exists('aksrt_header_user_checkin_btn')) :
return '<a class="' . esc_attr($class) . '" href="javascript:;" data-signin-btn' return '<a class="' . esc_attr($class) . '" href="javascript:;" data-signin-btn'
. ' data-signin-idle-label="' . esc_attr__('签到', 'aksrt') . '"' . ' data-signin-idle-label="' . esc_attr__('签到', 'aksrt') . '"'
. ' data-signin-done-label="' . esc_attr__('已签到', 'aksrt') . '">' . ' data-signin-done-label="' . esc_attr__('已签到', 'aksrt') . '">'
. '<i class="fa fas fa-calendar-check ml3 mr6" aria-hidden="true"></i>' . '<i class="fa far fa-calendar-check ml3 mr6" aria-hidden="true"></i>'
. '<span data-signin-label>' . esc_html__('签到', 'aksrt') . '</span>' . '<span data-signin-label>' . esc_html__('签到', 'aksrt') . '</span>'
. '</a>'; . '</a>';
} }
@@ -85,7 +85,7 @@ if (!function_exists('aksrt_header_user_checkin_btn')) :
return '<a class="but block c-yellow" href="javascript:;" data-signin-btn' return '<a class="but block c-yellow" href="javascript:;" data-signin-btn'
. ' data-signin-idle-label="' . esc_attr__('签到', 'aksrt') . '"' . ' data-signin-idle-label="' . esc_attr__('签到', 'aksrt') . '"'
. ' data-signin-done-label="' . esc_attr__('今日已签到', 'aksrt') . '">' . ' data-signin-done-label="' . esc_attr__('今日已签到', 'aksrt') . '">'
. '<i class="fa fas fa-calendar-check mr6" aria-hidden="true"></i>' . '<i class="fa far fa-calendar-check" aria-hidden="true"></i> '
. '<span data-signin-label>' . esc_html__('签到领取今日奖励', 'aksrt') . '</span>' . '<span data-signin-label>' . esc_html__('签到领取今日奖励', 'aksrt') . '</span>'
. '</a>'; . '</a>';
} }
@@ -137,14 +137,16 @@ if (!function_exists('aksrt_header_user_wallet_mini')) :
$center = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : '#'; $center = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : '#';
$box = ''; $box = '';
$balance_icon = aksrt_icon_markup('zibsvg-money-color-2', 'em14');
$points_icon = aksrt_icon_markup('zibsvg-points-color', 'em14');
if ($balance_on) { if ($balance_on) {
$box .= '<a rel="nofollow" style="flex: 1;" class="muted-box padding-6 flex1 radius4" href="' . esc_url($center) . '">' $box .= '<a rel="nofollow" style="flex: 1;" class="muted-box padding-6 flex1 radius4" href="' . esc_url($center) . '">'
. '<div class="muted-2-color px12">' . esc_html__('余额', 'aksrt') . '<div class="muted-2-color px12">' . esc_html__('余额', 'aksrt')
. '<i class="ml6 fa fas fa-angle-right em12" aria-hidden="true"></i></div>' . '<i class="ml6 fa fas fa-angle-right em12" aria-hidden="true"></i></div>'
. '<div class="flex jsb"><span class="font-bold c-blue-2" data-aksrt-wallet-value>' . '<div class="flex jsb"><span class="font-bold c-blue-2" data-aksrt-wallet-value data-aksrt-count-format="cut-count">'
. esc_html(aksrt_cut_count((float) aksrt_user_balance_decimal($user_id))) . esc_html(aksrt_cut_count((float) aksrt_user_balance_decimal($user_id)))
. '</span><i class="fa fas fa-money-bill-wave em14" aria-hidden="true"></i></div></a>'; . '</span>' . $balance_icon . '</div></a>';
} }
if ($points_on) { if ($points_on) {
@@ -152,9 +154,9 @@ if (!function_exists('aksrt_header_user_wallet_mini')) :
$box .= '<a rel="nofollow" style="flex: 1;" class="muted-box padding-6 flex1 radius4" href="' . esc_url($center) . '">' $box .= '<a rel="nofollow" style="flex: 1;" class="muted-box padding-6 flex1 radius4" href="' . esc_url($center) . '">'
. '<div class="muted-2-color px12">' . esc_html($currency) . '<div class="muted-2-color px12">' . esc_html($currency)
. '<i class="ml6 fa fas fa-angle-right em12" aria-hidden="true"></i></div>' . '<i class="ml6 fa fas fa-angle-right em12" aria-hidden="true"></i></div>'
. '<div class="flex jsb"><span class="font-bold c-yellow" data-aksrt-points-value>' . '<div class="flex jsb"><span class="font-bold c-yellow" data-aksrt-points-value data-aksrt-count-format="cut-count">'
. esc_html(aksrt_cut_count(aksrt_user_points($user_id))) . esc_html(aksrt_cut_count(aksrt_user_points($user_id)))
. '</span><i class="fa fas fa-coins em14" aria-hidden="true"></i></div></a>'; . '</span>' . $points_icon . '</div></a>';
} }
return '<div class="flex ab jsb col-ml6">' . $box . '</div>'; return '<div class="flex ab jsb col-ml6">' . $box . '</div>';
@@ -199,16 +201,18 @@ if (!function_exists('aksrt_header_payvip_bar')) :
if ($level > 0) { if ($level > 0) {
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
$name = (string) ($levels[$level]['name'] ?? ($level === 2 ? __('二级会员', 'aksrt') : __('一级会员', 'aksrt'))); $name = (string) ($levels[$level]['name'] ?? ($level === 2 ? __('二级会员', 'aksrt') : __('一级会员', 'aksrt')));
// 到期徽章语义对齐参考主题 zib_get_user_vip_exp_date_text():只有真的拿到
// 到期时间才渲染这枚徽章,已过期显示「会员已过期」否则显示裸日期;显式永久
// 会员显示「永久会员」;拿不到到期数据时参考主题返回 false,整枚徽章不渲染。
$exp_label = ''; $exp_label = '';
if (function_exists('aksrt_get_vip_info')) { if (function_exists('aksrt_get_user_vip_state')) {
$info = aksrt_get_vip_info($user_id); $state = aksrt_get_user_vip_state($user_id);
$expires = max(0, (int) ($info['expires'] ?? 0)); $expires = max(0, (int) ($state['expires'] ?? 0));
// 到期文案对齐参考主题:已过期 / 永久会员 / 裸日期。 if ($expires > 0) {
if ((string) ($info['state'] ?? '') === 'expired') { $exp_label = $expires <= time()
$exp_label = __('会员已过期', 'aksrt'); ? __('会员已过期', 'aksrt')
} elseif ($expires > 0) { : wp_date('Y年m月d日', $expires);
$exp_label = wp_date('Y年m月d日', $expires); } elseif (!empty($state['is_permanent'])) {
} else {
$exp_label = __('永久会员', 'aksrt'); $exp_label = __('永久会员', 'aksrt');
} }
} }
@@ -228,11 +232,11 @@ if (!function_exists('aksrt_header_payvip_bar')) :
return ''; return '';
} }
// 几何对齐参考主题 CTA(通栏、radius4)——不是顶导 navbar-cta chips。 // 1:1 对齐参考主题 zib_get_header_payvip_icon():通栏、radius4、vip_1 SVG 图标、裸文案。
return '<div class="mt10 em09" style="padding:2px;">' return '<div class="mt10 em09" style="padding:2px;">'
. '<a class="pay-vip but radius4 payvip-icon btn-block" href="javascript:;" vip-level="1">' . '<a class="pay-vip but radius4 payvip-icon btn-block" href="javascript:;">'
. '<i class="fa fas fa-gem em12 mr6" aria-hidden="true"></i>' . '<svg class="em12 mr6" aria-hidden="true"><use xlink:href="#icon-vip_1"></use></svg>'
. '<span>' . esc_html__('开通会员 尊享会员权益', 'aksrt') . '</span>' . esc_html__('开通会员 尊享会员权益', 'aksrt')
. '</a></div>'; . '</a></div>';
} }
endif; endif;
+24 -9
View File
@@ -42,7 +42,7 @@ endif;
if (!function_exists('aksrt_brand_icon_map')) : if (!function_exists('aksrt_brand_icon_map')) :
/** /**
* Short key → FA5 class string (allowlisted). * Short key → FA5 class string or built-in SVG sprite key (allowlisted).
* *
* @return array<string, string> * @return array<string, string>
*/ */
@@ -52,10 +52,12 @@ function aksrt_brand_icon_map(): array {
return $map; return $map;
} }
// FA5 Free has no bilibili/gitee brands — use solid stand-ins. // FA5 Free ships no Gitee brand glyph and no multi-colour Google mark, so
// those two resolve to the theme's built-in SVG sprite (zibsvg-*) instead of
// a solid stand-in; every other key stays on FA5.
$map = [ $map = [
'github' => 'fa fab fa-github', 'github' => 'fa fab fa-github',
'google' => 'fa fab fa-google', 'google' => 'zibsvg-google-color',
'x' => 'fa fab fa-twitter', 'x' => 'fa fab fa-twitter',
'twitter' => 'fa fab fa-twitter', 'twitter' => 'fa fab fa-twitter',
'email' => 'fa fas fa-envelope', 'email' => 'fa fas fa-envelope',
@@ -73,7 +75,7 @@ function aksrt_brand_icon_map(): array {
'instagram' => 'fa fab fa-instagram', 'instagram' => 'fa fab fa-instagram',
'web' => 'fa fas fa-globe', 'web' => 'fa fas fa-globe',
'globe' => 'fa fas fa-globe', 'globe' => 'fa fas fa-globe',
'gitee' => 'fa fas fa-code-branch', 'gitee' => 'zibsvg-gitee',
'baidu' => 'fa fas fa-paw', 'baidu' => 'fa fas fa-paw',
'alipay' => 'fa fab fa-alipay', 'alipay' => 'fa fab fa-alipay',
]; ];
@@ -127,10 +129,10 @@ endif;
if (!function_exists('aksrt_brand_icon_class')) : if (!function_exists('aksrt_brand_icon_class')) :
/** /**
* Resolve a short key or FA class string to an allowlisted FA5 class. * Resolve a short key, FA class string or sprite key to its icon value.
* *
* @param string $name Short key or full FA class string. * @param string $name Short key, full FA class string or zibsvg-/customsvg- value.
* @return string e.g. 'fa fab fa-github', or '' if unknown. * @return string e.g. 'fa fab fa-github' or 'zibsvg-gitee', or '' if unknown.
*/ */
function aksrt_brand_icon_class(string $name): string { function aksrt_brand_icon_class(string $name): string {
$raw = trim($name); $raw = trim($name);
@@ -138,6 +140,12 @@ function aksrt_brand_icon_class(string $name): string {
return ''; return '';
} }
// Built-in SVG sprite and inline SVG values pass straight through; they are
// not FA class strings and must survive to the markup renderer untouched.
if (preg_match('/^(?:zibsvg|customsvg)-/i', $raw)) {
return $raw;
}
// Pass through already-valid full FA class strings (footer contact_more). // Pass through already-valid full FA class strings (footer contact_more).
if (preg_match('/^fa\s+(?:fas|far|fab)\s+fa-[\w-]+(?:\s+fa-[\w-]+)*$/i', $raw)) { if (preg_match('/^fa\s+(?:fas|far|fab)\s+fa-[\w-]+(?:\s+fa-[\w-]+)*$/i', $raw)) {
// Prefer mapped short key when strip matches our catalog (normalizes weixin etc.). // Prefer mapped short key when strip matches our catalog (normalizes weixin etc.).
@@ -164,14 +172,21 @@ endif;
if (!function_exists('aksrt_brand_icon')) : if (!function_exists('aksrt_brand_icon')) :
/** /**
* Escaped FA <i> markup for a brand/contact icon. * Escaped markup for a brand/contact icon (FA5 <i> or built-in SVG sprite).
* *
* @param string $name Short key or FA class string. * @param string $name Short key, FA class string or zibsvg-/customsvg- value.
* @param array|string $extra_classes Optional extra classes. * @param array|string $extra_classes Optional extra classes.
* @return string Safe HTML or empty string when unknown. * @return string Safe HTML or empty string when unknown.
*/ */
function aksrt_brand_icon(string $name, $extra_classes = []): string { function aksrt_brand_icon(string $name, $extra_classes = []): string {
$class = aksrt_brand_icon_class($name); $class = aksrt_brand_icon_class($name);
// Sprite and inline-SVG values carry their own element, so the shared icon
// renderer owns the markup (it emits <svg class="icon"><use …>).
if (strpos($class, 'zibsvg-') === 0 || strpos($class, 'customsvg-') === 0) {
return aksrt_icon_markup($class, $extra_classes);
}
if ($class === '') { if ($class === '') {
return ''; return '';
} }
+3 -2
View File
@@ -327,8 +327,7 @@ if (!function_exists('aksrt_get_user_posts_meta_sum')) {
if (!function_exists('aksrt_get_user_badges')) { if (!function_exists('aksrt_get_user_badges')) {
/** /**
* legacy parity: 作者资料卡统计徽章行(评论 / 粉丝|点赞 / 人气值)。 * legacy parity: 作者资料卡统计徽章行(文章 / 评论 / 粉丝|点赞 / 人气值)。
* 本站不做帖子,故不输出文章徽章。
* *
* @param int $user_id 用户 ID。 * @param int $user_id 用户 ID。
*/ */
@@ -338,6 +337,7 @@ if (!function_exists('aksrt_get_user_badges')) {
} }
$home = aksrt_get_user_home_url($user_id); $home = aksrt_get_user_home_url($user_id);
$post_n = aksrt_cut_count(count_user_posts($user_id, 'post', true));
$com_n = aksrt_cut_count((int) get_comments([ $com_n = aksrt_cut_count((int) get_comments([
'user_id' => $user_id, 'user_id' => $user_id,
'status' => 'approve', 'status' => 'approve',
@@ -351,6 +351,7 @@ if (!function_exists('aksrt_get_user_badges')) {
$view_n = aksrt_cut_count(aksrt_get_user_posts_meta_sum($user_id, 'aksrt_post_views')); $view_n = aksrt_cut_count(aksrt_get_user_posts_meta_sum($user_id, 'aksrt_post_views'));
$html = ''; $html = '';
$html .= '<a class="but c-blue tag-posts" data-toggle="tooltip" title="' . esc_attr(sprintf(__('共%s篇文章', 'aksrt'), $post_n)) . '" href="' . esc_url($home) . '"><i class="fa fas fa-file-alt" aria-hidden="true"></i>' . $post_n . '</a>';
$html .= '<a class="but c-green tag-comment" data-toggle="tooltip" title="' . esc_attr(sprintf(__('共%s条评论', 'aksrt'), $com_n)) . '" href="' . esc_url($home) . '"><i class="fa fas fa-comment-dots" aria-hidden="true"></i>' . $com_n . '</a>'; $html .= '<a class="but c-green tag-comment" data-toggle="tooltip" title="' . esc_attr(sprintf(__('共%s条评论', 'aksrt'), $com_n)) . '" href="' . esc_url($home) . '"><i class="fa fas fa-comment-dots" aria-hidden="true"></i>' . $com_n . '</a>';
if ($followed_n) { if ($followed_n) {
$html .= '<a class="but c-yellow tag-follow" data-toggle="tooltip" title="' . esc_attr(sprintf(__('共%s个粉丝', 'aksrt'), $followed_n)) . '" href="' . esc_url($home) . '"><i class="fa fas fa-heart em09" aria-hidden="true"></i>' . $followed_n . '</a>'; $html .= '<a class="but c-yellow tag-follow" data-toggle="tooltip" title="' . esc_attr(sprintf(__('共%s个粉丝', 'aksrt'), $followed_n)) . '" href="' . esc_url($home) . '"><i class="fa fas fa-heart em09" aria-hidden="true"></i>' . $followed_n . '</a>';
+1 -4
View File
@@ -428,10 +428,7 @@ if (!function_exists('aksrt_submit_links_modal')) :
$body .= '<p><a href="' . esc_url($sign_url) . '" class="signin-loader but c-blue padding-lg">' $body .= '<p><a href="' . esc_url($sign_url) . '" class="signin-loader but c-blue padding-lg">'
. '<i class="fa fas fa-fw fa-sign-in-alt mr10" aria-hidden="true"></i>' . esc_html__('登录', 'aksrt') . '</a></p>'; . '<i class="fa fas fa-fw fa-sign-in-alt mr10" aria-hidden="true"></i>' . esc_html__('登录', 'aksrt') . '</a></p>';
if (class_exists('\AKSRT\Auth\OAuth') && method_exists(\AKSRT\Auth\OAuth::get_instance(), 'render_social_buttons')) { if (class_exists('\AKSRT\Auth\OAuth') && method_exists(\AKSRT\Auth\OAuth::get_instance(), 'render_social_buttons')) {
$social = (string) \AKSRT\Auth\OAuth::get_instance()->render_social_buttons(__('使用以下方式快速登录', 'aksrt')); $body .= (string) \AKSRT\Auth\OAuth::get_instance()->render_social_buttons(__('使用以下方式快速登录', 'aksrt'));
if ($social !== '') {
$body .= '<div class="social_loginbar">' . $social . '</div>';
}
} }
$body .= '</div>'; $body .= '</div>';
} else { } else {
+20 -8
View File
@@ -286,11 +286,7 @@ if (!function_exists('aksrt_header_user_box_html')) :
$con = '<div class="text-center">' . $href . '</div>'; $con = '<div class="text-center">' . $href . '</div>';
if (class_exists('\AKSRT\Auth\OAuth') && method_exists(\AKSRT\Auth\OAuth::get_instance(), 'render_social_buttons')) { if (class_exists('\AKSRT\Auth\OAuth') && method_exists(\AKSRT\Auth\OAuth::get_instance(), 'render_social_buttons')) {
$oauth_html = (string) \AKSRT\Auth\OAuth::get_instance()->render_social_buttons(__('快速登录', 'aksrt')); $con .= (string) \AKSRT\Auth\OAuth::get_instance()->render_social_buttons(__('快速登录', 'aksrt'));
if ($oauth_html !== '') {
$con .= '<p class="social-separator separator muted-3-color em09 mt10">' . esc_html__('快速登录', 'aksrt') . '</p>';
$con .= '<div class="social_loginbar">' . $oauth_html . '</div>';
}
} }
} }
@@ -303,6 +299,12 @@ if (!function_exists('aksrt_menu_button')) :
/** /**
* Right cluster: fold, publish/VIP, theme, user, search (legacy). * Right cluster: fold, publish/VIP, theme, user, search (legacy).
* *
* The reference splits the cluster into three floated .navbar-form
* groups (publish/VIP, theme + language, account row). Each group
* keeps its own .navbar-form padding, which is what produces the
* reference inter-cluster spacing, so the groups are emitted
* separately instead of being merged into one flex container.
*
* @param array<string,mixed> $ctx * @param array<string,mixed> $ctx
*/ */
function aksrt_menu_button(array $ctx): void { function aksrt_menu_button(array $ctx): void {
@@ -371,14 +373,24 @@ if (!function_exists('aksrt_menu_button')) :
$account = '<ul class="list-inline splitters relative aksrt-navbar-account' $account = '<ul class="list-inline splitters relative aksrt-navbar-account'
. ($user_id <= 0 && '2' === $layout ? ' navbar-text' : '') . '">' . ($user_id <= 0 && '2' === $layout ? ' navbar-text' : '') . '">'
. $notification_but . $sign_but . $search_but . '</ul>'; . $notification_but . $sign_but . $search_but . '</ul>';
$button = '<div class="aksrt-navbar-actions navbar-form navbar-right navbar-but">'
. $actions . $utility_buttons . $account . '</div>'; // Reference cluster split: publish/VIP, theme + language, account row.
$button = '';
if ($actions !== '') {
$button = '<div class="navbar-form navbar-right navbar-but">' . $actions . '</div>';
}
if ($utility_buttons !== '') {
$button .= '<div class="navbar-form navbar-right flex ac">' . $utility_buttons . '</div>';
}
$right_but = '<div class="navbar-form navbar-right'
. ($user_id <= 0 && '2' === $layout ? ' navbar-text' : '') . '">'
. $account . '</div>';
$fold = '<div class="navbar-form navbar-right hide show-nav-but" style="margin-right:-10px;">' $fold = '<div class="navbar-form navbar-right hide show-nav-but" style="margin-right:-10px;">'
. '<a data-toggle-class data-target=".nav.navbar-nav" href="javascript:;" class="aksrt-nav-icon" aria-label="' . '<a data-toggle-class data-target=".nav.navbar-nav" href="javascript:;" class="aksrt-nav-icon" aria-label="'
. esc_attr__('展开菜单', 'aksrt') . '"><svg class="icon" viewBox="0 0 1024 1024" aria-hidden="true"><use xlink:href="#icon-menu_2"></use></svg></a></div>'; . esc_attr__('展开菜单', 'aksrt') . '"><svg class="icon" viewBox="0 0 1024 1024" aria-hidden="true"><use xlink:href="#icon-menu_2"></use></svg></a></div>';
$html = $fold . $button; $html = $fold . $button . $right_but;
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
} }
@@ -15,7 +15,8 @@ require __DIR__ . '/sidebar-vip.php';
require __DIR__ . '/sidebar-commerce.php'; require __DIR__ . '/sidebar-commerce.php';
require __DIR__ . '/sidebar-creator.php'; require __DIR__ . '/sidebar-creator.php';
require __DIR__ . '/sidebar-nav.php'; require __DIR__ . '/sidebar-nav.php';
$drawer_trigger_icon = aksrt_icon_markup('zibsvg-menu');
?> ?>
<button type="button" class="uc-sidebar__drawer-trigger" id="uc-drawer-trigger" aria-label="<?php esc_attr_e('打开导航', 'aksrt'); ?>"> <button type="button" class="uc-sidebar__drawer-trigger" id="uc-drawer-trigger" aria-label="<?php esc_attr_e('打开导航', 'aksrt'); ?>">
<i class="fa fas fa-th-large" aria-hidden="true"></i> <?php echo $drawer_trigger_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</button> </button>
@@ -26,10 +26,14 @@ foreach ((array) ($oauth_data['bindings'] ?? []) as $binding) {
if (!$key) { if (!$key) {
continue; continue;
} }
// Reference parity: the provider type (clogin_ prefix stripped) drives the
// .social-login-item brand colour, matching zibll's binding list.
$icon_type = str_starts_with($key, 'clogin_') ? substr($key, 7) : $key;
$binding_rows[$key] = [ $binding_rows[$key] = [
'key' => $key, 'key' => $key,
'name' => (string) ($binding['label'] ?? $binding_name_map[$key] ?? $binding['name'] ?? ucfirst($key)), 'name' => (string) ($binding['label'] ?? $binding_name_map[$key] ?? $binding['name'] ?? ucfirst($key)),
'icon' => $binding['icon'] ?? '', 'icon' => $binding['icon'] ?? '',
'icon_type' => $icon_type,
'is_bound' => true, 'is_bound' => true,
'url' => '', 'url' => '',
]; ];
@@ -39,10 +43,12 @@ foreach ((array) ($oauth_data['available'] ?? []) as $binding) {
if (!$key) { if (!$key) {
continue; continue;
} }
$icon_type = str_starts_with($key, 'clogin_') ? substr($key, 7) : $key;
$binding_rows[$key] = [ $binding_rows[$key] = [
'key' => $key, 'key' => $key,
'name' => (string) ($binding['label'] ?? $binding_name_map[$key] ?? $binding['name'] ?? ucfirst($key)), 'name' => (string) ($binding['label'] ?? $binding_name_map[$key] ?? $binding['name'] ?? ucfirst($key)),
'icon' => $binding['icon'] ?? '', 'icon' => $binding['icon'] ?? '',
'icon_type' => $icon_type,
'is_bound' => false, 'is_bound' => false,
'url' => $binding['start_url'] ?? $binding['url'] ?? '', 'url' => $binding['start_url'] ?? $binding['url'] ?? '',
]; ];
@@ -64,7 +70,7 @@ foreach ((array) ($oauth_data['available'] ?? []) as $binding) {
<?php foreach ($binding_rows as $binding) : ?> <?php foreach ($binding_rows as $binding) : ?>
<?php if (!empty($binding['is_bound'])) : ?> <?php if (!empty($binding['is_bound'])) : ?>
<div class="aksrt-account-row binding-item"> <div class="aksrt-account-row binding-item">
<span class="binding-item__icon"><?php echo $binding['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span> <span class="social-login-item circular <?php echo esc_attr($binding['icon_type']); ?>"><?php echo $binding['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span class="aksrt-account-row__social-name"><?php echo esc_html($binding['name']); ?></span> <span class="aksrt-account-row__social-name"><?php echo esc_html($binding['name']); ?></span>
<span class="binding-status"> <span class="binding-status">
<i class="fa fas fa-check-circle badge__icon" aria-hidden="true"></i> <i class="fa fas fa-check-circle badge__icon" aria-hidden="true"></i>
@@ -76,7 +82,7 @@ foreach ((array) ($oauth_data['available'] ?? []) as $binding) {
</div> </div>
<?php else : ?> <?php else : ?>
<a href="<?php echo esc_url($binding['url']); ?>" class="aksrt-account-row binding-item binding-item--linkable" target="_self" data-provider="<?php echo esc_attr($binding['key']); ?>" data-bind-start-url="<?php echo esc_url($binding['url']); ?>"> <a href="<?php echo esc_url($binding['url']); ?>" class="aksrt-account-row binding-item binding-item--linkable" target="_self" data-provider="<?php echo esc_attr($binding['key']); ?>" data-bind-start-url="<?php echo esc_url($binding['url']); ?>">
<span class="binding-item__icon"><?php echo $binding['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span> <span class="social-login-item circular <?php echo esc_attr($binding['icon_type']); ?>"><?php echo $binding['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span class="aksrt-account-row__social-name"><?php echo esc_html($binding['name']); ?></span> <span class="aksrt-account-row__social-name"><?php echo esc_html($binding['name']); ?></span>
<span class="binding-status binding-status--idle"><?php esc_html_e('未绑定', 'aksrt'); ?></span> <span class="binding-status binding-status--idle"><?php esc_html_e('未绑定', 'aksrt'); ?></span>
<span class="but hollow c-blue aksrt-account-row__action"><?php esc_html_e('绑定', 'aksrt'); ?></span> <span class="but hollow c-blue aksrt-account-row__action"><?php esc_html_e('绑定', 'aksrt'); ?></span>
@@ -20,23 +20,26 @@ try {
$wallet = '0.00'; $wallet = '0.00';
} }
$points = max(0, (int) ($credit_balance ?? 0)); $points = max(0, (int) ($credit_balance ?? 0));
$wallet_icon = aksrt_icon_markup('zibsvg-money-color-2');
$points_icon = aksrt_icon_markup('zibsvg-points-color');
$arrow_icon = aksrt_icon_markup('zibsvg-right');
?> ?>
<div class="uc-sidebar__pair uc-sidebar__pair--assets"> <div class="uc-sidebar__pair uc-sidebar__pair--assets">
<?php if ($show_wallet) : ?> <?php if ($show_wallet) : ?>
<a class="uc-summary-card uc-summary-card--wallet" href="<?php echo esc_url($assets_url); ?>" data-tab="credits"> <a class="uc-summary-card uc-summary-card--wallet" href="<?php echo esc_url($assets_url); ?>" data-tab="credits">
<span class="uc-summary-card__head"><?php esc_html_e('账户余额', 'aksrt'); ?><i class="fa fas fa-angle-right" aria-hidden="true"></i></span> <span class="uc-summary-card__head"><?php esc_html_e('账户余额', 'aksrt'); ?><?php echo $arrow_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span class="uc-summary-card__body"> <span class="uc-summary-card__body">
<strong data-aksrt-wallet-value><?php echo esc_html($wallet); ?></strong> <strong data-aksrt-wallet-value><?php echo esc_html($wallet); ?></strong>
<i class="fa fas fa-money-bill" aria-hidden="true"></i> <?php echo $wallet_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</span> </span>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php if ($show_credits) : ?> <?php if ($show_credits) : ?>
<a class="uc-summary-card uc-summary-card--points" href="<?php echo esc_url($assets_url); ?>" data-tab="credits"> <a class="uc-summary-card uc-summary-card--points" href="<?php echo esc_url($assets_url); ?>" data-tab="credits">
<span class="uc-summary-card__head"><?php echo esc_html((string) ($currency_name ?? __('积分', 'aksrt'))); ?><i class="fa fas fa-angle-right" aria-hidden="true"></i></span> <span class="uc-summary-card__head"><?php echo esc_html((string) ($currency_name ?? __('积分', 'aksrt'))); ?><?php echo $arrow_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span class="uc-summary-card__body"> <span class="uc-summary-card__body">
<strong data-aksrt-points-value data-credits-value><?php echo esc_html(aksrt_format_points($points)); ?></strong> <strong data-aksrt-points-value data-credits-value><?php echo esc_html(aksrt_format_points($points)); ?></strong>
<i class="fa fas fa-money-bill" aria-hidden="true"></i> <?php echo $points_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</span> </span>
</a> </a>
<?php endif; ?> <?php endif; ?>
@@ -13,20 +13,23 @@ $revenue_url = isset($dashboard_tabs['revenue']) ? aksrt_get_user_center_tab_url
if ($creation_url === '' && $revenue_url === '') { if ($creation_url === '' && $revenue_url === '') {
return; return;
} }
$creation_icon = aksrt_icon_markup('zibsvg-merchant-color');
$revenue_icon = aksrt_icon_markup('zibsvg-money-color');
$arrow_icon = aksrt_icon_markup('zibsvg-right');
?> ?>
<div class="uc-sidebar__pair uc-sidebar__pair--centers"> <div class="uc-sidebar__pair uc-sidebar__pair--centers">
<?php if ($creation_url !== '') : ?> <?php if ($creation_url !== '') : ?>
<a class="uc-center-card uc-center-card--creation" href="<?php echo esc_url($creation_url); ?>" data-tab="creator"> <a class="uc-center-card uc-center-card--creation" href="<?php echo esc_url($creation_url); ?>" data-tab="creator">
<span class="uc-center-card__icon"><i class="fa fas fa-pen" aria-hidden="true"></i></span> <span class="uc-center-card__icon"><?php echo $creation_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span><strong><?php esc_html_e('创作收益', 'aksrt'); ?></strong><small><?php esc_html_e('作者收益明细', 'aksrt'); ?></small></span> <span><strong><?php esc_html_e('创作收益', 'aksrt'); ?></strong><small><?php esc_html_e('作者收益明细', 'aksrt'); ?></small></span>
<i class="fa fas fa-angle-right" aria-hidden="true"></i> <?php echo $arrow_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php if ($revenue_url !== '') : ?> <?php if ($revenue_url !== '') : ?>
<a class="uc-center-card uc-center-card--revenue" href="<?php echo esc_url($revenue_url); ?>" data-tab="revenue"> <a class="uc-center-card uc-center-card--revenue" href="<?php echo esc_url($revenue_url); ?>" data-tab="revenue">
<span class="uc-center-card__icon"><i class="fa fas fa-chart-line" aria-hidden="true"></i></span> <span class="uc-center-card__icon"><?php echo $revenue_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span><strong><?php esc_html_e('推广收益', 'aksrt'); ?></strong><small><?php esc_html_e('推广佣金明细', 'aksrt'); ?></small></span> <span><strong><?php esc_html_e('推广收益', 'aksrt'); ?></strong><small><?php esc_html_e('推广佣金明细', 'aksrt'); ?></small></span>
<i class="fa fas fa-angle-right" aria-hidden="true"></i> <?php echo $arrow_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -2,6 +2,9 @@
/** /**
* User center sidebar: services and account settings. * User center sidebar: services and account settings.
* *
* Icon set mirrors the reference theme user-center sidebar: every entry renders
* an inline SVG from the theme sprite through aksrt_icon_markup().
*
* @package AKSRT * @package AKSRT
*/ */
@@ -17,22 +20,20 @@ $orders_url = $orders_available ? (string) ($orders_url ?? '') : '';
$services = array_values(array_filter([ $services = array_values(array_filter([
[ [
'label' => __('我的等级', 'aksrt'), 'label' => __('我的等级', 'aksrt'),
'icon' => 'chart-line', 'icon' => 'trend-color',
'tone' => 'level',
'url' => $level_url, 'url' => $level_url,
'tab' => 'level',
], ],
[ [
'label' => __('官方认证', 'aksrt'), 'label' => __('官方认证', 'aksrt'),
'icon' => 'id-badge', 'icon' => 'user-auth',
'tone' => 'auth',
'url' => $auth_url, 'url' => $auth_url,
'tab' => 'auth', 'tab' => 'auth',
], ],
[ [
'label' => __('我的订单', 'aksrt'), 'label' => __('我的订单', 'aksrt'),
'icon' => 'receipt', 'icon' => 'order-color',
'tone' => 'orders',
'url' => $orders_url, 'url' => $orders_url,
'tab' => 'orders', 'tab' => 'orders',
], ],
@@ -41,35 +42,38 @@ $services = array_values(array_filter([
$settings = array_values(array_filter([ $settings = array_values(array_filter([
[ [
'label' => __('消息通知', 'aksrt'), 'label' => __('消息通知', 'aksrt'),
'icon' => 'bell', 'icon' => 'msg-color',
'tone' => 'notice',
'url' => isset($dashboard_tabs['msg']) ? aksrt_get_user_center_tab_url('msg') : '', 'url' => isset($dashboard_tabs['msg']) ? aksrt_get_user_center_tab_url('msg') : '',
'tab' => 'msg', 'tab' => 'msg',
], ],
[ [
'label' => __('个人资料', 'aksrt'), 'label' => __('个人资料', 'aksrt'),
'icon' => 'user-edit', 'icon' => 'user-color',
'tone' => 'profile',
'url' => isset($dashboard_tabs['profile']) ? aksrt_get_user_center_tab_url('profile') : '', 'url' => isset($dashboard_tabs['profile']) ? aksrt_get_user_center_tab_url('profile') : '',
'tab' => 'profile',
], ],
[ [
'label' => __('账户安全', 'aksrt'), 'label' => __('账户安全', 'aksrt'),
'icon' => 'shield-alt', 'icon' => 'security-color',
'tone' => 'security',
'url' => isset($dashboard_tabs['settings']) ? aksrt_get_user_center_tab_url('settings') : '', 'url' => isset($dashboard_tabs['settings']) ? aksrt_get_user_center_tab_url('settings') : '',
'tab' => 'settings',
], ],
], static fn(array $item): bool => (string) $item['url'] !== '')); ], static fn(array $item): bool => (string) $item['url'] !== ''));
$render_grid = static function (array $items): void { $render_grid = static function (array $items): void {
?> ?>
<div class="uc-service-grid"> <div class="flex ac hh text-center icon-but-box user-icon-but-box">
<?php foreach ($items as $item) : ?> <?php foreach ($items as $item) :
<a class="uc-service-grid__item uc-service-grid__item--<?php echo esc_attr((string) $item['tone']); ?><?php echo !empty($item['tab']) && ($active_tab ?? '') === $item['tab'] ? ' is-active' : ''; ?>" href="<?php echo esc_url((string) $item['url']); ?>"<?php if (!empty($item['tab'])) : ?> data-tab="<?php echo esc_attr((string) $item['tab']); ?>"<?php endif; ?>> $tab = (string) ($item['tab'] ?? '');
<span class="uc-service-grid__icon"> $item_class = 'icon-but-' . ($tab !== '' ? $tab : 'item');
<i class="fa fas fa-<?php echo esc_attr((string) $item['icon']); ?>" aria-hidden="true"></i> if (($active_tab ?? '') === $tab) {
<?php if (!empty($item['count'])) : ?><small><?php echo esc_html((string) $item['count']); ?></small><?php endif; ?> $item_class .= ' is-active';
</span> }
<span><?php echo esc_html((string) $item['label']); ?></span> $icon_html = aksrt_icon_markup('zibsvg-' . (string) $item['icon']);
?>
<a class="<?php echo esc_attr($item_class); ?>" href="<?php echo esc_url((string) $item['url']); ?>"<?php if ($tab !== '') : ?> data-tab="<?php echo esc_attr($tab); ?>"<?php endif; ?>>
<div class="em16"><?php echo $icon_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
<div class="px12 muted-color mt3"><?php echo esc_html((string) $item['label']); ?></div>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
@@ -49,7 +49,8 @@ $sidebar_level = $vip_level > 0 ? $vip_level : $expired_level;
$sidebar_icon_url = esc_url((string) ($vip_levels[$sidebar_level]['img_icon'] ?? '')); $sidebar_icon_url = esc_url((string) ($vip_levels[$sidebar_level]['img_icon'] ?? ''));
$sidebar_icon = $sidebar_icon_url !== '' $sidebar_icon = $sidebar_icon_url !== ''
? '<img' . aksrt_get_lazy_attr('lazy_other', $sidebar_icon_url, 'aksrt-vip-level-icon') . ' alt="" decoding="async">' ? '<img' . aksrt_get_lazy_attr('lazy_other', $sidebar_icon_url, 'aksrt-vip-level-icon') . ' alt="" decoding="async">'
: '<i class="fa fas fa-certificate" aria-hidden="true"></i>'; : aksrt_icon_markup('zibsvg-vip_1');
$sidebar_arrow = aksrt_icon_markup('zibsvg-right', 'uc-sidebar-vip__arrow');
?> ?>
<a class="uc-sidebar-vip" href="<?php echo esc_url($vip_url); ?>" data-tab="vip"> <a class="uc-sidebar-vip" href="<?php echo esc_url($vip_url); ?>" data-tab="vip">
<span class="uc-sidebar-vip__icon"><?php echo $sidebar_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span> <span class="uc-sidebar-vip__icon"><?php echo $sidebar_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
@@ -57,5 +58,5 @@ $sidebar_icon = $sidebar_icon_url !== ''
<strong><?php echo esc_html($title); ?></strong> <strong><?php echo esc_html($title); ?></strong>
<small><?php echo esc_html($status); ?></small> <small><?php echo esc_html($status); ?></small>
</span> </span>
<i class="fa fas fa-angle-right uc-sidebar-vip__arrow" aria-hidden="true"></i> <?php echo $sidebar_arrow; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</a> </a>
+22 -11
View File
@@ -29,6 +29,8 @@ $vip_state_name = (string) ($vip_state['state'] ?? 'unopened');
$vip_state_source = (string) ($vip_state['source'] ?? 'none'); $vip_state_source = (string) ($vip_state['source'] ?? 'none');
$levels = is_array($vip_settings['levels'] ?? null) ? $vip_settings['levels'] : []; $levels = is_array($vip_settings['levels'] ?? null) ? $vip_settings['levels'] : [];
$desc_top = (string) ($vip_settings['desc'] ?? ''); $desc_top = (string) ($vip_settings['desc'] ?? '');
$renew_desc = (string) ($vip_settings['renew']['desc'] ?? '');
$upgrade_desc = (string) ($vip_settings['upgrade']['desc'] ?? '');
// Membership summary: title → level → intro → expiry. // Membership summary: title → level → intro → expiry.
$vip_state = 'unopened'; $vip_state = 'unopened';
@@ -36,7 +38,9 @@ $vip_state_label = __('未开通', 'aksrt');
$vip_level_number = 0; $vip_level_number = 0;
$vip_level_name = __('未开通会员', 'aksrt'); $vip_level_name = __('未开通会员', 'aksrt');
$vip_description = $desc_top !== '' ? $desc_top : __('开通会员,尊享专属特权', 'aksrt'); $vip_description = $desc_top !== '' ? $desc_top : __('开通会员,尊享专属特权', 'aksrt');
$vip_expiry_text = __('到期时间:未开通', 'aksrt'); // 未开通时不留到期文案:状态标签已显示「未开通」,参考主题对从未开通
// 用户同样不输出任何到期信息(仅生效中/永久/已过期才有)。
$vip_expiry_text = '';
$vip_is_permanent = false; $vip_is_permanent = false;
$btn_type = 'pay'; $btn_type = 'pay';
@@ -91,11 +95,12 @@ $status_html = '<div class="uc-vip-status" role="status">'
. '<span class="uc-vip-status__state">' . esc_html($vip_state_label) . '</span>' . '<span class="uc-vip-status__state">' . esc_html($vip_state_label) . '</span>'
. '</div>' . '</div>'
. '<div class="uc-vip-status__main uc-vip-status__main--level-' . (int) $vip_level_number . '">' . '<div class="uc-vip-status__main uc-vip-status__main--level-' . (int) $vip_level_number . '">'
. '<span class="uc-vip-status__icon" aria-hidden="true">' . $status_icon . '</span>' // 未开通(level 0)不输出图标位:名称前不放通用证书图标。
. ($vip_level_number > 0 ? '<span class="uc-vip-status__icon" aria-hidden="true">' . $status_icon . '</span>' : '')
. '<span class="uc-vip-status__level">' . esc_html($vip_level_name) . '</span>' . '<span class="uc-vip-status__level">' . esc_html($vip_level_name) . '</span>'
. '</div>' . '</div>'
. '<div class="uc-vip-status__desc">' . esc_html($vip_description) . '</div>' . '<div class="uc-vip-status__desc">' . esc_html($vip_description) . '</div>'
. '<div class="uc-vip-status__expiry"><i class="fa fas fa-clock" aria-hidden="true"></i><span>' . esc_html($vip_expiry_text) . '</span></div>' . ($vip_expiry_text !== '' ? '<div class="uc-vip-status__expiry"><i class="fa fas fa-clock" aria-hidden="true"></i><span>' . esc_html($vip_expiry_text) . '</span></div>' : '')
. '</div>'; . '</div>';
// Level cards. // Level cards.
@@ -113,6 +118,9 @@ foreach ([1, 2] as $lv) {
: ''; : '';
$cta = sprintf(__('开通%s', 'aksrt'), $lv_name); $cta = sprintf(__('开通%s', 'aksrt'), $lv_name);
$vip_attr = $lv; $vip_attr = $lv;
// 参考主题 zibpay_get_vip_card:续费/升级档位卡展示各自的一句话简介。
$card_action = 'pay';
$card_desc = $lv_desc;
$lv_icon_url = esc_url((string) ($levels[$lv]['img_icon'] ?? '')); $lv_icon_url = esc_url((string) ($levels[$lv]['img_icon'] ?? ''));
$lv_icon = $lv_icon_url !== '' $lv_icon = $lv_icon_url !== ''
? '<img' . aksrt_get_lazy_attr('lazy_other', $lv_icon_url, 'aksrt-vip-level-icon') . ' alt="" decoding="async">' ? '<img' . aksrt_get_lazy_attr('lazy_other', $lv_icon_url, 'aksrt-vip-level-icon') . ' alt="" decoding="async">'
@@ -121,8 +129,10 @@ foreach ([1, 2] as $lv) {
$renew_lv = (int) substr($btn_type, 6); $renew_lv = (int) substr($btn_type, 6);
if ($lv === $renew_lv) { if ($lv === $renew_lv) {
$cta = sprintf(__('续费%s', 'aksrt'), $lv_name); $cta = sprintf(__('续费%s', 'aksrt'), $lv_name);
$card_action = 'renew';
} elseif ($lv > $renew_lv) { } elseif ($lv > $renew_lv) {
$cta = sprintf(__('升级%s', 'aksrt'), $lv_name); $cta = sprintf(__('升级%s', 'aksrt'), $lv_name);
$card_action = 'upgrade';
} else { } else {
continue; continue;
} }
@@ -131,19 +141,26 @@ foreach ([1, 2] as $lv) {
continue; continue;
} }
$cta = sprintf(__('升级%s', 'aksrt'), $lv_name); $cta = sprintf(__('升级%s', 'aksrt'), $lv_name);
$card_action = 'upgrade';
$vip_attr = 2; $vip_attr = 2;
} elseif ($level > 0 && $lv < $level) { } elseif ($level > 0 && $lv < $level) {
continue; continue;
} elseif ($level > 0 && $lv === $level) { } elseif ($level > 0 && $lv === $level) {
$cta = sprintf(__('续费%s', 'aksrt'), $lv_name); $cta = sprintf(__('续费%s', 'aksrt'), $lv_name);
$card_action = 'renew';
}
if ($card_action === 'renew' && $renew_desc !== '') {
$card_desc = $renew_desc;
} elseif ($card_action === 'upgrade' && $upgrade_desc !== '') {
$card_desc = $upgrade_desc;
} }
$cards .= '<div class="aksrt-panel text-center pay-vip-info flex1 level-' . $lv . ' uc-vip-level-card">'; $cards .= '<div class="aksrt-panel text-center pay-vip-info flex1 level-' . $lv . ' uc-vip-level-card">';
$cards .= '<div class="relative uc-vip-level-card__inner">'; $cards .= '<div class="relative uc-vip-level-card__inner">';
$cards .= '<div class="v-iconbox mb10">'; $cards .= '<div class="v-iconbox mb10">';
$cards .= '<div class="v-name font-bold"><span class="v-icon">' . $lv_icon . '</span>' . esc_html($lv_name) . '</div>'; $cards .= '<div class="v-name font-bold"><span class="v-icon">' . $lv_icon . '</span>' . esc_html($lv_name) . '</div>';
if ($lv_desc !== '') { if ($card_desc !== '') {
$cards .= '<div class="v-desc em09 muted-2-color">' . esc_html($lv_desc) . '</div>'; $cards .= '<div class="v-desc em09 muted-2-color">' . esc_html($card_desc) . '</div>';
} }
$cards .= '</div>'; $cards .= '</div>';
$cards .= $equity; $cards .= $equity;
@@ -169,10 +186,4 @@ foreach ([1, 2] as $lv) {
// 下方的全宽横条;仅非会员 + 积分兑换开启 + 有可兑换套餐时输出)。 // 下方的全宽横条;仅非会员 + 积分兑换开启 + 有可兑换套餐时输出)。
echo aksrt_get_vip_points_exchange_entry_html($user_id, $level); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- 各字段已在生成时转义。 echo aksrt_get_vip_points_exchange_entry_html($user_id, $level); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- 各字段已在生成时转义。
?> ?>
<?php if (!empty($vip_settings['more'])) : ?>
<div class="muted-2-color em09 muted-box padding-10 mini-radius mt15">
<?php echo $vip_settings['more']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
<?php endif; ?>
</div> </div>
+1 -105
View File
@@ -19,30 +19,12 @@ class AIController extends ControllerBase {
} }
public function register_routes(): void { public function register_routes(): void {
register_rest_route(self::API_NAMESPACE, '/ai/status', [
'methods' => 'GET',
'callback' => [$this, 'get_status'],
'permission_callback' => [$this, 'check_logged_in'],
]);
register_rest_route(self::API_NAMESPACE, '/ai/settings', [
'methods' => 'POST',
'callback' => [$this, 'update_settings'],
'permission_callback' => [$this, 'check_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/ai/seo-generate', [ register_rest_route(self::API_NAMESPACE, '/ai/seo-generate', [
'methods' => 'POST', 'methods' => 'POST',
'callback' => [$this, 'seo_generate'], 'callback' => [$this, 'seo_generate'],
'permission_callback' => [$this, 'check_logged_in'], 'permission_callback' => [$this, 'check_logged_in'],
]); ]);
register_rest_route(self::API_NAMESPACE, '/ai/seo-suggest', [
'methods' => 'POST',
'callback' => [$this, 'seo_suggest'],
'permission_callback' => [$this, 'check_write_assist'],
]);
register_rest_route(self::API_NAMESPACE, '/ai/summary-suggest', [ register_rest_route(self::API_NAMESPACE, '/ai/summary-suggest', [
'methods' => 'POST', 'methods' => 'POST',
'callback' => [$this, 'summary_suggest'], 'callback' => [$this, 'summary_suggest'],
@@ -76,66 +58,6 @@ class AIController extends ControllerBase {
return false; return false;
} }
public function get_status(): \WP_REST_Response {
if (!class_exists('\AKSRT\Ai\AI')) {
return $this->success(['data' => [
'enabled' => false,
'provider' => 'none',
'configured' => false,
'available' => false,
'backend' => 'none',
]]);
}
return $this->success(['data' => \AKSRT\Ai\AI::get_instance()->get_status()]);
}
public function update_settings(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Ai\AI')) {
return $this->error(__('AI 模块未加载', 'aksrt'), 500);
}
$params = $this->get_json_params($request);
$allowed = [
'enabled' => 'bool',
'provider' => 'string',
'api_key' => 'string',
'api_base' => 'url',
'model' => 'string',
'max_tokens' => 'int',
'rate_limit' => 'int',
];
$config = [];
foreach ($allowed as $key => $type) {
if (!isset($params[$key])) {
continue;
}
switch ($type) {
case 'bool':
$config[$key] = !empty($params[$key]);
break;
case 'int':
$config[$key] = (int) $params[$key];
break;
case 'url':
$config[$key] = esc_url_raw((string) $params[$key]);
break;
default:
$config[$key] = sanitize_text_field((string) $params[$key]);
}
}
$success = \AKSRT\Ai\AI::get_instance()->update_config($config);
if ($success) {
return $this->success([
'message' => __('AI 设置已保存', 'aksrt'),
'data' => \AKSRT\Ai\AI::get_instance()->get_status(),
]);
}
return $this->error(__('保存失败', 'aksrt'));
}
/** /**
* POST /ai/seo-generate — 从 post/term 实体生成 SEO 三字段。 * POST /ai/seo-generate — 从 post/term 实体生成 SEO 三字段。
*/ */
@@ -170,32 +92,6 @@ class AIController extends ControllerBase {
return $this->success(['data' => $result['data']]); return $this->success(['data' => $result['data']]);
} }
public function seo_suggest(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Ai\AISEO')) {
return $this->error(__('AI SEO 模块未加载', 'aksrt'), 500);
}
$params = $this->get_json_params($request);
$title = sanitize_text_field((string) ($params['title'] ?? ''));
$content = wp_kses_post((string) ($params['content'] ?? ''));
$post_id = absint($params['post_id'] ?? 0);
if ($post_id > 0 && !current_user_can('edit_post', $post_id)) {
return $this->error(__('权限不足', 'aksrt'), 403);
}
if ($title === '') {
return $this->error(__('标题不能为空', 'aksrt'));
}
$result = \AKSRT\Ai\AISEO::get_instance()->suggest($title, $content);
if (!empty($result['success'])) {
return $this->success(['data' => $result['data']]);
}
return $this->error((string) ($result['message'] ?? __('生成失败', 'aksrt')));
}
public function summary_suggest(\WP_REST_Request $request): \WP_REST_Response { public function summary_suggest(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Ai\AI') || !class_exists('\AKSRT\Ai\AIPrompt')) { if (!class_exists('\AKSRT\Ai\AI') || !class_exists('\AKSRT\Ai\AIPrompt')) {
return $this->error(__('AI 模块未加载', 'aksrt'), 500); return $this->error(__('AI 模块未加载', 'aksrt'), 500);
@@ -224,7 +120,7 @@ class AIController extends ControllerBase {
$ai = \AKSRT\Ai\AI::get_instance(); $ai = \AKSRT\Ai\AI::get_instance();
if (!$ai->is_available()) { if (!$ai->is_available()) {
return $this->error( return $this->error(
__('AI 服务未启用或未配置。请到「扩展 → AI 辅助」启用并填写 API Key(或配置 WordPress AI Connectors)。', 'aksrt'), __('AI 服务不可用,请配置 WordPress AI Connectors。', 'aksrt'),
503 503
); );
} }
+1 -7
View File
@@ -68,7 +68,7 @@ class GlobalSchema {
'groups' => [ 'groups' => [
['key' => 'basic', 'title' => 'SEO基础', 'fields' => [ ['key' => 'basic', 'title' => 'SEO基础', 'fields' => [
['key' => 'seo_enabled', 'label' => '启用核心SEO优化', 'type' => 'switch', 'default' => true, 'help' => '关闭后主题不再输出 SEO head 标签。'], ['key' => 'seo_enabled', 'label' => '启用核心SEO优化', 'type' => 'switch', 'default' => true, 'help' => '关闭后主题不再输出 SEO head 标签。'],
['key' => 'seo_ai_enabled', 'label' => 'AI生成SEO内容', 'type' => 'switch', 'default' => false, 'help' => '开启后文章/分类独立 SEO 框显示 AI 生成按钮;需配置 AI 连接(WP Connectors 或扩展 → AI 连接配置)。', 'dependsOn' => ['key' => 'seo_enabled', 'value' => true]], ['key' => 'seo_ai_enabled', 'label' => 'AI生成SEO内容', 'type' => 'switch', 'default' => false, 'help' => '开启后文章/分类独立 SEO 框显示 AI 生成按钮;需先在 WordPress 配置 AI Connectors。', 'dependsOn' => ['key' => 'seo_enabled', 'value' => true]],
['key' => 'seo_connector', 'label' => 'SEO连接符', 'type' => 'input', 'default' => '-', 'help' => 'AI 生成标题时用于追加站点名。'], ['key' => 'seo_connector', 'label' => 'SEO连接符', 'type' => 'input', 'default' => '-', 'help' => 'AI 生成标题时用于追加站点名。'],
['key' => 'seo_title', 'label' => 'SEO标题(title)', 'type' => 'textarea', 'default' => ''], ['key' => 'seo_title', 'label' => 'SEO标题(title)', 'type' => 'textarea', 'default' => ''],
['key' => 'seo_keywords', 'label' => 'SEO关键字(keywords)', 'type' => 'textarea', 'default' => ''], ['key' => 'seo_keywords', 'label' => 'SEO关键字(keywords)', 'type' => 'textarea', 'default' => ''],
@@ -79,12 +79,6 @@ class GlobalSchema {
['key' => 'ai_seo_keywords_prompt', 'label' => '关键词提示词', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'seo_ai_enabled', 'value' => true], 'help' => '留空使用内置默认。'], ['key' => 'ai_seo_keywords_prompt', 'label' => '关键词提示词', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'seo_ai_enabled', 'value' => true], 'help' => '留空使用内置默认。'],
['key' => 'ai_seo_description_prompt', 'label' => '描述提示词', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'seo_ai_enabled', 'value' => true], 'help' => '留空使用内置默认。'], ['key' => 'ai_seo_description_prompt', 'label' => '描述提示词', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'seo_ai_enabled', 'value' => true], 'help' => '留空使用内置默认。'],
]], ]],
['key' => 'baidu', 'title' => '百度SEO', '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' => ''],
]],
['key' => 'image', 'title' => '图片SEO', 'fields' => [ ['key' => 'image', 'title' => '图片SEO', 'fields' => [
['key' => 'seo_image_optimize', 'label' => '搜索引擎图片展示优化', 'type' => 'switch', 'default' => true], ['key' => 'seo_image_optimize', 'label' => '搜索引擎图片展示优化', 'type' => 'switch', 'default' => true],
['key' => 'seo_image_default', 'label' => '默认图像', 'type' => 'media', 'default' => ''], ['key' => 'seo_image_default', 'label' => '默认图像', 'type' => 'media', 'default' => ''],
+3 -8
View File
@@ -25,16 +25,11 @@ class SchemaExtensionsPush {
['key' => 'baidu_push_on_publish', 'label' => '发布时主动推送', 'type' => 'switch', 'default' => true], ['key' => 'baidu_push_on_publish', 'label' => '发布时主动推送', 'type' => 'switch', 'default' => true],
['key' => 'baidu_push_on_update', 'label' => '更新时推送', 'type' => 'switch', 'default' => false], ['key' => 'baidu_push_on_update', 'label' => '更新时推送', 'type' => 'switch', 'default' => false],
]], ]],
['key' => 'xzh', 'title' => '熊掌号 / JSON-LD', 'fields' => [ ['key' => 'channel', 'title' => '收录通道', 'fields' => [
['key' => 'xzh_on', 'label' => '启用熊掌号', 'type' => 'switch', 'default' => false], ['key' => 'baidu_push_normal', 'label' => '普通收录', 'type' => 'switch', 'default' => true],
['key' => 'xzh_appid', 'label' => '百度 AppID', 'type' => 'input', 'default' => ''], ['key' => 'baidu_push_fast', 'label' => '快速收录', 'type' => 'switch', 'default' => false],
['key' => 'xzh_render_tail', 'label' => '文章底部显示熊掌号尾部', 'type' => 'switch', 'default' => true],
['key' => 'xzh_jsonld_single', 'label' => '文章页 JSON-LD', 'type' => 'switch', 'default' => true],
['key' => 'xzh_jsonld_page', 'label' => '页面 JSON-LD', 'type' => 'switch', 'default' => false],
['key' => 'xzh_jsonld_img', 'label' => '不添加图片', 'type' => 'switch', 'default' => false],
]], ]],
['key' => 'advanced', 'title' => '高级设置', 'fields' => [ ['key' => 'advanced', 'title' => '高级设置', 'fields' => [
['key' => 'baidu_push_batch_size', 'label' => '批量推送条数', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 500],
['key' => 'baidu_push_log_enabled', 'label' => '记录推送日志', 'type' => 'switch', 'default' => true], ['key' => 'baidu_push_log_enabled', 'label' => '记录推送日志', 'type' => 'switch', 'default' => true],
]], ]],
], ],
+1 -1
View File
@@ -53,7 +53,7 @@ class SchemaPageDisplay {
self::field('nav_newposts', 'Add发布按钮', 'switcher', true, ['description' => '顶部导航显示文章投稿按钮。']), self::field('nav_newposts', 'Add发布按钮', 'switcher', true, ['description' => '顶部导航显示文章投稿按钮。']),
self::field('nav_newposts_radius', '按钮圆角', 'switcher', true, ['dependsOn' => self::enabled('nav_newposts')]), self::field('nav_newposts_radius', '按钮圆角', 'switcher', true, ['dependsOn' => self::enabled('nav_newposts')]),
self::field('nav_newposts_class', '按钮颜色', 'palette', 'jb-blue', ['dependsOn' => self::enabled('nav_newposts'), 'options' => self::aksrt_palette()]), self::field('nav_newposts_class', '按钮颜色', 'palette', 'jb-blue', ['dependsOn' => self::enabled('nav_newposts'), 'options' => self::aksrt_palette()]),
self::field('nav_newposts_text', '按钮文案', 'textarea', '<i class="fa fas fa-fw fas fa-pencil-alt"></i>发布', ['dependsOn' => self::enabled('nav_newposts'), 'sanitize_type' => 'html', 'rows' => 1]), self::field('nav_newposts_text', '按钮文案', 'textarea', '<i class="fa fas fa-fw fa-pencil-alt"></i>发布', ['dependsOn' => self::enabled('nav_newposts'), 'sanitize_type' => 'html', 'rows' => 1]),
]); ]);
} }
+198 -32
View File
@@ -159,7 +159,7 @@ class SchemaPayment {
['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_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' => '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_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' => '会员卡密仅需输入密码,不需要卡号;默认关闭,会员卡需输入卡号和密码(与其他卡密兑换一致)'], ['key' => 'pay_vip_pass_charge_only_password', 'label' => '会员卡密单密码模式', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'pay_vip_pass_charge_s', 'value' => true], 'help' => '会员卡密仅需输入密码,不需要卡号;默认关闭,会员卡需输入卡号和密码(与其他卡密兑换一致)'],
]], ]],
['key' => 'points-exchange', 'title' => '余额兑换积分', 'fields' => [ ['key' => 'points-exchange', 'title' => '余额兑换积分', 'fields' => [
@@ -178,7 +178,7 @@ class SchemaPayment {
'vip' => [ 'vip' => [
'key' => 'vip', 'key' => 'vip',
'title' => 'VIP会员', 'title' => 'VIP会员',
'version' => '1.1.0', 'version' => '1.2.0',
'option_key' => 'aksrt_settings', 'option_key' => 'aksrt_settings',
'groups' => [ 'groups' => [
['key' => 'notice', 'title' => '说明', 'fields' => [ ['key' => 'notice', 'title' => '说明', 'fields' => [
@@ -188,17 +188,8 @@ class SchemaPayment {
['key' => 'nav_pay_vip', 'label' => '导航栏购买按钮', 'type' => 'switch', 'default' => true, 'help' => '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。'], ['key' => 'nav_pay_vip', 'label' => '导航栏购买按钮', 'type' => 'switch', 'default' => true, 'help' => '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。'],
['key' => 'nav_user_pay_vip', 'label' => '用户框购买按钮', 'type' => 'switch', 'default' => true, 'help' => '在导航栏用户框内显示开通会员按钮。'], ['key' => 'nav_user_pay_vip', 'label' => '用户框购买按钮', 'type' => 'switch', 'default' => true, 'help' => '在导航栏用户框内显示开通会员按钮。'],
['key' => 'pay_user_vip_1_s', 'label' => '启用一级会员', 'type' => 'switch', 'default' => true], ['key' => 'pay_user_vip_1_s', 'label' => '启用一级会员', 'type' => 'switch', 'default' => true],
['key' => 'pay_user_vip_1_name', 'label' => '一级会员显示名称', 'type' => 'input', 'default' => '一级会员', 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true]], ['key' => '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_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]],
['key' => 'vip_1_img_icon', 'label' => '一级会员图标 URL', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true], 'help' => '留空使用内置 vip-1.svg 图标'],
['key' => 'pay_user_vip_2_s', 'label' => '启用二级会员', 'type' => 'switch', 'default' => true], ['key' => 'pay_user_vip_2_s', 'label' => '启用二级会员', 'type' => 'switch', 'default' => true],
['key' => 'pay_user_vip_2_name', 'label' => '二级会员显示名称', 'type' => 'input', 'default' => '二级会员', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true]],
['key' => 'pay_user_vip_2_desc', 'label' => '二级会员一句话简介', 'type' => 'input', 'default' => '尊享二级会员特权', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true]],
['key' => 'pay_user_vip_2_equity', 'label' => '二级会员权益 HTML', 'type' => 'textarea', 'default' => '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true]],
['key' => 'vip_2_img_icon', 'label' => '二级会员图标 URL', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true], 'help' => '留空使用内置 vip-2.svg 图标'],
['key' => 'pay_user_vip_desc', 'label' => '弹窗顶部会员说明', 'type' => 'textarea', 'default' => ''],
['key' => 'pay_user_vip_more', 'label' => '弹窗底部补充说明 HTML', 'type' => 'textarea', 'default' => ''],
]], ]],
['key' => 'benefit', 'title' => '会员权益', 'fields' => [ ['key' => 'benefit', 'title' => '会员权益', 'fields' => [
['key' => 'vip_benefit', 'label' => '会员权益', 'type' => 'tabbed', 'tabs' => [ ['key' => 'vip_benefit', 'label' => '会员权益', 'type' => 'tabbed', 'tabs' => [
@@ -206,11 +197,11 @@ class SchemaPayment {
['key' => 'pay_download_limit', 'label' => '付费资源:免费资源每日可下载(个)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'help' => '普通用户每日最多可下载几个免费资源(为0则不限制)'], ['key' => 'pay_download_limit', 'label' => '付费资源:免费资源每日可下载(个)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'help' => '普通用户每日最多可下载几个免费资源(为0则不限制)'],
['key' => 'pay_download_speed', 'label' => '付费资源:下载限速(KB/s)', 'type' => 'number', 'default' => 0, 'min' => 0, 'help' => '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效'], ['key' => 'pay_download_speed', 'label' => '付费资源:下载限速(KB/s)', 'type' => 'number', 'default' => 0, 'min' => 0, 'help' => '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效'],
]], ]],
['key' => 'vip1', 'title' => '一级会员', 'fields' => [ ['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_limit_vip_1', 'label' => '付费资源:免费资源每日可下载(个)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true], 'help' => '一级会员每日最多可下载几个免费资源(为0则不限制)'],
['key' => 'pay_download_speed_vip_1', 'label' => '付费资源:下载限速(KB/s)', 'type' => 'number', 'default' => 0, 'min' => 0, 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true], 'help' => '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效'], ['key' => 'pay_download_speed_vip_1', 'label' => '付费资源:下载限速(KB/s)', 'type' => 'number', 'default' => 0, 'min' => 0, 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true], 'help' => '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效'],
]], ]],
['key' => 'vip2', 'title' => '二级会员', 'fields' => [ ['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_limit_vip_2', 'label' => '付费资源:免费资源每日可下载(个)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true], 'help' => '二级会员每日最多可下载几个免费资源(为0则不限制)'],
['key' => 'pay_download_speed_vip_2', 'label' => '付费资源:下载限速(KB/s)', 'type' => 'number', 'default' => 0, 'min' => 0, 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true], 'help' => '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效'], ['key' => '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则不限制);只对本地服务器文件有效'],
]], ]],
@@ -218,36 +209,110 @@ class SchemaPayment {
]], ]],
['key' => 'opt', 'title' => '会员购买选项', 'fields' => [ ['key' => 'opt', 'title' => '会员购买选项', 'fields' => [
['key' => 'vip_opt', 'label' => '会员购买选项', 'type' => 'tabbed', 'tabs' => [ ['key' => 'vip_opt', 'label' => '会员购买选项', 'type' => 'tabbed', 'tabs' => [
['key' => 'vip1', 'title' => '一级会员', 'fields' => [ ['key' => 'vip1', 'title' => '一级会员', 'titleKey' => 'pay_user_vip_1_name', 'titleFallback' => '一级会员', 'fields' => self::vip_level_tab_fields(1)],
['key' => 'vip_1_product_s', 'label' => '允许购买一级会员', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true]], ['key' => 'vip2', 'title' => '二级会员', 'titleKey' => 'pay_user_vip_2_name', 'titleFallback' => '二级会员', 'fields' => self::vip_level_tab_fields(2)],
['key' => 'payment_vip_1_products', 'label' => '一级会员套餐', 'type' => 'textarea', 'default' => "30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡", 'dependsOn' => ['key' => 'vip_1_product_s', 'value' => true], 'help' => '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久'],
]],
['key' => 'vip2', 'title' => '二级会员', '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=永久'],
]],
['key' => 'renew', 'title' => '会员续费', 'fields' => [ ['key' => 'renew', 'title' => '会员续费', 'fields' => [
['key' => 'vip_renew', 'label' => '允许续费', 'type' => 'switch', 'default' => true, 'help' => '关闭后则不能续费,只能会员到期之后再购买。永久会员不需要续费'], ['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_desc', 'label' => '续费介绍', 'type' => 'input', 'default' => '立即续费会员,畅享VIP权益', 'dependsOn' => ['key' => 'vip_renew', 'value' => true], 'help' => '续费会员的一句话简介'],
['key' => 'vip_renew_price_type', 'label' => '续费价格类型', 'type' => 'radio', 'default' => 'discount', 'dependsOn' => ['key' => 'vip_renew', 'value' => true], 'options' => [ ['key' => 'vip_renew_price_type', 'label' => '续费价格类型', 'type' => 'select', 'default' => 'discount', 'dependsOn' => ['key' => 'vip_renew', 'value' => true], 'options' => [
['label' => '相同价格', 'value' => 'same'], ['label' => '相同价格', 'value' => 'same'],
['label' => '折扣价格', 'value' => 'discount'], ['label' => '折扣价格', 'value' => 'discount'],
['label' => '优惠立减', 'value' => 'reduce'], ['label' => '优惠立减', 'value' => 'reduce'],
]], ['label' => '自定义商品', 'value' => 'customize'],
['key' => 'vip_renew_discount', 'label' => '续费折扣(折,如 8=八折)', 'type' => 'number', 'default' => 8, 'min' => 1, 'max' => 100, 'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'discount']], ], 'help' => '自定义商品时,按下方「一级会员续费」「二级会员续费」的商品选项计价'],
['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']], ['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.00', 'show_price' => '199.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
['price' => '169.00', 'show_price' => '299.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
],
'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'customize'],
'help' => '一级会员续费的商品选项',
...self::vip_product_group_options(),
'fields' => self::vip_product_fields(),
],
[
'key' => 'vip_2_renew_product', 'label' => '二级会员续费', 'type' => 'group', 'max' => 8,
'button_title' => '添加续费商品', 'default' => [
['price' => '269.00', 'show_price' => '599.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
['price' => '369.00', 'show_price' => '899.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
],
'dependsOn' => ['key' => 'vip_renew_price_type', 'value' => 'customize'],
'help' => '二级会员续费的商品选项',
...self::vip_product_group_options(),
'fields' => self::vip_product_fields(),
],
]], ]],
['key' => 'upgrade', 'title' => '会员升级', 'fields' => [ ['key' => 'upgrade', 'title' => '会员升级', 'fields' => [
['key' => 'vip_upgrade', 'label' => '允许升级', 'type' => 'switch', 'default' => true], ['key' => 'vip_upgrade', 'label' => '允许升级', 'type' => 'switch', 'default' => true],
['key' => 'vip_upgrade_desc', 'label' => '升级说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'vip_upgrade', 'value' => true]], ['key' => 'vip_upgrade_desc', 'label' => '升级介绍', 'type' => 'input', 'default' => '升级VIP会员,享更多会员权益', 'dependsOn' => ['key' => 'vip_upgrade', 'value' => true], 'help' => '升级会员的一句话简介'],
['key' => 'vip_upgrade_product', 'label' => '升级产品', 'type' => 'textarea', 'default' => "unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级", 'dependsOn' => ['key' => 'vip_upgrade', 'value' => true], 'help' => '每行一条。永久VIP1:permanent|价格|标题;剩余天:unit|每日单价|标题;跳转永久:jump|价格|标题'], [
'key' => 'vip_upgrade_product', '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' => '会员升级的商品选项',
'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' => self::vip_tag_palette()],
['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' => self::vip_tag_palette()],
['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' => self::vip_tag_palette()],
],
],
]], ]],
]], ]],
]], ]],
['key' => 'points-exchange', 'title' => '积分兑换会员', 'fields' => [ ['key' => 'points-exchange', 'title' => '积分兑换会员', '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|积分永久", 'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true], 'help' => '每行:等级|积分|天数|标题(天数 0=永久;普通用户可开通,已有会员可续费同等级或升级更高等级)'], [
['key' => 'pay_vip_points_exchange_desc', 'label' => '兑换说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true]], 'key' => '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' => '每一行为一个可兑换的会员套餐',
'collapsible' => true,
'defaultCollapsed' => true,
'listPreview' => ['prefix' => '积分:', 'titleKey' => 'points', 'descKey' => 'time', 'descUnitKey' => 'unit'],
'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' => [
['label' => '天', 'value' => 'day'],
['label' => '月', 'value' => 'month'],
]],
],
],
['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' => '界面文案', 'fields' => [
['key' => 'pay_user_vip_desc', 'label' => '会员介绍', 'type' => 'input', 'default' => '开通VIP会员,享受会员专属折扣以及多项特权', 'dependsOn' => ['any' => [['key' => 'pay_user_vip_1_s', 'value' => true], ['key' => 'pay_user_vip_2_s', 'value' => true]]]],
['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]]]],
]], ]],
], ],
], ],
@@ -536,6 +601,19 @@ class SchemaPayment {
]; ];
} }
/**
* 会员商品/续费商品的手风琴配置:默认收起,标题行显示价格与时长摘要。
*
* @return array<string,mixed>
*/
private static function vip_product_group_options(): array {
return [
'collapsible' => true,
'defaultCollapsed' => true,
'listPreview' => ['prefix' => '价格:', 'titleKey' => 'price', 'descKey' => 'time', 'descUnitKey' => 'unit'],
];
}
/** /**
* PayPal / Stripe 结算货币选项(参考主题同款 26 种)。 * PayPal / Stripe 结算货币选项(参考主题同款 26 种)。
* *
@@ -576,4 +654,92 @@ class SchemaPayment {
} }
return $options; return $options;
} }
/**
* 会员商品/续费商品的统一字段集(参考主题 CFS_Module::vip_product 1:1)。
*
* @return array<int,array<string,mixed>>
*/
private static function vip_product_fields(): array {
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' => self::vip_tag_palette()],
['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' => [
['label' => '天', 'value' => 'day'],
['label' => '月', 'value' => 'month'],
]],
['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 1:1,显示名称并入 Tab)。
*
* @return array<int,array<string,mixed>>
*/
private static function vip_level_tab_fields(int $level): array {
$dep = ['key' => 'pay_user_vip_' . $level . '_s', 'value' => true];
$equity = '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>';
return [
['key' => 'pay_user_vip_' . $level . '_name', 'label' => '会员显示名称', 'type' => 'input', 'default' => $level === 1 ? '黄金会员' : '钻石会员', 'dependsOn' => $dep, 'help' => '会员名称(例如“黄金会员”、“超级会员”)'],
['key' => 'pay_user_vip_' . $level . '_desc', 'label' => '一句话简介', 'type' => 'input', 'default' => '尊享专属特权', 'dependsOn' => $dep],
['key' => 'pay_user_vip_' . $level . '_equity', 'label' => '会员权益 HTML', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => $equity, 'rows' => 4, 'dependsOn' => $dep, 'help' => '建议使用 <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' => self::vip_product_defaults(),
...self::vip_product_group_options(),
'dependsOn' => ['all' => [$dep, ['key' => 'vip_' . $level . '_product_s', 'value' => true]]],
'help' => '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
'fields' => self::vip_product_fields(),
],
['key' => 'vip_' . $level . '_img_icon', 'label' => '会员图标', 'type' => 'upload', 'default' => '', 'dependsOn' => $dep, 'help' => '留空使用内置 vip-' . $level . '.svg 图标;建议尺寸300x300'],
];
}
/**
* 会员商品默认两行(参考主题 vip_tab 的 group 默认值)。
*
* @return array<int,array<string,mixed>>
*/
private static function vip_product_defaults(): array {
return [
['price' => '99.00', 'show_price' => '199.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
['price' => '199.00', 'show_price' => '299.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
];
}
/**
* 标签颜色调色板(参考主题 zib_palette(array(), array('b','jb')))。
*
* @return array<int,array<string,string>>
*/
private static function vip_tag_palette(): array {
$colors = [
'b-red' => '#f74b3d',
'b-yellow' => '#f3920a',
'b-cyan' => '#08c4c1',
'b-blue' => '#0a8cf3',
'b-green' => '#1fd05a',
'b-purple' => '#c133f5',
'b-black' => '#121517',
'jb-red' => 'linear-gradient(135deg, #ffbeb4 10%, #f61a1a 100%)',
'jb-pink' => 'linear-gradient(135deg, #ff5e7f 30%, #ff967e 100%)',
'jb-yellow' => 'linear-gradient(135deg, #ffd6b2 10%, #ff651c 100%)',
'jb-cyan' => 'linear-gradient(140deg, #039ab3 10%, #58dbcf 90%)',
'jb-blue' => 'linear-gradient(135deg, #b6e6ff 10%, #198aff 100%)',
'jb-green' => 'linear-gradient(135deg, #ccffcd 10%, #52bb51 100%)',
'jb-purple' => 'linear-gradient(135deg, #fec2ff 10%, #d000de 100%)',
'jb-vip1' => 'linear-gradient(25deg, #eab869 10%, #fbecd4 60%, #ffe0ae 100%)',
'jb-vip2' => 'linear-gradient(317deg, #4d4c4c 30%, #878787 70%, #5f5c5c 100%)',
];
$options = [];
foreach ($colors as $value => $css) {
$options[] = ['label' => $css, 'value' => $value];
}
return $options;
}
} }
-30
View File
@@ -611,36 +611,6 @@ class SchemaUsers {
]], ]],
], ],
], ],
'wechat' => [
'key' => 'wechat',
'title' => '微信通知',
'version' => '1.0.0',
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'account', 'title' => '公众号配置', 'fields' => [
['key' => 'wechat_notify_enabled', 'label' => '启用微信通知', 'type' => 'switch', 'default' => false],
['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' => 'verify', 'title' => '服务器验证', 'fields' => [
['key' => 'wechat_token', 'label' => 'Token', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'wechat_notify_enabled', 'value' => true]],
['key' => 'wechat_encoding_aes_key', 'label' => 'EncodingAESKey', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'wechat_notify_enabled', 'value' => true]],
]],
['key' => 'templates', 'title' => '模板消息', 'fields' => [
['key' => 'wechat_template_comment', 'label' => '评论通知模板', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'wechat_notify_enabled', 'value' => true]],
['key' => 'wechat_template_reply', 'label' => '回复通知模板', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'wechat_notify_enabled', 'value' => true]],
['key' => 'wechat_template_system', 'label' => '系统通知模板', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'wechat_notify_enabled', 'value' => true]],
['key' => 'wechat_template_order', 'label' => '订单通知模板', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'wechat_notify_enabled', 'value' => true]],
]],
['key' => 'triggers', 'title' => '通知触发器', '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_notify_order', '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]],
]],
],
],
], ],
]; ];
} }
+56
View File
@@ -866,6 +866,47 @@ class SchemaProvider {
return null; return null;
} }
/**
* Resolve a submitted enum value to the value declared by its option.
*
* Payloads reach the sanitizer as strings (or numbers), so a strict match
* against the schema would reject a valid choice whenever the option value
* is not itself a string -- e.g. 积分兑换明细.level declares 1 / 2 while the
* SPA submits them as numbers and sanitize_text_field() then turns them into
* "1" / "2". Returning the declared value keeps the stored type aligned with
* the schema, which the radio/select controls compare with === when they
* re-render a saved setting.
*
* Returns null when nothing matches, so validation still reports the
* invalid value instead of silently rewriting the operator's input.
*
* @param mixed $value
* @param array<int,mixed> $options
* @return mixed
*/
private function match_option_value($value, array $options) {
foreach ($options as $opt) {
$opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt;
if ($opt_value === $value) {
return $opt_value;
}
}
if (is_bool($value) || !is_scalar($value)) {
return null;
}
$needle = (string) $value;
foreach ($options as $opt) {
$opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt;
if (is_bool($opt_value) || !is_scalar($opt_value)) {
continue;
}
if ((string) $opt_value === $needle) {
return $opt_value;
}
}
return null;
}
private function sanitize_field_value($value, array $field) { private function sanitize_field_value($value, array $field) {
if (!empty($field['captcha_provider'])) { if (!empty($field['captcha_provider'])) {
return function_exists('aksrt_captcha_sanitize_provider_config') return function_exists('aksrt_captcha_sanitize_provider_config')
@@ -898,6 +939,15 @@ class SchemaProvider {
return $normalized !== null ? $normalized : (string) ($field['default'] ?? ''); return $normalized !== null ? $normalized : (string) ($field['default'] ?? '');
} }
// Enum fields keep the option's declared value so the stored type always
// matches what validate() compares against with in_array(..., true).
if (in_array($type, ['select', 'radio', 'palette', 'image_select'], true) && empty($field['multiple']) && !empty($field['options'])) {
$matched = $this->match_option_value($value, (array) $field['options']);
if ($matched !== null) {
return $matched;
}
}
if (in_array($type, ['fieldset', 'accordion'], true)) { if (in_array($type, ['fieldset', 'accordion'], true)) {
return $this->sanitize_object_fields($value, (array) ($field['fields'] ?? [])); return $this->sanitize_object_fields($value, (array) ($field['fields'] ?? []));
} }
@@ -994,6 +1044,12 @@ class SchemaProvider {
if (!is_array($field) || empty($field['key'])) { if (!is_array($field) || empty($field['key'])) {
continue; continue;
} }
// UI-only chrome (notice / alert rows) is presentation only and must
// never become a persisted settings key, matching the top-level
// behaviour of collect_persisted_fields().
if (!empty($field['ui_only']) || ($field['type'] ?? '') === 'notice') {
continue;
}
$key = (string) $field['key']; $key = (string) $field['key'];
if (array_key_exists($key, $value)) { if (array_key_exists($key, $value)) {
$clean[$key] = $this->sanitize_field_value($value[$key], $field); $clean[$key] = $this->sanitize_field_value($value[$key], $field);
+48
View File
@@ -325,6 +325,54 @@ class UserController extends ControllerBase {
} }
/**
* GET /wechat/status — 微信模板消息配置状态。
*
* 状态形状与 WeChatNotify::get_status() 对齐(该方法的既定用途即 REST 与 AJAX 复用)。
*/
public function get_wechat_status(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Payment\WeChatNotify')) {
return $this->error(__('微信通知组件未加载', 'aksrt'), 500);
}
return $this->success(['data' => \AKSRT\Payment\WeChatNotify::get_instance()->get_status()]);
}
/**
* POST /wechat/settings — 更新微信模板消息配置。
*
* 前端在密钥留空且已配置时发送掩码 ***,此时保留已存密钥;
* 未提供的字段沿用当前值。键白名单由 Integration::update_wechat_settings() 收敛。
*/
public function update_wechat_settings(\WP_REST_Request $request): \WP_REST_Response {
$params = $this->get_json_params($request);
$current = \AKSRT\Settings\Settings::get_wechat_settings();
$enabled = array_key_exists('enabled', $params) ? !empty($params['enabled']) : !empty($current['enabled']);
$app_id = array_key_exists('app_id', $params)
? sanitize_text_field((string) $params['app_id'])
: (string) ($current['app_id'] ?? '');
$template_id = array_key_exists('template_id', $params)
? sanitize_text_field((string) $params['template_id'])
: (string) ($current['template_id'] ?? '');
// 掩码 *** 表示「不修改密钥」;仅在显式传入其他值时才覆盖。
if (array_key_exists('app_secret', $params) && (string) $params['app_secret'] !== '***') {
$app_secret = sanitize_text_field((string) $params['app_secret']);
} else {
$app_secret = (string) ($current['app_secret'] ?? '');
}
\AKSRT\Settings\Settings::update_wechat_settings([
'enabled' => $enabled,
'app_id' => $app_id,
'app_secret' => $app_secret,
'template_id' => $template_id,
]);
return $this->success(['message' => __('微信通知配置已保存', 'aksrt')]);
}
public function rest_handle_credit_settings(\WP_REST_Request $request): \WP_REST_Response { public function rest_handle_credit_settings(\WP_REST_Request $request): \WP_REST_Response {
if ($request->get_method() === 'GET') { if ($request->get_method() === 'GET') {
$config = \AKSRT\Settings\CreditsRuntime::get_credit_settings(); $config = \AKSRT\Settings\CreditsRuntime::get_credit_settings();
+9 -314
View File
@@ -11,9 +11,6 @@ class AI {
private static $instance = null; private static $instance = null;
const PROVIDER_NONE = 'none';
const PROVIDER_OPENAI = 'openai';
public static function get_instance(): self { public static function get_instance(): self {
if (null === self::$instance) { if (null === self::$instance) {
self::$instance = new self(); self::$instance = new self();
@@ -21,95 +18,10 @@ class AI {
return self::$instance; return self::$instance;
} }
/**
* 获取 AI 配置(主题侧 provider)
*/
public function get_config(): array {
$defaults = [
'enabled' => false,
'provider' => self::PROVIDER_NONE,
'api_key' => '',
'api_base' => 'https://api.openai.com/v1',
'model' => 'gpt-3.5-turbo',
'max_tokens' => 1000,
'rate_limit' => 10,
];
$saved = get_option('aksrt_ai_settings', []);
if (!is_array($saved)) {
$saved = [];
}
return array_merge($defaults, $saved);
}
/**
* 更新 AI 配置
*/
public function update_config(array $config): bool {
$current = $this->get_config();
$merged = array_merge($current, $config);
// Preserve existing key when client sends the masked placeholder.
if (isset($config['api_key']) && $config['api_key'] === '***') {
$merged['api_key'] = $current['api_key'];
}
$merged['enabled'] = !empty($merged['enabled']);
$merged['provider'] = sanitize_key((string) ($merged['provider'] ?? self::PROVIDER_NONE));
$merged['api_key'] = (string) ($merged['api_key'] ?? '');
$merged['api_base'] = $this->normalize_api_base((string) ($merged['api_base'] ?? 'https://api.openai.com/v1'));
$merged['model'] = sanitize_text_field((string) ($merged['model'] ?? 'gpt-3.5-turbo'));
$merged['max_tokens'] = max(100, min(4000, (int) ($merged['max_tokens'] ?? 1000)));
$merged['rate_limit'] = max(1, min(100, (int) ($merged['rate_limit'] ?? 10)));
// Persist only the known keys so shape stays stable.
$to_store = [
'enabled' => $merged['enabled'],
'provider' => $merged['provider'],
'api_key' => $merged['api_key'],
'api_base' => $merged['api_base'],
'model' => $merged['model'],
'max_tokens' => $merged['max_tokens'],
'rate_limit' => $merged['rate_limit'],
];
$before = get_option('aksrt_ai_settings', []);
$updated = update_option('aksrt_ai_settings', $to_store);
if ($updated) {
return true;
}
// update_option returns false when the value is unchanged — treat as success.
if (is_array($before)) {
$before_normalized = array_merge([
'enabled' => false,
'provider' => self::PROVIDER_NONE,
'api_key' => '',
'api_base' => 'https://api.openai.com/v1',
'model' => 'gpt-3.5-turbo',
'max_tokens' => 1000,
'rate_limit' => 10,
], $before);
$before_normalized['enabled'] = !empty($before_normalized['enabled']);
$before_normalized['provider'] = sanitize_key((string) ($before_normalized['provider'] ?? self::PROVIDER_NONE));
$before_normalized['api_key'] = (string) ($before_normalized['api_key'] ?? '');
$before_normalized['api_base'] = esc_url_raw((string) ($before_normalized['api_base'] ?? 'https://api.openai.com/v1'));
$before_normalized['model'] = sanitize_text_field((string) ($before_normalized['model'] ?? 'gpt-3.5-turbo'));
$before_normalized['max_tokens'] = max(100, min(4000, (int) ($before_normalized['max_tokens'] ?? 1000)));
$before_normalized['rate_limit'] = max(1, min(100, (int) ($before_normalized['rate_limit'] ?? 10)));
return maybe_serialize($before_normalized) === maybe_serialize($to_store)
|| $before_normalized == $to_store;
}
return maybe_serialize($before) === maybe_serialize($to_store);
}
/** /**
* WordPress 核心 AI Client 是否真正可用。 * WordPress 核心 AI Client 是否真正可用。
* *
* 仅有函数存根不够:没有 Connectors / wp_supports_ai=false 时不应抢占主题 provider。 * 仅有函数存根不够:没有 Connectors / wp_supports_ai=false 时不应声称可用。
*/ */
public function is_wp_ai_available(): bool { public function is_wp_ai_available(): bool {
if (!function_exists('wp_ai_client_prompt')) { if (!function_exists('wp_ai_client_prompt')) {
@@ -124,89 +36,24 @@ class AI {
} }
/** /**
* 主题 OpenAI 兼容 provider 是否可用。 * AI 生成是否可用(连接由 WordPress AI Connectors 提供)。
*/
public function is_theme_provider_available(): bool {
$config = $this->get_config();
$provider = sanitize_key((string) ($config['provider'] ?? self::PROVIDER_NONE));
$api_key = trim((string) ($config['api_key'] ?? ''));
$api_base = trim((string) ($config['api_base'] ?? ''));
return !empty($config['enabled'])
&& $provider !== self::PROVIDER_NONE
&& $provider !== ''
&& $api_key !== ''
&& $api_base !== '';
}
/**
* 任一后端可用即可生成。
*/ */
public function is_available(): bool { public function is_available(): bool {
return $this->is_theme_provider_available() || $this->is_wp_ai_available(); return $this->is_wp_ai_available();
}
/**
* 当前优先后端标识。
*
* Theme provider wins when explicitly configured — WP AI stubs often exist
* without a working connector and previously produced opaque 400 failures.
*/
public function resolve_backend(): string {
if ($this->is_theme_provider_available()) {
return 'theme';
}
if ($this->is_wp_ai_available()) {
return 'wp_ai';
}
return 'none';
}
/**
* 获取 AI 状态(不回显完整密钥)。
*/
public function get_status(): array {
$config = $this->get_config();
$seo = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_seo_settings() : [];
return [
'enabled' => !empty($config['enabled']),
'provider' => (string) ($config['provider'] ?? self::PROVIDER_NONE),
'configured' => !empty($config['api_key']) || $this->is_wp_ai_available(),
'model' => (string) ($config['model'] ?? ''),
'api_base' => (string) ($config['api_base'] ?? ''),
'api_key' => !empty($config['api_key']) ? '***' : '',
'max_tokens' => (int) ($config['max_tokens'] ?? 1000),
'rate_limit' => (int) ($config['rate_limit'] ?? 10),
'available' => $this->is_available(),
'backend' => $this->resolve_backend(),
'wp_ai_available' => $this->is_wp_ai_available(),
'theme_available' => $this->is_theme_provider_available(),
'seo_enabled' => !empty($seo['seo_enabled']),
'seo_ai_enabled' => !empty($seo['seo_ai_enabled']),
'write_assist' => $this->is_available(),
];
} }
/** /**
* 统一文本生成出口。 * 统一文本生成出口。
* *
* @param string $prompt 用户提示 / 上下文 * @param string $prompt 用户提示 / 上下文
* @param array $options system_instruction, max_tokens, temperature * @param array $options system_instruction(其余选项由 WP AI Client 忽略)
* @return array{success:bool,content?:string,message?:string,backend?:string} * @return array{success:bool,content?:string,message?:string,backend?:string}
*/ */
public function generate_text(string $prompt, array $options = []): array { public function generate_text(string $prompt, array $options = []): array {
if (!$this->is_available()) { if (!$this->is_available()) {
return [ return [
'success' => false, 'success' => false,
'message' => __('AI 服务未启用或未配置。请配置 WordPress AI Connectors,或在「扩展 → AI 辅助」填写 API Key。', 'aksrt'), 'message' => __('AI 服务不可用,请配置 WordPress AI Connectors。', 'aksrt'),
];
}
if (!$this->check_rate_limit()) {
return [
'success' => false,
'message' => __('请求过于频繁,请稍后再试', 'aksrt'),
]; ];
} }
@@ -214,42 +61,12 @@ class AI {
? (string) $options['system_instruction'] ? (string) $options['system_instruction']
: __('你是一个内容助手,提供建议性内容。输出仅作为参考建议,不会自动应用到线上内容。', 'aksrt'); : __('你是一个内容助手,提供建议性内容。输出仅作为参考建议,不会自动应用到线上内容。', 'aksrt');
$last_error = [ $result = $this->generate_via_wp_ai($prompt, $system, $options);
'success' => false, if (!empty($result['success'])) {
'message' => __('AI 服务未启用或未配置。请配置 WordPress AI Connectors,或在「扩展 → AI 辅助」填写 API Key。', 'aksrt'), $result['backend'] = 'wp_ai';
];
// 1) Prefer explicitly configured theme OpenAI-compatible provider.
if ($this->is_theme_provider_available()) {
$theme_result = $this->generate_via_theme($prompt, $system, $options);
$this->record_call();
if (!empty($theme_result['success'])) {
$theme_result['backend'] = 'theme';
return $theme_result;
}
$last_error = $theme_result;
// Fall through to WP AI if available.
} }
// 2) WordPress AI Client (Connectors). return $result;
if ($this->is_wp_ai_available()) {
$wp_result = $this->generate_via_wp_ai($prompt, $system, $options);
$this->record_call();
if (!empty($wp_result['success'])) {
$wp_result['backend'] = 'wp_ai';
return $wp_result;
}
$last_error = $wp_result;
}
return $last_error;
}
/**
* 兼容旧调用。
*/
public function generate(string $prompt, array $options = []): array {
return $this->generate_text($prompt, $options);
} }
private function generate_via_wp_ai(string $prompt, string $system, array $options): array { private function generate_via_wp_ai(string $prompt, string $system, array $options): array {
@@ -289,126 +106,4 @@ class AI {
]; ];
} }
} }
/**
* Normalize OpenAI-compatible base URL.
*
* Accepts gateway root (https://host), /v1, trailing slash, or full
* .../chat/completions and returns a base that can append /chat/completions.
*/
public function normalize_api_base(string $api_base): string {
$api_base = trim($api_base);
if ($api_base === '') {
return 'https://api.openai.com/v1';
}
$api_base = esc_url_raw($api_base);
$api_base = rtrim($api_base, '/');
// If user pasted the full chat endpoint, strip the leaf.
if (preg_match('#/chat/completions$#i', $api_base)) {
$api_base = (string) preg_replace('#/chat/completions$#i', '', $api_base);
$api_base = rtrim($api_base, '/');
}
// Common gateways (NewAPI etc.): root host needs /v1.
if (!preg_match('#/(v1|openai/v1)$#i', $api_base)) {
$api_base .= '/v1';
}
return rtrim($api_base, '/');
}
private function generate_via_theme(string $prompt, string $system, array $options): array {
$config = $this->get_config();
$max_tokens = (int) ($options['max_tokens'] ?? $config['max_tokens']);
$temperature = (float) ($options['temperature'] ?? 0.7);
$api_base = $this->normalize_api_base((string) $config['api_base']);
$response = wp_remote_post($api_base . '/chat/completions', [
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $config['api_key'],
],
'body' => wp_json_encode([
'model' => $config['model'],
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $prompt],
],
'max_tokens' => $max_tokens,
'temperature' => $temperature,
]),
'timeout' => 45,
]);
if (is_wp_error($response)) {
return [
'success' => false,
'message' => sprintf(
/* translators: %s: error message */
__('API 请求失败: %s', 'aksrt'),
$response->get_error_message()
),
];
}
$body = json_decode((string) wp_remote_retrieve_body($response), true);
$status_code = (int) wp_remote_retrieve_response_code($response);
if ($status_code !== 200) {
$error_msg = is_array($body) ? ($body['error']['message'] ?? __('未知错误', 'aksrt')) : __('未知错误', 'aksrt');
return [
'success' => false,
'message' => sprintf(
/* translators: 1: HTTP status, 2: error message */
__('API 错误 (%1$d): %2$s', 'aksrt'),
$status_code,
$error_msg
),
];
}
$message = is_array($body) ? ($body['choices'][0]['message'] ?? []) : [];
$content = is_array($message) ? (string) ($message['content'] ?? '') : '';
if ($content === '' && is_array($message) && !empty($message['reasoning_content'])) {
$content = (string) $message['reasoning_content'];
}
$content = trim($content);
if ($content === '') {
return [
'success' => false,
'message' => __('AI 返回内容为空(可能 max_tokens 不足)', 'aksrt'),
];
}
return [
'success' => true,
'content' => $content,
'reasoning' => is_array($message) ? (string) ($message['reasoning_content'] ?? '') : '',
'usage' => is_array($body) ? ($body['usage'] ?? []) : [],
'finish_reason' => is_array($body) ? (string) ($body['choices'][0]['finish_reason'] ?? '') : '',
];
}
private function check_rate_limit(): bool {
$config = $this->get_config();
$limit = (int) $config['rate_limit'];
if ($limit <= 0) {
return true;
}
$key = 'aksrt_ai_rate_' . get_current_user_id();
$count = (int) get_transient($key);
return $count < $limit;
}
private function record_call(): void {
$key = 'aksrt_ai_rate_' . get_current_user_id();
$count = (int) get_transient($key);
set_transient($key, $count + 1, MINUTE_IN_SECONDS);
}
} }
-25
View File
@@ -77,31 +77,6 @@ class AIPrompt {
6. 直接输出描述文本本身,不要任何前缀、引号或解释。'; 6. 直接输出描述文本本身,不要任何前缀、引号或解释。';
} }
/**
* 兼容旧单 prompt 构建(草稿/无 ID 场景)。
*/
public function build_seo_prompt(string $title, string $content): string {
$title_instruction = $this->get_seo_title_system_instruction();
$kw_instruction = $this->get_seo_keywords_system_instruction();
$desc_instruction = $this->get_seo_description_system_instruction();
return <<<PROMPT
请根据以下文章内容,生成 SEO 优化建议。
文章标题:{$title}
文章内容:
{$content}
请以 JSON 格式返回,包含以下字段:
- suggested_title: {$title_instruction}
- suggested_description: {$desc_instruction}
- suggested_keywords: {$kw_instruction}(以 JSON 数组形式返回)
只返回 JSON,不要其他内容。
PROMPT;
}
public function build_summary_prompt(string $content, int $max_length = 200): string { public function build_summary_prompt(string $content, int $max_length = 200): string {
$content = mb_substr($content, 0, 3000); $content = mb_substr($content, 0, 3000);
return <<<PROMPT return <<<PROMPT
-61
View File
@@ -107,67 +107,6 @@ class AISEO {
]; ];
} }
/**
* 草稿场景:用传入 title/content 生成(兼容旧 /ai/seo-suggest)。
*/
public function suggest(string $title, string $content = ''): array {
if (!$this->is_enabled()) {
return ['success' => false, 'message' => __('AI SEO 功能未启用', 'aksrt')];
}
if (!class_exists('AKSRT\\Ai\\AI') || !class_exists('AKSRT\\Ai\\AIPrompt')) {
return ['success' => false, 'message' => __('AI 模块未加载', 'aksrt')];
}
$ai = AI::get_instance();
if (!$ai->is_available()) {
return ['success' => false, 'message' => __('AI 服务未启用', 'aksrt')];
}
$title = trim($title);
$content = trim(wp_strip_all_tags($content));
if ($title === '' && $content === '') {
return [
'success' => false,
'message' => __('无可用内容,请先填写标题或正文', 'aksrt'),
];
}
$parts = [];
if ($title !== '') {
$parts[] = '标题: ' . $title;
}
if ($content !== '') {
$content = wp_trim_words($content, 1500, '');
$parts[] = "正文:\n" . $content;
}
$context = implode("\n\n", $parts);
$prompt = AIPrompt::get_instance();
$title_out = $this->run_field($ai, $context, $prompt->get_seo_title_system_instruction(), 'title');
$kw_out = $this->run_field($ai, $context, $prompt->get_seo_keywords_system_instruction(), 'keywords');
$desc_out = $this->run_field($ai, $context, $prompt->get_seo_description_system_instruction(), 'description');
$keywords = [];
if (!empty($kw_out['content'])) {
$keywords = array_values(array_filter(array_map('trim', explode(',', $kw_out['content']))));
}
return [
'success' => true,
'data' => [
'suggested_title' => (string) ($title_out['content'] ?? ''),
'suggested_description' => (string) ($desc_out['content'] ?? ''),
'suggested_keywords' => $keywords,
'fields' => [
'title' => $title_out,
'keywords' => $kw_out,
'description' => $desc_out,
],
],
];
}
/** /**
* @return string|\WP_Error * @return string|\WP_Error
*/ */
+41 -19
View File
@@ -219,35 +219,57 @@ final class OAuth {
'name' => $provider, 'name' => $provider,
'label' => OAuthProviderCatalog::label($provider), 'label' => OAuthProviderCatalog::label($provider),
'icon' => function_exists('aksrt_brand_icon') ? aksrt_brand_icon(OAuthProviderCatalog::icon_key($provider)) : '', 'icon' => function_exists('aksrt_brand_icon') ? aksrt_brand_icon(OAuthProviderCatalog::icon_key($provider)) : '',
'url' => esc_url($url), // Raw, matching get_frontend_bindings_data(): every surface escapes
// once on output. esc_url() is idempotent on its own output, so
// escaping here too was redundant rather than broken.
'url' => $url,
]; ];
} }
return $result; return $result;
} }
/**
* Reference-parity social login block (legacy zib_social_login()).
*
* Emits the muted separator plus a .social_loginbar of .social-login-item
* buttons, one per enabled provider. Returns '' when nothing is enabled.
*
* @param string $title Separator label; an empty title drops the separator.
*/
public function render_social_buttons(string $title = ''): string { public function render_social_buttons(string $title = ''): string {
$items = $this->get_auth_providers_data(); $items = $this->get_auth_providers_data();
if ($items === []) { if ($items === []) {
return ''; return '';
} }
ob_start();
?> $large = (bool) \AKSRT\Settings\Settings::get_value('oauth_button_lg', false);
<div class="social-login-divider"> $html = $title !== ''
<span><?php echo esc_html($title) ?: esc_html__('or', 'aksrt'); ?></span> ? '<p class="social-separator separator muted-3-color em09 mt10">' . esc_html($title) . '</p>'
</div> : '';
<div class="oauth-row"> $html .= '<div class="social_loginbar">';
<?php foreach ($items as $item) : ?> foreach ($items as $item) {
<a href="<?php echo esc_url((string) $item['url']); ?>" $name = sanitize_html_class((string) ($item['name'] ?? ''));
class="oauth-btn oauth-btn--<?php echo esc_attr((string) $item['name']); ?>" $type = str_starts_with($name, 'clogin_') ? substr($name, 7) : $name;
data-provider="<?php echo esc_attr((string) $item['name']); ?>" $label = trim((string) ($item['label'] ?? ''));
aria-label="<?php echo esc_attr((string) $item['label']); ?>" $url = (string) ($item['url'] ?? '');
title="<?php echo esc_attr((string) $item['label']); ?>"> if ($name === '' || $type === '' || $label === '' || $url === '') {
<?php echo $item['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> continue;
</a> }
<?php endforeach; ?> $class = 'social-login-item ' . $type . ($large ? ' button-lg' : ' toggle-radius');
</div> if ($name === 'weixingzh') {
<?php // Reference parity: the WeChat entry opens the QR panel instead of
return (string) ob_get_clean(); // navigating, so auth.js routes it to the modal's oauth tab.
$class .= ' qrcode-signin';
}
$html .= '<a rel="nofollow" href="' . esc_url($url) . '" class="' . esc_attr($class) . '"'
. ' data-provider="' . esc_attr($name) . '"'
. ' aria-label="' . esc_attr(sprintf(__('%s登录', 'aksrt'), $label)) . '"'
. ' title="' . esc_attr(sprintf(__('%s登录', 'aksrt'), $label)) . '">'
. $item['icon'] // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
. ($large ? '<span>' . esc_html(sprintf(__('%s登录', 'aksrt'), $label)) . '</span>' : '')
. '</a>';
}
return $html . '</div>';
} }
public function get_frontend_bindings_data(int $user_id): array { public function get_frontend_bindings_data(int $user_id): array {
+10 -7
View File
@@ -115,7 +115,6 @@ trait VipServiceModal {
'prefer_level' => $prefer_level, 'prefer_level' => $prefer_level,
'catalog' => [ 'catalog' => [
'desc' => (string) ($catalog['desc'] ?? ''), 'desc' => (string) ($catalog['desc'] ?? ''),
'help_text' => (string) ($catalog['help_text'] ?? ''),
'more' => (string) ($vip['more'] ?? ''), 'more' => (string) ($vip['more'] ?? ''),
'currency_name' => (string) ($catalog['currency_name'] ?? ''), 'currency_name' => (string) ($catalog['currency_name'] ?? ''),
'level_packages' => $groups, 'level_packages' => $groups,
@@ -215,11 +214,13 @@ trait VipServiceModal {
} }
$level = max(1, min(2, (int) ($package['level'] ?? 1))); $level = max(1, min(2, (int) ($package['level'] ?? 1)));
$points = max(0, (int) ($package['points'] ?? 0)); $points = max(0, (int) ($package['points'] ?? 0));
$days = max(0, (int) ($package['days'] ?? 0)); // 参考主题时长行:30<span class="px12">天</span> / 永久;time 为 Permanent 时按永久渲染。
// 参考主题时长行:30<span class="px12">天</span> / 永久。 $is_permanent = !empty($package['is_permanent']);
$duration = $days > 0 $pkg_time = max(0, (int) ($package['time'] ?? 0));
? (string) $days . '<span class="px12">' . esc_html__('天', 'aksrt') . '</span>' $pkg_unit = ((string) ($package['unit'] ?? 'day')) === 'month' ? 'month' : 'day';
: esc_html__('永久', 'aksrt'); $duration = ($is_permanent || $pkg_time <= 0)
? esc_html__('永久', 'aksrt')
: (string) $pkg_time . '<span class="px12">' . esc_html($pkg_unit === 'month' ? __('个月', 'aksrt') : __('天', 'aksrt')) . '</span>';
$level_name = (string) ($levels[$level]['name'] ?? ('VIP' . $level)); $level_name = (string) ($levels[$level]['name'] ?? ('VIP' . $level));
$cards .= '<div class="vip-card vip-theme' . $level . ($index === 0 ? ' active' : '') . ' " data-for="product_id" data-value="' . $package_id . '">' $cards .= '<div class="vip-card vip-theme' . $level . ($index === 0 ? ' active' : '') . ' " data-for="product_id" data-value="' . $package_id . '">'
@@ -239,7 +240,9 @@ trait VipServiceModal {
$desc_text = trim((string) ($points_ex['desc'] ?? '')); $desc_text = trim((string) ($points_ex['desc'] ?? ''));
$desc = $desc_text !== '' $desc = $desc_text !== ''
? '<div class="muted-box muted-2-color padding-10 em09 mt20">' . esc_html($desc_text) . '</div>' // 管理员编辑的富文本说明:保存端已 wp_kses_post,这里同级别输出
// (参考主题 zibpay_get_vip_points_exchange_modal 原样拼接)。
? '<div class="muted-box muted-2-color padding-10 em09 mt20">' . wp_kses_post($desc_text) . '</div>'
: ''; : '';
// 最低价套餐仍超出用户积分 → 参考主题的 badg c-red btn-block 提示。 // 最低价套餐仍超出用户积分 → 参考主题的 badg c-red btn-block 提示。
+90 -284
View File
@@ -11,82 +11,6 @@ trait VipServicePackages {
/**
* Parse per-level product lines (legacy vip_product).
*
* Preferred: price|show_price|time|unit|tag|title
* Legacy: price|days|title
*
* @return array<int, array{price:string,show_price:string,time:int,unit:string,tag:string,tag_class:string,title:string,days:int}>
*/
public function parse_level_products(string $raw): array {
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$out = [];
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
$parts = array_map('trim', explode('|', $line));
$price_minor = $this->parse_vip_money_minor($parts[0] ?? '');
if ($price_minor <= 0) {
continue;
}
$n = count($parts);
// New format: price|show_price|time|unit|tag|title (6+)
// Or: price|show_price|time|unit|title (5)
if ($n >= 4 && (in_array(strtolower((string) ($parts[3] ?? '')), ['day', 'month', 'd', 'm'], true)
|| (isset($parts[3]) && $parts[3] !== '' && !is_numeric($parts[3])))) {
$show_minor = $this->parse_vip_money_minor($parts[1] ?? '0');
$time = isset($parts[2]) ? (int) $parts[2] : 0;
$unit_raw = strtolower((string) ($parts[3] ?? 'month'));
$unit = ($unit_raw === 'day' || $unit_raw === 'd') ? 'day' : 'month';
$tag = '';
$title = '';
if ($n >= 6) {
$tag = sanitize_text_field((string) ($parts[4] ?? ''));
$title = sanitize_text_field((string) ($parts[5] ?? ''));
} elseif ($n === 5) {
// price|show|time|unit|title
$title = sanitize_text_field((string) ($parts[4] ?? ''));
}
} else {
// Legacy: price|days|title
$show_minor = 0;
$days_legacy = isset($parts[1]) ? (int) $parts[1] : 0;
$time = $days_legacy;
$unit = 'day';
$tag = '';
$title = isset($parts[2]) ? sanitize_text_field($parts[2]) : '';
}
$time = max(0, $time);
$days = $this->duration_to_days($time, $unit);
if ($title === '') {
if ($time <= 0) {
$title = __('永久会员', 'aksrt');
} elseif ($unit === 'day') {
$title = sprintf(__('%d天会员', 'aksrt'), $time);
} else {
$title = sprintf(__('%d个月会员', 'aksrt'), $time);
}
}
$out[] = [
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor),
'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor),
'time' => $time,
'unit' => $unit,
'tag' => $tag,
'tag_class' => '',
'title' => $title,
'days' => $days,
];
}
return $out;
}
/** /**
* Approximate days for catalog labels / order meta; activate prefers time+unit calendar math. * Approximate days for catalog labels / order meta; activate prefers time+unit calendar math.
*/ */
@@ -100,81 +24,30 @@ trait VipServicePackages {
return $time; return $time;
} }
/**
* @deprecated Use parse_level_products — kept as thin adapter for upgrade fixed lines.
*
* @return array<int, array{price:string,days:int,title:string}>
*/
public function parse_packages(string $raw): array {
$parsed = $this->parse_level_products($raw);
$out = [];
foreach ($parsed as $p) {
$out[] = [
'price' => (string) $p['price'],
'days' => (int) $p['days'],
'title' => (string) $p['title'],
];
}
return $out;
}
/** /**
* Build upgrade package items (legacy vip_upgrade_product parity). * Build upgrade package items (legacy vip_upgrade_product parity).
* *
* upgrade.product multi-line formats: * Three blocks (reference theme vip_upgrade_product accordion):
* - permanent VIP1: permanent|price|title[|show_price] * - month_to_month_s: timed VIP1 → VIP2, priced by remaining days, expiry unchanged
* - fixed (alias): price|days|title * - jump_s: timed VIP1 → permanent VIP2 at a fixed price
* - timed unit: unit|daily_price|title * - permanent_*: permanent VIP1 → permanent VIP2 at a fixed price
* - timed jump permanent: jump|price|title[|show_price]
* *
* Rules: * package index: 901 permanent, 902 unit, 903 jump
* - permanent user: only permanent/fixed (days=0 preferred)
* - timed user: unit (keep expiry) + optional jump (to permanent)
*
* package index: 901 permanent/fixed, 902 unit, 903 jump
* *
* @param array<string,mixed> $config
* @return array<int, array<string,mixed>> * @return array<int, array<string,mixed>>
*/ */
private function build_upgrade_items(int $user_id, int $expires, bool $is_permanent, string $raw): array { private function build_upgrade_items(int $user_id, int $expires, bool $is_permanent, array $config): array {
$raw = trim($raw);
if ($raw === '') {
return [];
}
$items = []; $items = [];
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: []; $now = time();
$has_permanent = false;
$has_unit = false;
$has_jump = false;
foreach ($lines as $line) { // 月费会员升级月费会员:按剩余天数计价,会员有效期不变(仅提升等级)。
$line = trim((string) $line); if (!$is_permanent && $expires > $now && !empty($config['month_to_month_s'])) {
if ($line === '') { $daily_minor = $this->parse_vip_money_minor((string) ($config['unit_price'] ?? ''));
continue; if ($daily_minor > 0) {
} $remaining = (int) ceil(max(1, ($expires - $now) / DAY_IN_SECONDS));
$parts = array_map('trim', explode('|', $line)); if ($daily_minor <= intdiv(PHP_INT_MAX, $remaining)) {
$mode = strtolower((string) ($parts[0] ?? '')); $price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($remaining * $daily_minor);
// unit|daily|title — timed VIP1 only.
if ($mode === 'unit') {
if ($is_permanent || $expires <= time() || $has_unit) {
continue;
}
$daily_minor = $this->parse_vip_money_minor($parts[1] ?? '');
if ($daily_minor <= 0) {
continue;
}
$remaining = (int) ceil(max(1, ($expires - time()) / DAY_IN_SECONDS));
if ($daily_minor > intdiv(PHP_INT_MAX, $remaining)) {
continue;
}
$price_minor = $remaining * $daily_minor;
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
$title = sanitize_text_field((string) ($parts[2] ?? ''));
if ($title === '') {
$title = sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining);
}
$items[] = [ $items[] = [
'package_id' => $this->encode_package_id(2, 902), 'package_id' => $this->encode_package_id(2, 902),
'level' => 2, 'level' => 2,
@@ -183,9 +56,10 @@ trait VipServicePackages {
'price' => $price, 'price' => $price,
'base_price' => $price, 'base_price' => $price,
'show_price' => '0.00', 'show_price' => '0.00',
'discount_tag' => __('剩余天数', 'aksrt'), 'discount_tag' => (string) ($config['unit_tag'] ?? ''),
'tag_class' => (string) ($config['unit_tag_class'] ?? ''),
'days' => $remaining, 'days' => $remaining,
'title' => $title, 'title' => sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining),
'price_label' => $this->format_cash_label($price), 'price_label' => $this->format_cash_label($price),
'show_price_label' => '', 'show_price_label' => '',
'days_label' => sprintf(__('%d 天', 'aksrt'), $remaining), 'days_label' => sprintf(__('%d 天', 'aksrt'), $remaining),
@@ -193,25 +67,16 @@ trait VipServicePackages {
'is_upgrade_product' => true, 'is_upgrade_product' => true,
'upgrade_mode' => 'unit', 'upgrade_mode' => 'unit',
]; ];
$has_unit = true; }
continue; }
} }
// jump|price|title[|show_price] — timed VIP1 only → permanent VIP2. // 月费会员升级永久会员:固定价格直接转为永久二级会员。
if ($mode === 'jump') { if (!$is_permanent && !empty($config['jump_s'])) {
if ($is_permanent || $has_jump) { $price_minor = $this->parse_vip_money_minor((string) ($config['jump_price'] ?? ''));
continue; if ($price_minor > 0) {
}
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
if ($price_minor <= 0) {
continue;
}
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor); $price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
$title = sanitize_text_field((string) ($parts[2] ?? '')); $show_minor = $this->parse_vip_money_minor((string) ($config['jump_show_price'] ?? ''));
if ($title === '') {
$title = __('升级为永久会员', 'aksrt');
}
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor); $show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
$items[] = [ $items[] = [
'package_id' => $this->encode_package_id(2, 903), 'package_id' => $this->encode_package_id(2, 903),
@@ -221,9 +86,10 @@ trait VipServicePackages {
'price' => $price, 'price' => $price,
'base_price' => $price, 'base_price' => $price,
'show_price' => $show, 'show_price' => $show,
'discount_tag' => __('永久升级', 'aksrt'), 'discount_tag' => (string) ($config['jump_tag'] ?? ''),
'tag_class' => (string) ($config['jump_tag_class'] ?? ''),
'days' => 0, 'days' => 0,
'title' => $title, 'title' => __('升级为永久会员', 'aksrt'),
'price_label' => $this->format_cash_label($price), 'price_label' => $this->format_cash_label($price),
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '', 'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
'days_label' => __('永久', 'aksrt'), 'days_label' => __('永久', 'aksrt'),
@@ -231,25 +97,15 @@ trait VipServicePackages {
'is_upgrade_product' => true, 'is_upgrade_product' => true,
'upgrade_mode' => 'jump', 'upgrade_mode' => 'jump',
]; ];
$has_jump = true; }
continue;
} }
// permanent|price|title[|show_price] // 永久会员升级永久会员。
if ($mode === 'permanent') { if ($is_permanent) {
if ($has_permanent) { $price_minor = $this->parse_vip_money_minor((string) ($config['permanent_price'] ?? ''));
continue; if ($price_minor > 0) {
}
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
if ($price_minor <= 0) {
continue;
}
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor); $price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
$title = sanitize_text_field((string) ($parts[2] ?? '')); $show_minor = $this->parse_vip_money_minor((string) ($config['permanent_show_price'] ?? ''));
if ($title === '') {
$title = __('永久会员升级', 'aksrt');
}
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor); $show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
$items[] = [ $items[] = [
'package_id' => $this->encode_package_id(2, 901), 'package_id' => $this->encode_package_id(2, 901),
@@ -259,9 +115,10 @@ trait VipServicePackages {
'price' => $price, 'price' => $price,
'base_price' => $price, 'base_price' => $price,
'show_price' => $show, 'show_price' => $show,
'discount_tag' => __('升级专享', 'aksrt'), 'discount_tag' => (string) ($config['permanent_tag'] ?? ''),
'tag_class' => (string) ($config['permanent_tag_class'] ?? ''),
'days' => 0, 'days' => 0,
'title' => $title, 'title' => __('永久会员升级', 'aksrt'),
'price_label' => $this->format_cash_label($price), 'price_label' => $this->format_cash_label($price),
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '', 'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
'days_label' => __('永久', 'aksrt'), 'days_label' => __('永久', 'aksrt'),
@@ -269,49 +126,7 @@ trait VipServicePackages {
'is_upgrade_product' => true, 'is_upgrade_product' => true,
'upgrade_mode' => 'permanent', 'upgrade_mode' => 'permanent',
]; ];
$has_permanent = true;
continue;
} }
// fixed: price|days|title
$parsed = $this->parse_packages($line);
if (empty($parsed[0]) || $has_permanent) {
continue;
}
// For permanent VIP1, only accept days=0 fixed lines as permanent upgrade.
if ($is_permanent && (int) $parsed[0]['days'] > 0) {
continue;
}
$pkg = $parsed[0];
$items[] = [
'package_id' => $this->encode_package_id(2, 901),
'level' => 2,
'index' => 901,
'action' => 'upgrade',
'price' => (string) $pkg['price'],
'base_price' => (string) $pkg['price'],
'show_price' => '0.00',
'discount_tag' => __('升级专享', 'aksrt'),
'days' => (int) $pkg['days'],
'title' => (string) $pkg['title'],
'price_label' => $this->format_cash_label((string) $pkg['price']),
'show_price_label' => '',
'days_label' => ((int) $pkg['days'] <= 0)
? __('永久', 'aksrt')
: sprintf(__('%d 天', 'aksrt'), (int) $pkg['days']),
'cta_label' => __('立即升级', 'aksrt'),
'is_upgrade_product' => true,
'upgrade_mode' => ((int) $pkg['days'] <= 0) ? 'permanent' : 'fixed',
];
$has_permanent = true;
}
// Permanent VIP1 should not see unit/jump.
if ($is_permanent) {
$items = array_values(array_filter($items, static function ($it) {
$m = (string) ($it['upgrade_mode'] ?? '');
return in_array($m, ['permanent', 'fixed'], true);
}));
} }
return $items; return $items;
@@ -320,7 +135,10 @@ trait VipServicePackages {
/** /**
* Points exchange packages: level|points|days|title * Points exchange packages from the structured 积分兑换明细 group.
*
* Each row is points / level / time / unit, where time is the literal
* string "Permanent" for a permanent grant (reference theme contract).
* *
* @param array<string,mixed> $vip * @param array<string,mixed> $vip
* @param array<int,mixed> $levels * @param array<int,mixed> $levels
@@ -328,46 +146,48 @@ trait VipServicePackages {
*/ */
public function get_points_exchange_packages(array $vip, array $levels): array { public function get_points_exchange_packages(array $vip, array $levels): array {
$points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : []; $points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : [];
$raw = (string) ($points_ex['packages_raw'] ?? ''); $rows = isset($points_ex['packages']) && is_array($points_ex['packages']) ? $points_ex['packages'] : [];
$currency = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt'); $currency = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
$out = []; $out = [];
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$i = 0; $i = 0;
foreach ($lines as $line) { foreach ($rows as $row) {
$line = trim((string) $line); if (!is_array($row)) {
if ($line === '') {
continue; continue;
} }
$parts = array_map('trim', explode('|', $line)); $lv = (int) ($row['level'] ?? 0);
if (count($parts) < 3) { $pts = max(0, (int) ($row['points'] ?? 0));
continue;
}
$lv = (int) ($parts[0] ?? 0);
$pts = (int) ($parts[1] ?? 0);
$days = (int) ($parts[2] ?? 0);
$title = sanitize_text_field((string) ($parts[3] ?? ''));
if ($lv < 1 || $lv > 2 || $pts <= 0) { if ($lv < 1 || $lv > 2 || $pts <= 0) {
continue; continue;
} }
if (empty($levels[$lv]['enabled'])) { if (empty($levels[$lv]['enabled'])) {
continue; continue;
} }
$i++; $is_permanent = !empty($row['is_permanent']);
if ($title === '') { $time = max(0, (int) ($row['time'] ?? 0));
$lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv)); $unit = ((string) ($row['unit'] ?? 'day')) === 'month' ? 'month' : 'day';
$title = $days > 0 $days = $is_permanent ? 0 : max(0, (int) ($row['days'] ?? 0));
? sprintf(__('%1$s · %2$d天', 'aksrt'), $lv_name, $days) if (!$is_permanent && $time <= 0) {
: sprintf(__('%s · 永久', 'aksrt'), $lv_name); continue;
} }
$i++;
$lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
$out[] = [ $out[] = [
'package_id' => 5000 + $i, 'package_id' => 5000 + $i,
'level' => $lv, 'level' => $lv,
'index' => $i, 'index' => $i,
'points' => $pts, 'points' => $pts,
'days' => max(0, $days), 'days' => $days,
'title' => $title, 'time' => $time,
'unit' => $unit,
'is_permanent' => $is_permanent,
'title' => $is_permanent
? sprintf(__('%s · 永久', 'aksrt'), $lv_name)
: sprintf(__('%1$s · %2$d%3$s', 'aksrt'), $lv_name, $time, $unit === 'month' ? __('个月', 'aksrt') : __('天', 'aksrt')),
'price_label' => $pts . ' ' . $currency, 'price_label' => $pts . ' ' . $currency,
'days_label' => $days > 0 ? sprintf(__('%d 天', 'aksrt'), $days) : __('永久', 'aksrt'), 'days_label' => $is_permanent
? __('永久', 'aksrt')
: ($unit === 'month' ? sprintf(__('%d 个月', 'aksrt'), $time) : sprintf(__('%d 天', 'aksrt'), $time)),
'duration_label' => $is_permanent ? __('永久', 'aksrt') : ($unit === 'month' ? sprintf(__('%d 个月', 'aksrt'), $time) : sprintf(__('%d 天', 'aksrt'), $time)),
'cta_label' => __('立即兑换', 'aksrt'), 'cta_label' => __('立即兑换', 'aksrt'),
]; ];
} }
@@ -376,41 +196,18 @@ trait VipServicePackages {
/**
* Single upgrade product line: price|days|title
*
* @return array{price:float,days:int,title:string}|null
*/
public function parse_upgrade_product_line(string $raw): ?array {
$raw = trim($raw);
if ($raw === '') {
return null;
}
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '' || str_starts_with(strtolower($line), 'unit|')) {
continue;
}
$parsed = $this->parse_packages($line);
if (!empty($parsed[0])) {
return $parsed[0];
}
}
return null;
}
/** /**
* Apply renew discount/reduce to same-level packages. * Apply renew discount/reduce to same-level packages.
* *
* `customize` reuses the dedicated renew product's own price/show_price/tag,
* so the caller passes that row's whole item in as $pkg.
*
* @param array<string,mixed> $renew * @param array<string,mixed> $renew
* @param array<string,mixed> $upgrade * @param array<string,mixed> $upgrade
* @return array{price:string,show_price:string,tag:string}|null * @return array{price:string,show_price:string,tag:string,tag_class:string}|null
*/ */
private function apply_action_price( private function apply_action_price(
string $base_price, array $pkg,
string $action, string $action,
array $renew, array $renew,
array $upgrade, array $upgrade,
@@ -420,6 +217,7 @@ trait VipServicePackages {
bool $is_permanent, bool $is_permanent,
int $days int $days
): ?array { ): ?array {
$base_price = (string) ($pkg['price'] ?? '');
$base_minor = $this->parse_vip_money_minor($base_price); $base_minor = $this->parse_vip_money_minor($base_price);
if ($base_minor <= 0) { if ($base_minor <= 0) {
return null; return null;
@@ -434,32 +232,31 @@ trait VipServicePackages {
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($base_minor), 'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($base_minor),
'show_price' => '0.00', 'show_price' => '0.00',
'tag' => '', 'tag' => '',
'tag_class' => '',
]; ];
} }
$type = sanitize_key((string) ($renew['price_type'] ?? 'discount')); $type = sanitize_key((string) ($renew['price_type'] ?? 'discount'));
// discount/reduce 的标签由优惠幅度计算得出,若实际没有优惠则属于虚假折扣。
$computed_tag = in_array($type, ['discount', 'reduce'], true);
$price_minor = $base_minor; $price_minor = $base_minor;
$show_minor = $base_minor; $show_minor = $base_minor;
$tag = ''; $tag = '';
$tag_class = '';
if ($type === 'discount') { if ($type === 'discount') {
$discount = (int) ($renew['discount'] ?? 8); $discount = (float) ($renew['discount'] ?? 8);
if ($discount <= 0) { if ($discount <= 0) {
$discount = 8; $discount = 8;
} }
if ($discount <= 10) { // 折值:8 = 八折(参考主题 slider 0.1–9.9 语义)。
$percent = $discount * 10; $discount = max(0.1, min(9.9, $discount));
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent); $price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $discount * 10);
$tag = sprintf( $tag = sprintf(
/* translators: %s: discount like 8 */ /* translators: %s: discount like 8 */
__('%s折续费', 'aksrt'), __('%s折优惠', 'aksrt'),
(string) $discount rtrim(rtrim(number_format($discount, 1, '.', ''), '0'), '.') ?: '0'
); );
} else {
$percent = min(100, $discount);
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent);
$tag = sprintf(__('%s%% 续费价', 'aksrt'), (string) (int) $discount);
}
} elseif ($type === 'reduce') { } elseif ($type === 'reduce') {
$reduce_minor = $this->parse_vip_money_minor($renew['reduce'] ?? '20'); $reduce_minor = $this->parse_vip_money_minor($renew['reduce'] ?? '20');
$price_minor = max(1, $base_minor - $reduce_minor); $price_minor = max(1, $base_minor - $reduce_minor);
@@ -468,6 +265,11 @@ trait VipServicePackages {
__('续费立减%s', 'aksrt'), __('续费立减%s', 'aksrt'),
$this->format_cash_label(\AKSRT\Ledger\AssetAmount::decimal_from_minor($reduce_minor)) $this->format_cash_label(\AKSRT\Ledger\AssetAmount::decimal_from_minor($reduce_minor))
); );
} elseif ($type === 'customize') {
// 自定义商品:沿用商品自身的原价、促销标签与标签颜色。
$show_minor = $this->parse_vip_money_minor((string) ($pkg['show_price'] ?? ''));
$tag = (string) ($pkg['tag'] ?? '');
$tag_class = (string) ($pkg['tag_class'] ?? '');
} else { } else {
$show_minor = 0; $show_minor = 0;
$tag = __('续费同价', 'aksrt'); $tag = __('续费同价', 'aksrt');
@@ -477,14 +279,18 @@ trait VipServicePackages {
$price_minor = 1; $price_minor = 1;
} }
if ($show_minor > 0 && $price_minor >= $show_minor) { if ($show_minor > 0 && $price_minor >= $show_minor) {
// 参考主题契约:原价仅在低于执行价时展示。
$show_minor = 0; $show_minor = 0;
if ($computed_tag) {
$tag = __('续费', 'aksrt'); $tag = __('续费', 'aksrt');
} }
}
return [ return [
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor), 'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor),
'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor > $price_minor ? $show_minor : 0), 'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor > $price_minor ? $show_minor : 0),
'tag' => $tag, 'tag' => $tag,
'tag_class' => $tag_class,
]; ];
} }
+40 -20
View File
@@ -139,20 +139,30 @@ trait VipServicePurchase {
$name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv)); $name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
$items = []; $items = [];
$packages = $product_s // 自定义续费商品时按续费商品组计价,否则按会员商品组计价。
? $this->parse_level_products((string) ($levels[$lv]['products_raw'] ?? '')) $level_action = 'pay';
: []; if ($level > 0 && $lv === $level) {
$level_action = 'renew';
} elseif ($level > 0 && $lv > $level) {
$level_action = 'upgrade';
}
$renew_custom = $level_action === 'renew'
&& sanitize_key((string) ($renew['price_type'] ?? 'discount')) === 'customize';
$packages = [];
if ($product_s) {
$packages = $renew_custom
? (array) ($levels[$lv]['renew_products'] ?? [])
: (array) ($levels[$lv]['products'] ?? []);
}
foreach ($packages as $idx => $pkg) { foreach ($packages as $idx => $pkg) {
if (!is_array($pkg)) {
continue;
}
$base_price = (string) $pkg['price']; $base_price = (string) $pkg['price'];
$days = (int) $pkg['days']; $days = (int) $pkg['days'];
$action = 'pay'; $action = $level_action;
if ($level > 0 && $lv === $level) {
$action = 'renew';
} elseif ($level > 0 && $lv > $level) {
$action = 'upgrade';
}
$priced = $this->apply_action_price($base_price, $action, $renew, $upgrade, $lv, $level, $expires, $is_permanent, $days); $priced = $this->apply_action_price($pkg, $action, $renew, $upgrade, $lv, $level, $expires, $is_permanent, $days);
if ($priced === null) { if ($priced === null) {
continue; continue;
} }
@@ -167,6 +177,10 @@ trait VipServicePurchase {
if ($discount_tag === '' && $action === 'pay' && !empty($pkg['tag'])) { if ($discount_tag === '' && $action === 'pay' && !empty($pkg['tag'])) {
$discount_tag = (string) $pkg['tag']; $discount_tag = (string) $pkg['tag'];
} }
$tag_class = (string) ($priced['tag_class'] ?? '');
if ($tag_class === '' && !empty($pkg['tag_class'])) {
$tag_class = (string) $pkg['tag_class'];
}
$days_label = __('永久', 'aksrt'); $days_label = __('永久', 'aksrt');
if ((int) $pkg['time'] > 0) { if ((int) $pkg['time'] > 0) {
@@ -184,10 +198,10 @@ trait VipServicePurchase {
'base_price' => $base_price, 'base_price' => $base_price,
'show_price' => $show_price, 'show_price' => $show_price,
'discount_tag' => $discount_tag, 'discount_tag' => $discount_tag,
'tag_class' => $tag_class,
'days' => $days, 'days' => $days,
'time' => (int) ($pkg['time'] ?? 0), 'time' => (int) ($pkg['time'] ?? 0),
'unit' => (string) ($pkg['unit'] ?? 'day'), 'unit' => (string) ($pkg['unit'] ?? 'day'),
'title' => (string) $pkg['title'],
'price_label' => $this->format_cash_label((string) $priced['price']), 'price_label' => $this->format_cash_label((string) $priced['price']),
'show_price_label' => $show_minor > 0 'show_price_label' => $show_minor > 0
? $this->format_cash_label($show_price) ? $this->format_cash_label($show_price)
@@ -201,7 +215,7 @@ trait VipServicePurchase {
// Dedicated upgrade product: fixed or remaining-days unit price. // Dedicated upgrade product: fixed or remaining-days unit price.
if ($level === 1 && $lv === 2 && $upgrade_enabled) { if ($level === 1 && $lv === 2 && $upgrade_enabled) {
$upgrade_items = $this->build_upgrade_items($user_id, $expires, $is_permanent, (string) ($upgrade['product'] ?? '')); $upgrade_items = $this->build_upgrade_items($user_id, $expires, $is_permanent, $upgrade);
foreach ($upgrade_items as $ui) { foreach ($upgrade_items as $ui) {
$items[] = $ui; $items[] = $ui;
} }
@@ -232,21 +246,19 @@ trait VipServicePurchase {
} }
} }
$points_balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS) : 0; $balances = \AKSRT\Ledger\LedgerService::get_instance()->points_and_balance($user_id);
$wallet_balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : '0.00';
$methods = $this->available_payment_methods(); $methods = $this->available_payment_methods();
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt'); $currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
$catalog = [ $catalog = [
'enabled' => !empty($vip['enabled']), 'enabled' => !empty($vip['enabled']),
'desc' => (string) ($vip['desc'] ?? ''), 'desc' => (string) ($vip['desc'] ?? ''),
'help_text' => (string) ($vip['help_text'] ?? ''),
'user_level' => $level, 'user_level' => $level,
'user_expires' => $expires, 'user_expires' => $expires,
'user_is_permanent' => $is_permanent, 'user_is_permanent' => $is_permanent,
'user_expires_label' => (string) ($member['expires_label'] ?? ''), 'user_expires_label' => (string) ($member['expires_label'] ?? ''),
'user_points' => $points_balance, 'user_points' => $balances['points'],
'wallet_balance' => $wallet_balance, 'wallet_balance' => $balances['balance'],
'currency_name' => $currency_name, 'currency_name' => $currency_name,
'renew_enabled' => $renew_enabled, 'renew_enabled' => $renew_enabled,
'upgrade_enabled' => $upgrade_enabled, 'upgrade_enabled' => $upgrade_enabled,
@@ -443,11 +455,12 @@ trait VipServicePurchase {
'note' => is_string($note) ? $note : '', 'note' => is_string($note) ? $note : '',
'expires_at' => $expires_at, 'expires_at' => $expires_at,
'subject' => sprintf( 'subject' => sprintf(
/* translators: 1: action 2: vip name 3: package title */ /* translators: 1: action 2: vip name 3: price label 4: duration label */
__('%1$s%2$s · %3$s', 'aksrt'), __('%1$s%2$s · %3$s / %4$s', 'aksrt'),
$action_label, $action_label,
$level_name, $level_name,
(string) ($pkg['title'] ?? '') (string) ($pkg['price_label'] ?? ''),
(string) ($pkg['days_label'] ?? '')
), ),
]; ];
@@ -759,6 +772,11 @@ trait VipServicePurchase {
$cost = (int) ($match['points'] ?? 0); $cost = (int) ($match['points'] ?? 0);
$level = (int) ($match['level'] ?? 1); $level = (int) ($match['level'] ?? 1);
$days = (int) ($match['days'] ?? 0); $days = (int) ($match['days'] ?? 0);
$pkg_time = max(0, (int) ($match['time'] ?? 0));
$pkg_unit = sanitize_key((string) ($match['unit'] ?? 'day'));
if (!in_array($pkg_unit, ['day', 'month'], true)) {
$pkg_unit = 'day';
}
if ($cost <= 0) { if ($cost <= 0) {
return new \WP_Error('aksrt_invalid_price', __('兑换积分无效', 'aksrt'), ['status' => 400]); return new \WP_Error('aksrt_invalid_price', __('兑换积分无效', 'aksrt'), ['status' => 400]);
} }
@@ -781,6 +799,8 @@ trait VipServicePurchase {
'vip_pay_type' => 'points_exchange', 'vip_pay_type' => 'points_exchange',
'level' => $level, 'level' => $level,
'days' => $days, 'days' => $days,
'time' => $pkg_time,
'unit' => $pkg_unit,
'points' => $cost, 'points' => $cost,
'package_id' => $package_id, 'package_id' => $package_id,
'package_title' => (string) ($match['title'] ?? ''), 'package_title' => (string) ($match['title'] ?? ''),
+4 -4
View File
@@ -210,16 +210,16 @@ class SEOMetaBox {
true true
); );
$status = class_exists('\AKSRT\Ai\AI') ? \AKSRT\Ai\AI::get_instance()->get_status() : []; $ai_available = class_exists('\AKSRT\Ai\AI') && \AKSRT\Ai\AI::get_instance()->is_available();
$seo = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_seo_settings() : []; $seo = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_seo_settings() : [];
wp_localize_script('aksrt-admin-ai-seo', 'aksrtAiSeo', [ wp_localize_script('aksrt-admin-ai-seo', 'aksrtAiSeo', [
'restUrl' => esc_url_raw(rest_url('aksrt/v1')), 'restUrl' => esc_url_raw(rest_url('aksrt/v1')),
'nonce' => wp_create_nonce('wp_rest'), 'nonce' => wp_create_nonce('wp_rest'),
'ai' => [ 'ai' => [
'available' => !empty($status['available']), 'available' => $ai_available,
'seo_ai_enabled' => !empty($seo['seo_ai_enabled']) && !empty($seo['seo_enabled']), 'seo_ai_enabled' => !empty($seo['seo_ai_enabled']) && !empty($seo['seo_enabled']),
'write_assist' => !empty($status['available']), 'write_assist' => $ai_available,
], ],
'i18n' => [ 'i18n' => [
'generating' => __('生成中…', 'aksrt'), 'generating' => __('生成中…', 'aksrt'),
@@ -262,7 +262,7 @@ class SEOMetaBox {
$available = class_exists('\AKSRT\Ai\AI') && \AKSRT\Ai\AI::get_instance()->is_available(); $available = class_exists('\AKSRT\Ai\AI') && \AKSRT\Ai\AI::get_instance()->is_available();
$disabled = (!$available || $id <= 0) ? ' disabled' : ''; $disabled = (!$available || $id <= 0) ? ' disabled' : '';
$title = !$available $title = !$available
? esc_attr__('请先配置 AI 连接(扩展 → AI 连接配置 或 WP Connectors)', 'aksrt') ? esc_attr__('请先配置 WordPress AI Connectors', 'aksrt')
: ($id <= 0 ? esc_attr__('请先保存后再生成', 'aksrt') : ''); : ($id <= 0 ? esc_attr__('请先保存后再生成', 'aksrt') : '');
return sprintf( return sprintf(
+3
View File
@@ -63,7 +63,10 @@ trait ScriptsPart1b {
}; };
// 顶部导航用户卡片(legacy header-userbox 模块样式)。 // 顶部导航用户卡片(legacy header-userbox 模块样式)。
// 仅登录用户渲染用户卡(aksrt-header-userbox.php 的渲染条件),访客页面不加载。
if (is_user_logged_in()) {
$enqueue_module('header-userbox'); $enqueue_module('header-userbox');
}
if (!is_user_logged_in() || (bool) get_query_var('aksrt_sign')) { if (!is_user_logged_in() || (bool) get_query_var('aksrt_sign')) {
$auth_css = get_template_directory() . '/assets/css/routes/sign.css'; $auth_css = get_template_directory() . '/assets/css/routes/sign.css';
+22 -1
View File
@@ -30,6 +30,9 @@ class StatsTracker {
/** /**
* 追踪访问 * 追踪访问
*
* 访客判定与 setcookie 必须留在 wp_head(响应头尚未发送);
* 计数读写改挂 shutdown,避免每次前台请求都在渲染关键路径上写库。
*/ */
public function track_visit(): void { public function track_visit(): void {
// 排除管理员、爬虫、AJAX请求 // 排除管理员、爬虫、AJAX请求
@@ -41,6 +44,12 @@ class StatsTracker {
return; return;
} }
static $scheduled = false;
if ($scheduled) {
return;
}
$scheduled = true;
$today = current_time('Y-m-d'); $today = current_time('Y-m-d');
$option_key = 'aksrt_daily_stats_' . $today; $option_key = 'aksrt_daily_stats_' . $today;
@@ -48,6 +57,19 @@ class StatsTracker {
$visitor_cookie = 'aksrt_visitor_' . $today; $visitor_cookie = 'aksrt_visitor_' . $today;
$is_unique = empty($_COOKIE[$visitor_cookie]); $is_unique = empty($_COOKIE[$visitor_cookie]);
if ($is_unique) {
setcookie($visitor_cookie, '1', time() + DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN);
}
add_action('shutdown', static function () use ($option_key, $is_unique): void {
self::get_instance()->record_stats($option_key, $is_unique);
});
}
/**
* 计数落库(shutdown 阶段执行;请求中断时本次统计丢弃)。
*/
public function record_stats(string $option_key, bool $is_unique): void {
// 获取或初始化今日统计 // 获取或初始化今日统计
$stats = get_option($option_key, ['visitors' => 0, 'pageviews' => 0]); $stats = get_option($option_key, ['visitors' => 0, 'pageviews' => 0]);
@@ -59,7 +81,6 @@ class StatsTracker {
$stats['pageviews']++; $stats['pageviews']++;
if ($is_unique) { if ($is_unique) {
$stats['visitors']++; $stats['visitors']++;
setcookie($visitor_cookie, '1', time() + DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN);
} }
update_option($option_key, $stats, false); update_option($option_key, $stats, false);
+275 -59
View File
@@ -9,6 +9,14 @@ defined('ABSPATH') || exit;
class SearchPush { class SearchPush {
/** 推送结果 meta 键(编辑页面板与去重逻辑共用)。 */
public const META_KEY = '_aksrt_baidu_push_back';
/** 「重新提交」nonce 与复选框字段名(与 SearchPushMetaBox 共用)。 */
public const NONCE_ACTION = 'aksrt_push_meta_save';
public const NONCE_FIELD = 'aksrt_push_meta_nonce';
public const RESUBMIT_FIELD = 'aksrt_push_resubmit';
private static $instance = null; private static $instance = null;
public static function get_instance(): self { public static function get_instance(): self {
@@ -17,16 +25,20 @@ class SearchPush {
} }
/** /**
* 绑定运行时钩子:发布/更新主动推送 + 前台自动推送 JS。 * 绑定运行时钩子:发布/更新主动推送 + 分类法保存推送 + 前台自动推送 JS。
*/ */
public function init(): void { public function init(): void {
add_action('transition_post_status', [$this, 'on_transition_post_status'], 10, 3); add_action('transition_post_status', [$this, 'on_transition_post_status'], 10, 3);
add_action('saved_term', [$this, 'on_saved_term'], 10, 3);
add_action('wp_footer', [$this, 'output_auto_push_script'], 99); add_action('wp_footer', [$this, 'output_auto_push_script'], 99);
} }
/** /**
* 文章发布/更新时主动推送链接到百度。 * 文章发布/更新时主动推送链接到百度。
* *
* 与参考主题一致:推送结果写入 meta,成功后不再重复提交;
* 编辑页勾选「重新提交」可清空记录并强制再次推送。
*
* @param string $new_status 新状态。 * @param string $new_status 新状态。
* @param string $old_status 旧状态。 * @param string $old_status 旧状态。
* @param \WP_Post $post 文章对象。 * @param \WP_Post $post 文章对象。
@@ -42,18 +54,18 @@ class SearchPush {
return; return;
} }
// 仅推送公开可索引的内容类型。 // 仅推送公开可索引的内容类型。
$allowed = function_exists('aksrt_searchable_post_types') if (!in_array($post->post_type, self::pushable_post_types(), true)) {
? aksrt_searchable_post_types()
: ['post'];
if (!in_array($post->post_type, $allowed, true)) {
return; return;
} }
$config = $this->get_config(); $config = $this->get_config();
if (empty($config['baidu_push_enabled'])) { if (!$this->should_push($config)) {
return; return;
} }
if ($this->resubmit_requested()) {
self::clear_push_record($post->ID);
} else {
$is_new = $old_status !== 'publish'; $is_new = $old_status !== 'publish';
if ($is_new && empty($config['baidu_push_on_publish'])) { if ($is_new && empty($config['baidu_push_on_publish'])) {
return; return;
@@ -61,8 +73,211 @@ class SearchPush {
if (!$is_new && empty($config['baidu_push_on_update'])) { if (!$is_new && empty($config['baidu_push_on_update'])) {
return; return;
} }
// 已成功提交过的链接不再重复推送。
if ($this->record_is_complete(self::get_push_record($post->ID), $config)) {
return;
}
}
$this->push_url((string) get_permalink($post)); $this->push_and_record((string) get_permalink($post), $post->ID, false, $config);
}
/**
* 分类法术语保存时主动推送链接到百度。
*
* 术语无发布/更新状态,沿用参考主题的 saved_term 触发,
* 与文章推送共用 meta 记录、去重与「重新提交」。
*
* @param int $term_id 术语 ID。
* @param int $tt_id 术语分类法 ID。
* @param string $taxonomy 分类法名称。
*/
public function on_saved_term(int $term_id, int $tt_id, string $taxonomy): void {
if (!in_array($taxonomy, self::pushable_taxonomies(), true)) {
return;
}
$config = $this->get_config();
if (!$this->should_push($config)) {
return;
}
if ($this->resubmit_requested()) {
self::clear_push_record($term_id, true);
} elseif ($this->record_is_complete(self::get_push_record($term_id, true), $config)) {
return;
}
$url = get_term_link($term_id, $taxonomy);
if (is_wp_error($url) || $url === '') {
return;
}
$this->push_and_record((string) $url, $term_id, true, $config);
}
/**
* 可推送的文章类型(与站内搜索 / MeiliSearch 索引共用同一策略)。
*
* @return string[]
*/
public static function pushable_post_types(): array {
$types = function_exists('aksrt_searchable_post_types') ? aksrt_searchable_post_types() : ['post'];
return array_values(array_unique(array_filter((array) $types)));
}
/**
* 可推送的分类法:可推送文章类型上的公开分类法(排除文章形式等)。
*
* @return string[]
*/
public static function pushable_taxonomies(): array {
$taxonomies = get_object_taxonomies(self::pushable_post_types(), 'names');
$taxonomies = array_diff((array) $taxonomies, ['post_format', 'nav_menu']);
/**
* 过滤百度推送覆盖的分类法。
*
* @param string[] $taxonomies 分类法 slug 列表。
*/
return array_values(array_unique(apply_filters('aksrt_baidu_push_taxonomies', $taxonomies)));
}
/**
* 推送功能是否已启用且配置完整(决定编辑页面板是否展示)。
*/
public function is_ready(): bool {
return $this->should_push($this->get_config());
}
/**
* 读取文章或术语的推送记录。
*
* @param int $object_id 文章 / 术语 ID。
* @param bool $is_term 是否为术语。
* @return array 记录为空时返回 []。
*/
public static function get_push_record(int $object_id, bool $is_term = false): array {
$record = $is_term ? get_term_meta($object_id, self::META_KEY, true) : get_post_meta($object_id, self::META_KEY, true);
return is_array($record) ? $record : [];
}
/**
* 写入推送记录。
*
* @param int $object_id 文章 / 术语 ID。
* @param array $record 推送记录。
* @param bool $is_term 是否为术语。
*/
public static function save_push_record(int $object_id, array $record, bool $is_term = false): void {
if ($is_term) {
update_term_meta($object_id, self::META_KEY, $record);
} else {
update_post_meta($object_id, self::META_KEY, $record);
}
}
/**
* 清除推送记录(「重新提交」时调用)。
*
* @param int $object_id 文章 / 术语 ID。
* @param bool $is_term 是否为术语。
*/
public static function clear_push_record(int $object_id, bool $is_term = false): void {
if ($is_term) {
delete_term_meta($object_id, self::META_KEY);
} else {
delete_post_meta($object_id, self::META_KEY);
}
}
/**
* 推送是否可用:总开关开启、Token 已配置且至少启用一个收录通道。
*
* @param array $config 推送配置。
*/
private function should_push(array $config): bool {
if (empty($config['baidu_push_enabled'])) {
return false;
}
if ((string) ($config['baidu_push_token'] ?? '') === '') {
return false;
}
return !empty($config['baidu_push_normal']) || !empty($config['baidu_push_fast']);
}
/**
* 推送 URL 并把结果写入 meta(成功记录用于去重)。
*
* @param string $url 待推送链接。
* @param int $object_id 文章 / 术语 ID。
* @param bool $is_term 是否为术语。
* @param array $config 推送配置。
*/
private function push_and_record(string $url, int $object_id, bool $is_term, array $config): void {
if ($url === '') {
return;
}
$outcome = $this->push_url($url);
self::save_push_record($object_id, $this->build_record($outcome, $config), $is_term);
}
/**
* 组装推送记录:通道结果 + 更新时间(结构与参考主题 meta 对齐)。
*
* @param array $outcome push_url() 返回值。
* @param array $config 推送配置。
*/
private function build_record(array $outcome, array $config): array {
$record = ['update_time' => current_time('Y-m-d H:i:s')];
$result = isset($outcome['result']) && is_array($outcome['result']) ? $outcome['result'] : [];
if (!empty($config['baidu_push_normal'])) {
$channel = $result['normal'] ?? null;
$record['normal_push'] = is_array($channel) && !empty($channel['success']);
$record['normal_result'] = $channel;
}
if (!empty($config['baidu_push_fast'])) {
$channel = $result['daily'] ?? null;
$record['daily_push'] = is_array($channel) && !empty($channel['success']);
$record['daily_result'] = $channel;
}
return $record;
}
/**
* 推送记录是否已覆盖全部启用通道(去重判断)。
*
* @param array $record 推送记录。
* @param array $config 推送配置。
*/
private function record_is_complete(array $record, array $config): bool {
if (!empty($config['baidu_push_normal']) && empty($record['normal_push'])) {
return false;
}
if (!empty($config['baidu_push_fast']) && empty($record['daily_push'])) {
return false;
}
return true;
}
/**
* 编辑页是否勾选了「重新提交」(需通过 metabox nonce 校验)。
*/
private function resubmit_requested(): bool {
if (empty($_POST[self::RESUBMIT_FIELD])) {
return false;
}
$nonce = isset($_POST[self::NONCE_FIELD]) ? sanitize_text_field(wp_unslash($_POST[self::NONCE_FIELD])) : '';
return (bool) wp_verify_nonce($nonce, self::NONCE_ACTION);
} }
/** /**
@@ -96,7 +311,50 @@ class SearchPush {
return ['success' => false, 'message' => __('推送 Token 未配置', 'aksrt'), 'result' => []]; return ['success' => false, 'message' => __('推送 Token 未配置', 'aksrt'), 'result' => []];
} }
$api_url = "http://data.zz.baidu.com/urls?site=" . rawurlencode(home_url('/')) . "&token={$token}"; // 收录通道与百度接口 type 参数对应:普通收录不带参数,快速收录为 daily。
$channels = [];
if (!empty($config['baidu_push_normal'])) {
$channels['normal'] = '';
}
if (!empty($config['baidu_push_fast'])) {
$channels['daily'] = '&type=daily';
}
if (!$channels) {
return ['success' => false, 'message' => __('未启用普通收录或快速收录通道', 'aksrt'), 'result' => []];
}
$success = false;
$results = [];
$messages = [];
foreach ($channels as $channel => $suffix) {
$outcome = $this->submit($url, $token, $suffix, $config);
$results[$channel] = $outcome['result'];
if ($outcome['success']) {
$success = true;
} else {
$messages[] = $outcome['message'];
}
}
if ($success) {
return ['success' => true, 'message' => __('推送成功', 'aksrt'), 'result' => $results];
}
return ['success' => false, 'message' => implode('; ', $messages), 'result' => $results];
}
/**
* 向百度链接提交接口发送单条 URL。
*
* @param string $url 待推送的完整 URL。
* @param string $token 百度准入密钥。
* @param string $suffix 接口附加参数(快速收录为 &type=daily)。
* @param array $config 推送配置。
* @return array{success: bool, message: string, result: array}
*/
private function submit(string $url, string $token, string $suffix, array $config): array {
$api_url = 'http://data.zz.baidu.com/urls?site=' . rawurlencode(home_url('/')) . '&token=' . $token . $suffix;
$response = wp_remote_post($api_url, [ $response = wp_remote_post($api_url, [
'timeout' => 10, 'timeout' => 10,
@@ -105,7 +363,7 @@ class SearchPush {
]); ]);
if (is_wp_error($response)) { if (is_wp_error($response)) {
$this->log('error', $url, $response->get_error_message()); $this->log($config, 'error', $url, $response->get_error_message());
return ['success' => false, 'message' => $response->get_error_message(), 'result' => []]; return ['success' => false, 'message' => $response->get_error_message(), 'result' => []];
} }
@@ -113,59 +371,15 @@ class SearchPush {
$status = wp_remote_retrieve_response_code($response); $status = wp_remote_retrieve_response_code($response);
if ($status === 200 && isset($body['success'])) { if ($status === 200 && isset($body['success'])) {
$this->log('success', $url, wp_json_encode($body)); $this->log($config, 'success', $url, wp_json_encode($body));
return ['success' => true, 'message' => __('推送成功', 'aksrt'), 'result' => $body]; return ['success' => true, 'message' => __('推送成功', 'aksrt'), 'result' => $body];
} }
$err_msg = $body['message'] ?? ($body['error'] ?? sprintf('HTTP %d', $status)); $err_msg = $body['message'] ?? ($body['error'] ?? sprintf('HTTP %d', $status));
$this->log('error', $url, $err_msg); $this->log($config, 'error', $url, $err_msg);
return ['success' => false, 'message' => $err_msg, 'result' => $body ?? []]; return ['success' => false, 'message' => $err_msg, 'result' => $body ?? []];
} }
/**
* 批量推送 URL
*
* @param string[] $urls
* @return array{success: int, failed: int, results: array}
*/
public function push_batch(array $urls): array {
$config = $this->get_config();
$batch_size = max(1, min(500, (int) ($config['baidu_push_batch_size'] ?? 50)));
$success = 0;
$failed = 0;
$results = [];
$chunks = array_chunk($urls, $batch_size);
foreach ($chunks as $chunk) {
$token = (string) ($config['baidu_push_token'] ?? '');
$api_url = "http://data.zz.baidu.com/urls?site=" . rawurlencode(home_url('/')) . "&token={$token}";
$body_str = implode("\n", $chunk);
$response = wp_remote_post($api_url, [
'timeout' => 15,
'headers' => ['Content-Type' => 'text/plain'],
'body' => $body_str,
]);
if (is_wp_error($response)) {
$failed += count($chunk);
$results[] = ['error' => $response->get_error_message()];
continue;
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (isset($body['success'])) {
$success += (int) ($body['success'] ?? 0);
$failed += (int) ($body['not_same_site'] ?? 0) + (int) ($body['not_valid'] ?? 0);
} else {
$failed += count($chunk);
}
$results[] = $body ?? [];
}
return ['success' => $success, 'failed' => $failed, 'results' => $results];
}
/** /**
* 获取自动推送 JS 代码 * 获取自动推送 JS 代码
*/ */
@@ -175,7 +389,6 @@ class SearchPush {
return ''; return '';
} }
$site_url = home_url('/');
return <<<SCRIPT return <<<SCRIPT
<script> <script>
(function(){ (function(){
@@ -211,13 +424,15 @@ SCRIPT;
'provider' => 'baidu', 'provider' => 'baidu',
'configured' => $enabled && $token !== '', 'configured' => $enabled && $token !== '',
'available' => $enabled && $token !== '', 'available' => $enabled && $token !== '',
'verified' => !empty($config['baidu_push_verified']), // 百度链接提交接口无单独校验握手,Token 配置完成即可推送。
'verified' => false,
'transport' => 'http_api', 'transport' => 'http_api',
'missing_fields' => $missing, 'missing_fields' => $missing,
'auto_push' => !empty($config['baidu_push_auto']), 'auto_push' => !empty($config['baidu_push_auto']),
'normal_push' => !empty($config['baidu_push_normal']),
'fast_push' => !empty($config['baidu_push_fast']),
'on_publish' => !empty($config['baidu_push_on_publish']), 'on_publish' => !empty($config['baidu_push_on_publish']),
'on_update' => !empty($config['baidu_push_on_update']), 'on_update' => !empty($config['baidu_push_on_update']),
'batch_size' => max(1, (int) ($config['baidu_push_batch_size'] ?? 50)),
]; ];
} }
@@ -228,7 +443,8 @@ SCRIPT;
return ['baidu_push_enabled' => false]; return ['baidu_push_enabled' => false];
} }
private function log(string $level, string $url, string $message): void { private function log(array $config, string $level, string $url, string $message): void {
if (empty($config['baidu_push_log_enabled'])) return;
if (!defined('WP_DEBUG') || !WP_DEBUG) return; if (!defined('WP_DEBUG') || !WP_DEBUG) return;
error_log(sprintf('[AKSRT Search Push] [%s] URL: %s — %s', $level, $url, $message)); error_log(sprintf('[AKSRT Search Push] [%s] URL: %s — %s', $level, $url, $message));
} }
+190
View File
@@ -0,0 +1,190 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Extensions;
defined('ABSPATH') || exit;
/**
* 百度推送编辑页面板(对齐参考主题「百度资源提交」Metabox)。
*
* 文章:侧栏 Metabox;分类法:编辑 / 新增表单行。
* 展示推送结果与更新时间,并提供「重新提交」勾选——
* 勾选后保存会清空去重记录并强制再次推送。
*/
class SearchPushMetaBox {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('add_meta_boxes', [$this, 'register_post_meta_box']);
// 分类法可能由插件延后注册,面板在 admin_init 统一挂载。
add_action('admin_init', [$this, 'register_term_panels']);
}
/**
* 为可推送文章类型注册侧栏 Metabox(推送功能就绪后才展示)。
*/
public function register_post_meta_box(): void {
if (!$this->is_ready()) {
return;
}
foreach (SearchPush::pushable_post_types() as $post_type) {
add_meta_box(
'aksrt_baidu_push',
__('百度资源提交', 'aksrt'),
[$this, 'render_post_panel'],
$post_type,
'side',
'high'
);
}
}
/**
* 为可推送分类法注册编辑 / 新增表单面板。
*/
public function register_term_panels(): void {
if (!$this->is_ready()) {
return;
}
foreach (SearchPush::pushable_taxonomies() as $taxonomy) {
add_action("{$taxonomy}_edit_form_fields", [$this, 'render_term_panel'], 20);
add_action("{$taxonomy}_add_form_fields", [$this, 'render_term_panel'], 20);
}
}
/**
* 渲染文章 Metabox。
*
* @param mixed $post WP_Post 或 null。
*/
public function render_post_panel($post): void {
if (!$post instanceof \WP_Post) {
return;
}
$this->render_panel((int) $post->ID, false);
}
/**
* 渲染分类法表单行(新增表单无术语记录,仅展示提示)。
*
* @param mixed $term WP_Term、分类名字符串或 null。
*/
public function render_term_panel($term = null): void {
$term_id = 0;
if ($term instanceof \WP_Term) {
$term_id = (int) $term->term_id;
} elseif (isset($_GET['tag_ID'])) {
$term_id = (int) $_GET['tag_ID'];
}
// 新增表单无表格结构,仅编辑表单以表单行包裹。
$this->render_panel($term_id, true, $term_id > 0);
}
private function is_ready(): bool {
return class_exists(SearchPush::class) && SearchPush::get_instance()->is_ready();
}
/**
* 渲染推送结果面板。
*
* @param int $object_id 文章 / 术语 ID(0 表示新增表单)。
* @param bool $is_term 是否为术语。
* @param bool $as_row 是否以分类法表单行包裹。
*/
private function render_panel(int $object_id, bool $is_term, bool $as_row = false): void {
ob_start();
wp_nonce_field(SearchPush::NONCE_ACTION, SearchPush::NONCE_FIELD);
$record = $object_id > 0 ? SearchPush::get_push_record($object_id, $is_term) : [];
$lines = $this->result_lines($record);
?>
<div class="aksrt-push-meta-box">
<?php if ($lines) : ?>
<p class="aksrt-push-meta-box__label"><strong><?php esc_html_e('提交结果:', 'aksrt'); ?></strong></p>
<ul class="aksrt-push-meta-box__results">
<?php foreach ($lines as $line) : ?>
<li><strong><?php echo esc_html($line[0]); ?></strong> <span><?php echo esc_html($line[1]); ?></span></li>
<?php endforeach; ?>
</ul>
<?php else : ?>
<p class="description"><?php esc_html_e('发布、更新后刷新页面后可查看提交结果', 'aksrt'); ?></p>
<?php endif; ?>
<?php if (!empty($record['update_time'])) : ?>
<p class="aksrt-push-meta-box__resubmit">
<label>
<input type="checkbox" name="<?php echo esc_attr(SearchPush::RESUBMIT_FIELD); ?>" value="1" />
<?php esc_html_e('重新提交', 'aksrt'); ?>
</label>
<span class="description"><?php esc_html_e('勾选后保存将清空提交记录并再次推送', 'aksrt'); ?></span>
</p>
<?php endif; ?>
</div>
<?php
$body = (string) ob_get_clean();
if ($as_row) {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- 面板 HTML 已在输出前逐项转义。
echo '<tr class="form-field"><th scope="row">' . esc_html(__('百度资源提交', 'aksrt')) . '</th><td>' . $body . '</td></tr>';
return;
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- 面板 HTML 已在输出前逐项转义。
echo $body;
}
/**
* 汇总推送结果行(展示项与参考主题对齐)。
*
* @param array $record 推送记录。
* @return array<int,array{0:string,1:string}>
*/
private function result_lines(array $record): array {
$lines = [];
if (!empty($record['normal_push'])) {
$lines[] = [__('普通收录:成功', 'aksrt'), $this->format_result($record['normal_result'] ?? null)];
} elseif (array_key_exists('normal_push', $record)) {
$lines[] = [__('普通收录:失败', 'aksrt'), $this->format_result($record['normal_result'] ?? null)];
}
if (!empty($record['daily_push'])) {
$lines[] = [__('快速收录:成功', 'aksrt'), $this->format_result($record['daily_result'] ?? null)];
} elseif (array_key_exists('daily_push', $record)) {
$lines[] = [__('快速收录:失败', 'aksrt'), $this->format_result($record['daily_result'] ?? null)];
}
if (!empty($record['update_time'])) {
$lines[] = [__('更新时间:', 'aksrt'), (string) $record['update_time']];
}
return $lines;
}
/**
* 格式化接口返回结果。
*
* @param mixed $result 百度接口返回的解码数据。
*/
private function format_result($result): string {
if (is_array($result) || is_object($result)) {
return (string) wp_json_encode($result);
}
return (string) $result;
}
}
+40
View File
@@ -63,6 +63,46 @@ final class LedgerService {
return AssetAmount::from_minor(AssetType::BALANCE, $this->balance($user_id, AssetType::BALANCE))->decimal(); return AssetAmount::from_minor(AssetType::BALANCE, $this->balance($user_id, AssetType::BALANCE))->decimal();
} }
/**
* Read points and cash balance together (VIP modal catalog).
*
* The meta adapter serves both assets from user meta; the asset-account
* fallback answers both rows with a single query.
*
* @return array{points:int,balance:string}
*/
public function points_and_balance(int $user_id): array {
if ($user_id <= 0) {
return ['points' => 0, 'balance' => '0.00'];
}
if (class_exists('\AKSRT\Credits\MetaAssetService')) {
$meta = \AKSRT\Credits\MetaAssetService::get_instance();
return [
'points' => $meta->balance_points($user_id),
'balance' => $meta->balance_decimal($user_id),
];
}
global $wpdb;
$table = $this->table('asset_account');
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT asset, balance_minor FROM {$table} WHERE user_id = %d AND asset IN (%s, %s)",
$user_id,
AssetType::POINTS->value,
AssetType::BALANCE->value
), ARRAY_A);
$minor = ['points' => 0, 'balance' => 0];
foreach ((array) $rows as $row) {
$asset = (string) ($row['asset'] ?? '');
if (isset($minor[$asset])) {
$minor[$asset] = max(0, (int) ($row['balance_minor'] ?? 0));
}
}
return [
'points' => $minor['points'],
'balance' => AssetAmount::from_minor(AssetType::BALANCE, $minor['balance'])->decimal(),
];
}
public function format_balance(int $minor): string { public function format_balance(int $minor): string {
return AssetAmount::from_minor(AssetType::BALANCE, max(0, $minor))->formatted(); return AssetAmount::from_minor(AssetType::BALANCE, max(0, $minor))->formatted();
} }
-4
View File
@@ -24,10 +24,6 @@ trait DisplayPageGroups {
'ai_seo_title_prompt' => trim((string) ($s['ai_seo_title_prompt'] ?? '')), 'ai_seo_title_prompt' => trim((string) ($s['ai_seo_title_prompt'] ?? '')),
'ai_seo_keywords_prompt' => trim((string) ($s['ai_seo_keywords_prompt'] ?? '')), 'ai_seo_keywords_prompt' => trim((string) ($s['ai_seo_keywords_prompt'] ?? '')),
'ai_seo_description_prompt' => trim((string) ($s['ai_seo_description_prompt'] ?? '')), 'ai_seo_description_prompt' => trim((string) ($s['ai_seo_description_prompt'] ?? '')),
'seo_baidu_push' => !empty($s['seo_baidu_push']) || !empty($s['baidu_push_enabled']),
'seo_baidu_normal' => !empty($s['seo_baidu_normal']),
'seo_baidu_fast' => !empty($s['seo_baidu_fast']),
'seo_baidu_token' => (string) (($s['seo_baidu_token'] ?? '') ?: ($s['baidu_push_token'] ?? '')),
'seo_image_optimize' => !isset($s['seo_image_optimize']) || !empty($s['seo_image_optimize']), 'seo_image_optimize' => !isset($s['seo_image_optimize']) || !empty($s['seo_image_optimize']),
'seo_image_default' => esc_url_raw((string) ($s['seo_image_default'] ?? '')), 'seo_image_default' => esc_url_raw((string) ($s['seo_image_default'] ?? '')),
'seo_image_always_default'=> !empty($s['seo_image_always_default']), 'seo_image_always_default'=> !empty($s['seo_image_always_default']),
+4 -18
View File
@@ -128,29 +128,15 @@ class ExtensionRuntime {
public static function get_search_push_settings(): array { public static function get_search_push_settings(): array {
$settings = Settings::get_all(); $settings = Settings::get_all();
$enabled = !empty($settings['baidu_push_enabled']) || !empty($settings['seo_baidu_push']);
$token = (string) ($settings['baidu_push_token'] ?? '');
if ($token === '' && !empty($settings['seo_baidu_token'])) {
$token = (string) $settings['seo_baidu_token'];
}
return [ return [
'baidu_push_enabled' => $enabled, 'baidu_push_enabled' => !empty($settings['baidu_push_enabled']),
'baidu_push_token' => $token, 'baidu_push_token' => (string) ($settings['baidu_push_token'] ?? ''),
'baidu_push_auto' => !isset($settings['baidu_push_auto']) || !empty($settings['baidu_push_auto']), 'baidu_push_auto' => !isset($settings['baidu_push_auto']) || !empty($settings['baidu_push_auto']),
'baidu_push_normal' => !isset($settings['baidu_push_normal']) || !empty($settings['baidu_push_normal']),
'baidu_push_fast' => !empty($settings['baidu_push_fast']),
'baidu_push_on_publish' => !isset($settings['baidu_push_on_publish']) || !empty($settings['baidu_push_on_publish']), 'baidu_push_on_publish' => !isset($settings['baidu_push_on_publish']) || !empty($settings['baidu_push_on_publish']),
'baidu_push_on_update' => !empty($settings['baidu_push_on_update']), 'baidu_push_on_update' => !empty($settings['baidu_push_on_update']),
'baidu_push_batch_size' => max(1, min(500, (int) ($settings['baidu_push_batch_size'] ?? 50))),
'baidu_push_log_enabled' => !isset($settings['baidu_push_log_enabled']) || !empty($settings['baidu_push_log_enabled']), 'baidu_push_log_enabled' => !isset($settings['baidu_push_log_enabled']) || !empty($settings['baidu_push_log_enabled']),
'baidu_push_verified' => !empty($settings['baidu_push_verified']),
'xzh_on' => !empty($settings['xzh_on']),
'xzh_appid' => (string) ($settings['xzh_appid'] ?? ''),
'xzh_render_tail' => !isset($settings['xzh_render_tail']) || !empty($settings['xzh_render_tail']),
'xzh_jsonld_single' => !isset($settings['xzh_jsonld_single']) || !empty($settings['xzh_jsonld_single']),
'xzh_jsonld_page' => !empty($settings['xzh_jsonld_page']),
'xzh_jsonld_img' => !empty($settings['xzh_jsonld_img']),
'seo_baidu_normal' => !empty($settings['seo_baidu_normal']),
'seo_baidu_fast' => !empty($settings['seo_baidu_fast']),
]; ];
} }
-36
View File
@@ -210,42 +210,6 @@ class HomeCms {
return $settings['home_exclude_cats']; return $settings['home_exclude_cats'];
} }
public static function get_paid_content_settings(): array {
return PaymentRuntime::get_paid_content_settings();
}
public static function get_content_coupon_settings(): array {
return PaymentRuntime::get_content_coupon_settings();
}
public static function get_payment_recharge_settings(): array {
return PaymentRuntime::get_payment_recharge_settings();
}
public static function get_payment_vip_settings(): array {
return PaymentRuntime::get_payment_vip_settings();
}
public static function get_payment_revenue_settings(): array {
return PaymentRuntime::get_payment_revenue_settings();
}
public static function get_payment_affiliate_settings(): array {
return PaymentRuntime::get_payment_affiliate_settings();
}
public static function get_payment_gateway_settings(): array {
return PaymentRuntime::get_payment_gateway_settings();
}
public static function get_order_settings(): array {
return PaymentRuntime::get_order_settings();
}
public static function get_withdrawal_settings(): array {
return PaymentRuntime::get_withdrawal_settings();
}
private static function normalize_sort_options(array $raw): array { private static function normalize_sort_options(array $raw): array {
$allowed = self::homepage_allowed_sorts(); $allowed = self::homepage_allowed_sorts();
$result = []; $result = [];
+206 -37
View File
@@ -121,7 +121,9 @@ class PaymentRuntime {
], ],
'vip' => [ 'vip' => [
'enabled' => !empty($settings['pay_vip_pass_charge_s']), 'enabled' => !empty($settings['pay_vip_pass_charge_s']),
'desc' => sanitize_textarea_field((string) ($settings['pay_vip_pass_charge_desc'] ?? '')), // 保存端已按 post 级 HTML 放行(schema sanitize_type html),
// 输出端不能再被 textarea 净化剥掉标签。
'desc' => wp_kses_post((string) ($settings['pay_vip_pass_charge_desc'] ?? '')),
// 默认非单密码(卡号+密码),对齐参考主题 pay_vip_pass_charge_only_password 默认 false。 // 默认非单密码(卡号+密码),对齐参考主题 pay_vip_pass_charge_only_password 默认 false。
'only_password' => !empty($settings['pay_vip_pass_charge_only_password']), 'only_password' => !empty($settings['pay_vip_pass_charge_only_password']),
], ],
@@ -171,33 +173,21 @@ class PaymentRuntime {
$name1 = trim((string) ($settings['pay_user_vip_1_name'] ?? '')); $name1 = trim((string) ($settings['pay_user_vip_1_name'] ?? ''));
$name2 = trim((string) ($settings['pay_user_vip_2_name'] ?? '')); $name2 = trim((string) ($settings['pay_user_vip_2_name'] ?? ''));
if ($name1 === '') { if ($name1 === '') {
$name1 = __('一级会员', 'aksrt'); $name1 = __('黄金会员', 'aksrt');
} }
if ($name2 === '') { if ($name2 === '') {
$name2 = __('二级会员', 'aksrt'); $name2 = __('钻石会员', 'aksrt');
} }
$badge = $vip2_enabled ? $name2 : ($vip1_enabled ? $name1 : __('VIP', 'aksrt')); $badge = $vip2_enabled ? $name2 : ($vip1_enabled ? $name1 : __('VIP', 'aksrt'));
$renew_price_type = sanitize_key((string) ($settings['vip_renew_price_type'] ?? 'discount')); $renew_price_type = sanitize_key((string) ($settings['vip_renew_price_type'] ?? 'discount'));
if (!in_array($renew_price_type, ['same', 'discount', 'reduce'], true)) { if (!in_array($renew_price_type, ['same', 'discount', 'reduce', 'customize'], true)) {
$renew_price_type = 'discount'; $renew_price_type = 'discount';
} }
$default_l1 = "30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡"; $desc = sanitize_text_field((string) ($settings['pay_user_vip_desc'] ?? __('开通VIP会员,享受会员专属折扣以及多项特权', 'aksrt')));
$default_l2 = "58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡"; $more = (string) ($settings['pay_user_vip_more'] ?? '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>');
$products_1 = sanitize_textarea_field((string) ($settings['payment_vip_1_products'] ?? $default_l1));
$products_2 = sanitize_textarea_field((string) ($settings['payment_vip_2_products'] ?? $default_l2));
// Restore newlines lost only if empty after sanitize — sanitize_textarea_field keeps \n.
if (trim($products_1) === '') {
$products_1 = $default_l1;
}
if (trim($products_2) === '') {
$products_2 = $default_l2;
}
$desc = sanitize_textarea_field((string) ($settings['pay_user_vip_desc'] ?? ''));
$more = (string) ($settings['pay_user_vip_more'] ?? '');
$allowed_equity = [ $allowed_equity = [
'li' => [], 'li' => [],
'ul' => [], 'ul' => [],
@@ -218,27 +208,30 @@ class PaymentRuntime {
'name' => $vip1_enabled ? $name1 : $badge, 'name' => $vip1_enabled ? $name1 : $badge,
'badge_text' => $badge, 'badge_text' => $badge,
'desc' => $desc, 'desc' => $desc,
'more' => wp_kses($more, $allowed_equity + ['div' => ['class' => true], 'p' => ['class' => true]]), // 管理员编辑的富文本说明:与保存端同为 post 级 HTML(wp_kses_post),
'help_text' => $desc !== '' ? $desc : __('会员权益以免扣下载和身份展示为主,开通后立即生效。', 'aksrt'), // 不再用权益列表的窄白名单二次剥标签(h/img/hr/table 等)。
'more' => wp_kses_post($more),
'levels' => [ 'levels' => [
1 => [ 1 => [
'enabled' => $vip1_enabled, 'enabled' => $vip1_enabled,
'name' => $name1, 'name' => $name1,
'desc' => sanitize_text_field((string) ($settings['pay_user_vip_1_desc'] ?? __('尊享一级会员特权', 'aksrt'))), 'desc' => sanitize_text_field((string) ($settings['pay_user_vip_1_desc'] ?? __('尊享专属特权', 'aksrt'))),
'equity' => wp_kses((string) (($settings['pay_user_vip_1_equity'] ?? '') !== '' ? $settings['pay_user_vip_1_equity'] : '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>'), $allowed_equity), 'equity' => wp_kses((string) (($settings['pay_user_vip_1_equity'] ?? '') !== '' ? $settings['pay_user_vip_1_equity'] : '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>'), $allowed_equity),
'img_icon' => self::get_vip_icon_url(1, (string) ($settings['vip_1_img_icon'] ?? '')), 'img_icon' => self::get_vip_icon_url(1, (string) ($settings['vip_1_img_icon'] ?? '')),
'product_s' => !isset($settings['vip_1_product_s']) || !empty($settings['vip_1_product_s']), 'product_s' => !isset($settings['vip_1_product_s']) || !empty($settings['vip_1_product_s']),
'products_raw' => $products_1, 'products' => self::normalize_vip_product_items($settings['vip_1_product'] ?? null, self::vip_product_defaults()),
'renew_products' => self::normalize_vip_product_items($settings['vip_1_renew_product'] ?? null, self::vip_renew_product_defaults(1)),
'download_limit' => max(0, min(9999, (int) ($settings['pay_download_limit_vip_1'] ?? 0))), 'download_limit' => max(0, min(9999, (int) ($settings['pay_download_limit_vip_1'] ?? 0))),
], ],
2 => [ 2 => [
'enabled' => $vip2_enabled, 'enabled' => $vip2_enabled,
'name' => $name2, 'name' => $name2,
'desc' => sanitize_text_field((string) ($settings['pay_user_vip_2_desc'] ?? __('尊享二级会员特权', 'aksrt'))), 'desc' => sanitize_text_field((string) ($settings['pay_user_vip_2_desc'] ?? __('尊享专属特权', 'aksrt'))),
'equity' => wp_kses((string) (($settings['pay_user_vip_2_equity'] ?? '') !== '' ? $settings['pay_user_vip_2_equity'] : '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>'), $allowed_equity), 'equity' => wp_kses((string) (($settings['pay_user_vip_2_equity'] ?? '') !== '' ? $settings['pay_user_vip_2_equity'] : '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>'), $allowed_equity),
'img_icon' => self::get_vip_icon_url(2, (string) ($settings['vip_2_img_icon'] ?? '')), 'img_icon' => self::get_vip_icon_url(2, (string) ($settings['vip_2_img_icon'] ?? '')),
'product_s' => !isset($settings['vip_2_product_s']) || !empty($settings['vip_2_product_s']), 'product_s' => !isset($settings['vip_2_product_s']) || !empty($settings['vip_2_product_s']),
'products_raw' => $products_2, 'products' => self::normalize_vip_product_items($settings['vip_2_product'] ?? null, self::vip_product_defaults()),
'renew_products' => self::normalize_vip_product_items($settings['vip_2_renew_product'] ?? null, self::vip_renew_product_defaults(2)),
'download_limit' => max(0, min(9999, (int) ($settings['pay_download_limit_vip_2'] ?? 0))), 'download_limit' => max(0, min(9999, (int) ($settings['pay_download_limit_vip_2'] ?? 0))),
], ],
], ],
@@ -247,21 +240,16 @@ class PaymentRuntime {
], ],
'renew' => [ 'renew' => [
'enabled' => !isset($settings['vip_renew']) || !empty($settings['vip_renew']), 'enabled' => !isset($settings['vip_renew']) || !empty($settings['vip_renew']),
'desc' => sanitize_textarea_field((string) ($settings['vip_renew_desc'] ?? '')), 'desc' => sanitize_text_field((string) ($settings['vip_renew_desc'] ?? '立即续费会员,畅享VIP权益')),
'price_type' => $renew_price_type, 'price_type' => $renew_price_type,
'discount' => max(1, min(100, (int) ($settings['vip_renew_discount'] ?? 8))), 'discount' => max(0.1, min(9.9, (float) ($settings['vip_renew_discount'] ?? 8))),
'reduce' => self::money_decimal($settings['vip_renew_reduce'] ?? '20.00', '20.00', 999900), 'reduce' => self::money_decimal($settings['vip_renew_reduce'] ?? '20.00', '20.00', 50000),
],
'upgrade' => [
'enabled' => !isset($settings['vip_upgrade']) || !empty($settings['vip_upgrade']),
'desc' => sanitize_textarea_field((string) ($settings['vip_upgrade_desc'] ?? '')),
// Multi-line upgrade product rules — must not collapse newlines.
'product' => sanitize_textarea_field((string) ($settings['vip_upgrade_product'] ?? '')),
], ],
'upgrade' => self::normalize_vip_upgrade_settings($settings),
'points_exchange' => [ 'points_exchange' => [
'enabled' => !empty($settings['pay_vip_points_exchange_s']), 'enabled' => !empty($settings['pay_vip_points_exchange_s']),
'desc' => sanitize_textarea_field((string) ($settings['pay_vip_points_exchange_desc'] ?? '')), 'desc' => wp_kses_post((string) ($settings['pay_vip_points_exchange_desc'] ?? '')),
'packages_raw' => sanitize_textarea_field((string) ($settings['payment_vip_points_packages'] ?? "1|500|30|积分月卡\n1|5000|0|积分永久")), 'packages' => self::normalize_vip_points_packages($settings['pay_vip_points_exchange_product'] ?? null),
], ],
]; ];
} }
@@ -728,4 +716,185 @@ class PaymentRuntime {
} }
return implode("\n", $lines); return implode("\n", $lines);
} }
/**
* Approximate days for catalog labels / order meta; activation prefers
* calendar time+unit math.
*/
private static function duration_to_days(int $time, string $unit = 'day'): int {
if ($time <= 0) {
return 0;
}
return $unit === 'month' ? $time * 30 : $time;
}
/**
* 会员商品默认两行(参考主题 vip_tab 的 group 默认值)。
*
* @return array<int,array<string,mixed>>
*/
private static function vip_product_defaults(): array {
return [
['price' => '99.00', 'show_price' => '199.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
['price' => '199.00', 'show_price' => '299.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
];
}
/**
* @return array<int,array<string,mixed>>
*/
private static function vip_renew_product_defaults(int $level): array {
if ($level === 2) {
return [
['price' => '269.00', 'show_price' => '599.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
['price' => '369.00', 'show_price' => '899.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
];
}
return [
['price' => '69.00', 'show_price' => '199.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', 'time' => 3, 'unit' => 'month'],
['price' => '169.00', 'show_price' => '299.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', 'time' => 6, 'unit' => 'month'],
];
}
/**
* 标签颜色可选值(参考主题 zib_palette(array(), array('b','jb')))。
*
* @return array<int,string>
*/
private static function vip_tag_palette_keys(): array {
return ['b-red', 'b-yellow', 'b-cyan', 'b-blue', 'b-green', 'b-purple', 'b-black', 'jb-red', 'jb-pink', 'jb-yellow', 'jb-cyan', 'jb-blue', 'jb-green', 'jb-purple', 'jb-vip1', 'jb-vip2'];
}
/**
* Normalize a structured 会员商品 group into purchasable rows.
*
* @param mixed $raw Stored group value.
* @param array<int,array<string,mixed>> $fallback Default rows when unset.
* @return array<int,array{price:string,show_price:string,tag:string,tag_class:string,time:int,unit:string,days:int}>
*/
private static function normalize_vip_product_items($raw, array $fallback): array {
$rows = is_array($raw) ? array_values($raw) : [];
if ($rows === []) {
$rows = $fallback;
}
$palette = self::vip_tag_palette_keys();
$out = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$price = self::money_decimal($row['price'] ?? '', '0.00');
if (bccomp($price, '0.00', 2) <= 0) {
continue;
}
$time = max(0, (int) ($row['time'] ?? 0));
$unit = ((string) ($row['unit'] ?? 'month')) === 'day' ? 'day' : 'month';
$tag_class = (string) ($row['tag_class'] ?? '');
if (!in_array($tag_class, $palette, true)) {
$tag_class = '';
}
$out[] = [
'price' => $price,
'show_price' => self::money_decimal($row['show_price'] ?? '', '0.00'),
'tag' => wp_kses_post((string) ($row['tag'] ?? '')),
'tag_class' => $tag_class,
'time' => $time,
'unit' => $unit,
'days' => self::duration_to_days($time, $unit),
];
}
return $out;
}
/**
* Normalize the structured 升级价格 accordion.
*
* @param array<string,mixed> $settings
* @return array<string,mixed>
*/
private static function normalize_vip_upgrade_settings(array $settings): array {
$raw = isset($settings['vip_upgrade_product']) && is_array($settings['vip_upgrade_product'])
? $settings['vip_upgrade_product']
: [];
$palette = self::vip_tag_palette_keys();
$money = static function ($value, string $default): string {
return self::money_decimal(is_string($value) ? $value : (is_numeric($value) ? (string) $value : ''), $default);
};
$tag = static function ($value, string $default): string {
$text = trim((string) $value);
return $text !== '' ? wp_kses_post($text) : $default;
};
$tag_class = static function ($value) use ($palette): string {
$key = (string) $value;
return in_array($key, $palette, true) ? $key : '';
};
$switch = static function ($value, bool $default): bool {
return isset($value) ? !empty($value) : $default;
};
return [
'enabled' => !isset($settings['vip_upgrade']) || !empty($settings['vip_upgrade']),
'desc' => sanitize_text_field((string) ($settings['vip_upgrade_desc'] ?? '升级VIP会员,享更多会员权益')),
'month_to_month_s' => $switch($raw['month_to_month_s'] ?? null, true),
'unit_price' => $money($raw['unit_price'] ?? null, '10.00'),
'unit_tag' => $tag($raw['unit_tag'] ?? null, '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐'),
'unit_tag_class' => $tag_class($raw['unit_tag_class'] ?? 'jb-yellow'),
'jump_s' => $switch($raw['jump_s'] ?? null, false),
'jump_price' => $money($raw['jump_price'] ?? null, '199.00'),
'jump_show_price' => $money($raw['jump_show_price'] ?? null, '299.00'),
'jump_tag' => $tag($raw['jump_tag'] ?? null, '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠'),
'jump_tag_class' => $tag_class($raw['jump_tag_class'] ?? 'jb-red'),
'permanent_price' => $money($raw['permanent_price'] ?? null, '199.00'),
'permanent_show_price' => $money($raw['permanent_show_price'] ?? null, '299.00'),
'permanent_tag' => $tag($raw['permanent_tag'] ?? null, '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠'),
'permanent_tag_class' => $tag_class($raw['permanent_tag_class'] ?? 'jb-red'),
];
}
/**
* 积分兑换明细默认行(对齐旧 textarea 的开箱配置)。
*
* @return array<int,array<string,mixed>>
*/
private static function vip_points_package_defaults(): array {
return [
['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'day'],
['points' => 5000, 'level' => 1, 'time' => 'Permanent'],
];
}
/**
* Normalize the structured 积分兑换明细 group. `time` is the literal string
* "Permanent" for a permanent grant, otherwise a day/month count.
*
* @param mixed $raw
* @return array<int,array{points:int,level:int,time:int,unit:string,is_permanent:bool,days:int}>
*/
private static function normalize_vip_points_packages($raw): array {
$rows = is_array($raw) ? array_values($raw) : self::vip_points_package_defaults();
$out = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$points = max(0, (int) ($row['points'] ?? 0));
$level = (int) ($row['level'] ?? 1);
$time_raw = trim((string) ($row['time'] ?? ''));
if ($points <= 0 || $level < 1 || $level > 2 || $time_raw === '') {
continue;
}
$is_permanent = strcasecmp($time_raw, 'Permanent') === 0;
$time = $is_permanent ? 0 : max(0, (int) $time_raw);
$unit = ((string) ($row['unit'] ?? 'day')) === 'month' ? 'month' : 'day';
$out[] = [
'points' => $points,
'level' => $level,
'time' => $time,
'unit' => $unit,
'is_permanent' => $is_permanent,
'days' => $is_permanent ? 0 : self::duration_to_days($time, $unit),
];
}
return $out;
}
} }
+1 -1
View File
@@ -125,7 +125,7 @@ final class UserRenderer {
if ($name === '' || $style_name === '' || $label === '' || $url === '') { if ($name === '' || $style_name === '' || $label === '' || $url === '') {
continue; continue;
} }
$html .= '<a rel="nofollow" href="' . esc_url($url) . '" class="social-login-item ' . esc_attr($style_name) . '" data-provider="' . esc_attr($name) . '" aria-label="' . esc_attr(sprintf(__('%s登录', 'aksrt'), $label)) . '" title="' . esc_attr($label) . '">' . (string) ($item['icon'] ?? '') . '</a>'; $html .= '<a rel="nofollow" href="' . esc_url($url) . '" class="social-login-item ' . esc_attr($style_name) . ' toggle-radius" data-provider="' . esc_attr($name) . '" aria-label="' . esc_attr(sprintf(__('%s登录', 'aksrt'), $label)) . '" title="' . esc_attr(sprintf(__('%s登录', 'aksrt'), $label)) . '">' . (string) ($item['icon'] ?? '') . '</a>';
} }
return $html . '</div>'; return $html . '</div>';
} }
-9
View File
@@ -106,11 +106,6 @@ if (file_exists($js)) {
], ],
'imageUploadMultiple' => $image_upload_multiple, 'imageUploadMultiple' => $image_upload_multiple,
'ai' => [
'available' => !empty($ctx['ai_available']),
'summary' => !empty($ctx['ai_available']),
'tags' => !empty($ctx['ai_available']),
],
'editor' => [ 'editor' => [
'canUploadImage' => !empty($ctx['can_upload_img']), 'canUploadImage' => !empty($ctx['can_upload_img']),
'canUploadVideo' => !empty($ctx['can_upload_video']), 'canUploadVideo' => !empty($ctx['can_upload_video']),
@@ -142,10 +137,6 @@ if (file_exists($js)) {
'submitted' => __('投稿已提交', 'aksrt'), 'submitted' => __('投稿已提交', 'aksrt'),
'error' => __('操作失败,请重试', 'aksrt'), 'error' => __('操作失败,请重试', 'aksrt'),
'confirmDelete' => __('确定要删除这个草稿吗?', 'aksrt'), 'confirmDelete' => __('确定要删除这个草稿吗?', 'aksrt'),
'aiGenerating' => __('生成中…', 'aksrt'),
'aiFailed' => __('AI 生成失败', 'aksrt'),
'needContent' => __('请先填写正文内容', 'aksrt'),
'aiFilled' => __('已填入,可继续编辑', 'aksrt'),
'deleting' => __('正在删除草稿…', 'aksrt'), 'deleting' => __('正在删除草稿…', 'aksrt'),
'deleted' => __('草稿已删除', 'aksrt'), 'deleted' => __('草稿已删除', 'aksrt'),
'uploading' => __('正在上传…', 'aksrt'), 'uploading' => __('正在上传…', 'aksrt'),
-1
View File
@@ -147,6 +147,5 @@ $oauth_bind_pass = $oauth_intent === 'bind'
<?php foreach ($oauth_providers as $provider) : ?><?php if (($provider['name'] ?? '') === 'weixingzh') continue; ?><?php $provider_label = (string) ($provider['label'] ?? $provider['name'] ?? ''); ?><a class="social-login-item <?php echo esc_attr((string) ($provider['name'] ?? '')); ?>" href="<?php echo esc_url((string) ($provider['url'] ?? '#')); ?>" data-provider="<?php echo esc_attr((string) ($provider['name'] ?? '')); ?>" aria-label="<?php echo esc_attr(sprintf(__('%s登录', 'aksrt'), $provider_label)); ?>" title="<?php echo esc_attr($provider_label); ?>"><?php echo $provider['icon'] ?? ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><?php if ($oauth_large) : ?><span><?php printf(esc_html__('%s登录', 'aksrt'), esc_html($provider_label)); ?></span><?php endif; ?></a><?php endforeach; ?> <?php foreach ($oauth_providers as $provider) : ?><?php if (($provider['name'] ?? '') === 'weixingzh') continue; ?><?php $provider_label = (string) ($provider['label'] ?? $provider['name'] ?? ''); ?><a class="social-login-item <?php echo esc_attr((string) ($provider['name'] ?? '')); ?>" href="<?php echo esc_url((string) ($provider['url'] ?? '#')); ?>" data-provider="<?php echo esc_attr((string) ($provider['name'] ?? '')); ?>" aria-label="<?php echo esc_attr(sprintf(__('%s登录', 'aksrt'), $provider_label)); ?>" title="<?php echo esc_attr($provider_label); ?>"><?php echo $provider['icon'] ?? ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><?php if ($oauth_large) : ?><span><?php printf(esc_html__('%s登录', 'aksrt'), esc_html($provider_label)); ?></span><?php endif; ?></a><?php endforeach; ?>
<?php if ($wechat_qr) : ?><button type="button" class="social-login-item weixingzh" data-auth-tab="oauth" aria-label="<?php esc_attr_e('微信扫码登录', 'aksrt'); ?>" title="<?php esc_attr_e('微信扫码登录', 'aksrt'); ?>"><i class="fa fab fa-weixin" aria-hidden="true"></i><?php if ($oauth_large) : ?><span><?php esc_html_e('微信登录', 'aksrt'); ?></span><?php endif; ?></button><?php endif; ?> <?php if ($wechat_qr) : ?><button type="button" class="social-login-item weixingzh" data-auth-tab="oauth" aria-label="<?php esc_attr_e('微信扫码登录', 'aksrt'); ?>" title="<?php esc_attr_e('微信扫码登录', 'aksrt'); ?>"><i class="fa fab fa-weixin" aria-hidden="true"></i><?php if ($oauth_large) : ?><span><?php esc_html_e('微信登录', 'aksrt'); ?></span><?php endif; ?></button><?php endif; ?>
</div></div> </div></div>
<?php if (!empty($agreement['enabled'])) : ?><div class="sign-agreement sign-agreement-implicit"><?php esc_html_e('使用社交账号登录即表示', 'aksrt'); ?><?php echo $agreement['label_html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div><?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
-3
View File
@@ -177,8 +177,6 @@ if ($is_guest) {
$submit_label = __('保存', 'aksrt'); $submit_label = __('保存', 'aksrt');
} }
$ai_available = class_exists('\AKSRT\Ai\AI') && \AKSRT\Ai\AI::get_instance()->is_available();
return [ return [
'front' => $front, 'front' => $front,
'user_id' => $user_id, 'user_id' => $user_id,
@@ -207,7 +205,6 @@ return [
'vip' => $vip, 'vip' => $vip,
'points_enabled' => $points_enabled, 'points_enabled' => $points_enabled,
'submit_label' => $submit_label, 'submit_label' => $submit_label,
'ai_available' => $ai_available,
'can_pay' => $cap('new_post_pay'), 'can_pay' => $cap('new_post_pay'),
'can_pay_download' => $cap('new_post_pay_download'), 'can_pay_download' => $cap('new_post_pay_download'),
'can_cover_image' => $cap('new_post_image_cover'), 'can_cover_image' => $cap('new_post_image_cover'),
+91
View File
@@ -0,0 +1,91 @@
'use strict';
/** AI 连接配置页已移除:主题只走 WordPress AI Connectors,生成能力与入口保留。 */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8');
const ai = read('src/Ai/AI.php');
const controller = read('src/Admin/AIController.php');
const seoSchema = read('assets/admin/src/schema/sections/global/seo.js');
const extensionsMenu = read('assets/admin/src/constants/sections/extensions.js');
const apiModule = read('assets/admin/src/api/modules/extensions.js');
const frontSubmit = read('assets/js/front-submit.js');
const submitContext = read('templates/parts/submit/context.php');
const submitPage = read('templates/pages/page-submit.php');
const removed = [
'aksrt_ai_settings',
'AI 连接配置',
'AI连接配置',
'ExtensionAI',
'extensions-ai',
'/ai/settings',
'/ai/status',
'ai-assist-btn',
'扩展 → AI',
];
const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
return ['node_modules', 'build', 'dist'].includes(entry.name) ? [] : walk(full);
}
return [full];
});
const sources = ['src', 'assets/admin/src', 'templates', 'assets/js', 'inc']
.flatMap((dir) => walk(path.join(root, dir)));
test('后台菜单、路由与 API 中不再有 AI 连接配置页', () => {
assert.doesNotMatch(extensionsMenu, /extensions-ai|ExtensionAI|AI 连接配置/);
assert.doesNotMatch(apiModule, /getAiStatus|updateAiSettings|generateSeo|suggestSummary|suggestTags/);
assert.ok(!fs.existsSync(path.join(root, 'assets/admin/src/pages/extensions/ExtensionAI.vue')));
assert.ok(!fs.existsSync(path.join(root, 'assets/admin/src/router/component-loaders/extensions.js')));
});
test('AI.php 只保留 WP AI Client 单路出口', () => {
assert.doesNotMatch(ai, /PROVIDER_OPENAI|PROVIDER_NONE/);
assert.doesNotMatch(ai, /get_config|update_config/);
assert.doesNotMatch(ai, /generate_via_theme|normalize_api_base/);
assert.doesNotMatch(ai, /is_theme_provider_available|resolve_backend/);
assert.doesNotMatch(ai, /check_rate_limit|record_call/);
assert.match(ai, /wp_ai_client_prompt/);
assert.match(ai, /function is_wp_ai_available/);
assert.match(ai, /function is_available/);
});
test('生成路由保留,连接配置与旧草稿兼容路由移除', () => {
assert.match(controller, /\/ai\/seo-generate/);
assert.match(controller, /\/ai\/summary-suggest/);
assert.match(controller, /\/ai\/tags-suggest/);
assert.doesNotMatch(controller, /\/ai\/seo-suggest/);
assert.doesNotMatch(controller, /function get_status|function update_settings|function seo_suggest/);
});
test('残留文案不再指向已删除的配置页', () => {
for (const [name, source] of [['AI.php', ai], ['AIController.php', controller], ['seo.js', seoSchema]]) {
assert.doesNotMatch(source, /AI 连接配置/, `${name} 仍引用 AI 连接配置页`);
assert.doesNotMatch(source, /扩展 → AI/, `${name} 仍引导到扩展菜单`);
}
});
test('前台投稿 AI 辅助死代码已清理', () => {
assert.doesNotMatch(frontSubmit, /bindAi|ai-assist-btn|cfg\.ai/);
assert.doesNotMatch(submitContext, /ai_available/);
assert.doesNotMatch(submitPage, /'ai'\s*=>|aiGenerating|aiFailed|aiFilled|needContent/);
});
test('全仓源码不再引用已删除的 AI 连接配置', () => {
const offenders = sources.filter((file) => {
const text = fs.readFileSync(file, 'utf8');
return removed.some((pattern) => text.includes(pattern));
});
assert.deepEqual(offenders.map((file) => path.relative(root, file)), []);
});
+265
View File
@@ -0,0 +1,265 @@
<?php
/**
* Baidu push harness.
*
* Drives the REAL SearchPush / SearchPushMetaBox against WP stubs so the
* reference-parity contract is covered end to end:
* - normal / daily channels hit data.zz.baidu.com (daily appends type=daily);
* - successful pushes are recorded in meta and never re-submitted;
* - failed pushes retry on update when baidu_push_on_update is on;
* - the 「重新提交」checkbox forces a push only with a valid metabox nonce;
* - terms push via saved_term (post_format and other taxonomies excluded);
* - the edit panel renders status lines, update time and the resubmit checkbox.
*
* Usage: php baidu-push-harness.php -> prints "Baidu push harness passed".
*/
namespace {
define('ABSPATH', __DIR__ . '/');
define('WP_DEBUG', false);
final class WP_Error {
public function __construct(private string $code, private string $message) {}
public function get_error_message(): string { return $this->message; }
}
final class WP_Post {
public int $ID = 0;
public string $post_type = 'post';
public string $post_status = 'publish';
}
final class WP_Term {
public int $term_id = 0;
public string $taxonomy = 'category';
}
// ---------- WP function stubs ----------
function add_action(...$args) {}
function apply_filters($tag, $value) { return $value; }
function __($s, $d = null) { return (string) $s; }
function esc_html($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
function esc_attr($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
function esc_html_e($s, $d = null) { echo esc_html($s); }
function wp_json_encode($v, $f = 0) { return (string) json_encode($v, $f); }
function wp_nonce_field($action, $name) { echo '<input type="hidden" name="' . $name . '" value="nonce" />'; }
function wp_unslash($s) { return $s; }
function sanitize_text_field($s) { return trim(strip_tags((string) $s)); }
function wp_verify_nonce($nonce, $action) { return $nonce === 'valid-nonce' && $action === 'aksrt_push_meta_save'; }
function is_wp_error($t) { return $t instanceof WP_Error; }
function home_url($path = '/') { return 'https://example.test' . $path; }
function current_time($type = 'mysql') { return '2026-10-13 12:00:00'; }
function get_permalink($post) { return 'https://example.test/?p=' . (int) $post->ID; }
function get_term_link($term, $taxonomy = '') {
$id = is_object($term) ? (int) $term->term_id : (int) $term;
return 'https://example.test/?cat=' . $id;
}
function wp_is_post_revision($id) { return false; }
function wp_is_post_autosave($id) { return false; }
function aksrt_searchable_post_types(): array { return ['post']; }
function get_object_taxonomies($types, $output = 'names') { return ['post_tag', 'category', 'post_format']; }
// Outgoing HTTP: every call is captured so channel/URL/body can be asserted.
$GLOBALS['push_requests'] = [];
function wp_remote_post($url, $args = []) {
$GLOBALS['push_requests'][] = ['url' => (string) $url, 'body' => (string) ($args['body'] ?? '')];
return ['response' => ['code' => 200], 'body' => (string) json_encode(['success' => 1, 'remain' => 99])];
}
function wp_remote_retrieve_body($r) { return is_array($r) ? (string) ($r['body'] ?? '') : ''; }
function wp_remote_retrieve_response_code($r) { return is_array($r) ? (int) ($r['response']['code'] ?? 0) : 0; }
// In-memory meta stores.
$GLOBALS['push_post_meta'] = [];
$GLOBALS['push_term_meta'] = [];
function get_post_meta($id, $key, $single = false) { return $GLOBALS['push_post_meta'][$id][$key] ?? ''; }
function update_post_meta($id, $key, $value) { $GLOBALS['push_post_meta'][$id][$key] = $value; return true; }
function delete_post_meta($id, $key) { unset($GLOBALS['push_post_meta'][$id][$key]); return true; }
function get_term_meta($id, $key, $single = false) { return $GLOBALS['push_term_meta'][$id][$key] ?? ''; }
function update_term_meta($id, $key, $value) { $GLOBALS['push_term_meta'][$id][$key] = $value; return true; }
function delete_term_meta($id, $key) { unset($GLOBALS['push_term_meta'][$id][$key]); return true; }
}
namespace AKSRT\Settings {
class Settings {
public static array $config = [];
public static function get_search_push_settings(): array { return self::$config; }
}
}
namespace {
require_once dirname(__DIR__, 2) . '/src/Extensions/SearchPush.php';
require_once dirname(__DIR__, 2) . '/src/Extensions/SearchPushMetaBox.php';
use AKSRT\Extensions\SearchPush;
use AKSRT\Extensions\SearchPushMetaBox;
$failures = [];
$check = static function (string $label, bool $ok) use (&$failures): void {
if (!$ok) {
$failures[] = $label;
}
};
$reset = static function (array $config): void {
\AKSRT\Settings\Settings::$config = $config;
$GLOBALS['push_requests'] = [];
$GLOBALS['push_post_meta'] = [];
$GLOBALS['push_term_meta'] = [];
$_POST = [];
};
$post = static function (int $id): WP_Post {
$p = new WP_Post();
$p->ID = $id;
return $p;
};
$defaults = [
'baidu_push_enabled' => true,
'baidu_push_token' => 'TOKEN',
'baidu_push_auto' => true,
'baidu_push_on_publish' => true,
'baidu_push_on_update' => false,
'baidu_push_normal' => true,
'baidu_push_fast' => false,
'baidu_push_log_enabled' => true,
];
$push = SearchPush::get_instance();
$api = 'http://data.zz.baidu.com/urls?site=' . rawurlencode('https://example.test/') . '&token=TOKEN';
// 1) First publish pushes once through the normal-collection endpoint.
$reset($defaults);
$push->on_transition_post_status('publish', 'draft', $post(11));
$check('首次发布推送一次', count($GLOBALS['push_requests']) === 1);
$check('普通收录请求地址正确', ($GLOBALS['push_requests'][0]['url'] ?? '') === $api);
$check('普通收录请求体为文章链接', ($GLOBALS['push_requests'][0]['body'] ?? '') === 'https://example.test/?p=11');
$record = SearchPush::get_push_record(11);
$check('推送记录写入 meta', !empty($record['normal_push']));
$check('推送记录带更新时间', !empty($record['update_time']));
$check('推送记录不含快速收录', !array_key_exists('daily_push', $record));
// 2) A completed push is never re-submitted on save.
$push->on_transition_post_status('publish', 'publish', $post(11));
$check('成功后更新不重复推送', count($GLOBALS['push_requests']) === 1);
// 3) Failed push retries on update when the update switch is on.
$reset(array_merge($defaults, ['baidu_push_on_update' => true]));
SearchPush::save_push_record(12, ['normal_push' => false, 'normal_result' => null, 'update_time' => '2026-10-13 09:00:00']);
$push->on_transition_post_status('publish', 'publish', $post(12));
$check('失败后更新重试一次', count($GLOBALS['push_requests']) === 1);
$check('重试后记录为成功', !empty(SearchPush::get_push_record(12)['normal_push']));
$push->on_transition_post_status('publish', 'publish', $post(12));
$check('重试成功后不重复推送', count($GLOBALS['push_requests']) === 1);
// 4) 「重新提交」with a valid nonce forces a push even after success.
$reset($defaults);
$push->on_transition_post_status('publish', 'draft', $post(13));
$_POST[SearchPush::RESUBMIT_FIELD] = '1';
$_POST[SearchPush::NONCE_FIELD] = 'valid-nonce';
$push->on_transition_post_status('publish', 'publish', $post(13));
$check('重新提交强制再次推送', count($GLOBALS['push_requests']) === 2);
// 5) A forged nonce is ignored, dedup still applies.
$reset($defaults);
$push->on_transition_post_status('publish', 'draft', $post(14));
$_POST[SearchPush::RESUBMIT_FIELD] = '1';
$_POST[SearchPush::NONCE_FIELD] = 'forged-nonce';
$push->on_transition_post_status('publish', 'publish', $post(14));
$check('无效 nonce 的重新提交被忽略', count($GLOBALS['push_requests']) === 1);
// 6) Fast collection appends type=daily and records both channels.
$reset(array_merge($defaults, ['baidu_push_fast' => true]));
$push->on_transition_post_status('publish', 'draft', $post(15));
$check('双通道推送两次', count($GLOBALS['push_requests']) === 2);
$check('快速收录走 type=daily', str_contains($GLOBALS['push_requests'][1]['url'] ?? '', '&type=daily'));
$record15 = SearchPush::get_push_record(15);
$check('双通道记录完整', !empty($record15['normal_push']) && !empty($record15['daily_push']));
$push->on_transition_post_status('publish', 'publish', $post(15));
$check('双通道成功后不重复推送', count($GLOBALS['push_requests']) === 2);
// 7) No channel enabled: nothing pushed, nothing recorded.
$reset(array_merge($defaults, ['baidu_push_normal' => false]));
$push->on_transition_post_status('publish', 'draft', $post(16));
$check('通道全关不推送', count($GLOBALS['push_requests']) === 0);
$check('通道全关不写记录', SearchPush::get_push_record(16) === []);
// 8) Feature off / token missing: nothing pushed, nothing recorded.
$reset(array_merge($defaults, ['baidu_push_enabled' => false]));
$push->on_transition_post_status('publish', 'draft', $post(17));
$check('未启用不推送', count($GLOBALS['push_requests']) === 0);
$reset(array_merge($defaults, ['baidu_push_token' => '']));
$push->on_transition_post_status('publish', 'draft', $post(18));
$check('缺少 Token 不推送', count($GLOBALS['push_requests']) === 0);
$check('缺少 Token 不写记录', SearchPush::get_push_record(18) === []);
// 9) Terms push via saved_term with the same dedup / resubmit rules.
$reset($defaults);
$push->on_saved_term(21, 21, 'category');
$check('术语推送一次', count($GLOBALS['push_requests']) === 1);
$check('术语推送链接正确', ($GLOBALS['push_requests'][0]['body'] ?? '') === 'https://example.test/?cat=21');
$check('术语记录写入', !empty(SearchPush::get_push_record(21, true)['normal_push']));
$push->on_saved_term(21, 21, 'category');
$check('术语成功后不重复推送', count($GLOBALS['push_requests']) === 1);
$push->on_saved_term(22, 22, 'post_format');
$check('post_format 术语不推送', count($GLOBALS['push_requests']) === 1);
$_POST[SearchPush::RESUBMIT_FIELD] = '1';
$_POST[SearchPush::NONCE_FIELD] = 'valid-nonce';
$push->on_saved_term(21, 21, 'category');
$check('术语重新提交强制推送', count($GLOBALS['push_requests']) === 2);
// 10) Shared type / taxonomy policy.
$check('可推送文章类型', SearchPush::pushable_post_types() === ['post']);
$check('可推送分类法排除 post_format', SearchPush::pushable_taxonomies() === ['post_tag', 'category']);
// 11) Edit panel rendering.
$reset($defaults);
SearchPush::save_push_record(31, [
'normal_push' => true,
'normal_result' => ['success' => 1],
'daily_push' => false,
'daily_result' => null,
'update_time' => '2026-10-13 09:00:00',
]);
SearchPush::save_push_record(31, [
'normal_push' => true,
'normal_result' => ['success' => 1],
'daily_push' => false,
'daily_result' => null,
'update_time' => '2026-10-13 09:00:00',
], true);
$box = SearchPushMetaBox::get_instance();
ob_start();
$box->render_post_panel($post(31));
$html = (string) ob_get_clean();
$check('面板含普通收录成功', str_contains($html, '普通收录:成功'));
$check('面板含快速收录失败', str_contains($html, '快速收录:失败'));
$check('面板含更新时间', str_contains($html, '2026-10-13 09:00:00'));
$check('面板含重新提交勾选', str_contains($html, 'name="aksrt_push_resubmit"'));
$check('面板含 nonce 字段', str_contains($html, 'name="aksrt_push_meta_nonce"'));
$check('面板转义接口 JSON', str_contains($html, '{&quot;success&quot;:1}'));
ob_start();
$box->render_term_panel(null);
$addHtml = (string) ob_get_clean();
$check('新增术语表单仅提示', str_contains($addHtml, '发布、更新后刷新页面后可查看提交结果') && !str_contains($addHtml, '<tr'));
$term = new WP_Term();
$term->term_id = 31;
ob_start();
$box->render_term_panel($term);
$termHtml = (string) ob_get_clean();
$check('术语编辑表单为表单行', str_contains($termHtml, '<tr class="form-field">'));
$check('术语编辑表单含重新提交', str_contains($termHtml, 'name="aksrt_push_resubmit"'));
if ($failures) {
echo "Baidu push harness FAILED\n";
foreach ($failures as $failure) {
echo ' - ' . $failure . "\n";
}
exit(1);
}
echo "Baidu push harness passed\n";
}
@@ -0,0 +1,203 @@
'use strict';
/**
* 「百度推送」后台设置页契约(对齐参考主题 zibll):
* 1) PHP schema(SchemaExtensionsPush.php)与 JS schema(push.js)字段键、顺序、默认值一致;
* 2) 参考主题字段全集存在:自动推送 JS、普通收录、快速收录、准入 Token、发布/更新触发;
* 3) 双路/迁移字段(SEO 页 seo_baidu_*、熊掌号 xzh_*、批量推送 batch_*)已彻底移除,无双路兼容;
* 4) 运行时(SearchPush + ExtensionRuntime)只读取 canonical 键,快速收录走 type=daily;
* 5) 分类法 saved_term 推送、meta 去重、「重新提交」nonce 与编辑页面板均已接入,
* 并由 PHP harness 驱动真实逻辑验证。
*/
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const php = fs.readFileSync(path.join(root, 'src/Admin/Schema/SchemaExtensionsPush.php'), 'utf8');
const js = fs.readFileSync(path.join(root, 'assets/admin/src/schema/sections/extensions/push.js'), 'utf8');
const runtime = fs.readFileSync(path.join(root, 'src/Extensions/SearchPush.php'), 'utf8');
const extensionRuntime = fs.readFileSync(path.join(root, 'src/Settings/ExtensionRuntime.php'), 'utf8');
const globalPhp = fs.readFileSync(path.join(root, 'src/Admin/Schema/GlobalSchema.php'), 'utf8');
const seoJs = fs.readFileSync(path.join(root, 'assets/admin/src/schema/sections/global/seo.js'), 'utf8');
const displayGroups = fs.readFileSync(path.join(root, 'src/Settings/DisplayPageGroups.php'), 'utf8');
const metaBox = fs.readFileSync(path.join(root, 'src/Extensions/SearchPushMetaBox.php'), 'utf8');
const functions = fs.readFileSync(path.join(root, 'functions.php'), 'utf8');
// 参考主题(zibll)百度推送字段全集,AKSRT 单一 canonical 配置面(扩展&增强 → 百度推送)。
const REFERENCE_KEYS = [
'baidu_push_enabled',
'baidu_push_token',
'baidu_push_auto',
'baidu_push_on_publish',
'baidu_push_on_update',
'baidu_push_normal',
'baidu_push_fast',
'baidu_push_log_enabled',
];
// 双路 / 迁移 / 死代码字段:SEO 页 seo_baidu_*、熊掌号 xzh_*、批量推送与校验占位。
const DUAL_KEYS = [
'seo_baidu_push',
'seo_baidu_normal',
'seo_baidu_fast',
'seo_baidu_token',
'xzh_on',
'xzh_appid',
'xzh_render_tail',
'xzh_jsonld_single',
'xzh_jsonld_page',
'xzh_jsonld_img',
'baidu_push_batch_size',
'baidu_push_verified',
'push_batch',
'batch_size',
];
function phpFieldKeys(src) {
const found = [];
const re = /'key'\s*=>\s*'([a-z0-9_]+)',\s*'label'/g;
let m;
while ((m = re.exec(src)) !== null) found.push(m[1]);
return found;
}
function jsFieldKeys(src) {
const found = [];
const re = /key:\s*'([a-z0-9_]+)',\s*label:/g;
let m;
while ((m = re.exec(src)) !== null) found.push(m[1]);
return found;
}
test('PHP push schema 字段键与顺序对齐参考主题', () => {
assert.deepEqual(phpFieldKeys(php), REFERENCE_KEYS);
});
test('JS push schema 字段键与顺序对齐参考主题', () => {
assert.deepEqual(jsFieldKeys(js), REFERENCE_KEYS);
});
test('PHP schema 默认值对齐', () => {
assert.match(php, /'baidu_push_enabled',\s*'label'\s*=>\s*'启用百度推送',\s*'type'\s*=>\s*'switch',\s*'default'\s*=>\s*false/);
assert.match(php, /'baidu_push_token',\s*'label'\s*=>\s*'推送 Token',\s*'type'\s*=>\s*'input',\s*'default'\s*=>\s*''/);
assert.match(php, /'baidu_push_auto',\s*'label'\s*=>\s*'自动推送',\s*'type'\s*=>\s*'switch',\s*'default'\s*=>\s*true/);
assert.match(php, /'baidu_push_on_publish',\s*'label'\s*=>\s*'发布时主动推送',\s*'type'\s*=>\s*'switch',\s*'default'\s*=>\s*true/);
assert.match(php, /'baidu_push_on_update',\s*'label'\s*=>\s*'更新时推送',\s*'type'\s*=>\s*'switch',\s*'default'\s*=>\s*false/);
assert.match(php, /'baidu_push_normal',\s*'label'\s*=>\s*'普通收录',\s*'type'\s*=>\s*'switch',\s*'default'\s*=>\s*true/);
assert.match(php, /'baidu_push_fast',\s*'label'\s*=>\s*'快速收录',\s*'type'\s*=>\s*'switch',\s*'default'\s*=>\s*false/);
assert.match(php, /'baidu_push_log_enabled',\s*'label'\s*=>\s*'记录推送日志',\s*'type'\s*=>\s*'switch',\s*'default'\s*=>\s*true/);
});
test('JS schema 默认值对齐', () => {
assert.match(js, /baidu_push_enabled:\s*false/);
assert.match(js, /baidu_push_token:\s*''/);
assert.match(js, /baidu_push_auto:\s*true/);
assert.match(js, /baidu_push_on_publish:\s*true/);
assert.match(js, /baidu_push_on_update:\s*false/);
assert.match(js, /baidu_push_normal:\s*true/);
assert.match(js, /baidu_push_fast:\s*false/);
assert.match(js, /baidu_push_log_enabled:\s*true/);
});
test('双路/迁移/死代码字段在 schema、运行时与展示层均已移除(无双路)', () => {
for (const legacy of DUAL_KEYS) {
assert.ok(!php.includes(legacy), `SchemaExtensionsPush 残留: ${legacy}`);
assert.ok(!js.includes(legacy), `push.js 残留: ${legacy}`);
assert.ok(!runtime.includes(legacy), `SearchPush 残留: ${legacy}`);
assert.ok(!extensionRuntime.includes(legacy), `ExtensionRuntime 残留: ${legacy}`);
assert.ok(!globalPhp.includes(legacy), `GlobalSchema 残留: ${legacy}`);
assert.ok(!seoJs.includes(legacy), `seo.js 残留: ${legacy}`);
assert.ok(!displayGroups.includes(legacy), `DisplayPageGroups 残留: ${legacy}`);
assert.ok(!metaBox.includes(legacy), `SearchPushMetaBox 残留: ${legacy}`);
assert.ok(!functions.includes(legacy), `functions.php 残留: ${legacy}`);
}
assert.ok(!globalPhp.includes("'baidu'"), 'GlobalSchema SEO 分组残留百度重复配置面');
assert.ok(!seoJs.includes("key: 'baidu'"), 'seo.js 残留百度重复配置面');
});
test('普通/快速收录通道接入百度链接提交接口', () => {
assert.match(runtime, /data\.zz\.baidu\.com\/urls\?site=/);
assert.match(runtime, /'baidu_push_normal'/);
assert.match(runtime, /'baidu_push_fast'/);
assert.match(runtime, /&type=daily/);
assert.match(extensionRuntime, /'baidu_push_normal'\s*=>/);
assert.match(extensionRuntime, /'baidu_push_fast'\s*=>/);
});
test('前台自动推送 JS 与参考主题一致', () => {
assert.match(runtime, /zz\.bdstatic\.com\/linksubmit\/push\.js/);
assert.match(runtime, /push\.zhanzhang\.baidu\.com\/push\.js/);
assert.match(runtime, /add_action\('wp_footer'/);
});
test('发布/更新触发沿用 transition_post_status 且仅限可检索内容类型', () => {
assert.match(runtime, /transition_post_status/);
assert.match(runtime, /aksrt_searchable_post_types/);
assert.match(runtime, /wp_is_post_revision/);
assert.match(runtime, /wp_is_post_autosave/);
});
test('推送日志开关接入运行时', () => {
assert.match(runtime, /baidu_push_log_enabled/);
assert.match(extensionRuntime, /'baidu_push_log_enabled'/);
});
test('服务状态契约保留扩展总览消费的键', () => {
for (const key of ['enabled', 'provider', 'configured', 'available', 'verified', 'transport', 'missing_fields']) {
assert.ok(runtime.includes(`'${key}'`), `get_status 缺少 ${key}`);
}
});
test('分类法推送接入 saved_term 且排除文章形式', () => {
assert.match(runtime, /add_action\('saved_term'/);
assert.match(runtime, /public static function pushable_taxonomies/);
assert.match(runtime, /array_diff\(\(array\) \$taxonomies, \['post_format', 'nav_menu'\]\)/);
assert.match(runtime, /aksrt_baidu_push_taxonomies/);
assert.match(runtime, /get_term_link\(\$term_id, \$taxonomy\)/);
});
test('推送结果写入 meta 并按记录去重', () => {
assert.match(runtime, /META_KEY\s*=\s*'_aksrt_baidu_push_back'/);
assert.match(runtime, /private function record_is_complete/);
assert.match(runtime, /private function build_record/);
assert.match(runtime, /'normal_push'/);
assert.match(runtime, /'daily_push'/);
assert.match(runtime, /'update_time'/);
assert.match(runtime, /update_term_meta\(/);
assert.match(runtime, /delete_term_meta\(/);
});
test('「重新提交」勾选需通过 metabox nonce 校验', () => {
assert.match(runtime, /NONCE_ACTION\s*=\s*'aksrt_push_meta_save'/);
assert.match(runtime, /NONCE_FIELD\s*=\s*'aksrt_push_meta_nonce'/);
assert.match(runtime, /RESUBMIT_FIELD\s*=\s*'aksrt_push_resubmit'/);
assert.match(runtime, /wp_verify_nonce\(\$nonce, self::NONCE_ACTION\)/);
});
test('编辑页面板与参考主题「百度资源提交」Metabox 对齐', () => {
assert.match(metaBox, /add_meta_box\(\s*'aksrt_baidu_push',\s*__\('百度资源提交', 'aksrt'\)/);
assert.match(metaBox, /'side',\s*'high'/);
assert.match(metaBox, /\{\$taxonomy\}_edit_form_fields/);
assert.match(metaBox, /\{\$taxonomy\}_add_form_fields/);
assert.match(metaBox, /普通收录:成功/);
assert.match(metaBox, /快速收录:失败/);
assert.match(metaBox, /更新时间:/);
assert.match(metaBox, /重新提交/);
assert.match(metaBox, /发布、更新后刷新页面后可查看提交结果/);
assert.match(metaBox, /wp_nonce_field\(SearchPush::NONCE_ACTION, SearchPush::NONCE_FIELD\)/);
assert.match(metaBox, /esc_html\(\$line\[1\]\)/, '接口结果需转义后输出');
});
test('functions.php 接线推送面板', () => {
assert.match(functions, /SearchPushMetaBox::get_instance\(\)->init\(\)/);
});
test('百度推送行为 harness 通过', () => {
const harness = path.join(root, 'tests', 'admin', 'baidu-push-harness.php');
const output = execFileSync('php', [harness], { encoding: 'utf8' });
assert.match(output, /Baidu push harness passed/);
});
+151
View File
@@ -0,0 +1,151 @@
<?php
/**
* WeChat template-message settings harness.
*
* Drives the REAL UserController REST callbacks (get_wechat_status /
* update_wechat_settings) against WP stubs so the previously-broken routes are
* covered end to end:
* - GET /wechat/status returns the WeChatNotify status shape (masked secret);
* - POST /wechat/settings persists through Integration's key whitelist;
* - the *** mask keeps the stored secret instead of overwriting it;
* - omitted fields fall back to current values; unknown keys are dropped;
* - inputs are sanitized before storage.
*
* Usage: php wechat-settings-harness.php -> prints "WeChat settings harness passed".
*/
namespace {
define('ABSPATH', __DIR__ . '/');
final class WP_REST_Response {
public $data;
public $status;
public function __construct($data = null, int $status = 200) {
$this->data = $data;
$this->status = $status;
}
}
final class WP_REST_Request {
private array $json;
public function __construct(array $json = []) { $this->json = $json; }
public function get_json_params(): array { return $this->json; }
public function get_method(): string { return 'POST'; }
}
// ---------- WP function stubs ----------
function __($s, $d = null) { return (string) $s; }
function sanitize_text_field($s) { return trim(strip_tags((string) $s)); }
function current_user_can($c) { return true; }
function wp_send_json_success($d) { echo (string) json_encode($d); }
function get_transient($k) { return $GLOBALS['wechat_transient'] ?? false; }
function get_option($k, $default = false) { return $GLOBALS['wechat_options'][$k] ?? $default; }
function update_option($k, $v) { $GLOBALS['wechat_options'][$k] = $v; return true; }
$GLOBALS['wechat_options'] = ['aksrt_wechat_settings' => [
'enabled' => true,
'app_id' => 'wxAPPID',
'app_secret' => 'SECRET123',
'template_id' => 'TPL1',
]];
$GLOBALS['wechat_transient'] = 'ACCESS_TOKEN';
$GLOBALS['wpdb'] = new class {
public $usermeta = 'wp_usermeta';
public function get_var($sql) { return 7; }
};
}
namespace AKSRT\Settings {
// Settings 层仅为薄委托,harness 直接对接真实 Integration。
class Settings {
public static function get_wechat_settings(): array { return Integration::get_wechat_settings(); }
public static function update_wechat_settings(array $settings): void { Integration::update_wechat_settings($settings); }
}
}
namespace {
require_once dirname(__DIR__, 2) . '/src/Admin/ControllerBase.php';
require_once dirname(__DIR__, 2) . '/src/Admin/UserControllerReportOperations.php';
require_once dirname(__DIR__, 2) . '/src/Admin/UserControllerAuthPhoneOperations.php';
require_once dirname(__DIR__, 2) . '/src/Admin/UserControllerRestrictionOperations.php';
require_once dirname(__DIR__, 2) . '/src/Admin/UserControllerFollowOperations.php';
require_once dirname(__DIR__, 2) . '/src/Admin/UserControllerProfileMediaOperations.php';
require_once dirname(__DIR__, 2) . '/src/Admin/UserController.php';
require_once dirname(__DIR__, 2) . '/src/Settings/Integration.php';
require_once dirname(__DIR__, 2) . '/src/Settings/DomainGroups.php';
require_once dirname(__DIR__, 2) . '/src/Payment/WeChatNotify.php';
$failures = [];
$check = static function (string $label, bool $ok) use (&$failures): void {
if (!$ok) {
$failures[] = $label;
}
};
$stored = static function (): array {
$v = get_option('aksrt_wechat_settings', []);
return is_array($v) ? $v : [];
};
$controller = \AKSRT\Admin\UserController::get_instance();
// 1) GET /wechat/status 返回完整状态形状(密钥掩码)。
$response = $controller->get_wechat_status(new WP_REST_Request());
$data = is_array($response->data) ? ($response->data['data'] ?? []) : [];
$check('status 含 enabled', array_key_exists('enabled', $data) && !empty($data['enabled']));
$check('status 含 appid', ($data['appid'] ?? '') === 'wxAPPID');
$check('status configured', !empty($data['configured']));
$check('status has_token', !empty($data['has_token']));
$check('status has_template', !empty($data['has_template']));
$check('status bound_users', ($data['bound_users'] ?? 0) === 7);
$check('status 掩码 app_secret', ($data['app_secret'] ?? '') === '***');
$check('status allowed_types 非空', !empty($data['allowed_types']));
// 2) 完整更新写入全部字段。
$response = $controller->update_wechat_settings(new WP_REST_Request([
'enabled' => true,
'app_id' => 'wxNEW',
'app_secret' => 'NEWSECRET',
'template_id' => 'TPL2',
]));
$check('更新写入 app_id', ($stored()['app_id'] ?? '') === 'wxNEW');
$check('更新写入 app_secret', ($stored()['app_secret'] ?? '') === 'NEWSECRET');
$check('更新写入 template_id', ($stored()['template_id'] ?? '') === 'TPL2');
$check('更新返回成功消息', str_contains((string) ($response->data['message'] ?? ''), '已保存'));
// 3) 掩码 *** 保留已存密钥。
$controller->update_wechat_settings(new WP_REST_Request([
'enabled' => true,
'app_id' => 'wxNEW',
'app_secret' => '***',
]));
$check('掩码保留原密钥', ($stored()['app_secret'] ?? '') === 'NEWSECRET');
// 4) 缺省字段沿用当前值,开关可关闭。
$controller->update_wechat_settings(new WP_REST_Request(['enabled' => false]));
$check('缺省字段沿用当前值', ($stored()['app_id'] ?? '') === 'wxNEW' && ($stored()['template_id'] ?? '') === 'TPL2');
$check('enabled 可关闭', empty($stored()['enabled']));
// 5) 输入消毒 + 未知键被白名单丢弃。
$controller->update_wechat_settings(new WP_REST_Request([
'app_id' => '<b>wxXSS</b>',
'evil' => 'x',
]));
$check('app_id 经消毒', ($stored()['app_id'] ?? '') === 'wxXSS');
$check('未知键被忽略', !array_key_exists('evil', $stored()));
// 6) 未配置时空密钥可显式清空。
update_option('aksrt_wechat_settings', ['enabled' => true, 'app_id' => 'a', 'app_secret' => '', 'template_id' => '']);
$controller->update_wechat_settings(new WP_REST_Request(['app_secret' => '']));
$check('空密钥可清存', ($stored()['app_secret'] ?? '') === '');
if ($failures) {
echo "WeChat settings harness FAILED\n";
foreach ($failures as $failure) {
echo ' - ' . $failure . "\n";
}
exit(1);
}
echo "WeChat settings harness passed\n";
}
@@ -0,0 +1,84 @@
'use strict';
/**
* 微信模板消息 REST 契约(修复存量审计错误):
* 1) /wechat/status 与 /wechat/settings 的路由回调方法在 UserController 中真实存在;
* 2) 状态读取委托 WeChatNotify::get_status(),写入委托 Settings::update_wechat_settings();
* 3) app_secret 掩码 *** 不覆盖已存密钥,输入经 sanitize_text_field 消毒;
* 4) PHP harness 驱动真实控制器验证上述行为;
* 5) aksrt_settings 上的微信通知死键(wechat_notify_* 等)已彻底移除,
* 活配置面仅剩 aksrt_wechat_settings(/users/wechat-template 自定义页)。
*/
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const controller = fs.readFileSync(path.join(root, 'src/Admin/UserController.php'), 'utf8');
const schemaUsers = fs.readFileSync(path.join(root, 'src/Admin/Schema/SchemaUsers.php'), 'utf8');
const usersSchemaIndex = fs.readFileSync(path.join(root, 'assets/admin/src/schema/sections/users/index.js'), 'utf8');
const usersSections = fs.readFileSync(path.join(root, 'assets/admin/src/constants/sections/users.js'), 'utf8');
// 死双路面:写进 aksrt_settings、全仓无消费方的微信通知键。
const DEAD_WECHAT_KEYS = [
'wechat_notify_enabled',
'wechat_app_id',
'wechat_app_secret',
'wechat_template_id',
'wechat_token',
'wechat_encoding_aes_key',
'wechat_template_comment',
'wechat_template_reply',
'wechat_template_system',
'wechat_template_order',
'wechat_notify_comment',
'wechat_notify_reply',
'wechat_notify_system',
'wechat_notify_order',
'wechat_bind_entry',
];
test('路由与回调方法配对存在', () => {
assert.match(controller, /register_rest_route\(self::API_NAMESPACE, '\/wechat\/status'[\s\S]*?'callback'\s*=>\s*\[\$this, 'get_wechat_status'\]/);
assert.match(controller, /register_rest_route\(self::API_NAMESPACE, '\/wechat\/settings'[\s\S]*?'callback'\s*=>\s*\[\$this, 'update_wechat_settings'\]/);
assert.match(controller, /public function get_wechat_status\(\\WP_REST_Request \$request\)/);
assert.match(controller, /public function update_wechat_settings\(\\WP_REST_Request \$request\)/);
});
test('状态与写入委托既有服务层', () => {
assert.match(controller, /WeChatNotify::get_instance\(\)->get_status\(\)/);
assert.match(controller, /Settings::update_wechat_settings\(/);
});
test('密钥掩码与输入消毒', () => {
assert.match(controller, /\$params\['app_secret'\] !== '\*\*\*'/);
assert.match(controller, /sanitize_text_field\(\(string\) \$params\['app_id'\]\)/);
assert.match(controller, /sanitize_text_field\(\(string\) \$params\['app_secret'\]\)/);
assert.match(controller, /sanitize_text_field\(\(string\) \$params\['template_id'\]\)/);
});
test('微信设置行为 harness 通过', () => {
const harness = path.join(root, 'tests', 'admin', 'wechat-settings-harness.php');
const output = execFileSync('php', [harness], { encoding: 'utf8' });
assert.match(output, /WeChat settings harness passed/);
});
test('死双路面(aksrt_settings 微信通知键)已彻底移除', () => {
for (const key of DEAD_WECHAT_KEYS) {
assert.ok(!schemaUsers.includes(key), `SchemaUsers 残留: ${key}`);
assert.ok(!usersSections.includes(key), `users 菜单常量残留: ${key}`);
}
assert.ok(!usersSchemaIndex.includes('wechatSchema'), 'users schema index 残留 wechatSchema 注册');
assert.ok(!usersSections.includes("route: '/users/wechat',"), '用户菜单残留微信通知 schema 页');
assert.ok(!fs.existsSync(path.join(root, 'assets/admin/src/schema/sections/users/wechat.js')), 'wechat.js 未删除');
});
test('活配置面保持唯一(aksrt_wechat_settings + 自定义页)', () => {
assert.match(usersSections, /key: 'users-wechat-template'/);
assert.match(usersSections, /route: '\/users\/wechat-template'/);
assert.match(controller, /WeChatNotify::get_instance\(\)->get_status\(\)/);
assert.match(controller, /Settings::update_wechat_settings\(/);
});
@@ -175,16 +175,68 @@ test('guest account member VIP and publish contracts share reference header DOM'
assert.match(userboxCss, /\.aksrt-nav-notification \.aksrt-notification-badge/); assert.match(userboxCss, /\.aksrt-nav-notification \.aksrt-notification-badge/);
assert.doesNotMatch(userboxCss, /aksrt-header-wallet|aksrt-header-checkin|aksrt-header-payvip|\.aksrt-header-userbox \.aksrt-notification-badge/); assert.doesNotMatch(userboxCss, /aksrt-header-wallet|aksrt-header-checkin|aksrt-header-payvip|\.aksrt-header-userbox \.aksrt-notification-badge/);
assert.doesNotMatch(userboxCss, /width:\s*260px/); assert.doesNotMatch(userboxCss, /width:\s*260px/);
assert.match(userboxCss, /\.aksrt-header-userbox\s*\{[^}]*line-height:\s*1\.42857143/s);
assert.match(userboxCss, /\.aksrt-header-userbox\s+\.muted-box\.padding-6\s*\{[^}]*background-color:\s*var\(--this-bg,\s*var\(--color-line-soft\)\)[^}]*border-radius:\s*4px[^}]*padding:\s*6px/s);
assert.match(userboxCss, /\.aksrt-header-userbox\s*>\s*\.mt6\.em09\s*>\s*\.but\.block\s*\{[^}]*display:\s*block;[^}]*box-sizing:\s*border-box;[^}]*width:\s*100%;/s);
}); });
test('the top-nav midline chip rule leaves the popover social buttons alone', () => {
const userboxCss = read('assets/css/frontend/header-userbox.css');
const publicCss = read('assets/css/build/public.css');
// The account-row chips, the theme toggle and the language toggle all want a
// 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. flex-basis outranks width in a
// flex container and the bare rule is (0,3,0) against (0,2,0), so an
// unscoped selector collapses the buttons to 29px and zeroes their margins.
assert.match(userboxCss, /\.navbar-top \.navbar-form \.toggle-radius:not\(\.social-login-item\):not\(\.xh-social-item\)\s*\{[^}]*flex:\s*0 0 29px;[^}]*margin:\s*0;/s);
assert.doesNotMatch(userboxCss, /\.navbar-top \.navbar-form \.toggle-radius\s*\{/);
// header-userbox.css is also folded into the public bundle, so the exemption
// has to survive the build.
assert.match(publicCss, /\.navbar-top \.navbar-form \.toggle-radius:not\(\.social-login-item\):not\(\.xh-social-item\)/);
});
test('route and feature modules do not restyle the legacy top-nav chips', () => {
// Route and feature modules may not restyle the legacy .but colour modifiers
// without an enclosing scope. The top-nav chips ride the legacy geometry
// verbatim (newadd-btns but <skin> radius and pay-vip but jb-red radius
// payvip-icon), so their pill comes from .but.radius at (0,2,0). A later,
// equally specific rule that restates border-radius on a bare .but.<colour>
// out-ranks it and flattens the pill: user-center/credits.css did exactly that
// with .but.jb-blue, so the user-center route rendered a 4px publish chip while
// every other route kept the 50px one. Such rules must be scoped to their own
// shell, as credits.css now is with .uc-content.
const moduleCss = [
...listFiles('assets/css/routes', new Set(['.css'])),
...listFiles('assets/css/frontend', new Set(['.css'])),
].filter((file) => file !== 'assets/css/frontend/header-userbox.css');
for (const file of moduleCss) {
const css = read(file);
for (const [, selector, declarations] of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!/border-radius/.test(declarations)) continue;
for (const part of selector.split(',')) {
assert.doesNotMatch(
part.trim(),
/^\.but\./,
`${file} restates border-radius on an unscoped legacy .but modifier (${part.trim()}); scope it to the module's own shell so the top-nav chips keep the reference pill`
);
}
}
}
})
test('the account popover rows match the reference user box structure', () => { test('the account popover rows match the reference user box structure', () => {
const identity = read('inc/helpers/aksrt-identity.php'); const identity = read('inc/helpers/aksrt-identity.php');
const legacyMainCss = read('assets/css/main.css'); const legacyMainCss = read('assets/css/main.css');
const buttonsCss = read('assets/css/core/buttons.css');
const userboxCss = read('assets/css/frontend/header-userbox.css');
// Stats row is wired with the reference wrapper and drops the posts chip. // Stats row is wired with the reference wrapper and carries the posts chip.
assert.match(header, /aksrt_header_user_badges_row\(\$user_id\)/); assert.match(header, /aksrt_header_user_badges_row\(\$user_id\)/);
assert.match(header, /class="em09 author-tag mb10 mt6 flex jc"/); assert.match(header, /class="em09 author-tag mb10 mt6 flex jc"/);
assert.doesNotMatch(identity, /tag-posts/); assert.match(identity, /tag-posts/);
assert.match(identity, /fa fas fa-file-alt/);
assert.match(identity, /fa fas fa-comment-dots/); assert.match(identity, /fa fas fa-comment-dots/);
assert.match(identity, /fa fas fa-thumbs-up/); assert.match(identity, /fa fas fa-thumbs-up/);
assert.match(identity, /fa fas fa-fire/); assert.match(identity, /fa fas fa-fire/);
@@ -194,13 +246,18 @@ test('the account popover rows match the reference user box structure', () => {
assert.match(header, /admin\.php\?page=aksrt_pay/); assert.match(header, /admin\.php\?page=aksrt_pay/);
assert.match(header, /支付中心/); assert.match(header, /支付中心/);
// Wallet tiles: balance first, muted-box geometry, FA5 icons. // Wallet tiles: balance first, muted-box geometry, reference sprite icons.
assert.match(header, /class="flex ab jsb col-ml6"/); assert.match(header, /class="flex ab jsb col-ml6"/);
assert.match(header, /muted-box padding-6 flex1 radius4/); assert.match(header, /muted-box padding-6 flex1 radius4/);
assert.match(header, /font-bold c-blue-2/); assert.match(header, /font-bold c-blue-2/);
assert.match(header, /font-bold c-yellow/); assert.match(header, /font-bold c-yellow/);
assert.match(header, /fa fas fa-money-bill-wave/); assert.match(header, /aksrt_icon_markup\('zibsvg-money-color-2', 'em14'\)/);
assert.match(header, /fa fas fa-coins/); assert.match(header, /aksrt_icon_markup\('zibsvg-points-color', 'em14'\)/);
// The value icons are the reference theme's multi-tone SVGs, not FA5 glyphs;
// the tile arrow stays on fa-angle-right exactly as the reference renders it.
assert.doesNotMatch(header, /fa fas fa-money-bill-wave/);
assert.doesNotMatch(header, /fa fas fa-coins/);
assert.match(header, /fa fas fa-angle-right em12/);
// Message icon uses the reference markup. // Message icon uses the reference markup.
assert.match(header, /msg-news-icon abs-right/); assert.match(header, /msg-news-icon abs-right/);
@@ -211,11 +268,35 @@ test('the account popover rows match the reference user box structure', () => {
assert.match(header, /radius4 payvip-icon btn-block text-center/); assert.match(header, /radius4 payvip-icon btn-block text-center/);
assert.match(header, /vip-expdate-tag/); assert.match(header, /vip-expdate-tag/);
assert.match(header, /pay-vip but radius4 payvip-icon btn-block/); assert.match(header, /pay-vip but radius4 payvip-icon btn-block/);
// core/buttons.css restates .but after main.css, which would otherwise replace
// the legacy .payvip-icon surface with the .jb-* skin. The restoration must
// cover background, color, border and text-shadow, and must not be scoped to
// one call site or the top-nav chip keeps the wrong gradient.
assert.match(userboxCss, /\.payvip-icon\s*\{[\s\S]*?background:\s*linear-gradient\(300deg,[\s\S]*?color:\s*#ece0e0;[\s\S]*?--this-color:\s*#ece0e0;[\s\S]*?border:\s*none;[\s\S]*?text-shadow:\s*none;/s);
assert.doesNotMatch(userboxCss, /\.aksrt-header-userbox \.pay-vip\.but\.payvip-icon/)
assert.doesNotMatch(header, /vipbg-v2/); assert.doesNotMatch(header, /vipbg-v2/);
// Check-in entry is an anchor with the reference button classes. // Check-in entry is an anchor with the reference button classes.
assert.match(header, /but block c-yellow/); assert.match(header, /but block c-yellow/);
assert.match(header, /but block c-yellow is-done/);
assert.match(buttonsCss, /\.but\.block\s*\{\s*display:\s*block;\s*\}/);
assert.match(buttonsCss, /\.btn-block\s*\{\s*display:\s*block;\s*width:\s*100%;\s*\}/);
// The reference selector is a descendant, so the VIP chip inside its hover-show
// wrapper receives the same .but geometry (font-size, padding, margin) as the
// publish chip instead of falling back to the bare .but defaults.
assert.match(legacyMainCss, /\.navbar-top \.navbar-but \.but\s*\{[^}]*font-size:\s*0\.8em;[^}]*padding:\s*0\.3em 1em !important;[^}]*margin:\s*0\.4em;/s);
assert.doesNotMatch(legacyMainCss, /\.navbar-top \.navbar-but > \.but\s*\{/);
assert.doesNotMatch(header, /but block c-yellow[^"']*btn-block/);
assert.match(header, /<div class="mt6 em09" style="padding:2px;">' \. \$checkin \. '<\/div>/);
assert.match(header, /data-signin-done-label[\s\S]{0,120}?今日已签到/); assert.match(header, /data-signin-done-label[\s\S]{0,120}?今日已签到/);
// The reference renders fa-calendar-check-o, an outline glyph; FA5 keeps that
// look through the regular weight rather than the solid one.
assert.match(header, /fa far fa-calendar-check/);
assert.doesNotMatch(header, /fas fa-calendar-check/);
// .but already gives its icon a 0.25em margin, so the popover entry carries no
// margin class; the user-card badge keeps its own because it is not a .but.
assert.match(header, /<i class="fa far fa-calendar-check" aria-hidden="true"><\/i> '/);
assert.match(header, /fa far fa-calendar-check ml3 mr6/);
// Card geometry falls back to the reference .sub-user-box width. // Card geometry falls back to the reference .sub-user-box width.
assert.match(legacyMainCss, /\.sub-user-box\s*\{\s*width:\s*242px;/s); assert.match(legacyMainCss, /\.sub-user-box\s*\{\s*width:\s*242px;/s);
@@ -2,6 +2,7 @@ const test = require('node:test')
const assert = require('node:assert/strict') const assert = require('node:assert/strict')
const fs = require('node:fs') const fs = require('node:fs')
const path = require('node:path') const path = require('node:path')
const vm = require('node:vm')
const root = path.resolve(__dirname, '..', '..') const root = path.resolve(__dirname, '..', '..')
@@ -421,6 +422,23 @@ test('asset purchases share the order center and refresh every wallet surface',
assert.match(sidebar, /data-aksrt-points-value/) assert.match(sidebar, /data-aksrt-points-value/)
assert.match(header, /data-aksrt-wallet-value/) assert.match(header, /data-aksrt-wallet-value/)
assert.match(header, /data-aksrt-points-value/) assert.match(header, /data-aksrt-points-value/)
assert.match(header, /data-aksrt-wallet-value data-aksrt-count-format="cut-count"/)
assert.match(header, /data-aksrt-points-value data-aksrt-count-format="cut-count"/)
assert.match(creditsJs, /element\.dataset\.aksrtCountFormat === 'cut-count' \? formatCutCount\(/)
})
test('header wallet count formatting matches the server cut-count thresholds', () => {
const creditsJs = fs.readFileSync(path.join(root, 'assets', 'js', 'credits.js'), 'utf8')
const formatterSource = creditsJs.match(/function formatCutCount\(value\) \{[\s\S]*?\n\t\}/)
assert.ok(formatterSource, 'credits runtime must define the header cut-count formatter')
const formatCutCount = vm.runInNewContext('(' + formatterSource[0] + ')')
assert.equal(formatCutCount('0.00'), '0')
assert.equal(formatCutCount('9999'), '9999')
assert.equal(formatCutCount('10000'), '1W+')
assert.equal(formatCutCount('12345'), '1.2W+')
assert.equal(formatCutCount('1000000'), '100W+')
}) })
test('recharge completion always credits the ledger and expiry SQL is strict-mode safe', () => { test('recharge completion always credits the ledger and expiry SQL is strict-mode safe', () => {
@@ -239,3 +239,77 @@ test('OAuth frontend is accessible, redirect-aware, and removes retired provider
assert.doesNotMatch(signCss + mainCss, /social-login-item[^\n{]*(?:baidu|alipay)|social-login-item\.(?:baidu|alipay)/i); assert.doesNotMatch(signCss + mainCss, /social-login-item[^\n{]*(?:baidu|alipay)|social-login-item\.(?:baidu|alipay)/i);
assert.match(mainCss, /\.pay-box \.but\.baidu/); assert.match(mainCss, /\.pay-box \.but\.baidu/);
}); });
test('the social-login area drops the implicit agreement line', () => {
const shared = read('templates/parts/auth-content.php');
const userRenderer = read('src/WidgetSystem/Renderers/UserRenderer.php');
// The reference shows no agreement copy under the social buttons; only the
// registration panel keeps its own, and it stays reachable.
assert.doesNotMatch(shared, /使用社交账号登录即表示/);
assert.match(shared, /注册即表示/);
assert.match(shared, /name="agree_terms"[\s\S]*?required/);
assert.doesNotMatch(userRenderer, /使用社交账号登录即表示|我已阅读并同意/);
});
test('brand icons resolve Gitee and Google to the reference SVG sprite', () => {
const icons = read('inc/helpers/aksrt-icons.php');
const sprites = read('assets/js/runtime/svg-icon.js');
// FA5 Free ships no Gitee brand glyph and no multi-colour Google mark, so both
// resolve to the theme's own symbol sheet exactly like the reference does.
assert.match(icons, /'gitee'\s*=>\s*'zibsvg-gitee'/);
assert.match(icons, /'google'\s*=>\s*'zibsvg-google-color'/);
assert.match(icons, /'github'\s*=>\s*'fa fab fa-github'/);
assert.match(icons, /'wechat'\s*=>\s*'fa fab fa-weixin'/);
assert.match(sprites, /aksrt_svgs\['gitee'\]/);
assert.match(sprites, /aksrt_svgs\['google-color'\]/);
// Sprite values must reach the shared icon renderer instead of being wrapped
// in a bogus <i class="zibsvg-…">.
assert.match(icons, /strpos\(\$class, 'zibsvg-'\) === 0[\s\S]*?aksrt_icon_markup\(\$class, \$extra_classes\)/);
});
test('the top-nav user popover reproduces the reference quick-login block', () => {
const oauth = read('src/Auth/OAuth.php');
const header = read('inc/helpers/header/aksrt-header-navbar.php');
const links = read('inc/helpers/aksrt-links.php');
const legacyCss = read('assets/css/frontend/pages/base-1.css');
assert.match(oauth, /social-separator separator muted-3-color em09 mt10/);
assert.match(oauth, /'<div class="social_loginbar">'/);
assert.match(oauth, /'social-login-item ' \. \$type/);
assert.match(oauth, /' button-lg' : ' toggle-radius'/);
assert.match(oauth, /rel="nofollow"/);
assert.match(oauth, /qrcode-signin/);
assert.match(oauth, /oauth_button_lg/);
// Callers append the block verbatim — no hand-rolled separator or wrapper.
assert.match(header, /render_social_buttons\(__\('快速登录', 'aksrt'\)\)/);
assert.doesNotMatch(header, /social-separator|social_loginbar/);
assert.match(links, /render_social_buttons\(__\('使用以下方式快速登录', 'aksrt'\)\)/);
assert.doesNotMatch(links, /social_loginbar/);
// The retired .social-login-divider / .oauth-row / .oauth-btn markup and its
// stylesheet are gone.
assert.doesNotMatch(oauth, /oauth-row|oauth-btn|social-login-divider/);
assert.doesNotMatch(legacyCss, /\.oauth-row|\.oauth-btn|\.social-login-divider/);
});
test('provider URLs stay raw in the data layer and are escaped once per surface', () => {
const oauth = read('src/Auth/OAuth.php');
const shared = read('templates/parts/auth-content.php');
const userRenderer = read('src/WidgetSystem/Renderers/UserRenderer.php');
const comments = read('inc/helpers/aksrt-comments.php');
// get_auth_providers_data() hands out raw URLs, exactly like its sibling
// get_frontend_bindings_data() does. esc_url() is idempotent on its own
// output, so escaping here as well was redundant rather than broken.
assert.doesNotMatch(oauth, /'url'\s*=>\s*esc_url\(\$url\)/);
assert.match(oauth, /'url'\s*=>\s*\$url,/);
// Every consumer still escapes on output, so the rendered href is unchanged.
assert.match(shared, /esc_url\(\(string\) \(\$provider\['url'\] \?\? '#'\)\)/);
assert.match(userRenderer, /href="' \. esc_url\(\$url\)/);
assert.match(oauth, /'<a rel="nofollow" href="' \. esc_url\(\$url\)/);
assert.match(comments, /<a href="<\?php echo esc_url\(\$url\); \?\>" class="but hollow social-login-item ml10">/);
});
@@ -120,6 +120,10 @@ test('defaults, nested structures, dependencies, and excluded product families a
} }
assert.equal(headerMenuButtons.includes('nav_newposts_btns'), false) assert.equal(headerMenuButtons.includes('nav_newposts_btns'), false)
assert.match(headerMenuButtons, /aksrt_render_new_add_btns\(\['post'\], \$class, \$label\)/) assert.match(headerMenuButtons, /aksrt_render_new_add_btns\(\['post'\], \$class, \$label\)/)
// FA5 spelling of the reference '<i class="fa fa-fw fa-pencil"></i>发布'
// default: fa-pencil does not exist in the bundled FA 5.15.4 free set.
assert.match(schema, /'nav_newposts_text', '按钮文案', 'textarea', '<i class="fa fas fa-fw fa-pencil-alt"><\/i>发布'/)
assert.doesNotMatch(schema, /fa fas fa-fw fas /)
}) })
test('VIP navigation purchase switches are saved and consumed end to end', () => { test('VIP navigation purchase switches are saved and consumed end to end', () => {
@@ -136,8 +140,18 @@ test('VIP navigation purchase switches are saved and consumed end to end', () =>
}) })
test('header VIP hover cards match the legacy markup and style contract', () => { test('header VIP hover cards match the legacy markup and style contract', () => {
assert.match(headerMenuButtons, /<span class="hover-show inline-block navbar-vip-hover">/) assert.match(headerMenuButtons, /<span class="hover-show inline-block">/)
assert.match(headerMenuButtons, /<div class="hover-show-con sub-menu sub-vip-card">/) assert.match(headerMenuButtons, /<div class="sub-menu hover-show-con sub-vip-card">/)
assert.match(headerMenuButtons, /'<a class="' \. esc_attr\(\$cls\) \. ' but jb-red radius payvip-icon ml10" href="javascript:;">'/)
// The payvip surface must survive the .but compatibility layer for the top-nav
// chip too, not just the user-card CTA.
assert.match(headerCss, /\.payvip-icon\s*\{[\s\S]*?background:\s*linear-gradient\(300deg,[\s\S]*?--this-color:\s*#ece0e0;/s)
assert.match(headerMenuButtons, /<svg class="em12 mr3" aria-hidden="true"><use xlink:href="#icon-vip_1"><\/use><\/svg>'/)
assert.match(headerMenuButtons, /'<a rel="nofollow" class="newadd-btns ' \. esc_attr\(\$class\) \. ' btn-newadd"/)
assert.equal((headerMenuButtons.match(/Settings::close_sign\(\)/g) || []).length, 2)
for (const dead of ['navbar-cta', 'navbar-cta-text', 'navbar-newposts', 'navbar-vip-hover', 'newadd-btns__', 'vip-level', 'fa-gem']) {
assert.equal(headerMenuButtons.includes(dead), false, `AKSRT-only header CTA token remains: ${dead}`)
}
assert.match(vipCardRenderer, /aksrt_get_lazy_attr\('lazy_other', \$icon_url, 'img-icon'\)/) assert.match(vipCardRenderer, /aksrt_get_lazy_attr\('lazy_other', \$icon_url, 'img-icon'\)/)
assert.match(vipCardRenderer, /<div class=\"abs-center vip-baicon\"[^>]*>/) assert.match(vipCardRenderer, /<div class=\"abs-center vip-baicon\"[^>]*>/)
assert.match(vipCardRenderer, /<div class=\"vip-img abs-right\">/) assert.match(vipCardRenderer, /<div class=\"vip-img abs-right\">/)
@@ -154,8 +168,21 @@ test('header VIP hover cards match the legacy markup and style contract', () =>
assert.match(legacyMainCss, /\.sub-vip-card \.vip-card\s*\{[^}]*display:\s*inline-block;[^}]*width:\s*240px;[^}]*margin:\s*5px;/s) assert.match(legacyMainCss, /\.sub-vip-card \.vip-card\s*\{[^}]*display:\s*inline-block;[^}]*width:\s*240px;[^}]*margin:\s*5px;/s)
assert.match(legacyMainCss, /\.sub-vip-card \.vip-baicon\.abs-center\s*\{[^}]*font-size:\s*10em;[^}]*left:\s*55%;/s) assert.match(legacyMainCss, /\.sub-vip-card \.vip-baicon\.abs-center\s*\{[^}]*font-size:\s*10em;[^}]*left:\s*55%;/s)
assert.match(legacyMainCss, /\.sub-vip-card \.vip-img\s*\{[^}]*display:\s*none;/s) assert.match(legacyMainCss, /\.sub-vip-card \.vip-img\s*\{[^}]*display:\s*none;/s)
assert.match(headerCss, /\.navbar-top \.aksrt-navbar-actions \.navbar-vip-hover\s*\{[^}]*align-self:\s*stretch;[^}]*overflow:\s*visible;/s) // The hover wrapper must stay a plain block container so the legacy static
assert.match(headerCss, /\.navbar-top \.aksrt-navbar-actions \.navbar-vip-hover > \.sub-vip-card\s*\{[^}]*top:\s*100%;[^}]*right:\s*0;[^}]*left:\s*auto;[^}]*max-width:\s*calc\(100vw - 24px\);/s) // position of .sub-vip-card matches the reference theme.
assert.match(headerCss, /\.navbar-top \.navbar-but > \.hover-show\s*\{[^}]*display:\s*inline-block;/s)
assert.doesNotMatch(headerCss, /\.navbar-cta|\.navbar-vip-hover/)
assert.match(legacyMainCss, /\.navbar-top \.navbar-right \.sub-menu\s*\{[^}]*right:\s*0;/s)
})
test('user card VIP CTA matches the reference zib_get_header_payvip_icon()', () => {
assert.match(headerUserbox, /'<a class="pay-vip but radius4 payvip-icon btn-block" href="javascript:;">'/)
assert.match(headerUserbox, /<svg class="em12 mr6" aria-hidden="true"><use xlink:href="#icon-vip_1"><\/use><\/svg>'/)
assert.match(headerUserbox, /esc_html__\('开通会员 尊享会员权益', 'aksrt'\)/)
for (const dead of ['fa-gem', 'vip-level']) {
assert.equal(headerUserbox.includes(dead), false, `AKSRT-only user card CTA token remains: ${dead}`)
}
assert.doesNotMatch(headerUserbox, /<span>' \. esc_html__\('开通会员 尊享会员权益'/)
}) })
test('VIP purchase modal matches legacy desktop-center and mobile-bottom positioning', () => { test('VIP purchase modal matches legacy desktop-center and mobile-bottom positioning', () => {
@@ -322,13 +349,30 @@ test('user profile and author widgets render the reference user-card contract',
}) })
test('desktop header actions share one row without generic button masks', () => { test('desktop header actions share one row without generic button masks', () => {
assert.match(headerNavbar, /aksrt-navbar-actions navbar-form navbar-right/) assert.match(headerNavbar, /navbar-form navbar-right navbar-but/)
assert.match(headerNavbar, /navbar-form navbar-right flex ac/)
assert.match(headerNavbar, /\$fold \. \$button \. \$right_but/)
assert.match(headerNavbar, /class="aksrt-nav-icon nav-search-btn main-search-btn"/) assert.match(headerNavbar, /class="aksrt-nav-icon nav-search-btn main-search-btn"/)
assert.doesNotMatch(headerNavbar, /class="btn nav-search-btn main-search-btn"/) assert.doesNotMatch(headerNavbar, /class="btn nav-search-btn main-search-btn"/)
assert.match(headerCss, /\.navbar-top \.aksrt-navbar-actions\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;/s) assert.match(headerCss, /\.navbar-top \.aksrt-navbar-account\s*\{[^}]*display:\s*inline-flex;[^}]*align-items:\s*center;/s)
assert.match(headerCss, /\.navbar-top \.aksrt-nav-icon,[\s\S]*?background:\s*transparent !important;/) assert.match(headerCss, /\.navbar-top \.aksrt-nav-icon,[\s\S]*?background:\s*transparent !important;/)
assert.match(headerCss, /\.navbar-top \.aksrt-navbar-actions \.navbar-cta\.radius\s*\{[^}]*border-radius:\s*50px;/s) // Bootstrap's base .navbar-form rule keeps margin-top: 8px at >=768px (the
assert.match(headerCss, /\.navbar-top \.aksrt-navbar-actions \.navbar-cta > \.icon\s*\{[^}]*width:\s*1\.2em;[^}]*height:\s*1\.2em;[^}]*flex:\s*0 0 auto;/s) // override only zeroes the horizontal margins), so every right-cluster group
// starts 8px below the top of .navbar-collapse. A 34px cross-size therefore
// puts each group's midline on the navbar's 25px line, where the 50px menu row
// and the logo already sit. A 50px min-height double-counts that 8px: the
// group's content drops 8px below the line and the navbar grows from 50px to
// 58px, which also breaks the body.nav-fixed offset.
assert.doesNotMatch(headerCss, /\.navbar-top \.aksrt-navbar-account\s*\{[^}]*min-height/s)
assert.match(headerCss, /\.navbar-top \.navbar-form\.flex\.ac\s*\{[^}]*min-height:\s*34px;/s)
// The publish and VIP chips ride the legacy .but geometry verbatim; nothing in
// the header module may restate their padding, margin, radius or icon size.
// Descendant, not child: the VIP chip lives inside the hover-show wrapper.
assert.match(legacyMainCss, /\.navbar-top \.navbar-but \.but\s*\{[^}]*font-size:\s*0\.8em;[^}]*padding:\s*0\.3em 1em !important;[^}]*margin:\s*0\.4em;/s)
assert.doesNotMatch(headerCss, /\.navbar-cta|\.navbar-vip-hover/)
// No merged flex container: the three groups keep their own .navbar-form
// padding, which is what produces the reference inter-cluster spacing.
assert.doesNotMatch(headerCss, /\.aksrt-navbar-actions/)
assert.match(headerCss, /\.navbar-top \.aksrt-navbar-account > li\s*\{[^}]*position:\s*relative;/s) assert.match(headerCss, /\.navbar-top \.aksrt-navbar-account > li\s*\{[^}]*position:\s*relative;/s)
assert.match(headerCss, /\.navbar-top \.aksrt-navbar-account > li > \.sub-menu\s*\{[^}]*top:\s*calc\(100% \+ 5px\);[^}]*right:\s*0;/s) assert.match(headerCss, /\.navbar-top \.aksrt-navbar-account > li > \.sub-menu\s*\{[^}]*top:\s*calc\(100% \+ 5px\);[^}]*right:\s*0;/s)
}) })
@@ -378,10 +422,18 @@ test('header user-card membership stays on one row with a date-only expiry', ()
assert.match(headerUserbox, /aksrt_get_vip_icon\(\$user_id, 'em12 mr6', false\)/) assert.match(headerUserbox, /aksrt_get_vip_icon\(\$user_id, 'em12 mr6', false\)/)
assert.match(headerUserbox, /class="ml10 badg jb-yellow vip-expdate-tag"/) assert.match(headerUserbox, /class="ml10 badg jb-yellow vip-expdate-tag"/)
assert.match(legacyMainCss, /\.sub-user-box \.payvip-icon\s*\{[^}]*padding:\s*8px;/s) assert.match(legacyMainCss, /\.sub-user-box \.payvip-icon\s*\{[^}]*padding:\s*8px;/s)
assert.match(legacyMainCss, /\.vip-expdate-tag\s*\{[^}]*font-size:\s*0\.8em;[^}]*padding:\s*0\.1em 6px;[^}]*vertical-align:\s*0\.1em;/s)
assert.doesNotMatch(vipModalCss, /\.vip-expdate-tag\s*\{/)
assert.doesNotMatch(headerCss, /aksrt-header-payvip/) assert.doesNotMatch(headerCss, /aksrt-header-payvip/)
// The CTA wrapper is the reference padding wrapper, not a bespoke hook. // The CTA wrapper is the reference padding wrapper, not a bespoke hook.
assert.match(headerUserbox, /'<div class="mt10 em09" style="padding:2px;">'/) assert.match(headerUserbox, /'<div class="mt10 em09" style="padding:2px;">'/)
assert.doesNotMatch(headerUserbox, /aksrt-header-payvip/) assert.doesNotMatch(headerUserbox, /aksrt-header-payvip/)
// Expiry badge follows zib_get_user_vip_exp_date_text(): an expiry that has
// passed reads 会员已过期, a live one reads the bare date, 永久会员 is reserved
// for an explicit permanent membership, and no expiry data renders no badge.
assert.match(headerUserbox, /\$expires <= time\(\)/)
assert.match(headerUserbox, /empty\(\$state\['is_permanent'\]\)/)
assert.doesNotMatch(headerUserbox, /aksrt_get_vip_info/)
}) })
test('save contract rejects invalid input and round-trips canonical nested values', () => { test('save contract rejects invalid input and round-trips canonical nested values', () => {
@@ -307,8 +307,8 @@ test('after-box author card mirrors legacy user-card structure', () => {
assert.match(render, /<div class="user-desc mt10 muted-2-color em09">/); assert.match(render, /<div class="user-desc mt10 muted-2-color em09">/);
assert.doesNotMatch(render, /user-btns/); assert.doesNotMatch(render, /user-btns/);
assert.doesNotMatch(render, /发布文章.*用户中心/s); assert.doesNotMatch(render, /发布文章.*用户中心/s);
// The project ships no posts, so the stats row carries no posts chip. // The stats row carries the posts chip.
assert.doesNotMatch(identity, /tag-posts/); assert.match(identity, /tag-posts/);
assert.match(identity, /tag-comment/); assert.match(identity, /tag-comment/);
assert.match(identity, /tag-follow/); assert.match(identity, /tag-follow/);
assert.match(identity, /tag-like/); assert.match(identity, /tag-like/);
@@ -0,0 +1,155 @@
'use strict';
/**
* AksField 的 group / repeater 行身份稳定性契约。
*
* 背景:inline 展开模式用 vuedraggable 渲染行,vuedraggable 的 computeNodes 会把
* `node.key = getKey(element)`,而行 key 由 AksField 的 repeaterDragKey 提供。服务端
* 历史行没有 _id,key 来自以「对象身份」为键的 WeakMap。
*
* 关键坑:行数据是 reactive 的。updateRepeaterItem 一旦把行展开成一个新对象,渲染时
* 读到的是那个新对象的又一个 reactive 代理——身份再次改变,WeakMap 认不出它便生成
* 新 key,整行随之重挂载、输入框失焦,表现为「打一个字就退出编辑」。所以必须原地写,
* 让行身份跨渲染保持不变。本测试直接跑这两个真实函数来锁定该不变量。
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const fieldVue = fs.readFileSync(
path.join(root, 'assets', 'admin', 'src', 'components', 'form', 'AksField.vue'),
'utf8'
);
/**
* 模拟 Vue 的 reactive():每个原始对象只包一次代理并缓存,之后读多少次都返回同一个
* 代理。这正是让 WeakMap 身份在渲染与更新之间保持一致(或不一致)的原因。
*/
const proxyCache = new WeakMap();
const proxySet = new WeakSet();
function reactiveOf(value) {
if (!value || typeof value !== 'object' || proxySet.has(value)) return value;
let proxy = proxyCache.get(value);
if (!proxy) {
proxy = new Proxy(value, {
get(target, key, receiver) { return Reflect.get(target, key, receiver); },
set(target, key, next) { target[key] = next; return true; },
});
proxyCache.set(value, proxy);
proxySet.add(proxy);
}
return proxy;
}
/** 取一整行声明(WeakMap / 种子计数器都没有花括号体)。 */
function declaration(source, marker) {
const start = source.indexOf(marker);
assert.notStrictEqual(start, -1, `AksField.vue 缺少声明: ${marker}`);
const end = source.indexOf('\n', start);
return source.slice(start, end === -1 ? undefined : end);
}
/** 按花括号配平切出一个顶层函数(这些函数体内没有字符串/注释里的孤立花括号)。 */
function functionBlock(source, marker) {
const start = source.indexOf(marker);
assert.notStrictEqual(start, -1, `AksField.vue 缺少函数: ${marker}`);
const open = source.indexOf('{', start);
assert.notStrictEqual(open, -1, `函数没有函数体: ${marker}`);
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}') {
depth--;
if (depth === 0) return source.slice(start, i + 1);
}
}
assert.fail(`花括号不配平: ${marker}`);
}
const prelude = [
declaration(fieldVue, 'const dragRowKeys = new WeakMap()'),
declaration(fieldVue, 'let dragRowKeySeed = 0'),
functionBlock(fieldVue, 'function repeaterDragKey(item)'),
functionBlock(fieldVue, 'function updateRepeaterItem(index, fieldKey, value)'),
].join('\n');
/** 用真实的 props / emit 形状驱动这两个函数。 */
function makeField(rows) {
const emitted = [];
// props.modelValue 是 reactive 视图,正如真实组件收到的那样。
const props = { modelValue: rows.map(reactiveOf) };
const emit = (event, value) => emitted.push({ event, value });
const factory = new Function('props', 'emit', `${prelude}\nreturn { repeaterDragKey, updateRepeaterItem };`);
const api = factory(props, emit);
// 父组件把 emit 出来的数组写回 reactive 树,读出来时行又被包成代理。
const roundTrip = () => {
props.modelValue = emitted[emitted.length - 1].value.map(reactiveOf);
};
return { api, props, emitted, roundTrip };
}
test('连续输入期间行身份不变,drag key 保持稳定', () => {
// 服务端历史行:没有 _id,key 只能由 WeakMap 派生。
const serverRows = [
{ points: 500, level: 1, time: 30, unit: 'day' },
{ points: 5000, level: 1, time: 'Permanent' },
];
const { api, props, emitted, roundTrip } = makeField(serverRows);
const firstKey = api.repeaterDragKey(props.modelValue[0]);
const secondKey = api.repeaterDragKey(props.modelValue[1]);
assert.notStrictEqual(firstKey, secondKey, '两行必须各有一个 key');
const firstRow = props.modelValue[0];
// 模拟逐字输入:每次 emit 后父组件把新数组回填 props.modelValue。
let rows = serverRows;
for (const value of ['5', '50', '500']) {
api.updateRepeaterItem(0, 'points', value);
rows = emitted[emitted.length - 1].value;
roundTrip();
}
assert.strictEqual(rows[0].points, '500', '输入值应写进行内');
// 核心不变量:行对象身份必须跨渲染保持不变,否则 WeakMap 会发新 key。
assert.strictEqual(props.modelValue[0], firstRow, '行身份必须在编辑期间保持不变');
assert.strictEqual(
api.repeaterDragKey(props.modelValue[0]),
firstKey,
'行 key 必须在编辑期间保持稳定,否则整行重挂载、输入框失焦'
);
assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), secondKey, '未编辑的行 key 不应变');
});
test('emit 出去的是新数组,父组件仍会重渲染', () => {
const { api, props, emitted, roundTrip } = makeField([{ points: 500 }]);
const before = props.modelValue;
api.updateRepeaterItem(0, 'points', '9');
const after = emitted[emitted.length - 1].value;
assert.notStrictEqual(after, before, '必须发出新数组,否则父组件不会重渲染');
roundTrip();
assert.strictEqual(props.modelValue[0].points, '9');
});
test('行不是对象时退化为新建,不抛错', () => {
const { api, props, roundTrip } = makeField([]);
api.updateRepeaterItem(0, 'points', '7');
roundTrip();
assert.strictEqual(props.modelValue.length, 1);
assert.strictEqual(props.modelValue[0].points, '7');
});
test('带 _id 的行仍以 _id 作为 key', () => {
const rows = [{ _id: 'rpt_1', points: 100 }, { _id: 'rpt_2', points: 200 }];
const { api, props } = makeField(rows);
assert.strictEqual(api.repeaterDragKey(props.modelValue[0]), 'rpt_1');
assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), 'rpt_2');
});
test('新增行获得独立 key,不与既有行冲突', () => {
const { api, props } = makeField([{ points: 500 }, { points: 5000 }]);
const keys = new Set(props.modelValue.map((row) => api.repeaterDragKey(row)));
assert.strictEqual(keys.size, 2);
assert.ok(!keys.has(api.repeaterDragKey({ points: 0 })), '新行必须拿到全新 key');
});
@@ -379,8 +379,6 @@ test('front submit actions use direct fixed-id notyf flows', () => {
'aksrt-front-submit-delete-draft-toast', 'aksrt-front-submit-delete-draft-toast',
'aksrt-front-submit-resource-upload-toast', 'aksrt-front-submit-resource-upload-toast',
'aksrt-front-submit-editor-media-toast', 'aksrt-front-submit-editor-media-toast',
'aksrt-front-submit-ai-tags-toast',
'aksrt-front-submit-ai-summary-toast',
].forEach((id) => assert.match(source, new RegExp("['\"]" + id + "['\"]"))); ].forEach((id) => assert.match(source, new RegExp("['\"]" + id + "['\"]")));
assert.match(submit, /submit: function[\s\S]*?notyf\([^;]+?'load'[^;]+?toastId[\s\S]*?notyf\([^;]+?'success'[^;]+?toastId[\s\S]*?notyf\([^;]+?'danger'[^;]+?toastId/); assert.match(submit, /submit: function[\s\S]*?notyf\([^;]+?'load'[^;]+?toastId[\s\S]*?notyf\([^;]+?'success'[^;]+?toastId[\s\S]*?notyf\([^;]+?'danger'[^;]+?toastId/);
@@ -398,8 +396,6 @@ test('front submit actions use direct fixed-id notyf flows', () => {
// 付费下载附件选择后仅补一条成功确认,上传过程通知属于选择器。 // 付费下载附件选择后仅补一条成功确认,上传过程通知属于选择器。
assert.match(downloads, /AksrtSubmitMedia\.open\([\s\S]*?'success', 5, 'aksrt-front-submit-resource-upload-toast'/); assert.match(downloads, /AksrtSubmitMedia\.open\([\s\S]*?'success', 5, 'aksrt-front-submit-resource-upload-toast'/);
assert.match(editor, /aksrt-front-submit-editor-media-toast[\s\S]*?'load'[\s\S]*?'success'[\s\S]*?'danger'/); assert.match(editor, /aksrt-front-submit-editor-media-toast[\s\S]*?'load'[\s\S]*?'success'[\s\S]*?'danger'/);
assert.match(submit, /aksrt-front-submit-ai-tags-toast[\s\S]*?aksrt-front-submit-ai-summary-toast[\s\S]*?'load'[\s\S]*?'success'[\s\S]*?'danger'/);
assert.match(submit, /needContent[^;]+?'warning'/);
assert.doesNotMatch(submit, /\b(?:window\.)?alert\s*\(/); assert.doesNotMatch(submit, /\b(?:window\.)?alert\s*\(/);
assert.match(page, /'aksrt-front-submit',[\s\S]*?'\/assets\/js\/front-submit\.js',[\s\S]*?\[[^\]]*'loader_js'[^\]]*\]/); assert.match(page, /'aksrt-front-submit',[\s\S]*?'\/assets\/js\/front-submit\.js',[\s\S]*?\[[^\]]*'loader_js'[^\]]*\]/);
@@ -0,0 +1,100 @@
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const root = path.resolve(__dirname, '..', '..')
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
// Reference theme (zibll css/main.css): .jb-yellow sets --this-bg-b: #f59f54 and
// --this-bg: linear-gradient(135deg, #f59f54 10%, #ff6922 100%). build/public.min.css
// is enqueued after main.min.css, so the legacy .jb-* compat layer in core/buttons.css
// overrides main.css there; the two must agree or every .jb-yellow surface (the
// user-card check-in badge first of all) paints the wrong gradient.
const referenceGradient = /--this-bg:\s*linear-gradient\(135deg,\s*#f59f54 10%,\s*#ff6922 100%\)/
const staleHexes = /#f99d4d|#f7631d/
test('buttons.css legacy .jb-yellow gradient matches the reference theme', () => {
const buttons = read('assets/css/core/buttons.css')
const start = buttons.indexOf('.jb-yellow {')
assert.notEqual(start, -1, 'missing the legacy .jb-yellow rule')
const block = buttons.slice(start, buttons.indexOf('}', start))
assert.match(block, referenceGradient)
assert.doesNotMatch(block, staleHexes)
})
test('the built public bundle carries the reference .jb-yellow gradient', () => {
const bundle = read('assets/css/build/public.min.css')
assert.match(
bundle,
/\.jb-yellow\{--this-bg: linear-gradient\(135deg, #f59f54 10%, #ff6922 100%\)\}/
)
assert.doesNotMatch(
bundle,
/\.jb-yellow\{--this-bg: linear-gradient\(135deg, #f99d4d 10%, #f7631d 100%\)\}/
)
})
test('main.css still owns the reference .jb-yellow gradient', () => {
const main = read('assets/css/main.css')
assert.match(main, referenceGradient)
assert.doesNotMatch(main, staleHexes)
})
test('the badge typography is pinned to the reference bootstrap body values', () => {
const main = read('assets/css/main.css')
const start = main.indexOf('.user-card .card-content .img-badge')
assert.notEqual(start, -1, 'missing the user-card img-badge adaptation rule')
const block = main.slice(start, main.indexOf('}', start))
assert.match(block, /line-height:\s*1\.42857143/)
// Reference body font stack (zibll bootstrap.min.css; zibll main.css does not
// override it), while AKSRT's reset imposes the modern --font-body stack.
assert.match(block, /font-family:\s*"Helvetica Neue", Helvetica, Arial, sans-serif/)
// The AKSRT html reset forces antialiased + optimizeLegibility; the badge
// restores the browser defaults the reference renders with.
assert.match(block, /-webkit-font-smoothing:\s*auto/)
assert.match(block, /text-rendering:\s*auto/)
const min = read('assets/css/main.min.css')
assert.match(
min,
/\.user-card \.card-content \.img-badge\{line-height:1\.42857143;font-family:Helvetica Neue,Helvetica,Arial,sans-serif;-webkit-font-smoothing:auto;text-rendering:auto\}/
)
})
test('the sidebar widget link color does not recolor the check-in badge', () => {
// .widget a (sidebar.css, shipped in the homepage bundle) out-specifies the
// .jb-* color group, so the badge would paint --color-ink-2 instead of the
// white --this-color; the established .but restoration must cover img-badge.
const sidebar = read('assets/css/frontend/sidebar.css')
const start = sidebar.indexOf('.widget a.but')
assert.notEqual(start, -1, 'missing the .widget a color restoration')
const block = sidebar.slice(start, sidebar.indexOf('}', start))
assert.match(block, /\.widget a\.but,/)
assert.match(block, /\.widget a\.img-badge/)
assert.match(block, /color:\s*var\(--this-color/)
const bundle = read('assets/css/build/homepage.min.css')
assert.match(bundle, /\.widget a\.but,\.widget a\.img-badge\{color:var\(--this-color, #fff\)\}/)
})
test('the user-card check-in badge renders without the dead is-done class and bare span', () => {
const userbox = read('inc/helpers/aksrt-header-userbox.php')
const fnStart = userbox.indexOf('function aksrt_header_user_checkin_btn')
assert.notEqual(fnStart, -1, 'missing aksrt_header_user_checkin_btn')
const fnEnd = userbox.indexOf('if (!function_exists', fnStart)
assert.notEqual(fnEnd, -1)
const fn = userbox.slice(fnStart, fnEnd)
// Signed card badge: the badge class passes through untouched and the done
// label is bare text after the icon, matching the reference markup.
assert.match(fn, /aksrt_get_user_checkin_details_link\(\s*\$class,/)
assert.doesNotMatch(fn, /\$class \. ' is-done'/)
assert.doesNotMatch(fn, /'<span>'/)
// The idle badge keeps the data-signin-label hook the sign-in controller
// uses to swap labels in place.
assert.match(fn, /<span data-signin-label>/)
})
@@ -23,9 +23,13 @@ test('user and author widgets render the reference state contract', () => {
assert.match(data.guest, /signup-loader ml10 but jb-yellow padding-lg/) assert.match(data.guest, /signup-loader ml10 but jb-yellow padding-lg/)
assert.match(data.guest, /social-separator separator muted-3-color em09 mt20 mb20/) assert.match(data.guest, /social-separator separator muted-3-color em09 mt20 mb20/)
assert.match(data.guest, /social_loginbar/) assert.match(data.guest, /social_loginbar/)
assert.match(data.guest, /class="social-login-item github"/) assert.match(data.guest, /class="social-login-item github toggle-radius"/)
assert.match(data.guest, /class="social-login-item qq" data-provider="clogin_qq"/) assert.match(data.guest, /class="social-login-item qq toggle-radius" data-provider="clogin_qq"/)
assert.match(data.guest, /title="GitHub登录"/)
assert.doesNotMatch(data.guest, /aksrt-widget|aksrt-panel-wrap|user-card__auth-btns|oauth-row|oauth-btn/) assert.doesNotMatch(data.guest, /aksrt-widget|aksrt-panel-wrap|user-card__auth-btns|oauth-row|oauth-btn/)
// The social-login area carries no agreement copy; the reference guest card
// only shows the separator, the .social_loginbar and the provider buttons.
assert.doesNotMatch(data.guest, /我已阅读并同意|使用社交账号登录即表示|注册即表示/)
assert.match(data.guest_no_registration, /signin-loader but jb-blue padding-lg/) assert.match(data.guest_no_registration, /signin-loader but jb-blue padding-lg/)
assert.doesNotMatch(data.guest_no_registration, /signup-loader/) assert.doesNotMatch(data.guest_no_registration, /signup-loader/)
@@ -0,0 +1,132 @@
/**
* 用户中心左侧栏图标契约(参考主题 1:1 对齐)。
*
* 硬约束:
* 1) 「我的服务 / 功能设置」两个网格使用参考主题的 icon-but-box / user-icon-but-box
* 外壳,图标全部来自主题内置 SVG sprite(aksrt_icon_markup('zibsvg-*')),
* 不再出现 Font Awesome 字形。
* 2) 侧栏其余部件(余额/积分卡、创作/推广卡、VIP 入口、抽屉触发器)同样只使用
* sprite 图标,箭头统一为 zibsvg-right,保证侧栏只剩一种图标族。
* 3) 旧的 uc-service-grid 外壳、色调底色与计数角标必须已彻底移除,不得残留。
*/
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const sidebarDir = 'inc/helpers/user-center-renderers/';
const sidebarFiles = [
'sidebar-nav.php',
'sidebar-commerce.php',
'sidebar-creator.php',
'sidebar-vip.php',
'profile-sidebar.php',
];
test('服务/设置网格使用参考主题 icon-but-box 外壳与 sprite 图标', () => {
const nav = read(sidebarDir + 'sidebar-nav.php');
assert.match(nav, /class="flex ac hh text-center icon-but-box user-icon-but-box"/);
assert.match(nav, /<div class="em16"><\?php echo \$icon_html;/);
assert.match(nav, /<div class="px12 muted-color mt3">/);
assert.match(nav, /aksrt_icon_markup\('zibsvg-' \. \(string\) \$item\['icon'\]\)/);
// 参考主题选型:我的服务 = trend-color / user-auth / order-color。
assert.match(nav, /'icon'\s+=>\s+'trend-color'/);
assert.match(nav, /'icon'\s+=>\s+'user-auth'/);
assert.match(nav, /'icon'\s+=>\s+'order-color'/);
// 功能设置 = msg-color / user-color / security-color。
assert.match(nav, /'icon'\s+=>\s+'msg-color'/);
assert.match(nav, /'icon'\s+=>\s+'user-color'/);
assert.match(nav, /'icon'\s+=>\s+'security-color'/);
// 每项都带 tab,保证 icon-but-* 类名完整且选中态可同步。
for (const tab of ['level', 'auth', 'orders', 'msg', 'profile', 'settings']) {
assert.match(nav, new RegExp(`'tab'\\s+=>\\s+'${tab}'`), `missing tab key: ${tab}`);
}
// AKSRT 的跳转契约保留 <a href> + data-tab,不搬参考主题的 <item data-onclick>。
assert.match(nav, /<a class="<\?php echo esc_attr\(\$item_class\);/);
assert.match(nav, /data-tab="<\?php echo esc_attr\(\$tab\); \?>/);
assert.doesNotMatch(nav, /data-onclick/);
});
test('侧栏五个部件均不再出现 Font Awesome 字形', () => {
for (const file of sidebarFiles) {
const source = read(sidebarDir + file);
assert.doesNotMatch(source, /fa\s+fas\s+fa-/, `${file} still renders a Font Awesome glyph`);
assert.doesNotMatch(source, /<i class="fa/, `${file} still renders a Font Awesome <i>`);
}
});
test('其余侧栏部件按参考主题选型替换为 sprite 图标', () => {
const commerce = read(sidebarDir + 'sidebar-commerce.php');
assert.match(commerce, /aksrt_icon_markup\('zibsvg-money-color-2'\)/);
assert.match(commerce, /aksrt_icon_markup\('zibsvg-points-color'\)/);
assert.match(commerce, /aksrt_icon_markup\('zibsvg-right'\)/);
const creator = read(sidebarDir + 'sidebar-creator.php');
assert.match(creator, /aksrt_icon_markup\('zibsvg-merchant-color'\)/);
assert.match(creator, /aksrt_icon_markup\('zibsvg-money-color'\)/);
assert.match(creator, /aksrt_icon_markup\('zibsvg-right'\)/);
const vip = read(sidebarDir + 'sidebar-vip.php');
assert.match(vip, /aksrt_icon_markup\('zibsvg-vip_1'\)/);
assert.match(vip, /aksrt_icon_markup\('zibsvg-right', 'uc-sidebar-vip__arrow'\)/);
const profile = read(sidebarDir + 'profile-sidebar.php');
assert.match(profile, /aksrt_icon_markup\('zibsvg-menu'\)/);
});
test('旧 uc-service-grid 外壳与色调底色已彻底移除', () => {
const shell = read('assets/css/routes/user-center/shell.css');
assert.doesNotMatch(shell, /uc-service-grid/);
for (const file of sidebarFiles) {
assert.doesNotMatch(read(sidebarDir + file), /uc-service-grid/);
}
// 计数角标与 tone 键随色调 CSS 一并成为死代码。
assert.doesNotMatch(shell, /uc-service-grid__icon small/);
assert.doesNotMatch(read(sidebarDir + 'sidebar-nav.php'), /\$item\['count'\]/);
assert.doesNotMatch(read(sidebarDir + 'sidebar-nav.php'), /'tone'/);
// 参考主题网格尺寸落地。
assert.match(shell, /\.user-icon-but-box > a \{[^}]*width: calc\(25% - 10px\)/s);
assert.match(shell, /\.user-icon-but-box > a \{[^}]*min-width: 50px/s);
assert.match(shell, /\.user-icon-but-box > a\.is-active/);
// 卡片上的 <i> 已换成 <svg>,元素选择器需覆盖两者。
assert.match(shell, /\.uc-summary-card__body > svg/);
assert.match(shell, /\.uc-center-card > svg/);
});
test('所用 sprite key 均在前端 sprite 中定义', () => {
const sprite = read('assets/js/runtime/svg-icon.js');
const keys = [
'trend-color', 'user-auth', 'order-color', 'msg-color', 'user-color', 'security-color',
'money-color-2', 'points-color', 'merchant-color', 'money-color', 'vip_1', 'menu', 'right',
];
for (const key of keys) {
assert.match(sprite, new RegExp(`aksrt_svgs\\['${key}'\\]\\s*=`), `sprite key missing: ${key}`);
}
});
test('导航栏用户浮层的余额/积分磁贴与侧栏使用同一组 sprite key', () => {
const nav = read(sidebarDir + 'sidebar-commerce.php');
const wallet = read('inc/helpers/aksrt-header-userbox.php');
assert.match(wallet, /aksrt_icon_markup\('zibsvg-money-color-2', 'em14'\)/);
assert.match(wallet, /aksrt_icon_markup\('zibsvg-points-color', 'em14'\)/);
assert.match(nav, /aksrt_icon_markup\('zibsvg-money-color-2'\)/);
assert.match(nav, /aksrt_icon_markup\('zibsvg-points-color'\)/);
// 磁贴箭头沿用参考主题的 fa-angle-right,不做多余替换。
assert.match(wallet, /fa fas fa-angle-right em12/);
assert.doesNotMatch(wallet, /fa fas fa-money-bill-wave/);
assert.doesNotMatch(wallet, /fa fas fa-coins/);
});
@@ -144,7 +144,6 @@ test('retired admin pages, schemas, APIs, and metadata chains are absent', () =>
'assets/admin/src/constants/sections/extensions.js', 'assets/admin/src/constants/sections/extensions.js',
'assets/admin/src/router/component-loaders/users.js', 'assets/admin/src/router/component-loaders/users.js',
'assets/admin/src/router/component-loaders/tools.js', 'assets/admin/src/router/component-loaders/tools.js',
'assets/admin/src/router/component-loaders/extensions.js',
'assets/admin/src/api/modules/tools.js', 'assets/admin/src/api/modules/tools.js',
'assets/admin/src/schema/sections/tools/index.js', 'assets/admin/src/schema/sections/tools/index.js',
'src/Admin/Schema/SchemaTools.php', 'src/Admin/Schema/SchemaTools.php',
@@ -0,0 +1,208 @@
/**
* VIP 后台设置弹窗文案字段契约(对照参考主题 zibll 修正)。
*
* 硬约束:
* 1) 弹窗文案字段更名为「会员介绍」(pay_user_vip_desc, input) 与
* 「开通会员更多内容」(pay_user_vip_more, textarea rows 4 +
* sanitize_type html),并从「会员等级」组移到分区末尾的
* 「界面文案」(modal-copy) 独立分组;
* 2) 文案默认值对齐 zibll(黄金会员/钻石会员、尊享专属特权、权益四项、
* 会员介绍与开通会员更多内容默认值),SPA schema / PHP schema /
* PaymentRuntime 回退三处一致——Settings::get_all() 不合并
* schema 默认值,运行时回退即实际默认值;
* 3) vip_renew_desc / vip_upgrade_desc 为参考主题的一句话简介字段,
* 两份 schema 与 PaymentRuntime 均已定义,并由用户中心 /user/vip/
* 档位卡渲染消费(vip-tab.php 按续费/升级动作取对应简介);
* 4) 权益与卡密说明字段保存时保留 HTML(sanitize_type html),
* 不再被 sanitize_textarea_field 剥掉标签。
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..', '..');
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
const vipSchemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'vip.js');
const schemaPhp = read('src', 'Admin', 'Schema', 'SchemaPayment.php');
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
const rechargeJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'recharge.js');
const vipTabPhp = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
const modalSource = read('src', 'Content', 'VipServiceModal.php');
const payVipJs = read('assets', 'js', 'pay-vip.js');
const DESC_DEFAULT = '开通VIP会员,享受会员专属折扣以及多项特权';
const MORE_DEFAULT = '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>';
const EQUITY_DEFAULT = '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>';
// 取两个标记之间的源码段(分组切片,避免断言误伤其它功能的同名键)。
function slice(source, from, to) {
const start = source.indexOf(from);
assert.notStrictEqual(start, -1, `missing ${from}`);
const end = source.indexOf(to, start + from.length);
return source.slice(start, end === -1 ? undefined : end);
}
test('弹窗文案字段更名为会员介绍/开通会员更多内容并改为正确类型', () => {
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp]]) {
assert.ok(!src.includes('弹窗顶部会员说明') && !src.includes('弹窗底部补充说明 HTML'), `${name} 旧名称应已移除`);
assert.ok(src.includes('会员介绍') && src.includes('开通会员更多内容'), `${name} 应含新名称`);
}
// 会员介绍为单行 input(zibll text「一句话简介」);开通会员更多内容为 rows 4 的 HTML textarea。
assert.match(
vipSchemaJs,
/key: 'pay_user_vip_desc',\s*\n\s*label: '会员介绍',\s*\n\s*type: 'input',\s*\n\s*default: '开通VIP会员,享受会员专属折扣以及多项特权'/
);
assert.match(
vipSchemaJs,
/key: 'pay_user_vip_more',\s*\n\s*label: '开通会员更多内容',\s*\n\s*type: 'textarea',\s*\n\s*sanitize_type: 'html'[\s\S]*?rows: 4/
);
assert.match(
schemaPhp,
/'key' => 'pay_user_vip_desc', 'label' => '会员介绍', 'type' => 'input', 'default' => '开通VIP会员,享受会员专属折扣以及多项特权'/
);
assert.match(
schemaPhp,
/'key' => 'pay_user_vip_more', 'label' => '开通会员更多内容', 'type' => 'textarea', 'sanitize_type' => 'html'[\s\S]*?'rows' => 4/
);
// 帮助文本对齐 zibll 的 desc。
assert.ok(vipSchemaJs.includes('显示在开通界面顶部一句话简介,可以为会员权益简介或者活动介绍'));
assert.ok(vipSchemaJs.includes('显示在开通界面底部位置,可以为提醒事项、用户协议等,支持HTML代码'));
});
test('两个文案字段位于分区末尾的界面文案分组且不再属于会员等级组', () => {
const modalJs = vipSchemaJs.indexOf("key: 'modal-copy'");
const modalPhp = schemaPhp.indexOf("'key' => 'modal-copy'");
assert.notStrictEqual(modalJs, -1, 'vip.js 应含 modal-copy 分组');
assert.notStrictEqual(modalPhp, -1, 'SchemaPayment.php 应含 modal-copy 分组');
assert.ok(modalJs > vipSchemaJs.indexOf("key: 'points-exchange'"), 'vip.js modal-copy 应在分区末尾');
assert.ok(modalPhp > schemaPhp.indexOf("'key' => 'points-exchange'"), 'SchemaPayment.php modal-copy 应在分区末尾');
// modal-copy 之后不再出现其它分组/字段 key。
// modal-copy 之后(至分区末尾)不再出现其它分组/字段 key;dependsOn 引用的等级开关除外。
assert.doesNotMatch(vipSchemaJs.slice(modalJs), /key: '(?!modal-copy|pay_user_vip_desc|pay_user_vip_more|pay_user_vip_1_s|pay_user_vip_2_s)/);
const phpTail = slice(schemaPhp, "'key' => 'modal-copy'", "'revenue'");
assert.doesNotMatch(phpTail, /'key' => '(?!modal-copy|pay_user_vip_desc|pay_user_vip_more|pay_user_vip_1_s|pay_user_vip_2_s)/);
// 「会员等级」组不再包含这两个字段。
assert.doesNotMatch(slice(vipSchemaJs, "key: 'basic'", "key: 'benefit'"), /pay_user_vip_desc|pay_user_vip_more/);
assert.doesNotMatch(slice(schemaPhp, "'key' => 'basic'", "'key' => 'benefit'"), /pay_user_vip_desc|pay_user_vip_more/);
// 「会员等级」组只留导航按钮与两个等级开关;等级身份字段全部搬进
// 「会员购买选项」的等级 Tab(名称/简介/权益/图标)。
const basicJs = slice(vipSchemaJs, "key: 'basic'", "key: 'benefit'");
const basicPhp = slice(schemaPhp, "'key' => 'basic'", "'key' => 'benefit'");
for (const key of ['pay_user_vip_1_name', 'pay_user_vip_2_name', 'pay_user_vip_1_desc', 'pay_user_vip_2_desc', 'vip_1_img_icon', 'vip_2_img_icon']) {
assert.ok(!basicJs.includes(key), `vip.js 会员等级组不应再含 ${key}`);
assert.ok(!basicPhp.includes(key), `SchemaPayment.php 会员等级组不应再含 ${key}`);
}
assert.ok(basicJs.includes('pay_user_vip_1_s') && basicJs.includes('pay_user_vip_2_s'), 'vip.js 会员等级组应保留两个等级开关');
assert.ok(basicPhp.includes("'key' => 'pay_user_vip_1_s'") && basicPhp.includes("'key' => 'pay_user_vip_2_s'"), 'SchemaPayment.php 会员等级组应保留两个等级开关');
// 二级会员先于一级会员开启时的危险提示(参考主题同款)。
assert.ok(basicJs.includes('vip_level_order_notice') && basicPhp.includes('vip_level_order_notice'));
// 依赖:任一等级启用时显示(zibll pay_user_vip_1_s 依赖的双等级适配)。
assert.ok(
vipSchemaJs.includes("dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] }"),
'vip.js 应为 any 双等级依赖'
);
assert.ok(
schemaPhp.includes("'dependsOn' => ['any' => [['key' => 'pay_user_vip_1_s', 'value' => true], ['key' => 'pay_user_vip_2_s', 'value' => true]]]"),
'SchemaPayment.php 应为 any 双等级依赖'
);
});
test('续费/升级介绍字段存在且被档位卡消费', () => {
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp]]) {
assert.ok(src.includes('vip_renew_desc'), `${name} 应含 vip_renew_desc`);
assert.ok(src.includes('vip_upgrade_desc'), `${name} 应含 vip_upgrade_desc`);
}
// 运行时回退默认值(get_all 不合并 schema 默认值,回退即实际默认值)。
assert.ok(paymentRuntime.includes("$settings['vip_renew_desc'] ?? '立即续费会员,畅享VIP权益'"));
assert.ok(paymentRuntime.includes("$settings['vip_upgrade_desc'] ?? '升级VIP会员,享更多会员权益'"));
// 消费方:/user/vip/ 档位卡按动作取续费/升级简介,不得回退成死配置。
assert.ok(vipTabPhp.includes("$vip_settings['renew']['desc']"));
assert.ok(vipTabPhp.includes("$vip_settings['upgrade']['desc']"));
assert.match(vipTabPhp, /\$card_action = 'renew'/);
assert.match(vipTabPhp, /\$card_action = 'upgrade'/);
});
test('文案默认值三处一致并对齐 zibll', () => {
// SPA defaults 块与字段级 default。
assert.ok(vipSchemaJs.includes("pay_user_vip_1_name: '黄金会员'"));
assert.ok(vipSchemaJs.includes("pay_user_vip_2_name: '钻石会员'"));
assert.ok(vipSchemaJs.includes("pay_user_vip_1_desc: '尊享专属特权'"));
assert.ok(vipSchemaJs.includes("pay_user_vip_2_desc: '尊享专属特权'"));
assert.ok(vipSchemaJs.includes(`pay_user_vip_desc: '${DESC_DEFAULT}'`));
assert.ok(vipSchemaJs.includes(`pay_user_vip_more: '${MORE_DEFAULT}'`));
// 权益默认值收敛为常量,两份 schema 与运行时共用同一份字面量。
assert.ok(vipSchemaJs.includes(`const VIP_EQUITY_DEFAULT = '${EQUITY_DEFAULT}'`));
assert.ok(vipSchemaJs.includes('default: VIP_EQUITY_DEFAULT'));
// PHP schema 字段 default。
assert.ok(schemaPhp.includes("'default' => $level === 1 ? '黄金会员' : '钻石会员'"));
assert.ok(schemaPhp.includes(EQUITY_DEFAULT));
assert.ok(schemaPhp.includes(`'default' => '${DESC_DEFAULT}'`));
assert.ok(schemaPhp.includes(`'default' => '${MORE_DEFAULT}'`));
// PaymentRuntime 运行时回退(get_all 不合并 schema 默认值,回退即实际默认值)。
assert.ok(paymentRuntime.includes(`$desc = sanitize_text_field((string) ($settings['pay_user_vip_desc'] ?? __('${DESC_DEFAULT}', 'aksrt')))`));
assert.ok(paymentRuntime.includes(`$more = (string) ($settings['pay_user_vip_more'] ?? '${MORE_DEFAULT}')`));
assert.ok(paymentRuntime.includes("$name1 = __('黄金会员', 'aksrt');"));
assert.ok(paymentRuntime.includes("$name2 = __('钻石会员', 'aksrt');"));
assert.ok(paymentRuntime.includes("$settings['pay_user_vip_1_desc'] ?? __('尊享专属特权', 'aksrt')"));
assert.ok(paymentRuntime.includes("$settings['pay_user_vip_2_desc'] ?? __('尊享专属特权', 'aksrt')"));
assert.ok(paymentRuntime.includes(EQUITY_DEFAULT));
// 旧默认值不再出现在任何一处。
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp], ['PaymentRuntime.php', paymentRuntime]]) {
assert.ok(!/尊享一级会员特权|尊享二级会员特权|免费下载部分资源|更高免费下载额度/.test(src), `${name} 旧默认值应已移除`);
}
});
test('权益与卡密说明字段保存时保留 HTML', () => {
// 权益字段已并入「会员购买选项」的等级 Tab,标签统一为「会员权益 HTML」。
assert.ok(vipSchemaJs.includes("key: `pay_user_vip_${level}_equity`, label: '会员权益 HTML', type: 'textarea', sanitize_type: 'html'"));
assert.ok(schemaPhp.includes("['key' => 'pay_user_vip_' . $level . '_equity', 'label' => '会员权益 HTML', 'type' => 'textarea', 'sanitize_type' => 'html'"));
assert.ok(rechargeJs.includes("key: 'pay_vip_pass_charge_desc', label: '卡密兑换会员说明', type: 'textarea', sanitize_type: 'html'"));
assert.ok(schemaPhp.includes("'key' => 'pay_vip_pass_charge_desc', 'label' => '卡密兑换会员说明', 'type' => 'textarea', 'sanitize_type' => 'html'"));
});
test('会员介绍运行时按单行文本净化', () => {
// 只收敛 VIP 设置函数:充值等其它设置的 $desc 与本文无关。
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
assert.ok(vipSettings.includes("$desc = sanitize_text_field((string) ($settings['pay_user_vip_desc']"));
assert.ok(!vipSettings.includes('$desc = sanitize_textarea_field'));
});
test('开通会员更多内容只在弹窗内渲染,不裸露在用户中心页面', () => {
// 参考主题 zibpay_user_page_tab_content_vip 只渲染 pay_user_vip_desc;
// pay_user_vip_more 仅出现在两个弹窗函数(购买/续费弹窗)中。
assert.ok(!vipTabPhp.includes("vip_settings['more']"), '用户中心 VIP 页不应渲染 more');
assert.ok(!vipTabPhp.includes('muted-box padding-10 mini-radius mt15'), '用户中心 VIP 页不应有 more 提示盒');
// 弹窗侧保持:视图模型透传 more + pay-vip.js 右列渲染。
assert.match(modalSource, /'more'\s+=>/);
assert.match(payVipJs, /catalog\.more/);
});
test('more 与卡密说明输出按 post 级 HTML 放行', () => {
// 保存端已 wp_kses_post,输出端不能再窄白名单/textarea 净化二次剥标签。
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
assert.match(vipSettings, /'more'\s+=> wp_kses_post\(\$more\)/);
assert.doesNotMatch(vipSettings, /\$allowed_equity \+ \['div'/);
const rechargeSettings = slice(paymentRuntime, 'public static function get_payment_recharge_settings', 'public static function get_payment_vip_settings');
assert.match(rechargeSettings, /'desc'\s+=> wp_kses_post\(\(string\) \(\$settings\['pay_vip_pass_charge_desc'\]/);
});
test('弹窗内 more 与卡密说明按 HTML 渲染而非纯文本', () => {
// node() 走 textContent 会把标签显示为字面文本;富文本字段必须走 innerHTML。
assert.match(payVipJs, /function htmlNode\(tag, className, value\) \{[\s\S]*?el\.innerHTML = text\(value\);/);
assert.ok(payVipJs.includes('htmlNode("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more)'));
assert.ok(payVipJs.includes('htmlNode("div", "muted-box muted-2-color padding-10 mb10 em09", cardPass.desc)'));
});
test('积分兑换说明按 post 级 HTML 放行并原样渲染', () => {
// 参考主题该字段 sanitize=false 且弹窗内原样拼接;AKSRT 对齐为保存/输出
// 双端 wp_kses_post + 弹窗 wp_kses_post 渲染。
assert.match(vipSchemaJs, /key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', sanitize_type: 'html'/);
assert.match(schemaPhp, /'key' => 'pay_vip_points_exchange_desc', 'label' => '兑换说明', 'type' => 'textarea', 'sanitize_type' => 'html'/);
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
assert.match(vipSettings, /'desc'\s+=> wp_kses_post\(\(string\) \(\$settings\['pay_vip_points_exchange_desc'\]/);
// 弹窗渲染端不再 esc_html 剥标签。
assert.match(modalSource, /<div class="muted-box muted-2-color padding-10 em09 mt20">' \. wp_kses_post\(\$desc_text\)/);
assert.doesNotMatch(modalSource, /esc_html\(\$desc_text\)/);
});
@@ -0,0 +1,134 @@
/**
* 会员弹窗骨架屏 / 打开提速 / help_text 清理 / 启动优化契约。
*
* 硬约束:
* 1) 会员弹窗加载占位为完整布局仿形骨架(vipSkeleton()),复用全局
* aksrt-modal-runtime__skeleton-line/-block 与 shimmer 动画,
* VIP 专属形状样式落在 assets/css/frontend/vip-modal.css;
* 2) 弹窗数据缓存 TTL 60s,悬停/聚焦入口按等级意图预热,预热与
* openRemote 共用 cacheKey 与 flight;
* 3) 弹窗底部提示句与整条 help_text 管线已移除,顶部说明 desc 保留;
* 4) 积分/钱包余额合并为一次 LedgerService::points_and_balance();
* 5) 访问统计写库移到 shutdown,wp_head 只做访客判定与 setcookie;
* 6) header-userbox 样式仅登录用户入队。
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..', '..');
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
// 取某个方法体(到下一个同类方法声明为止),避免断言误伤其它功能的同名键。
function sliceMethod(source, signature, nextSignature) {
const start = source.indexOf(signature);
assert.notStrictEqual(start, -1, `missing ${signature}`);
const end = source.indexOf(nextSignature, start + signature.length);
return source.slice(start, end === -1 ? undefined : end);
}
const vipJs = read('assets', 'js', 'pay-vip.js');
const vipCss = read('assets', 'css', 'frontend', 'vip-modal.css');
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
const modal = read('src', 'Content', 'VipServiceModal.php');
const catalogSource = read('src', 'Content', 'VipServicePurchase.php');
const ledger = read('src', 'Ledger', 'LedgerService.php');
const stats = read('src', 'Core', 'StatsTracker.php');
const scripts = read('src', 'Core', 'ScriptsPart1b.php');
test('会员弹窗使用完整布局仿形骨架屏', () => {
assert.match(vipJs, /function vipSkeleton\(\) \{/);
assert.match(vipJs, /node\("div", "aksrt-vip-modal-skeleton"\)/);
assert.match(vipJs, /skeleton\.setAttribute\("aria-busy", "true"\)/);
// 结构:用户框(头像+两行)、等级卡行、双列面板(权益+套餐区)。
assert.match(vipJs, /aksrt-vip-modal-skeleton__user/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__avatar/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__lines/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__tabs/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__tab/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__pane/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__info/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__products/);
assert.match(vipJs, /aksrt-vip-modal-skeleton__button/);
// 线条/色块复用全局骨架类。
assert.match(vipJs, /aksrt-modal-runtime__skeleton-line is-wide/);
assert.match(vipJs, /aksrt-modal-runtime__skeleton-line is-short/);
assert.match(vipJs, /aksrt-modal-runtime__skeleton-block/);
// openRemote 传入自定义骨架,不再使用纯文本占位。
assert.match(vipJs, /skeleton: vipSkeleton\(\),/);
assert.doesNotMatch(vipJs, /加载中/);
});
test('骨架样式落在 vip-modal.css 并对齐 640px 双列阈值', () => {
assert.match(vipCss, /\.aksrt-vip-modal-skeleton \{/);
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__avatar \{[\s\S]*?border-radius: 50%/);
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__tab \{[\s\S]*?flex: 1/);
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__button \{[\s\S]*?height: 38px/);
assert.match(vipCss, /animation: aksrt-modal-skeleton/);
assert.match(vipCss, /@media \(min-width: 640px\)[\s\S]*?\.aksrt-vip-modal-skeleton__pane \{[\s\S]*?grid-template-columns: 1fr 1\.2fr/);
});
test('弹窗打开提速:缓存 TTL 提升与意图预热', () => {
assert.match(vipJs, /cacheTtl: 60000,/);
// 预热与 openRemote 同一 cacheKey(method + AksrtRequest.url)。
assert.match(vipJs, /function vipModalCacheKey\(level\) \{/);
assert.match(vipJs, /"GET:" \+ window\.AksrtRequest\.url\("\/pay\/vip\/modal", \{ vip_level: level \}\)/);
assert.match(vipJs, /cache\.acquire\(vipModalCacheKey\(level\)/);
assert.match(vipJs, /ttl: 60000,/);
assert.match(vipJs, /tags: \["vip"\]/);
// 悬停/聚焦触发,绑定进 bindOnce。
assert.match(vipJs, /\["pointerenter", "focusin"\]/);
assert.match(vipJs, /function bindPrefetch\(\) \{/);
assert.match(vipJs, /document\.addEventListener\("click", onDocClick, true\);\s*bindPrefetch\(\);/);
// 每等级每页只预热一次。
assert.match(vipJs, /prefetchedLevels\[level\] = true;/);
});
test('弹窗底部提示与 help_text 管线已彻底移除', () => {
assert.doesNotMatch(vipJs, /help_text/);
// 仅收敛 VIP 设置函数:充值/网关设置的 help_text 与本文无关,保持原样。
const vipSettings = sliceMethod(
paymentRuntime,
'public static function get_payment_vip_settings',
'public static function get_payment_revenue_settings'
);
assert.doesNotMatch(vipSettings, /help_text/);
assert.doesNotMatch(vipSettings, /开通后立即生效/);
assert.doesNotMatch(modal, /help_text/);
const viewModel = sliceMethod(modal, 'public function get_modal_view_model', 'public function get_points_exchange_modal_html');
assert.doesNotMatch(viewModel, /help_text/);
assert.doesNotMatch(catalogSource, /help_text/);
// 顶部说明 desc 仍保留(后台 pay_user_vip_desc 继续生效)。
assert.match(vipJs, /catalog\.desc/);
assert.match(paymentRuntime, /'desc'\s+=> \$desc,/);
});
test('积分与钱包余额合并为一次 LedgerService 读取', () => {
assert.match(ledger, /public function points_and_balance\(int \$user_id\): array/);
// 仅收敛 get_catalog:购买/履约路径的余额读取与本文无关,保持原样。
const getCatalog = sliceMethod(catalogSource, 'public function get_catalog', 'private function find_catalog_package');
assert.match(getCatalog, /points_and_balance\(\$user_id\)/);
assert.match(getCatalog, /'user_points'\s+=> \$balances\['points'\]/);
assert.match(getCatalog, /'wallet_balance'\s+=> \$balances\['balance'\]/);
assert.doesNotMatch(getCatalog, /LedgerService::get_instance\(\)->balance\(/);
assert.doesNotMatch(getCatalog, /balance_decimal/);
});
test('访问统计写库移出渲染关键路径', () => {
assert.match(stats, /add_action\('wp_head', \[self::get_instance\(\), 'track_visit'\], 1\)/);
assert.match(stats, /add_action\('shutdown'/);
assert.match(stats, /public function record_stats\(string \$option_key, bool \$is_unique\): void/);
// wp_head 阶段只做判定与 setcookie,不再读写 option。
const trackVisit = stats.slice(stats.indexOf('public function track_visit'), stats.indexOf('public function record_stats'));
assert.doesNotMatch(trackVisit, /update_option|get_option/);
assert.match(trackVisit, /setcookie\(\$visitor_cookie/);
// 落库与月初清理收敛到 record_stats。
const recordStats = stats.slice(stats.indexOf('public function record_stats'));
assert.match(recordStats, /update_option\(\$option_key, \$stats, false\)/);
assert.match(recordStats, /cleanup_old_stats\(\)/);
});
test('header-userbox 样式仅登录用户入队', () => {
assert.match(scripts, /if \(is_user_logged_in\(\)\) \{\s*\$enqueue_module\('header-userbox'\);/);
});
@@ -0,0 +1,506 @@
/**
* VIP 后台设置结构化契约(对照参考主题 zibll 1:1)。
*
* 硬约束:
* 1) 会员商品 / 续费商品 / 升级价格 / 积分兑换明细全部为结构化 group 或
* accordion,`|` 分隔的 textarea 配置已彻底移除(不写双路/迁移);
* 2) 「会员等级」组只留导航按钮与两个等级开关,等级身份字段(显示名称/
* 简介/权益HTML/图标URL)与「会员商品」同处「会员购买选项」的等级 Tab;
* 3) 会员商品字段集恰好 6 项:执行价/原价/促销标签/标签颜色/会员有效时间/
* 时长单位;标签颜色为参考主题 b+jb 全量 16 色;
* 4) 续费价格类型含「自定义商品」,升级价格为参考主题同款三块 accordion;
* 5) tag_class 一路透传到前端套餐卡;等级 Tab 标题由显示名称动态驱动。
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { execFileSync } = require('node:child_process');
const root = path.join(__dirname, '..', '..');
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
const vipSchemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'vip.js');
const schemaPhp = read('src', 'Admin', 'Schema', 'SchemaPayment.php');
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
const packagesTrait = read('src', 'Content', 'VipServicePackages.php');
const purchaseSource = read('src', 'Content', 'VipServicePurchase.php');
const modalSource = read('src', 'Content', 'VipServiceModal.php');
const rendererVue = read('assets', 'admin', 'src', 'components', 'form', 'AksFormRenderer.vue');
const fieldVue = read('assets', 'admin', 'src', 'components', 'form', 'AksField.vue');
const payVipJs = read('assets', 'js', 'pay-vip.js');
const vipTabPhp = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
const homeCms = read('src', 'Settings', 'HomeCms.php');
const TAG_PALETTE = [
'b-red', 'b-yellow', 'b-cyan', 'b-blue', 'b-green', 'b-purple', 'b-black',
'jb-red', 'jb-pink', 'jb-yellow', 'jb-cyan', 'jb-blue', 'jb-green', 'jb-purple',
'jb-vip1', 'jb-vip2',
];
const PRODUCT_FIELDS = ['price', 'show_price', 'tag', 'tag_class', 'time', 'unit'];
const UPGRADE_FIELDS = [
'month_to_month_s', 'unit_price', 'unit_tag', 'unit_tag_class',
'jump_s', 'jump_price', 'jump_show_price', 'jump_tag', 'jump_tag_class',
'permanent_price', 'permanent_show_price', 'permanent_tag', 'permanent_tag_class',
];
const LEGACY_KEYS = [
'payment_vip_1_products',
'payment_vip_2_products',
'payment_vip_points_packages',
];
function runHarness() {
const out = execFileSync(process.env.PHP_BINARY || 'php', [
path.join(__dirname, 'vip-settings-structured-harness.php'),
], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
return JSON.parse(out);
}
/** 在 SPA schema 里按 key 递归查找字段定义。 */
function findGroup(schema, key) {
const walk = (nodes) => {
for (const node of nodes) {
if (node.key === key) return node
if (Array.isArray(node.tabs)) {
for (const tab of node.tabs) {
const hit = walk(tab.fields || [])
if (hit) return hit
}
}
if (Array.isArray(node.fields)) {
const hit = walk(node.fields)
if (hit) return hit
}
}
return null
}
return walk(schema.groups)
}
// 只切 VIP 设置函数:充值/积分设置里的 products_raw 与本文无关。
function vipSettingsSlice() {
const start = paymentRuntime.indexOf('public static function get_payment_vip_settings');
assert.notStrictEqual(start, -1, 'missing get_payment_vip_settings');
const end = paymentRuntime.indexOf('public static function get_payment_revenue_settings', start);
return paymentRuntime.slice(start, end === -1 ? undefined : end);
}
let harness;
test('PHP harness 可执行并产出结构化设置', () => {
harness = runHarness();
assert.ok(harness && harness.schema && harness.runtime, 'harness 输出不完整');
});
test('旧 `|` 分隔配置键已从两份 schema 与运行时彻底移除', () => {
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp], ['PaymentRuntime.php', paymentRuntime]]) {
for (const key of LEGACY_KEYS) {
assert.ok(!src.includes(key), `${name} 不应再含 ${key}`);
}
}
// 运行时不再暴露任何 *_raw 原始字符串。
const vipRuntime = vipSettingsSlice();
for (const key of ['products_raw', 'packages_raw']) {
assert.ok(!vipRuntime.includes(`'${key}'`), `PaymentRuntime VIP 设置不应再产出 ${key}`);
}
assert.ok(!vipRuntime.includes("'product' => sanitize_textarea_field"), 'upgrade.product 管道配置应已移除');
for (const key of LEGACY_KEYS) {
assert.ok(!vipRuntime.includes(key), `PaymentRuntime VIP 设置不应再读 ${key}`);
}
});
test('会员等级组只留导航按钮与等级开关,身份字段搬进等级 Tab', () => {
const basic = harness.schema.basic_field_keys;
assert.deepStrictEqual(basic, [
'nav_pay_vip', 'nav_user_pay_vip', 'pay_user_vip_1_s', 'vip_level_order_notice', 'pay_user_vip_2_s',
]);
const tabs = harness.schema.opt_tabs;
assert.deepStrictEqual(Object.keys(tabs), ['vip1', 'vip2', 'renew', 'upgrade']);
for (const level of [1, 2]) {
const fields = Object.keys(tabs[`vip${level}`].fields);
assert.deepStrictEqual(fields, [
`pay_user_vip_${level}_name`,
`pay_user_vip_${level}_desc`,
`pay_user_vip_${level}_equity`,
`vip_${level}_product_s`,
`vip_${level}_product`,
`vip_${level}_img_icon`,
]);
assert.strictEqual(tabs[`vip${level}`].fields[`vip_${level}_img_icon`].type, 'upload');
}
});
test('等级 Tab 标题由显示名称动态驱动', () => {
for (const level of [1, 2]) {
const tab = harness.schema.opt_tabs[`vip${level}`];
assert.strictEqual(tab.titleKey, `pay_user_vip_${level}_name`);
assert.strictEqual(tab.titleFallback, level === 1 ? '一级会员' : '二级会员');
}
// 会员权益的两个等级 Tab 同样动态。
assert.strictEqual(harness.schema.benefit_tabs.vip1.titleKey, 'pay_user_vip_1_name');
assert.strictEqual(harness.schema.benefit_tabs.vip2.titleKey, 'pay_user_vip_2_name');
// 渲染器支持 titleKey / titleFallback。
assert.match(rendererVue, /:name="tabName\(tab\)"/);
assert.match(rendererVue, /:tab="tabName\(tab\)"/);
assert.match(rendererVue, /function tabName\(tab\) \{[\s\S]*?tab\.titleKey[\s\S]*?tab\.titleFallback/);
// 手风琴:默认收起,点击标题行展开。
assert.match(fieldVue, /const repeaterCollapsible = computed\(\(\) => !!props\.schema\.collapsible\)/);
assert.match(fieldVue, /const repeaterDefaultOpen = computed\(\(\) => props\.schema\.defaultCollapsed === false\)/);
assert.match(fieldVue, /function toggleRepeaterItem\(index\)/);
assert.match(fieldVue, /v-show="!repeaterCollapsible \|\| isRepeaterItemOpen\(index\)"/);
assert.match(fieldVue, /aksrt-field__repeater-summary/);
// accordion/fieldset 的 sections 分块手风琴。
assert.match(fieldVue, /const fieldsetSections = computed\(/);
assert.match(fieldVue, /function toggleFieldsetSection\(index\)/);
assert.match(fieldVue, /aksrt-field__accordion-section-head/);
});
test('结构化分组默认收起,标题行带摘要;升级价格按三块手风琴渲染', () => {
const accordion = harness.accordion;
// 会员商品 / 续费商品 / 积分兑换明细均默认收起。
const expectedGroups = [
'vip1/vip_1_product',
'vip2/vip_2_product',
'renew/vip_1_renew_product',
'renew/vip_2_renew_product',
'points-exchange/pay_vip_points_exchange_product',
];
assert.deepStrictEqual(Object.keys(accordion.groups).sort(), [...expectedGroups].sort());
for (const key of expectedGroups) {
const cfg = accordion.groups[key];
assert.strictEqual(cfg.collapsible, true, `${key} 应开启手风琴`);
assert.strictEqual(cfg.defaultCollapsed, true, `${key} 应默认收起`);
assert.ok(cfg.listPreview && cfg.listPreview.titleKey, `${key} 应配置标题行摘要`);
assert.ok(cfg.listPreview.descKey, `${key} 应配置标题行时长摘要`);
assert.strictEqual(cfg.listPreview.descUnitKey, 'unit', `${key} 时长摘要应带单位`);
}
// 会员商品与续费商品摘要前缀为「价格:」,积分兑换为「积分:」。
assert.strictEqual(accordion.groups['vip1/vip_1_product'].listPreview.prefix, '价格:');
assert.strictEqual(accordion.groups['renew/vip_1_renew_product'].listPreview.prefix, '价格:');
assert.strictEqual(accordion.groups['points-exchange/pay_vip_points_exchange_product'].listPreview.prefix, '积分:');
assert.strictEqual(accordion.groups['points-exchange/pay_vip_points_exchange_product'].listPreview.titleKey, 'points');
// 升级价格拆成三块,且每块覆盖的字段并集恰好等于全部字段。
const sections = accordion.sections['upgrade/vip_upgrade_product'];
assert.strictEqual(sections.length, 3);
assert.deepStrictEqual(sections.map((s) => s.title), [
'月费会员升级月费会员',
'月费会员升级永久会员',
'永久会员升级永久会员',
]);
const covered = sections.flatMap((s) => s.keys).sort();
const declared = harness.schema.opt_tabs.upgrade.fields.vip_upgrade_product.fields
.map((f) => f.key)
.sort();
assert.deepStrictEqual(covered, declared, 'sections 必须覆盖升级价格的全部字段');
});
test('会员商品字段集恰好 6 项且标签颜色为参考主题 16 色', () => {
const group = harness.schema.opt_tabs.vip1.fields.vip_1_product;
assert.strictEqual(group.type, 'group');
assert.strictEqual(group.max, 8);
assert.strictEqual(group.button_title, '添加会员商品');
assert.deepStrictEqual(
group.fields.filter((f) => !f.ui_only).map((f) => f.key),
PRODUCT_FIELDS
);
assert.strictEqual(group.fields.find((f) => f.key === 'price').sanitize_type, 'money_decimal');
assert.strictEqual(group.fields.find((f) => f.key === 'show_price').sanitize_type, 'money_decimal');
assert.strictEqual(group.fields.find((f) => f.key === 'time').type, 'spinner');
assert.strictEqual(group.fields.find((f) => f.key === 'time').max, 3600);
assert.deepStrictEqual(
group.fields.find((f) => f.key === 'unit').options.map((o) => o.value),
['day', 'month']
);
const palette = group.fields.find((f) => f.key === 'tag_class');
assert.strictEqual(palette.type, 'palette');
assert.strictEqual(palette.default, 'jb-yellow');
assert.deepStrictEqual(palette.options.map((o) => o.value), TAG_PALETTE);
// 标签颜色仅在填写了促销标签时出现。
assert.deepStrictEqual(palette.dependsOn, { key: 'tag', operator: 'not_empty' });
// 时长单位在 time 为 0(永久)时隐藏。
assert.deepStrictEqual(group.fields.find((f) => f.key === 'unit').dependsOn, { key: 'time', operator: 'truthy' });
// 二级会员 Tab 复用同一字段集。
assert.deepStrictEqual(
harness.schema.opt_tabs.vip2.fields.vip_2_product.fields.filter((f) => !f.ui_only).map((f) => f.key),
PRODUCT_FIELDS
);
});
test('续费价格类型含自定义商品,折扣为折值滑块', () => {
const renew = harness.schema.opt_tabs.renew.fields;
assert.deepStrictEqual(renew.vip_renew_price_type.options.map((o) => o.value), [
'same', 'discount', 'reduce', 'customize',
]);
const discount = renew.vip_renew_discount;
assert.strictEqual(discount.type, 'slider');
assert.strictEqual(discount.sanitize_type, 'float');
assert.strictEqual(discount.min, 0.1);
assert.strictEqual(discount.max, 9.9);
assert.strictEqual(discount.step, 0.1);
assert.strictEqual(renew.vip_renew_reduce.type, 'spinner');
assert.strictEqual(renew.vip_renew_reduce.max, 500);
assert.strictEqual(renew.vip_renew_reduce.step, 5);
// 自定义续费商品组仅在 customize 时出现。
for (const level of [1, 2]) {
const group = renew[`vip_${level}_renew_product`];
assert.strictEqual(group.type, 'group');
assert.strictEqual(group.max, 8);
assert.strictEqual(group.button_title, '添加续费商品');
assert.deepStrictEqual(group.dependsOn, { key: 'vip_renew_price_type', value: 'customize' });
assert.deepStrictEqual(
group.fields.filter((f) => !f.ui_only).map((f) => f.key),
PRODUCT_FIELDS
);
}
});
test('升级价格为参考主题同款三块 accordion', () => {
const upgrade = harness.schema.opt_tabs.upgrade.fields.vip_upgrade_product;
assert.strictEqual(upgrade.type, 'accordion');
const keys = upgrade.fields.filter((f) => !f.ui_only).map((f) => f.key);
assert.deepStrictEqual(keys, UPGRADE_FIELDS);
assert.strictEqual(upgrade.fields.find((f) => f.key === 'month_to_month_s').default, true);
assert.strictEqual(upgrade.fields.find((f) => f.key === 'jump_s').default, false);
for (const key of ['unit_price', 'jump_price', 'jump_show_price', 'permanent_price', 'permanent_show_price']) {
assert.strictEqual(upgrade.fields.find((f) => f.key === key).sanitize_type, 'money_decimal', `${key} 应为 money_decimal`);
}
for (const key of ['unit_tag_class', 'jump_tag_class', 'permanent_tag_class']) {
const field = upgrade.fields.find((f) => f.key === key);
assert.strictEqual(field.type, 'palette', `${key} 应为 palette`);
assert.deepStrictEqual(field.options.map((o) => o.value), TAG_PALETTE);
}
});
test('积分兑换明细为结构化 group,时长为 time + Permanent 语义', () => {
assert.deepStrictEqual(harness.schema.points_fields, [
'pay_vip_points_exchange_s', 'pay_vip_points_exchange_product', 'pay_vip_points_exchange_desc',
]);
const group = harness.schema.points_group;
assert.strictEqual(group.type, 'group');
assert.strictEqual(group.max, 16);
assert.strictEqual(group.button_title, '添加积分兑换商品');
assert.deepStrictEqual(group.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']);
assert.strictEqual(group.fields.find((f) => f.key === 'points').type, 'number');
assert.strictEqual(group.fields.find((f) => f.key === 'level').type, 'radio');
assert.strictEqual(group.fields.find((f) => f.key === 'time').type, 'input');
// 永久时隐藏时长单位。
assert.deepStrictEqual(group.fields.find((f) => f.key === 'unit').dependsOn, { key: 'time', operator: 'neq', value: 'Permanent' });
});
test('积分兑换明细.level 枚举净化后仍能通过校验,非法值依旧报错', () => {
const { sanitize } = harness;
// level 的选项值是整数 1 / 2。默认两行提交数字即可保存,且净化后必须保持
// 整数类型:前端 n-radio 用 === 比较保存值,字符串化会让单选框失活。
assert.strictEqual(sanitize.default_rows.valid, true);
assert.deepStrictEqual(sanitize.default_rows.errors, []);
assert.deepStrictEqual(sanitize.default_rows.points_rows.map((r) => r.level), [1, 1]);
// SPA 提交字符串等级时,同样收敛回选项声明的整数。
assert.strictEqual(sanitize.string_level.valid, true);
assert.deepStrictEqual(sanitize.string_level.points_rows.map((r) => r.level), [2]);
// 收敛不得掩盖真正的非法值:level 与 unit 仍各自报出精确错误。
assert.strictEqual(sanitize.invalid_level.valid, false);
assert.deepStrictEqual(sanitize.invalid_level.errors, ['积分兑换明细[0].level has an invalid value']);
assert.strictEqual(sanitize.invalid_unit.valid, false);
assert.deepStrictEqual(sanitize.invalid_unit.errors, ['积分兑换明细[0].unit has an invalid value']);
// 字符串型枚举(palette)不受影响。
assert.strictEqual(sanitize.invalid_tag_class.valid, false);
assert.deepStrictEqual(sanitize.invalid_tag_class.errors, ['会员商品[0].tag_class has an invalid value']);
});
test('运行时归一化:默认值、价格净化、零价与非法行剔除', () => {
const defaults = harness.runtime.defaults;
// 参考主题默认两行。
assert.strictEqual(defaults.levels['1'].products.length, 2);
assert.strictEqual(defaults.levels['1'].products[0].price, '99.00');
assert.strictEqual(defaults.levels['1'].products[0].time, 3);
assert.strictEqual(defaults.levels['1'].products[0].unit, 'month');
assert.strictEqual(defaults.levels['1'].products[0].days, 90);
assert.strictEqual(defaults.levels['1'].renew_products[0].price, '69.00');
assert.strictEqual(defaults.levels['2'].renew_products[0].price, '269.00');
// 积分兑换默认两行(含 Permanent)。
assert.strictEqual(defaults.points_exchange.packages.length, 2);
assert.strictEqual(defaults.points_exchange.packages[1].is_permanent, true);
assert.strictEqual(defaults.points_exchange.packages[1].days, 0);
// 续费/升级默认值。
assert.strictEqual(defaults.renew.price_type, 'discount');
assert.strictEqual(defaults.renew.discount, 8);
assert.strictEqual(defaults.upgrade.month_to_month_s, true);
assert.strictEqual(defaults.upgrade.jump_s, false);
assert.strictEqual(defaults.upgrade.unit_price, '10.00');
assert.strictEqual(defaults.upgrade.unit_tag_class, 'jb-yellow');
assert.strictEqual(defaults.upgrade.jump_tag_class, 'jb-red');
const custom = harness.runtime.custom;
// 零价行被剔除;价格规范化为两位小数。
assert.strictEqual(custom.levels['1'].products.length, 2);
assert.strictEqual(custom.levels['1'].products[0].price, '88.80');
assert.strictEqual(custom.levels['1'].products[0].tag_class, 'jb-green');
// 非法标签颜色回落为空串。
assert.strictEqual(custom.levels['2'].products[0].tag_class, '');
// 自定义续费价格类型与折扣。
assert.strictEqual(custom.renew.price_type, 'customize');
assert.strictEqual(custom.renew.discount, 7.5);
assert.strictEqual(custom.renew.desc, '续费有礼');
assert.strictEqual(custom.levels['1'].renew_products[0].tag_class, 'b-red');
// 升级三块参数透传。
assert.strictEqual(custom.upgrade.unit_price, '12.50');
assert.strictEqual(custom.upgrade.unit_tag_class, 'jb-blue');
assert.strictEqual(custom.upgrade.jump_s, true);
assert.strictEqual(custom.upgrade.permanent_tag_class, 'jb-vip2');
// 积分兑换:非法行(积分为 0 / 等级越界 / 时长为空)被剔除。
assert.strictEqual(custom.points_exchange.packages.length, 3);
assert.strictEqual(custom.points_exchange.packages[0].days, 30);
assert.strictEqual(custom.points_exchange.packages[1].is_permanent, true);
assert.strictEqual(custom.points_exchange.packages[2].days, 90);
});
test('旧 `|` 管道存量值按新默认值回退,不会渲染成假条目', () => {
const legacy = harness.runtime.legacy;
assert.strictEqual(legacy.levels['1'].products.length, 2);
assert.strictEqual(legacy.levels['1'].products[0].price, '99.00');
assert.strictEqual(legacy.points_exchange.packages.length, 2);
assert.strictEqual(legacy.upgrade.unit_price, '10.00');
});
test('续费计价:折值/立减/自定义商品三种语义', () => {
const p = harness.pricing;
// 相同价格:不改价、不展示原价。
assert.strictEqual(p.same.price, '100.00');
assert.strictEqual(p.same.show_price, '0.00');
// 8 折:执行价 80,原价划线为 100。
assert.strictEqual(p.discount_8.price, '80.00');
assert.strictEqual(p.discount_8.show_price, '100.00');
assert.strictEqual(p.discount_8.tag, '8折优惠');
// 支持小数折。
assert.strictEqual(p.discount_7_5.price, '75.00');
assert.strictEqual(p.discount_7_5.tag, '7.5折优惠');
// 超出滑块上限时按 9.9 折钳制(参考主题 slider max)。
assert.strictEqual(p.discount_10_clamped.price, '99.00');
assert.strictEqual(p.discount_10_clamped.tag, '9.9折优惠');
// 立减 20:执行价 80。
assert.strictEqual(p.reduce_20.price, '80.00');
assert.strictEqual(p.reduce_20.show_price, '100.00');
assert.strictEqual(p.reduce_20.tag, '续费立减¥20.00');
// 自定义商品:沿用商品自身的原价、促销标签与标签颜色。
assert.strictEqual(p.customize.price, '100.00');
assert.strictEqual(p.customize.show_price, '200.00');
assert.strictEqual(p.customize.tag, '限时');
assert.strictEqual(p.customize.tag_class, 'jb-red');
// 开通动作不套用续费规则。
assert.strictEqual(p.pay.price, '100.00');
assert.strictEqual(p.pay.show_price, '0.00');
assert.strictEqual(p.pay.tag, '');
});
test('tag_class 透传到前端套餐卡与积分兑换弹窗', () => {
assert.match(payVipJs, /item\.tag_class \|\| "jb-yellow"/);
// 弹窗时长行按 time/unit 渲染,Permanent 走永久。
assert.match(modalSource, /\$is_permanent = !empty\(\$package\['is_permanent'\]\)/);
assert.match(modalSource, /\$pkg_unit === 'month' \? __\('个月', 'aksrt'\) : __\('天', 'aksrt'\)/);
// 用户中心档位卡消费续费/升级简介。
assert.match(vipTabPhp, /\$renew_desc = \(string\) \(\$vip_settings\['renew'\]\['desc'\]/);
assert.match(vipTabPhp, /\$upgrade_desc = \(string\) \(\$vip_settings\['upgrade'\]\['desc'\]/);
});
test('管道解析死代码已清理', () => {
for (const [name, src] of [['VipServicePackages.php', packagesTrait], ['VipServicePurchase.php', purchaseSource]]) {
assert.ok(!/function parse_level_products/.test(src), `${name} 不应再定义 parse_level_products`);
assert.ok(!/function parse_packages\(/.test(src), `${name} 不应再定义 parse_packages`);
assert.ok(!/function parse_upgrade_product_line/.test(src), `${name} 不应再定义 parse_upgrade_product_line`);
assert.ok(!/preg_split\('\/\\r\\n\|\\r\|\\n\/'/.test(src), `${name} 不应再按行拆分管道配置`);
}
// HomeCms 的零调用 PaymentRuntime 代理方法已删除。
for (const method of ['get_payment_vip_settings', 'get_payment_recharge_settings', 'get_withdrawal_settings']) {
assert.ok(!homeCms.includes(`function ${method}`), `HomeCms 不应再含死代理 ${method}`);
}
});
test('SPA 镜像 schema 与 PHP schema 同构', async () => {
const mod = await import(pathToFileURL(path.join(root, 'assets/admin/src/schema/sections/payment/vip.js')).href);
const schema = mod.vipSchema;
assert.deepStrictEqual(schema.groups.map((g) => g.key), [
'notice', 'basic', 'benefit', 'opt', 'points-exchange', 'modal-copy',
]);
const opt = schema.groups.find((g) => g.key === 'opt').fields[0];
assert.strictEqual(opt.type, 'tabbed');
assert.deepStrictEqual(opt.tabs.map((t) => t.key), ['vip1', 'vip2', 'renew', 'upgrade']);
for (const level of [1, 2]) {
const tab = opt.tabs[level - 1];
assert.strictEqual(tab.titleKey, `pay_user_vip_${level}_name`);
assert.deepStrictEqual(tab.fields.map((f) => f.key), [
`pay_user_vip_${level}_name`,
`pay_user_vip_${level}_desc`,
`pay_user_vip_${level}_equity`,
`vip_${level}_product_s`,
`vip_${level}_product`,
`vip_${level}_img_icon`,
]);
}
assert.deepStrictEqual(
opt.tabs[2].fields.find((f) => f.key === 'vip_renew_price_type').options.map((o) => o.value),
['same', 'discount', 'reduce', 'customize']
);
assert.deepStrictEqual(
opt.tabs[3].fields.find((f) => f.key === 'vip_upgrade_product').fields.filter((f) => !f.ui_only).map((f) => f.key),
UPGRADE_FIELDS
);
const points = schema.groups.find((g) => g.key === 'points-exchange').fields[1];
assert.strictEqual(points.type, 'group');
assert.deepStrictEqual(points.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']);
// 手风琴配置与 PHP 侧一致。
for (const key of ['vip_1_product', 'vip_2_product', 'vip_1_renew_product', 'vip_2_renew_product']) {
const group = findGroup(schema, key);
assert.strictEqual(group.collapsible, true, `${key} 应开启手风琴`);
assert.strictEqual(group.defaultCollapsed, true, `${key} 应默认收起`);
}
assert.strictEqual(findGroup(schema, 'pay_vip_points_exchange_product').collapsible, true);
const upgrade = findGroup(schema, 'vip_upgrade_product');
assert.strictEqual(upgrade.sections.length, 3);
// SPA 默认值与 PHP 侧一致。
assert.strictEqual(schema.defaults.vip_1_product[0].price, 99);
assert.strictEqual(schema.defaults.vip_1_renew_product[0].price, 69);
assert.strictEqual(schema.defaults.vip_2_renew_product[0].price, 269);
assert.strictEqual(schema.defaults.vip_renew_discount, 8);
assert.strictEqual(schema.defaults.vip_upgrade_product.jump_s, false);
// 旧键不出现在 SPA schema。
for (const key of LEGACY_KEYS) {
assert.ok(!vipSchemaJs.includes(key), `vip.js 不应再含 ${key}`);
}
});
test('升级价格 sections 能展开成三块手风琴,且块内字段遵循 dependsOn', async () => {
const { buildFieldsetSections } = await import(
pathToFileURL(path.join(root, 'assets/admin/src/components/form/fieldset-sections.js')).href
);
const mod = await import(pathToFileURL(path.join(root, 'assets/admin/src/schema/sections/payment/vip.js')).href);
const upgrade = findGroup(mod.vipSchema, 'vip_upgrade_product');
const sections = buildFieldsetSections(upgrade);
assert.strictEqual(sections.length, 3, '升级价格应展开成三块');
assert.deepStrictEqual(sections.map((s) => s.title), [
'月费会员升级月费会员',
'月费会员升级永久会员',
'永久会员升级永久会员',
]);
// 每块的字段并集恰好等于声明的全部字段,且顺序与 schema.fields 一致。
const expanded = sections.flatMap((s) => s.fields.map((f) => f.key));
assert.deepStrictEqual(expanded, upgrade.fields.map((f) => f.key));
// 第一块带「已设置为永久会员」之外的提示与开关,字段定义必须是真实对象而非占位。
assert.ok(sections[0].fields.every((f) => f && typeof f === 'object' && f.key));
// 没有 sections 声明的 fieldset 保持平铺,不产生假块。
assert.deepStrictEqual(buildFieldsetSections({ type: 'fieldset', fields: [{ key: 'a' }] }), []);
assert.deepStrictEqual(buildFieldsetSections(undefined), []);
// 渲染层把 sections 交给纯函数,并对块内字段套用 dependsOn。
assert.match(fieldVue, /const fieldsetSections = computed\(\(\) => \{[\s\S]*?buildFieldsetSections\(props\.schema\)/);
assert.match(fieldVue, /function sectionFields\(section\) \{[\s\S]*?matchDependency\(field\.dependsOn, props\.modelValue\)/);
// 结构化分组之间留出间隔,两个分组不会上下贴在一起。
assert.match(fieldVue, /\.aksrt-field--group,\s*\.aksrt-field--repeater,\s*\.aksrt-field--accordion,\s*\.aksrt-field--fieldset \{[\s\S]*?margin-bottom: 26px;/);
assert.match(fieldVue, /\.aksrt-field__accordion-section \+ \.aksrt-field__accordion-section \{[\s\S]*?margin-top: 14px;/);
});
@@ -0,0 +1,306 @@
<?php
/**
* Executable VIP structured-settings contract harness.
*
* Builds the payment schema with WordPress stubs, normalizes a stored
* `aksrt_settings` snapshot through PaymentRuntime, and prints the result as
* JSON so the Node contract test can assert on the real shapes.
*/
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
define('DAY_IN_SECONDS', 86400);
function __(string $text, string $domain = ''): string { return $text; }
function _x(string $text, string $context, string $domain = ''): string { return $text; }
function esc_html($value): string { return (string) $value; }
function esc_attr($value): string { return (string) $value; }
function esc_url($value): string { return (string) $value; }
function esc_url_raw($value): string { return trim((string) $value); }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_textarea_field($value): string { return trim((string) $value); }
function wp_kses_post($value): string { return (string) $value; }
function wp_kses($value, $allowed = null): string { return (string) $value; }
function trailingslashit($value): string { return rtrim((string) $value, '/\\') . '/'; }
function get_template_directory_uri(): string { return 'https://example.test/wp-content/themes/aksrt'; }
function absint($value): int { return max(0, (int) $value); }
function wp_json_encode($value, int $flags = 0): string { return json_encode($value, $flags | JSON_UNESCAPED_SLASHES); }
function is_multisite(): bool { return false; }
function get_option(string $key, $default = false) { return $default; }
function did_action(string $hook): int { return 0; }
function apply_filters(string $hook, $value, ...$args) { return $value; }
function add_action(...$args): void {}
function add_filter(...$args): void {}
}
namespace AKSRT\Settings {
/** Test double: PaymentRuntime reads every raw settings key through this. */
final class Settings {
/** @var array<string,mixed> */
public static array $snapshot = [];
public static function get_all(): array {
return self::$snapshot;
}
public static function get_paid_content_settings(): array {
return ['display' => ['currency_symbol' => '¥']];
}
public static function reset(): void {
self::$snapshot = [];
}
}
}
namespace {
require dirname(__DIR__, 2) . '/src/Ledger/AssetType.php';
require dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php';
require dirname(__DIR__, 2) . '/src/Settings/PaymentRuntime.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPayment.php';
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
require dirname(__DIR__, 2) . '/src/Content/VipServicePackages.php';
/** Exposes the trait's private renew-pricing math for contract assertions. */
final class VipPricingProbe {
use \AKSRT\Content\VipServicePackages;
/** @param array<string,mixed> $pkg */
public function price(array $pkg, string $action, array $renew, int $days): ?array {
return $this->apply_action_price($pkg, $action, $renew, [], 1, 1, 0, false, $days);
}
}
use AKSRT\Admin\Schema\SchemaPayment;
use AKSRT\Settings\PaymentRuntime;
use AKSRT\Settings\Settings;
$vip = SchemaPayment::build()['sections']['vip'];
$groups = [];
foreach ($vip['groups'] as $group) {
$groups[$group['key']] = $group;
}
$opt_tabs = [];
foreach ($groups['opt']['fields'][0]['tabs'] as $tab) {
$fields = [];
foreach ($tab['fields'] as $field) {
$fields[$field['key']] = $field;
}
$opt_tabs[$tab['key']] = [
'title' => $tab['title'],
'titleKey' => $tab['titleKey'] ?? null,
'titleFallback' => $tab['titleFallback'] ?? null,
'fields' => $fields,
];
}
$benefit_tabs = [];
foreach ($groups['benefit']['fields'][0]['tabs'] as $tab) {
$benefit_tabs[$tab['key']] = [
'title' => $tab['title'],
'titleKey' => $tab['titleKey'] ?? null,
'titleFallback' => $tab['titleFallback'] ?? null,
];
}
// ---- runtime: defaults (nothing stored) -------------------------------
Settings::reset();
$defaults = PaymentRuntime::get_payment_vip_settings();
// ---- runtime: fully customised stored snapshot -------------------------
Settings::$snapshot = [
'pay_user_vip_1_s' => true,
'pay_user_vip_2_s' => true,
'pay_user_vip_1_name' => '青铜会员',
'pay_user_vip_2_name' => '王者会员',
'vip_renew' => true,
'vip_renew_desc' => '续费有礼',
'vip_renew_price_type' => 'customize',
'vip_renew_discount' => 7.5,
'vip_renew_reduce' => '15.00',
'vip_upgrade' => true,
'vip_upgrade_desc' => '升级王者',
'vip_upgrade_product' => [
'month_to_month_s' => true,
'unit_price' => '12.50',
'unit_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 按天升级',
'unit_tag_class' => 'jb-blue',
'jump_s' => true,
'jump_price' => '259.00',
'jump_show_price' => '399.00',
'jump_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 直升永久',
'jump_tag_class' => 'jb-red',
'permanent_price' => '159.00',
'permanent_show_price' => '259.00',
'permanent_tag' => '<i class="fa fas fa-fw fa-bolt"></i> 王者特惠',
'permanent_tag_class' => 'jb-vip2',
],
'vip_1_product' => [
['price' => '88.80', 'show_price' => '188.00', 'tag' => '<i class="fa fas fa-fw fa-bolt"></i> 新客', 'tag_class' => 'jb-green', 'time' => 1, 'unit' => 'month'],
['price' => '0', 'show_price' => '', 'tag' => '', 'time' => 0, 'unit' => 'day'],
['price' => '10', 'show_price' => '20', 'tag' => '', 'time' => 2, 'unit' => 'month'],
],
'vip_2_product' => [
['price' => '300', 'show_price' => '600', 'tag' => 'x', 'tag_class' => 'not-a-real-class', 'time' => 12, 'unit' => 'month'],
],
'vip_1_renew_product' => [
['price' => '60', 'show_price' => '99', 'tag' => '续费专享', 'tag_class' => 'b-red', 'time' => 1, 'unit' => 'month'],
],
'vip_2_renew_product' => [],
'pay_vip_points_exchange_s' => true,
'pay_vip_points_exchange_product' => [
['points' => 800, 'level' => 1, 'time' => 30, 'unit' => 'day'],
['points' => 9000, 'level' => 2, 'time' => 'Permanent'],
['points' => 0, 'level' => 1, 'time' => 10, 'unit' => 'day'],
['points' => 100, 'level' => 9, 'time' => 10, 'unit' => 'day'],
['points' => 100, 'level' => 1, 'time' => '', 'unit' => 'day'],
['points' => 120, 'level' => 2, 'time' => 3, 'unit' => 'month'],
],
];
$custom = PaymentRuntime::get_payment_vip_settings();
// ---- legacy scalar payloads must fall back to defaults -----------------
Settings::$snapshot = [
'pay_user_vip_1_s' => true,
'vip_1_product' => "30|59|1|month|限时|月卡",
'pay_vip_points_exchange_product' => "1|500|30|积分月卡",
'vip_upgrade_product' => "unit|1|按剩余天数升级",
];
$legacy = PaymentRuntime::get_payment_vip_settings();
// ---- sanitize -> validate round-trip -----------------------------------
// 复现并锁定:积分兑换明细.level 的选项值为整数,而 SPA 提交的可能是数字
// 或字符串;净化必须把值收敛回选项声明的类型,否则严格 in_array 校验会以
// "has an invalid value" 拒绝一次完全合法的保存。
$sanitize = (static function (): array {
$provider = \AKSRT\Admin\SchemaProvider::get_instance();
$cases = [
'default_rows' => [
'pay_vip_points_exchange_s' => true,
'pay_vip_points_exchange_product' => [
['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'day'],
['points' => 5000, 'level' => 1, 'time' => 'Permanent'],
],
],
'string_level' => [
'pay_vip_points_exchange_s' => true,
'pay_vip_points_exchange_product' => [
['points' => 800, 'level' => '2', 'time' => 3, 'unit' => 'month'],
],
],
'invalid_level' => [
'pay_vip_points_exchange_s' => true,
'pay_vip_points_exchange_product' => [
['points' => 500, 'level' => 9, 'time' => 30, 'unit' => 'day'],
],
],
'invalid_unit' => [
'pay_vip_points_exchange_s' => true,
'pay_vip_points_exchange_product' => [
['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'year'],
],
],
'invalid_tag_class' => [
'vip_1_product' => [
['price' => '88.80', 'show_price' => '99', 'tag' => 'x', 'tag_class' => 'nope', 'time' => 1, 'unit' => 'month'],
],
],
];
$out = [];
foreach ($cases as $name => $payload) {
$clean = $provider->sanitize_payload('payment', $payload, 'vip');
$result = $provider->validate('payment', $clean, 'vip');
$out[$name] = [
'points_rows' => $clean['pay_vip_points_exchange_product'] ?? null,
'valid' => $result['valid'],
'errors' => $result['errors'],
];
}
return $out;
})();
echo wp_json_encode([
'schema' => [
'version' => $vip['version'],
'group_keys' => array_column($vip['groups'], 'key'),
'basic_field_keys' => array_column($groups['basic']['fields'], 'key'),
'benefit_tabs' => $benefit_tabs,
'opt_tabs' => $opt_tabs,
'points_fields' => array_column($groups['points-exchange']['fields'], 'key'),
'points_group' => $groups['points-exchange']['fields'][1],
'tag_palette' => $opt_tabs['vip1']['fields']['vip_1_product']['fields'][3]['options'],
],
'runtime' => [
'defaults' => $defaults,
'custom' => $custom,
'legacy' => $legacy,
],
'sanitize' => $sanitize,
'accordion' => (static function (array $groups): array {
$out = ['groups' => [], 'sections' => []];
foreach ($groups as $group) {
foreach ($group['fields'] as $top) {
if (($top['type'] ?? '') !== 'tabbed') {
continue;
}
foreach ($top['tabs'] as $tab) {
foreach ($tab['fields'] as $field) {
if (($field['type'] ?? '') === 'group') {
$out['groups'][$tab['key'] . '/' . $field['key']] = [
'collapsible' => $field['collapsible'] ?? null,
'defaultCollapsed' => $field['defaultCollapsed'] ?? null,
'listPreview' => $field['listPreview'] ?? null,
];
}
if (($field['type'] ?? '') === 'accordion') {
$out['sections'][$tab['key'] . '/' . $field['key']] = array_map(
static fn(array $section): array => [
'title' => $section['title'],
'keys' => $section['keys'],
],
$field['sections'] ?? []
);
}
}
}
}
}
foreach ($groups as $group) {
if ($group['key'] !== 'points-exchange') {
continue;
}
foreach ($group['fields'] as $field) {
if (($field['type'] ?? '') !== 'group') {
continue;
}
$out['groups']['points-exchange/' . $field['key']] = [
'collapsible' => $field['collapsible'] ?? null,
'defaultCollapsed' => $field['defaultCollapsed'] ?? null,
'listPreview' => $field['listPreview'] ?? null,
];
}
}
return $out;
})($groups),
'pricing' => (static function (): array {
$probe = new VipPricingProbe();
$base = [
'price' => '100.00', 'show_price' => '200.00', 'tag' => '限时',
'tag_class' => 'jb-red', 'time' => 1, 'unit' => 'month',
];
return [
'same' => $probe->price($base, 'renew', ['price_type' => 'same'], 30),
'discount_8' => $probe->price($base, 'renew', ['price_type' => 'discount', 'discount' => 8], 30),
'discount_7_5' => $probe->price($base, 'renew', ['price_type' => 'discount', 'discount' => 7.5], 30),
'discount_10_clamped' => $probe->price($base, 'renew', ['price_type' => 'discount', 'discount' => 10], 30),
'reduce_20' => $probe->price($base, 'renew', ['price_type' => 'reduce', 'reduce' => '20.00'], 30),
'customize' => $probe->price($base, 'renew', ['price_type' => 'customize'], 30),
'pay' => $probe->price($base, 'pay', ['price_type' => 'discount'], 30),
];
})(),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL;
}
@@ -0,0 +1,43 @@
/**
* 用户中心 VIP 页状态卡契约(inc/helpers/user-center-renderers/vip-tab.php)。
*
* 硬约束:
* 1) 未开通用户不渲染到期时间行——状态标签已显示「未开通」,参考主题
* zibpay_user_page_tab_content_vip 对从未开通用户同样不输出任何到期
* 信息(仅生效中/永久/已过期才有);
* 2) 生效中、永久、已过期三个分支的到期文案保持产出;
* 3) 到期行整体按文案非空条件渲染,不留空壳 div。
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..', '..');
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
const vipTab = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
test('未开通用户不渲染到期时间行', () => {
// 未开通初始化不再产出「到期时间:未开通」文案。
assert.ok(!vipTab.includes('到期时间:未开通'), '未开通不应再产出到期文案');
assert.match(vipTab, /\$vip_expiry_text\s*=\s*'';/);
// 到期行按文案非空条件渲染。
assert.match(vipTab, /\$vip_expiry_text !== '' \? '<div class="uc-vip-status__expiry">/);
});
test('生效中/永久/已过期分支仍产出到期文案', () => {
assert.match(vipTab, /到期时间:永久会员/);
assert.match(vipTab, /到期时间:%s/);
assert.match(vipTab, /到期时间:已过期/);
// 三个分支都会给 $vip_expiry_text 赋值。
const assignments = vipTab.match(/\$vip_expiry_text\s*=/g) || [];
assert.ok(assignments.length >= 4, '初始化 + 永久 + 生效中 + 已过期均应赋值');
});
test('未开通状态名称前不渲染图标位', () => {
// 图标位仅在有等级(生效中/永久/已过期)时输出;未开通(level 0)不输出,
// 避免「未开通会员」前面挂通用证书图标。
assert.match(vipTab, /\$vip_level_number > 0 \? '<span class="uc-vip-status__icon"/);
assert.ok(!vipTab.includes(". '<span class=\"uc-vip-status__icon\" aria-hidden=\"true\">' . \$status_icon"), '图标位不应再无条件渲染');
});
+63 -12
View File
@@ -1,5 +1,5 @@
{ {
"generated_at": "2026-10-03T20:06:15+00:00", "generated_at": "2026-10-13T12:53:18+00:00",
"errors": [], "errors": [],
"warnings": [ "warnings": [
"customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)", "customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",
@@ -45,6 +45,8 @@
"enum incomplete parse for 'search_type_in' (php subset of js): php=[post] js=[post,user]", "enum incomplete parse for 'search_type_in' (php subset of js): php=[post] js=[post,user]",
"enum incomplete parse for 'sign_card_position' (php subset of js): php=[left] js=[left,center,right]", "enum incomplete parse for 'sign_card_position' (php subset of js): php=[left] js=[left,center,right]",
"enum incomplete parse for 'sign_theme_mode' (php subset of js): php=[no] js=[no,white-theme,dark-theme]", "enum incomplete parse for 'sign_theme_mode' (php subset of js): php=[no] js=[no,white-theme,dark-theme]",
"enum incomplete parse for 'sms_ali_api_type' (php subset of js): php=[sms] js=[sms,verify]",
"enum incomplete parse for 'sms_sdk' (php subset of js): php=[null] js=[null,ali,tencent,smsbao]",
"enum incomplete parse for 'style' (php subset of js): php=[null] js=[null,style2]", "enum incomplete parse for 'style' (php subset of js): php=[null] js=[null,style2]",
"enum incomplete parse for 'system_notice_btn1_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]", "enum incomplete parse for 'system_notice_btn1_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]",
"enum incomplete parse for 'system_notice_btn2_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]", "enum incomplete parse for 'system_notice_btn2_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]",
@@ -57,12 +59,12 @@
"enum incomplete parse for 'user_sign_type' (php subset of js): php=[modal] js=[modal,page]", "enum incomplete parse for 'user_sign_type' (php subset of js): php=[modal] js=[modal,page]",
"enum incomplete parse for 'user_signin_nopas_active' (php subset of js): php=[nopas] js=[nopas,pas,only_nopas]", "enum incomplete parse for 'user_signin_nopas_active' (php subset of js): php=[nopas] js=[nopas,pas,only_nopas]",
"enum incomplete parse for 'wechat_payment_type' (php subset of js): php=[native] js=[native,h5]", "enum incomplete parse for 'wechat_payment_type' (php subset of js): php=[native] js=[native,h5]",
"enum incomplete parse for 'weixingzh_gzh_type' (php subset of js): php=[service] js=[service,subscribe]", "enum incomplete parse for 'weixingzh_gzh_type' (php subset of js): php=[service] js=[service,subscribe]"
"assets/admin/build/ missing — run: cd assets/admin && npm run build"
], ],
"info": [ "info": [
"PHP schema domains: global, page_display, post_list, permissions, users, payment, extensions, tools, ExtensionsAudit, ExtensionsIP, ExtensionsPush, ToolsBackup, ToolsHealth, ToolsImportExport", "PHP schema domains: global, page_display, post_list, permissions, users, payment, extensions, tools, ExtensionsAudit, ExtensionsIP, ExtensionsPush, ExtensionsSecurity, ExtensionsSystem, ToolsBackup, ToolsHealth, ToolsImportExport",
"Menu pages discovered: 60" "Menu pages discovered: 61",
"admin build ok; chunks=30"
], ],
"domains": { "domains": {
"global": { "global": {
@@ -136,8 +138,7 @@
"oauth", "oauth",
"report", "report",
"sms", "sms",
"verify", "verify"
"wechat"
], ],
"files": [ "files": [
"src/Admin/Schema/SchemaUsers.php" "src/Admin/Schema/SchemaUsers.php"
@@ -146,6 +147,7 @@
"payment": { "payment": {
"sections": [ "sections": [
"affiliate", "affiliate",
"after_sale",
"gateway", "gateway",
"knowledge", "knowledge",
"recharge", "recharge",
@@ -161,7 +163,9 @@
"sections": [ "sections": [
"audit", "audit",
"ip", "ip",
"push" "push",
"security",
"system"
], ],
"files": [ "files": [
"src/Admin/Schema/SchemaExtensions.php" "src/Admin/Schema/SchemaExtensions.php"
@@ -201,6 +205,22 @@
"src/Admin/Schema/SchemaExtensionsPush.php" "src/Admin/Schema/SchemaExtensionsPush.php"
] ]
}, },
"ExtensionsSecurity": {
"sections": [
"security"
],
"files": [
"src/Admin/Schema/SchemaExtensionsSecurity.php"
]
},
"ExtensionsSystem": {
"sections": [
"system"
],
"files": [
"src/Admin/Schema/SchemaExtensionsSystem.php"
]
},
"ToolsBackup": { "ToolsBackup": {
"sections": [ "sections": [
"backup" "backup"
@@ -226,11 +246,42 @@
] ]
} }
}, },
"page_count": 60, "page_count": 61,
"build": { "build": {
"present": false, "present": true,
"entry": false, "entry": true,
"missing_chunks": [], "missing_chunks": [],
"chunks": [] "chunks": [
"SchemaPage.BrFz57tJ.js",
"vendor-vue.BqIbVHit.js",
"FaIcon.BMA0TNWc.js",
"AksMediaField.C9RRDn2V.js",
"FaIconPicker.CA3w9r30.js",
"shared-api.Cak1GAri.js",
"vendor-misc.Dr7TL_YE.js",
"vendor-draggable.CjYjXbZD.js",
"level.E3ANSDAX.js",
"badge.CKTsmH1z.js",
"vendor-naive.Dzr6v856.js",
"DeferredPage.Bo6-Z82H.js",
"NativeOpsRedirect.wMMcdVjx.js",
"FloatingButtonsSettings.DM1NXEcr.js",
"PageHeader.UhySXCpq.js",
"FooterTabbarSettings.8D3P8zNv.js",
"PostPageSettings.B5Sm-lcm.js",
"CommentSettings.DiQrTo4p.js",
"PermissionsCapabilities.CnQfjtxC.js",
"CmsCredits.Dh2b9uHb.js",
"ThemeInfoPage.zYktV7qz.js",
"ThemeAuthorizationPage.e-ksjN6X.js",
"DocsPage.Dtd_-3fP.js",
"UsersVerify.CAJUfACd.js",
"UsersLevel.CBoqr3ux.js",
"UsersBadge.B_lwRfdl.js",
"UsersWechatTemplate.F0aCC5C-.js",
"ToolsImportExport.CN30A6Ge.js",
"ToolsHealth.B0GkAdJR.js",
"ToolsUpdate.DHtpNLfB.js"
]
} }
} }