Compare commits

..
57 Commits
Author SHA1 Message Date
kate522andClaude Opus 5 a120f61e13 fix(vip): render points exchange description
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-19 14:04:30 +08:00
kate522andClaude Opus 5 0d51beeaad fix(vip): add spacing before points exchange notice
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-19 13:22:04 +08:00
kate522andClaude Opus 5 573040381d fix(vip): preserve safe HTML in VIP copy
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-19 12:52:23 +08:00
kate522 7a0445fc4a fix(admin): write group rows in place so drag keys stay stable
The previous fix inherited the drag key onto a replacement row object, but the row data is reactive: Vue hands the renderer a fresh proxy for every new object, so the WeakMap still missed and minted a new key on each keystroke. That remounted the row and dropped input focus after one character, so editing 执行价, 原价, 促销标签, 使用积分, 兑换时长 and 会员有效时间 was unusable.

updateRepeaterItem now writes through the existing row instead of spreading a new object, so the row identity survives across renders and the drag key stays valid. The emitted array is still a fresh array, so parents re-render normally, and rows that are not objects still fall back to a new object.

Verified in a real browser against the actual payment/vip schema: all six fields keep the same DOM node across repeated keystrokes, typed values reach the model, and add/remove still work. The contract test now simulates Vue reactive proxying and asserts row identity is preserved; it fails against the previous implementation. Full suite: 663 tests passing.
2026-10-18 21:59:06 +08:00
kate522 0bc72616e5 fix(admin): keep group row keys stable while editing fields
The inline group/repeater renderer feeds vuedraggable an item key from a WeakMap, because rows loaded from the server carry no _id. updateRepeaterItem rebuilt every row with a fresh object spread on each keystroke, so the WeakMap missed and minted a brand new key, which remounted the whole row and dropped the input focus after a single character.

Inherit the existing drag key onto the replacement row object so the key survives for the whole life of the row. Values that match no option still fall through to the previous coercion, so invalid input keeps reporting a precise error, and rows that already carry an _id continue to key off it.

Covered by a new contract test that extracts the real repeaterDragKey and updateRepeaterItem from AksField.vue and drives them through a simulated typing session. Full suite: 661 tests passing.
2026-10-18 21:15:19 +08:00
kate522 064f4e0955 fix(admin): keep enum values in their declared type when sanitizing
The 积分兑换明细.level radio declares its options as the integers 1 and 2, but sanitize_field_value() ran every radio value through sanitize_text_field(), so the sanitized payload held the string 1. validate() then compared with in_array(..., true) and rejected both default rows, which made the VIP payment section impossible to save.

Resolve enum values to the value declared by their option during sanitization, covering select, radio, palette and image_select. A value that matches no option still falls through to the previous string coercion, so invalid input keeps producing a precise validation error. This also keeps the stored type aligned with the schema, which the naive-ui radio group compares with === when it re-renders a saved setting.

Verified with an end-to-end sanitize, validate, write and read round-trip, and locked in with a contract test in the VIP structured-settings suite. The widget-system, admin, front-submit and navigation suites all pass (658 tests).
2026-10-18 19:45:57 +08:00
kate522 4b5098a753 fix(vip): restore the upgrade-price accordion and space out structured groups
升级价格 declared three reference-theme blocks through the accordion sections option, but the section builder handed the computed the whole field object instead of its sections array, so the guard always saw an empty length and the renderer silently fell back to a flat list. The three blocks ran into each other with no separation at all.

- Move the section expansion into a pure buildFieldsetSections() helper so the mistake is now covered by a real contract test that runs the actual schema through it, instead of a regex over the component source.
- Apply dependsOn to fields inside each block, so 天单价/促销标签 hide when 月费会员升级月费会员 is switched off, matching the flat path.
- Give structured groups (会员商品/续费商品/积分兑换明细/升级价格) their own bottom margin and widen the gap between accordion blocks, so two adjacent groups no longer sit flush against each other.

Verified: 657 Node contract tests passing, the admin SPA builds.
2026-10-18 19:24:00 +08:00
kate522 05f16d5fd7 feat(vip): collapse the structured membership groups into accordions
The structured groups introduced in the previous commit rendered every row
fully expanded, which buried the fields an admin actually came to edit.
They now behave like accordions and start collapsed.

- 会员商品, 一级/二级会员续费 and 积分兑换明细 opt in via `collapsible`
  on the group schema. Each row shows only its header, carrying a summary
  line built from `listPreview` (价格:99.00 · 3 个月 / 积分:500 · 30 天),
  and expands on click. `defaultCollapsed` keeps the collapsed default;
  setting it to false restores the previous always-open behaviour.
- 升级价格 splits into its three reference-theme blocks
  (月费升月费 / 月费升永久 / 永久升永久) through a new `sections` option
  on the accordion type, each block independently collapsible.
- AksField keeps the existing open state keyed by index so adding or
  removing a row does not disturb the others, and the drag handle, delete
  button and chevron are excluded from the header toggle.
- Non-collapsible groups are untouched: the accordion markup only renders
  when the schema opts in.

Verified: php -l on the changed PHP, 656 Node contract tests passing
(including new assertions that every collapsible group is opted in, that
the upgrade sections cover exactly the declared fields, and that the SPA
mirror matches), and the admin SPA builds.
2026-10-18 17:51:28 +08:00
kate522 af8dccdc45 feat(vip): restructure membership settings into structured groups
Replace the pipe-delimited VIP configuration with structured groups and
accordions, matching the reference theme (Zibll) 1:1.

Settings:
- 会员等级 keeps only the nav buttons and the two level switches, plus the
  "must enable level 1 first" guard. Level identity fields (display name,
  intro, benefits HTML, icon) and 会员商品 move into the 一级会员/二级会员
  tabs of 会员购买选项, so each level is configured in one place.
- Level tab titles bind to the configured display name via a new
  titleKey/titleFallback hook in AksFormRenderer.
- 会员商品 is a repeater with exactly the reference fields: 执行价/原价/
  促销标签/标签颜色/会员有效时间/时长单位. The tag colour uses the full
  b+jb palette and now reaches the product card instead of a hardcoded
  jb-yellow.
- 会员续费 gains the 自定义商品 type backed by per-level renew-product
  groups; the discount becomes a 0.1-9.9 slider with float sanitizing.
- 会员升级 becomes the reference three-block accordion (month-to-month
  day-rate, jump to permanent, permanent to permanent), each with its own
  price, strikethrough price, promo tag and tag colour.
- 积分兑换明细 is a repeater (points/level/duration/unit) where the
  duration accepts the literal Permanent.

Runtime:
- PaymentRuntime normalizes the structured groups into purchasable rows
  (money normalization, palette validation, zero-price and out-of-range
  rows dropped) and no longer emits any *_raw strings.
- The catalog builder carries tag_class through and routes renewals to the
  renew-product group under 自定义商品.
- The points-exchange order note carries time/unit so month durations use
  calendar math instead of a 30-day approximation.

Dead code removed: parse_level_products, parse_packages,
parse_upgrade_product_line and their pipe-splitting, plus nine zero-call
PaymentRuntime proxy methods in HomeCms. No dual-format or migration path
was added; SchemaProvider already falls back to defaults when a stored
group value is not an array, so existing pipe strings degrade safely.

Verified: php -l on every changed PHP file, 655 Node contract tests passing
(14 new ones in vip-settings-structured-contract.test.cjs exercising the
real schema build and runtime normalization via a PHP harness), and the
admin SPA builds.
2026-10-18 17:14:00 +08:00
kate522 dcb80cff6e fix(vip): let 积分兑换说明 support HTML like the reference theme
The points-exchange desc was stripped at all three layers: no sanitize_type on the schema fields (save ran sanitize_textarea_field), the runtime re-ran the same strip, and the points modal escaped it with esc_html. The reference theme declares sanitize=false and concatenates the value raw into the modal.

- Schema (SPA + PHP): sanitize_type html, rows 2, help text noting HTML support

- PaymentRuntime: output via wp_kses_post

- VipServiceModal points modal: render via wp_kses_post instead of esc_html

Verification: new contract test covers schema/runtime/modal layers; node --test tests/**/*.test.cjs 641/641 pass; php -l clean on both PHP files.
2026-10-18 15:06:29 +08:00
kate522 2e2558e5ce fix(vip): render 开通会员更多内容 as HTML in the modal
Three layers were dropping the markup: the save path was already relaxed to wp_kses_post via schema sanitize_type html, but the runtime output re-filtered more through the narrow equity whitelist (h/img/hr/table stripped), and pay-vip.js inserted it with node() whose textContent showed the tags as literal text. The card-pass desc had the same output-side strip despite its save-side fix.

- PaymentRuntime: more and pay_vip_pass_charge_desc now output via wp_kses_post, matching the save-time level (repo precedent: revenue note, after-sale desc)

- pay-vip.js: new htmlNode() helper (innerHTML) for server-sanitized rich text; more and cardPass.desc use it, plain-text fields keep node()

Verification: 3 new contract tests cover output-level kses and innerHTML rendering; node --test tests/**/*.test.cjs 640/640 pass; php -l and node --check clean.
2026-10-18 14:53:42 +08:00
kate522 817ac9c647 fix(user-center): drop icon before unopened VIP status name
The status card always rendered the icon slot, so never-opened members saw a generic certificate glyph in front of 未开通会员 (level 0 has no configured icon and fell back to fa-certificate). Members with a real level keep their icon; only the level-0 case drops the slot.

Render the icon span only when vip_level_number > 0.

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

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

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

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

Verification: regression test locks vip-tab.php free of the more block while the modal path keeps carrying it; node --test tests/**/*.test.cjs 635/635 pass; php -l clean.
2026-10-18 14:37:35 +08:00
kate522 7f556892ad fix(vip): align modal copy fields with reference theme backend
Rename and reposition the two VIP modal copy settings to match the zibll reference backend, drop dead renew/upgrade desc config, and align copy defaults.

- pay_user_vip_desc: modal top intro -> 会员介绍, textarea -> input (one-line), zibll help text, default copy from zibll

- pay_user_vip_more: bottom note -> 开通会员更多内容, rows 4 + sanitize_type html (HTML was stripped on save), zibll help text and three-div default

- Both fields move from the member-level group to a new 界面文案 (modal-copy) group at the end of the VIP section; dependsOn any-of-two-levels adapted from zibll's pay_user_vip_1_s dependency

- Copy defaults synced in three places (SPA schema, PHP schema, PaymentRuntime fallbacks, since Settings::get_all() does not merge schema defaults): 黄金会员/钻石会员, 尊享专属特权, zibll four-item equity list

- Remove vip_renew_desc / vip_upgrade_desc: AKSRT has no renew/upgrade card render surface (render_vip_card takes no type) and zero consumers repo-wide; zibll renders them on page-level cards only

- pay_user_vip_1/2_equity and pay_vip_pass_charge_desc gain sanitize_type html so admins no longer lose list/HTML markup on save

Verification: new tests/widget-system/vip-modal-copy-fields.test.cjs (6 contract tests); node --test tests/**/*.test.cjs 634/634 pass; php -l clean on both changed PHP files; assets/admin npm run build succeeds
2026-10-18 14:30:15 +08:00
kate522 cb76b95b01 feat(vip): skeleton loading state and faster modal/site startup
The VIP purchase/renew modal opened with a single "loading..." line, which
reads as a hang while /pay/vip/modal round-trips. Every frontend pageview
also paid an option write inside wp_head, and the header user-card CSS was
enqueued even for guests who never see the card.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Restore the descendant selector so both chips share one geometry:

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

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

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

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

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

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

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

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

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

No migration for sites that already stored the old nav_newposts_text.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

验证:tests/widget-system/ 459/459 通过(此前 458/459)。
2026-10-09 12:04:48 +08:00
127 changed files with 6465 additions and 3059 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 } }) },
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 }) },
}
+253 -22
View File
@@ -476,14 +476,43 @@
<!-- Fieldset (single nested object) -->
<div v-else-if="schema.type === 'fieldset' || schema.type === 'accordion'" class="aksrt-field__fieldset">
<AksField
v-for="field in visibleNestedFields"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
:form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
<template v-if="fieldsetSections.length">
<div
v-for="(section, sIndex) in fieldsetSections"
:key="section.title"
class="aksrt-field__accordion-section"
:class="{ 'is-collapsed': !isFieldsetSectionOpen(sIndex) }"
>
<div class="aksrt-field__accordion-section-head" @click="toggleFieldsetSection(sIndex)">
<span class="aksrt-field__accordion-section-title">{{ section.title }}</span>
<FaIcon
:icon="isFieldsetSectionOpen(sIndex) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
:size="12"
class="aksrt-field__accordion-section-toggle"
/>
</div>
<div v-show="isFieldsetSectionOpen(sIndex)" class="aksrt-field__accordion-section-body">
<AksField
v-for="field in sectionFields(section)"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
:form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
</div>
</div>
</template>
<template v-if="!fieldsetSections.length">
<AksField
v-for="field in visibleNestedFields"
:key="field.key"
:schema="field"
:model-value="fieldsetValue(field)"
:form-data="formData"
@update:model-value="updateFieldsetValue(field.key, $event)"
/>
</template>
</div>
<div v-else-if="schema.type === 'link'" class="aksrt-field__link">
@@ -517,23 +546,38 @@
animation="180"
>
<template #item="{ element, index }">
<div class="aksrt-field__repeater-item">
<div class="aksrt-field__repeater-header">
<div class="aksrt-field__repeater-item" :class="{ 'is-collapsible': repeaterCollapsible, 'is-collapsed': repeaterCollapsible && !isRepeaterItemOpen(index) }">
<div
class="aksrt-field__repeater-header"
:class="{ 'is-clickable': repeaterCollapsible }"
@click="repeaterCollapsible ? toggleRepeaterItem(index) : undefined"
>
<div class="aksrt-field__repeater-title">
<i class="fa fas fa-grip-vertical aksrt-field__repeater-handle" aria-hidden="true"></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 v-if="repeaterCollapsible && repeaterItemSummary(element)" class="aksrt-field__repeater-summary">
{{ repeaterItemSummary(element) }}
</span>
</div>
<div class="aksrt-field__repeater-header-actions" @click.stop>
<FaIcon
v-if="repeaterCollapsible"
:icon="isRepeaterItemOpen(index) ? 'fa fas fa-chevron-down' : 'fa fas fa-chevron-right'"
:size="12"
class="aksrt-field__repeater-toggle"
/>
<n-button
size="tiny"
type="error"
text
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button>
</div>
<n-button
size="tiny"
type="error"
text
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button>
</div>
<div class="aksrt-field__repeater-fields">
<div v-show="!repeaterCollapsible || isRepeaterItemOpen(index)" class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key">
<AksField
v-if="matchDependency(field.dependsOn, element)"
@@ -697,6 +741,7 @@ import FaIconPicker from './FaIconPicker.vue'
import AksMediaField from './AksMediaField.vue'
import AksGalleryField from './AksGalleryField.vue'
import AksCodeEditor from './AksCodeEditor.vue'
import { buildFieldsetSections } from './fieldset-sections.js'
import draggable from 'vuedraggable'
import api from '@/api'
@@ -730,6 +775,60 @@ const repeaterItems = computed(() => Array.isArray(props.modelValue) ? props.mod
const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0))
const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max))
/* ------------------------------------------------------------------ *
* 手风琴模式:schema.collapsible 开启后,每一项默认收起,仅显示标题行,
* 点击标题行展开/收起。defaultCollapsed 为 false 时改为默认全部展开。
* ------------------------------------------------------------------ */
const repeaterCollapsible = computed(() => !!props.schema.collapsible)
const repeaterDefaultOpen = computed(() => props.schema.defaultCollapsed === false)
// 只记录「与默认状态相反」的下标,新增/删除项时其余项的开合状态不受影响。
const toggledRepeaterItems = ref(new Set())
function isRepeaterItemOpen(index) {
if (!repeaterCollapsible.value) return true
return repeaterDefaultOpen.value !== toggledRepeaterItems.value.has(index)
}
function toggleRepeaterItem(index) {
const next = new Set(toggledRepeaterItems.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
toggledRepeaterItems.value = next
}
/** 收起态标题行上的一行摘要,取自 listPreview 配置。 */
function repeaterItemSummary(item) {
if (!repeaterCollapsible.value || !item) return ''
const cfg = getRepeaterPreviewConfig()
const titleKey = cfg.titleKey || 'title'
const title = String(item[titleKey] ?? '').trim()
const prefix = String(cfg.prefix || '')
if (title === '') return prefix
const desc = repeaterItemDuration(item)
return desc ? `${prefix}${title} · ${desc}` : `${prefix}${title}`
}
/** time/unit 形态的时长摘要:0 视为永久,其余按 day/month 补单位。 */
function repeaterItemDuration(item) {
const cfg = getRepeaterPreviewConfig()
const descKey = cfg.descKey
if (!descKey) return ''
const raw = item[descKey]
if (raw === '' || raw == null) return ''
const amount = Number(raw)
if (!Number.isFinite(amount)) return String(raw)
if (amount <= 0) return '永久'
const unitKey = cfg.descUnitKey
if (!unitKey) return String(raw)
const unit = String(item[unitKey] || '')
if (unit === 'month') return `${amount} 个月`
if (unit === 'day') return `${amount} 天`
return String(raw)
}
/** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */
const repeaterDragList = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
@@ -762,6 +861,35 @@ function fieldsetValue(field) {
return Object.prototype.hasOwnProperty.call(current, field.key) ? current[field.key] : field.default
}
/* ------------------------------------------------------------------ *
* accordion/fieldset 的分块手风琴:schema.sections 声明每块的标题与字段
* key,默认全部收起,点击块标题展开。未声明 sections 时保持平铺渲染。
* ------------------------------------------------------------------ */
const fieldsetSections = computed(() => {
return buildFieldsetSections(props.schema)
})
const openFieldsetSections = ref(new Set())
function isFieldsetSectionOpen(index) {
return openFieldsetSections.value.has(index)
}
/** 分块内字段同样遵循 dependsOn,数据源是本 fieldset 的值对象。 */
function sectionFields(section) {
return (section.fields || []).filter((field) => matchDependency(field.dependsOn, props.modelValue))
}
function toggleFieldsetSection(index) {
const next = new Set(openFieldsetSections.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
openFieldsetSections.value = next
}
function updateFieldsetValue(key, value) {
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
@@ -1053,6 +1181,8 @@ const repeaterDraft = ref({})
function createRepeaterItem() {
const newItem = { _id: `rpt_${Date.now()}_${++repeaterIdCounter}` }
for (const field of (props.schema.fields || [])) {
// UI-only chrome rows (notice / alert) are presentation only.
if (field.ui_only) continue
if (field.default !== undefined) {
newItem[field.key] = field.default
} else if (field.type === 'switch') {
@@ -1082,7 +1212,16 @@ function removeRepeaterItem(index) {
function updateRepeaterItem(index, fieldKey, value) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current[index] = { ...current[index], [fieldKey]: value }
const row = current[index]
if (row && typeof row === 'object') {
// 必须原地写,不能展开成新对象:drag key 存在以「对象身份」为键的 WeakMap
// 里,而行数据是 reactive 的——换一个新对象后,渲染时读到的是它的一个新
// reactive 代理,WeakMap 认不出它便生成新 key,整行重挂载、输入框失焦,
// 表现为打一个字就退出编辑。经代理原地写既保持身份,也照样触发响应式。
row[fieldKey] = value
} else {
current[index] = { [fieldKey]: value }
}
emit('update:modelValue', current)
}
@@ -1184,6 +1323,15 @@ function confirmRepeaterModal() {
margin-bottom: 0;
}
/* 结构化分组(会员商品/续费商品/积分兑换/升级价格)与相邻字段之间留出更明确的间隔,
* 避免两个分组上下贴在一起,和参考主题的留白一致。 */
.aksrt-field--group,
.aksrt-field--repeater,
.aksrt-field--accordion,
.aksrt-field--fieldset {
margin-bottom: 26px;
}
.aksrt-field__input-wrap {
display: grid;
gap: 8px;
@@ -1449,6 +1597,48 @@ function confirmRepeaterModal() {
background: var(--aksrt-color-canvas);
}
.aksrt-field__accordion-section + .aksrt-field__accordion-section {
margin-top: 14px;
}
.aksrt-field__accordion-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--aksrt-color-border);
border-radius: 6px;
background: var(--aksrt-color-surface);
cursor: pointer;
user-select: none;
font-size: 13px;
font-weight: 600;
color: var(--aksrt-color-text);
transition: border-color 0.15s, color 0.15s;
}
.aksrt-field__accordion-section-head:hover {
border-color: var(--aksrt-color-primary);
color: var(--aksrt-color-primary);
}
.aksrt-field__accordion-section-title {
min-width: 0;
}
.aksrt-field__accordion-section-toggle {
flex-shrink: 0;
color: var(--aksrt-color-text-secondary);
}
.aksrt-field__accordion-section-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 2px 2px;
}
.aksrt-field__repeater {
display: flex;
flex-direction: column;
@@ -1477,6 +1667,47 @@ function confirmRepeaterModal() {
min-width: 0;
}
.aksrt-field__repeater-header.is-clickable {
cursor: pointer;
user-select: none;
border-radius: 6px;
margin: -4px -4px 0;
padding: 4px 4px 0;
}
.aksrt-field__repeater-header.is-clickable:hover .aksrt-field__repeater-index {
color: var(--aksrt-color-primary-hover);
}
.aksrt-field__repeater-header-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.aksrt-field__repeater-toggle {
color: var(--aksrt-color-text-secondary);
transition: transform 0.15s;
}
.aksrt-field__repeater-summary {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--aksrt-color-text-secondary);
}
.aksrt-field__repeater-item.is-collapsible {
padding: 10px 12px;
}
.aksrt-field__repeater-item.is-collapsed .aksrt-field__repeater-header {
margin-bottom: 0;
}
.aksrt-field__repeater-handle {
cursor: grab;
color: var(--aksrt-color-text-tertiary);
@@ -64,9 +64,9 @@
<n-tabs type="line" animated>
<n-tab-pane
v-for="tab in field.tabs"
:key="tab.title || tab.key"
:name="tab.title || tab.key"
:tab="tab.title || tab.key"
:key="tab.key || tab.title"
:name="tabName(tab)"
:tab="tabName(tab)"
>
<template v-for="sub in (tab.fields || [])" :key="sub.key">
<AksField
@@ -231,6 +231,18 @@ function isFieldVisible(field) {
return matchDependsOn(field.dependsOn)
}
/**
* Tab label. `titleKey` binds the label to a live form value (e.g. the VIP level
* display name) so renaming a level renames its tab; `titleFallback` is used
* while that value is still empty.
*/
function tabName(tab) {
if (!tab) return ''
const dynamic = tab.titleKey ? props.formData?.[tab.titleKey] : ''
const label = typeof dynamic === 'string' ? dynamic.trim() : ''
return label || tab.titleFallback || tab.title || tab.key || ''
}
/** Whole group can be hidden (e.g. optional advanced blocks). */
function isGroupVisible(group) {
return matchDependsOn(group.dependsOn)
@@ -0,0 +1,24 @@
/**
* 把 accordion / fieldset 的 `schema.sections` 声明展开成「块标题 + 字段定义」。
*
* 参考主题把「升级价格」这类复合配置拆成若干可折叠块,每块用 keys 声明自己
* 包含哪些字段。这里是纯函数,方便在 Node 契约测试里直接用真实 schema 校验,
* 避免渲染层的一个小疏忽让整块手风琴静默退化成平铺。
*/
export function buildFieldsetSections(schema) {
const declared = schema && Array.isArray(schema.sections) ? schema.sections : []
if (!declared.length) return []
const byKey = {}
const nested = Array.isArray(schema.fields) ? schema.fields : []
for (const field of nested) {
if (field && field.key) byKey[field.key] = field
}
return declared.map((section) => ({
title: String((section && section.title) || ''),
fields: (Array.isArray(section && section.keys) ? section.keys : [])
.map((key) => byKey[key])
.filter(Boolean),
}))
}
@@ -1,4 +1,4 @@
import { renderFaIcon, schemaPage, customPage } from './helpers'
import { renderFaIcon, schemaPage } from './helpers'
export const extensionsSection = {
key: 'extensions',
@@ -42,7 +42,7 @@ export const extensionsSection = {
label: '百度推送',
route: '/extensions/push',
title: '百度推送',
desc: '配置搜索引擎主动推送、批量提交和状态追踪。',
desc: '配置百度自动推送、收录通道与触发规则;推送结果与「重新提交」在文章/分类编辑页查看。',
canonicalDomain: 'extensions',
section: 'push',
}),
@@ -56,15 +56,5 @@ export const extensionsSection = {
canonicalDomain: 'extensions',
section: 'security',
}),
customPage({
key: 'extensions-ai',
name: 'ExtensionAI',
label: 'AI 连接配置',
route: '/extensions/ai',
component: 'ExtensionAI',
status: 'available',
title: 'AI 连接配置',
desc: '配置 AI 生成连接;SEO 在文章独立 SEO 框,摘要/标签在前台投稿页。',
}),
],
}
@@ -108,16 +108,6 @@ export const usersSection = {
canonicalDomain: 'users',
section: 'sms',
}),
schemaPage({
key: 'users-wechat',
name: 'UsersWechat',
label: '微信通知',
route: '/users/wechat',
title: '微信通知',
desc: '配置微信模板消息、订阅/解绑和站外通知策略。',
canonicalDomain: 'users',
section: 'wechat',
}),
customPage({
key: 'users-wechat-template',
name: 'UsersWechatTemplate',
@@ -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 { usersPageLoaders } from './users'
import { toolsPageLoaders } from './tools'
import { extensionsPageLoaders } from './extensions'
export const componentLoaders = {
...corePageLoaders,
...usersPageLoaders,
...toolsPageLoaders,
...extensionsPageLoaders,
}
@@ -2,21 +2,16 @@ export const pushSchema = {
key: 'extensions.push',
title: '百度推送',
icon: 'fa fas fa-paper-plane',
description: '配置搜索引擎主动推送、批量提交和状态追踪。',
description: '配置百度主动推送、收录通道和推送状态。',
defaults: {
baidu_push_enabled: false,
baidu_push_token: '',
baidu_push_auto: true,
baidu_push_on_publish: true,
baidu_push_on_update: false,
baidu_push_batch_size: 50,
baidu_push_normal: true,
baidu_push_fast: false,
baidu_push_log_enabled: true,
xzh_on: false,
xzh_appid: '',
xzh_render_tail: true,
xzh_jsonld_single: true,
xzh_jsonld_page: false,
xzh_jsonld_img: false,
},
groups: [
{
@@ -25,7 +20,7 @@ export const pushSchema = {
icon: 'fa fas fa-rocket',
fields: [
{ key: 'baidu_push_enabled', label: '启用百度推送', type: 'switch', default: false, help: '开启后将自动向百度搜索推送页面链接' },
{ key: 'baidu_push_token', label: '推送 Token', type: 'input', default: '', dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在百度搜索资源平台获取的推送准入 Token' },
{ key: 'baidu_push_token', label: '推送 Token', type: 'input', default: '', dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在百度搜索资源平台获取的推送准入 Token(仅填 Token,勿填完整链接),并确保平台中的站点为本站' },
],
},
{
@@ -34,21 +29,17 @@ export const pushSchema = {
icon: 'fa fas fa-bolt',
fields: [
{ key: 'baidu_push_auto', label: '自动推送 (页面加载时)', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在前台页面中嵌入百度自动推送 JS 代码' },
{ key: 'baidu_push_on_publish', label: '发布时主动推送', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } },
{ key: 'baidu_push_on_update', label: '更新时推送', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true } },
{ key: 'baidu_push_on_publish', label: '发布时主动推送', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '文章 / 分类首次发布时推送链接;推送成功后会记录,不再重复提交' },
{ key: 'baidu_push_on_update', label: '更新时推送', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '已发布内容再次保存时,若上次推送未成功则重试;需主动再次提交请在编辑页勾选「重新提交」' },
],
},
{
key: 'xzh',
title: '熊掌号 / JSON-LD',
icon: 'fa fas fa-paw',
key: 'channel',
title: '收录通道',
icon: 'fa fas fa-inbox',
fields: [
{ key: 'xzh_on', label: '启用熊掌号', type: 'switch', default: false },
{ key: 'xzh_appid', label: '百度 AppID', type: 'input', default: '', dependsOn: { key: 'xzh_on', value: true } },
{ key: 'xzh_render_tail', label: '文章底部显示熊掌号尾部', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } },
{ key: 'xzh_jsonld_single', label: '文章页 JSON-LD', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } },
{ key: 'xzh_jsonld_page', label: '页面 JSON-LD', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } },
{ key: 'xzh_jsonld_img', label: '不添加图片', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } },
{ key: 'baidu_push_normal', label: '普通收录', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '每天可提交最多10万条有价值内容,收录速度较慢' },
{ key: 'baidu_push_fast', label: '快速收录', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '百度新推出的高效收录接口,目前仅对部分优质站点开放,请确保您的站点已开放快速收录功能' },
],
},
{
@@ -56,8 +47,7 @@ export const pushSchema = {
title: '高级设置',
icon: 'fa fas fa-cog',
fields: [
{ key: 'baidu_push_batch_size', label: '批量推送条数', type: 'number', default: 50, min: 1, max: 500, dependsOn: { key: 'baidu_push_enabled', value: true } },
{ key: 'baidu_push_log_enabled', label: '记录推送日志', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } },
{ key: 'baidu_push_log_enabled', label: '记录推送日志', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: 'WP_DEBUG 开启时,将推送结果写入错误日志' },
],
},
],
+2 -17
View File
@@ -2,7 +2,7 @@ export const seoSchema = {
key: 'global.seo',
title: 'SEO 优化',
icon: 'fa fas fa-search',
description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框;连接配置在「扩展 → AI 连接配置」。优先使用 WordPress AI Connectors。',
description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框,连接由 WordPress AI Connectors 提供。',
defaults: {
seo_enabled: true,
seo_ai_enabled: false,
@@ -13,10 +13,6 @@ export const seoSchema = {
ai_seo_title_prompt: '',
ai_seo_keywords_prompt: '',
ai_seo_description_prompt: '',
seo_baidu_push: false,
seo_baidu_normal: false,
seo_baidu_fast: false,
seo_baidu_token: '',
seo_image_optimize: true,
seo_image_default: '',
seo_image_always_default: false,
@@ -48,7 +44,7 @@ export const seoSchema = {
label: '启用 AI SEO 生成',
type: 'switch',
default: false,
help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先配置 AI 连接(WP Connectors 或扩展 → AI 连接配置)。',
help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先在 WordPress 配置 AI Connectors。',
dependsOn: { key: 'seo_enabled', value: true },
},
{ key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |', help: 'AI 生成标题时会用此连接符追加站点名。' },
@@ -95,17 +91,6 @@ export const seoSchema = {
},
],
},
{
key: 'baidu',
title: '百度推送',
icon: 'fa fas fa-paper-plane',
fields: [
{ key: 'seo_baidu_push', label: '启用百度自动推送', type: 'switch', default: false },
{ key: 'seo_baidu_normal', label: '普通收录推送', type: 'switch', default: false },
{ key: 'seo_baidu_fast', label: '快速收录推送', type: 'switch', default: false },
{ key: 'seo_baidu_token', label: '百度推送 Token', type: 'input', default: '', placeholder: '百度资源平台 Token' },
],
},
{
key: 'image',
title: '图片 SEO',
@@ -111,7 +111,7 @@ export const rechargeSchema = {
{ key: 'points_pass_exchange_desc', label: '卡密兑换积分说明', type: 'textarea', default: '', rows: 2, dependsOn: { key: 'points_pass_exchange_s', value: true }, help: '用户使用卡密兑换积分时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
{ key: 'points_pass_exchange_only_password', label: '积分卡密单密码模式', type: 'switch', default: false, dependsOn: { key: 'points_pass_exchange_s', value: true }, help: '积分卡密仅需输入密码,不需要卡号' },
{ key: 'pay_vip_pass_charge_s', label: '启用卡密兑换会员功能', type: 'switch', default: false, help: '开启后用户可在会员购买/续费弹窗选择「卡密」渠道,使用会员卡密直接兑换会员' },
{ key: 'pay_vip_pass_charge_desc', label: '卡密兑换会员说明', type: 'textarea', default: '', rows: 2, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '用户使用卡密兑换会员时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
{ key: 'pay_vip_pass_charge_desc', label: '卡密兑换会员说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 2, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '用户使用卡密兑换会员时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码' },
{ key: 'pay_vip_pass_charge_only_password', label: '会员卡密单密码模式', type: 'switch', default: false, dependsOn: { key: 'pay_vip_pass_charge_s', value: true }, help: '会员卡密仅需输入密码,不需要卡号;默认关闭,会员卡需输入卡号和密码(与其他卡密兑换一致)' },
],
},
+342 -63
View File
@@ -1,27 +1,127 @@
/**
* VIP会员 — 参考主题结构:会员等级、会员权益(普通/一级/二级 tab)、
* 会员购买选项(一级/二级/续费/升级 tab)、卡密兑换与积分兑换。
* VIP会员 — 结构 1:1 对照参考主题 Zibll:会员等级(开关)、会员权益
* (普通/一级/二级 tab)、会员购买选项(一级/二级/续费/升级 tab)、
* 积分兑换会员与界面文案。
*
* 会员商品 / 续费商品 / 升级价格 / 积分兑换明细均为结构化 group 或
* accordion,不再使用 `|` 分隔的 textarea。等级 Tab 标题通过 titleKey
* 绑定「会员显示名称」,改名即改 Tab 名。
*/
const VIP_EQUITY_DEFAULT = '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>';
const vipTagPalette = [
{ label: '#f74b3d', value: 'b-red' },
{ label: '#f3920a', value: 'b-yellow' },
{ label: '#08c4c1', value: 'b-cyan' },
{ label: '#0a8cf3', value: 'b-blue' },
{ label: '#1fd05a', value: 'b-green' },
{ label: '#c133f5', value: 'b-purple' },
{ label: '#121517', value: 'b-black' },
{ label: 'linear-gradient(135deg, #ffbeb4 10%, #f61a1a 100%)', value: 'jb-red' },
{ label: 'linear-gradient(135deg, #ff5e7f 30%, #ff967e 100%)', value: 'jb-pink' },
{ label: 'linear-gradient(135deg, #ffd6b2 10%, #ff651c 100%)', value: 'jb-yellow' },
{ label: 'linear-gradient(140deg, #039ab3 10%, #58dbcf 90%)', value: 'jb-cyan' },
{ label: 'linear-gradient(135deg, #b6e6ff 10%, #198aff 100%)', value: 'jb-blue' },
{ label: 'linear-gradient(135deg, #ccffcd 10%, #52bb51 100%)', value: 'jb-green' },
{ label: 'linear-gradient(135deg, #fec2ff 10%, #d000de 100%)', value: 'jb-purple' },
{ label: 'linear-gradient(25deg, #eab869 10%, #fbecd4 60%, #ffe0ae 100%)', value: 'jb-vip1' },
{ label: 'linear-gradient(317deg, #4d4c4c 30%, #878787 70%, #5f5c5c 100%)', value: 'jb-vip2' },
];
const vipUnitOptions = [
{ label: '天', value: 'day' },
{ label: '月', value: 'month' },
];
// 会员商品/续费商品的手风琴配置:默认收起,标题行显示价格与时长摘要。
const vipProductGroupOptions = {
collapsible: true,
defaultCollapsed: true,
listPreview: { prefix: '价格:', titleKey: 'price', descKey: 'time', descUnitKey: 'unit' },
};
// 积分兑换明细的手风琴配置:默认收起,标题行显示积分与时长摘要。
const vipPointsGroupOptions = {
collapsible: true,
defaultCollapsed: true,
listPreview: { prefix: '积分:', titleKey: 'points', descKey: 'time', descUnitKey: 'unit' },
};
/**
* 会员商品字段集(参考主题 CFS_Module::vip_product)。
*/
function vipProductFields() {
return [
{ key: 'price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '699.00', min: 0, suffix: '元', help: '会员的实际购买价格' },
{ key: 'show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '999.00', min: 0, suffix: '元', help: '显示在执行价格前面,并划掉' },
{ key: 'tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '', rows: 1, help: '支持HTML,请注意控制长度' },
{ key: 'tag_class', label: '标签颜色', type: 'palette', default: 'jb-yellow', dependsOn: { key: 'tag', operator: 'not_empty' }, options: vipTagPalette },
{ key: 'time', label: '会员有效时间', type: 'spinner', default: 3, min: 0, max: 3600, step: 1, help: '开通会员的时长。填0则为永久会员' },
{ key: 'unit', label: '时长单位', type: 'radio', default: 'month', inline: true, dependsOn: { key: 'time', operator: 'truthy' }, options: vipUnitOptions },
{ key: '_time_permanent_hint', label: '', type: 'alert', alertType: 'success', ui_only: true, dependsOn: { key: 'time', operator: 'eq', value: 0 }, content: '会员有效时间已设置为:永久会员' },
];
}
/**
* 会员等级 Tab 字段集(参考主题 CFS_Module::vip_tab,显示名称并入 Tab)。
*/
function vipLevelTabFields(level) {
const dep = { key: `pay_user_vip_${level}_s`, value: true };
return [
{ key: `pay_user_vip_${level}_name`, label: '会员显示名称', type: 'input', sanitize_type: 'html', default: level === 1 ? '黄金会员' : '钻石会员', dependsOn: dep, help: '支持HTML,请注意控制长度' },
{ key: `pay_user_vip_${level}_desc`, label: '一句话简介', type: 'input', sanitize_type: 'html', default: '尊享专属特权', dependsOn: dep, help: '支持HTML' },
{ key: `pay_user_vip_${level}_equity`, label: '会员权益 HTML', type: 'textarea', sanitize_type: 'html', default: VIP_EQUITY_DEFAULT, rows: 4, dependsOn: dep, help: '支持安全HTML,建议使用 <li> 列表项' },
{ key: `vip_${level}_product_s`, label: '购买会员', type: 'switch', default: true, dependsOn: dep, help: '关闭后则不能付费购买会员,但可通过积分兑换会员,请确保已开启积分以及积分兑换会员功能' },
{
key: `vip_${level}_product`,
label: '会员商品',
type: 'group',
max: 8,
button_title: '添加会员商品',
default: [
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
...vipProductGroupOptions,
dependsOn: { all: [dep, { key: `vip_${level}_product_s`, value: true }] },
help: '会员购买价格配置:执行价、原价、促销标签、标签颜色与会员有效时间',
fields: vipProductFields(),
},
{ key: `vip_${level}_img_icon`, label: '会员图标', type: 'upload', default: '', dependsOn: dep, help: `留空使用内置 vip-${level}.svg 图标;建议尺寸300x300` },
];
}
export const vipSchema = {
key: 'payment.vip',
title: 'VIP会员',
icon: 'fa fas fa-gem',
description: '双级会员:等级名称、权益(下载次数与限速)、套餐、续费/升级与积分兑换。',
description: '双级会员:等级开关、权益(下载次数与限速)、会员商品、续费/升级与积分兑换。',
defaults: {
nav_pay_vip: true,
nav_user_pay_vip: true,
pay_user_vip_1_s: true,
pay_user_vip_1_name: '一级会员',
pay_user_vip_1_desc: '尊享一级会员特权',
pay_user_vip_1_equity: '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>',
pay_user_vip_1_name: '黄金会员',
pay_user_vip_1_desc: '尊享专属特权',
pay_user_vip_1_equity: VIP_EQUITY_DEFAULT,
vip_1_product_s: true,
vip_1_product: [
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_1_img_icon: '',
pay_user_vip_2_s: true,
pay_user_vip_2_name: '二级会员',
pay_user_vip_2_desc: '尊享二级会员特权',
pay_user_vip_2_equity: '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>',
pay_user_vip_2_name: '钻石会员',
pay_user_vip_2_desc: '尊享专属特权',
pay_user_vip_2_equity: VIP_EQUITY_DEFAULT,
vip_2_product_s: true,
vip_2_product: [
{ price: 99, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 199, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_2_img_icon: '',
pay_user_vip_desc: '',
pay_user_vip_more: '',
pay_user_vip_desc: '开通VIP会员,享受会员专属折扣以及多项特权',
pay_user_vip_more: '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>',
pay_download_limit: 0,
pay_download_speed: 0,
pay_download_limit_vip_1: 0,
@@ -29,19 +129,45 @@ export const vipSchema = {
pay_download_limit_vip_2: 0,
pay_download_speed_vip_2: 0,
vip_1_product_s: true,
payment_vip_1_products: '30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡',
vip_2_product_s: true,
payment_vip_2_products: '58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡',
vip_renew: true,
vip_renew_desc: '',
vip_renew_desc: '立即续费会员,畅享VIP权益',
vip_renew_price_type: 'discount',
vip_renew_discount: 8,
vip_renew_reduce: '20.00',
vip_1_renew_product: [
{ price: 69, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 169, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_2_renew_product: [
{ price: 269, show_price: 599, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 369, show_price: 899, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
vip_2_product_s: true,
vip_renew: true,
vip_renew_discount: 8,
vip_renew_reduce: '20.00',
vip_upgrade: true,
vip_upgrade_desc: '',
vip_upgrade_product: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级',
vip_upgrade_desc: '升级VIP会员,享更多会员权益',
vip_upgrade_product: {
month_to_month_s: true,
unit_price: '10.00',
unit_tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐',
unit_tag_class: 'jb-yellow',
jump_s: false,
jump_price: '199.00',
jump_show_price: '299.00',
jump_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
jump_tag_class: 'jb-red',
permanent_price: '199.00',
permanent_show_price: '299.00',
permanent_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
permanent_tag_class: 'jb-red',
},
pay_vip_points_exchange_s: false,
payment_vip_points_packages: '1|500|30|积分月卡\n1|5000|0|积分永久',
pay_vip_points_exchange_product: [
{ points: 500, level: 1, time: 30, unit: 'day' },
{ points: 5000, level: 1, time: 'Permanent' },
],
pay_vip_points_exchange_desc: '',
},
groups: [
@@ -67,17 +193,16 @@ export const vipSchema = {
{ key: 'nav_pay_vip', label: '导航栏购买按钮', type: 'switch', default: true, help: '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。' },
{ key: 'nav_user_pay_vip', label: '用户框购买按钮', type: 'switch', default: true, help: '在导航栏用户框内显示开通会员按钮。' },
{ key: 'pay_user_vip_1_s', label: '启用一级会员', type: 'switch', default: true },
{ key: 'pay_user_vip_1_name', label: '一级会员显示名称', type: 'input', default: '一级会员', dependsOn: { key: 'pay_user_vip_1_s', value: true } },
{ key: 'pay_user_vip_1_desc', label: '一级会员一句话简介', type: 'input', default: '尊享一级会员特权', dependsOn: { key: 'pay_user_vip_1_s', value: true } },
{ key: 'pay_user_vip_1_equity', label: '一级会员权益 HTML', type: 'textarea', default: '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>', dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '建议使用 <li> 列表项' },
{ key: 'vip_1_img_icon', label: '一级会员图标 URL', type: 'input', default: '', dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '留空使用内置 vip-1.svg 图标' },
{
key: 'vip_level_order_notice',
label: '',
type: 'notice',
ui_only: true,
alertType: 'danger',
dependsOn: { all: [{ key: 'pay_user_vip_2_s', value: true }, { key: 'pay_user_vip_1_s', operator: 'falsy' }] },
content: '<div style="text-align:center"><b>必须先开启一级会员后才能开启二级会员!</b></div>',
},
{ key: 'pay_user_vip_2_s', label: '启用二级会员', type: 'switch', default: true },
{ key: 'pay_user_vip_2_name', label: '二级会员显示名称', type: 'input', default: '二级会员', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{ key: 'pay_user_vip_2_desc', label: '二级会员一句话简介', type: 'input', default: '尊享二级会员特权', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{ key: 'pay_user_vip_2_equity', label: '二级会员权益 HTML', type: 'textarea', default: '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>', dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{ key: 'vip_2_img_icon', label: '二级会员图标 URL', type: 'input', default: '', dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '留空使用内置 vip-2.svg 图标' },
{ key: 'pay_user_vip_desc', label: '弹窗顶部会员说明', type: 'textarea', default: '' },
{ key: 'pay_user_vip_more', label: '弹窗底部补充说明 HTML', type: 'textarea', default: '' },
],
},
{
@@ -101,6 +226,8 @@ export const vipSchema = {
{
key: 'vip1',
title: '一级会员',
titleKey: 'pay_user_vip_1_name',
titleFallback: '一级会员',
fields: [
{ key: 'pay_download_limit_vip_1', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '一级会员每日最多可下载几个免费资源(为0则不限制)' },
{ key: 'pay_download_speed_vip_1', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, dependsOn: { key: 'pay_user_vip_1_s', value: true }, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' },
@@ -109,6 +236,8 @@ export const vipSchema = {
{
key: 'vip2',
title: '二级会员',
titleKey: 'pay_user_vip_2_name',
titleFallback: '二级会员',
fields: [
{ key: 'pay_download_limit_vip_2', label: '付费资源:免费资源每日可下载(个)', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '二级会员每日最多可下载几个免费资源(为0则不限制)' },
{ key: 'pay_download_speed_vip_2', label: '付费资源:下载限速(KB/s)', type: 'number', default: 0, min: 0, dependsOn: { key: 'pay_user_vip_2_s', value: true }, help: '付费下载限制最大下载速度(为0则不限制);只对本地服务器文件有效' },
@@ -131,53 +260,111 @@ export const vipSchema = {
{
key: 'vip1',
title: '一级会员',
titleKey: 'pay_user_vip_1_name',
titleFallback: '一级会员',
fields: [
{ key: 'vip_1_product_s', label: '允许购买一级会员', type: 'switch', default: true, dependsOn: { key: 'pay_user_vip_1_s', value: true } },
{
key: 'payment_vip_1_products',
label: '一级会员套餐',
type: 'textarea',
default: '30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡',
dependsOn: { key: 'vip_1_product_s', value: true },
help: '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久',
},
...vipLevelTabFields(1),
],
},
{
key: 'vip2',
title: '二级会员',
titleKey: 'pay_user_vip_2_name',
titleFallback: '二级会员',
fields: [
{ key: 'vip_2_product_s', label: '允许购买二级会员', type: 'switch', default: true, dependsOn: { key: 'pay_user_vip_2_s', value: true } },
{
key: 'payment_vip_2_products',
label: '二级会员套餐',
type: 'textarea',
default: '58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡',
dependsOn: { key: 'vip_2_product_s', value: true },
help: '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久',
},
...vipLevelTabFields(2),
],
},
{
key: 'renew',
title: '会员续费',
fields: [
{ key: 'vip_renew', label: '允许续费', type: 'switch', default: true, help: '关闭后则不能续费,只能会员到期之后再购买。永久会员不需要续费' },
{ key: 'vip_renew_desc', label: '续费说明', type: 'textarea', default: '', dependsOn: { key: 'vip_renew', value: true } },
{
key: 'vip_renew',
label: '允许续费',
type: 'switch',
default: true,
help: '关闭后则不能续费,只能会员到期之后再购买。永久会员不需要续费',
},
{
key: 'vip_renew_desc',
label: '续费介绍',
type: 'input',
sanitize_type: 'html',
default: '立即续费会员,畅享VIP权益',
dependsOn: { key: 'vip_renew', value: true },
help: '续费会员的一句话简介,支持HTML',
},
{
key: 'vip_renew_price_type',
label: '续费价格类型',
type: 'radio',
type: 'select',
default: 'discount',
dependsOn: { key: 'vip_renew', value: true },
options: [
{ label: '相同价格', value: 'same' },
{ label: '折扣价格', value: 'discount' },
{ label: '优惠立减', value: 'reduce' },
{ label: '自定义商品', value: 'customize' },
],
help: '自定义商品时,按下方「一级会员续费」「二级会员续费」的商品选项计价',
},
{
key: 'vip_renew_discount',
label: '续费折扣',
type: 'slider',
sanitize_type: 'float',
default: 8,
min: 0.1,
max: 9.9,
step: 0.1,
unit: '折',
dependsOn: { key: 'vip_renew_price_type', value: 'discount' },
help: '在会员商品基础上打几折,如 8 = 八折',
},
{
key: 'vip_renew_reduce',
label: '续费减免金额',
type: 'spinner',
sanitize_type: 'money_decimal',
default: '20.00',
min: 0,
max: 500,
step: 5,
unit: '元',
dependsOn: { key: 'vip_renew_price_type', value: 'reduce' },
help: '在会员商品基础上优惠的金额,优惠后总金额不能为0元',
},
{
key: 'vip_1_renew_product',
label: '一级会员续费',
type: 'group',
max: 8,
button_title: '添加续费商品',
default: [
{ price: 69, show_price: 199, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 169, show_price: 299, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
help: '一级会员续费的商品选项',
...vipProductGroupOptions,
fields: vipProductFields(),
},
{
key: 'vip_2_renew_product',
label: '二级会员续费',
type: 'group',
max: 8,
button_title: '添加续费商品',
default: [
{ price: 269, show_price: 599, tag: '<i class="fa fas fa-fw fa-bolt"></i> 限时特惠', time: 3, unit: 'month' },
{ price: 369, show_price: 899, tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', time: 6, unit: 'month' },
],
dependsOn: { key: 'vip_renew_price_type', value: 'customize' },
help: '二级会员续费的商品选项',
...vipProductGroupOptions,
fields: vipProductFields(),
},
{ key: 'vip_renew_discount', label: '续费折扣(折,如 8=八折)', type: 'number', default: 8, min: 1, max: 100, dependsOn: { key: 'vip_renew_price_type', value: 'discount' } },
{ key: 'vip_renew_reduce', label: '续费减免金额', type: 'input', sanitize_type: 'money_decimal', default: '20.00', min: 0, max: 9999, dependsOn: { key: 'vip_renew_price_type', value: 'reduce' } },
],
},
{
@@ -185,14 +372,50 @@ export const vipSchema = {
title: '会员升级',
fields: [
{ key: 'vip_upgrade', label: '允许升级', type: 'switch', default: true },
{ key: 'vip_upgrade_desc', label: '升级说明', type: 'textarea', default: '', dependsOn: { key: 'vip_upgrade', value: true } },
{ key: 'vip_upgrade_desc', label: '升级介绍', type: 'input', sanitize_type: 'html', default: '升级VIP会员,享更多会员权益', dependsOn: { key: 'vip_upgrade', value: true }, help: '升级会员的一句话简介,支持HTML' },
{
key: 'vip_upgrade_product',
label: '升级产品',
type: 'textarea',
default: 'unit|1|按剩余天数升级\njump|199|升级永久二级会员\npermanent|299|永久一级升永久二级',
label: '升级价格',
type: 'accordion',
default: {
month_to_month_s: true,
unit_price: '10.00',
unit_tag: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐',
unit_tag_class: 'jb-yellow',
jump_s: false,
jump_price: '199.00',
jump_show_price: '299.00',
jump_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
jump_tag_class: 'jb-red',
permanent_price: '199.00',
permanent_show_price: '299.00',
permanent_tag: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠',
permanent_tag_class: 'jb-red',
},
dependsOn: { key: 'vip_upgrade', value: true },
help: '每行一条。永久VIP1升级:permanent|价格|标题;按剩余天:unit|每日单价|标题;限时跳转永久:jump|价格|标题',
help: '会员升级的商品选项',
sections: [
{ title: '月费会员升级月费会员', keys: ['_upgrade_unit_notice', 'month_to_month_s', 'unit_price', 'unit_tag', 'unit_tag_class'] },
{ title: '月费会员升级永久会员', keys: ['_upgrade_jump_notice', 'jump_s', 'jump_price', 'jump_show_price', 'jump_tag', 'jump_tag_class'] },
{ title: '永久会员升级永久会员', keys: ['permanent_price', 'permanent_show_price', 'permanent_tag', 'permanent_tag_class'] },
],
fields: [
{ key: '_upgrade_unit_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '月费会员升级为月费会员,用户会员有效期不会改变;价格按照会员剩余天数计算升级价格,请在下方设置每天的单价' },
{ key: 'month_to_month_s', label: '月费会员升级月费会员', type: 'switch', default: true },
{ key: 'unit_price', label: '天单价', type: 'number', sanitize_type: 'money_decimal', default: '10.00', min: 0, unit: '元', dependsOn: { key: 'month_to_month_s', value: true } },
{ key: 'unit_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 站长推荐', rows: 1, dependsOn: { key: 'month_to_month_s', value: true }, help: '支持HTML,请注意控制长度' },
{ key: 'unit_tag_class', label: '标签颜色', type: 'palette', default: 'jb-yellow', dependsOn: { all: [{ key: 'month_to_month_s', value: true }, { key: 'unit_tag', operator: 'not_empty' }] }, options: vipTagPalette },
{ key: '_upgrade_jump_notice', label: '', type: 'notice', ui_only: true, alertType: 'warning', content: '如果网站可以购买月费会员和永久会员,则可以开启此项;允许用户由月费会员直接升级到更高一级的永久会员,用户之前还未到期的部分将自动一起转为永久会员,可能会涉及到差价问题,请自行告知用户;如果网站没有永久会员的购买选项请务必关闭此项' },
{ key: 'jump_s', label: '月费会员升级永久会员', type: 'switch', default: false },
{ key: 'jump_price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '199.00', min: 0, unit: '元', dependsOn: { key: 'jump_s', value: true }, help: '月费会员升级永久会员的价格' },
{ key: 'jump_show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '299.00', min: 0, unit: '元', dependsOn: { key: 'jump_s', value: true }, help: '显示在执行价格前面,并划掉' },
{ key: 'jump_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', rows: 1, dependsOn: { key: 'jump_s', value: true }, help: '支持HTML,请注意控制长度' },
{ key: 'jump_tag_class', label: '标签颜色', type: 'palette', default: 'jb-red', dependsOn: { all: [{ key: 'jump_s', value: true }, { key: 'jump_tag', operator: 'not_empty' }] }, options: vipTagPalette },
{ key: 'permanent_price', label: '执行价', type: 'number', sanitize_type: 'money_decimal', default: '199.00', min: 0, unit: '元', help: '永久会员升级永久会员的价格' },
{ key: 'permanent_show_price', label: '原价', type: 'number', sanitize_type: 'money_decimal', default: '299.00', min: 0, unit: '元', help: '显示在执行价格前面,并划掉' },
{ key: 'permanent_tag', label: '促销标签', type: 'textarea', sanitize_type: 'html', default: '<i class="fa fas fa-fw fa-bolt"></i> 升级特惠', rows: 1, help: '支持HTML,请注意控制长度' },
{ key: 'permanent_tag_class', label: '标签颜色', type: 'palette', default: 'jb-red', dependsOn: { key: 'permanent_tag', operator: 'not_empty' }, options: vipTagPalette },
],
},
],
},
@@ -205,16 +428,72 @@ export const vipSchema = {
title: '积分兑换会员',
icon: 'fa fas fa-database',
fields: [
{ key: 'pay_vip_points_exchange_s', label: '启用积分兑换', type: 'switch', default: false },
{ key: 'pay_vip_points_exchange_s', label: '启用积分兑换', type: 'switch', default: false, help: '开启后用户可使用积分兑换会员;由于积分可免费获取,所以积分兑换会员功能不支持升级及续费' },
{
key: 'payment_vip_points_packages',
label: '兑换套餐',
type: 'textarea',
default: '1|500|30|积分月卡\n1|5000|0|积分永久',
key: 'pay_vip_points_exchange_product',
label: '积分兑换明细',
type: 'group',
max: 16,
button_title: '添加积分兑换商品',
default: [
{ points: 500, level: 1, time: 30, unit: 'day' },
{ points: 5000, level: 1, time: 'Permanent' },
],
dependsOn: { key: 'pay_vip_points_exchange_s', value: true },
help: '每行:等级|积分|天数|标题(天数 0=永久;仅未开通会员可兑)',
help: '每一行为一个可兑换的会员套餐',
...vipPointsGroupOptions,
fields: [
{ key: 'points', label: '使用积分', type: 'number', default: 100, min: 0, unit: '积分' },
{
key: 'level',
label: '兑换会员',
type: 'radio',
default: 1,
inline: true,
options: [
{ label: '一级会员', value: 1 },
{ label: '二级会员', value: 2 },
],
},
{ key: 'time', label: '兑换时长', type: 'input', default: '', help: '填 Permanent 为永久' },
{
key: 'unit',
label: '时长单位',
type: 'radio',
default: 'day',
inline: true,
dependsOn: { key: 'time', operator: 'neq', value: 'Permanent' },
options: vipUnitOptions,
},
],
},
{ key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 2, dependsOn: { key: 'pay_vip_points_exchange_s', value: true }, help: '显示在积分兑换弹窗底部,可以为提醒事项、兑换协议等,支持HTML代码' },
],
},
{
key: 'modal-copy',
title: '界面文案',
icon: 'fa fas fa-align-left',
fields: [
{
key: 'pay_user_vip_desc',
label: '会员介绍',
type: 'input',
sanitize_type: 'html',
default: '开通VIP会员,享受会员专属折扣以及多项特权',
dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] },
help: '显示在开通界面顶部一句话简介,可以为会员权益简介或者活动介绍,支持HTML',
},
{
key: 'pay_user_vip_more',
label: '开通会员更多内容',
type: 'textarea',
sanitize_type: 'html',
default: '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>',
rows: 4,
dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] },
help: '显示在开通界面底部位置,可以为提醒事项、用户协议等,支持HTML代码',
},
{ key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', default: '', dependsOn: { key: 'pay_vip_points_exchange_s', value: true } },
],
},
],
@@ -10,7 +10,6 @@ import { featuresSchema } from './features.js'
import { verifySchema } from './verify.js'
import { levelSchema } from './level.js'
import { badgeSchema } from './badge.js'
import { wechatSchema } from './wechat.js'
import { reportSchema } from './report.js'
import { smsSchema } from './sms.js'
@@ -23,7 +22,6 @@ export const usersSchemas = {
verify: verifySchema,
level: levelSchema,
badge: badgeSchema,
wechat: wechatSchema,
report: reportSchema,
sms: smsSchema,
}
@@ -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);
}
/* .widget a 的链接色不得覆盖 .but 按钮自身配色(zibll 的 zib-widget 不设置链接色) */
.widget a.but {
/* .widget a 的链接色不得覆盖 .but / .img-badge 自身配色(zibll 的 zib-widget 不设置链接色) */
.widget a.but,
.widget a.img-badge {
color: var(--this-color, #fff);
}
+1 -1
View File
@@ -1 +1 @@
.posts-row{margin-right:-8px;margin-bottom:15px;margin-left:-8px}.posts-row>*:not(.post_ajax_loader){margin-right:8px!important;margin-left:8px!important}.posts-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
+117 -349
View File
@@ -916,6 +916,7 @@ strong, b {
/* === Full width === */
.btn-block {
display: block;
width: 100%;
}
@@ -961,6 +962,8 @@ strong, b {
.but > .fa:not(.fa-angle-right),
.but > i[class*="fa-"]:not(.fa-angle-right) { margin-right: 0.25em; }
.but.block { display: block; }
/* hover / active */
.but:focus,
.but:hover { opacity: 0.8; color: var(--this-color); }
@@ -1092,7 +1095,7 @@ strong, b {
/* gradient modifiers (legacy .jb-*) */
.jb-red { --this-bg: linear-gradient(135deg, #fd7a64 10%, #fb2d2d 100%); }
.jb-yellow { --this-bg: linear-gradient(135deg, #f99d4d 10%, #f7631d 100%); }
.jb-yellow { --this-bg: linear-gradient(135deg, #f59f54 10%, #ff6922 100%); }
.jb-blue { --this-bg: linear-gradient(135deg, #59c3fb 10%, #268df7 100%); }
.jb-green { --this-bg: linear-gradient(135deg, #60e464 10%, #5cb85b 100%); }
.jb-purple { --this-bg: linear-gradient(135deg, #f98dfb 10%, #ea00f9 100%); }
@@ -2062,6 +2065,22 @@ html.dark .alert--info { color: #93C5FD; }
.aksrt-avatar--lg { width: var(--aksrt-avatar-size-lg); height: var(--aksrt-avatar-size-lg); }
.aksrt-avatar--xl { width: var(--aksrt-avatar-size-xl); height: var(--aksrt-avatar-size-xl); }
/* ============================================================
* 8b. 头像形状统一 — 圆形基底
* --radius-avatar 是前端用户头像形状的唯一来源。任何带头像类名的
* <img>(含未包 .avatar-img 的裸 img)都必须是正圆,供后续头像
* 挂件/装扮叠加。特异性 0,1,1 高于 main.css 的 .avatar{100px},
* 同值为圆;路由 CSS 中显式声明圆角的头像框需各自对齐。
* ============================================================ */
img.avatar,
img.avatar-mini,
img.avatar-md,
img.avatar-lg,
.avatar-img img {
border-radius: var(--radius-avatar, 50%);
object-fit: cover;
}
/* ============================================================
* 9. 涟漪点击效果 — .aksrt-ripple
* 容器加此类,点击时 JS 注入 span.aksrt-ripple__wave
@@ -2638,7 +2657,8 @@ main.container,
.user-card.author .user-name > name {
overflow: visible;
}
.user-card.author .user-name > name > .display-name {
.user-card.author .user-name > name > .display-name,
.user-card.widget .user-name > name > .display-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -2688,31 +2708,35 @@ main.container,
/**
* Header account popover.
*
* The shared legacy navigation styles provide the popover shell. This module
* owns AKSRT-specific account content such as check-in, wallet and VIP rows.
* The shared legacy navigation styles provide the popover shell, and every
* account row (avatar, VIP bar, check-in, wallet tiles, stats badges) renders
* through the reference legacy classes. This module only owns the navigation
* shell anchoring plus the account action-row geometry.
*/
/* The desktop action cluster owns one horizontal alignment context. */
.navbar-top .aksrt-navbar-actions {
display: flex;
align-items: center;
gap: 10px;
min-height: 50px;
margin-top: 0;
margin-bottom: 0;
padding: 0;
}
.navbar-top .aksrt-navbar-actions > *,
.navbar-top .aksrt-navbar-account,
.navbar-top .aksrt-navbar-account > li {
/* The reference keeps the right cluster as three floated .navbar-form groups.
* Bootstrap's base .navbar-form rule carries margin-top: 8px and the >=768px
* override only zeroes the horizontal margins, so every right-cluster group
* starts 8px below the top of .navbar-collapse. The 50px menu row and the logo
* put the navbar midline at 25px, so a 34px cross-size lands a group's midline
* at 8 + 17 = 25px. The publish/VIP chips already reach that line through their
* own 0.4em margin stacked on the group's 8px; the account row reaches it from
* its 34px icon tiles, and the theme group needs the same cross-size because
* its 29px toggle is otherwise 2.5px high. Nothing here may restate a 50px
* min-height: it would double-count the 8px and push that group's content 8px
* below the line while inflating the navbar from 50px to 58px. */
.navbar-top .aksrt-navbar-account {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 4px;
margin: 0;
}
.navbar-top .aksrt-navbar-account > li {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
padding: 0;
overflow: visible;
}
@@ -2725,39 +2749,6 @@ main.container,
margin: 0;
}
.navbar-top .aksrt-navbar-account {
gap: 4px;
min-height: 50px;
margin: 0;
}
.navbar-top .aksrt-navbar-account.navbar-text {
float: none;
}
.navbar-top .aksrt-navbar-actions .navbar-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 29px;
margin: 0;
padding: .3em 1em !important;
border-radius: var(--radius-btn, 4px);
font-size: .8em;
line-height: 1.44;
}
.navbar-top .aksrt-navbar-actions .navbar-cta.radius {
border-radius: 50px;
}
.navbar-top .aksrt-navbar-actions .navbar-cta > .icon {
width: 1.2em;
height: 1.2em;
flex: 0 0 auto;
}
.navbar-top .aksrt-nav-icon,
.navbar-top .aksrt-nav-avatar {
display: inline-flex !important;
@@ -2790,33 +2781,34 @@ main.container,
object-fit: cover;
}
.navbar-top .aksrt-navbar-actions .toggle-radius {
/* The account-row chips, the theme and the language toggles all want the 29px
* midline circle. The popover's quick-login buttons are .toggle-radius too and
* also live inside .navbar-form, but they must keep the .social_loginbar
* geometry from main.css: in a flex container flex-basis outranks width, so
* without this exemption the buttons collapse to 29px and lose the 5px side
* margins that space them apart. */
.navbar-top .navbar-form .toggle-radius:not(.social-login-item):not(.xh-social-item) {
flex: 0 0 29px;
margin: 0;
vertical-align: middle;
}
.navbar-top .aksrt-navbar-language {
/* Match the account row's 34px cross-size so the 29px toggle shares the 25px
* midline instead of sitting 2.5px above it. */
.navbar-top .navbar-form.flex.ac {
min-height: 34px;
}
.navbar-top .navbar-form .aksrt-navbar-language {
margin: 0;
}
/* Anchor the VIP preview to the full navigation row instead of relying on
* the absolute panel's static position beside the compact CTA. */
.navbar-top .aksrt-navbar-actions .navbar-vip-hover {
position: relative;
align-self: stretch;
align-items: center;
overflow: visible;
}
.navbar-top .aksrt-navbar-actions .navbar-vip-hover > .sub-vip-card {
top: 100%;
right: 0;
left: auto;
width: max-content;
max-width: calc(100vw - 24px);
margin: 0;
z-index: 1002;
/* The reference keeps the publish/VIP hover wrapper a plain inline-block, so the
* absolutely positioned .sub-vip-card takes its normal-flow static position.
* The shared action cluster otherwise forces inline-flex on every child, which
* would move that static position to the flex container's cross-start. */
.navbar-top .navbar-but > .hover-show {
display: inline-block;
}
/* Keep the drop-down search panel above a fixed header (z-index: 999). */
@@ -2852,85 +2844,41 @@ main.container,
}
.aksrt-header-userbox {
width: 260px;
color: var(--color-ink-2);
line-height: 1.42857143;
}
.aksrt-header-userbox .user-info {
min-height: 44px;
padding: 2px;
/* public.css redeclares .muted-box after the legacy utility rules, which
* otherwise overrides the wallet tiles' padding-6 and radius4 utilities. */
.aksrt-header-userbox .muted-box.padding-6 {
background-color: var(--this-bg, var(--color-line-soft));
border-radius: 4px;
padding: 6px;
}
.aksrt-header-userbox .user-info > .avatar {
flex: 0 0 44px;
width: 44px;
height: 44px;
object-fit: cover;
/* core/buttons.css restates the legacy .but block after main.css, so its
* background / color / border / text-shadow declarations outrank .payvip-icon
* and replace the payvip surface with whatever .jb-* skin is on the element.
* The reference theme has no such compatibility layer, so its .payvip-icon
* declarations are the last word in the cascade. Re-assert them here, which
* loads after core/buttons.css, so every payvip surface (top-nav chip, user
* card status bar and CTA, guest CTA) keeps the legacy metallic gradient. */
.payvip-icon {
background: linear-gradient(300deg, #4c4d51, #2a2a31 15%, #85858a 40%, #393a3c 60%, #393838 80%, #5e5f62 100%);
color: #ece0e0;
--this-color: #ece0e0;
border: none;
text-shadow: none;
}
.aksrt-header-userbox .user-right,
.aksrt-header-userbox .user-right > .flex1 {
min-width: 0;
}
.aksrt-header-userbox .user-right b {
display: flex;
align-items: center;
gap: 4px;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.aksrt-header-userbox .display-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aksrt-header-userbox .user-right .img-icon,
.aksrt-header-userbox .user-right .aksrt-user-level-icon,
.aksrt-header-userbox .user-right .aksrt-identity-medal,
.aksrt-header-userbox .user-right .aksrt-header-user-medal,
.aksrt-header-userbox .user-right .aksrt-identity-certification-icon {
flex: 0 0 auto;
height: 1.1em;
}
.aksrt-header-userbox .user-info > .abs-right {
top: 50%;
right: 2px;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--surface-2);
color: var(--color-ink-3);
}
.aksrt-header-userbox .aksrt-notification-badge {
position: absolute;
top: -4px;
right: -4px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 17px;
height: 17px;
padding: 0 4px;
border: 2px solid var(--surface-1);
border-radius: 999px;
background: #e74c3c;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 1;
/* Keep the header check-in action flush with the VIP row's padded inner width. */
.aksrt-header-userbox > .mt6.em09 > .but.block {
display: block;
box-sizing: border-box;
width: 100%;
}
/* Top-nav bell badge (the card itself uses the legacy badge.top element). */
.aksrt-nav-notification .aksrt-notification-badge {
position: absolute;
top: 2px;
@@ -2950,222 +2898,20 @@ main.container,
line-height: 1;
}
.aksrt-header-wallet {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 9px 10px;
border-radius: 8px;
background: var(--surface-2);
color: var(--color-ink-2);
font-size: 12px;
line-height: 1.35;
}
.aksrt-header-wallet:hover {
color: var(--color-ink);
}
.aksrt-header-wallet__item {
min-width: 0;
white-space: nowrap;
}
.aksrt-header-wallet__item i {
margin-right: 2px;
color: var(--color-ink-3);
}
.aksrt-header-wallet__item b {
margin-left: 2px;
color: var(--color-ink);
font-weight: 700;
}
.aksrt-header-checkin {
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 6px;
width: 100% !important;
min-height: 36px;
margin: 0;
padding: 8px 10px !important;
border: 0 !important;
border-radius: 6px !important;
background: #fff6db !important;
color: #8a5a00 !important;
font-size: 13px !important;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
}
.aksrt-header-checkin > i {
margin: 0 !important;
color: #b78103 !important;
}
.aksrt-header-checkin:hover {
background: #fff0c2 !important;
color: #8a5a00 !important;
}
/* 已签到态对齐参考主题:保留原配色(参考主题签到后仍为同一彩色按钮),
* 且按钮可点击(打开签到详情弹窗);仅请求进行中的禁用态降不透明度。 */
.aksrt-header-checkin:disabled,
.aksrt-header-checkin:disabled:hover {
cursor: default;
opacity: .85;
}
.aksrt-header-payvip__cta,
.aksrt-header-payvip:not(.is-vip) .payvip-icon,
.aksrt-header-payvip:not(.is-vip) .but {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
width: 100% !important;
min-width: 0 !important;
min-height: 36px;
margin: 0 !important;
padding: 8px 10px !important;
border: 0 !important;
border-radius: 6px !important;
background: linear-gradient(90deg, #ff6b6b, #f04494) !important;
color: #fff !important;
font-size: 13px !important;
font-weight: 600;
line-height: 1.3;
text-align: center;
white-space: nowrap;
}
.aksrt-header-payvip__cta > i,
.aksrt-header-payvip:not(.is-vip) .payvip-icon > i,
.aksrt-header-payvip:not(.is-vip) .but > i {
margin: 0 !important;
color: inherit !important;
}
.aksrt-header-payvip__cta:hover {
color: #fff !important;
opacity: .92;
}
.aksrt-header-payvip__active {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px !important;
width: 100%;
min-height: 42px;
padding: 7px 10px !important;
white-space: nowrap;
}
.aksrt-header-payvip__level {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.aksrt-header-payvip__level > i {
margin: 0 !important;
}
.aksrt-header-payvip__exp {
display: inline;
margin: 0;
padding: 0;
border: 0;
background: none !important;
box-shadow: none;
color: inherit;
font-size: .85em;
font-weight: 400;
line-height: 1.3;
opacity: .9;
white-space: nowrap;
}
/* Navigation links are globally capped at 50px. Account actions are taller
* icon tiles and must opt out or their labels overlap the following row. */
.navbar-top .aksrt-header-userbox .header-user-href {
align-items: flex-start;
gap: 4px;
}
* icon tiles and must opt out or their labels overlap the following row.
* The tile geometry itself stays with the shared legacy rules
* (.header-user-href > a and .header-user-href .toggle-radius in main.css)
* so the action row keeps the reference proportions. */
.navbar-top .aksrt-header-userbox .header-user-href > a,
.aksrt-header-userbox .header-user-href > a {
display: flex;
flex: 1 1 0;
flex-direction: column;
align-items: center;
min-width: 0;
max-height: none;
padding: 10px 2px 2px;
line-height: 1.35;
}
.aksrt-header-userbox .header-user-href > a:hover {
opacity: .75;
}
.aksrt-header-userbox .header-user-href .toggle-radius {
flex: 0 0 36px;
width: 36px;
height: 36px;
}
.aksrt-header-userbox .header-user-href > a > div:last-child {
max-width: 100%;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-navbar .aksrt-header-userbox {
width: 100%;
width: auto;
max-width: 400px;
margin-right: auto;
margin-left: auto;
}
body.dark-theme .aksrt-header-checkin {
background: rgba(255, 193, 7, .18) !important;
color: #f0c14b !important;
}
body.dark-theme .aksrt-header-checkin > i {
color: #f0c14b !important;
}
body.dark-theme .aksrt-header-wallet {
background: rgba(255, 255, 255, .06);
}
body.dark-theme .aksrt-header-wallet__item,
body.dark-theme .aksrt-header-wallet__item b {
color: inherit;
}
@media (max-width: 360px) {
.aksrt-header-userbox .header-user-href {
gap: 2px;
}
.aksrt-header-userbox .header-user-href .toggle-radius {
flex-basis: 34px;
width: 34px;
height: 34px;
}
margin: auto;
}
/* source: assets/css/frontend/card-primitives.css */
@@ -6693,6 +6439,28 @@ body.aksrt-modal-runtime-open {
.aksrt-after-sale-status.is-rejected,
.aksrt-after-sale-status.is-cancelled { color: var(--color-error, #ef4444); }
/* 售后处理记录时间线:[节点][处理状态][时间] 一行,处理备注另起一行并对齐状态文字。
售后详细弹窗与订单详情弹窗的「售后记录」共用同一套标记;竖线为条目的
::before,末项不收口,避免出现悬空的线段。 */
.aksrt-after-sale-log { display: grid; gap: 6px; }
.aksrt-after-sale-log__empty { color: var(--modal-muted); font-size: 12px; }
.aksrt-after-sale-timeline { display: grid; margin: 0; padding: 0; list-style: none; }
.aksrt-after-sale-timeline__item { --as-accent: var(--modal-muted); position: relative; display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 10px; padding: 0 0 14px; }
.aksrt-after-sale-timeline__item:last-child { padding-bottom: 0; }
.aksrt-after-sale-timeline__item.is-pending { --as-accent: var(--modal-warning); }
.aksrt-after-sale-timeline__item.is-processing { --as-accent: var(--modal-primary); }
.aksrt-after-sale-timeline__item.is-completed { --as-accent: var(--modal-success); }
.aksrt-after-sale-timeline__item.is-rejected,
.aksrt-after-sale-timeline__item.is-cancelled { --as-accent: var(--modal-danger, #ef4444); }
.aksrt-after-sale-timeline__item::before { content: ""; position: absolute; top: 14px; bottom: -2px; left: 4px; width: 2px; border-radius: 1px; background: color-mix(in srgb, var(--as-accent) 30%, var(--modal-line)); }
.aksrt-after-sale-timeline__item:last-child::before { display: none; }
.aksrt-after-sale-timeline__knot { position: relative; z-index: 1; width: 10px; height: 10px; margin-top: 4px; border: 2px solid var(--as-accent); border-radius: 50%; background: var(--modal-surface, #fff); }
.aksrt-after-sale-timeline__body { display: grid; gap: 3px; min-width: 0; }
.aksrt-after-sale-timeline__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
.aksrt-after-sale-timeline__status { color: var(--as-accent); font-size: 12px; font-weight: 700; line-height: 1.5; }
.aksrt-after-sale-timeline__time { color: var(--modal-muted); font-size: 11px; line-height: 1.5; font-variant-numeric: tabular-nums; }
.aksrt-after-sale-timeline__note { margin: 0; color: var(--modal-ink); font-size: 12.5px; line-height: 1.65; word-break: break-word; }
/* 状态行右侧单元格:状态徽章 + 「撤销售后」入口(仅待审核工单渲染)。 */
.aksrt-after-sale-detail__statusCell { display: inline-flex; align-items: center; gap: 10px; }
.aksrt-after-sale-withdraw { display: inline-flex; align-items: center; gap: 3px; padding: 0; border: 0; background: transparent; color: var(--modal-danger, #ef4444); font-size: 12px; font-weight: 600; line-height: 1.4; cursor: pointer; transition: color .15s ease; }
+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 === */
.btn-block {
display: block;
width: 100%;
}
@@ -182,6 +183,8 @@
.but > .fa:not(.fa-angle-right),
.but > i[class*="fa-"]:not(.fa-angle-right) { margin-right: 0.25em; }
.but.block { display: block; }
/* hover / active */
.but:focus,
.but:hover { opacity: 0.8; color: var(--this-color); }
@@ -313,7 +316,7 @@
/* gradient modifiers (legacy .jb-*) */
.jb-red { --this-bg: linear-gradient(135deg, #fd7a64 10%, #fb2d2d 100%); }
.jb-yellow { --this-bg: linear-gradient(135deg, #f99d4d 10%, #f7631d 100%); }
.jb-yellow { --this-bg: linear-gradient(135deg, #f59f54 10%, #ff6922 100%); }
.jb-blue { --this-bg: linear-gradient(135deg, #59c3fb 10%, #268df7 100%); }
.jb-green { --this-bg: linear-gradient(135deg, #60e464 10%, #5cb85b 100%); }
.jb-purple { --this-bg: linear-gradient(135deg, #f98dfb 10%, #ea00f9 100%); }
+16
View File
@@ -424,6 +424,22 @@
.aksrt-avatar--lg { width: var(--aksrt-avatar-size-lg); height: var(--aksrt-avatar-size-lg); }
.aksrt-avatar--xl { width: var(--aksrt-avatar-size-xl); height: var(--aksrt-avatar-size-xl); }
/* ============================================================
* 8b. 头像形状统一 — 圆形基底
* --radius-avatar 是前端用户头像形状的唯一来源。任何带头像类名的
* <img>(含未包 .avatar-img 的裸 img)都必须是正圆,供后续头像
* 挂件/装扮叠加。特异性 0,1,1 高于 main.css 的 .avatar{100px},
* 同值为圆;路由 CSS 中显式声明圆角的头像框需各自对齐。
* ============================================================ */
img.avatar,
img.avatar-mini,
img.avatar-md,
img.avatar-lg,
.avatar-img img {
border-radius: var(--radius-avatar, 50%);
object-fit: cover;
}
/* ============================================================
* 9. 涟漪点击效果 — .aksrt-ripple
* 容器加此类,点击时 JS 注入 span.aksrt-ripple__wave
+2 -1
View File
@@ -242,7 +242,8 @@ main.container,
.user-card.author .user-name > name {
overflow: visible;
}
.user-card.author .user-name > name > .display-name {
.user-card.author .user-name > name > .display-name,
.user-card.widget .user-name > name > .display-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
+73 -347
View File
@@ -1,31 +1,35 @@
/**
* Header account popover.
*
* The shared legacy navigation styles provide the popover shell. This module
* owns AKSRT-specific account content such as check-in, wallet and VIP rows.
* The shared legacy navigation styles provide the popover shell, and every
* account row (avatar, VIP bar, check-in, wallet tiles, stats badges) renders
* through the reference legacy classes. This module only owns the navigation
* shell anchoring plus the account action-row geometry.
*/
/* The desktop action cluster owns one horizontal alignment context. */
.navbar-top .aksrt-navbar-actions {
display: flex;
align-items: center;
gap: 10px;
min-height: 50px;
margin-top: 0;
margin-bottom: 0;
padding: 0;
}
.navbar-top .aksrt-navbar-actions > *,
.navbar-top .aksrt-navbar-account,
.navbar-top .aksrt-navbar-account > li {
/* The reference keeps the right cluster as three floated .navbar-form groups.
* Bootstrap's base .navbar-form rule carries margin-top: 8px and the >=768px
* override only zeroes the horizontal margins, so every right-cluster group
* starts 8px below the top of .navbar-collapse. The 50px menu row and the logo
* put the navbar midline at 25px, so a 34px cross-size lands a group's midline
* at 8 + 17 = 25px. The publish/VIP chips already reach that line through their
* own 0.4em margin stacked on the group's 8px; the account row reaches it from
* its 34px icon tiles, and the theme group needs the same cross-size because
* its 29px toggle is otherwise 2.5px high. Nothing here may restate a 50px
* min-height: it would double-count the 8px and push that group's content 8px
* below the line while inflating the navbar from 50px to 58px. */
.navbar-top .aksrt-navbar-account {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 4px;
margin: 0;
}
.navbar-top .aksrt-navbar-account > li {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
padding: 0;
overflow: visible;
}
@@ -38,39 +42,6 @@
margin: 0;
}
.navbar-top .aksrt-navbar-account {
gap: 4px;
min-height: 50px;
margin: 0;
}
.navbar-top .aksrt-navbar-account.navbar-text {
float: none;
}
.navbar-top .aksrt-navbar-actions .navbar-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 29px;
margin: 0;
padding: .3em 1em !important;
border-radius: var(--radius-btn, 4px);
font-size: .8em;
line-height: 1.44;
}
.navbar-top .aksrt-navbar-actions .navbar-cta.radius {
border-radius: 50px;
}
.navbar-top .aksrt-navbar-actions .navbar-cta > .icon {
width: 1.2em;
height: 1.2em;
flex: 0 0 auto;
}
.navbar-top .aksrt-nav-icon,
.navbar-top .aksrt-nav-avatar {
display: inline-flex !important;
@@ -103,33 +74,34 @@
object-fit: cover;
}
.navbar-top .aksrt-navbar-actions .toggle-radius {
/* The account-row chips, the theme and the language toggles all want the 29px
* midline circle. The popover's quick-login buttons are .toggle-radius too and
* also live inside .navbar-form, but they must keep the .social_loginbar
* geometry from main.css: in a flex container flex-basis outranks width, so
* without this exemption the buttons collapse to 29px and lose the 5px side
* margins that space them apart. */
.navbar-top .navbar-form .toggle-radius:not(.social-login-item):not(.xh-social-item) {
flex: 0 0 29px;
margin: 0;
vertical-align: middle;
}
.navbar-top .aksrt-navbar-language {
/* Match the account row's 34px cross-size so the 29px toggle shares the 25px
* midline instead of sitting 2.5px above it. */
.navbar-top .navbar-form.flex.ac {
min-height: 34px;
}
.navbar-top .navbar-form .aksrt-navbar-language {
margin: 0;
}
/* Anchor the VIP preview to the full navigation row instead of relying on
* the absolute panel's static position beside the compact CTA. */
.navbar-top .aksrt-navbar-actions .navbar-vip-hover {
position: relative;
align-self: stretch;
align-items: center;
overflow: visible;
}
.navbar-top .aksrt-navbar-actions .navbar-vip-hover > .sub-vip-card {
top: 100%;
right: 0;
left: auto;
width: max-content;
max-width: calc(100vw - 24px);
margin: 0;
z-index: 1002;
/* The reference keeps the publish/VIP hover wrapper a plain inline-block, so the
* absolutely positioned .sub-vip-card takes its normal-flow static position.
* The shared action cluster otherwise forces inline-flex on every child, which
* would move that static position to the flex container's cross-start. */
.navbar-top .navbar-but > .hover-show {
display: inline-block;
}
/* Keep the drop-down search panel above a fixed header (z-index: 999). */
@@ -165,85 +137,41 @@
}
.aksrt-header-userbox {
width: 260px;
color: var(--color-ink-2);
line-height: 1.42857143;
}
.aksrt-header-userbox .user-info {
min-height: 44px;
padding: 2px;
/* public.css redeclares .muted-box after the legacy utility rules, which
* otherwise overrides the wallet tiles' padding-6 and radius4 utilities. */
.aksrt-header-userbox .muted-box.padding-6 {
background-color: var(--this-bg, var(--color-line-soft));
border-radius: 4px;
padding: 6px;
}
.aksrt-header-userbox .user-info > .avatar {
flex: 0 0 44px;
width: 44px;
height: 44px;
object-fit: cover;
/* core/buttons.css restates the legacy .but block after main.css, so its
* background / color / border / text-shadow declarations outrank .payvip-icon
* and replace the payvip surface with whatever .jb-* skin is on the element.
* The reference theme has no such compatibility layer, so its .payvip-icon
* declarations are the last word in the cascade. Re-assert them here, which
* loads after core/buttons.css, so every payvip surface (top-nav chip, user
* card status bar and CTA, guest CTA) keeps the legacy metallic gradient. */
.payvip-icon {
background: linear-gradient(300deg, #4c4d51, #2a2a31 15%, #85858a 40%, #393a3c 60%, #393838 80%, #5e5f62 100%);
color: #ece0e0;
--this-color: #ece0e0;
border: none;
text-shadow: none;
}
.aksrt-header-userbox .user-right,
.aksrt-header-userbox .user-right > .flex1 {
min-width: 0;
}
.aksrt-header-userbox .user-right b {
display: flex;
align-items: center;
gap: 4px;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.aksrt-header-userbox .display-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aksrt-header-userbox .user-right .img-icon,
.aksrt-header-userbox .user-right .aksrt-user-level-icon,
.aksrt-header-userbox .user-right .aksrt-identity-medal,
.aksrt-header-userbox .user-right .aksrt-header-user-medal,
.aksrt-header-userbox .user-right .aksrt-identity-certification-icon {
flex: 0 0 auto;
height: 1.1em;
}
.aksrt-header-userbox .user-info > .abs-right {
top: 50%;
right: 2px;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--surface-2);
color: var(--color-ink-3);
}
.aksrt-header-userbox .aksrt-notification-badge {
position: absolute;
top: -4px;
right: -4px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 17px;
height: 17px;
padding: 0 4px;
border: 2px solid var(--surface-1);
border-radius: 999px;
background: #e74c3c;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 1;
/* Keep the header check-in action flush with the VIP row's padded inner width. */
.aksrt-header-userbox > .mt6.em09 > .but.block {
display: block;
box-sizing: border-box;
width: 100%;
}
/* Top-nav bell badge (the card itself uses the legacy badge.top element). */
.aksrt-nav-notification .aksrt-notification-badge {
position: absolute;
top: 2px;
@@ -263,220 +191,18 @@
line-height: 1;
}
.aksrt-header-wallet {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 9px 10px;
border-radius: 8px;
background: var(--surface-2);
color: var(--color-ink-2);
font-size: 12px;
line-height: 1.35;
}
.aksrt-header-wallet:hover {
color: var(--color-ink);
}
.aksrt-header-wallet__item {
min-width: 0;
white-space: nowrap;
}
.aksrt-header-wallet__item i {
margin-right: 2px;
color: var(--color-ink-3);
}
.aksrt-header-wallet__item b {
margin-left: 2px;
color: var(--color-ink);
font-weight: 700;
}
.aksrt-header-checkin {
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 6px;
width: 100% !important;
min-height: 36px;
margin: 0;
padding: 8px 10px !important;
border: 0 !important;
border-radius: 6px !important;
background: #fff6db !important;
color: #8a5a00 !important;
font-size: 13px !important;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
}
.aksrt-header-checkin > i {
margin: 0 !important;
color: #b78103 !important;
}
.aksrt-header-checkin:hover {
background: #fff0c2 !important;
color: #8a5a00 !important;
}
/* 已签到态对齐参考主题:保留原配色(参考主题签到后仍为同一彩色按钮),
* 且按钮可点击(打开签到详情弹窗);仅请求进行中的禁用态降不透明度。 */
.aksrt-header-checkin:disabled,
.aksrt-header-checkin:disabled:hover {
cursor: default;
opacity: .85;
}
.aksrt-header-payvip__cta,
.aksrt-header-payvip:not(.is-vip) .payvip-icon,
.aksrt-header-payvip:not(.is-vip) .but {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
width: 100% !important;
min-width: 0 !important;
min-height: 36px;
margin: 0 !important;
padding: 8px 10px !important;
border: 0 !important;
border-radius: 6px !important;
background: linear-gradient(90deg, #ff6b6b, #f04494) !important;
color: #fff !important;
font-size: 13px !important;
font-weight: 600;
line-height: 1.3;
text-align: center;
white-space: nowrap;
}
.aksrt-header-payvip__cta > i,
.aksrt-header-payvip:not(.is-vip) .payvip-icon > i,
.aksrt-header-payvip:not(.is-vip) .but > i {
margin: 0 !important;
color: inherit !important;
}
.aksrt-header-payvip__cta:hover {
color: #fff !important;
opacity: .92;
}
.aksrt-header-payvip__active {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px !important;
width: 100%;
min-height: 42px;
padding: 7px 10px !important;
white-space: nowrap;
}
.aksrt-header-payvip__level {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.aksrt-header-payvip__level > i {
margin: 0 !important;
}
.aksrt-header-payvip__exp {
display: inline;
margin: 0;
padding: 0;
border: 0;
background: none !important;
box-shadow: none;
color: inherit;
font-size: .85em;
font-weight: 400;
line-height: 1.3;
opacity: .9;
white-space: nowrap;
}
/* Navigation links are globally capped at 50px. Account actions are taller
* icon tiles and must opt out or their labels overlap the following row. */
.navbar-top .aksrt-header-userbox .header-user-href {
align-items: flex-start;
gap: 4px;
}
* icon tiles and must opt out or their labels overlap the following row.
* The tile geometry itself stays with the shared legacy rules
* (.header-user-href > a and .header-user-href .toggle-radius in main.css)
* so the action row keeps the reference proportions. */
.navbar-top .aksrt-header-userbox .header-user-href > a,
.aksrt-header-userbox .header-user-href > a {
display: flex;
flex: 1 1 0;
flex-direction: column;
align-items: center;
min-width: 0;
max-height: none;
padding: 10px 2px 2px;
line-height: 1.35;
}
.aksrt-header-userbox .header-user-href > a:hover {
opacity: .75;
}
.aksrt-header-userbox .header-user-href .toggle-radius {
flex: 0 0 36px;
width: 36px;
height: 36px;
}
.aksrt-header-userbox .header-user-href > a > div:last-child {
max-width: 100%;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-navbar .aksrt-header-userbox {
width: 100%;
width: auto;
max-width: 400px;
margin-right: auto;
margin-left: auto;
}
body.dark-theme .aksrt-header-checkin {
background: rgba(255, 193, 7, .18) !important;
color: #f0c14b !important;
}
body.dark-theme .aksrt-header-checkin > i {
color: #f0c14b !important;
}
body.dark-theme .aksrt-header-wallet {
background: rgba(255, 255, 255, .06);
}
body.dark-theme .aksrt-header-wallet__item,
body.dark-theme .aksrt-header-wallet__item b {
color: inherit;
}
@media (max-width: 360px) {
.aksrt-header-userbox .header-user-href {
gap: 2px;
}
.aksrt-header-userbox .header-user-href .toggle-radius {
flex-basis: 34px;
width: 34px;
height: 34px;
}
margin: auto;
}
+22
View File
@@ -816,6 +816,28 @@ body.aksrt-modal-runtime-open {
.aksrt-after-sale-status.is-rejected,
.aksrt-after-sale-status.is-cancelled { color: var(--color-error, #ef4444); }
/* 售后处理记录时间线:[节点][处理状态][时间] 一行,处理备注另起一行并对齐状态文字。
售后详细弹窗与订单详情弹窗的「售后记录」共用同一套标记;竖线为条目的
::before,末项不收口,避免出现悬空的线段。 */
.aksrt-after-sale-log { display: grid; gap: 6px; }
.aksrt-after-sale-log__empty { color: var(--modal-muted); font-size: 12px; }
.aksrt-after-sale-timeline { display: grid; margin: 0; padding: 0; list-style: none; }
.aksrt-after-sale-timeline__item { --as-accent: var(--modal-muted); position: relative; display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 10px; padding: 0 0 14px; }
.aksrt-after-sale-timeline__item:last-child { padding-bottom: 0; }
.aksrt-after-sale-timeline__item.is-pending { --as-accent: var(--modal-warning); }
.aksrt-after-sale-timeline__item.is-processing { --as-accent: var(--modal-primary); }
.aksrt-after-sale-timeline__item.is-completed { --as-accent: var(--modal-success); }
.aksrt-after-sale-timeline__item.is-rejected,
.aksrt-after-sale-timeline__item.is-cancelled { --as-accent: var(--modal-danger, #ef4444); }
.aksrt-after-sale-timeline__item::before { content: ""; position: absolute; top: 14px; bottom: -2px; left: 4px; width: 2px; border-radius: 1px; background: color-mix(in srgb, var(--as-accent) 30%, var(--modal-line)); }
.aksrt-after-sale-timeline__item:last-child::before { display: none; }
.aksrt-after-sale-timeline__knot { position: relative; z-index: 1; width: 10px; height: 10px; margin-top: 4px; border: 2px solid var(--as-accent); border-radius: 50%; background: var(--modal-surface, #fff); }
.aksrt-after-sale-timeline__body { display: grid; gap: 3px; min-width: 0; }
.aksrt-after-sale-timeline__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
.aksrt-after-sale-timeline__status { color: var(--as-accent); font-size: 12px; font-weight: 700; line-height: 1.5; }
.aksrt-after-sale-timeline__time { color: var(--modal-muted); font-size: 11px; line-height: 1.5; font-variant-numeric: tabular-nums; }
.aksrt-after-sale-timeline__note { margin: 0; color: var(--modal-ink); font-size: 12.5px; line-height: 1.65; word-break: break-word; }
/* 状态行右侧单元格:状态徽章 + 「撤销售后」入口(仅待审核工单渲染)。 */
.aksrt-after-sale-detail__statusCell { display: inline-flex; align-items: center; gap: 10px; }
.aksrt-after-sale-withdraw { display: inline-flex; align-items: center; gap: 3px; padding: 0; border: 0; background: transparent; color: var(--modal-danger, #ef4444); font-size: 12px; font-weight: 600; line-height: 1.4; cursor: pointer; transition: color .15s ease; }
-50
View File
@@ -166,56 +166,6 @@ html.dark .archive-timeline__badge {
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 {
text-align: center;
margin-top: 20px;
+3 -2
View File
@@ -50,8 +50,9 @@
color: var(--color-primary, #f04494);
}
/* .widget a 的链接色不得覆盖 .but 按钮自身配色(zibll 的 zib-widget 不设置链接色) */
.widget a.but {
/* .widget a 的链接色不得覆盖 .but / .img-badge 自身配色(zibll 的 zib-widget 不设置链接色) */
.widget a.but,
.widget a.img-badge {
color: var(--this-color, #fff);
}
+97 -12
View File
@@ -64,6 +64,27 @@
z-index: 100055;
}
/* public.css redeclares .muted-box after legacy utilities, overriding its
background, radius, and padding utilities. Restore the reference box contract. */
.modal[data-aksrt-vip-points-modal] .muted-box.padding-h10 {
background-color: var(--this-bg, var(--muted-border-color));
border-radius: var(--main-radius);
padding: 10px 15px;
}
.modal[data-aksrt-vip-points-modal] .muted-box.padding-10,
.modal[data-aksrt-vip-points-modal] .aksrt-vip-points-desc {
background-color: var(--this-bg, var(--muted-border-color));
border-radius: var(--main-radius);
padding: 10px;
}
.modal[data-aksrt-vip-points-modal] .aksrt-vip-points-desc {
display: block;
margin-top: 20px;
color: var(--muted-2-color, #999);
}
.pay-vip-form-row {
display: grid;
gap: 12px;
@@ -102,21 +123,85 @@
cursor: wait;
}
.vipbg-v2 {
/* 会员弹窗骨架屏(pay-vip.js vipSkeleton()):仿真实弹窗布局——
用户框 + 等级卡行 + 双列套餐面板。线条/色块与 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;
justify-content: center;
gap: 4px;
padding: 8px 10px;
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;
color: #fff;
background: linear-gradient(90deg, #ffb347, #ff6b6b);
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;
}
.vipbg-v2.level-2 {
background: linear-gradient(90deg, #9b59b6, #3f51b5);
}
.vip-expdate-tag {
font-size: 12px;
@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 {
content: '\f219';
content: '\f3a5';
font: normal normal 900 14px/1 "Font Awesome 5 Free";
font-size: 3.5em;
right: -0.2em !important;
@@ -5944,16 +5944,6 @@ body:has(.modal.in) .float-btn,
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 {
font-size: 0.85em;
background: transparent;
@@ -9665,11 +9655,16 @@ img.alignleft {
padding: 15px;
}
/* AKSRT 适配:站点行高(--leading-normal: 1.6)与参考主题 bootstrap
* 的 1.42857143 不同,用户卡片内的 img-badge(签到书签)钉住参考行高;
* 字号已随 --text-base 对齐参考主题(14px),无需单独声明。 */
/* AKSRT 适配:签到书签排版逐字对齐参考主题 bootstrap 的 body 计算值——
* 行高 1.42857143(站点 --leading-normal 为 1.6)、字体族
* "Helvetica Neue", Helvetica, Arial, sans-serif(站点 --font-body 为
* 现代系统栈),并还原浏览器默认的字体平滑与渲染(站点 html 重置为
* antialiased + optimizeLegibility);字号随 --text-base 已是参考的 14px。 */
.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;
}
.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
+1 -1
View File
@@ -34,7 +34,7 @@
.friend-card__avatar {
width: 48px;
height: 48px;
border-radius: var(--radius);
border-radius: var(--radius-avatar, 50%);
object-fit: cover;
flex-shrink: 0;
background: var(--surface-2);
-5
View File
@@ -211,10 +211,5 @@ html.dark .aksrt-orders-page__stat, html.dark .aksrt-orders-page__card { backgro
.aksrt-order-detail__after-sale-badge { padding: 3px 11px; border-radius: 999px; background: color-mix(in srgb, var(--as-accent) 12%, transparent); color: var(--as-accent); font-size: 12px; font-weight: 700; line-height: 1.5; }
.aksrt-order-detail__after-sale-head time { color: var(--detail-muted); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.aksrt-order-detail__after-sale-reason { margin: 0; color: var(--detail-ink); font-size: 13px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
.aksrt-order-detail__after-sale-meta { display: grid; margin: 0; overflow: hidden; border: 1px solid var(--detail-line); border-radius: 10px; background: var(--detail-surface-3); }
.aksrt-order-detail__after-sale-meta > div { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 10px; padding: 9px 12px; border-bottom: 1px solid var(--detail-line); font-size: 12.5px; }
.aksrt-order-detail__after-sale-meta > div:last-child { border-bottom: 0; }
.aksrt-order-detail__after-sale-meta dt { color: var(--detail-muted); }
.aksrt-order-detail__after-sale-meta dd { margin: 0; color: var(--detail-ink); font-size: 12.5px; text-align: right; word-break: break-word; }
.aksrt-order-detail__after-sale-field { color: var(--detail-muted); }
.aksrt-order-detail__after-sale-foot { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
+2
View File
@@ -125,6 +125,8 @@
flex-shrink: 0;
width: 140px;
height: 140px;
/* 用户中心头图卡保持 legacy 圆角方形:显式声明圆角,避免被 public 捆绑里
同特异性(0,1,1)的全局头像原语 img.avatar 抢先。 */
border-radius: 6px;
}
-12
View File
@@ -478,18 +478,6 @@
border-color: var(--color-primary);
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 {
display: inline-flex;
align-items: center;
+22 -10
View File
@@ -304,12 +304,24 @@
body.uc-modal-open {
overflow: hidden;
}
.btn-block {
display: block;
width: 100%;
}
.but.jb-blue,
.but.b-blue-2 {
/* Scoped to the user-center content shell. These are the credits panel's own
* button skins, and every element they target renders inside .uc-content (the
* panels and their modals are all emitted through templates/parts/user-center/
* panel.php, which templates/pages/page-profile.php wraps in .uc-content).
*
* 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;
color: #fff;
border: 0;
@@ -317,18 +329,18 @@ body.uc-modal-open {
padding: 0.45em 1em;
cursor: pointer;
}
.but.c-blue-2.hollow,
.but.c-green.hollow {
.uc-content .but.c-blue-2.hollow,
.uc-content .but.c-green.hollow {
background: transparent;
border: 1px solid currentColor;
border-radius: 4px;
padding: 0.4em 0.9em;
cursor: pointer;
}
.but.c-blue-2 {
.uc-content .but.c-blue-2 {
color: #3b82f6;
}
.but.c-green {
.uc-content .but.c-green {
color: #16a34a;
}
@@ -264,8 +264,6 @@
.aksrt-account-settings__section--social .binding-captcha { margin-bottom: 12px; }
.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 .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--idle { color: var(--muted-2-color, var(--color-ink-4, #999)); }
.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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -684,7 +665,8 @@ body.aksrt-user-center .uc-content,
text-overflow: ellipsis;
}
.uc-summary-card__body > i {
.uc-summary-card__body > i,
.uc-summary-card__body > svg {
font-size: 18px;
}
@@ -716,14 +698,26 @@ body.aksrt-user-center .uc-content,
.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 > 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--revenue .uc-center-card__icon { color: #ce6b35; background: rgba(234, 112, 55, 0.12); }
.uc-service-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
/* Services / settings grids, ported from the reference theme sidebar:
four per row, no tinted tile behind the icon. */
.user-icon-but-box > a {
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; }
@@ -735,44 +729,3 @@ body.aksrt-user-center .uc-content,
.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; } }
.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);
}
+2
View File
@@ -88,6 +88,8 @@
.aksrt-author-hero__avatar img {
width: 120px;
height: 120px;
/* 公开主页 hero 头像保持 legacy 圆角方形:显式声明圆角,避免被 public 捆绑里
同特异性(0,1,1)的全局头像原语 img.avatar 抢先。 */
border-radius: var(--radius);
border: 4px solid var(--surface-1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+14 -4
View File
@@ -38,6 +38,14 @@
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) {
return window.AksrtRequest.request(path, options || {}).then(function (response) {
return {
@@ -64,16 +72,18 @@
var walletNumber = Number(wallet);
var walletText = Number.isFinite(walletNumber) ? walletNumber.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(wallet);
document.querySelectorAll('[data-aksrt-wallet-value]').forEach(function (element) {
element.textContent = walletText;
element.title = walletText;
var text = element.dataset.aksrtCountFormat === 'cut-count' ? formatCutCount(wallet) : walletText;
element.textContent = text;
element.title = text;
});
}
if (points !== undefined && points !== null && points !== '') {
var pointsNumber = Number(points);
var pointsText = Number.isFinite(pointsNumber) ? pointsNumber.toLocaleString() : String(points);
document.querySelectorAll('[data-aksrt-points-value]').forEach(function (element) {
element.textContent = pointsText;
element.title = pointsText;
var text = element.dataset.aksrtCountFormat === 'cut-count' ? formatCutCount(points) : pointsText;
element.textContent = text;
element.title = text;
});
}
}
-62
View File
@@ -103,7 +103,6 @@
this.bindFeatured();
this.bindPay();
this.bindLeaveWarning();
this.bindAi();
this.bindTagActive();
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(); });
+42 -8
View File
@@ -91,11 +91,6 @@
return '¥' + raw;
}
function aftersaleAmountText(value, order) {
var amount = String(value == null ? '' : value);
return order && order.is_points ? amount + ' ' + String((order && order.currency_symbol) || '积分') : '¥' + amount;
}
function orderSummaryHtml(order) {
if (!order) return '';
return '<div class="muted-box padding-10 mb20 em09 aksrt-after-sale-summary">' +
@@ -169,6 +164,46 @@
'<span class="aksrt-after-sale-detail__value">' + escapeHtml(value) + '</span></div>';
}
// 处理记录节点标题:用户侧提交/撤脱离不开工单状态,单独措辞;
// 后台提交直接用目标状态,与后台列表口径一致。
function handlingEntryTitle(entry) {
var status = String((entry && entry.status) || '');
if (String((entry && entry.source) || '') === 'user') {
return status === 'cancelled' ? '用户撤销售后' : '用户提交申请';
}
return STATUS_LABELS[status] || status;
}
// 处理记录时间线:左列绳结状态条(节点 + 状态 + 时间),右列处理备注。
// 数据源为 latest.handling_log(工单行内 JSON,按时间正序)。
function handlingLogHtml(log) {
var entries = Array.isArray(log) ? log : [];
if (!entries.length) {
return '<div class="aksrt-after-sale-log"><div class="aksrt-after-sale-log__empty">暂无处理记录</div></div>';
}
var html = '<div class="aksrt-after-sale-log"><div class="muted-2-color em09 mb6">处理记录</div>';
html += '<ol class="aksrt-after-sale-timeline">';
entries.forEach(function (entry) {
var status = String((entry && entry.status) || '');
var note = String((entry && entry.note) || '');
html += '<li class="aksrt-after-sale-timeline__item is-' + escapeHtml(status) + '">';
html += '<span class="aksrt-after-sale-timeline__knot"></span>';
html += '<div class="aksrt-after-sale-timeline__body">';
// [节点][处理状态][时间] 同一行,处理备注另起一行
html += '<div class="aksrt-after-sale-timeline__head">' +
'<span class="aksrt-after-sale-timeline__status">' + escapeHtml(handlingEntryTitle(entry)) + '</span>' +
'<time class="aksrt-after-sale-timeline__time">' + escapeHtml(String((entry && entry.created_at) || '')) + '</time>' +
'</div>';
if (note !== '') {
html += '<p class="aksrt-after-sale-timeline__note">' + escapeHtml(note).replace(/\r\n|\r|\n/g, '<br>') + '</p>';
}
html += '</div>';
html += '</li>';
});
html += '</ol></div>';
return html;
}
function buildDetailHtml(order) {
var afterSale = (order && order.after_sale) || {};
var latest = afterSale.latest || {};
@@ -191,10 +226,9 @@
html += '<div class="aksrt-after-sale-detail__rows">';
html += detailRow('申请时间', String(latest.created_at || ''));
html += detailRow('退款金额', aftersaleAmountText(latest.refund_amount, order));
html += detailRow('处理备注', String(latest.admin_note || ''));
html += detailRow('完成时间', String(latest.completed_at || ''));
html += '</div>';
// 处理记录:每次后台提交一条,替代原先单行的「处理备注 / 完成时间」。
html += handlingLogHtml(latest.handling_log);
// 申请表单分行展示:预置原因 / 申请资料 / 申请说明各自独立成行,
// 与上方「申请时间」等明细行同格式;老数据无 apply_form 时兜底展示融合串。
+1 -27
View File
@@ -106,26 +106,6 @@
return "<div><dt>" + escapeHtml(label) + "</dt><dd>" + cell + "</dd></div>";
}
function gatewayResultText(value) {
if (!value) return "";
var result = value;
if (typeof value === "string") {
try { result = JSON.parse(value); } catch (_) { return String(value); }
}
if (!result || typeof result !== "object") return String(result);
var labels = { succeeded: "退款成功", failed: "渠道明确拒绝", pending: "渠道处理中", unknown: "结果待确认" };
var parts = [];
if (result.status) parts.push(labels[String(result.status)] || String(result.status));
if (result.message) parts.push(String(result.message));
if (result.refund_id) parts.push("退款单号 " + String(result.refund_id));
return parts.join(" · ") || "渠道暂未提供可读结果";
}
function afterSaleAmount(value, order) {
var amount = String(value == null ? "" : value);
return order && order.is_points ? amount + " " + String(order.currency_symbol || "积分") : "¥" + amount;
}
function colorfulHeader(title, icon) {
return '<div class="modal-colorful-header colorful-bg jb-blue">' +
'<button type="button" class="close" data-aksrt-modal-close aria-label="关闭"><svg class="ic-close" aria-hidden="true"><use xlink:href="#icon-close"></use></svg></button>' +
@@ -205,11 +185,6 @@
} else if (item.reason) {
html += '<p class="aksrt-order-detail__after-sale-reason">' + escapeHtml(item.reason).replace(/\r\n|\r|\n/g, "<br>") + "</p>";
}
html += '<dl class="aksrt-order-detail__after-sale-meta">';
html += "<div><dt>退款金额</dt><dd>" + escapeHtml(afterSaleAmount(item.refund_amount, order)) + "</dd></div>";
if (item.admin_note) html += "<div><dt>处理备注</dt><dd>" + escapeHtml(item.admin_note) + "</dd></div>";
if (item.gateway_result) html += "<div><dt>渠道结果</dt><dd>" + escapeHtml(gatewayResultText(item.gateway_result)) + "</dd></div>";
html += "</dl>";
// 查看详细:逐记录构造售后详细弹窗载荷(after_sale.latest 指向本条记录);
// 再次申请仅对最新一条且订单级可再申请时开放(after_sales 按 id 倒序,首条即最新)。
var recordPayload = escapeHtml(JSON.stringify({
@@ -230,8 +205,7 @@
images: Array.isArray(item.images) ? item.images : [],
refund_amount: String(item.refund_amount || "0"),
created_at: String(item.created_at || ""),
completed_at: String(item.completed_at || ""),
admin_note: String(item.admin_note || ""),
handling_log: Array.isArray(item.handling_log) ? item.handling_log : [],
},
},
}));
+110 -23
View File
@@ -17,6 +17,14 @@
return el;
}
// Server-side schema sanitization limits these configured rich-text fields to post-safe HTML.
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) {
if (!src) return null;
var image = document.createElement("img");
@@ -61,7 +69,7 @@
if (levelInfo && levelInfo.img_icon) {
var img = node("img", "img-icon");
img.src = text(levelInfo.img_icon);
img.alt = text(levelInfo.name || "");
img.alt = text(levelInfo.name_text || levelInfo.name || "");
img.decoding = "async";
return img;
}
@@ -115,7 +123,8 @@
// Zibll 套餐卡:zib-widget vip-product relative text-center product-box
// (右上 tag + 价格行 + 时长 badge,无标题行)。
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, htmlNode("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 || ""));
if (item.days_label) append(product, node("div", "muted-2-color c-blue em09 p2-10 badg radius", item.days_label));
append(label, product);
@@ -179,7 +188,7 @@
box.setAttribute("data-condition", "==");
box.setAttribute("data-value", "card_pass");
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 ? "请输入卡密" : "请输入卡号和密码"));
if (!cardPass.only_password) {
var cardInput = node("input", "form-control mb6");
@@ -384,12 +393,7 @@
var avatarItem = node("li");
var avatarWrap = node("div", "avatar-img");
if (member.avatar_url) {
var avatar = node("img");
avatar.src = text(member.avatar_url);
avatar.alt = "";
avatar.width = 42;
avatar.height = 42;
append(avatarWrap, avatar);
append(avatarWrap, imageNode(text(member.avatar_url), "avatar"));
}
append(avatarItem, avatarWrap);
append(userBox, avatarItem);
@@ -403,7 +407,7 @@
} else if (member.state === "expired") {
append(userInfo, node("div", "muted-2-color em09", "已过期"));
} else if (catalog.desc) {
append(userInfo, node("div", "muted-2-color em09", catalog.desc));
append(userInfo, htmlNode("div", "muted-2-color em09", catalog.desc));
}
append(userBox, userInfo);
append(modal, userBox);
@@ -439,8 +443,8 @@
append(miniIcon, vipIcon(levelInfo));
append(miniIconbox, miniIcon);
var miniInfo = node("div", "v-info");
append(miniInfo, node("div", "v-name", actionLabel(action) + (levelInfo.name || group.name || "VIP" + level)));
append(miniInfo, node("div", "v-desc em09", levelInfo.desc || ""));
append(miniInfo, htmlNode("div", "v-name", actionLabel(action) + (levelInfo.name || group.name || "VIP" + level)));
append(miniInfo, htmlNode("div", "v-desc em09", levelInfo.desc || ""));
append(miniIconbox, miniInfo);
append(miniCard, miniIconbox);
append(tab, miniCard);
@@ -466,15 +470,12 @@
append(vicon, vipIcon(levelInfo));
append(iconbox, vicon);
var vinfo = node("div", "v-info");
append(vinfo, node("div", "v-name", levelInfo.name || group.name || "VIP" + level));
append(vinfo, node("div", "v-desc em09", levelInfo.desc || ""));
append(vinfo, htmlNode("div", "v-name", levelInfo.name || group.name || "VIP" + level));
append(vinfo, htmlNode("div", "v-desc em09", levelInfo.desc || ""));
append(iconbox, vinfo);
append(info, iconbox);
if (levelInfo.equity) {
var equity = document.createElement("ul");
equity.className = "v-equity";
equity.innerHTML = text(levelInfo.equity);
append(info, equity);
append(info, htmlNode("div", "", levelInfo.equity));
}
var infoBa = node("div", "abs-center vip-baicon");
append(infoBa, vipIcon(levelInfo));
@@ -497,7 +498,7 @@
+ '<i class="fa fas fa-angle-right em12 ml10" aria-hidden="true"></i>';
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);
var actionName = actionKey(action);
var actionAllowed = allowed[actionName] === true;
@@ -524,8 +525,6 @@
append(modal, tabs);
append(modal, content);
if (catalog.help_text) append(modal, node("div", "muted-2-color em09 mb6", catalog.help_text));
// 卡密渠道初始显隐(选中 card_pass 时隐藏套餐、显示卡密输入盒)。
modal.querySelectorAll(".aksrt-vip-pane").forEach(function (pane) {
applyVipMethodVisibility(pane);
@@ -703,6 +702,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) {
var level = trigger
? parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1
@@ -711,11 +750,11 @@
url: "/pay/vip/modal",
query: { vip_level: level },
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",
kind: "vip",
responseType: "json",
cacheTtl: 15000,
cacheTtl: 60000,
cacheTags: ["vip"],
renderData: function (data, session) {
renderModalView(session.content, data);
@@ -733,6 +772,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) {
var trigger = event.target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]");
if (!trigger) return;
@@ -773,6 +859,7 @@
if (window.__aksrtPayVipBound) return;
window.__aksrtPayVipBound = true;
document.addEventListener("click", onDocClick, true);
bindPrefetch();
bindPointsModal();
bindPointsEntry();
}
+3
View File
@@ -295,6 +295,9 @@ if (class_exists('\AKSRT\Extensions\MeiliSearch')) {
if (class_exists('\AKSRT\Extensions\SearchPush')) {
\AKSRT\Extensions\SearchPush::get_instance()->init();
}
if (class_exists('\AKSRT\Extensions\SearchPushMetaBox')) {
\AKSRT\Extensions\SearchPushMetaBox::get_instance()->init();
}
if (class_exists('\AKSRT\Core\SEOHead')) {
\AKSRT\Core\SEOHead::get_instance()->init();
}
+30 -107
View File
@@ -37,102 +37,33 @@ if (!function_exists('aksrt_get_new_add_actions')) :
}
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')) :
/**
* 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
*/
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);
if (!$actions) {
// Last resort: keep a write-post entry so the top-nav chip is never blank.
$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',
]];
return '';
}
$a = $actions[0];
if ($label === '') {
$label = __('发布', 'aksrt');
}
$label_html = aksrt_normalize_header_cta_label((string) $label, 'fa fas fa-pen');
// Split paint classes vs wrapper tokens.
$class_tokens = preg_split('/\s+/', trim($class), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$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;
return '<a rel="nofollow" class="newadd-btns ' . esc_attr($class) . ' btn-newadd" href="' . esc_url($a['url']) . '">'
. $label . '</a>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Saved through sanitize_type 'html' (wp_kses_post).
}
endif;
@@ -142,26 +73,19 @@ if (!function_exists('aksrt_render_header_menu_buttons')) :
*/
function aksrt_render_header_menu_buttons(): string {
$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']),
'text' => trim((string) ($settings['nav_newposts_text'] ?? __('发布', 'aksrt'))),
'radius_pill' => !isset($settings['nav_newposts_radius']) || !empty($settings['nav_newposts_radius']),
'class' => sanitize_html_class((string) (($settings['nav_newposts_class'] ?? '') ?: 'jb-blue')),
];
$enabled = !isset($settings['nav_newposts']) || !empty($settings['nav_newposts']);
$text = trim((string) ($settings['nav_newposts_text'] ?? ''));
$pill = !isset($settings['nav_newposts_radius']) || !empty($settings['nav_newposts_radius']);
$skin = sanitize_html_class((string) (($settings['nav_newposts_class'] ?? '') ?: 'jb-blue'));
$html = '';
if (!empty($cfg['enabled'])) {
if ($enabled) {
// legacy default: but nowave jb-blue (+ radius).
$class = 'but nowave navbar-newposts';
$skin = trim((string) ($cfg['class'] ?? ''));
if ($skin === '') {
$skin = 'jb-blue';
}
$class .= ' ' . $skin;
$pill = !empty($cfg['radius_pill']);
$class = 'but nowave ' . $skin;
if ($pill && !str_contains($class, 'radius')) {
$class .= ' radius';
}
$label = (string) ($cfg['text'] ?? __('发布', 'aksrt'));
$label = $text !== '' ? $text : __('发布', 'aksrt');
$btn = aksrt_render_new_add_btns(['post'], $class, $label);
if ($btn !== '') {
$html .= $btn;
@@ -173,7 +97,8 @@ if (!function_exists('aksrt_render_header_menu_buttons')) :
$show_vip = false;
if (class_exists('\AKSRT\Settings\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) {
$is_vip = false;
@@ -188,17 +113,15 @@ if (!function_exists('aksrt_render_header_menu_buttons')) :
if (method_exists($svc, 'render_vip_card')) {
$cards = $svc->render_vip_card(1, $user_id) . $svc->render_vip_card(2, $user_id);
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
$html .= '<span class="hover-show inline-block navbar-vip-hover">';
$html .= '<a class="but jb-red radius payvip-icon ml10 navbar-cta ' . esc_attr($cls) . '" href="javascript:;"'
. ($user_id > 0 ? ' vip-level="1"' : '') . '>';
$html .= '<svg class="icon em12 mr3" aria-hidden="true"><use xlink:href="#icon-vip_1"></use></svg>'
. '<span class="navbar-cta-text">' . esc_html__('开通会员', 'aksrt') . '</span>';
$html .= '</a>' . $hover . '</span>';
// legacy: span.hover-show wraps button + .sub-menu.sub-vip-card
$html .= '<span class="hover-show inline-block">';
$html .= '<a class="' . esc_attr($cls) . ' but jb-red radius payvip-icon ml10" href="javascript:;">'
. '<svg class="em12 mr3" aria-hidden="true"><use xlink:href="#icon-vip_1"></use></svg>'
. esc_html__('开通会员', 'aksrt') . '</a>' . $hover . '</span>';
}
}
+86 -59
View File
@@ -35,12 +35,11 @@ if (!function_exists('aksrt_header_user_checkin_btn')) :
* Check-in CTA for header dropdown and compact card placements.
*
* @param int $user_id Current user ID.
* @param bool $compact Render a short card-friendly label.
* @param string|null $class Override the classes and render the reference-parity
* <a> badge (user-card 签到书签); null keeps the header
* dropdown <button> runtime.
* dropdown <a> runtime.
*/
function aksrt_header_user_checkin_btn(int $user_id = 0, bool $compact = false, ?string $class = null): string {
function aksrt_header_user_checkin_btn(int $user_id = 0, ?string $class = null): string {
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
if ($user_id <= 0 || !class_exists('\AKSRT\Credits\Signin')) {
return '';
@@ -56,38 +55,39 @@ if (!function_exists('aksrt_header_user_checkin_btn')) :
// 已签到:与参考主题一致,入口按钮变为签到详情弹窗触发链接(可点击)。
if ($signed) {
$icon = '<i class="fa fas fa-calendar-check ml3 mr6" aria-hidden="true"></i>';
if ($class !== null) {
// 用户卡片签到书签:保留书签类与 is-done 视觉,仅将行为切换为打开详情弹窗。
// 用户卡片签到书签:与参考主题一致,已签到态仅切换为详情弹窗触发链接,书签类名与文本不变。
return aksrt_get_user_checkin_details_link(
$class . ' is-done',
$icon . '<span>' . esc_html__('已签到', 'aksrt') . '</span>'
$class,
'<i class="fa far fa-calendar-check ml3 mr6" aria-hidden="true"></i>'
. esc_html__('已签到', 'aksrt')
);
}
return aksrt_get_user_checkin_details_link(
'but block c-yellow aksrt-header-checkin is-done',
'<i class="fa fas fa-calendar-check" aria-hidden="true"></i> ' . esc_html($compact ? __('已签到', 'aksrt') : __('今日已签到', 'aksrt'))
'but block c-yellow is-done',
'<i class="fa far fa-calendar-check" aria-hidden="true"></i> '
. esc_html__('今日已签到', 'aksrt')
);
}
$classes = $class ?? ('but block c-yellow aksrt-header-checkin' . ($compact ? ' aksrt-header-checkin--compact' : ''));
// Card badge variant (explicit $class, e.g. the user-card 签到 badge):
// reference parity renders an <a> so the badge inherits the card font
// instead of the UA button stylesheet (font/UI 1:1 with the reference).
if ($class !== null) {
return '<a class="' . esc_attr($classes) . '" 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-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>'
. '</a>';
}
return '<button type="button" class="' . esc_attr($classes) . '" data-signin-btn data-signin-idle-label="' . esc_attr__('签到', 'aksrt') . '" data-signin-done-label="' . esc_attr__('已签到', 'aksrt') . '">'
. '<i class="fa fas fa-calendar-check" aria-hidden="true"></i>'
. '<span data-signin-label>' . esc_html($compact ? __('签到', 'aksrt') : __('签到领取今日奖励', 'aksrt')) . '</span>'
. '</button>';
return '<a class="but block c-yellow" href="javascript:;" data-signin-btn'
. ' data-signin-idle-label="' . esc_attr__('签到', 'aksrt') . '"'
. ' data-signin-done-label="' . esc_attr__('今日已签到', 'aksrt') . '">'
. '<i class="fa far fa-calendar-check" aria-hidden="true"></i> '
. '<span data-signin-label>' . esc_html__('签到领取今日奖励', 'aksrt') . '</span>'
. '</a>';
}
endif;
@@ -118,41 +118,65 @@ add_action('wp_footer', 'aksrt_render_signin_captcha');
if (!function_exists('aksrt_header_user_wallet_mini')) :
/**
* Mini wallet strip: credits + cash balance.
* Mini wallet tiles: cash balance + credits (reference parity).
*
* Each tile is gated by its payment switch; an all-off configuration
* renders nothing at all.
*/
function aksrt_header_user_wallet_mini(int $user_id = 0): string {
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
if ($user_id <= 0) {
if ($user_id <= 0 || !class_exists('\AKSRT\Settings\Settings')) {
return '';
}
$credits = aksrt_user_points($user_id);
$currency = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_currency_name()
: __('积分', 'aksrt');
$wallet = aksrt_user_balance_decimal($user_id);
$center = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : '#';
$html = '<a class="aksrt-header-wallet flex ac jsb" href="' . esc_url($center) . '">';
$html .= '<span class="aksrt-header-wallet__item"><i class="fa fas fa-database" aria-hidden="true"></i> '
. esc_html($currency) . ' <b data-aksrt-points-value data-credits-value>' . esc_html(number_format_i18n($credits)) . '</b></span>';
$html .= '<span class="aksrt-header-wallet__item"><i class="fa fas fa-money-bill" aria-hidden="true"></i> '
. esc_html__('余额', 'aksrt') . ' <b data-aksrt-wallet-value>' . esc_html(number_format_i18n($wallet, 2)) . '</b></span>';
$html .= '</a>';
return $html;
$balance_on = !empty(\AKSRT\Settings\Settings::get_payment_recharge_settings()['enabled']);
$points_on = \AKSRT\Settings\Settings::credits_enabled();
if (!$balance_on && !$points_on) {
return '';
}
$center = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : '#';
$box = '';
$balance_icon = aksrt_icon_markup('zibsvg-money-color-2', 'em14');
$points_icon = aksrt_icon_markup('zibsvg-points-color', 'em14');
if ($balance_on) {
$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')
. '<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 data-aksrt-count-format="cut-count">'
. esc_html(aksrt_cut_count((float) aksrt_user_balance_decimal($user_id)))
. '</span>' . $balance_icon . '</div></a>';
}
if ($points_on) {
$currency = \AKSRT\Settings\Settings::get_currency_name();
$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)
. '<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 data-aksrt-count-format="cut-count">'
. esc_html(aksrt_cut_count(aksrt_user_points($user_id)))
. '</span>' . $points_icon . '</div></a>';
}
return '<div class="flex ab jsb col-ml6">' . $box . '</div>';
}
endif;
if (!function_exists('aksrt_header_user_badges_row')) :
/**
* Identity / medal chips under profile.
* Interaction stats row under the wallet (reference parity).
*/
function aksrt_header_user_badges_row(int $user_id = 0): string {
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
if ($user_id <= 0 || !function_exists('aksrt_render_user_identity_chips')) {
if ($user_id <= 0 || !function_exists('aksrt_get_user_badges')) {
return '';
}
// Certification is displayed beside the name and level icon in the profile row.
return '';
$items = aksrt_get_user_badges($user_id);
if ($items === '') {
return '';
}
return '<div class="em09 author-tag mb10 mt6 flex jc">' . $items . '</div>';
}
endif;
@@ -173,30 +197,33 @@ if (!function_exists('aksrt_header_payvip_bar')) :
return '';
}
$settings = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_all()
: [];
$level = function_exists('aksrt_get_user_vip_level') ? (int) aksrt_get_user_vip_level($user_id) : 0;
if ($level > 0) {
$levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : [];
$name = (string) ($levels[$level]['name'] ?? ($level === 2 ? __('二级会员', 'aksrt') : __('一级会员', 'aksrt')));
$name = (string) ($levels[$level]['name_html'] ?? $levels[$level]['name'] ?? ($level === 2 ? __('二级会员', 'aksrt') : __('一级会员', 'aksrt')));
// 到期徽章语义对齐参考主题 zib_get_user_vip_exp_date_text():只有真的拿到
// 到期时间才渲染这枚徽章,已过期显示「会员已过期」否则显示裸日期;显式永久
// 会员显示「永久会员」;拿不到到期数据时参考主题返回 false,整枚徽章不渲染。
$exp_label = '';
if (function_exists('aksrt_get_vip_info')) {
$info = aksrt_get_vip_info($user_id);
$expires = max(0, (int) ($info['expires'] ?? 0));
$exp_label = $expires > 0
? sprintf(__('到期时间:%s', 'aksrt'), wp_date('Y年m月d日', $expires))
: (string) ($info['expires_label'] ?? '');
if (function_exists('aksrt_get_user_vip_state')) {
$state = aksrt_get_user_vip_state($user_id);
$expires = max(0, (int) ($state['expires'] ?? 0));
if ($expires > 0) {
$exp_label = $expires <= time()
? __('会员已过期', 'aksrt')
: wp_date('Y年m月d日', $expires);
} elseif (!empty($state['is_permanent'])) {
$exp_label = __('永久会员', 'aksrt');
}
}
$html = '<div class="mt10 em09 aksrt-header-payvip is-vip">';
$html .= '<span class="radius4 payvip-icon btn-block text-center vipbg-v2 level-' . (int) $level . ' aksrt-header-payvip__active">';
$html .= '<span class="aksrt-header-payvip__level">';
$html .= '<i class="fa fas fa-gem" aria-hidden="true"></i>';
$html .= '<span>' . esc_html($name) . '</span>';
$html .= '</span>';
$html = '<div class="mt10 em09" style="padding:2px;">';
$html .= '<span class="radius4 payvip-icon btn-block text-center">';
$html .= function_exists('aksrt_get_vip_icon') ? aksrt_get_vip_icon($user_id, 'em12 mr6', false) : '';
$html .= '<span>' . wp_kses_post($name) . '</span>';
if ($exp_label !== '') {
$html .= '<span class="aksrt-header-payvip__exp">' . esc_html($exp_label) . '</span>';
$html .= '<span class="ml10 badg jb-yellow vip-expdate-tag">'
. '<i class="fa fas fa-clock mr3" aria-hidden="true"></i>'
. esc_html($exp_label) . '</span>';
}
$html .= '</span></div>';
return $html;
@@ -205,11 +232,11 @@ if (!function_exists('aksrt_header_payvip_bar')) :
return '';
}
// Match check-in CTA geometry (full width, 6px radius) — not top-nav navbar-cta chips.
return '<div class="mt10 em09 aksrt-header-payvip">'
. '<a class="pay-vip but block payvip-icon aksrt-header-payvip__cta" href="javascript:;" vip-level="1">'
. '<i class="fa fas fa-gem" aria-hidden="true"></i>'
. '<span>' . esc_html__('开通会员 尊享会员权益', 'aksrt') . '</span>'
// 1:1 对齐参考主题 zib_get_header_payvip_icon():通栏、radius4、vip_1 SVG 图标、裸文案。
return '<div class="mt10 em09" style="padding:2px;">'
. '<a class="pay-vip but radius4 payvip-icon btn-block" href="javascript:;">'
. '<svg class="em12 mr6" aria-hidden="true"><use xlink:href="#icon-vip_1"></use></svg>'
. esc_html__('开通会员 尊享会员权益', 'aksrt')
. '</a></div>';
}
endif;
+24 -9
View File
@@ -42,7 +42,7 @@ endif;
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>
*/
@@ -52,10 +52,12 @@ function aksrt_brand_icon_map(): array {
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 = [
'github' => 'fa fab fa-github',
'google' => 'fa fab fa-google',
'google' => 'zibsvg-google-color',
'x' => 'fa fab fa-twitter',
'twitter' => 'fa fab fa-twitter',
'email' => 'fa fas fa-envelope',
@@ -73,7 +75,7 @@ function aksrt_brand_icon_map(): array {
'instagram' => 'fa fab fa-instagram',
'web' => 'fa fas fa-globe',
'globe' => 'fa fas fa-globe',
'gitee' => 'fa fas fa-code-branch',
'gitee' => 'zibsvg-gitee',
'baidu' => 'fa fas fa-paw',
'alipay' => 'fa fab fa-alipay',
];
@@ -127,10 +129,10 @@ endif;
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.
* @return string e.g. 'fa fab fa-github', or '' if unknown.
* @param string $name Short key, full FA class string or zibsvg-/customsvg- value.
* @return string e.g. 'fa fab fa-github' or 'zibsvg-gitee', or '' if unknown.
*/
function aksrt_brand_icon_class(string $name): string {
$raw = trim($name);
@@ -138,6 +140,12 @@ function aksrt_brand_icon_class(string $name): string {
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).
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.).
@@ -164,14 +172,21 @@ endif;
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.
* @return string Safe HTML or empty string when unknown.
*/
function aksrt_brand_icon(string $name, $extra_classes = []): string {
$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 === '') {
return '';
}
+4 -4
View File
@@ -351,14 +351,14 @@ if (!function_exists('aksrt_get_user_badges')) {
$view_n = aksrt_cut_count(aksrt_get_user_posts_meta_sum($user_id, 'aksrt_post_views'));
$html = '';
$html .= '<a class="but c-blue tag-posts" data-toggle="tooltip" title="' . esc_attr(sprintf(__('共%s篇文章', 'aksrt'), $post_n)) . '" href="' . esc_url($home) . '">' . aksrt_renderer_svg_icon('post') . $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) . '">' . aksrt_renderer_svg_icon('comment') . $com_n . '</a>';
$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>';
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>';
} elseif ($like_n) {
$html .= '<span class="badg c-yellow tag-like" data-toggle="tooltip" title="' . esc_attr(sprintf(__('获得%s个点赞', 'aksrt'), $like_n)) . '">' . aksrt_renderer_svg_icon('like') . $like_n . '</span>';
$html .= '<span class="badg c-yellow tag-like" data-toggle="tooltip" title="' . esc_attr(sprintf(__('获得%s个点赞', 'aksrt'), $like_n)) . '"><i class="fa fas fa-thumbs-up" aria-hidden="true"></i>' . $like_n . '</span>';
}
$html .= '<span class="badg c-red tag-view" data-toggle="tooltip" title="' . esc_attr(sprintf(__('人气值 %s', 'aksrt'), $view_n)) . '">' . aksrt_renderer_svg_icon('hot') . $view_n . '</span>';
$html .= '<span class="badg c-red tag-view" data-toggle="tooltip" title="' . esc_attr(sprintf(__('人气值 %s', 'aksrt'), $view_n)) . '"><i class="fa fas fa-fire" aria-hidden="true"></i>' . $view_n . '</span>';
/**
* Filter the author-card count badges row.
+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">'
. '<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')) {
$social = (string) \AKSRT\Auth\OAuth::get_instance()->render_social_buttons(__('使用以下方式快速登录', 'aksrt'));
if ($social !== '') {
$body .= '<div class="social_loginbar">' . $social . '</div>';
}
$body .= (string) \AKSRT\Auth\OAuth::get_instance()->render_social_buttons(__('使用以下方式快速登录', 'aksrt'));
}
$body .= '</div>';
} else {
+6 -2
View File
@@ -1309,6 +1309,7 @@ if (!function_exists('aksrt_get_posts_vip_price')) {
}
$level_name = (string) ($level_settings['name'] ?? sprintf(__('VIP%d', 'aksrt'), $level));
$level_name_html = (string) ($level_settings['name_html'] ?? $level_name);
$price = $vip_minor === 0
? esc_html__('免费', 'aksrt')
: $mark . esc_html(aksrt_format_price($value));
@@ -1319,7 +1320,7 @@ if (!function_exists('aksrt_get_posts_vip_price')) {
? ' data-toggle="tooltip" title="' . esc_attr($title) . '"'
: '';
$items[] = '<span class="but vip-price' . $action_class . '" vip-level="' . esc_attr((string) $level) . '" data-vip-level="' . esc_attr((string) $level) . '"' . $title_attr . '>' . $icon . esc_html($level_name) . '<span class="em12 ml3 vip-price-text">' . $price . '</span></span>';
$items[] = '<span class="but vip-price' . $action_class . '" vip-level="' . esc_attr((string) $level) . '" data-vip-level="' . esc_attr((string) $level) . '"' . $title_attr . '>' . $icon . wp_kses_post($level_name_html) . '<span class="em12 ml3 vip-price-text">' . $price . '</span></span>';
}
$html = implode('', $items);
@@ -1384,10 +1385,13 @@ if (!function_exists('aksrt_get_paywall_html')) {
$vip_action = $vip_level > 0
? sprintf(__('升级%s', 'aksrt'), $level_name)
: __('开通会员', 'aksrt');
$vip_action_html = $vip_level > 0
? __('升级', 'aksrt') . wp_kses_post((string) ($level_settings['name_html'] ?? $level_name))
: esc_html__('开通会员', 'aksrt');
$action_class = is_user_logged_in() ? 'pay-vip' : 'signin-loader';
$icon = aksrt_get_pay_vip_icon_html($level_settings);
$button = '<div class="badg c-yellow em09 mb6"><i class="fa fas fa-fw fa-info-circle mr6" aria-hidden="true"></i>' . esc_html(sprintf(__('您暂无购买权限,请先%s', 'aksrt'), $vip_action)) . '</div>';
$button .= '<a href="javascript:;" vip-level="' . esc_attr((string) $pay_limit) . '" data-vip-level="' . esc_attr((string) $pay_limit) . '" class="but btn-block jb-vip' . esc_attr((string) $pay_limit) . ' ' . esc_attr($action_class) . ' padding-lg">' . $icon . esc_html($vip_action) . '</a>';
$button .= '<a href="javascript:;" vip-level="' . esc_attr((string) $pay_limit) . '" data-vip-level="' . esc_attr((string) $pay_limit) . '" class="but btn-block jb-vip' . esc_attr((string) $pay_limit) . ' ' . esc_attr($action_class) . ' padding-lg">' . $icon . $vip_action_html . '</a>';
} elseif (!is_user_logged_in()) {
if ($amount_minor > 0 && $modo === '0' && aksrt_allow_guest_content_pay($post)) {
$button = '<button type="button" class="but jb-red padding-lg cashier-link' . $block_class . '" data-aksrt-pay-content="' . esc_attr((string) $post->ID) . '"><i class="fa fas fa-shopping-cart" aria-hidden="true"></i> ' . esc_html__('立即购买', 'aksrt') . '</button>';
+3 -2
View File
@@ -285,11 +285,12 @@ if (!function_exists('aksrt_single_render_author_card')) :
}
$settings = is_array($ctx['settings'] ?? null) ? $ctx['settings'] : [];
$avatar_box = aksrt_get_avatar_box($author_id, 'avatar-img avatar-lg');
// 会员/禁封徽章不再挂头像角标,改由名字行的会员位出图(aksrt_get_user_name)。
$avatar_box = aksrt_get_avatar_box($author_id, 'avatar-img avatar-lg', true, false);
$name_html = aksrt_get_user_name([
'id' => $author_id,
'class' => 'flex1 flex ac',
'vip' => false,
'vip' => true,
'medal' => true,
'auth' => true,
'level' => true,
+2 -16
View File
@@ -481,7 +481,7 @@ endif;
* or Chinese keyword heuristics — that mangles intentional HTML.
*
* Rules:
* - Sanitize with a small allowlist.
* - Sanitize with WordPress post-safe HTML.
* - If markup already has <li>, wrap the items in <ul class="v-equity …">.
* - Otherwise output the sanitized HTML inside <div class="v-equity …">.
* - Plain multi-line text: each non-empty line becomes one <li> (only \n split).
@@ -497,21 +497,7 @@ if (!function_exists('aksrt_format_vip_equity_html')) {
return '';
}
$allowed = [
'li' => ['class' => true],
'ul' => ['class' => true],
'ol' => ['class' => true],
'strong' => [],
'b' => [],
'em' => [],
'i' => ['class' => true],
'span' => ['class' => true],
'br' => [],
'p' => ['class' => true],
'div' => ['class' => true],
'a' => ['href' => true, 'target' => true, 'rel' => true, 'class' => true],
];
$clean = trim(wp_kses($raw, $allowed));
$clean = trim(wp_kses_post($raw));
if ($clean === '') {
return '';
}
+47 -19
View File
@@ -138,10 +138,10 @@ if (!function_exists('aksrt_header_user_box_html')) :
$con = '';
if ($user_id > 0) {
$user = $ctx['current_user'] instanceof WP_User ? $ctx['current_user'] : wp_get_current_user();
$user = $ctx['current_user'] instanceof WP_User ? $ctx['current_user'] : wp_get_current_user();
$display_name = $user->display_name ?: $user->user_login;
$avatar = aksrt_get_avatar_html($user_id, 44, ['alt' => $display_name]);
$desc = (string) ($ctx['user_desc'] ?? '');
$avatar = aksrt_get_avatar_box($user_id, 'avatar-img', true, false);
$desc = (string) ($ctx['user_desc'] ?? '');
$vip_badge = function_exists('aksrt_get_vip_icon') ? aksrt_get_vip_icon($user_id) : '';
// Level and certification icons ride inline to the right of the display name.
$level_badge = (class_exists('\AKSRT\User\Level') && method_exists('\AKSRT\User\Level', 'get_badge_html'))
@@ -174,13 +174,13 @@ if (!function_exists('aksrt_header_user_box_html')) :
$msg_icon = '';
if ($msg_url !== '' && in_array('avatar_dropdown', $positions, true)) {
$msg_icon = '<a class="abs-right" href="' . esc_url($msg_url) . '" title="' . esc_attr__('消息中心', 'aksrt') . '">';
$msg_icon .= '<i class="fa fas fa-bell" aria-hidden="true"></i>';
if ($unread > 0) {
$msg_icon .= '<span class="aksrt-notification-badge">' . esc_html((string) min(99, $unread)) . '</span>';
}
$msg_icon .= '</a>';
$msg_icon = '<a class="msg-news-icon abs-right" href="' . esc_url($msg_url) . '" title="' . esc_attr__('消息中心', 'aksrt') . '">';
$msg_icon .= '<span class="toggle-radius msg-icon"><i class="fa fas fa-bell" aria-hidden="true"></i></span>';
if ($unread > 0) {
$msg_icon .= '<badge class="top">' . esc_html((string) min(99, $unread)) . '</badge>';
}
$msg_icon .= '</a>';
}
$href = '<div class="flex jsa header-user-href">';
$href .= '<a rel="nofollow" href="' . esc_url(function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/')) . '">';
@@ -224,6 +224,10 @@ if (!function_exists('aksrt_header_user_box_html')) :
$href .= '<a rel="nofollow" target="_blank" href="' . esc_url(admin_url('widgets.php')) . '">';
$href .= '<div class="badg mb6 toggle-radius c-yellow"><i class="fa fas fa-chart-pie" aria-hidden="true"></i></div>';
$href .= '<div class="c-yellow">' . esc_html__('模块布局', 'aksrt') . '</div></a>';
// legacy: 商城中心 → 支付中心(AKSRT 支付中心后台菜单)。
$href .= '<a rel="nofollow" target="_blank" href="' . esc_url(admin_url('admin.php?page=aksrt_pay')) . '">';
$href .= '<div class="badg mb6 toggle-radius c-yellow"><i class="fa fas fa-credit-card" aria-hidden="true"></i></div>';
$href .= '<div class="c-yellow">' . esc_html__('支付中心', 'aksrt') . '</div></a>';
$href .= '<a rel="nofollow" target="_blank" href="' . esc_url(admin_url()) . '">';
$href .= '<div class="badg mb6 toggle-radius c-yellow"><i class="fa fas fa-cog" aria-hidden="true"></i></div>';
$href .= '<div class="c-yellow">' . esc_html__('后台管理', 'aksrt') . '</div></a>';
@@ -234,7 +238,10 @@ if (!function_exists('aksrt_header_user_box_html')) :
$con .= $avatar;
$con .= '<div class="user-right flex flex1 ac jsb ml10">';
$con .= '<div class="flex1" style="max-width: calc(100% - 40px);"><b>'
. '<span class="display-name text-ellipsis">' . esc_html($display_name) . '</span>' . $vip_badge . $medal_badge . $certification_badge . $level_badge
. '<name class="flex ac flex1">'
. '<a class="display-name text-ellipsis" href="' . esc_url(function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($user_id) : get_author_posts_url($user_id)) . '">' . esc_html($display_name) . '</a>'
. $vip_badge . $medal_badge . $certification_badge . $level_badge
. '</name>'
. '</b><div class="px12 muted-2-color text-ellipsis">' . esc_html($desc) . '</div></div>';
$con .= '</div>';
$con .= $msg_icon;
@@ -264,17 +271,22 @@ if (!function_exists('aksrt_header_user_box_html')) :
$href .= '<a href="javascript:;" class="signup-loader"><div class="badg mb6 toggle-radius c-green"><i class="fa fas fa-user-plus" aria-hidden="true"></i></div><div class="c-green">'
. esc_html__('注册', 'aksrt') . '</div></a>';
}
$href .= '<a rel="nofollow" href="' . esc_url(wp_lostpassword_url(home_url('/'))) . '"><div class="badg mb6 toggle-radius c-purple"><i class="fa fas fa-key" aria-hidden="true"></i></div><div class="c-purple">'
// legacy: 找回密码 → 统一登录页 resetpassword 标签(带回跳)。
$reset_url = function_exists('aksrt_get_sign_page_url')
? aksrt_get_sign_page_url('resetpassword', [
'redirect_to' => remove_query_arg(
'redirect_to',
home_url(isset($_SERVER['REQUEST_URI']) ? wp_unslash($_SERVER['REQUEST_URI']) : '/')
),
])
: wp_lostpassword_url(home_url('/'));
$href .= '<a target="_blank" rel="nofollow" href="' . esc_url($reset_url) . '"><div class="badg mb6 toggle-radius c-purple"><i class="fa fas fa-key" aria-hidden="true"></i></div><div class="c-purple">'
. esc_html__('找回密码', 'aksrt') . '</div></a>';
$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')) {
$oauth_html = (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>';
}
$con .= (string) \AKSRT\Auth\OAuth::get_instance()->render_social_buttons(__('快速登录', 'aksrt'));
}
}
@@ -287,6 +299,12 @@ if (!function_exists('aksrt_menu_button')) :
/**
* 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
*/
function aksrt_menu_button(array $ctx): void {
@@ -355,14 +373,24 @@ if (!function_exists('aksrt_menu_button')) :
$account = '<ul class="list-inline splitters relative aksrt-navbar-account'
. ($user_id <= 0 && '2' === $layout ? ' navbar-text' : '') . '">'
. $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;">'
. '<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>';
$html = $fold . $button;
$html = $fold . $button . $right_but;
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-creator.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'); ?>">
<i class="fa fas fa-th-large" aria-hidden="true"></i>
<?php echo $drawer_trigger_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</button>
@@ -26,10 +26,14 @@ foreach ((array) ($oauth_data['bindings'] ?? []) as $binding) {
if (!$key) {
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] = [
'key' => $key,
'name' => (string) ($binding['label'] ?? $binding_name_map[$key] ?? $binding['name'] ?? ucfirst($key)),
'icon' => $binding['icon'] ?? '',
'icon_type' => $icon_type,
'is_bound' => true,
'url' => '',
];
@@ -39,10 +43,12 @@ foreach ((array) ($oauth_data['available'] ?? []) as $binding) {
if (!$key) {
continue;
}
$icon_type = str_starts_with($key, 'clogin_') ? substr($key, 7) : $key;
$binding_rows[$key] = [
'key' => $key,
'name' => (string) ($binding['label'] ?? $binding_name_map[$key] ?? $binding['name'] ?? ucfirst($key)),
'icon' => $binding['icon'] ?? '',
'icon_type' => $icon_type,
'is_bound' => false,
'url' => $binding['start_url'] ?? $binding['url'] ?? '',
];
@@ -64,7 +70,7 @@ foreach ((array) ($oauth_data['available'] ?? []) as $binding) {
<?php foreach ($binding_rows as $binding) : ?>
<?php if (!empty($binding['is_bound'])) : ?>
<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="binding-status">
<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>
<?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']); ?>">
<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="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>
@@ -20,23 +20,26 @@ try {
$wallet = '0.00';
}
$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">
<?php if ($show_wallet) : ?>
<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">
<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>
</a>
<?php endif; ?>
<?php if ($show_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">
<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>
</a>
<?php endif; ?>
@@ -13,20 +13,23 @@ $revenue_url = isset($dashboard_tabs['revenue']) ? aksrt_get_user_center_tab_url
if ($creation_url === '' && $revenue_url === '') {
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">
<?php if ($creation_url !== '') : ?>
<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>
<i class="fa fas fa-angle-right" aria-hidden="true"></i>
<?php echo $arrow_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</a>
<?php endif; ?>
<?php if ($revenue_url !== '') : ?>
<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>
<i class="fa fas fa-angle-right" aria-hidden="true"></i>
<?php echo $arrow_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</a>
<?php endif; ?>
</div>
@@ -2,6 +2,9 @@
/**
* 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
*/
@@ -17,22 +20,20 @@ $orders_url = $orders_available ? (string) ($orders_url ?? '') : '';
$services = array_values(array_filter([
[
'label' => __('我的等级', 'aksrt'),
'icon' => 'chart-line',
'tone' => 'level',
'icon' => 'trend-color',
'url' => $level_url,
'tab' => 'level',
],
[
'label' => __('官方认证', 'aksrt'),
'icon' => 'id-badge',
'tone' => 'auth',
'icon' => 'user-auth',
'url' => $auth_url,
'tab' => 'auth',
],
[
'label' => __('我的订单', 'aksrt'),
'icon' => 'receipt',
'tone' => 'orders',
'icon' => 'order-color',
'url' => $orders_url,
'tab' => 'orders',
],
@@ -41,35 +42,38 @@ $services = array_values(array_filter([
$settings = array_values(array_filter([
[
'label' => __('消息通知', 'aksrt'),
'icon' => 'bell',
'tone' => 'notice',
'icon' => 'msg-color',
'url' => isset($dashboard_tabs['msg']) ? aksrt_get_user_center_tab_url('msg') : '',
'tab' => 'msg',
],
[
'label' => __('个人资料', 'aksrt'),
'icon' => 'user-edit',
'tone' => 'profile',
'icon' => 'user-color',
'url' => isset($dashboard_tabs['profile']) ? aksrt_get_user_center_tab_url('profile') : '',
'tab' => 'profile',
],
[
'label' => __('账户安全', 'aksrt'),
'icon' => 'shield-alt',
'tone' => 'security',
'icon' => 'security-color',
'url' => isset($dashboard_tabs['settings']) ? aksrt_get_user_center_tab_url('settings') : '',
'tab' => 'settings',
],
], static fn(array $item): bool => (string) $item['url'] !== ''));
$render_grid = static function (array $items): void {
?>
<div class="uc-service-grid">
<?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; ?>>
<span class="uc-service-grid__icon">
<i class="fa fas fa-<?php echo esc_attr((string) $item['icon']); ?>" aria-hidden="true"></i>
<?php if (!empty($item['count'])) : ?><small><?php echo esc_html((string) $item['count']); ?></small><?php endif; ?>
</span>
<span><?php echo esc_html((string) $item['label']); ?></span>
<div class="flex ac hh text-center icon-but-box user-icon-but-box">
<?php foreach ($items as $item) :
$tab = (string) ($item['tab'] ?? '');
$item_class = 'icon-but-' . ($tab !== '' ? $tab : 'item');
if (($active_tab ?? '') === $tab) {
$item_class .= ' is-active';
}
$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>
<?php endforeach; ?>
</div>
@@ -29,7 +29,7 @@ if ($status === '') {
}
if ($vip_level > 0) {
$title = (string) ($vip_levels[$vip_level]['name'] ?? ('VIP' . $vip_level));
$title = (string) ($vip_levels[$vip_level]['name_html'] ?? $vip_levels[$vip_level]['name'] ?? ('VIP' . $vip_level));
if ($expires <= 0 && ($vip_state['source'] ?? 'none') !== 'order') {
$status = __('永久会员', 'aksrt');
} else {
@@ -41,7 +41,7 @@ if ($vip_level > 0) {
);
}
} elseif ($expired_level > 0) {
$title = (string) ($vip_levels[$expired_level]['name'] ?? ('VIP' . $expired_level));
$title = (string) ($vip_levels[$expired_level]['name_html'] ?? $vip_levels[$expired_level]['name'] ?? ('VIP' . $expired_level));
$status = __('已过期', 'aksrt');
}
@@ -49,13 +49,14 @@ $sidebar_level = $vip_level > 0 ? $vip_level : $expired_level;
$sidebar_icon_url = esc_url((string) ($vip_levels[$sidebar_level]['img_icon'] ?? ''));
$sidebar_icon = $sidebar_icon_url !== ''
? '<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">
<span class="uc-sidebar-vip__icon"><?php echo $sidebar_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
<span class="uc-sidebar-vip__content">
<strong><?php echo esc_html($title); ?></strong>
<small><?php echo esc_html($status); ?></small>
<strong><?php echo wp_kses_post($title); ?></strong>
<small><?php echo wp_kses_post($status); ?></small>
</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>
+35 -24
View File
@@ -29,6 +29,8 @@ $vip_state_name = (string) ($vip_state['state'] ?? 'unopened');
$vip_state_source = (string) ($vip_state['source'] ?? 'none');
$levels = is_array($vip_settings['levels'] ?? null) ? $vip_settings['levels'] : [];
$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.
$vip_state = 'unopened';
@@ -36,13 +38,15 @@ $vip_state_label = __('未开通', 'aksrt');
$vip_level_number = 0;
$vip_level_name = __('未开通会员', 'aksrt');
$vip_description = $desc_top !== '' ? $desc_top : __('开通会员,尊享专属特权', 'aksrt');
$vip_expiry_text = __('到期时间:未开通', 'aksrt');
// 未开通时不留到期文案:状态标签已显示「未开通」,参考主题对从未开通
// 用户同样不输出任何到期信息(仅生效中/永久/已过期才有)。
$vip_expiry_text = '';
$vip_is_permanent = false;
$btn_type = 'pay';
if ($level > 0) {
$vip_level_number = $level;
$vip_level_name = (string) ($levels[$level]['name'] ?? ('VIP' . $level));
$vip_level_name = (string) ($levels[$level]['name_html'] ?? $levels[$level]['name'] ?? ('VIP' . $level));
$level_desc = (string) ($levels[$level]['desc'] ?? '');
$vip_description = $level_desc !== '' ? $level_desc : __('尊享专属特权', 'aksrt');
@@ -59,7 +63,7 @@ if ($level > 0) {
$vip_state_label = __('生效中', 'aksrt');
$vip_expiry_text = sprintf(__('到期时间:%s', 'aksrt'), wp_date('Y年m月d日', $expires));
if ($base_level > 0 && $base_level < $level) {
$base_name = (string) ($levels[$base_level]['name'] ?? ('VIP' . $base_level));
$base_name = wp_strip_all_tags((string) ($levels[$base_level]['name'] ?? ('VIP' . $base_level)));
$base_expiry_text = $base_expires > 0
? wp_date('Y年m月d日', $base_expires)
: __('永久', 'aksrt');
@@ -71,7 +75,7 @@ if ($level > 0) {
$vip_state = 'expired';
$vip_state_label = __('已过期', 'aksrt');
$vip_level_number = $expired_level;
$vip_level_name = (string) ($levels[$expired_level]['name'] ?? ('VIP' . $expired_level));
$vip_level_name = (string) ($levels[$expired_level]['name_html'] ?? $levels[$expired_level]['name'] ?? ('VIP' . $expired_level));
$expired_desc = (string) ($levels[$expired_level]['desc'] ?? '');
$vip_description = $expired_desc !== ''
? $expired_desc
@@ -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>'
. '</div>'
. '<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>'
. '<span class="uc-vip-status__level">' . esc_html($vip_level_name) . '</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">' . wp_kses_post($vip_level_name) . '</span>'
. '</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>'
. '<div class="uc-vip-status__desc">' . wp_kses_post($vip_description) . '</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>';
// Level cards.
@@ -104,15 +109,18 @@ foreach ([1, 2] as $lv) {
if (empty($levels[$lv]['enabled'])) {
continue;
}
$lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
$lv_name = (string) ($levels[$lv]['name_html'] ?? $levels[$lv]['name'] ?? ('VIP' . $lv));
$lv_desc = (string) ($levels[$lv]['desc'] ?? '');
// Preserve admin equity HTML as-is (sanitize/wrap only; never re-split into items).
$equity_raw = (string) ($levels[$lv]['equity'] ?? '');
$equity = function_exists('aksrt_format_vip_equity_html')
? aksrt_format_vip_equity_html($equity_raw)
: '';
$cta = sprintf(__('开通%s', 'aksrt'), $lv_name);
$cta = __('开通', 'aksrt') . wp_kses_post($lv_name);
$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 = $lv_icon_url !== ''
? '<img' . aksrt_get_lazy_attr('lazy_other', $lv_icon_url, 'aksrt-vip-level-icon') . ' alt="" decoding="async">'
@@ -120,9 +128,11 @@ foreach ([1, 2] as $lv) {
if (str_starts_with($btn_type, 'renew_')) {
$renew_lv = (int) substr($btn_type, 6);
if ($lv === $renew_lv) {
$cta = sprintf(__('续费%s', 'aksrt'), $lv_name);
$cta = __('续费', 'aksrt') . wp_kses_post($lv_name);
$card_action = 'renew';
} elseif ($lv > $renew_lv) {
$cta = sprintf(__('升级%s', 'aksrt'), $lv_name);
$cta = __('升级', 'aksrt') . wp_kses_post($lv_name);
$card_action = 'upgrade';
} else {
continue;
}
@@ -130,25 +140,32 @@ foreach ([1, 2] as $lv) {
if ($lv <= $level) {
continue;
}
$cta = sprintf(__('升级%s', 'aksrt'), $lv_name);
$cta = __('升级', 'aksrt') . wp_kses_post($lv_name);
$card_action = 'upgrade';
$vip_attr = 2;
} elseif ($level > 0 && $lv < $level) {
continue;
} elseif ($level > 0 && $lv === $level) {
$cta = sprintf(__('续费%s', 'aksrt'), $lv_name);
$cta = __('续费', 'aksrt') . wp_kses_post($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="relative uc-vip-level-card__inner">';
$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>';
if ($lv_desc !== '') {
$cards .= '<div class="v-desc em09 muted-2-color">' . esc_html($lv_desc) . '</div>';
$cards .= '<div class="v-name font-bold"><span class="v-icon">' . $lv_icon . '</span>' . wp_kses_post($lv_name) . '</div>';
if ($card_desc !== '') {
$cards .= '<div class="v-desc em09 muted-2-color">' . wp_kses_post($card_desc) . '</div>';
}
$cards .= '</div>';
$cards .= $equity;
$cards .= '<a class="padding-lg but jb-vip' . $lv . ' radius pay-vip uc-vip-cta" href="javascript:;" vip-level="' . (int) $vip_attr . '">'
. '<span>' . esc_html($cta) . '</span>'
. '<span>' . $cta . '</span>'
. ' <i class="fa fas fa-chevron-circle-right" aria-hidden="true"></i></a>';
$cards .= '</div></div>';
}
@@ -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 -- 各字段已在生成时转义。
?>
<?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>
+1 -105
View File
@@ -19,30 +19,12 @@ class AIController extends ControllerBase {
}
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', [
'methods' => 'POST',
'callback' => [$this, 'seo_generate'],
'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', [
'methods' => 'POST',
'callback' => [$this, 'summary_suggest'],
@@ -76,66 +58,6 @@ class AIController extends ControllerBase {
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 三字段。
*/
@@ -170,32 +92,6 @@ class AIController extends ControllerBase {
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 {
if (!class_exists('\AKSRT\Ai\AI') || !class_exists('\AKSRT\Ai\AIPrompt')) {
return $this->error(__('AI 模块未加载', 'aksrt'), 500);
@@ -224,7 +120,7 @@ class AIController extends ControllerBase {
$ai = \AKSRT\Ai\AI::get_instance();
if (!$ai->is_available()) {
return $this->error(
__('AI 服务未启用或未配置。请到「扩展 → AI 辅助」启用并填写 API Key(或配置 WordPress AI Connectors)。', 'aksrt'),
__('AI 服务不可用,请配置 WordPress AI Connectors。', 'aksrt'),
503
);
}
+80 -11
View File
@@ -351,6 +351,13 @@ class PayOrders {
echo '<p><a class="button" href="' . esc_url($this->page_url('aksrt_orders')) . '">&larr; ' . esc_html__('返回订单列表', 'aksrt') . '</a></p>';
// 售后记录区块:工单卡片 + 处理记录列表(与售后面板共用 .aksrt-as-log 样式)。
echo '<style>'
. '.aksrt-as-ticket{padding:10px 0;border-bottom:1px solid #f0f0f1;}'
. '.aksrt-as-ticket:last-child{padding-bottom:0;border-bottom:0;}'
. '.aksrt-as-ticket__head{margin:0 0 4px;}'
. $this->after_sale_log_css()
. '</style>';
echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
$rows = [
__('订单号', 'aksrt') => $order_number,
@@ -371,12 +378,17 @@ class PayOrders {
$after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number);
if ($after_sales) {
echo '<tr><th>' . esc_html__('售后记录', 'aksrt') . '</th><td>';
$status_labels = ['pending' => '待审核', 'processing' => '处理中', 'completed' => '已完成', 'rejected' => '已拒绝', 'cancelled' => '已取消'];
$status_labels = $this->after_sale_status_labels();
foreach ($after_sales as $application) {
echo '<p><strong>' . esc_html($status_labels[(string) ($application['status'] ?? '')] ?? (string) ($application['status'] ?? '')) . '</strong> · ' . esc_html((string) ($application['refund_amount'] ?? '')) . '<br>' . esc_html((string) ($application['reason'] ?? ''));
if (!empty($application['admin_note'])) echo '<br>' . esc_html__('处理备注:', 'aksrt') . esc_html((string) $application['admin_note']);
if (!empty($application['gateway_result'])) echo '<br><code>' . esc_html((string) $application['gateway_result']) . '</code>';
echo '</p>';
$as_status = (string) ($application['status'] ?? '');
echo '<div class="aksrt-as-ticket is-' . esc_attr($as_status) . '">';
echo '<p class="aksrt-as-ticket__head"><strong>' . esc_html($status_labels[$as_status] ?? $as_status) . '</strong>'
. ' <span class="description">' . esc_html((string) ($application['refund_amount'] ?? '')) . '</span>'
. ' <span class="description">' . esc_html((string) ($application['created_at'] ?? '')) . '</span></p>';
echo '<p>' . nl2br(esc_html((string) ($application['reason'] ?? ''))) . '</p>';
// 处理记录:后台每一次提交都留一条(状态 + 备注 + 时间),与售后面板同款标记。
echo $this->render_after_sale_log((array) ($application['handling_log'] ?? []), $status_labels); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '</div>';
}
echo '</td></tr>';
}
@@ -429,7 +441,7 @@ class PayOrders {
echo '<form method="get"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_AFTER_SALES_SLUG) . '" />';
echo '<select name="after_sale_status"><option value="">' . esc_html__('全部售后状态', 'aksrt') . '</option>';
foreach ([\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消'] as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
foreach ($this->after_sale_status_labels() as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
echo '</select> <label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> <label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
echo '</form>';
@@ -452,12 +464,13 @@ class PayOrders {
. '.aksrt-as-review textarea{flex:1 1 240px;min-width:0;box-sizing:border-box;margin:0;}'
. '.aksrt-as-review-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;margin-left:auto;}'
. '.aksrt-as-field--wide{grid-column:1/-1;}'
. $this->after_sale_log_css()
. '@media (min-width:1500px){.aksrt-as-detail-grid{grid-template-columns:repeat(5,minmax(0,1fr));}}'
. '@media (max-width:600px){.aksrt-as-detail-grid{grid-template-columns:minmax(0,1fr);}}'
. '</style>';
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>订单号</th><th>用户</th><th>原因</th><th>申请资料</th><th>申请说明</th><th>退款金额</th><th>状态</th><th>操作 / 处理记录</th></tr></thead><tbody>';
if (!$items) echo '<tr><td colspan="9">' . esc_html__('暂无售后申请', 'aksrt') . '</td></tr>';
$status_labels = [\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核', \AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中', \AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成', \AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝', \AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消'];
$status_labels = $this->after_sale_status_labels();
foreach ($items as $item) {
$id = (int) $item['id']; $number = (string) $item['order_number']; $uid = (int) $item['user_id'];
$user = $uid > 0 ? get_userdata($uid) : null;
@@ -487,13 +500,15 @@ class PayOrders {
$image_urls = array_values(array_filter((array) ($item['images'] ?? []), 'is_string'));
$thumbs = [];
foreach ($image_urls as $url) {
$thumbs[] = '<a href="' . esc_url($url) . '" target="_blank" rel="noopener"><img src="' . esc_url($url) . '" alt="" class="aksrt-as-thumb" loading="lazy" /></a>';
// 不写原生 loading 懒加载属性:lazyload 契约测试全仓库禁 loading=lazy/eager,
// 且后台不加载 lazySizes 运行时,写了也不会被主题那套接管。
// 与 UsersMenusVerify 的后台缩略图保持一致(光 <img src>)。
$thumbs[] = '<a href="' . esc_url($url) . '" target="_blank" rel="noopener"><img src="' . esc_url($url) . '" alt="" class="aksrt-as-thumb" /></a>';
}
// 举证图片并入申请说明:缩略图展示在描述文字下方,不再单独成列。
$desc_cell = ($desc_text !== '' ? nl2br(esc_html($desc_text)) : '') . ($thumbs ? '<div class="aksrt-as-desc-thumbs" style="margin-top:6px;">' . implode('', $thumbs) . '</div>' : '');
if ($desc_cell === '') $desc_cell = '—';
$status_label = $status_labels[(string) $item['status']] ?? (string) $item['status'];
$admin_note = (string) ($item['admin_note'] ?? '');
$order_refunded = is_array($order) && (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED;
// 摘要行 + 手风琴详情行:点击「操作」展开整条记录的完整信息与状态面板
echo '<tr class="aksrt-as-row">';
@@ -517,13 +532,15 @@ class PayOrders {
__('当前状态', 'aksrt') => esc_html($status_label),
__('售后原因', 'aksrt') => $reason_cell,
__('申请资料', 'aksrt') => $materials_cell,
__('处理备注', 'aksrt') => $admin_note !== '' ? esc_html($admin_note) : '—',
__('审核时间', 'aksrt') => !empty($item['reviewed_at']) ? esc_html((string) $item['reviewed_at']) : '—',
];
echo '<div class="aksrt-as-detail"><div class="aksrt-as-detail-grid">';
foreach ($detail_fields as $field_label => $field_value) {
echo '<span class="aksrt-as-field"><span class="aksrt-as-label">' . esc_html($field_label) . '</span><span class="aksrt-as-value">' . $field_value . '</span></span>';
}
// 处理备注:单独占整行,放在申请说明上方;每次提交一条,含状态与时间。
echo '<span class="aksrt-as-field aksrt-as-field--wide"><span class="aksrt-as-label">' . esc_html__('处理备注', 'aksrt') . '</span><span class="aksrt-as-value">'
. $this->render_after_sale_log((array) ($item['handling_log'] ?? []), $status_labels) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
. '</span></span>';
// 举证图片并入申请说明,单独占整行展示。
echo '<span class="aksrt-as-field aksrt-as-field--wide"><span class="aksrt-as-label">' . esc_html__('申请说明', 'aksrt') . '</span><span class="aksrt-as-value">' . $desc_cell . '</span></span>';
echo '</div>';
@@ -616,6 +633,58 @@ class PayOrders {
];
}
/** 售后状态中文标签:订单明细页与售后面板(含筛选下拉)共用,取代两处内联数组。 */
/** 处理记录列表样式:两个后台页面各自输出同一套规则,不依赖对方加载。 */
private function after_sale_log_css(): string {
return '.aksrt-as-log{display:grid;gap:5px;margin:6px 0 0;padding:0;list-style:none;}'
. '.aksrt-as-log__item{display:flex;flex-wrap:wrap;gap:3px 10px;align-items:baseline;font-size:12px;line-height:1.7;}'
. '.aksrt-as-log__time{color:#646970;font-variant-numeric:tabular-nums;}'
. '.aksrt-as-log__status{font-weight:600;}'
. '.aksrt-as-log__item.is-pending .aksrt-as-log__status{color:#b26200;}'
. '.aksrt-as-log__item.is-processing .aksrt-as-log__status{color:#2271b1;}'
. '.aksrt-as-log__item.is-completed .aksrt-as-log__status{color:#007017;}'
. '.aksrt-as-log__item.is-rejected .aksrt-as-log__status,.aksrt-as-log__item.is-cancelled .aksrt-as-log__status{color:#b32d2e;}'
. '.aksrt-as-log__note{flex:1 1 100%;color:#3c434a;}';
}
private function after_sale_status_labels(): array {
return [
\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核',
\AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中',
\AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成',
\AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝',
\AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消',
];
}
/**
* 处理记录列表:每一次状态提交一条「时间 + 状态 + 备注」,备注为空也留痕。
* 后台订单明细页与售后面板共用同一套标记与样式。
*
* @param list<array<string,mixed>> $log
* @param array<string,string> $status_labels
*/
private function render_after_sale_log(array $log, array $status_labels): string {
if (!$log) {
return '<p class="description">' . esc_html__('暂无处理记录', 'aksrt') . '</p>';
}
$service = \AKSRT\Payment\AfterSaleService::class;
$html = '<ul class="aksrt-as-log">';
foreach ($log as $entry) {
$status = (string) ($entry['status'] ?? '');
$label = (string) ($entry['source'] ?? '') === $service::SOURCE_USER
? ($status === $service::STATUS_CANCELLED ? esc_html__('用户撤销售后', 'aksrt') : esc_html__('用户提交申请', 'aksrt'))
: ($status_labels[$status] ?? $status);
$note = (string) ($entry['note'] ?? '');
$html .= '<li class="aksrt-as-log__item is-' . esc_attr($status) . '">'
. '<span class="aksrt-as-log__time">' . esc_html((string) ($entry['created_at'] ?? '')) . '</span>'
. '<span class="aksrt-as-log__status">' . esc_html($label) . '</span>'
. '<span class="aksrt-as-log__note">' . ($note !== '' ? nl2br(esc_html($note)) : '—') . '</span>'
. '</li>';
}
return $html . '</ul>';
}
public function handle_after_sale_status(): void {
if (!current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
$id = isset($_POST['after_sale_id']) ? absint($_POST['after_sale_id']) : 0;
+3 -1
View File
@@ -92,7 +92,9 @@ class PostListEnhance {
$order = $this->current_order();
$order_by = $this->current_orderby();
$o_icon = '<i class="opacity5 ml3 fa fa-long-arrow-' . ($order === 'asc' ? 'down' : 'up') . '"></i>';
$o_icon = $order === 'asc'
? '<i class="opacity5 ml3 fa fas fa-long-arrow-alt-down"></i>'
: '<i class="opacity5 ml3 fa fas fa-long-arrow-alt-up"></i>';
$new_columns = [];
foreach ($columns as $key => $label) {
+1 -7
View File
@@ -68,7 +68,7 @@ class GlobalSchema {
'groups' => [
['key' => 'basic', 'title' => 'SEO基础', 'fields' => [
['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_title', 'label' => 'SEO标题(title)', '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_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' => 'seo_image_optimize', 'label' => '搜索引擎图片展示优化', 'type' => 'switch', 'default' => true],
['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_update', 'label' => '更新时推送', 'type' => 'switch', 'default' => false],
]],
['key' => 'xzh', 'title' => '熊掌号 / JSON-LD', 'fields' => [
['key' => 'xzh_on', 'label' => '启用熊掌号', 'type' => 'switch', 'default' => false],
['key' => 'xzh_appid', 'label' => '百度 AppID', 'type' => 'input', 'default' => ''],
['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' => 'channel', 'title' => '收录通道', 'fields' => [
['key' => 'baidu_push_normal', 'label' => '普通收录', 'type' => 'switch', 'default' => true],
['key' => 'baidu_push_fast', 'label' => '快速收录', 'type' => 'switch', 'default' => false],
]],
['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],
]],
],
+1 -1
View File
@@ -53,7 +53,7 @@ class SchemaPageDisplay {
self::field('nav_newposts', 'Add发布按钮', 'switcher', true, ['description' => '顶部导航显示文章投稿按钮。']),
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_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_only_password', 'label' => '积分卡密单密码模式', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'points_pass_exchange_s', 'value' => true], 'help' => '积分卡密仅需输入密码,不需要卡号'],
['key' => 'pay_vip_pass_charge_s', 'label' => '启用卡密兑换会员功能', 'type' => 'switch', 'default' => false, 'help' => '开启后用户可在会员购买/续费弹窗选择「卡密」渠道,使用会员卡密直接兑换会员'],
['key' => 'pay_vip_pass_charge_desc', 'label' => '卡密兑换会员说明', 'type' => 'textarea', 'default' => '', 'rows' => 2, 'dependsOn' => ['key' => 'pay_vip_pass_charge_s', 'value' => true], 'help' => '用户使用卡密兑换会员时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码'],
['key' => 'pay_vip_pass_charge_desc', 'label' => '卡密兑换会员说明', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '', 'rows' => 2, 'dependsOn' => ['key' => 'pay_vip_pass_charge_s', 'value' => true], 'help' => '用户使用卡密兑换会员时显示的内容,建议为提醒事项、兑换协议等,支持HTML代码'],
['key' => 'pay_vip_pass_charge_only_password', 'label' => '会员卡密单密码模式', 'type' => 'switch', 'default' => false, 'dependsOn' => ['key' => 'pay_vip_pass_charge_s', 'value' => true], 'help' => '会员卡密仅需输入密码,不需要卡号;默认关闭,会员卡需输入卡号和密码(与其他卡密兑换一致)'],
]],
['key' => 'points-exchange', 'title' => '余额兑换积分', 'fields' => [
@@ -178,7 +178,7 @@ class SchemaPayment {
'vip' => [
'key' => 'vip',
'title' => 'VIP会员',
'version' => '1.1.0',
'version' => '1.2.0',
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'notice', 'title' => '说明', 'fields' => [
@@ -188,17 +188,8 @@ class SchemaPayment {
['key' => 'nav_pay_vip', 'label' => '导航栏购买按钮', 'type' => 'switch', 'default' => true, 'help' => '在顶部导航栏显示开通会员按钮;请注意顶部导航的整体宽度。'],
['key' => 'nav_user_pay_vip', 'label' => '用户框购买按钮', 'type' => 'switch', 'default' => true, 'help' => '在导航栏用户框内显示开通会员按钮。'],
['key' => 'pay_user_vip_1_s', 'label' => '启用一级会员', 'type' => 'switch', 'default' => true],
['key' => 'pay_user_vip_1_name', 'label' => '一级会员显示名称', 'type' => 'input', 'default' => '一级会员', 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true]],
['key' => 'pay_user_vip_1_desc', 'label' => '一级会员一句话简介', 'type' => 'input', 'default' => '尊享一级会员特权', 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true]],
['key' => 'pay_user_vip_1_equity', 'label' => '一级会员权益 HTML', 'type' => 'textarea', 'default' => '<li>免费下载部分资源</li><li>会员专属身份标识</li><li>付费内容会员价</li>', 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true]],
['key' => 'vip_1_img_icon', 'label' => '一级会员图标 URL', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true], 'help' => '留空使用内置 vip-1.svg 图标'],
['key' => 'vip_level_order_notice', 'label' => '', 'type' => 'notice', 'ui_only' => true, 'alertType' => 'danger', 'dependsOn' => ['all' => [['key' => 'pay_user_vip_2_s', 'value' => true], ['key' => 'pay_user_vip_1_s', 'operator' => 'falsy']]], 'content' => '<div style="text-align:center"><b>必须先开启一级会员后才能开启二级会员!</b></div>'],
['key' => 'pay_user_vip_2_s', 'label' => '启用二级会员', 'type' => 'switch', 'default' => true],
['key' => 'pay_user_vip_2_name', 'label' => '二级会员显示名称', 'type' => 'input', 'default' => '二级会员', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true]],
['key' => 'pay_user_vip_2_desc', 'label' => '二级会员一句话简介', 'type' => 'input', 'default' => '尊享二级会员特权', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true]],
['key' => 'pay_user_vip_2_equity', 'label' => '二级会员权益 HTML', 'type' => 'textarea', 'default' => '<li>更高免费下载额度</li><li>更高等级身份标识</li><li>更多付费内容会员价</li>', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true]],
['key' => 'vip_2_img_icon', 'label' => '二级会员图标 URL', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'pay_user_vip_2_s', 'value' => true], 'help' => '留空使用内置 vip-2.svg 图标'],
['key' => 'pay_user_vip_desc', 'label' => '弹窗顶部会员说明', 'type' => 'textarea', 'default' => ''],
['key' => 'pay_user_vip_more', 'label' => '弹窗底部补充说明 HTML', 'type' => 'textarea', 'default' => ''],
]],
['key' => 'benefit', 'title' => '会员权益', 'fields' => [
['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_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_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_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' => 'vip_opt', 'label' => '会员购买选项', 'type' => 'tabbed', 'tabs' => [
['key' => 'vip1', 'title' => '一级会员', 'fields' => [
['key' => 'vip_1_product_s', 'label' => '允许购买一级会员', 'type' => 'switch', 'default' => true, 'dependsOn' => ['key' => 'pay_user_vip_1_s', 'value' => true]],
['key' => 'payment_vip_1_products', 'label' => '一级会员套餐', 'type' => 'textarea', 'default' => "30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡", 'dependsOn' => ['key' => 'vip_1_product_s', 'value' => true], 'help' => '每行:执行价|原价|时长|单位(day|month)|标签|标题;时长 0=永久'],
]],
['key' => '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' => 'vip1', 'title' => '一级会员', 'titleKey' => 'pay_user_vip_1_name', 'titleFallback' => '一级会员', 'fields' => self::vip_level_tab_fields(1)],
['key' => 'vip2', 'title' => '二级会员', 'titleKey' => 'pay_user_vip_2_name', 'titleFallback' => '二级会员', 'fields' => self::vip_level_tab_fields(2)],
['key' => 'renew', 'title' => '会员续费', 'fields' => [
['key' => 'vip_renew', 'label' => '允许续费', 'type' => 'switch', 'default' => true, 'help' => '关闭后则不能续费,只能会员到期之后再购买。永久会员不需要续费'],
['key' => 'vip_renew_desc', 'label' => '续费说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'vip_renew', 'value' => true]],
['key' => 'vip_renew_price_type', 'label' => '续费价格类型', 'type' => 'radio', 'default' => 'discount', 'dependsOn' => ['key' => 'vip_renew', 'value' => true], 'options' => [
['key' => 'vip_renew_desc', 'label' => '续费介绍', 'type' => 'input', 'sanitize_type' => 'html', 'default' => '立即续费会员,畅享VIP权益', 'dependsOn' => ['key' => 'vip_renew', 'value' => true], 'help' => '续费会员的一句话简介,支持HTML'],
['key' => 'vip_renew_price_type', 'label' => '续费价格类型', 'type' => 'select', 'default' => 'discount', 'dependsOn' => ['key' => 'vip_renew', 'value' => true], 'options' => [
['label' => '相同价格', 'value' => 'same'],
['label' => '折扣价格', 'value' => 'discount'],
['label' => '优惠立减', 'value' => 'reduce'],
]],
['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']],
['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.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' => 'vip_upgrade', 'label' => '允许升级', 'type' => 'switch', 'default' => true],
['key' => 'vip_upgrade_desc', 'label' => '升级说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'vip_upgrade', 'value' => true]],
['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_desc', 'label' => '升级介绍', 'type' => 'input', 'sanitize_type' => 'html', 'default' => '升级VIP会员,享更多会员权益', 'dependsOn' => ['key' => 'vip_upgrade', 'value' => true], 'help' => '升级会员的一句话简介,支持HTML'],
[
'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' => 'pay_vip_points_exchange_s', 'label' => '启用积分兑换', 'type' => 'switch', 'default' => false],
['key' => 'payment_vip_points_packages', 'label' => '兑换套餐', 'type' => 'textarea', 'default' => "1|500|30|积分月卡\n1|5000|0|积分永久", 'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true], 'help' => '每行:等级|积分|天数|标题(天数 0=永久;普通用户可开通,已有会员可续费同等级或升级更高等级)'],
['key' => 'pay_vip_points_exchange_desc', 'label' => '兑换说明', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true]],
['key' => 'pay_vip_points_exchange_s', 'label' => '启用积分兑换', 'type' => 'switch', 'default' => false, 'help' => '开启后用户可使用积分兑换会员;由于积分可免费获取,所以积分兑换会员功能不支持升级及续费'],
[
'key' => 'pay_vip_points_exchange_product', 'label' => '积分兑换明细', 'type' => 'group', 'max' => 16,
'button_title' => '添加积分兑换商品', 'default' => [
['points' => 500, 'level' => 1, 'time' => 30, 'unit' => 'day'],
['points' => 5000, 'level' => 1, 'time' => 'Permanent'],
],
'dependsOn' => ['key' => 'pay_vip_points_exchange_s', 'value' => true],
'help' => '每一行为一个可兑换的会员套餐',
'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', 'sanitize_type' => 'html', 'default' => '开通VIP会员,享受会员专属折扣以及多项特权', 'dependsOn' => ['any' => [['key' => 'pay_user_vip_1_s', 'value' => true], ['key' => 'pay_user_vip_2_s', 'value' => true]]], 'help' => '显示在开通界面顶部的一句话简介,支持HTML'],
['key' => 'pay_user_vip_more', 'label' => '开通会员更多内容', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>', 'rows' => 4, 'dependsOn' => ['any' => [['key' => 'pay_user_vip_1_s', 'value' => true], ['key' => 'pay_user_vip_2_s', 'value' => true]]]],
]],
],
],
@@ -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 种)。
*
@@ -576,4 +654,92 @@ class SchemaPayment {
}
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', 'sanitize_type' => 'html', 'default' => $level === 1 ? '黄金会员' : '钻石会员', 'dependsOn' => $dep, 'help' => '会员名称,支持HTML'],
['key' => 'pay_user_vip_' . $level . '_desc', 'label' => '一句话简介', 'type' => 'input', 'sanitize_type' => 'html', 'default' => '尊享专属特权', 'dependsOn' => $dep, 'help' => '支持HTML'],
['key' => 'pay_user_vip_' . $level . '_equity', 'label' => '会员权益 HTML', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => $equity, 'rows' => 4, 'dependsOn' => $dep, 'help' => '支持安全HTML,建议使用 <li> 列表项'],
['key' => 'vip_' . $level . '_product_s', 'label' => '购买会员', 'type' => 'switch', 'default' => true, 'dependsOn' => $dep, 'help' => '关闭后则不能付费购买会员,但可通过积分兑换会员,请确保已开启积分以及积分兑换会员功能'],
[
'key' => 'vip_' . $level . '_product', 'label' => '会员商品', 'type' => 'group', 'max' => 8,
'button_title' => '添加会员商品', 'default' => 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;
}
/**
* 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) {
if (!empty($field['captcha_provider'])) {
return function_exists('aksrt_captcha_sanitize_provider_config')
@@ -898,6 +939,15 @@ class SchemaProvider {
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)) {
return $this->sanitize_object_fields($value, (array) ($field['fields'] ?? []));
}
@@ -994,6 +1044,12 @@ class SchemaProvider {
if (!is_array($field) || empty($field['key'])) {
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'];
if (array_key_exists($key, $value)) {
$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 {
if ($request->get_method() === 'GET') {
$config = \AKSRT\Settings\CreditsRuntime::get_credit_settings();
+9 -314
View File
@@ -11,9 +11,6 @@ class AI {
private static $instance = null;
const PROVIDER_NONE = 'none';
const PROVIDER_OPENAI = 'openai';
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
@@ -21,95 +18,10 @@ class AI {
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 是否真正可用。
*
* 仅有函数存根不够:没有 Connectors / wp_supports_ai=false 时不应抢占主题 provider。
* 仅有函数存根不够:没有 Connectors / wp_supports_ai=false 时不应声称可用。
*/
public function is_wp_ai_available(): bool {
if (!function_exists('wp_ai_client_prompt')) {
@@ -124,89 +36,24 @@ class AI {
}
/**
* 主题 OpenAI 兼容 provider 是否可用。
*/
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 !== '';
}
/**
* 任一后端可用即可生成。
* AI 生成是否可用(连接由 WordPress AI Connectors 提供)。
*/
public function is_available(): bool {
return $this->is_theme_provider_available() || $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(),
];
return $this->is_wp_ai_available();
}
/**
* 统一文本生成出口。
*
* @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}
*/
public function generate_text(string $prompt, array $options = []): array {
if (!$this->is_available()) {
return [
'success' => false,
'message' => __('AI 服务未启用或未配置。请配置 WordPress AI Connectors,或在「扩展 → AI 辅助」填写 API Key。', 'aksrt'),
];
}
if (!$this->check_rate_limit()) {
return [
'success' => false,
'message' => __('请求过于频繁,请稍后再试', 'aksrt'),
'message' => __('AI 服务不可用,请配置 WordPress AI Connectors。', 'aksrt'),
];
}
@@ -214,42 +61,12 @@ class AI {
? (string) $options['system_instruction']
: __('你是一个内容助手,提供建议性内容。输出仅作为参考建议,不会自动应用到线上内容。', 'aksrt');
$last_error = [
'success' => false,
'message' => __('AI 服务未启用或未配置。请配置 WordPress AI Connectors,或在「扩展 → AI 辅助」填写 API Key。', 'aksrt'),
];
// 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.
$result = $this->generate_via_wp_ai($prompt, $system, $options);
if (!empty($result['success'])) {
$result['backend'] = 'wp_ai';
}
// 2) WordPress AI Client (Connectors).
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);
return $result;
}
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. 直接输出描述文本本身,不要任何前缀、引号或解释。';
}
/**
* 兼容旧单 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 {
$content = mb_substr($content, 0, 3000);
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
*/
+41 -19
View File
@@ -219,35 +219,57 @@ final class OAuth {
'name' => $provider,
'label' => OAuthProviderCatalog::label($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;
}
/**
* 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 {
$items = $this->get_auth_providers_data();
if ($items === []) {
return '';
}
ob_start();
?>
<div class="social-login-divider">
<span><?php echo esc_html($title) ?: esc_html__('or', 'aksrt'); ?></span>
</div>
<div class="oauth-row">
<?php foreach ($items as $item) : ?>
<a href="<?php echo esc_url((string) $item['url']); ?>"
class="oauth-btn oauth-btn--<?php echo esc_attr((string) $item['name']); ?>"
data-provider="<?php echo esc_attr((string) $item['name']); ?>"
aria-label="<?php echo esc_attr((string) $item['label']); ?>"
title="<?php echo esc_attr((string) $item['label']); ?>">
<?php echo $item['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</a>
<?php endforeach; ?>
</div>
<?php
return (string) ob_get_clean();
$large = (bool) \AKSRT\Settings\Settings::get_value('oauth_button_lg', false);
$html = $title !== ''
? '<p class="social-separator separator muted-3-color em09 mt10">' . esc_html($title) . '</p>'
: '';
$html .= '<div class="social_loginbar">';
foreach ($items as $item) {
$name = sanitize_html_class((string) ($item['name'] ?? ''));
$type = str_starts_with($name, 'clogin_') ? substr($name, 7) : $name;
$label = trim((string) ($item['label'] ?? ''));
$url = (string) ($item['url'] ?? '');
if ($name === '' || $type === '' || $label === '' || $url === '') {
continue;
}
$class = 'social-login-item ' . $type . ($large ? ' button-lg' : ' toggle-radius');
if ($name === 'weixingzh') {
// Reference parity: the WeChat entry opens the QR panel instead of
// 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 {
+27 -22
View File
@@ -38,8 +38,9 @@ trait VipServiceModal {
: wp_kses_post($equity);
$level_view[] = [
'level' => $level,
'name' => (string) ($levels[$level]['name'] ?? ('VIP' . $level)),
'desc' => (string) ($levels[$level]['desc'] ?? ''),
'name' => wp_kses_post((string) ($levels[$level]['name_html'] ?? $levels[$level]['name'] ?? ('VIP' . $level))),
'name_text' => (string) ($levels[$level]['name'] ?? ('VIP' . $level)),
'desc' => wp_kses_post((string) ($levels[$level]['desc'] ?? '')),
'equity' => $equity,
'img_icon' => esc_url_raw((string) ($levels[$level]['img_icon'] ?? '')),
];
@@ -115,7 +116,6 @@ trait VipServiceModal {
'prefer_level' => $prefer_level,
'catalog' => [
'desc' => (string) ($catalog['desc'] ?? ''),
'help_text' => (string) ($catalog['help_text'] ?? ''),
'more' => (string) ($vip['more'] ?? ''),
'currency_name' => (string) ($catalog['currency_name'] ?? ''),
'level_packages' => $groups,
@@ -215,15 +215,17 @@ trait VipServiceModal {
}
$level = max(1, min(2, (int) ($package['level'] ?? 1)));
$points = max(0, (int) ($package['points'] ?? 0));
$days = max(0, (int) ($package['days'] ?? 0));
// 参考主题时长行:30<span class="px12">天</span> / 永久。
$duration = $days > 0
? (string) $days . '<span class="px12">' . esc_html__('天', 'aksrt') . '</span>'
: esc_html__('永久', 'aksrt');
$level_name = (string) ($levels[$level]['name'] ?? ('VIP' . $level));
// 参考主题时长行:30<span class="px12">天</span> / 永久;time 为 Permanent 时按永久渲染。
$is_permanent = !empty($package['is_permanent']);
$pkg_time = max(0, (int) ($package['time'] ?? 0));
$pkg_unit = ((string) ($package['unit'] ?? 'day')) === 'month' ? 'month' : 'day';
$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_html'] ?? $levels[$level]['name'] ?? ('VIP' . $level));
$cards .= '<div class="vip-card vip-theme' . $level . ($index === 0 ? ' active' : '') . ' " data-for="product_id" data-value="' . $package_id . '">'
. '<span class=" ">' . $this->points_exchange_card_icon($levels[$level] ?? []) . esc_html($level_name) . '</span>'
. '<span class=" ">' . $this->points_exchange_card_icon($levels[$level] ?? []) . wp_kses_post($level_name) . '</span>'
. '<div class="flex jsb ac hh em16 mt10">'
. '<span class="shrink0 mr10">' . $duration . '</span>'
. '<span class="shrink0">' . $points . '<span class="px12">' . esc_html($currency) . '</span></span>'
@@ -237,16 +239,18 @@ trait VipServiceModal {
return '';
}
$desc_text = trim((string) ($points_ex['desc'] ?? ''));
$desc_text = trim((string) ($vip['points_exchange']['desc'] ?? ''));
$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 aksrt-vip-points-desc">' . wp_kses_post($desc_text) . '</div>'
: '';
// 最低价套餐仍超出用户积分 → 参考主题的 badg c-red btn-block 提示。
// (参考主题源码写作 `!$min_val > $user_points`,因运算符优先级恒假,
// 此处按其原意取真实比较,保证提示分支可达。)
if ($min_points > $user_points) {
$form = '<div class="badg c-red btn-block">' . esc_html__('积分不足,暂时无法兑换', 'aksrt') . '</div>';
$form = '<div class="mt20"><div class="badg c-red btn-block">' . esc_html__('积分不足,暂时无法兑换', 'aksrt') . '</div></div>';
} else {
$form = '<div class="mt20">'
. '<input type="hidden" name="product_id" value="' . $first_package_id . '">'
@@ -289,14 +293,14 @@ trait VipServiceModal {
* @param array<string,mixed> $level_settings 等级设置(含 name / img_icon)。
*/
private function points_exchange_card_icon(array $level_settings): string {
$name = (string) ($level_settings['name'] ?? '');
$name_text = (string) ($level_settings['name'] ?? '');
$icon_url = (string) ($level_settings['img_icon'] ?? '');
if ($icon_url === '') {
return '<i class="fa fas fa-certificate mr3" aria-hidden="true"></i>';
}
return '<img' . aksrt_get_lazy_attr('lazy_other', $icon_url, 'img-icon mr3')
. ' title="' . esc_attr($name) . '" alt="' . esc_attr($name) . '">';
. ' title="' . esc_attr($name_text) . '" alt="' . esc_attr($name_text) . '">';
}
/**
@@ -327,26 +331,27 @@ trait VipServiceModal {
if (empty($levels[$level]['enabled'])) {
return '';
}
$name = (string) ($levels[$level]['name'] ?? ('VIP' . $level));
$name = wp_kses_post((string) ($levels[$level]['name_html'] ?? $levels[$level]['name'] ?? ('VIP' . $level)));
$name_text = (string) ($levels[$level]['name'] ?? ('VIP' . $level));
$equity = (string) ($levels[$level]['equity'] ?? '');
$cls = $user_id > 0 ? ' pay-vip' : ' signin-loader';
$equity_html = $equity !== ''
? '<ul class="mb10 relative">' . wp_kses_post($equity) . '</ul>'
: '';
$equity_html = $equity !== '' && function_exists('aksrt_format_vip_equity_html')
? aksrt_format_vip_equity_html($equity, 'mb10 relative')
: ($equity !== '' ? '<div class="v-equity mb10 relative">' . wp_kses_post($equity) . '</div>' : '');
$icon_url = esc_url((string) ($levels[$level]['img_icon'] ?? ''));
$icon = $icon_url !== ''
? '<img' . aksrt_get_lazy_attr('lazy_other', $icon_url, 'img-icon') . ' title="' . esc_attr($name) . '" alt="' . esc_attr($name) . '" decoding="async">'
? '<img' . aksrt_get_lazy_attr('lazy_other', $icon_url, 'img-icon') . ' title="' . esc_attr($name_text) . '" alt="' . esc_attr($name_text) . '" decoding="async">'
: '<i class="fa fas fa-gem" aria-hidden="true"></i>';
$html = '<div class="vip-card pointer level-' . $level . ' vip-theme' . $level . $cls . '" vip-level="' . $level . '">';
$html .= '<div class="abs-center vip-baicon" aria-hidden="true">' . $icon . '</div>';
$html .= '<div class="vip-img abs-right">' . $icon . '</div>';
$html .= '<div class="relative">';
$html .= '<div class="vip-name mb10"><span class="mr6">' . $icon . '</span>' . esc_html($name) . '</div>';
$html .= '<div class="vip-name mb10"><span class="mr6">' . $icon . '</span>' . wp_kses_post($name) . '</div>';
$html .= $equity_html;
$html .= '<a class="but jb-blue radius payvip-button" href="javascript:;">'
. esc_html(sprintf(__('开通%s', 'aksrt'), $name))
. wp_kses_post(sprintf(__('开通%s', 'aksrt'), $name))
. '<span class="ml6"><i class="fa fas fa-chevron-circle-right" aria-hidden="true"></i></span></a>';
$html .= '</div></div>';
return $html;
+120 -314
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.
*/
@@ -100,118 +24,59 @@ trait VipServicePackages {
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).
*
* upgrade.product multi-line formats:
* - permanent VIP1: permanent|price|title[|show_price]
* - fixed (alias): price|days|title
* - timed unit: unit|daily_price|title
* - timed jump permanent: jump|price|title[|show_price]
*
* Rules:
* - 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
*
* @return array<int, array<string,mixed>>
*/
private function build_upgrade_items(int $user_id, int $expires, bool $is_permanent, string $raw): array {
$raw = trim($raw);
if ($raw === '') {
return [];
}
* Three blocks (reference theme vip_upgrade_product accordion):
* - month_to_month_s: timed VIP1 → VIP2, priced by remaining days, expiry unchanged
* - jump_s: timed VIP1 → permanent VIP2 at a fixed price
* - permanent_*: permanent VIP1 → permanent VIP2 at a fixed price
*
* package index: 901 permanent, 902 unit, 903 jump
*
* @param array<string,mixed> $config
* @return array<int, array<string,mixed>>
*/
private function build_upgrade_items(int $user_id, int $expires, bool $is_permanent, array $config): array {
$items = [];
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$has_permanent = false;
$has_unit = false;
$has_jump = false;
$now = time();
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
// 月费会员升级月费会员:按剩余天数计价,会员有效期不变(仅提升等级)。
if (!$is_permanent && $expires > $now && !empty($config['month_to_month_s'])) {
$daily_minor = $this->parse_vip_money_minor((string) ($config['unit_price'] ?? ''));
if ($daily_minor > 0) {
$remaining = (int) ceil(max(1, ($expires - $now) / DAY_IN_SECONDS));
if ($daily_minor <= intdiv(PHP_INT_MAX, $remaining)) {
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($remaining * $daily_minor);
$items[] = [
'package_id' => $this->encode_package_id(2, 902),
'level' => 2,
'index' => 902,
'action' => 'upgrade',
'price' => $price,
'base_price' => $price,
'show_price' => '0.00',
'discount_tag' => (string) ($config['unit_tag'] ?? ''),
'tag_class' => (string) ($config['unit_tag_class'] ?? ''),
'days' => $remaining,
'title' => sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining),
'price_label' => $this->format_cash_label($price),
'show_price_label' => '',
'days_label' => sprintf(__('%d 天', 'aksrt'), $remaining),
'cta_label' => __('立即升级', 'aksrt'),
'is_upgrade_product' => true,
'upgrade_mode' => 'unit',
];
}
}
$parts = array_map('trim', explode('|', $line));
$mode = strtolower((string) ($parts[0] ?? ''));
}
// 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;
// 月费会员升级永久会员:固定价格直接转为永久二级会员。
if (!$is_permanent && !empty($config['jump_s'])) {
$price_minor = $this->parse_vip_money_minor((string) ($config['jump_price'] ?? ''));
if ($price_minor > 0) {
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
$title = sanitize_text_field((string) ($parts[2] ?? ''));
if ($title === '') {
$title = sprintf(__('按剩余 %d 天升级', 'aksrt'), $remaining);
}
$items[] = [
'package_id' => $this->encode_package_id(2, 902),
'level' => 2,
'index' => 902,
'action' => 'upgrade',
'price' => $price,
'base_price' => $price,
'show_price' => '0.00',
'discount_tag' => __('剩余天数', 'aksrt'),
'days' => $remaining,
'title' => $title,
'price_label' => $this->format_cash_label($price),
'show_price_label' => '',
'days_label' => sprintf(__('%d 天', 'aksrt'), $remaining),
'cta_label' => __('立即升级', 'aksrt'),
'is_upgrade_product' => true,
'upgrade_mode' => 'unit',
];
$has_unit = true;
continue;
}
// jump|price|title[|show_price] — timed VIP1 only → permanent VIP2.
if ($mode === 'jump') {
if ($is_permanent || $has_jump) {
continue;
}
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
if ($price_minor <= 0) {
continue;
}
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
$title = sanitize_text_field((string) ($parts[2] ?? ''));
if ($title === '') {
$title = __('升级为永久会员', 'aksrt');
}
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
$show_minor = $this->parse_vip_money_minor((string) ($config['jump_show_price'] ?? ''));
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
$items[] = [
'package_id' => $this->encode_package_id(2, 903),
@@ -221,9 +86,10 @@ trait VipServicePackages {
'price' => $price,
'base_price' => $price,
'show_price' => $show,
'discount_tag' => __('永久升级', 'aksrt'),
'discount_tag' => (string) ($config['jump_tag'] ?? ''),
'tag_class' => (string) ($config['jump_tag_class'] ?? ''),
'days' => 0,
'title' => $title,
'title' => __('升级为永久会员', 'aksrt'),
'price_label' => $this->format_cash_label($price),
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
'days_label' => __('永久', 'aksrt'),
@@ -231,25 +97,15 @@ trait VipServicePackages {
'is_upgrade_product' => true,
'upgrade_mode' => 'jump',
];
$has_jump = true;
continue;
}
}
// permanent|price|title[|show_price]
if ($mode === 'permanent') {
if ($has_permanent) {
continue;
}
$price_minor = $this->parse_vip_money_minor($parts[1] ?? '');
if ($price_minor <= 0) {
continue;
}
// 永久会员升级永久会员。
if ($is_permanent) {
$price_minor = $this->parse_vip_money_minor((string) ($config['permanent_price'] ?? ''));
if ($price_minor > 0) {
$price = \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor);
$title = sanitize_text_field((string) ($parts[2] ?? ''));
if ($title === '') {
$title = __('永久会员升级', 'aksrt');
}
$show_minor = $this->parse_vip_money_minor($parts[3] ?? '0');
$show_minor = $this->parse_vip_money_minor((string) ($config['permanent_show_price'] ?? ''));
$show = \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor);
$items[] = [
'package_id' => $this->encode_package_id(2, 901),
@@ -259,9 +115,10 @@ trait VipServicePackages {
'price' => $price,
'base_price' => $price,
'show_price' => $show,
'discount_tag' => __('升级专享', 'aksrt'),
'discount_tag' => (string) ($config['permanent_tag'] ?? ''),
'tag_class' => (string) ($config['permanent_tag_class'] ?? ''),
'days' => 0,
'title' => $title,
'title' => __('永久会员升级', 'aksrt'),
'price_label' => $this->format_cash_label($price),
'show_price_label' => $show_minor > 0 ? $this->format_cash_label($show) : '',
'days_label' => __('永久', 'aksrt'),
@@ -269,49 +126,7 @@ trait VipServicePackages {
'is_upgrade_product' => true,
'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;
@@ -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<int,mixed> $levels
@@ -328,47 +146,49 @@ trait VipServicePackages {
*/
public function get_points_exchange_packages(array $vip, array $levels): array {
$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');
$out = [];
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$i = 0;
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$parts = array_map('trim', explode('|', $line));
if (count($parts) < 3) {
continue;
}
$lv = (int) ($parts[0] ?? 0);
$pts = (int) ($parts[1] ?? 0);
$days = (int) ($parts[2] ?? 0);
$title = sanitize_text_field((string) ($parts[3] ?? ''));
$lv = (int) ($row['level'] ?? 0);
$pts = max(0, (int) ($row['points'] ?? 0));
if ($lv < 1 || $lv > 2 || $pts <= 0) {
continue;
}
if (empty($levels[$lv]['enabled'])) {
continue;
}
$i++;
if ($title === '') {
$lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
$title = $days > 0
? sprintf(__('%1$s · %2$d天', 'aksrt'), $lv_name, $days)
: sprintf(__('%s · 永久', 'aksrt'), $lv_name);
$is_permanent = !empty($row['is_permanent']);
$time = max(0, (int) ($row['time'] ?? 0));
$unit = ((string) ($row['unit'] ?? 'day')) === 'month' ? 'month' : 'day';
$days = $is_permanent ? 0 : max(0, (int) ($row['days'] ?? 0));
if (!$is_permanent && $time <= 0) {
continue;
}
$i++;
$lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
$out[] = [
'package_id' => 5000 + $i,
'level' => $lv,
'index' => $i,
'points' => $pts,
'days' => max(0, $days),
'title' => $title,
'price_label' => $pts . ' ' . $currency,
'days_label' => $days > 0 ? sprintf(__('%d 天', 'aksrt'), $days) : __('永久', 'aksrt'),
'cta_label' => __('立即兑换', 'aksrt'),
'package_id' => 5000 + $i,
'level' => $lv,
'index' => $i,
'points' => $pts,
'days' => $days,
'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,
'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'),
];
}
return $out;
@@ -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.
*
* `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> $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(
string $base_price,
array $pkg,
string $action,
array $renew,
array $upgrade,
@@ -420,6 +217,7 @@ trait VipServicePackages {
bool $is_permanent,
int $days
): ?array {
$base_price = (string) ($pkg['price'] ?? '');
$base_minor = $this->parse_vip_money_minor($base_price);
if ($base_minor <= 0) {
return null;
@@ -434,32 +232,31 @@ trait VipServicePackages {
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($base_minor),
'show_price' => '0.00',
'tag' => '',
'tag_class' => '',
];
}
$type = sanitize_key((string) ($renew['price_type'] ?? 'discount'));
// discount/reduce 的标签由优惠幅度计算得出,若实际没有优惠则属于虚假折扣。
$computed_tag = in_array($type, ['discount', 'reduce'], true);
$price_minor = $base_minor;
$show_minor = $base_minor;
$tag = '';
$tag_class = '';
if ($type === 'discount') {
$discount = (int) ($renew['discount'] ?? 8);
$discount = (float) ($renew['discount'] ?? 8);
if ($discount <= 0) {
$discount = 8;
}
if ($discount <= 10) {
$percent = $discount * 10;
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent);
$tag = sprintf(
/* translators: %s: discount like 8 */
__('%s折续费', 'aksrt'),
(string) $discount
);
} else {
$percent = min(100, $discount);
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $percent);
$tag = sprintf(__('%s%% 续费价', 'aksrt'), (string) (int) $discount);
}
// 折值:8 = 八折(参考主题 slider 0.1–9.9 语义)。
$discount = max(0.1, min(9.9, $discount));
$price_minor = \AKSRT\Ledger\AssetAmount::percentage($base_minor, $discount * 10);
$tag = sprintf(
/* translators: %s: discount like 8 */
__('%s折优惠', 'aksrt'),
rtrim(rtrim(number_format($discount, 1, '.', ''), '0'), '.') ?: '0'
);
} elseif ($type === 'reduce') {
$reduce_minor = $this->parse_vip_money_minor($renew['reduce'] ?? '20');
$price_minor = max(1, $base_minor - $reduce_minor);
@@ -468,6 +265,11 @@ trait VipServicePackages {
__('续费立减%s', 'aksrt'),
$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 {
$show_minor = 0;
$tag = __('续费同价', 'aksrt');
@@ -477,14 +279,18 @@ trait VipServicePackages {
$price_minor = 1;
}
if ($show_minor > 0 && $price_minor >= $show_minor) {
// 参考主题契约:原价仅在低于执行价时展示。
$show_minor = 0;
$tag = __('续费', 'aksrt');
if ($computed_tag) {
$tag = __('续费', 'aksrt');
}
}
return [
'price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($price_minor),
'show_price' => \AKSRT\Ledger\AssetAmount::decimal_from_minor($show_minor > $price_minor ? $show_minor : 0),
'tag' => $tag,
'tag_class' => $tag_class,
];
}
+41 -21
View File
@@ -137,22 +137,32 @@ trait VipServicePurchase {
continue;
}
$name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv));
$name = trim(wp_strip_all_tags((string) ($levels[$lv]['name'] ?? ('VIP' . $lv))));
$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) {
if (!is_array($pkg)) {
continue;
}
$base_price = (string) $pkg['price'];
$days = (int) $pkg['days'];
$action = 'pay';
if ($level > 0 && $lv === $level) {
$action = 'renew';
} elseif ($level > 0 && $lv > $level) {
$action = 'upgrade';
}
$action = $level_action;
$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) {
continue;
}
@@ -167,6 +177,10 @@ trait VipServicePurchase {
if ($discount_tag === '' && $action === 'pay' && !empty($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');
if ((int) $pkg['time'] > 0) {
@@ -184,10 +198,10 @@ trait VipServicePurchase {
'base_price' => $base_price,
'show_price' => $show_price,
'discount_tag' => $discount_tag,
'tag_class' => $tag_class,
'days' => $days,
'time' => (int) ($pkg['time'] ?? 0),
'unit' => (string) ($pkg['unit'] ?? 'day'),
'title' => (string) $pkg['title'],
'price_label' => $this->format_cash_label((string) $priced['price']),
'show_price_label' => $show_minor > 0
? $this->format_cash_label($show_price)
@@ -201,7 +215,7 @@ trait VipServicePurchase {
// Dedicated upgrade product: fixed or remaining-days unit price.
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) {
$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;
$wallet_balance = $user_id > 0 ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : '0.00';
$balances = \AKSRT\Ledger\LedgerService::get_instance()->points_and_balance($user_id);
$methods = $this->available_payment_methods();
$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt');
$catalog = [
'enabled' => !empty($vip['enabled']),
'desc' => (string) ($vip['desc'] ?? ''),
'help_text' => (string) ($vip['help_text'] ?? ''),
'user_level' => $level,
'user_expires' => $expires,
'user_is_permanent' => $is_permanent,
'user_expires_label' => (string) ($member['expires_label'] ?? ''),
'user_points' => $points_balance,
'wallet_balance' => $wallet_balance,
'user_points' => $balances['points'],
'wallet_balance' => $balances['balance'],
'currency_name' => $currency_name,
'renew_enabled' => $renew_enabled,
'upgrade_enabled' => $upgrade_enabled,
@@ -443,11 +455,12 @@ trait VipServicePurchase {
'note' => is_string($note) ? $note : '',
'expires_at' => $expires_at,
'subject' => sprintf(
/* translators: 1: action 2: vip name 3: package title */
__('%1$s%2$s · %3$s', 'aksrt'),
/* translators: 1: action 2: vip name 3: price label 4: duration label */
__('%1$s%2$s · %3$s / %4$s', 'aksrt'),
$action_label,
$level_name,
(string) ($pkg['title'] ?? '')
(string) ($pkg['price_label'] ?? ''),
(string) ($pkg['days_label'] ?? '')
),
];
@@ -759,6 +772,11 @@ trait VipServicePurchase {
$cost = (int) ($match['points'] ?? 0);
$level = (int) ($match['level'] ?? 1);
$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) {
return new \WP_Error('aksrt_invalid_price', __('兑换积分无效', 'aksrt'), ['status' => 400]);
}
@@ -781,6 +799,8 @@ trait VipServicePurchase {
'vip_pay_type' => 'points_exchange',
'level' => $level,
'days' => $days,
'time' => $pkg_time,
'unit' => $pkg_unit,
'points' => $cost,
'package_id' => $package_id,
'package_title' => (string) ($match['title'] ?? ''),
+4 -1
View File
@@ -17,7 +17,10 @@ class DBMigrator {
// 2.5.1: order_aftersale 新增 images 列(售后举证图片)。dbDelta 仅在版本变化时
// 重跑,新增/调整列必须同步升版本,否则老安装不会执行 ALTER TABLE。
// 2.5.2: order_aftersale 新增 apply_form 列(结构化申请表单:预置原因 / 申请资料 / 申请说明)。
const SCHEMA_VERSION = '2.5.2';
// 2.6.0: order_aftersale 新增 handling_log 列(处理记录:每次状态提交一条
// 「状态 + 备注 + 来源 + 时间」),取代单一的 admin_note / reviewed_at / completed_at,
// 并移除从未被写入的 gateway_request_id / gateway_refund_id / gateway_result。
const SCHEMA_VERSION = '2.6.0';
const OPT_KEY = 'aksrt_db_schema_version';
+1 -6
View File
@@ -213,15 +213,10 @@ trait DBMigratorPart2 {
reason TEXT NOT NULL,
apply_form LONGTEXT DEFAULT NULL,
images LONGTEXT DEFAULT NULL,
handling_log LONGTEXT DEFAULT NULL,
refund_amount VARCHAR(32) NOT NULL DEFAULT '0',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
admin_note TEXT DEFAULT NULL,
gateway_request_id VARCHAR(128) DEFAULT NULL,
gateway_refund_id VARCHAR(128) DEFAULT NULL,
gateway_result LONGTEXT DEFAULT NULL,
created_at DATETIME NOT NULL,
reviewed_at DATETIME DEFAULT NULL,
completed_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_order_status (order_number, status),
KEY idx_user_created (user_id, created_at),
+5 -5
View File
@@ -210,16 +210,16 @@ class SEOMetaBox {
true
);
$status = class_exists('\AKSRT\Ai\AI') ? \AKSRT\Ai\AI::get_instance()->get_status() : [];
$seo = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_seo_settings() : [];
$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() : [];
wp_localize_script('aksrt-admin-ai-seo', 'aksrtAiSeo', [
'restUrl' => esc_url_raw(rest_url('aksrt/v1')),
'nonce' => wp_create_nonce('wp_rest'),
'ai' => [
'available' => !empty($status['available']),
'available' => $ai_available,
'seo_ai_enabled' => !empty($seo['seo_ai_enabled']) && !empty($seo['seo_enabled']),
'write_assist' => !empty($status['available']),
'write_assist' => $ai_available,
],
'i18n' => [
'generating' => __('生成中…', 'aksrt'),
@@ -262,7 +262,7 @@ class SEOMetaBox {
$available = class_exists('\AKSRT\Ai\AI') && \AKSRT\Ai\AI::get_instance()->is_available();
$disabled = (!$available || $id <= 0) ? ' disabled' : '';
$title = !$available
? esc_attr__('请先配置 AI 连接(扩展 → AI 连接配置 或 WP Connectors)', 'aksrt')
? esc_attr__('请先配置 WordPress AI Connectors', 'aksrt')
: ($id <= 0 ? esc_attr__('请先保存后再生成', 'aksrt') : '');
return sprintf(
+4 -1
View File
@@ -63,7 +63,10 @@ trait ScriptsPart1b {
};
// 顶部导航用户卡片(legacy header-userbox 模块样式)。
$enqueue_module('header-userbox');
// 仅登录用户渲染用户卡(aksrt-header-userbox.php 的渲染条件),访客页面不加载。
if (is_user_logged_in()) {
$enqueue_module('header-userbox');
}
if (!is_user_logged_in() || (bool) get_query_var('aksrt_sign')) {
$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 {
// 排除管理员、爬虫、AJAX请求
@@ -41,6 +44,12 @@ class StatsTracker {
return;
}
static $scheduled = false;
if ($scheduled) {
return;
}
$scheduled = true;
$today = current_time('Y-m-d');
$option_key = 'aksrt_daily_stats_' . $today;
@@ -48,6 +57,19 @@ class StatsTracker {
$visitor_cookie = 'aksrt_visitor_' . $today;
$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]);
@@ -59,7 +81,6 @@ class StatsTracker {
$stats['pageviews']++;
if ($is_unique) {
$stats['visitors']++;
setcookie($visitor_cookie, '1', time() + DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN);
}
update_option($option_key, $stats, false);
+7 -2
View File
@@ -314,8 +314,13 @@ class Signin {
}
$checkin_all_day = $total ? '<div class="mb6 em09">' . sprintf(__('累计签到%s天', 'aksrt'), '<badge class="c-blue">' . esc_html((string) $total) . '</badge>') . '</div>' : '';
$avatar_img = aksrt_get_avatar_box($user_id, 'avatar-img avatar-mini mr6', false, true);
$html .= '<div class="mb20"><div class="flex ac jsb"><div class="mb10">' . $avatar_img . esc_html($user->display_name) . '</div>' . $checkin_all_day . '</div><div class="flex ab jsb">' . $user_card . '</div></div>';
// 会员图标不再挂头像角标,改到昵称行最前;禁封徽章依旧占据会员图标位。
$avatar_img = aksrt_get_avatar_box($user_id, 'avatar-img avatar-mini mr6', false, false);
$name_badge = function_exists('aksrt_get_user_ban_name_badge') ? aksrt_get_user_ban_name_badge($user_id) : '';
if ($name_badge === '' && function_exists('aksrt_get_vip_icon')) {
$name_badge = aksrt_get_vip_icon($user_id);
}
$html .= '<div class="mb20"><div class="flex ac jsb"><div class="mb10">' . $avatar_img . esc_html($user->display_name) . $name_badge . '</div>' . $checkin_all_day . '</div><div class="flex ab jsb">' . $user_card . '</div></div>';
// 每日签到奖励卡片(参考主题:积分徽章 + 经验值徽章,经验值随等级系统展示)
$reward_badge = $base ? '<badge class=" p2-10 mr6 c-green" data-toggle="tooltip" title="' . esc_attr(sprintf(__('积分奖励%s', 'aksrt'), $base)) . '">' . $points_svg('icon') . '+' . esc_html((string) $base) . '</badge>' : '';
+279 -63
View File
@@ -9,6 +9,14 @@ defined('ABSPATH') || exit;
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;
public static function get_instance(): self {
@@ -17,16 +25,20 @@ class SearchPush {
}
/**
* 绑定运行时钩子:发布/更新主动推送 + 前台自动推送 JS。
* 绑定运行时钩子:发布/更新主动推送 + 分类法保存推送 + 前台自动推送 JS。
*/
public function init(): void {
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);
}
/**
* 文章发布/更新时主动推送链接到百度。
*
* 与参考主题一致:推送结果写入 meta,成功后不再重复提交;
* 编辑页勾选「重新提交」可清空记录并强制再次推送。
*
* @param string $new_status 新状态。
* @param string $old_status 旧状态。
* @param \WP_Post $post 文章对象。
@@ -42,27 +54,230 @@ class SearchPush {
return;
}
// 仅推送公开可索引的内容类型。
$allowed = function_exists('aksrt_searchable_post_types')
? aksrt_searchable_post_types()
: ['post'];
if (!in_array($post->post_type, $allowed, true)) {
if (!in_array($post->post_type, self::pushable_post_types(), true)) {
return;
}
$config = $this->get_config();
if (!$this->should_push($config)) {
return;
}
if ($this->resubmit_requested()) {
self::clear_push_record($post->ID);
} else {
$is_new = $old_status !== 'publish';
if ($is_new && empty($config['baidu_push_on_publish'])) {
return;
}
if (!$is_new && empty($config['baidu_push_on_update'])) {
return;
}
// 已成功提交过的链接不再重复推送。
if ($this->record_is_complete(self::get_push_record($post->ID), $config)) {
return;
}
}
$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;
}
$is_new = $old_status !== 'publish';
if ($is_new && empty($config['baidu_push_on_publish'])) {
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 (!$is_new && empty($config['baidu_push_on_update'])) {
return;
if (!empty($config['baidu_push_fast'])) {
$channel = $result['daily'] ?? null;
$record['daily_push'] = is_array($channel) && !empty($channel['success']);
$record['daily_result'] = $channel;
}
$this->push_url((string) get_permalink($post));
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' => []];
}
$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, [
'timeout' => 10,
@@ -105,7 +363,7 @@ class SearchPush {
]);
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' => []];
}
@@ -113,59 +371,15 @@ class SearchPush {
$status = wp_remote_retrieve_response_code($response);
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];
}
$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 ?? []];
}
/**
* 批量推送 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 代码
*/
@@ -175,7 +389,6 @@ class SearchPush {
return '';
}
$site_url = home_url('/');
return <<<SCRIPT
<script>
(function(){
@@ -211,13 +424,15 @@ SCRIPT;
'provider' => 'baidu',
'configured' => $enabled && $token !== '',
'available' => $enabled && $token !== '',
'verified' => !empty($config['baidu_push_verified']),
// 百度链接提交接口无单独校验握手,Token 配置完成即可推送。
'verified' => false,
'transport' => 'http_api',
'missing_fields' => $missing,
'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_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];
}
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;
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();
}
/**
* 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 {
return AssetAmount::from_minor(AssetType::BALANCE, max(0, $minor))->formatted();
}
+91 -8
View File
@@ -11,6 +11,11 @@ final class AfterSaleService {
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_COMPLETED = 'completed';
/** 处理记录来源:后台提交 / 用户提交(申请与撤销)。 */
public const SOURCE_ADMIN = 'admin';
public const SOURCE_USER = 'user';
/** 处理记录上限:与 MetaAssetService::MAX_RECORDS 同口径,超出后只保留最近的一段。 */
private const MAX_LOG_ENTRIES = 50;
private static ?self $instance = null;
public static function get_instance(): self { return self::$instance ??= new self(); }
@@ -144,6 +149,8 @@ final class AfterSaleService {
try {
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number);
if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('只有已支付订单可以申请售后', 'aksrt'), ['status' => 409]);
// 只挡「仍在流转」的工单。已完结(completed)的工单不挡——能否再售后由
// 「订单是否已退款」决定,而订单已退款时上面的 STATUS_PAID 校验已先挡掉。
$active = $this->find_active($order_number);
if ($active) {
if ((int) ($active['user_id'] ?? 0) === $user_id && hash_equals((string) ($active['reason'] ?? ''), $reason)) {
@@ -153,13 +160,15 @@ final class AfterSaleService {
}
$amount = $this->refund_amount($order);
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
$now = current_time('mysql');
$inserted = $wpdb->insert($this->table(), [
'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund',
'reason' => $reason,
'apply_form' => $apply_form === [] ? null : wp_json_encode($apply_form, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'images' => wp_json_encode($images), 'refund_amount' => $amount,
'status' => self::STATUS_PENDING, 'created_at' => current_time('mysql'),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
'status' => self::STATUS_PENDING, 'created_at' => $now,
'handling_log' => $this->encode_log([$this->log_entry(self::STATUS_PENDING, '', self::SOURCE_USER, $now)]),
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
@@ -212,6 +221,15 @@ final class AfterSaleService {
return $clean;
}
/**
* 退款金额:积分单取整,现金单经 AssetAmount 归一为两位小数;
* 金额无法解析时返回 null,由 create() 拒掉这次申请。
*/
private function refund_amount(array $order): ?string {
if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0));
try { return \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; }
}
/** 最新一条售后工单(列表视图只取最新态,避免整表拉取)。 */
public function latest_for_order(string $order_number): ?array {
global $wpdb;
@@ -229,6 +247,7 @@ final class AfterSaleService {
if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]);
if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]);
if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]);
$this->append_log((int) $row['id'], $row, self::STATUS_CANCELLED, '', self::SOURCE_USER);
return $this->get((int) $row['id']);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
@@ -298,6 +317,10 @@ final class AfterSaleService {
/**
* 管理员直接设置售后工单状态:纯状态更新,不触发订单、资产与支付渠道联动。
*
* 每次提交都往 handling_log 落一条「状态 + 备注 + 来源 + 时间」,备注为空也留痕,
* 后台与前台才能回放整条处理链路。读取-追加-写回需要与 create()/cancel_for_user()
* 同一把订单锁,否则两条并发提交会互相覆盖日志。
*/
public function set_status(int $id, string $status, string $note = '') {
if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
@@ -305,12 +328,48 @@ final class AfterSaleService {
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) return new \WP_Error('aksrt_after_sale_status', __('售后状态无效', 'aksrt'), ['status' => 400]);
$row = $this->get($id);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
$data = ['status' => $status];
if ($note !== '') $data['admin_note'] = $note;
if ($status !== self::STATUS_PENDING) $data['reviewed_at'] = current_time('mysql');
if (!$this->update($id, $data)) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
return $this->get($id);
global $wpdb;
$lock = 'aksrt_after_sale_' . md5((string) ($row['order_number'] ?? ''));
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
try {
// 锁内重读:追加必须基于最新快照,否则两条并发提交仍会互相覆盖日志。
$row = $this->get($id);
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
$note = sanitize_textarea_field($note);
if (!$this->update($id, ['status' => $status])) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
$this->append_log($id, $row, $status, $note, self::SOURCE_ADMIN);
return $this->get($id);
} finally {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
}
}
/**
* 追加一条处理记录。日志按时间正序存放,超出 MAX_LOG_ENTRIES 条时只保留最近的一段。
*
* @param array<string,mixed> $current 追加前的工单行(含已规范化的 handling_log)。
*/
private function append_log(int $id, array $current, string $status, string $note, string $source): void {
$log = is_array($current['handling_log'] ?? null) ? $current['handling_log'] : [];
$log[] = $this->log_entry($status, $note, $source, current_time('mysql'));
if (count($log) > self::MAX_LOG_ENTRIES) {
$log = array_slice($log, -self::MAX_LOG_ENTRIES);
}
$this->update($id, ['handling_log' => $this->encode_log($log)]);
}
/** @return array<string,string> */
private function log_entry(string $status, string $note, string $source, string $created_at): array {
return [
'status' => $status,
'note' => $note,
'source' => $source,
'created_at' => $created_at,
];
}
private function encode_log(array $log): string {
return wp_json_encode($log, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/** 该订单当前仍在处理中的售后工单(待审核/处理中),退款闸门只认它。 */
@@ -345,6 +404,30 @@ final class AfterSaleService {
: [];
$apply_form = json_decode((string) ($row['apply_form'] ?? ''), true);
$row['apply_form'] = is_array($apply_form) ? $apply_form : null;
$row['handling_log'] = $this->normalize_log(json_decode((string) ($row['handling_log'] ?? ''), true));
return $row;
}
/**
* 处理记录规范化:只保留 status 非空的条目,source 只认 user,其余归 admin;
* 非数组(含损坏 JSON)整体视为空,界面对老工单按「暂无处理记录」处理。
*
* @return list<array<string,string>>
*/
private function normalize_log($log): array {
if (!is_array($log)) return [];
$clean = [];
foreach ($log as $entry) {
if (!is_array($entry)) continue;
$status = sanitize_key((string) ($entry['status'] ?? ''));
if ($status === '') continue;
$clean[] = [
'status' => $status,
'note' => (string) ($entry['note'] ?? ''),
'source' => (string) ($entry['source'] ?? '') === self::SOURCE_USER ? self::SOURCE_USER : self::SOURCE_ADMIN,
'created_at' => (string) ($entry['created_at'] ?? ''),
];
}
return $clean;
}
}
+10 -5
View File
@@ -290,9 +290,15 @@ class UserOrders {
// - pending / processing:工单仍在流转(待审核 / 处理中),不允许重复申请;
// - rejected:审核未通过,允许补充资料后再次申请;
// - cancelled:用户自行取消,允许重新发起;
// - completed:退款已完成,工单终结,不允许再次申请。
$reapplyable_statuses = [AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED];
// - completed:工单已完结,但只要订单没被另标「已退款」(enabled 仍为 true),
// 依旧允许再次申请。能否再售后由「订单是否已退款」决定,不由工单状态决定:
// 订单已退款时 $group 变成 refunded、enabled 直接为 false,上面那条自然挡死。
$latest_status = (string) ($latest['status'] ?? '');
$reapplyable_statuses = [
AfterSaleService::STATUS_REJECTED,
AfterSaleService::STATUS_CANCELLED,
AfterSaleService::STATUS_COMPLETED,
];
return [
'enabled' => $enabled,
@@ -308,9 +314,8 @@ class UserOrders {
'images' => array_values((array) ($latest['images'] ?? [])),
'refund_amount' => (string) ($latest['refund_amount'] ?? '0'),
'created_at' => (string) ($latest['created_at'] ?? ''),
'reviewed_at' => (string) ($latest['reviewed_at'] ?? ''),
'completed_at' => (string) ($latest['completed_at'] ?? ''),
'admin_note' => (string) ($latest['admin_note'] ?? ''),
// 处理记录:每次状态提交一条(状态 + 备注 + 来源 + 时间),前台时间线的唯一数据源。
'handling_log' => array_values((array) ($latest['handling_log'] ?? [])),
] : null,
];
}
-4
View File
@@ -24,10 +24,6 @@ trait DisplayPageGroups {
'ai_seo_title_prompt' => trim((string) ($s['ai_seo_title_prompt'] ?? '')),
'ai_seo_keywords_prompt' => trim((string) ($s['ai_seo_keywords_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_default' => esc_url_raw((string) ($s['seo_image_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 {
$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 [
'baidu_push_enabled' => $enabled,
'baidu_push_token' => $token,
'baidu_push_enabled' => !empty($settings['baidu_push_enabled']),
'baidu_push_token' => (string) ($settings['baidu_push_token'] ?? ''),
'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_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_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'];
}
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 {
$allowed = self::homepage_allowed_sorts();
$result = [];
+214 -51
View File
@@ -121,7 +121,9 @@ class PaymentRuntime {
],
'vip' => [
'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。
'only_password' => !empty($settings['pay_vip_pass_charge_only_password']),
],
@@ -168,48 +170,28 @@ class PaymentRuntime {
$vip2_enabled = !empty($settings['pay_user_vip_2_s']);
$enabled = $vip1_enabled || $vip2_enabled;
$name1 = trim((string) ($settings['pay_user_vip_1_name'] ?? ''));
$name2 = trim((string) ($settings['pay_user_vip_2_name'] ?? ''));
$name1_html = trim(wp_kses_post((string) ($settings['pay_user_vip_1_name'] ?? '')));
$name2_html = trim(wp_kses_post((string) ($settings['pay_user_vip_2_name'] ?? '')));
$name1 = sanitize_text_field(wp_strip_all_tags($name1_html));
$name2 = sanitize_text_field(wp_strip_all_tags($name2_html));
if ($name1 === '') {
$name1 = __('一级会员', 'aksrt');
$name1 = __('黄金会员', 'aksrt');
$name1_html = $name1;
}
if ($name2 === '') {
$name2 = __('二级会员', 'aksrt');
$name2 = __('钻石会员', 'aksrt');
$name2_html = $name2;
}
$badge = $vip2_enabled ? $name2 : ($vip1_enabled ? $name1 : __('VIP', 'aksrt'));
$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';
}
$default_l1 = "30|59|1|month|限时|月卡\n98|199|3|month|特惠|季卡\n365|599|12|month|超值|年卡\n999|1299|0|day|永久|永久卡";
$default_l2 = "58|99|1|month|限时|月卡\n168|299|3|month|特惠|季卡\n598|999|12|month|超值|年卡\n1299|1999|0|day|永久|永久卡";
$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 = [
'li' => [],
'ul' => [],
'ol' => [],
'strong' => [],
'b' => [],
'em' => [],
'i' => ['class' => true],
'span' => ['class' => true],
'br' => [],
'a' => ['href' => true, 'target' => true, 'rel' => true, 'class' => true],
];
$desc = wp_kses_post((string) ($settings['pay_user_vip_desc'] ?? __('开通VIP会员,享受会员专属折扣以及多项特权', 'aksrt')));
$more = (string) ($settings['pay_user_vip_more'] ?? '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>');
return [
'enabled' => $enabled,
@@ -218,27 +200,32 @@ class PaymentRuntime {
'name' => $vip1_enabled ? $name1 : $badge,
'badge_text' => $badge,
'desc' => $desc,
'more' => wp_kses($more, $allowed_equity + ['div' => ['class' => true], 'p' => ['class' => true]]),
'help_text' => $desc !== '' ? $desc : __('会员权益以免扣下载和身份展示为主,开通后立即生效。', 'aksrt'),
// 管理员编辑的富文本说明:与保存端同为 post 级 HTML(wp_kses_post),
// 不再用权益列表的窄白名单二次剥标签(h/img/hr/table 等)。
'more' => wp_kses_post($more),
'levels' => [
1 => [
'enabled' => $vip1_enabled,
'name' => $name1,
'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),
'name_html' => $name1_html,
'desc' => wp_kses_post((string) ($settings['pay_user_vip_1_desc'] ?? __('尊享专属特权', 'aksrt'))),
'equity' => wp_kses_post((string) (($settings['pay_user_vip_1_equity'] ?? '') !== '' ? $settings['pay_user_vip_1_equity'] : '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>')),
'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']),
'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))),
],
2 => [
'enabled' => $vip2_enabled,
'name' => $name2,
'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),
'name_html' => $name2_html,
'desc' => wp_kses_post((string) ($settings['pay_user_vip_2_desc'] ?? __('尊享专属特权', 'aksrt'))),
'equity' => wp_kses_post((string) (($settings['pay_user_vip_2_equity'] ?? '') !== '' ? $settings['pay_user_vip_2_equity'] : '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>')),
'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']),
'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))),
],
],
@@ -247,21 +234,16 @@ class PaymentRuntime {
],
'renew' => [
'enabled' => !isset($settings['vip_renew']) || !empty($settings['vip_renew']),
'desc' => sanitize_textarea_field((string) ($settings['vip_renew_desc'] ?? '')),
'desc' => wp_kses_post((string) ($settings['vip_renew_desc'] ?? '立即续费会员,畅享VIP权益')),
'price_type' => $renew_price_type,
'discount' => max(1, min(100, (int) ($settings['vip_renew_discount'] ?? 8))),
'reduce' => self::money_decimal($settings['vip_renew_reduce'] ?? '20.00', '20.00', 999900),
],
'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'] ?? '')),
'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', 50000),
],
'upgrade' => self::normalize_vip_upgrade_settings($settings),
'points_exchange' => [
'enabled' => !empty($settings['pay_vip_points_exchange_s']),
'desc' => sanitize_textarea_field((string) ($settings['pay_vip_points_exchange_desc'] ?? '')),
'packages_raw' => sanitize_textarea_field((string) ($settings['payment_vip_points_packages'] ?? "1|500|30|积分月卡\n1|5000|0|积分永久")),
'desc' => wp_kses_post((string) ($settings['pay_vip_points_exchange_desc'] ?? '')),
'packages' => self::normalize_vip_points_packages($settings['pay_vip_points_exchange_product'] ?? null),
],
];
}
@@ -728,4 +710,185 @@ class PaymentRuntime {
}
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' => wp_kses_post((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;
}
}
+5 -4
View File
@@ -125,7 +125,7 @@ final class UserRenderer {
if ($name === '' || $style_name === '' || $label === '' || $url === '') {
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>';
}
@@ -162,11 +162,12 @@ final class UserRenderer {
'post_style' => 'card',
]);
$avatar = aksrt_get_avatar_box($user_id, 'avatar-img avatar-lg');
// 会员/禁封徽章不再挂头像角标,改由名字行的会员位出图(aksrt_get_user_name)。
$avatar = aksrt_get_avatar_box($user_id, 'avatar-img avatar-lg', true, false);
$name = aksrt_get_user_name([
'id' => $user_id,
'class' => 'flex1 flex ac',
'vip' => false,
'vip' => true,
'medal' => true,
'auth' => true,
'level' => true,
@@ -192,7 +193,7 @@ final class UserRenderer {
]);
}
if ($args['show_checkin'] && $cuid === $user_id) {
$checkin = aksrt_header_user_checkin_btn($user_id, true, 'img-badge jb-yellow');
$checkin = aksrt_header_user_checkin_btn($user_id, 'img-badge jb-yellow');
}
if ($args['show_button']) {
-9
View File
@@ -106,11 +106,6 @@ if (file_exists($js)) {
],
'imageUploadMultiple' => $image_upload_multiple,
'ai' => [
'available' => !empty($ctx['ai_available']),
'summary' => !empty($ctx['ai_available']),
'tags' => !empty($ctx['ai_available']),
],
'editor' => [
'canUploadImage' => !empty($ctx['can_upload_img']),
'canUploadVideo' => !empty($ctx['can_upload_video']),
@@ -142,10 +137,6 @@ if (file_exists($js)) {
'submitted' => __('投稿已提交', 'aksrt'),
'error' => __('操作失败,请重试', 'aksrt'),
'confirmDelete' => __('确定要删除这个草稿吗?', 'aksrt'),
'aiGenerating' => __('生成中…', 'aksrt'),
'aiFailed' => __('AI 生成失败', 'aksrt'),
'needContent' => __('请先填写正文内容', 'aksrt'),
'aiFilled' => __('已填入,可继续编辑', 'aksrt'),
'deleting' => __('正在删除草稿…', 'aksrt'),
'deleted' => __('草稿已删除', '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 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>
<?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; ?>
</div>
-3
View File
@@ -177,8 +177,6 @@ if ($is_guest) {
$submit_label = __('保存', 'aksrt');
}
$ai_available = class_exists('\AKSRT\Ai\AI') && \AKSRT\Ai\AI::get_instance()->is_available();
return [
'front' => $front,
'user_id' => $user_id,
@@ -207,7 +205,6 @@ return [
'vip' => $vip,
'points_enabled' => $points_enabled,
'submit_label' => $submit_label,
'ai_available' => $ai_available,
'can_pay' => $cap('new_post_pay'),
'can_pay_download' => $cap('new_post_pay_download'),
'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\(/);
});
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more