Compare commits

...
402 Commits
Author SHA1 Message Date
kate522andClaude Opus 5 5f31c58df2 fix(front-submit): let drafts save with partial content
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-07 00:58:13 +08:00
kate522andClaude Opus 5 4a119358ca feat(creator-studio): bottom status bar, editor-rendered math/footnote, code language picker, block delete
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-07 00:38:39 +08:00
kate522andClaude Opus 5 6928127bfa fix(creator-studio): render math/footnote shortcodes, inline code blocks, block deletion
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 23:52:39 +08:00
kate522andClaude Opus 5 7885f42cff feat(creator-studio): dialog-driven table/math/footnote, theme quote, code language picker
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 23:23:59 +08:00
kate522andClaude Opus 5 7c33482be7 feat(creator-studio): icon toolbar with block tools, drop author strip
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 22:30:08 +08:00
kate522andClaude Opus 5 7bdf7cf018 feat(creator-studio): centered paper layout and scheduled publishing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 21:33:40 +08:00
kate522andClaude Opus 5 384d2b7aa0 feat(creator-studio): switch writing editor to TinyMCE
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 20:51:18 +08:00
kate522andClaude Opus 5 4868de2cda fix(creator-studio): 加载正文编辑格式工具栏
选中文字时的 RichText 加粗、斜体、链接等工具由 WordPress format-library 注册。
BlockTools 虽然提供 inline toolbar slot,但此前前台没有加载 wp-format-library,
因此正文选区没有格式工具。为 Creator Studio 前台显式加载该 script/style handle,
并将行内格式工具栏统一为深色圆角悬浮样式。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 17:59:17 +08:00
kate522andClaude Opus 5 9e8913ea0a fix(creator-studio): 固定公众号式创作工具栏
- 顶部改为 fixed 创作工具栏:左侧返回/品牌/模式,中间文章标题与保存状态,
  右侧撤销/重做、保存草稿、预览、发布
- 新增工作台级撤销/重做快照栈,连续编辑合并记录;支持 Ctrl/Cmd+Z / Shift+Z,
  不覆盖 RichText 输入框自身的编辑历史
- 移动端收敛工具栏布局,保留撤销/重做、预览、发布
- 先保存草稿再请求审核/直接发布;请求使用服务端更新后的文章与版本 ID
- 固定工具栏以独立 chrome 层布局,正文避开顶部工具栏高度
- 补充固定工具栏、历史操作与键盘快捷键契约

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 17:36:15 +08:00
kate522andClaude Opus 5 f5e9420266 feat(creator-studio): 图片宽度四档与作者行
- 通过 editor.BlockEdit 过滤器给 core/image 增加「半屏 / 中等 / 通栏 / 全宽」
  四档宽度,选中图片时出现在区块工具条;宽度写入 className 并序列化到
  post_content,前台样式即可生效,无需自定义区块或额外元数据
- 前台样式:半屏靠左 50%,中等 72%,通栏跟随正文行宽,全宽突破行宽但受容器
  内边距约束;移动端半屏与全宽回退为整行,避免横向滚动
- 新增作者行(头像 + 昵称 + 身份说明),数据由 CreatorRest::author_payload()
  从服务端给出,前端不自行拼装;无昵称时整行不渲染,头像缺失时回退首字占位
- 作者行放在摘要之后、正文之前

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 16:05:19 +08:00
kate522andClaude Opus 5 79909f449e feat(creator-studio): 公众号式插入面板与封面条
- 新增常驻「添加内容」按钮与插入面板:分组网格列出正文/标题/列表/引用、
  图片/视频/嵌入、代码/分隔线/表格/付费内容;条目不满足能力时不出现在面板里
- 面板支持方向键在网格内移动焦点、Esc 关闭;桌面居中浮层,移动端贴底抽屉并
  留出安全区
- 空文档插入区块时替换初始空白段落,不留空行
- 新增封面条:无封面时给低调入口,有封面时显示 2.35:1 缩略图,悬停出现
  「更换封面」,右上角可移除
- 补充契约测试:头部/页脚/挂件区开关、插入面板真实区块名与能力过滤、
  中文字数口径

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 15:57:44 +08:00
kate522andClaude Opus 5 d07cffe99e fix(creator-studio): 沉浸式布局与公众号式写作体验
首轮实机体验暴露的问题:

- 投稿页仍在渲染主题头部的 all_top_fluid 挂件区、页脚与浮动入口,
  与工作台顶栏重复。header.php / footer.php 新增 jio_render_header、
  jio_render_footer、jio_render_fluid_widgets 过滤器,工作台模式下全部关闭
- 区块 Inspector 被写在 BlockList 之后,作为正文流的一部分渲染出
  一个巨大的「未选择区块」白框;移除内联 Inspector(区块设置改由
  行内工具栏的区块菜单提供)
- 中文正文被 wordcount 的 'words' 口径算成「字数:1」;改用
  characters_excluding_spaces,与前台 TinyMCE 字数一致
- 行宽 820px、标题 34px 偏松散;收窄到 720px / 27px,正文 16px/1.85,
  段落间距与选区描边收敛,行内工具栏改为深色圆角悬浮条

新增本地视觉验收脚本(不参与运行时):
- scripts/preview.cjs:用模拟 bootstrap 生成离线预览页
- scripts/shoot.cjs:桌面/移动端截图与控制台错误采集

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 15:49:57 +08:00
kate522andClaude Opus 5 2c7262770b fix(creator-studio): 修复白屏与前台 REST 鉴权
白屏根因:CreatorRest / SubmissionVersion 位于 Jio\Core 命名空间,
但调用 Jio\Content\PrivateAssets 时缺少 use 导入,PHP 就近解析为不存在的
Jio\Core\PrivateAssets,在 wp_head 中抛致命错误导致页面输出截断。

- 为 CreatorRest、SubmissionVersion 补上 use Jio\Content\PrivateAssets
- 新增前台 api-fetch nonce 中间件:WordPress 只在后台注入 wpApiSettings,
  前台工作台里 Gutenberg core-data 请求(/wp/v2/users/me)因此全部 403
- 提交前先落一次草稿,避免自动保存未触发时发布到旧内容
- 新增 tools/check-class-refs.php:静态检测命名空间内未导入的裸类引用
- 新增 assets/creator-studio/scripts/smoke.cjs:浏览器冒烟脚本,
  抓控制台错误、失败请求与未捕获的 Promise 拒绝

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 15:39:10 +08:00
kate522andClaude Opus 5 fac4a714e0 feat(creator-studio): 前台 Gutenberg 创作工作台与受保护付费附件
前台投稿页改为沉浸式创作工作台:Gutenberg 负责区块解析与序列化,
React + TypeScript 负责工作台外壳与发布抽屉,Jio 服务端继续作为权限、
审核与付费的唯一权威。

- 新增 assets/creator-studio 独立构建(wp-scripts,锁定 WP 6.4 时代包),
  仅在投稿模板加载,构建缺失或游客访问时回退原有 TinyMCE 表单
- 自动保存(4s 防抖 + rev 冲突 409)、本地快照恢复、发布配置抽屉、
  移动端全屏单列与安全区适配
- 图片插入走现有 front-submit 上传管线,保留能力校验与待认领生命周期
- 已发布文章的改稿写入私有版本,审核通过前线上内容/链接/付费条款不变;
  原生后台「改稿审核」一次性应用或退回,并校验源文章并发指纹
- 私有附件新表 jio_private_asset + jio_dl_token.asset_id,schema 升到 3.4.0;
  文件存放 ABSPATH 之外,未配置私有存储时 fail closed
- 下载令牌绑定文章/附件/用户/IP/UA/次数,每次交付复核 Visibility 的
  订单、VIP 与配额判定;中断传输归还次数
- 付费字段改为写入前校验,非法价格不再留下半保存文章
- 新增 creator-studio 契约测试;更新受影响的 schema/下载/投稿契约

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 14:53:23 +08:00
kate522andClaude Opus 5 7a8279f8e7 fix(layout): pass term object to archive settings
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 00:00:17 +08:00
kate522andClaude Opus 5 fbb53c6890 feat(categories): align archive and directory pages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 23:51:33 +08:00
kate522andClaude Opus 5 7faa2d53d8 fix(admin): improve native link manager filters
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 22:19:50 +08:00
kate522andClaude Opus 5 b124795d84 fix(links): use plus icon for logo upload placeholder
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 21:41:09 +08:00
kate522andClaude Opus 5 f53cdd4dc8 feat(links): align the friends page layout and submit UI
- assemble the friends template with page header, search cover, content position, widget zones and comments
- render the three page title styles and the tabbed search cover
- restore card, category nav and submit modal markup for the friends page
- add page content style support to the page editor
- port the linked card, category nav and submit modal styles to the mobile and desktop breakpoints

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 21:09:40 +08:00
kate522andClaude Opus 5 4c562428b4 feat(links): move friend links to native WordPress links with review flow
- enable the native Links manager and drop the custom friend-link option/REST path
- add per-page link settings for the friends template
- render link categories from get_bookmarks() with search and page-content options
- add a validated public link submission endpoint with logo upload, captcha, rate limiting and duplicate checks
- create pending links for review and notify administrators by email and in-app message
- add native Links admin pending count, columns and edit notes
- cover the new submission and cleanup contracts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 19:54:33 +08:00
kate522andClaude Opus 5 ef7b5939ba chore(assets): refresh the site logo
Same 2172x724 artwork, re-exported at 29,609 bytes (down from 41,469).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 18:30:11 +08:00
kate522andClaude Opus 5 6bcfbfb252 feat(branding): default both site logos to assets/img/logo.png
- Point 站点 Logo / 暗色模式 Logo schema defaults (PHP + VUE) and the
  Settings::logo_url() fallback at assets/img/logo.png.
- Point the footer night image default at the same file.
- Remove logo_dark.png and the now-unreferenced assets/icons/default-logo.svg
  along with the private default_asset_url() helper that was its only reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 18:02:51 +08:00
kate522andClaude Opus 5 1dfcc38a83 chore(assets): ship the new site logo
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 17:59:19 +08:00
kate522andClaude Opus 5 8e74b60570 fix(navigation): let CSS size the nav logo and drop the inline width cap
The navbar logo carried an inline `height:36px;width:auto;max-width:{logo_width}px`
while `.navbar-logo > img` already owns `height:36px;padding:0 30px`. Under
`box-sizing:border-box` that max-width capped the border box, so the 60px of
horizontal padding stayed inside it and wide logos were squeezed against a
fixed height, breaking the intrinsic aspect ratio.

- Render the nav logo with no inline sizing; CSS alone sizes it.
- Add a `$lazy` argument to jio_get_adaptive_theme_img() so the navbar can emit
  the real src up front instead of a square lazyload placeholder; defaults to
  'auto' so every other caller keeps the lazy_other behaviour.
- Remove the superseded logo_width plumbing: the setting (PHP + VUE schemas),
  Settings::logo_width(), jio_get_site_logo_width(), the --jio-logo-width token
  and the unused has_logo context key.
- Cover the contract in the header reference test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 17:50:56 +08:00
kate522andClaude Opus 5 3ce5bfc20c feat(branding): default the site icons to assets/img/favicon.png
- Point the 浏览器图标 / 移动端图标 schema defaults (PHP + VUE) at
  assets/img/favicon.png instead of an empty media field.
- Fall back to the same PNG in Settings::favicon_url() when no custom
  favicon and no native site_icon are configured.
- Add Settings::mobile_icon_url() and emit apple-touch-icon from SEOHead;
  the 移动端图标 field previously had no consumer and never reached the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 17:00:50 +08:00
kate522andClaude Opus 5 b56b9bbe37 fix(navigation): restore the reference 8px radius and unclip the mega menu
Traced against the running site (docker theme at AKSRT-theme) with real
computed styles:

- theme_main_radius defaulted to 12, so --main-radius was 12px and every
  dropdown panel, card and button was rounder than the reference, which
  sets 8px. Default is now 8 in the runtime reader and both schemas.
- The public bundle loads after main.min.css. Its bare img rule replaced
  the inline image baseline the menu relies on, and html{overflow-x} made
  the root a clipping context that crops the fixed senior mega menu.
  Reset is now scoped to content; the horizontal-overflow guard is on body.
- The senior mega menu renders a .container inside the dropdown where the
  reference keeps Bootstrap's 15px gutter; the padding-free site-shell
  .container rule stripped it, running the card/link grid to the edges.

Rebuilt public.css / public.min.css and the admin SPA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 12:33:10 +08:00
kate522andClaude Opus 5 ed38dec314 fix(navigation): stop the public reset from overriding the reference menu styles
The public bundle loads after main.min.css, and its bare-element rules
won on equal specificity:

- img { display: block; height: auto } replaced the inline image baseline
  the menu relies on (Bootstrap img{vertical-align:middle} + main.css
  img{max-height:100%}). Menu glyphs and graphic-card thumbnails were
  pulled out of the inline flow, breaking row height and baseline.
  Scoped the reset to content and restored the inline baseline inside
  .header / .mobile-header.
- html { overflow-x: clip/hidden } made the root a clipping context,
  which crops the position:fixed senior mega menu and its drop panels.
  The horizontal-overflow guard now lives on body only.

Rebuilt public.css / public.min.css.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 12:04:31 +08:00
kate522andClaude Opus 5 3595935475 refactor(moderation): remove the Akismet backend and its admin fields
The comment moderation pipeline no longer calls Akismet. Removed:
- check_akismet() and the Akismet step in Moderation::process().
- akismet_* config keys from Moderation defaults and ExtensionRuntime.
- akismet decision state, the jio_moderation_akismet / akismet_result
  comment meta writes, and the legacy-meta fallbacks in the comments column.
- The Akismet fieldset in both the PHP and Vue permissions schemas.

Rate limit, GeoIP, blocked keywords and AI moderation are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 11:27:24 +08:00
kate522andClaude Opus 5 e6d9ff49a2 fix(navigation): align mega-menu rendering and menu-item backend with the reference
Renderer:
- Graphic-card images carry fit-cover so they crop into the fixed card box
  instead of stretching it and pushing the title out of alignment.
- Mega-menu link icons use fa-fw (fixed glyph column) instead of the
  undefined menu-item-icon class, so link titles line up.

Backend:
- Restore show_policy (login / vip / vip_2 / auth) on menu items; the field
  was missing entirely, so items could not be gated by identity.
- Default submenu_type to graphic_card, badge_class to jb-red, arrangement
  to swiper, size to md.
- Clamp img_scale to 20-300 to match the reference's spinner bounds.
- Drop the now-meaningless "none" submenu_type option: the senior-submenu
  switch is itself the on/off control.
- Admin JS gains the align control (left/center/space-around).

Cleanup:
- Remove the unused header_actions nav menu location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 10:51:36 +08:00
kate522 55423403a6 fix qcode default 2026-11-04 21:27:05 +08:00
kate522 8c4aabb9a4 fix(withdrawal): wire the channel hint to the actual withdrawal modal
The previous commit put the channel-switching code in the wrong init block.
credits.js initializes two modals side by side — the points recharge dialog and
the withdrawal dialog — and the snippet landed in the recharge one, which has
no [data-jio-withdraw-method] element at all. Both symptoms followed from that:
methodField resolved to null so no change listener was ever bound, and the
initial sync never ran, leaving the note paragraph permanently hidden.

The block now lives inside initWithdraw(), and the stray copy is gone. The
behaviour itself was already correct — switching channels swaps the account
placeholder and shows or hides the channel note — it just never executed.

Verified in a headless browser against the real markup and stylesheet: the
placeholder starts as the first channel's hint and the note is hidden; switching
to the second channel swaps the placeholder and reveals the note as
display:block; switching back hides it again as display:none.

The contract test previously only checked that the code existed, which is why
it missed a placement bug that made the whole block dead. It now asserts
syncChannelHint appears exactly once, sits after initWithdraw(), and does not
run past the next function definition. 873 tests pass.
2026-11-04 19:37:56 +08:00
kate522 dcd306b428 feat(withdrawal): make payout channels a configurable sortable list
The payout channel was a two-item checkbox (alipay / wechat). Sites that pay
out through a bank transfer, USDT or any other route had no way to express it,
and every surface behind it — the withdrawal modal, the server-side check, the
admin review list — hard-coded those two names.

affiliate_payout_channels is now a repeater on both the PHP and Vue schemas,
using the same draggable component as the post filters and gateway config. Each
row carries a stable key, a display label, an account placeholder, an optional
note and an enabled flag. Disabled channels disappear from the front end but
stay resolvable, so historical withdrawal records still render their original
name.

PaymentRuntime::payout_channels() normalises the stored structure: it drops rows
with an empty key or label, de-duplicates keys, and falls back to the built-in
two channels when nothing is configured. enabled_payout_channels() feeds the
front end and payout_channel() resolves a single key for record display.
CommissionLedger's validation now compares against the configured, enabled keys
instead of a literal allowlist.

The withdrawal modal renders the configured list and swaps the account
placeholder and note when the channel changes; credits.js drives that. The
admin review page reads the same settings for its method labels and filter, and
the review dialog gained a "渠道说明" row so the operator sees the payout
instruction, hidden when empty.

Default channels live in SchemaPayment as plain data — the runtime imports them
rather than the reverse, because the schema layer must not depend on a runtime
settings class (doing it the other way made the schema harness fatal).

Verification: drove payout_channels() through default / custom / dirty / duplicate
inputs and confirmed enabled filtering, note lookup and disabled-channel
resolution. Added payment-withdrawal-channels-contract.test.cjs covering the
schema shape, runtime helpers, front-end wiring and admin read path. 873 tests
pass, PSR-4 audit clean, admin SPA builds.
2026-11-04 19:16:39 +08:00
kate522 bcba0f598a fix(admin): stop the browser autofilling credentials into the settings search box
A settings screen renders twenty-odd AppSecret and private-key fields as
type="password". Chrome reads any page carrying a password input as a login
form and fills the saved username and password into the first text box it
finds — the settings search field at the top of the panel.

Suppressing autocomplete on that one input could not fix it, because the
trigger is the password inputs elsewhere on the page. Secret fields now render
as text with CSS masking (-webkit-text-security: disc), so they still look
like ciphertext but no longer register as password inputs; the mask lifts on
focus so a freshly typed value can be checked. That covers the 21 schema-driven
fields through JioField plus the four captcha provider forms and the WeChat
template page.

The search box keeps a second layer of defence: a descriptive name and
autocomplete/autocorrect/spellcheck disabled, with the LastPass and 1Password
ignore markers set.

Verification: vite build succeeds and the emitted bundles no longer contain a
single type="password"; 980 admin/widget tests pass.

Note: assets/admin/build/ is gitignored, so the production build must be run on
deploy. -webkit-text-security is WebKit/Blink only — Firefox falls back to
plain text.
2026-11-04 18:53:44 +08:00
kate522 b1c3d86e44 refactor(oauth): drop the hand-written providers and keep only what the SDK ships
The previous commit added five direct clients (Douyin, Microsoft, Facebook,
Twitter, Apple) written against each platform's own endpoints. Maintaining
five bespoke OAuth implementations — including Apple's ES256 client secret and
Twitter's PKCE — is maintenance the bundled yurunsoft SDK does not ask for,
and none of them could be verified without production credentials.

Only providers yurunsoft/yurun-oauth-login actually implements stay on the
direct surface: GitHub, Gitee, QQ, Google, Weibo, Weixin, Baidu and Alipay.
Baidu and Alipay move into OAuthProviderBroker's existing client() switch and
normalize() map rather than living in a separate class, so there is no new
layer to reason about.

OAuthProviderClient, its harness and its contract test are deleted outright
(-809 lines). The Apple form_post handling added to handle_callback() is
reverted too — without Apple there is no POST callback to read.

The aggregate-login channel keeps its 16 platforms, matching the reference;
that list describes what the aggregation provider offers, not a local
implementation. Brand icons for those names stay for the same reason.

Verification: 869 widget/navigation tests pass, the PSR-4 audit reports 0
errors, and the catalog constant, PHP schema and Vue schema now list the same
16 aggregate platforms.
2026-11-04 18:33:37 +08:00
kate522 6db34646ed feat(oauth): add the seven missing providers and open the full aggregate list
The theme shipped six direct providers while the reference carries fourteen,
and its aggregate-login channel offered three of sixteen platforms.

OAuthProviderClient now owns the providers the bundled SDK does not cover.
Douyin, Microsoft, Facebook, Twitter and Apple talk to their documented OAuth2
endpoints directly; Baidu and Alipay reuse yurunsoft, which already ships both
(Alipay needs its application private key, so provider_config learned an
extra_keys map to surface it). The broker dispatches through handles(), so the
existing start/callback routes stay provider-agnostic.

Three provider quirks needed handling rather than a plain copy:

Apple returns the callback as response_mode=form_post, and handle_callback
only read $_GET, so it now reads $_POST when the request is a POST — otherwise
every Apple login would fail with code_missing. Its client secret is built as
an ES256 JWT from Team ID / Key ID / .p8 rather than a static string.

Twitter requires PKCE. The verifier is derived from the state token, so it
travels with the existing transaction row and needs no extra storage.

Facebook hands back protocol-less avatar URLs, which are upgraded to https the
same way the OAuth avatar resolver does.

Settings gained matching toggles and credential groups on both sides — the PHP
schema and the Vue schema — and the aggregate platform list went from three to
sixteen, with CLoginClient's hard-coded allowlist replaced by the catalog
constant. Brand icons were extended so each new provider resolves to its
sprite; facebook and twitter moved to their colour sprites, and the
`twitter -> x` alias was dropped since it bypassed the new mapping.

The oauth-security contract previously asserted that Baidu and Alipay must not
appear on the OAuth surface. That was the old intent, so the assertion is
replaced with a positive one, keeping the separate check that the Alipay
payment gateway still exists.

Verification: a new harness stubs the HTTP layer and drives all five custom
clients — authorization URLs carry redirect/state (Twitter also a PKCE
challenge), identities normalize to the shared shape, Facebook's avatar is
upgraded to https, Apple's private-relay email survives, and an error payload
throws instead of yielding an empty identity. 871 widget/navigation tests pass
and the PSR-4 audit reports 0 errors.

Not covered: no live handshake happened — every provider needs real AppID and
secret pairs, and Apple/Douyin require developer-account review before the
full round trip can be exercised.
2026-11-04 18:00:29 +08:00
kate522 6f29e3d865 fix(oauth): actually use the third-party avatar and seed it only once
The OAuth flow wrote the provider avatar to `jio_oauth_avatar`, but nothing
ever read it — every user who signed up through GitHub/QQ saw a Gravatar hash
or the default avatar instead of their own picture. The reference seeds its
`custom_avatar` from the OAuth profile and its resolver reads it back, so both
halves were missing here.

jio_get_custom_avatar_url() now falls back to the stored OAuth avatar when no
uploaded one exists, which puts the chain at uploaded → third-party → external
service → default. The theme already swaps WordPress's get_avatar_data, so one
change covers every avatar surface.

Writes go through a single seed_avatar() guard: it no-ops when an uploaded
avatar or an existing OAuth avatar is present, matching the reference's
`if ($args['avatar'] && !$custom_avatar)`. Previously both the bind path and
the login refresh overwrote the meta unconditionally, which would have wiped a
user's own picture on their next social login.

Because the OAuth avatar now shows through once the upload is gone, both
"restore default" flows (the REST endpoint and the admin profile box) delete
it too; the admin checkbox also appears when only an OAuth avatar is set,
since $avatar_url is now non-empty in that case.

Baidu serves avatars from tb.himg.baidu.com, which 403s — rewritten to
himg.bdimg.com the way the reference does. On top of that the URL is upgraded
to https when the site runs over SSL, because the reference's scheme-relative
form still yields a plain http:// when copied verbatim, and browsers block it
as mixed content.

WeChatShare was reading `custom_avatar` — a reference-theme key this codebase
never writes — so its author branch always fell through to get_avatar_url();
it now uses the shared resolver like every other surface.

Verification: drove the resolver through five states with a stub (none /
OAuth only / both / upload only / Baidu legacy host) and confirmed the
expected URL each time. Added oauth-avatar-contract.test.cjs to pin the
resolution order, the seed guard, and the reset cleanup. 868 widget and
navigation tests pass; the PSR-4 path audit reports 0 errors.
2026-11-04 17:41:48 +08:00
kate522 7d78a33a9d fix(user-center): match the reference meta pills on the profile header card
The username and e-mail entries under the profile header rendered as plain
grey text on a solid icon. The reference makes each one a bordered pill that
copies its own value on click, drawn with the outline glyphs.

Both entries now carry `.but` plus `data-clipboard-tag` / `data-clipboard-text`
so the existing frontend clipboard runtime (assets/js/runtime/libs/clipboard.js)
picks them up and toasts "用户名已复制". Icons move from `fas` to `far`, which
is the FA5 equivalent of the reference's `fa-user-o` / `fa-envelope-o`.

Geometry follows `.author-header .user-identity > .but/.badg` exactly:
padding 2px 6px, margin 5px 6px 0 0, 12px type, 1.4 line height, and the meta
row gets the reference's 520px max width. medal.css already carried its own
approximation of this pill (2px 8px 2px 0, .3em .6em, 1.44) for the medal
entry; it now uses the same numbers so the two entries line up. The base rule
lives in layout.css rather than medal.css because the medal module is only
enqueued when badges are enabled, and the header must keep its shape either
way.

Verification: rendered the real header through a PHP stub and measured the
computed styles in the browser — padding 2px 6px, margin 5px 6px 0 0, 12px /
16.8px, 23px tall, both entries carrying the right clipboard payload. Added
user-center-header-meta-contract.test.cjs to pin the markup and geometry;
864 widget/navigation tests pass.
2026-11-04 17:27:51 +08:00
kate522 6d01e7489a fix(user-center): use the reference trend sprite for experience task rows
Each experience task row closed with a solid `fa-bolt` glyph. The reference
marks the same slot with the colour `trend-color` sprite followed by "+ N",
and uses that sprite for the 成长体系 heading as well, so the level page also
carried a stray `fa-chart-line` there.

The sprite already ships in the theme (`jiosvg-trend-color`); its path data is
byte-identical to the reference sheet. Both slots now render it, sized to
match the reference (`icon mr6 em09` for the rows, `icon mr6 em12` for the
heading). The "动态" rows keep their wording without a "+", which is how they
read before.

The check-in experience badges in src/Credits/Signin.php stay on
`fa-line-chart` — the reference uses the same monochrome glyph there.

Verification: rendered the real level page through a PHP stub — all ten task
rows emit `#icon-trend-color`, the only Font Awesome glyph left is the
"每日详情" arrow, matching the reference. Screenshotted against the real
stylesheet. A contract test pins both sprite usages and rejects a regression
to `fa-bolt` / `fa-chart-line`; 862 widget/navigation tests pass.
2026-11-04 17:17:49 +08:00
kate522 09b1e535e4 fix(user-center): use the reference colour sprites on the balance and points cards
Both asset cards opened with the same solid `fa-money-bill` glyph, so the
wallet and the points card showed an identical icon and neither matched the
reference, which pairs a blue wallet mark with the balance card and an orange
coin mark with the points card.

The theme already ships both sprites — the header wallet popover and the user
center sidebar resolve to `jiosvg-money-color-2` and `jiosvg-points-color`
through the same helper. Only these two cards were still on Font Awesome.
Swapping them keeps one icon family across the user center; the existing
`em12 mr6` sizing carries over unchanged because `svg` is 1em tall.

The wallet/points icon contract test now pins both keys and fails if either
card falls back to `fa-money-bill` or `fa-coins`.

Verification: rendered both cards through a PHP stub and screenshotted them
against the real theme stylesheet — the blue wallet and orange coin render at
the same optical size as the labels. 861 widget/navigation tests pass.
2026-11-04 17:08:09 +08:00
kate522 00ae62f62b fix(auth): close the modal regressions and keep the header component off /sign/
Three defects slipped into the sign-card rebuild:

The hidden-glyph rule was dropped. `.fa` sets display:inline-block, which
outranks the UA [hidden] rule, so every hidden glyph stayed visible — most
visibly a spinner rendered beside the login button at rest. `.sign [hidden]`
restores the override.

Closing account login produced an empty shell. The whole sign-in pane was
gated on `!$close_signin`, and the social block carried the same gate, so a
site with account login off but registration on still opened on an empty
panel with no social entry. The pane is now rendered in three shapes: open
registration falls through to the signup tab, a QR entry falls through to
the QR tab, social-only renders a notice plus the social bar, and when
nothing is available it states that login is closed.

The modal close button sat in normal flow and pushed the card content down;
`#u_sign .sign > .close` pins it back to the top-right corner.

The /sign/ route is `index.php?jio_sign=1`, so WordPress reports it as home.
The header multifunction component resolves page type by query flags and the
home branch won, so the component appeared on the auth page whenever "首页"
was in its scope — even though the sign page template itself was already
excluded. `jio_header_slide_current_page_type()` now bails out on `jio_sign`
before any of the is_* checks.

The breakpoint overrides that were fighting the shared sheet between 641px
and 991px are narrowed to the one offset that actually needed adjusting.

Verification: the modal was rendered through a PHP stub, served with the real
theme stylesheets, and driven by the real auth.js in a headless browser —
tab switching, signin-mode switching, password reveal, close button and
geometry at 1440/1100/900/760/500/390px. The header harness gained a
`jio_sign` case asserting no markup, and 860 widget/navigation tests pass.
2026-11-04 16:51:41 +08:00
kate522 41366e7fc4 feat(auth): rebuild the sign-in surfaces on the shared sign-card contract
The login/register page and the auth modal had drifted into a parallel
system: floating-input fields, a sign-page-card shell and a bespoke
jio-auth-modal wrapper that none of the theme's own sign CSS targeted.
The reference card rules (.sign, .sign-img, .sign-content, .line-form-input,
.scale-placeholder, .social_loginbar, .title-h-left, #u_sign .modal-dialog,
.qrcode-signin-container) have been in main.css all along, so the fix is to
render the reference DOM instead of re-styling a second one.

auth-content.php now emits one .sign.jio-panel.blur-bg.relative card with a
.tab-content of #tab-sign-in / #tab-sign-up / #tab-resetpassword /
#tab-qrcode-signin / #tab-bind panes. Each pane opens with a
.title-h-left.fa-2x heading plus a .muted-color.px12 link, fields are
.relative.line-form.mb10 > .line-form-input + .scale-placeholder +
.line-form-line, the verification trigger is the .yztx.abs-right captchsubmit
button, and submits are .but.radius.jb-blue|jb-green.padding-lg.btn-block.
Social entries come from the shared render_social_buttons() separator +
.social_loginbar, and the OAuth bind pane uses the oauth-data-box avatar card.

The modal shell is #u_sign.modal > .modal-dialog > .sign-content >
.sign-img.absolute.hide-sm + .sign, and the page shell is the reference
.container.sign-page > .row.sign-row.gutters-5 > .col-md-6(.col-md-offset-6/
.col-md-offset-3).sign-col with a .fixed.lazyload background and a blurred
fixed footer. auth.js drives .in instead of .is-open, toggles the new
password/signin-mode/invite-gate controls and no longer touches the retired
markup. The page also drops two footer-hook removals that targeted functions
which no longer exist, and opts out of the fixed-nav offset.

The sign page's custom theme setting now actually applies: Router answers
jio_forced_theme_mode, jioData.themeForced wins over the stored cookie in
theme.js, theme-toggle positions are suppressed, and the page marks html/body
dark or white accordingly.

sign.css shrinks from ~114 lines of bespoke component styling to a thin
positioning layer, and the retired classes are gone rather than kept.

Verification: php -l on every changed PHP file, node --check on both JS files,
node --test across widget-system, navigation and front-submit (873 passing),
and tools/audit-psr4-paths.php / audit-admin-contracts.php both at 0 errors.
assets/js/runtime/main.min.js was rebuilt because theme.js gained the forced
theme branch.
2026-11-04 16:04:24 +08:00
kate522andClaude Fable 5 d40906b4a3 feat(single): align author bar follow and reward actions with the site flows
- guests get the sign-in trigger instead of a redirect to the login page,
  for both follow and wallet reward controls
- follow requests swap the label for an inline loader and block a second
  submit; the followed state uses the .actived hook
- follower counts sync through data-follow-count on the user homepage
  (the previous selector matched nothing) and only for the viewed profile
- reward button uses the secondary action colour; the reward template is
  only rendered for signed-in visitors
- drop the unused author-hero aria-disabled rule and stale wording in the
  touched comments

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-04 13:34:34 +08:00
kate522 8ad5ef9aff fix(single): align article code blocks with the reference theme
The article body ran two highlighting pipelines at once: the runtime
EnlighterJS pass (same one the comment area uses) plus a bespoke
precode.js enhancer, and a hand-rolled .code-block skin. The two
disagreed, so rendered blocks carried a dark #282c34 card with 15px
padding, 14px text, a border and a second copy button stacked on top of
the EnlighterJS toolbar.

Drop the parallel path and let one pipeline own code blocks:
- delete precode.js, single-post/code-blocks.css and the unused
  assets/js/enlighter copy that only precode.js referenced
- drop the now-dead has_code requirement (PHP scan + feature enqueue)
- render pre exactly as the reference does: bootstrap's pre box
  (9.5px padding / 13px / radius 4) plus main.css's muted background,
  with the EnlighterJS theme supplying the skin

Also scope the inline-code skin to :not(pre code) so it stops painting
the block background pink, and remap the toolbar copy glyph: \f0ea is
the clipboard in FA4 but "paste" in FA5; the clipboard is \f328.

Verified against the reference at 1440px both themes: pre 13px/18.57,
9.5px padding, radius 4, muted background; EnlighterJS block heights
151.31 / 60.38 match exactly.
2026-11-03 23:31:28 +08:00
kate522andClaude Opus 5 ba32e0ef9c fix(nav): match the theme toggle icon size and baseline to its neighbours
Measured in the live header: the toggle's glyph was 15.25px tall centred at
y=31.5, while the account and search icons are 14px tall centred at y=34.09.
Two causes stack up:

- `.toggle-radius` is a fixed 29x29 circle with no font-size, so the sun/moon
  pseudo-elements inherit `.navbar-top`'s 15px. The sibling `.jio-nav-icon`
  buttons pin 14px, so the toggle glyph renders a size larger.
- The toggle sits alone in a 29px `.navbar-form`, while the account cluster is
  34px (6px padding + 20px line box + 6px). Both clusters are top-aligned, so
  their centres differ by 2.5px.

Pin the toggle's glyph to 14px and give its cluster the same 34px flex line as
its neighbours. Scoped to `.navbar-top`, so the mobile drawer and the floating
rail (which also carry .toggle-theme) keep their own geometry.

Verified via headless Edge + CDP on https://localhost: glyph now 14px, centre
delta 0.09px (was 2.5px); pixel measurement shows the sun at 14.0px against
14.0px for the search icon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 21:36:56 +08:00
kate522andClaude Opus 5 02246c7ec8 fix(nav): render the theme toggle with the outline sun/moon face
The top-bar theme switch looked heavier and larger than the icons beside it.
An earlier commit dropped the `far` modifier from fa-toggle-theme, which moved
the glyphs to the Solid face. The reference's glyphs are the outline variants
(FA4's sun-o / moon-o), and in FA5 those live in the Regular face — so the
Solid replacement reads bolder and visually bigger at the same 1em size, while
the neighbouring account icons stay on the lighter Regular-style SVG set.

Restore `fa far fa-toggle-theme` on all three placements: desktop navbar,
mobile header, and the floating rail. The .fa-toggle-theme::before/::after
composite rules only handle positioning and the slide transition, so they are
unaffected by the face swap.

legacy-navigation-contract already asserts `fa far fa-toggle-theme` and passes
again; full suite 999/1000 with the pre-existing frontend-lifecycle failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 19:58:54 +08:00
kate522andClaude Opus 5 6c3c0f7c6d refactor(admin): fold code highlight and system notice into 常用功能
The 代码高亮 and 系统公告 schemas existed but appeared in no menu, so their
routes were never generated (routes come from the sidebar constants). The
reference keeps both inside the 常用功能 panel rather than as their own
entries, so follow that structure instead of adding sidebar items.

- global/common now carries three groups after the lazy-load ones:
  代码高亮 (switch / line numbers / buttons / max height / light+dark theme)
  and 弹窗通知 (policy, content, 4 buttons, size, radius, icon, expiry)
- group keys are uniquified (code-highlight, code-theme, notice-*) so they do
  not collide with the existing performance group
- delete the orphan code-highlight.js / system-notice.js schemas and their
  section entries on both the Vue and PHP sides
- delete post-list/pay.js and its PHP counterpart: every post_pay_* and
  post_download_* key there had zero runtime consumers, and the reference has
  no such page (paid-content config lives under 支付&付费)

tools/health and tools/import-export turned out to already be reachable as
custom pages, and remain untouched.

Tests: admin contract audit 0 errors (warnings 56 → 51); SPA builds clean;
suite 967/968 with the pre-existing frontend-lifecycle failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 17:50:32 +08:00
kate522andClaude Opus 5 451b04160b fix(comments): restore syntax highlighting for comment code blocks
Comment [code] blocks rendered as bare <pre><code> with only Bootstrap's
grey box — no syntax colours, unlike the reference.

The runtime gates EnlighterJS on `_win.highlight_kg`, which was hardcoded to
false. With that false the selector degrades to `pre code.gl, pre code.special`,
and comment code carries neither class, so it never highlighted. The admin
already exposes 「全局功能 → 代码高亮」 (default on), and jioData.codeHighlight
reads it — only the runtime payload disagreed.

- _win.highlight_kg / highlight_hh / highlight_zt (plus white/dark theme
  names) now come from Settings::get_code_highlight_config(); the initial
  theme follows cookie → mode → time-auto via initial_theme_mode()
- define those values before the _win localize (it sits above jioData in the
  same method)
- inject the reference's two head rules the runtime path was missing:
  max-height from `highlight_maxheight`, and
  `.enlighter-toolbar{display:none}` when `highlight_btn` is off

Tests: comment-image-upload contract gains cases pinning the settings-backed
booleans (and their definition order) and the two inline CSS rules; suite
837/838 with the pre-existing frontend-lifecycle failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 17:31:42 +08:00
kate522andClaude Opus 5 0007e78bfe fix(comments): render image upload progress like the reference
While uploading, the confirm button turned into a thin 3px bar below the
label instead of the reference's striped overlay that fills the button.

Two causes:
- the markup used <span class="progress">; Bootstrap sizes the bar with a
  block-level .progress, so the swap produced a flattened line
- shell.css carried a bespoke override pinning .progress to
  `position: static; height: 3px`, discarding the absolute full-size
  overlay that main.css already provides

Changes:
- emit the reference skeleton: <div class="progress progress-striped active">
  > <div class="progress-bar progress-bar-success">, so the striped bar
  spans the button and the gradient shows through the translucent layer
- give the upload button `position: relative` so the overlay is contained by
  the button rather than the dropdown panel; delete the static-line override
- restore the reference's "正在处理请稍等..." toast and switch the label to
  "处理中 ..." at 100%, both keyed to the shared `miniupload_ajax` toast id
  so the completion message replaces the loading one
- notify() now forwards the toast id instead of dropping it

Tests: comment-image-upload contract gains two cases pinning the div-based
striped overlay and the deletion of the 3px override; suite 835/836 with the
pre-existing frontend-lifecycle failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 17:12:52 +08:00
kate522andClaude Opus 5 f6fde18857 fix(single): align author-bar publish time with the reference
The publish time under the author name diverged from the reference in four
ways: it rendered a leading bullet from `.icon-circle`, its tooltip opened
above instead of below, its wording was "2 天发布" (WordPress'
human_time_diff has no 前 and keeps spaces), and it ignored the
「时间显示数据源」 setting entirely.

- markup: drop `.icon-circle` from the time span and the description fallback;
  add `data-placement="bottom"` so the tooltip opens below like the reference
- wording: use jio_time_ago() (which already ends in 前) and append only
  发布 / 更新, yielding "2天前发布"
- branches: mirror zib_get_post_time_tooltip exactly (date source → publish
  time; unpublished → modified time; published-with-update → modified time
  labelled 更新 while the tooltip keeps the publish time; otherwise publish)
- the setting is read from post_time_source via get_post_list_style_settings()

Tests: new author-bar-time suite drives the real template through a stub
harness across all four branches (4/4); widget suite 833/834 with the
pre-existing frontend-lifecycle failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 16:58:05 +08:00
kate522andClaude Opus 5 f14e6bd88d fix(single): align cover breadcrumb with the article title
With a featured image the breadcrumb sits under the title inside the cover
overlay, but its first icon started 6px left of the title's left edge.

The overlay rule carried an extra `padding: 0` that this theme added on top of
the reference. The same node also matches `ul.breadcrumb { padding: 0 }`
(0,1,1 specificity), which outranks the base `.breadcrumb { padding: 0 8px }`
(0,1,0) — so the reference's `margin-left: -6px` nudge was applied with no
inset to compensate, pulling the whole row 6px to the left.

Writing the inset back as `padding: 0 8px` on the overlay rule restores the
reference geometry: -6px + 8px nets +2px from the panel edge. The global
`ul.breadcrumb` reset and the video footer variant are untouched, so archive
and video breadcrumbs keep their current padding.

Tests: new case in the single-cover contract suite pins the 8px inset (and the
global reset); cover suite 12/12, widget suite 829/830 with the pre-existing
frontend-lifecycle failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 16:48:38 +08:00
kate522andClaude Opus 5 93e83e1835 fix(single): align article like/favorite interaction UX with reference
The like and favorite buttons gave no in-flight feedback: the label just sat
there until the request returned, the lovestar animation never replayed on a
repeat like, duplicate clicks reported "success", and guest favorites silently
wrote a cookie instead of asking the user to sign in.

- pending state: replace the count with the reference inline spinner
  (`<i class="loading zts">`) and disable the button until the response lands
- animation: strip `action-animation` before re-adding it (forced reflow), so
  every successful like replays the reference lovestar keyframes
- duplicate: when the server reports the same state and count, skip the
  animation and surface the reference warning copy (已赞过此文章了!)
- failures: restore the original count and toast `danger`; guest-only actions
  now open the site login modal instead of dead-ending on an error
- favorite requires login server-side, matching the reference; the guest
  favourite cookie branch and its reader are removed as dead code
- unify like/favorite through one bindToggle path and keep every mirror
  button (article row + footer tabbar) in sync

Tests: new article-actions-ux suite (8 cases) drives assets/js/core/article.js
against a DOM/fetch shim — pending spinner, replay, duplicate no-op,
concurrency, rollback, guest login, mirror sync. Widget suite 828/829; the one
failure (frontend-lifecycle theme-init stub) predates this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 16:36:45 +08:00
kate522andClaude Opus 5 3c07ef7a8d fix(widgets): restore the 20px bottom gap under the quote and search widgets
The reference wraps these two widgets in a middle .theme-box layer
(widget-container > .theme-box > title + card), which is what supplies the
20px bottom margin. Our outer .jio-widget-box-wrap is only a transparent
layout container, so dropping .theme-box left the quote card flush against
the following module.

- search/quote: emit the reference .theme-box middle layer; the card itself
  stays margin-free like the reference, so the gap is exactly 20px
- keep the generic .widget-content branch for user_card/author_card and the
  other reference widgets untouched (the string is contract-tested)
- contract test now pins the .theme-box layer and asserts the quote card
  carries no margin class of its own

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 16:09:46 +08:00
kate522andClaude Opus 5 f80ca1e27d fix(widgets): restore single-card reference UI for search and quote widgets
The 搜索框 and 一言 sidebar widgets fell into the generic renderer branch, so
they rendered a .jio-panel card wrapping a second bespoke card
(.jio-widget-search / .jio-widget-quote). In-article 一言 also used
divergent modifier classes and an extra hover rule.

- search: render through the shared jio_get_search_box() (legacy keyword,
  history and category pickers) inside one reference card, mirroring the
  reference widget's markup and title default 「搜索」
- quote: render the reference .yiyan-box card itself as the click root; the
  shell now emits only the reference widget-container/title wrapper
- article 一言: adopt the reference class lists for header, footer and the
  standalone after-article box; drop the invented yiyan--* modifiers and
  the duplicate hover/opacity rules
- client: fill .cn/.en in the reference order (first line .cn, second .en)
  and drop the stale window.JioYiyan bridge plus the dead qv-yiyan.php AJAX
  path in runtime main.js
- schema: replace the ad-hoc search_style radio with the reference switches
  (keywords, history, input category, more_cats) and default title
- assets: enqueue assets/js/yiyan.js through the quote widget's asset list

Tests: widget-system suite 820/821 (the one failure is the pre-existing
frontend-lifecycle theme-init stub); new search/quote card contracts pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 15:56:52 +08:00
kate522andClaude Fable 5 1549d5b25d fix(payment): honor schema defaults for balance recharge and fix withdrawal warning
Fixes two issues:

1. **Balance recharge disabled on fresh install**: The `pay_balance_s`
   setting has `default: true` in SchemaPayment but was treated as
   disabled when the key was absent from the database. This meant users
   had to toggle it off and back on to activate the feature.

   Fixed by using `!isset() || !empty()` pattern to honor the schema
   default when the key is missing.

2. **PHP Warning in withdrawal settings**: `get_withdrawal_settings()`
   passed `$s['affiliate_withdrawal_reset_time']` directly to
   `preg_match()` without extracting it first, causing "Undefined
   array key" warnings when the option was unset.

   Fixed by extracting to `$reset_time_raw` before the regex check.

Changed:
- `PaymentRuntime::get_payment_recharge_settings()`: enabled defaults to true
- `PaymentRuntime::get_payment_gateway_settings()`: balance_enabled defaults to true
- `PaymentRuntime::is_gateway_enabled('balance')`: defaults to true
- `PaymentRuntime::get_withdrawal_settings()`: no longer warns on missing key

Added test script `tools/test-payment-runtime-defaults.php` to verify
the fix works with empty settings (fresh install).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-02 22:33:18 +08:00
kate522andClaude Opus 5 030baa511e fix(nav): restore reference theme toggle geometry and single binding
The top-bar theme switch was bound twice — core/theme.js attached a click
handler to #theme-toggle-desktop while runtime/main.js delegated click on
.toggle-theme, and both ship in main.min.js, so one press toggled twice and
the button looked dead.

Also realign the control with the reference:

- Drop the far modifier from fa-toggle-theme so the sun/moon glyphs resolve
  against the Solid face at the reference weight instead of Regular.
- Restore .toggle-radius .fa/.icon to the reference form (left/right 0 +
  auto margin only). The extra position/top/transform/size/flex block and
  the > i selector were overriding the shared abs-* centering rule.
- Replace the per-ID bindings with one delegated listener so the floating
  rail, which carries no id, keeps working.
- Remove dead theme.js queries for selectors no markup or stylesheet ships
  (#theme-toggle, #dropdown-theme-toggle, .theme-icon-moon/.theme-icon-sun,
  [data-action='toggle-theme'], .is-dark).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-02 22:24:20 +08:00
kate522andClaude Opus 5 e30de6562c fix(routing): bootstrap permalink structure so fresh installs resolve single posts
A fresh install leaves `permalink_structure` empty, so WordPress snapshots
$wp_rewrite with no post/page permalink rules and every single post resolves
to 404 until someone re-saves Settings -> Permalinks.

The existing mitigation fired too late to help: routing.php wrote the option
and flushed from `init`, but WP_Rewrite was already constructed with the empty
structure, and core defers pre-`wp_loaded` flushes to a `wp_loaded` callback
that regenerates from that stale snapshot. The rewrite_rules option ended up
non-empty but broken, so the 404 persisted on every request.

New \Jio\Core\RewriteBootstrap owns this: on `after_setup_theme` (still inside
wp-settings.php, before wp-blog-header.php parses the query) it calls
$wp_rewrite->set_permalink_structure() — the same API wp-admin/options-permalink.php
uses — which persists the option *and* refreshes the in-memory snapshot, so both
the current request and the rules written for the next one are correct. A
structure an administrator chose is never overridden.

Also consolidates the two duplicated flush sites that lived inline in
functions.php (`after_switch_theme` closure and the `jio_flushed_version`
block, whose `delete_option('rewrite_rules')` forced a needless regenerate),
and drops the now-redundant permalink write from routing.php. The version
stamp moves to `wp_loaded` priority 20 so it sees the complete rule set
instead of the pre-`add_rewrite_rule()` snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-02 19:50:24 +08:00
kate522andClaude Opus 5 39aed0d430 fix(admin): show the real default URL for level and VIP icons
用户等级配置的「等级图标 URL」和 VIP 的「会员图标」在后台显示为空白输入框,
但前台实际渲染的是主题自带图标——后台既看不到当前效果,也无法判断该字段
是否已被配置。根因是 schema default 写成空串,而运行时(Level::
get_level_icon_url() / PaymentRuntime::get_vip_icon_url())在值为空时回退
到 assets/img 下的内置图标。

把 schema default 改为直接写入主题自带图标 URL,与运行时回退链保持同一路径、
同一 is_file 守卫:后台展示的就是前台真正使用的图标,清空则回到文本/FA 回退。

- SchemaUsers::default_level_options():icon_img_N 写入 user-level-N.png
- SchemaPayment::vip_icon_default():vip_N_img_icon 写入 user/vip/vip-N.svg
- level.js / vip.js 同步默认值,help 与 placeholder 文案改为描述默认图标
- 两个测试 harness 补齐 get_theme_file_uri/path stub(schema 现在需要它们)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-02 14:06:52 +08:00
kate522andClaude Opus 5 427968a721 chore(branding): remove leftover upstream theme identifiers
The theme was renamed Zibll/子比 → AKSRT → Jio, but upstream branding
survived the sweeps in CSS classes, the SVG sprite prefix, a persisted
widget field ID, JS events, admin Vue class names, test infrastructure
and docblocks.

Renames:
- zib-widget / zib-widget-wrap / zib-slider → jio-widget-box /
  jio-widget-box-wrap / jio-slider-box (jio-widget is taken by the modern
  widget shell, jio-box-* by the card primitives)
- zib-partners-scroll + zps-* → jio-partners-scroll + jps-*
- zib-combo-info-card + zci-* → jio-combo-info-card + jci-*
- zibsvg- → jiosvg-, including the exported picker helpers and the
  fa-zibsvg- family-prefix guard
- zibpay_type → pay_type; the dual-read fallback in PostQuery collapses
  to a single key
- [zibajax="submit"] dropped (no PHP emitter); zib_ajax.success →
  jio_ajax.success
- aks-* admin Vue classes → jio-*
- zib_palette() → palette()

User-facing vendor traces are gone: the floating "more" button default no
longer ships a zibll.com link or 子比主题 marketing copy, the notice
widget ships no preset promo entries, and the four 官网教程 links are
removed from the post-list admin schema.

Test infrastructure renamed to neutral names (docker-compose.reference.yml,
Caddyfile, article-baseline.json, modern-widgets-contract.test.cjs) with
their DB/volume/env/slug identifiers following.

Kept intentionally, documented in tests/fixtures/README.md: the golden
fixture reference fingerprint (a SHA-256 record of an external directory,
read by validate-widget-golden / generate-widget-golden-fixture /
audit-widget-system), the doesNotMatch guards that prove the old tokens are
gone, and the reference theme's own meta key in the visual fixtures.

No backward-compat or data migration: saved widget configs holding old
icon keys or the old field ID silently lose them, per the project's
rewrite-over-compatibility rule.

Verified: 132/132 node contract tests, php tests/widget-system/run.php
(146 assertions), audit-admin-contracts, audit-psr4-paths and
validate-widget-golden all green; tracked bundles regenerated and in sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-02 00:06:54 +08:00
kate522andClaude Opus 5 42cc64106d docs: point the theme folder references at Jio, not Jio-theme
The mechanical rename turned AKSRT-theme into Jio-theme, but the
directory itself is Jio. Fix the three places that name the folder
(README install steps and tree, wp-cli-visual-fixtures usage comment,
DocsPage docs deep-link) and rebuild the SPA bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:12:25 +08:00
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00
kate522andClaude Opus 5 5b16bc6e15 fix(frontend): route the VIP points-exchange entry through the custom currency name
/user/vip/ 档位购买卡下方的入口横条仍硬编码「积分兑换会员」,而后台
「积分配置 → 自定义货币名称」改名后不跟随。上一轮改名只覆盖了兑换弹窗
标题与会员弹窗内入口,页面自身的入口横条是另一处服务端渲染,漏改了。

入口横条(inc/helpers/aksrt-vip.php)
- aksrt_get_vip_points_exchange_entry_html 改取已加载的
  catalog.currency_name,回退 Settings::get_currency_name() 再回退「积分」

同一页面上其余漏改的兑换文案一并收口,命名与入口/弹窗保持一致:
- VipServiceModal:积分不足提示 → {货币名}不足,暂时无法兑换
- VipServicePurchase.exchange_with_points:兑换未启用 / 兑换无效 /
  不足三处 WP_Error、流水说明、订单备注(%1$s兑换会员 %2$d %1$s。)
  currency_name 在此单独解析,get_catalog 内的局部变量不在该作用域
- LedgerPresentation:vip_points_exchange 流水原因标签

契约测试新增源断言,并用两个 PHP harness 做运行时断言:入口横条
(改名后 / 已是会员不输出 / 缺 key 回退)与弹窗(余额充足 / 不足两分支)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 16:19:49 +08:00
XingandClaude Opus 5 4e62b325d2 fix(badges): keep the medal catalog from being cast to a string on save
user_medal_args was declared as type=json without sanitize_type, so
sanitize_field_value() fell through to sanitize_text_field((string) $value)
and stored the literal string "Array". validate() has no json shape check,
so the save reported success.

Consequence: parse_medal_catalog() json_decoded "Array" into an empty
catalog, the dictionary came back empty, and user_context() dropped every
owned badge — the front end showed "获取徽章" and the grant modal listed
nothing, hiding both the newly added and the pre-existing badges.

Add the missing sanitize_type to match its sibling level/integral fields,
and guard sanitize_field_value() so structured container types can never
reach the scalar fallback again. Mirrors the declaration in the Vue schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 15:47:09 +08:00
kate522andClaude Opus 5 5c6b422866 fix(frontend): route every user-center points label through the custom currency name
后台「积分配置 → 自定义货币名称」改名后,用户中心仍有大量硬编码
「积分」:做任务赚积分 / 购买积分 / 签到领积分、积分记录 / 积分任务、
每日可免费获取 XX 积分、我的积分、积分+1、创作中心积分收益等。

get_currency_name() 早已存在且可用,问题只是这些文案没有走它。
本PR 全部替换,无双路、无迁移:

服务端渲染(用户中心 renderers)
- credits-balance.php:购买/做任务赚/签到领 + 整个购买弹窗
- profile-credits.php:积分记录 / 积分任务 页签
- credits-points-tasks.php:每日上限三态与空态
- sidebar-creator.php / profile-creator.php:积分收益
- transfer-modal.php:积分转账提示
- Signin.php:签到详情弹窗(我的积分 / 奖励 tooltip / 积分+N)
- VipServiceModal.php:我的积分 / 积分兑换会员
- download-permission-card.php:购买价格

客户端渲染(JS 无法调用 PHP,注入 currencyName 到 aksrtData)
- signin.js:签到状态行「已签到 N 天 +1」
- credits.js:转账标题/金额标签 + 购买提示 toast
- cms-download.js:积分不足视图、我的积分 入口、余额不足文案
- pay-vip.js:积分兑换会员入口(优先 catalog.currency_name)
- order-center.js:订单行单位回退

契约测试同步改为断言模板构造而非死字符串,并新增两例:
改名后跟随、未注入时回退「积分」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 15:09:49 +08:00
kate522andClaude Opus 5 1c95f94bca refactor(admin): drop the SPA credits asset record browser
「用户近期资产记录」卡片在 SPA 积分配置页里按用户 ID + 资产类型 +
日期查 user meta 历史,再伪装成统一账本行(entries / operation_key /
ref_key)。SPA 只承载配置,运营记录归原生 用户 → 积分/余额 屏幕
(CreditsAdmin / BalanceAdmin),这条平行路径删除,不留双读或迁移。

- CmsCredits.vue: 移除卡片、表格、分页及 ledger* 状态与
  formatAssetValue/loadLedger/searchLedger/resetLedger
- users.js: 删除仅服务该卡片的 getCreditLog()
- UserController: 删除 /assets/transactions 路由、rest_get_credits_log()
  及其唯一调用方 asset_value_to_minor()
- 契约测试改为断言该能力不得回归

points_record / balance_record 本身保留,前端账本与原生后台仍在消费。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 14:20:07 +08:00
kate522 324bcfdadc fix(admin): verify the whole SPA build graph, not just the entry
find_missing_admin_chunks() only parsed index.js, but a lazily
imported page chunk pulls in its own deps (shared-api, PageHeader,
sortable-options…). A deploy that drops one of those still leaves the
entry internally consistent, so the existing notice never fires and the
failure only shows up when an admin opens that menu — every schemaPage
keeps working, which makes the breakage look arbitrary.

The same scan never looked at CSS at all: 18 stylesheets are reachable
from the graph and none of them were checked, so a missing page
stylesheet ships an unstyled screen with no warning.

Walk the module graph transitively from index.js, queueing each
discovered dependency, and match .js and .css specifiers. Deleting
PageHeader now also reports every page that depends on it.

This is a diagnostic hardening only — it makes an incomplete deploy loud
instead of guessing at the cause of a broken page.
2026-11-01 13:41:58 +08:00
kate522 df12f51199 fix(admin): stop cropping the layout preview images in image_select
AksImageSelectField pinned every preview to a 60x60 box with
object-fit:cover, but these are wide layout strips: header layouts are
597x57 (10.47:1), the footer template is 800x190 (4.21:1) and the slide
align set is 200x101. A 10:1 strip inside a square crop keeps ~6% of its
width, so the layout it is supposed to illustrate is unrecognisable.

Constrain the width instead and let the height follow, which is what the
reference theme's CSF image_select does (max-width:100%; height:auto).
Each field now declares the width that suits its images, and an option
may still override it: header 200, footer 240, slide align 160, mobile
header 120.

Only presentation changed. No image files and no stored values moved,
so existing settings are unaffected.
2026-11-01 12:18:14 +08:00
kate522 4740078135 fix(icons): repair the FA5 generator and drop the 404 font references
generate-fa5-icons.php read fa-{brands,regular,solid}.svg from
webfonts/, but only woff/woff2 ship with the theme, so the generator
died on file_get_contents() and the committed fa5-icons.php could not
be regenerated from what the theme actually distributes.

Decode the style split from each shipped woff's cmap table instead.
The split has to come from the font: FA5 assigns different drawings to
the same codepoint per style, so fas/far/fab membership is unanswerable
from the CSS alone. Output is byte-identical to the committed catalog
(1001/151/456), so the picker is unaffected.

all.min.css also declared eot / eot?#iefix / ttf / svg fallbacks for all
three families while webfonts/ holds only woff2 + woff, so a browser
that stops at woff requested six files that do not exist. Add
tools/strip-fa5-legacy-fonts.mjs to keep the src: list to what ships;
it is idempotent and touches nothing but the @font-face blocks.
2026-11-01 11:52:14 +08:00
kate522 9c70d7929a chore(assets): drop the duplicated header-layout previews
assets/admin/public/images/header-layout duplicated
assets/images/header-layout byte for byte (all five PNGs, matching
md5). The directory only existed because assets/admin/public is Vite's
default publicDir, so every build copied it into build/images.

Nothing referenced it: SchemaPageDisplay builds its preview URLs from
assets/images/header-layout via self::asset(), and a repo-wide search
finds no reference to public/images anywhere. Remove the source and let
build/images stop being regenerated.
2026-11-01 11:52:11 +08:00
kate522andClaude Fable 5 869d437485 test(admin): match invite bulk row by class token, not exact attribute
The bulk row gained the shared aksrt-native-bulk class in 1273a013, so the
exact class="aksrt-inv-bulk" string match no longer held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-01 00:22:44 +08:00
kate522andClaude Fable 5 9805a371d8 feat(admin): rebuild the payment center overview with ECharts analytics
- Overview page: realtime today vs. same time yesterday, 6 KPI cards with
  period-over-period deltas, trend (paid / net / refund curves + order count
  line), category curves, payment method donut, top 10 paid content,
  conversion / close / refund rates, todos and shortcuts
- Date presets, custom range and category filter via
  GET aksrt/v1/admin/pay-overview (manage_options + wp_rest nonce)
- Metrics: paid amount by paid date including later-refunded orders, refunds
  by refund date, net = paid - refund; CNY only, points and recharge excluded,
  foreign-currency orders listed separately; integer cents internally
- Daily records cached per month in transients (today/yesterday live),
  invalidated on order status change and on purge
- ECharts 6.1.0 subset bundled to assets/js/runtime/libs/echarts-pay.min.js
  (npm run build:pay-charts), loaded only on the overview page
- Split payment-attempt methods out of OrderRepositoryCore into
  OrderRepositoryAttempts; remove dead get_stats(), cash_decimal(),
  Order::get_stats() and the old .aksrt-native-links styles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-01 00:22:44 +08:00
kate522andClaude Fable 5 d98f8d7975 feat(admin): redesign withdrawal review page and drop dead withdrawal code
- Split PayWithdrawals into shell + PayWithdrawalsList flat sibling trait
- Add process/policy callout, open/paid/fee stats, status views with counts
- Filter by source and payout method; reset keeps the status view
- Show net payout with gross/fee breakdown, copyable payee, note column
- Replace inline select form with per-row actions and a confirm dialog;
  reject requires a reason; double-submit guard; retry reuses the
  idempotency key; redirect back preserving filters and paging
- Withdrawal: add get_status_counts() and method filter; get_stats() now
  returns open_gross / paid_net / paid_fee
- Remove unused REST routes (/payment/withdrawals, /transition),
  admin_withdrawals(), update_status(), has_open_withdrawal(),
  count_open(), resolve_gross_amount()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 21:06:52 +08:00
kate522andClaude Fable 5 1d6bc8f7cc feat(admin): add bulk actions and pending-record deletion to creator income
- Always render the checkbox column and bulk bar; disable with a reason
  instead of hiding when settlement is off or the page has no pending rows
- Replace the batch-settle button with a bulk action select (settle / delete)
  plus apply, matching the charge cards and invite codes pages
- Add per-row delete for pending records and Revenue::delete_pending(),
  guarded by status = 'pending' so settled ledger-backed rows are never removed
- Report done / failed counts for bulk settle and delete

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 19:51:55 +08:00
kate522andClaude Fable 5 b25ebc5fff feat(admin): redesign creator income page with status views and safer settlement
- Split PayIncome into shell + PayIncomeList flat sibling trait
- Add policy callout, per-currency stats, status views, filter card
- Show source post/order/buyer, base amount x ratio, expected settle time
- Confirm single/batch settlement, selection count, double-submit guard
- Redirect back preserving filters; report settled/failed counts
- Revenue: add get_status_counts(), share admin_list_where(), drop dead settle()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 19:37:38 +08:00
kate522andClaude Fable 5 1273a0138e fix(admin): match bulk action select and apply button sizes
- Add shared .aksrt-native-bulk row that locks select and button to the
  same height, font size and line height
- Apply it to the charge cards and invite codes bulk action rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 19:13:09 +08:00
kate522andClaude Fable 5 4f6e17ba5e feat(admin): create and batch-generate coupons in dialogs
- Move "新建优惠码" / "批量生成" into header-launched <dialog>s
- Extract forms into PayCouponsForms trait with shared rule fields,
  hints, validation and currency/percent unit switching
- Generated codes card gets a copy-all button; list codes copy on click
- Drop old inline forms, render_generated_dump and the unread
  aksrt_coupon_generated redirect arg

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 19:03:49 +08:00
kate522andClaude Fable 5 92a3c2b8c7 fix(admin): stop stretching checkboxes full-width in the pay products dialog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 18:45:56 +08:00
kate522andClaude Fable 5 dc6dbc05d8 feat(admin): quick-edit paid post settings in a dialog on the pay products page
- Rebuild aksrt_pay_products as list + in-page quick settings dialog
  (pay type, limit, cash/points prices, sales float, coupon, order expiry, copy)
- Query by _aksrt_pay_type with real pagination; drop order-summary fallback
  and the duplicate "Meta 销量" column
- Centralize pay meta writes in DomainModel::persist_pay_meta(); stop writing
  unread _aksrt_pay_modo / _aksrt_pay_limit keys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 17:28:45 +08:00
kate522andClaude Fable 5 4083613dbe fix(admin): scroll ticket notes inside their card instead of the whole sidebar
Keep ticket info at its natural height and let the internal notes card fill
the remaining sidebar height, so only the notes list scrolls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 15:34:24 +08:00
kate522andClaude Fable 5 ed526709c8 fix(admin): align ticket sidebar and conversation pane heights
Share one --aksrt-ticket-pane-height between the conversation panel and
the sidebar cap so both columns line up, and drop the sidebar sticky
offset that no longer matches the pane height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 15:20:45 +08:00
kate522andClaude Fable 5 b9000e1b93 feat(admin): dock ticket reply composer in conversation and move notes to a dialog
Put the reply composer at the bottom of the conversation card so the
timeline and composer form one viewport-height panel; the send button is
visible on load and Ctrl/Cmd+Enter submits. Internal notes now open in a
dialog instead of expanding inline, so the sidebar no longer grows.

Remove the shared ticket_composer helper and the inline note expansion CSS;
both dialogs share ticket_dialog_head.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 15:10:13 +08:00
kate522andClaude Fable 5 54cc5301c3 feat(admin): open ticket creation in a dialog on the list page
Move "代客提交工单" back into a native <dialog> on the ticket list while
the ticket workspace stays a full page. Closing asks before discarding
input, ignores backdrop clicks, resets the form and returns focus; an
uncertain create result keeps the form locked behind "重新加载".

Remove the ?view=new create page route and its styles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 14:57:35 +08:00
kate522andClaude Fable 5 d86a85e6a6 feat(admin): replace ticket dialogs with in-page views
Ticket handling on users.php?page=aksrt_tickets no longer uses dialogs.
The list, the workspace (?id=N) and the assisted create form (?view=new)
are server-rendered page views that carry the list filters and page
for going back. Writes still go through the ticket REST routes; on
success the page reloads with a native notice, and unsent drafts in
other forms survive the reload via sessionStorage. Uncertain or
conflicting writes lock every form and only offer a reload.

Remove dead code: UsersMenusTicketsDialogs, the aksrt_ticket_view AJAX
read and its nonce, dialog CSS/JS (backdrop, scroll lock, focus return,
read races) and the dialog runtime test. Fix the create link carrying
an unclamped page number on out-of-range list pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 14:45:27 +08:00
kate522andClaude Fable 5 48e54dc115 fix(tests): restore green contract suite
Drop the legacy credits_added message label per the hard-cut rule, switch
three remaining Font Awesome 4 icons to FA5 solid, and align two widget
contracts with the badge rule grouping and renderer list from fc04bf65.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 14:18:01 +08:00
kate522andClaude Fable 5 b32b56f8bc feat(admin): redesign order list and detail workspace
Replace the type/method tab rows with status views backed by one GROUP BY
count, move filters into a single toolbar card, show gateway names, keep
list context across detail and actions, and split the detail page into a
summary/timeline column and an actions/buyer/note sidebar. Confirmations
move to admin-orders.js; PayOrders splits into flat List/Detail traits.

Remove dead code: unused Order AJAX endpoints and init(), get_orders_count(),
shipping flash codes, and the legacy raw-note fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 14:03:57 +08:00
kate522andClaude Fable 5 8c553c7221 fix(admin): auto-dismiss transient ticket dialog feedback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 12:23:51 +08:00
kate522andClaude Fable 5 7b65b3c6d8 fix(admin): keep ticket internal notes compact
Show only the latest three notes and fold older ones into a details
toggle, collapse the note composer to one line until focused, and cap
the sidebar height to the dialog body so its controls stay reachable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 12:21:53 +08:00
kate522andClaude Fable 5 a9709377bb fix(admin): make ticket "view latest" jump reliably
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 12:13:57 +08:00
kate522andClaude Fable 5 d7616b193e feat(admin): redesign ticket handling dialog as conversation workspace
Render the original question and replies as one chat-style timeline,
merge editor and toolbar into a shared composer for replies and notes,
and refresh sidebar cards. Remove superseded renderers and selectors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 12:02:49 +08:00
kate522andClaude Opus 5 09f3a94cea fix(admin): merge ticket controls into information panel
Edit status, department and assignee directly in ticket information, remove the redundant handling card and its layout rules, and cover editable and closed-ticket rendering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-31 11:46:15 +08:00
kate522andClaude Opus 5 7f1a0dbeb4 fix(admin): remove duplicate ticket dialog subtitle
Keep the ticket number and status in the sidebar only, remove the unused header description payload and bindings, and update dialog regression coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-31 11:34:21 +08:00
kate522andClaude Opus 5 264015b7e9 feat(admin): streamline ticket management with dialogs
Move on-behalf ticket creation into a header dialog and load ticket handling on demand. Reuse the admin REST write path, preserve drafts and filters, and cover dialog and server boundaries with regression tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-31 11:25:56 +08:00
kate522andClaude Fable 5 b45fd161f0 fix(reports): separate readable categories from report content
Store readable category snapshots separately from details and evidence, validate report categories, and preserve context in admin displays and moderation actions. Widen report categories to TEXT and bump the schema to 3.2.0 without legacy data conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 00:57:13 +08:00
kate522andClaude Fable 5 b2811cc248 fix(frontend): simplify report dialog controls and feedback
Use highlight-only reason choices, show the associated target on one line, and replace inline submission progress with shared toast feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 00:14:36 +08:00
kate522andClaude Fable 5 80aecab586 feat(frontend): upgrade report dialog experience
Replace manual report links with automatic target association, improve validation and evidence uploads, and remove retired report UI code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 23:56:59 +08:00
kate522andClaude Fable 5 59ddf677b6 feat(admin): review reports and ban appeals in dialogs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 23:06:04 +08:00
kate522andClaude Fable 5 32ac2112b0 fix(uploads): reclaim abandoned front-submit attachments
Mark new regular and chunked uploads as pending and claim persisted article references, including partial saves and large reference sets. Protect associated media from cleanup and add upload lifecycle regression coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 21:57:46 +08:00
kate522andClaude Fable 5 f9b7997ec2 fix(user): match profile validation hook argument type
Accept the stdClass user data passed by WordPress and derive the user ID only from the hook object. Cover callback registration, nonce and permission guards, and mismatched posted IDs; refresh the identity review dialog contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 20:38:00 +08:00
kate522andClaude Fable 5 bc26d62602 feat(admin): redesign identity review with dialogs
Replace inline identity review forms with focused detail and review dialogs, improve list filters and caching, and align evidence permissions and deletion feedback. Add contract and runtime regression coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 20:05:21 +08:00
kate522andClaude Fable 5 5732be847e fix(admin): keep invite filters inside the list card
Unify status filters, search, bulk actions and pagination in one invite list card. Remove the standalone filter summary and preserve usable search widths on small screens, with structural regression coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 19:10:13 +08:00
kate522andClaude Fable 5 a6776ffe0a feat(admin): upgrade invite code management with dialogs
Replace standalone add and export views with accessible dialogs, refine list interactions, and remove obsolete paths and unused counts. Fix filtered totals and reject oversized imports without truncation, with regression coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 18:53:01 +08:00
kate522andClaude Fable 5 1cd19434d1 fix(admin): translate credits_added notification type
Display credits_added as 积分增加 with a success badge across message
filters, list rows and details. Extend rendering regression coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 18:04:47 +08:00
kate522andClaude Fable 5 f208424a26 fix(admin): center message actions with balanced cell padding
Center the operations heading and row buttons, with 20px padding on both
sides of the cell. Add a regression contract for alignment and spacing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 17:56:31 +08:00
kate522andClaude Fable 5 479b324f95 fix(admin): move message row actions into a rightmost column
Add a dedicated operations column for viewing, marking read and deleting
messages. Keep the message cell focused on its title, excerpt and ID;
adjust column widths and empty-state colspan with regression coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 17:45:52 +08:00
kate522andClaude Fable 5 1902ba1e87 fix(admin): translate asset notification type labels
Register Chinese labels and matching badge tones for balance and points
increase/decrease types across message filters, list badges and details.
Add regression coverage without changing notification delivery behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 17:36:42 +08:00
kate522andClaude Fable 5 fe3ef82718 feat(admin): upgrade message management and broadcast interactions
Add message details, per-row actions, selection-aware bulk controls and
confirmation dialogs. Merge filters into the list card and link overview
statistics to the matching views with site-timezone today filtering.

Improve the compose dialog with Unicode character counters, recipient mode
segments, role counts and explicit broadcast confirmation verified against
the resolved role and recipient count on submission. Split rendering into
focused sibling traits and cover rendering, handlers and UI interactions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 17:24:54 +08:00
kate522andClaude Fable 5 6195508481 feat(admin): move message compose into a header-button dialog
Replace the inline <details> compose card on users.php?page=aksrt_messages
with a primary header button that opens a native <dialog>. NativeUi::header()
gains dialog-type actions via NativeUi::button_dialog(). The dialog reopens
automatically after a failed send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 16:42:56 +08:00
kate522andClaude Fable 5 8f1bbf2411 fix(admin): replace VIP expiry sort glyph with stacked CSS carets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 16:29:46 +08:00
kate522andClaude Fable 5 117da19f16 fix(admin): hide VIP dialog radio marks and align button icons
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 16:20:06 +08:00
kate522andClaude Fable 5 1be52d75a0 feat(admin): redesign VIP membership page with modal editing
- Replace per-row inline <details> forms and the separate bulk form with a
  single <dialog> shared by single-user and bulk adjustments
- Add stats row, styled tabs, date range filter with reset, sortable expiry
- Show fallback tier, remaining time, and disable unavailable operations
- History tab: labelled operation/status badges, avatars, local time,
  batch marker and error message
- Move inline script/onclick to assets/js/admin-vip.js; add vip.css
- Surface the service error message when every user fails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 16:12:12 +08:00
kate522andClaude Fable 5 18951b01d6 feat(admin): unify native admin pages on a shared modern UI
Add NativeUi helper + native-ui.css (tokens mirror the SPA Naive UI theme)
and move the pay center, orders, products, coupons, charge cards, income,
withdrawals, tickets, invite codes, VIP, verify, messages and report/ban
pages onto shared header, stat cards, cards, filters, badges and empty
states. Remove inline styles and dead helpers; split PayChargeCards into
sibling traits to stay under the file size limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 15:05:38 +08:00
kate522andClaude Fable 5 aa692f3c9f fix(widgets): drop 520px control width so expanded widgets stay in the sidebar column
WP core widgets.js applies a negative margin-left to widgets whose
control width exceeds 250px, pushing the form over the available
widgets list. Use the default width like the reference theme.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 14:13:38 +08:00
kate522andClaude Fable 5 05c7ade3f5 fix(widgets): keep widget form accordions within the sidebar column
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 14:10:12 +08:00
kate522andClaude Fable 5 7650c3537a fix(widgets): disable block widget editor to restore classic widgets screen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 14:06:49 +08:00
kate522andClaude Fable 5 a579a5b00a feat(widgets): rebuild admin widgets page to match reference theme
- Replace transfer modal/admin-post flow with reference-style AJAX
  export/import/template dialogs (admin-transfer.js, TransferController)
- Customizer "移出模块" action, reference lazy form loading with t-notice
- Common fields use reference labels, dependencies, compact/inline rows,
  top/bottom-only extra spacing; form header with description/reminder
- Remove placement hint, template preview and legacy transfer code
- Fix widget audit (current_datetime stub, honor catalog common flags)
  and regenerate stale golden fixtures for posts_toc, quote, posts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 14:00:57 +08:00
kate522 ea80cd96cc fix(widgets): restore article table of contents behavior and styles 2026-10-30 13:18:40 +08:00
kate522 fc04bf65e6 fix(widgets): restore article list rendering and reference styles 2026-10-30 12:47:07 +08:00
kate522 4ea00f1322 fix(frontend): unify article timestamp clock icons 2026-10-29 23:13:14 +08:00
kate522 4cdde50695 fix(frontend): align post thumbnail display with reference 2026-10-29 23:07:08 +08:00
kate522 542daca457 fix(frontend): align search and header components with reference 2026-10-29 22:21:01 +08:00
kate522 4c13ef478c fix(widgets): replace old page before showing pagination skeleton 2026-10-29 21:29:17 +08:00
kate522 985f18c750 fix(widgets): align mini paid badges with reference 2026-10-29 21:15:11 +08:00
kate522 fd034ff055 fix(widgets): align tabbed post layouts with reference 2026-10-29 20:31:39 +08:00
kate522 17515cf9c5 fix(comments): center empty-state illustration 2026-10-29 19:31:16 +08:00
kate522 8aa116ec43 fix(theme): align reference layouts and restore passing contracts
Align homepage and category discovery, modern widgets, admin controls, quotes, and comments with the reference theme. Remove superseded paths and add rendering and behavior coverage. Update stale asset and finance contracts; all 760 Node tests and 139 widget assertions pass.
2026-10-29 19:16:03 +08:00
kate522 b520d29368 fix(frontend): match navigation icon divider spacing 2026-10-29 18:17:51 +08:00
kate522 f019760297 chore(frontend): normalize credits script line endings 2026-10-29 18:11:59 +08:00
kate522 9b27508a5b fix(frontend): align top navigation with reference theme 2026-10-29 18:10:00 +08:00
kate522 1c501ff713 fix(user): align badge modals with reference theme 2026-10-29 17:38:10 +08:00
kate522 8e9d3a7e49 fix(signin): align check-in feedback with reference theme 2026-10-29 17:06:25 +08:00
kate522andClaude Fable 5 1df2822f01 feat(pay): 统一收银台四种模式(站内二维码/当前页跳转/表单提交/JSAPI)
- PaymentOrchestrator 统一返回 mode + code_url/payment_url/form_html/jsapi_params
- 支付宝:当面付二维码 / 电脑网站支付 / H5;微信:Native / H5 / JSAPI(WechatOpenid 静默授权)
- 易支付:PC mapi 二维码,移动端自动提交表单
- 删除旧键 alipay_payment_type / wechat_payment_type / epay_device 与 redirect/qr_content 字段
- 新增 pay-cashier.js,移除 window.open,订单状态接口改为 /pay/order/{n}
- 拆分 credits.js(affiliate / withdraw-history)至 1000 行以内

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-29 14:01:28 +08:00
kate522andClaude Fable 5 b340b77d5a fix(pay): 订单缓存失效时订单列表无刷新重载
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-29 11:21:51 +08:00
kate522andClaude Fable 5 c3328e7279 fix(pay): 待支付浮窗无刷新同步
- modal-cache 失效时广播 aksrt:cache:invalidated
- 新增 GET /orders/wait-pay-float 返回浮窗片段
- wait-pay-float.js 监听 orders 标签失效并替换/移除浮窗、重启倒计时
- VIP/充值/下载待支付分支触发 orders 失效

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-29 11:11:13 +08:00
kate522andClaude Fable 5 d9d1122e9c feat(pay): add wait-pay float window aligned with reference theme
- Render pending orders (max 5, >=3 min left) in a top-right float button
  with hover list and countdown, gated by pay_wait_float_s
- Fix order countdown parsing expires_at as UTC
- Drop unused .paid-modal-lists-box thumb rule

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-29 10:45:23 +08:00
kate522andClaude Fable 5 7e86f2a821 用户中心侧栏工单中心图标改用 comment-color
- sidebar-nav: 工单中心 icon ticket-color -> comment-color
- 删除 assets/icons/sprite/ticket-color.svg 并重建 svg-icon.js
- 同步更新相关契约测试

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-29 10:10:49 +08:00
kate522andClaude Fable 5 b7a910b758 fix(scripts): bust tbquire module cache with newest runtime/*.js mtime
_win.ver only tracked main.min.js, so rebuilding svg-icon.js alone kept
serving the cached sprite and new icons (ticket-color) rendered blank.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-29 00:08:05 +08:00
kate522andClaude Fable 5 54ea83b262 feat(icons): split built-in sprite into per-icon SVG sources and add ticket icon
Move the 114 built-in sprite icons out of assets/js/runtime/svg-icon.js into
assets/icons/sprite/{key}.svg and generate svg-icon.js with
tools/build-svg-sprite.mjs. Add ticket-color and use it for the user center
ticket entry. Update sidebar contract tests for the creator card labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 23:59:06 +08:00
kate522andClaude Fable 5 794d981260 revert(user-center): restore promotion center ratio label
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 23:41:37 +08:00
kate522andClaude Fable 5 52749bf232 feat(user-center): rename promotion center ratio label to commission ratio
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 23:40:41 +08:00
kate522andClaude Fable 5 72e55d95fd feat(user-center): show cash and points income on sidebar creator card
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 23:39:01 +08:00
kate522andClaude Fable 5 293e1121c5 fix(user-center): align sidebar card spacing with reference mb20/mb10-sm flow
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 23:35:53 +08:00
kate522andClaude Fable 5 5d83a81b31 fix(user-center): match reference hover/focus interaction on sidebar statistics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 23:21:53 +08:00
kate522andClaude Fable 5 cfe806728d fix(user-center): restore reference 6px padding on sidebar icon boxes
widgets/frontend.css loads after main.min.css and its global
.zib-widget{padding:15px} overrode .padding-6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 23:14:48 +08:00
kate522andClaude Fable 5 c152e00501 fix(user-center): 1:1 port reference sidebar statistics row and icon box line-height
- Rewrite sidebar stats as a single zib-widget row (user-statistics-item, em14 / em09 opacity5 mt6), drop legacy .uc-stats styles
- Restore reference body line-height (1.42857143) on stats and service/settings boxes
- Move 我的订单 to the front of 我的服务 to match reference order

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 22:48:54 +08:00
kate522andClaude Fable 5 6ded62df92 fix(user-center): 1:1 port reference sidebar service/settings icon boxes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 22:10:45 +08:00
kate522andClaude Fable 5 9fbfab54c2 feat(user-center): 1:1 port reference sidebar VIP bar and creator/promotion cards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 22:01:37 +08:00
kate522andClaude Fable 5 5dd7d14334 fix(modal): compensate scrollbar width when locking page scroll
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 21:07:27 +08:00
kate522andClaude Fable 5 fcec5b996f refactor(color): use wpColorPicker for page config nav color
Replace the native input[type=color] in the page config meta box with a
wpColorPicker-enhanced text input, enqueued only on page edit screens, and
contract-lock that no native color inputs remain in theme PHP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 21:01:54 +08:00
kate522andClaude Fable 5 ed1487d505 fix(tests): repair stale pagination/lazyload contracts and ban defaults script
- pay-pages-pagination asserts the $parent_file base URL added in b74381cf
- Drop native loading="lazy" from message avatars and medal presets per
  the lazyload contract
- verify-ban-settings-defaults requires SchemaUsersTicket and new deps

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 20:47:42 +08:00
kate522andClaude Fable 5 57ff405691 refactor(color): unify color sanitizing and picking behind shared components
- Add AKSRT\Core\Color::sanitize($value, $allow_alpha) as the single PHP
  color sanitizer; replace sanitize_hex_color calls and per-class regexes
  (FloatButtons, FieldSchema, ThemeSearch, icon cards, TicketStatusService)
- SchemaProvider / ControllerBase sanitize `color` fields as colors;
  alpha kept only when the schema sets showAlpha
- Split SchemaProvider OAuth validation and option matching into sibling
  traits to stay under the 1000-line limit
- Add AksColorPicker.vue + utils/color.js mirroring the PHP rules; AksField,
  FloatBtnCommonFields and TicketStatusPanel use it
- Widget background color input now uses wpColorPicker
- Remove dead AksCompositeField dimensions/spacing/background branches and
  the unused Gutenberg ColorPicker var
- Add color-unification contract test; update harness requires and tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 20:47:41 +08:00
kate522andClaude Fable 5 74021d26bb fix(layout): align color scheme, content width and fonts with reference theme
- Color scheme: zibll field order/labels, theme_skin_custom overrides palette,
  hex palette swatches, dark_theme_skin_custom, light/dark background images,
  close_backdrop via body.fps-accelerat, theme_main_radius drives --main-radius
- Emit --theme-color / --focus-shadow-color / --focus-color-opacity* vars
- Drop border_radius, primary_color readers and legacy value maps
- Content max width: free 1200-2200 number field; fix font family/custom stack

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 19:36:11 +08:00
kate522andClaude Fable 5 6da08d9d4f refactor(settings): remove filing info config and readers
Drop the 备案信息 group (icp_filing, police_filing, footer_text) from the
global.common PHP/Vue schemas, the FacadeBulk readers and the
get_global_settings() payload. No frontend consumer existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 18:36:27 +08:00
kate522andClaude Fable 5 32172582ff fix(admin): shrink image previews to natural size like reference theme
Media and gallery previews now hug the image (inline box, capped height)
instead of stretching across fixed grid cells, removing empty letterbox space.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 18:30:34 +08:00
kate522andClaude Fable 5 7d61de70e3 fix(admin): keep image previews at natural aspect ratio
Media/gallery previews no longer stretch or crop: use object-fit contain,
natural sizing for single media, and the uncropped medium size instead of
the square thumbnail for gallery and native meta-box previews.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 15:58:01 +08:00
kate522andClaude Fable 5 fa89f43ac9 feat(admin): collapse provider/gateway groups by default, mask alipay private key, drop inline notice styles
- OAuth provider and payment gateway groups are collapsible and collapsed by default; remove dead expandWhen logic
- Textarea fields support secret masking; mark alipay_privatekey as secret in JS and PHP schemas
- Extract AksSecretClear shared by input and textarea secret fields
- Replace inline styles in payment/post-list notice HTML with aksrt-hint / aksrt-warn classes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 15:50:37 +08:00
kate522andClaude Fable 5 3179754670 feat(admin): polish badge, OAuth and payment gateway settings UX
- Badge page: rewrite as collapsible sortable category/item cards with
  preset medal icon picker, dirty-tracked save bar and duplicate slug check;
  drop user_badge_* dual-read and legacy name/icon_url fallbacks
- OAuth: provider config groups collapsible, expanded when provider is on;
  clogin visibility moved to group-level dependsOn
- Gateway: expandWhen accepts dependsOn-style rules; select-gated groups no
  longer always expand ('null' was truthy); mark five gateway secrets as
  secret/password in JS and PHP; class-based notice list styling
- Remove unused UserRuntime::parse_badge_dictionary

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 15:33:49 +08:00
kate522andClaude Fable 5 116b6b2223 feat(admin): make all sortable/repeater cards collapsible, collapsed by default
- Add useCollapsibleItems: key-based open state, clickable/keyboard head, aria-expanded
- sortable items with sub-fields and every inline repeater/group row now collapse
- Repeater head summary falls back to the first filled field when no listPreview
- Drop per-schema collapsible/defaultCollapsed for repeater groups (JS + PHP)
- Align FloatBtnItemPanel head with the shared collapse attrs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 14:12:13 +08:00
kate522andClaude Fable 5 8843ae4261 refactor(admin): unify sortable/draggable/repeater list UI
- Add shared sortable-list.css, SORTABLE_LIST_ATTRS and AksSortHandle
- Migrate schema sortable, selected order, inline/modal repeater, sorter,
  floating buttons, footer tabbar and ticket prereply lists to the shared design
- Split AksField.vue (1885 -> 969 lines) into focused components/helpers
- Remove dead AksArrayTable/AksRepeaterField and move-up/down helpers
- Replace hardcoded colors and --n-* vars with --aksrt-color-* tokens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 13:41:26 +08:00
kate522andClaude Fable 5 135fb4eaa4 feat(tickets): allow custom status key in status create/edit
- Validate status_key (^[a-z][a-z0-9_]{1,23}$, unique) on create/update
- Renaming a key moves aksrt_ticket.status in the same transaction
- Default statuses remain immutable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 23:22:38 +08:00
kate522andClaude Fable 5 8d871c0828 fix(tickets): hide native radio dot in department picker
Global input[type=radio] rules in main.css out-specified the ticket
picker's hide rule, pulling the radio back into flow before the pill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 23:22:38 +08:00
kate522 174fbb54c5 Merge pull request 'feat(tickets): make ticket title/content/reply length limits configurable' (#40) from feat/ticket-length-limits into main
Reviewed-on: https://gitea.rutua.cn/kate522/wordpress-theme-aksrt/pulls/40
2026-10-27 15:07:16 +00:00
kate522andClaude Fable 5 63ab8b8fc9 feat(tickets): make ticket title/content/reply length limits configurable
Add ticket_title_max / ticket_content_max / ticket_reply_max to the
users/ticket schema (PHP + SPA) with clamped TicketSettings readers.
All write paths (front REST, admin REST, note REST, native admin page)
and front-end maxlength/counters now read the configured limits.
Remove the hardcoded TICKET_CONTENT_MAX constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 23:06:28 +08:00
kate522andClaude Fable 5 6681b0c306 feat(messages): redesign native message admin page
- Rebuild users.php?page=aksrt_messages: overview stats, collapsible
  compose card (users list / role recipients), read-state views,
  type/recipient/keyword filters, bulk mark-read/delete, shared
  AdminListPagination
- Extract NotificationAdminQuery / AdminBroadcast / MessageTypes under
  src/Messages; page logic lives in UsersMenusMessages trait
- Page-scoped assets/css/admin/messages.css
- Remove dead aksrt_notification_type_label() and
  Message::get_all_notifications()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 22:51:11 +08:00
kate522andClaude Fable 5 bbb41547e2 fix(messages): default message center slug to messages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 22:28:57 +08:00
kate522andClaude Fable 5 d71528f500 fix(messages): stop skeleton sticking after full content replace
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 22:24:35 +08:00
kate522andClaude Fable 5 5febdb35c8 feat(ticket): record urges in thread, show urge count in admin, mail admins
- aksrt_ticket gains urge_count / last_urge_time (schema 3.1.0)
- cooldown moves from transient to an atomic conditional UPDATE
- urge inserts a system reply rendered as a centered notice (not quotable)
- admin list badge + info card row for urge count / last time
- new ticket_urge_admin mail event via aksrt_ticket_urged

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 22:17:40 +08:00
kate522andClaude Fable 5 ac3ed9c956 feat(upload): pending lifecycle for upload-on-select images
Mark front-end uploads (comment/ticket/report) as pending, return the
attachment id, discard own pending uploads on remove via
DELETE /upload/pending/{id}, claim referenced URLs on successful submit,
and purge unclaimed uploads older than 24h with an hourly cron.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 21:54:48 +08:00
kate522andClaude Fable 5 a308f96837 fix(ticket): route attachment upload through UserController singleton
The static-array callable pointed at an instance method, which PHP 8
rejects as non-callable, so WP answered every upload with rest_invalid_handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 21:41:38 +08:00
kate522andClaude Fable 5 13bde11dda fix(ticket): rework detail modal layout and restore urge
- Detail modal: fixed-height dialog, thread is the only (thin) scroll area
- Collapsible pinned summary bar (collapsed by default, state kept across re-render)
- Composer: toolbar (image / urge / close) above input, pending image tray with remove
- Paste screenshots to upload; create form uses the same button + tray
- Urge/close now POST (routes are CREATABLE); urge cooldown countdown via urge_wait
- Notify admins on urge when ticket is unassigned
- Split composer/form styles into tickets-compose.css; drop dead uploader code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 21:37:03 +08:00
kate522andClaude Fable 5 b74381cfa2 feat(ticket): redesign native ticket admin page UI/UX
- Fix empty status/department selects in the detail panel (printf leak)
- Replace dead 代客提交 button with a working collapsible create form
- Error-aware notices; redirect keeps filters and the open ticket
- Status views with counts, incl. 未关闭 default and 已关闭 view
- Two-column detail: conversation + attachments + prereplies | info,
  status/department/assignee select, internal notes; closed = read-only
- Split detail panel into UsersMenusTicketsDetail trait; page-scoped
  tickets.css / admin-tickets.js replace inline styles
- AdminListPagination accepts parent file so users.php keeps menu highlight
- Drop native loading="lazy" per lazyload contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 14:04:48 +08:00
kate522andClaude Fable 5 b927b9ec7c fix(ticket): keep file picker out of modal shell to stop dialog offset
The hidden file input was appended to the flex-centered modal shell,
becoming a second flex child that pushed the dialog left and exposed
the native file button (theme display rules override [hidden]).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 12:47:49 +08:00
kate522andClaude Fable 5 3bd15feccb feat(ticket): redesign user-center ticket center UI/UX
- Status stat cards double as quick filters; debounced search with stale-response guard
- Card list with status stripe, windowed pagination, skeleton/empty/error states
- Detail dialog as chat timeline with in-place refresh, quote preview, Ctrl+Enter send
- Create form with department chips, inline validation, multi-image upload, success notice
- Split shared helpers into ticket-center-core.js and enqueue it before the panel script
- Drop dead data-aksrt-ticket-config and legacy colorful modal header

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 23:06:34 +08:00
kate522andClaude Fable 5 af4fb87e6d feat(ticket): redesign status color picker and show swatches in color column
- Move table cell styles out of scoped block so h()-rendered swatches get size
- Replace the bare color-picker trigger with preset swatch radios + custom picker
- Disable alpha so picked colors sync to the hex input (was #rrggbbaa, dropped)
- Normalize hex via toHex() matching TicketStatusService::sanitize_color()
- Add live badge preview, hex input chip, inline closed-state hint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 22:02:52 +08:00
kate522andClaude Fable 5 1a88498d57 fix(admin): stop dropping naive-ui styles for core components
提取脚本有两类问题,都只表现为「跳过 N 个」而不报错:

1. render() 漏传 props:CNode 形态的导出走
   defaultExport.render(),cssr 的 render 需要 props 对象,
   否则读 props.bPrefix 直接抛错。card / data-table / dialog /
   checkbox / slider / list / descriptions 的样式因此整体没进包——
   后台正是由卡片和表格搭起来的,等于一直基本没样式。
2. 路径写错:多数组件的样式不是单个 index.cssr.mjs,而是按子组件
   拆分的文件(form / input / radio / typography / layout);
   space 只用内联 style、tooltip 复用 popover 共享样式、
   _styles/transitions 返回的是运行时过渡对象而非 CSS,都不该列在表里。

- 两个导出形态统一传 { clsPrefix: 'n' }
- 逐条修正路径,去掉 space / tooltip / transitions 三类无效条目
- date-picker 的 indexcode 笔误一并修正

提取结果:成功 67 / 跳过 0(此前 42 / 22),CSS 197.0 KB → 293.0 KB,
类名 356 → 583。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 21:34:50 +08:00
kate522andClaude Fable 5 d53d86332a fix(admin): ship the color picker styles in the naive-ui bundle
工单状态弹窗的取色器糊成一团、像代码而不是颜色预览,根因不在页面代码:
extract-naive-styles.mjs 把取色器样式路径写成了
color-picker/src/styles/indexcode/index.cssr.mjs,该路径不存在,
import 抛 ERR_MODULE_NOT_FOUND 后被 catch 静默计数跳过,于是
build/naive-ui.css 里一条 .n-color-picker 规则都没有——组件没有边框、
没有高度、没有棋盘格色块,但页面不报错。

- 修正 color-picker 与 input-otp 的样式路径(两处都误写成 indexcode)
- catch 改为显式报出路径不存在与模块结构异常,避免同类问题再次无声滑过

提取结果:成功 42 → 44,跳过 22 → 20,CSS 195.0 KB → 197.0 KB。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 21:15:21 +08:00
kate522andClaude Fable 5 44fa34fdff fix(ticket): repair the ticket status panel color and table cells
工单状态 Tab 三处渲染问题:

- 状态名称与标识两列空白:h() 的第三个参数对原生元素必须是文本本身,
  写成 () => row.name 的插槽函数只对组件生效,单元格因此为空
- 编辑弹窗「状态颜色」区域失灵:取色器未限定 modes,默认
  ['rgb','hex','hsl'],拖动色板回传 rgb(...),被 setColor 的十六进制
  守卫丢弃,色块与输入框都不跟随。改为 :modes="['hex']",与
  AksField 的 color 字段一致
- 颜色列原本只是纯文本,按需求补上色块预览;标识改用等宽 span 渲染

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 20:43:12 +08:00
kate522andClaude Fable 5 e7ca0ee5df refactor(ticket): move the config tab into the SPA ticket center
users.php → 工单管理 的「配置」Tab 只管理部门 / 状态 / 预设回复三块
DB 字典,与 SPA「工单中心」已有的 8 个标量配置分裂成两处;且同一套
CRUD 在 admin_post 与 TicketConfigRest 里各写了一遍,REST 侧此前
没有任何调用方。

- 删除原生 config tab:UsersMenusTicketsConfig.php 整份移除,
  UsersMenusTicketsCenter 由 512 行降到 251 行(六个写处理方法、
  分发分支、十条提示码与 ticket_delete_button 一并清理)
- SPA /users/ticket 改为 customPage:内嵌 SchemaPage 承载原有 8 字段,
  三块字典改为 n-data-table + n-modal + n-dialog,取色器/管理员多选/
  拖拽排序替代原生手填表单
- TicketConfigRest 成为三块字典的唯一实现,新增 api/modules/tickets.js
  封装 aksrt/v1/admin/tickets*
- 双向深链:原生页跳 SPA 配置页,SPA 页跳回原生工单列表

契约测试同步更新,并新增 ticket-spa-config-contract 守护接线。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 19:48:58 +08:00
kate522andClaude Opus 5 d32bdc0581 fix(ticket): register the ticket schema inside the users sections array
/users/ticket loaded in the SPA but the form never did: the console showed
"未知的设置分组: ticket" from /settings/schema/users/ticket. The ticket section
was composed one array level too high in SchemaUsers::build() — as a sibling of
'sections' rather than inside it — so SchemaProvider::get_sections(), which reads
only $schema['sections'], never saw it and the REST handler 404'd.

Every other axis was wired correctly: the route, the menu entry, users/ticket.js
and SchemaUsersTicket itself. That is why only this page failed while sibling
/users/* pages worked. Move the entry inside sections.

The contract test could not catch this because it asserted only that the compose
call appears somewhere in the file. Replace it with a bracket-balanced scan that
locates the sections array bounds and asserts the compose call falls inside, so
nesting mistakes are visible.

Also update the two ticket contract tests that still read the trait from
src/Ticket/TicketRepository-traits/, its pre-flattening path. They assert on the
trait's contents, so they were silently skipping the very file the earlier fatal
was about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-26 14:16:25 +08:00
kate522andClaude Opus 5 ffd8d7d1b7 fix(ticket): flatten TicketRepository traits to PSR-4-visible paths
TicketRepository fatalled on /user/tickets/ with "Trait AKSRT\Ticket\TicketQuery
not found". src/ classes load only via Composer PSR-4 (AKSRT\ => src/) — there is
no bootstrap require list for them — so the autoloader resolved the traits by
namespace to src/Ticket/TicketQuery.php and src/Ticket/TicketStatus.php. Both
files actually sat in src/Ticket/TicketRepository-traits/, one level deeper, and
were therefore invisible: PHP hit the `use` statement with no trait declared and
died the moment the repository compiled.

The nested directory name imitated the old inc/classes/**/*-traits/ layout, which
only worked because inc/bootstrap.php carried an explicit require_once for every
one of those files. No such mechanism exists under src/. Move both traits up to
sit flat as siblings of the class that uses them — the same shape as
GlobalControllerPart1, ModerationPart1 and the rest of src/. No code changes
inside the traits; their namespace was already correct.

Add tools/audit-psr4-paths.php, which checks every symbol under src/ against the
path Composer maps it to and exits 1 on mismatch. It flags this exact layout, so
the nesting cannot come back unnoticed. Document it in CLAUDE.md next to the
existing admin-contract audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-26 13:04:46 +08:00
kate522andClaude Opus 5 dbb7de41e9 feat(ticket): replace the after-sale flow with a general ticket system
The old 售后 flow was an order-refund appendage: tickets were hard-bound to an
order number, users could only file them from 「我的订单」against a paid order,
and the admin action bottomed out at 「标记订单已退款」. It could not carry
account questions or feature requests that have nothing to do with an order.

Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket:
title + department + description are required, linking an order is optional
(user's own paid orders only). Add departments, custom statuses, preset replies
and internal admin notes as four configuration surfaces. Conversation history
moves off the ticket row's JSON log into dedicated ticket_reply rows, which
unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end
moves from 支付中心 → 售后服务 to users.php → 工单管理.

Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new
ones, delete the old code. No dual paths, no data migration. The upstream
plugin's upstream/downstream delivery and internal-ticket conversion are
deliberately left out — both are multi-provider platform concerns with no
meaning on a single WordPress site.

Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply,
ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION
2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status
「待接单」is flagged as non-editable.

Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse
UserController::rest_upload_comment_image with a ticket-specific permission
callback, mirroring the existing /report/upload-image route rather than
growing a second upload path.

Notifications go through AKSRT\Messages\Message::send() and deep-link to the
right surface; email/SMS stay out of scope since the theme's mail and SMS
stacks are separate implementations.

Refund gate opens up: marking an order refunded no longer requires an in-flight
ticket, only that the order is paid.

Also generalise a hardcoded special case in tools/audit-admin-contracts.php —
schema domain inference now reads the parent directory instead of matching
"PostList" by name, so new leaf schema files need no per-name exception.

Verification:
  php tools/audit-admin-contracts.php   exit 0, errors=0
  node --test (widget-system + admin)   664 pass / 0 fail
  php -l over all new/changed files     clean
  node --check over all JS              clean
  npm run build + build:public          clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-25 23:29:51 +08:00
kate522andClaude Opus 5 761c84d70f feat(orders): replace order stat cards with a 售后中 filter
/user/orders 顶部的「已退款」「售后待处理」统计卡移除(统计行改为
全部 / 待支付 / 已支付 / 已关闭 四格,栅格同步由 3 列调为 4 列),
筛选的订单状态新增「售后中」,命中带有「待审核 / 处理中」工单的订单。

售后中与订单自身状态正交(售后中的订单可能仍是 paid,也可能已
refunded),因此不过滤 status 列,而是由 OrderCenterQuery 通过
repository_args 的 after_sale_open 下传,在 get_user_order_query_parts
中作为独立谓词叠加工单子查询,与类型、订单号条件自然组合。

工单流转口径复用 AfterSaleService 的 STATUS_PENDING / STATUS_PROCESSING,
与 find_active() 的退款闸门同一套判定;已完结(completed)与已撤销
(cancelled)不算售后中。

同时清理随之失效的死代码:UserOrders::get_user_order_stats()、
AfterSaleService::count_user_open()、aksrt_get_order_status_labels(),
以及两处 summary 查询与空视图中的 refunded / after_sale_pending 统计键。
不写双路与数据迁移,旧键直接替换。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-25 18:58:06 +08:00
kate522andClaude Opus 5 81b60eb427 fix(revenue): replicate the reference rebate poster canvas layers
The poster modal shell already matched the reference theme, but the canvas
drawing diverged from zibll's poster-share.js rebate branch in four places:

- No 推荐人 tags layer. The reference paints a red #fc4e1f 24px line at
  img_height+160; AKSRT dropped the layer entirely, so the poster lost the
  referrer attribution that identifies whose link it is.
- The logo slot painted text. The reference contains the real logo image
  into (80, height-190, 300, 100); AKSRT drew the site name as a 30px string
  plus a hardcoded 长按识别二维码访问 caption in that slot.
- Bottom copy was hardcoded. The reference reads share_desc; now sourced
  from the new pay_rebate_poster_desc_bottom field with share_desc fallback.
- No banner fallback. The reference walks banner → banner_default →
  banner_spare; a single broken URL left the top half of the poster black.

Also aligns the week label to the reference's 周 prefix (was 星期日) and ports
drawTextOverflow for wrapping/truncation, which retires the local wrapText.

Adds pay_rebate_poster_banner and pay_rebate_poster_desc_bottom to both the
Vue and PHP schemas; rest_me now returns logo, compatible and referrer_name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-21 21:07:48 +08:00
kate522andClaude Fable 5 ef15416ef7 fix(revenue): keep the poster modal download button circular
The blue download anchor rendered square while the adjacent yellow close
button stayed round. Both carry .but.cir, so the asymmetry had to come
from a rule matching only one of them.

credits.css:334 scopes .uc-content .but.jb-blue to a flat #3b82f6 skin
with border-radius:4px — that rule exists to stop the same class
flattening the top-nav publish chip and the site-wide login/submit/badge
buttons, so it stays. But the poster modal is emitted inside .uc-content,
and at (0,3,0) loading after main.css it out-ranks .poster-share
.but.cir's (0,2,0) on both counts, overriding the 5em pill with 4px on
the blue button only. jb-yellow is not in that selector, which is exactly
why its neighbour survived.

Restate the pill at matching weight, scoped to this modal. Padding comes
along because .uc-content .but.jb-blue also sets 0.45em 1em, which would
otherwise re-inflate the 36px circle. The 36x36 box and drop shadow stay
with .poster-share .but.cir in main.css.

Add a contract test for the radius restatement and the sizing it relies
on; verified it fails when the selector is renamed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-21 20:17:50 +08:00
kate522andClaude Fable 5 0cfd4c898b fix(revenue): drop 待偿欠款 from the commission card
The field rendered in two places on /user/revenue/: the 我的佣金 card's
meta line and the 累计佣金 row, both gated on a non-zero balance. Remove
both, along with the now-unused $debt_amount local and the dead
.uc-commission-card__debt rule.

Source trace, since the label is only the surface: CommissionLedger::
overview() reads debt_minor from aksrt_commission_debt and returns it as
'debt'; record_debt() books the shortfall when a refund cannot be clawed
back in full, and repay_debt_from_pool() later clears it out of the
commission pool. That mechanism is untouched — arrears still accrue and
still get repaid, the UI just no longer surfaces them. The reference
theme's rebate card has no equivalent concept, so this is not a parity
target either.

Keep 'debt' in the template's overview() fallback array: it mirrors the
real return shape and the repayment path still depends on the value.

Add a contract test asserting the label, its class and the local are gone
from the display layer while overview() keeps returning the key; verified
it fails if the label is reintroduced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-21 20:08:34 +08:00
kate522andClaude Fable 5 9a22c7eb8b fix(revenue): stop poster modal from blocking the whole revenue page
Two regressions from the previous commit made /user/revenue/ unusable.

The rewrite dropped data-aksrt-affiliate-poster-modal from the modal shell.
That single attribute was both the JS lookup key and the CSS hook, so
initAffiliatePoster() bailed at `if (!button || !modal) return` — which is
why the 推广海报 button never got un-hidden — and no CSS could target the
shell. Restore it.

With the shell back but still visible, main.css's .flex{display:flex} loads
after bootstrap and matches .modal{display:none} on specificity, so the
resting modal rendered as a full-viewport flex box. Combined with .fade's
opacity:0 that is an invisible overlay swallowing every click on the page.
Pin the resting state to display:none and the open state to flex, the same
way credits.css already does for the charge and points-pay modals — that
file documents this exact trap.

Add a contract test asserting the markup, JS selector and CSS hook share
one attribute and that the resting state is explicitly hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-21 19:26:48 +08:00
kate522andClaude Fable 5 de0e5cd481 fix(revenue): match reference poster modal UI 1:1
The 推广海报 modal on the revenue page used a bespoke .uc-modal card —
white panel, 12px radius, heavy shadow, title bar, corner × button and a
full-width 保存海报 button — plus a light-gray plate with its own
border-radius around the poster canvas. None of that exists in the
reference theme, so the popup read as bordered and off-spec.

Rebuild the modal on the reference poster-share structure: a borderless
360px .modal-dialog.poster-share centred by .modal.flex.jc, a
.loading-mask overlay while the canvas renders, and the download/close
buttons moved onto a .abs-center.hover-show-con layer that only fades in
on hover. initAffiliatePoster() now drives Bootstrap 3 modal semantics
(.in toggle, .modal-backdrop, body.modal-open, ESC and backdrop-click to
close), mirroring the existing openChargeModal/closeChargeModal pair, and
animates .modal-content from the 220px placeholder to the poster's real
height before clearing the inline styles.

No CSS added — every rule the reference modal needs was already ported.
Drop the now-dead .uc-affiliate-poster rules; the shared .uc-modal shell
and openModal/closeModal stay for the withdraw and transfer modals.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- PaymentRuntime: output via wp_kses_post

- VipServiceModal points modal: render via wp_kses_post instead of esc_html

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

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

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

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

Render the icon span only when vip_level_number > 0.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Restore the descendant selector so both chips share one geometry:

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

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

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

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

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

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

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

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

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

No migration for sites that already stored the old nav_newposts_text.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

同时修正两个相邻问题:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@
/.workbuddy/
# Project planning, internal docs, and local-only development files
/.aksrt-chrome-profile/
/.jio-chrome-profile/
/.specs/
/docs/
@@ -1,60 +0,0 @@
# 用户公开主页通知按钮改为纯图标+未读角标
## 现状
- 目标按钮: `templates/parts/user/author-hero.php:144-149`(仅 `$is_own` 查看自己主页时输出),当前为铃铛图标 + "消息" 文字,无角标。
- 参照实现: 用户中心头部 `inc/helpers/user-center-renderers/profile-header.php:87-94` — `btn-ghost btn-sm btn-icon` 纯图标按钮,带 `aria-label`/`title`,未读数 > 0 时输出角标 `<span class="uc-header__action-badge">N</span>`(不封顶 99)。
- 未读数函数: `aksrt_get_user_unread_notification_count($user_id)`(`inc/modules/user-notifications.php:387`),模块经 `inc/bootstrap.php:78` 无条件加载,可直接调用。
- hero 卡片仅由 `page-user-home.php` 服务端静态渲染一次(DOM 无刷新只作用于 tab 面板),角标为静态服务端渲染,与用户中心行为一致。
- `.btn-icon` / `.btn-icon.btn-sm` 样式已存在于 `assets/css/core/buttons.css:129-136`,全局可用;但 `.uc-header__action-badge` 样式在 user-center 路由 CSS 中,不会在公开主页加载,需在 `assets/css/routes/user-home.css`(该页直接入队,无构建步骤)新增。
## 修改内容
### 1. `templates/parts/user/author-hero.php`
- 在第 58 行 `$messages_url` 之后新增:
```php
$unread_count = $is_own ? aksrt_get_user_unread_notification_count($user_id) : 0;
```
- 将消息按钮(144-149 行)整体替换为与用户中心一致的纯图标形式,类名沿用本文件 BEM 前缀:
```php
<a href="..." class="btn-ghost btn-sm btn-icon aksrt-author-hero__message-btn" aria-label="消息通知" title="消息通知">
<i class="fa fas fa-bell" aria-hidden="true"></i>
<?php if ($unread_count > 0) : ?>
<span class="aksrt-author-hero__action-badge"><?php echo esc_html((string) $unread_count); ?></span>
<?php endif; ?>
</a>
```
- 删除原 `<span>消息</span>` 文字输出,不保留旧结构。
### 2. `assets/css/routes/user-home.css`
在 `.aksrt-author-hero__follow-btn` 样式附近(约 167 行后)新增,视觉与用户中心角标(`user-center/shell.css:136-157`)完全一致:
```css
.aksrt-author-hero__message-btn {
position: relative;
}
.aksrt-author-hero__action-badge {
position: absolute;
top: -5px;
right: -5px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
margin: 0;
padding: 0 4px;
border-radius: 999px;
background: var(--color-error, #ef4444);
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
```
## 验证
- `php -l templates/parts/user/author-hero.php`
- 运行 `node --test tests/widget-system/page-display-contract.test.cjs tests/widget-system/post-visual-contract.test.cjs`(已确认两者均未断言该按钮标记,应保持通过)
- 桌面/移动端检查: 图标按钮为 28px 方形(`.btn-icon.btn-sm`),角标绝对定位右上角,移动端居中布局不受影响(角标不占布局空间)
不写 function_exists 兜底、不保留文字版本、不添加迁移逻辑。
@@ -1,31 +0,0 @@
修复文章详情页分割线右侧三点图标不居中的问题
## 根因
三点按钮(`inc/helpers/aksrt-single-components.php:131-146`)为 `<button class="but cir post-drop-meta__toggle"><i class="fa fas fa-ellipsis-h"></i></button>`,位于 `templates/parts/single-author-bar.php` 中 `.single-meta-row` 分割线右端。`.but.cir` 是 `inline-flex; justify-content: center`,flex 居中的是子元素的 margin box。`assets/css/core/buttons.css` 中存在特异性冲突:
- 第 183 行 `.but > i[class*="fa-"]:not(.fa-angle-right) { margin-right: 0.25em; }` 特异性 (0,3,1)
- 第 225 行 `.but.cir > .fa { margin: 0; }`(本意的重置规则)特异性 (0,3,0),被压掉
导致图标右侧残留 0.25em 外边距(15px 字号下约 3.75px),三点整体左偏约 1.9px。这是全局 bug,所有 `.but.cir` 图标按钮都受影响。
## 修改内容
1. **`assets/css/core/buttons.css:224-225`** — 加强重置规则的特异性到 (0,4,0),稳定压过 (0,3,1) 的 margin 规则(不依赖源码顺序):
```css
.but.cir svg.icon,
.but.cir > .fa,
.but.cir > .fa:not(.fa-angle-right),
.but.cir > i[class*="fa-"]:not(.fa-angle-right) { margin: 0; }
```
附一行注释说明 `:not()` 变体是为压过 `.but > i[class*="fa-"]:not(.fa-angle-right)` 的 margin 规则。保留原 `.but.cir > .fa` 选择器以继续覆盖 fa-angle-right 图标。不改 main.css(旧的手工捆绑副本):public 捆绑包中 core/buttons.css 副本排在 main.min.css 之后,高特异性重置必然胜出。
2. **重建捆绑包** — 运行 `node tools/build-public-assets.mjs`(esbuild 已就绪),重新生成 `assets/css/build/public.css/.min.css` 等捆绑文件。
3. **新增契约测试** `tests/widget-system/circular-button-icon-margin-contract.test.cjs`:断言源文件 buttons.css 与构建产物 public.min.css 中包含加强后的重置选择器(防止特异性回归),沿用现有 `*.test.cjs` 契约测试模式。
## 验证
- `node --test tests/widget-system/circular-button-icon-margin-contract.test.cjs` 通过
- 在构建后的 `public.min.css` 中确认重置规则存在且位于 margin 规则之后
- 刷新文章详情页目视确认三点图标在圆形按钮内水平居中(同时全站其他 `.but.cir` 图标按钮居中恢复正常)
@@ -1,163 +0,0 @@
## 目标与边界
将“全局功能 → Email邮件”收敛为一套真正可用的邮件系统:后台完整展示 SMTP、发件品牌、认证/账号、内容互动、审核、私信、身份/账号处理、举报,以及订单、支付、提现、返佣、分成、转账等商城/资金类邮件;模板主题和正文默认直接显示,不再用空值加 placeholder 代替;所有真实发送统一由同一服务渲染和发送。
明确排除:
- 不实现论坛、版主、论坛帖子审核、回答采纳等论坛专属邮件。
- 不实现邮箱回滚、撤销链接、回滚令牌或旧邮箱恢复流程;邮箱变更邮件只做安全通知。
- 不做旧 option 的数据迁移,不保留新旧双路,不写兼容 adapter。
- 不做浏览器或真实视觉验收,只做代码、契约、语法和构建验证。
## 1. 建立唯一邮件配置与模板目录
- 以 `aksrt_settings` 的 Email section 作为唯一配置来源;SMTP、品牌、通知开关和模板都通过 `/settings/schema/global/email` 读写。
- 在邮件域建立集中式模板目录,统一声明每个非论坛事件的事件键、后台名称、默认开关、默认主题、默认 HTML 正文、占位符及说明。
- 邮件运行时和后台 Schema 均从该目录生成,避免默认文案继续散落在业务调用点。
- 对每个事件同时声明“开关、主题模板、正文模板”;模板正文在后台直接显示为非空默认值。数据库中已有空主题/正文时,仅在读取响应和表单初始化时解析为目录默认值,不执行迁移。
- HTML 模板和 `mail_more_content` 使用 `wp_kses_post()` 清理;主题、SMTP host/username/name 等按文本清理;邮箱地址使用 `sanitize_email()`;端口限制到合法范围;加密方式只允许 `ssl`、`tls` 或空值;SMTP 密码使用 secret 掩码/清除机制。
## 2. 完整重做 Email 后台 Schema
在 `src/Admin/Schema/GlobalSchema.php` 中由集中目录生成 Email section;让 `SchemaPage.vue` 对 `global.email` 使用服务端 Schema,删除前端重复的静态 Email schema 定义,避免 PHP/JS 两份字段漂移。
页面分组与事件如下:
1. **管理员邮件推送**
- 友情链接提交
- 新订单
- 提现申请
- 投稿待审核
- 身份认证申请
- 账号封禁申诉
- 举报提交
2. **用户邮件推送**
- 注册验证码
- 注册成功
- 免密登录验证码
- 密码重置验证码
- 邮箱/敏感操作验证码
- 邮箱变更安全通知
- 新订单/支付结果
- 提现处理结果
- 返佣到账
- 创作分成到账
- 收到积分/余额转账
- 评论通过审核
- 评论回复
- 投稿发布/驳回结果
- 手机号修改通知
- 身份认证审核结果
- 账号封禁/解封通知
- 举报处理结果
- 私信通知
- 私信邮件用户组限制
- 所有用户
- 所有会员
- 二级会员及以上
- 仅管理员
3. **自定义发件人/品牌**
- `mail_showname`,默认站点名
- `mail_title_prefix`,默认 `[站点名]`
- `mail_description`,默认“此信为系统邮件,请不要直接回复。”
- `mail_more_content`,默认显示“访问网站 / 联系站长”等基础 HTML 链接
4. **SMTP 配置**(沿用参考主题语义,但使用当前主题唯一字段契约)
- `mail_smtps`
- `mail_name`
- `mail_passwd`
- `mail_smtp_name`
- `mail_host`
- `mail_port`
- `mail_smtpauth`
- `mail_smtpsecure`
- SMTP 风险提示和依赖显示
5. **测试邮件**
- Email 页面内提供测试收件地址和发送操作。
- 有未保存修改时禁止测试并提示先保存,确保测试当前生效配置。
- 测试邮件走统一 HTML 包装、品牌和 SMTP 主线。
每个事件组直接显示“启用开关 + 主题模板 + HTML 正文模板 + 可用占位符说明”。论坛事件不进入 Schema、模板目录、默认值或运行时注册。
## 3. 实现参考主题式统一邮件渲染
重构 `src/Settings/Mail.php` 和 `inc/core-hooks/part-2.php`:
- 集中服务完成事件查询、开关判断、占位符替换、主题前缀、HTML 安全处理、品牌包装和 `wp_mail()` 调用。
- 复刻参考主题邮件信封:站点 Logo、主体内容、站点名称、底部说明、附加链接及现有 `assets/img/mail-bg.png` 背景图。
- 主题前缀只添加一次,并清除 WordPress 默认重复 `[站点名]` 前缀。
- 发件人名称保留明确优先级:`mail_showname` → 站点名;SMTP 发件地址和 SMTPAuth 使用同一 Email section 的 `mail_*` 字段。
- 将 `send_event_email()` 统一为事件键、收件人、占位符(必要时允许结构化上下文),默认模板不再由调用方重复传入。
- 未知事件失败关闭;无效收件地址不发送;事件关闭时不发送。
- 统一管理员收件人获取、去重和有效邮箱过滤。
## 4. 接通所有非论坛真实业务发送点
新增聚合通知注册类,基于当前主题已有 WordPress/AKSRT action 注册邮件发送;不凭空添加论坛功能。每个事件都复用集中模板目录和统一发送服务。
### 认证与账号
- 改造 `UserControllerAuthPhoneOperations.php`:注册验证码、免密登录验证码、密码重置验证码按独立事件发送;失败时删除验证码 transient,维持现有频率限制和安全行为。
- 注册成功在用户创建成功且用户对象有效时发送,提供用户名、站点名、个人中心/站点链接等占位符。
- 改造 `account-ajax.php`:敏感操作验证码、邮箱变更通知走统一服务;删除直接 `wp_mail()` fallback;不增加邮箱回滚。
- 手机号绑定/换绑成功后向有效邮箱发送通知。
### 内容互动与审核
- 评论通过审核、评论回复:接入 WordPress 评论状态/回复钩子,避免重复状态流转、自己回复自己和无效邮箱。
- 投稿待审核与发布/驳回结果:接入现有 `aksrt_new_posts_pending` 与文章状态转换,只处理当前主题实际支持的投稿内容,防止重复通知。
- 友情链接提交:接入当前友情链接写入成功动作,向有效管理员邮箱发送。
### 身份、账号、举报和私信
- 身份认证申请/审核、封禁申诉/处理、举报提交/处理:接入现有成功动作;若成功路径缺少动作,只在事务成功点增加明确的 `aksrt_*` action。
- 私信通知:接入现有消息/通知主线,只处理 private 类型;实现参考主题的用户组限制,并过滤无效或占位邮箱。
### 订单与资金业务(保留)
- 订单创建/支付成功/支付失败或状态变化:接入当前支付订单状态 action,分别通知管理员、买家及适用的订单关联用户。
- 提现申请与处理结果:接入当前提现申请、审核/完成/拒绝路径,通知管理员和申请用户。
- 返佣通知:接入当前 referral/佣金到账 action,向推荐人发送订单和佣金信息。
- 创作分成通知:接入当前 ledger/收益分配成功 action,向分成作者发送到账明细。
- 积分/余额转账通知:接入当前资产转账完成 action,向收款用户发送转账信息。
- 上述商城/资金事件只使用仓库中已存在的订单、支付、ledger、referral、资产动作和数据结构;若某事件没有可直接监听的成功动作,在对应现有事务完成点补充 action,不复制业务逻辑、不新建第二套交易流程。
论坛/版主/回答采纳动作不注册任何邮件监听器,也不产生对应模板字段。
## 5. 删除旧接口、旧存储与绕路代码
- 删除 `GlobalController` 的 `/settings/mail` 路由和 `rest_handle_mail()`,不再使用 `aksrt_mail_config`。
- 保留 `/mail/test` 作为动作接口,但重写为统一邮件服务测试;配置读写只有 Schema 路由。
- 删除前端 `getMailSettings()` / `saveMailSettings()`,只保留测试邮件 API 方法。
- 删除 `UserController` 的 `/settings/mail-templates` 路由、`handle_mail_templates()` 和 `aksrt_mail_templates` 契约。
- 删除 `Settings::get_smtp()` 对 `aksrt_mail_smtp` 的读取,并从 standalone/secret option 注册表移除该旧 option;SMTP 只读 Email section 的统一字段。
- 删除认证、账号和各业务模块中邮件服务不存在时的直接 `wp_mail()` fallback;正常业务只允许集中邮件服务内部调用 `wp_mail()`。
- 删除旧六事件空模板回退参数、重复前端 Email Schema 及不再使用的旧字段;不读取、不转换旧 option 值。
- 不删除数据库中的旧 option 值,但代码完全停止消费它们;不执行迁移脚本或双写。
## 6. 契约测试与构建验证
新增邮件系统契约测试/harness,覆盖:
- 后端 Email Schema 包含认证、账号、内容互动、审核、私信、身份/账号处理、举报、订单、支付、提现、返佣、分成、转账等事件。
- Schema 明确不包含 `bbs`、moderator、answer-adopted 及其它论坛字段。
- 每个主题和正文默认值非空;空存储读取时后台仍返回可见模板;模板占位符替换后无未知占位符。
- HTML 邮件信封包含品牌说明、附加内容、Logo 和背景图;主题前缀不重复;发件人和 SMTP 字段优先级稳定。
- SMTP 开关、认证、host、port、encryption、secret 掩码/清除行为正确。
- 事件关闭、未知事件、无效收件地址均不发送。
- 注册/登录/重置/邮箱变更路径不再绕过集中服务,发送失败时验证码缓存处理正确;不出现邮箱回滚入口或令牌。
- 评论、投稿、友情链接、身份认证、封禁、举报、私信及所有订单/资金事件分别绑定真实 action,并覆盖去重、无效邮箱和边界条件。
- 论坛事件不注册、不发送、不出现在后台;订单/支付/提现/返佣/分成/转账事件必须存在并可配置。
- 旧 `/settings/mail`、`/settings/mail-templates`、`aksrt_mail_config`、`aksrt_mail_templates`、`aksrt_mail_smtp` 和旧前端 API 不再存在。
- 全仓库业务代码中的 `wp_mail()` 只剩集中邮件服务内部调用。
执行验证:
1. 对所有变更 PHP 文件运行 `php -l`。
2. 运行新增邮件契约测试及受影响的 admin/UI 契约测试。
3. 在 `assets/admin` 执行 `npm run build`,由 Vite 清理旧 chunk 并更新完整 `assets/admin/build/`。
4. 使用 `rg` 做最终静态检查:无论坛邮件字段、无旧 option/API、无散落直接 `wp_mail()`,且订单/支付/提现/返佣/分成/转账事件均有 Schema、模板目录和发送入口。
5. 不启动浏览器、不截图、不做真实视觉效果验收。
@@ -1,56 +0,0 @@
## 目标与口径
将 AKSRT 小工具系统中 12 个小工具对照 Zibll(仅参考"(新)"模块,忽略一切"旧版即将删除")做全量 1:1 复刻:后台配置项(字段、类型、选项、默认值、依赖、min/max/step、分组小标题)与前端 DOM/样式/状态全部对齐参考主题。已确认口径:
- **专题全部移除**:本项目无 topics 分类法,12 个小工具范围内所有"专题限制"字段与 `PostQuery` 的 topics 查询分支一并删除,测试同步更新。
- **修正参考源码明显 bug 的文案**(如排序 label 颠倒、最近评论 style 默认值 'card' 不在选项中),数据值与行为保持一致。
- **省略 AKSRT 无法支撑的排序选项**(销售价格/积分售价/销售数量),排序值域 = views/like/comment_count/favorite/modified/date/rand。
- **新增"搜索式多选+拖拽排序"分类选择字段**(terms 类型)。
- 不写双路/迁移;清理被替换的死代码;不做 Docker 视觉验收;不改 37 个 slug 与 REST 路由形态。
## 复用基础(已核实)
- 文章 item DOM 契约已在 `inc/helpers/posts-renderer/markup.php`(posts-item/posts-mini/badges/meta,9.0 契约);`main.css` 已含 `.zci-*`、`.zps-*`、`.graphic*` 等 Zibll 同款 CSS(76 处命中);`frontend.css:179-228` 已有 tab/slider 的 Zibll 契约块;runtime `main.js` 已有 `.swiper-scroll`/`.new-swiper` 初始化。复刻 = 补齐缺失部分并对齐 markup,而非从零移植。
- 前次复刻先例:tab_posts/slider/buttons/notice 走 `FrontendRenderer` "特殊壳"路径(before_widget + reference_title + 内容自闭环),本次 12 个中需要 Zibll 壳的一并加入该列表。
## 实施步骤
### Phase A 基础设施
1. `FieldSchema.php`/`FormRenderer.php`:新增 `terms` 字段类型(动态 get_terms 分类选项、搜索过滤、拖拽排序、按选择顺序保存 int 数组;支持 taxonomy 参数以供合作伙伴的链接分类复用);spinner/slider 渲染 `unit` 后缀(此前 unit 是死键)。
2. `PostQuery.php`:删除 topics 分支;tabs 段支持 `zibpay_type`(数组,沿用 apply_pay_type 映射 1/2/5/6);orderby 值域与 allowlist 对齐。
3. `PostRenderer.php`:分页三态对齐 Zibll —— `''`→隐藏 `.ajax-pag.hide` 占位、`ajax`→"加载更多" `.ajax-pag .next-page.ajax-next`、`number`→`.pagenav.ajax-pag` 数字页码(全部带 `ajaxpager-target=".widget-ajaxpager"` 契约,REST 数据属性保留);`mini_opt` 应用于 posts;热榜从通用分支拆出专用渲染。
4. `FrontendRenderer.php`:特殊壳列表加入 `posts, oneline_posts, hot_posts, term_cards, term_aggregation, media_cards, combo_info, partners, recent_comments, posts_toc`。
5. `frontend.js`:移植/新增 ①IAS IntersectionObserver 懒加载(列表 `load_mode=ajax` 与加载更多自动触发,替代 Zibll lazysizes ias);②合作伙伴滚动初始化(测量 seed 组宽→克隆→`--zps-duration`,resize 重算,Zibll main.js:4204-4296);③幻灯片组合卡 footer 重算 `--cover-ratio`(widget-slider.php:848-875);④目录树逻辑(section-navs 移植:h1-h4 抓取、排除 `.item-heading`/`.no-nav`、`wznav_{i}`、scrollspy offset 105、>380px 折叠、hash 定位);保持 role=status/aria-busy/reduced-motion a11y 契约。
6. `admin.js`/`admin-core.js`/`admin.css`:terms 字段交互(搜索、拖拽排序、选序同步)。
### Phase B 逐个小工具(schema+render+CSS+JS+空态,均以参考文件为准逐字段移植)
| # | AKSRT slug | 参考定义(行号) | 要点 |
|---|---|---|---|
| 1 | posts 文章列表 | widget-posts.php:1881-2006 | load_mode radio(detail/ajax 默认 ajax)、cat(reference_ids)、zibpay_type checkbox、limit_day、orderby、order、style(list/card/mini)+mini_opt、count 4..20 默认 12、paginate radio ''/ajax/number;壳 `widget-main-post mb20 style-X`(mini 加 `zib-widget posts-mini-lists`);删旧 tag_ids/paginate select |
| 2 | tab_posts 多栏目文章 | widget-posts.php:2008-2155 | tabs 子字段 title/cat/zibpay_type/limit_day/orderby/order;移除 topics;**删除现有 legacy `post_ajax_trigger` 双路**,翻页三态;mini 骨架含缩略图变体 |
| 3 | oneline_posts 横向滚动文章 | widget-posts.php:2157-2234 | cat/zibpay_type/orderby/order/style 默认 card/count;移除 limit_day/paginate/autoplay(参考无);swiper-scroll DOM 已对齐,清 data-autoplay |
| 4 | hot_posts 热榜文章 | widget-posts.php:1803-1851 + zib-posts-list.php:769-888 | orderby radio 4 榜单、limit_day、count 默认 6、target_blank;`zib-widget hot-posts`、TOP1 大图+linear-mask+TOP 徽章(jb-red/jb-yellow/b-gray)、行布局与计数文案(人收藏/人点赞/条讨论/人已阅读);删 show_thumbnail/通用样式字段 |
| 5 | term_cards 分类图文卡片 | widget-posts.php:1565-1694 + functions.php:1939-2046 | 模块级 obs_animation/animation_repeat(common.animation 关)、terms 选择器、type style-1..4、height_scale、pc_row(1/2/3/4/6/12 默认 2)/m_row、mask_opacity 默认 10、target_blank;`row gutters-5` + `graphic hover-zoom-img` + `--cover-height-scale` + graphic-mask,style-2/3/4 文案差异与 tooltip;新建共享助手 `aksrt_graphic_card()`(inc/helpers),term 分类默认封面回退 |
| 6 | term_aggregation → 改名"分类聚合卡片" | widget-posts.php:1696-1801 + zib-category.php:606-751 | 仅分类(去标签/专题):模块级动画、pc_row slider 1..2 默认 2、terms、orderby/order、count 1..20 默认 4、target_blank;移植 `zib-widget term-aggregation`(term-img+img-badge 浏览合计、term-title/meta/更多文章、按 orderby 的行内 meta、--delay 交错);Catalog 名称/描述/共同字段更新;服务端仅 category |
| 7 | media_cards 图文视频封面卡片 | widget-more.php:2005-2191 | 模块级动画、pc_row/m_row(1..12,默认 4/2)、mask_opacity、height_scale 默认 30、font_size_pc/m、font_bold、font_color、covers group{image,video,hide,title,link};`widget-graphic-cover` + this-font 变量 + 移动端回退图片;删旧 description/image_dark 卡片字段 |
| 8 | slider_media_combo 幻灯片加图文视频卡片 | widget-slider.php:361-887 | 模块级动画;补 5 个 subheading 分组;option 默认值逐项核对(源文件为准);m_layout_type=default 时 m_row=pc_row;clamp 上限 4/6 修正;footer 重算 JS 接入 |
| 9 | partners 横向滚动合作伙伴 | widget-more.php:2317-2581 | rows/m_rows 1..6 默认 3、style_type card/logo/chip、card_width/height(依赖)、card_bg/card_radius、speed slider 0.1..10 步 0.1、direction/pause_hover/show_widget_bg/go_link/end_fade、link_type(link 类别多选用 terms 类型 taxonomy=link_category/link_limit/link_orderby/page_links_order)、partners group{title,desc,image,link};zps-* DOM、round-robin 行分配、get_bookmarks 来源、go_link 走 aksrt_go_link、CSS 动画(已有)+JS 初始化 |
| 10 | combo_info 复合信息卡片 | widget-more.php:2583-2833 | 模块级动画、pc_row 2/3/4/6 默认 4、m_row 1/2、pc_gap/m_gap(gutters)、card_radius、top_band/top_band_height、go_link、cta_text/cta_color、cards group{title,icon,icon_image,tags 嵌套 group{text,color},desc,link,cta_text,cta_color,band_color auto/7色};zci-* DOM + band auto 循环 + --delay 交错;markup 改挂到已有 `.aksrt-combo-info-card` CSS;删 aksrt-combo-card 死代码 |
| 11 | recent_comments 最近评论 | widget-more.php:3315-3363 + zib-comments-list.php:340-407 | limit 1..50 默认 8、outer/outpost、show_source 默认 false、style radio(''简约/style_2 风格2,默认修正为 '');`box-body comment-mini-lists zib-widget` + comment-widget-item(avatar box、昵称+icon-spot 时间、点赞、con-link text-ellipsis-5、style_2 盒装、来源行);复用 inc/helpers/aksrt-comments.php 时间/头像,内容过滤图片→[图片] |
| 12 | posts_toc 文章目录树 | widget-more.php:445-532 | 改旧式字段:title 默认"文章目录"、mini_title、in_affix(删 min_headings/collapsed/offset);渲染空占位 `.posts-nav-box[data-title]`;目录构建/scrollspy/折叠全部移入 frontend.js;`.posts-nav-lists` CSS 若缺则从 main.css:4835-4946 补 |
### Phase C 死代码清理
- 删除被替换的渲染分支与 CSS:aksrt-term-cards、aksrt-term-aggregation、aksrt-combo-card、aksrt-widget-partners 旧轨、aksrt-widget-media-cards/comments/toc/pagination/load-more 旧标记、tab_posts legacy trigger、hot_posts 的 show_thumbnail、oneline 的 autoplay;`media_card_fields()` 仍被 knowledge_product 组使用故保留,仅解除 12 个目标对它的引用。
### Phase D 测试与校验
- `tests/widget-system/run.php`:query_contract 移除 topics、加 tab zibpay_type;modern_widget_contract 扩充 12 个的 schema/DOM 断言;保持既有断言全绿。
- 新增 `tests/widget-system/zibll-parity-widgets-contract.test.cjs`(node --test 源码 token 契约:每 widget 的字段 id 清单、关键 DOM 类、CSS token、frontend.js 行为、死类负向断言)。
- 更新受影响的 zibll-modern-widgets / post-visual / a11y 契约测试(保持 aria-busy、role=status、reduced-motion)。
- 重新生成 `tests/fixtures/widget-golden-v1.json`(php tools/generate-widget-golden-fixture.php)并跑 php tools/validate-widget-golden.php(不带 --require-results)。
- 验证:`php -l` 全部改动 PHP;`node --test tests/widget-system/` 全量;`composer dump-autoload -o`(优先不新增类,仅改现有文件)。
### 明确不做
不做旧设置迁移与双路渲染;不做真实视觉/Docker 验收;不参考"(旧版即将删除)"模块;不改 37 slug 顺序、REST 路由形态、其他 25 个小工具。
### 主要参考文件
zibll:`inc/widgets/widget-posts.php`、`widget-more.php`、`widget-slider.php`、`inc/functions/{zib-posts-list,zib-category,functions}.php`、`js/section-navs.js`、`js/main.js`、`css/main.css`;AKSRT:`src/WidgetSystem/*`、`inc/helpers/*`、`assets/widgets/*`、`tests/widget-system/*`。
@@ -1,16 +0,0 @@
诊断结论(已核实):
`video-player.js:177` 是 `toggle()` 中的 `this.video.play()`。播放器进入“无可播放源”状态后,点击封面/遮罩触发 `toggle()`,该 Promise 没有 catch,于是抛出 `Uncaught (in promise) NotSupportedError: The element has no supported sources`。播放器进入无源状态有四条已确认路径:
1. `.m3u8` 在 Chrome:hls.js 从 `cdn.jsdelivr.net` 懒加载(video-player.js:22),CDN 不可达时 `loadScript` 静默失败(promise 也未 catch),`<video>` 始终没有源;
2. `.flv` 同理(flv.js,video-player.js:23);
3. 直链 mp4 地址 404/编码不支持,源加载失败后 `currentSrc` 为空;
4. `.switch-iframe` 剧集按钮被 video-player 的委托处理器(video-player.js:225)捕获,把 iframe 嵌入页 URL 当 mp4 塞给 `video.src`,必然无源。`.switch-iframe` 本应由 `runtime/main.js:1351` 的 iframe 链路处理。
修复方案(单一路径,不引入双路/迁移):
1. `assets/js/video-player.js`:
- `toggle()`:播放前守卫无源状态(`video.currentSrc`/`video.src`/`this.hls`/`this.flvPlayer` 均为空时直接返回),并对 `play()` 返回的 Promise 加 `.catch()`,播放失败不再产生未捕获异常;
- 两处 `loadScript()`(hls/flv)加 `.catch()`,CDN 失败被收敛为“无源”状态,不再产生未处理 rejection;
- 委托处理器限定为 `.switch-video`,移除对 `.switch-iframe` 的错误接管(iframe 剧集继续由 main.js 既有链路处理,属于删除错误重叠而非新增实现)。
2. 契约测试 `tests/widget-system/single-cover-height-contract.test.cjs` 的 video cover 用例追加断言:toggle 守卫 + play catch、loadScript catch、处理器不含 `.switch-iframe`。
3. 验证:`node --check` 播放器文件、运行 `single-cover-height-contract` 及相关契约测试、`rg` 复查、`git diff --check`;不做真实视觉效果验收。
@@ -1,142 +0,0 @@
## 目标与边界
将 AKSRT 前台 DOM 架构一次性硬切换为 `C:\Users\Xing\Desktop\zibll` 的真实架构:
```text
WordPress 配置/_win
→ jQuery → Bootstrap → runtime/loader.js
→ tbquire(['main'])
→ main.js 顶层全局委托 + window.auto_fun()
→ 局部 AJAX:post_ajax.ed
→ JSON AJAX:zib_ajax.success
→ 远程 modal:loaded.bs.modal → auto_fun()
```
不保留 `AksrtFrontend`、不写兼容适配器、不做双路初始化、不保留“迁移中”分支。保留 AKSRT 的业务能力和安全处理,但全部挂到参考主题的唯一 `main/auto_fun` owner 上。按用户要求只做静态、契约、VM、语法和构建验证,不做真实视觉验收。
## 一、运行时入口硬切换
1. **重写 `assets/js/runtime/loader.js`**
- 以参考主题 loader 为行为基准,恢复 RequireJS 改名运行时、`tbquire.config()` 和最后的 `tbquire(['main'])`。
- 根据 AKSRT 实际目录调整 `baseUrl` 与 URL 版本参数,但保持参考主题的加载语义和 module mapping 结构。
- 将当前仍有业务消费者的模块纳入 mapping:main、imgbox、page-navs、section-navs、comment、message、input-expand、mini-upload、page-template、page-edit、author、poster-share、captcha、pay、swiper、dplayer、lazySizes、clipboard、svg-icon、enlighter、translate 等;每个 mapping 只指向一个真实文件,不复制第二份实现。
- 删除当前仅为 `AksrtFrontend`/`aksrt.tbquire` 服务的映射和 fallback。
2. **重写 `assets/js/runtime/main.js`**
- 以参考主题 `zibll/js/main.js` 的全局顺序为骨架:顶层建立 `_win.bd`/`_win.window`,注册 body/document 委托,定义全局 API 与插件,定义 `z_auto_fun`,暴露 `window.auto_fun = debounce(z_auto_fun, 100)`,顶层首次调用 `auto_fun()`,最后执行单一 `$(document).ready(...)` 首屏逻辑。
- 将 AKSRT 当前 `aksrt-runtime.js` 中仍需要的 declarative action、确认、通知、上传/安全/站点功能直接并入该唯一运行时或对应 `tbquire` 模块,不再通过第二个 runtime 注册表调用。
- 将所有按 DOM 条件加载逻辑集中到 `z_auto_fun()`:支付、消息、输入扩展、上传、海报分享、验证码、复制、tooltip、popover、affix、Swiper、DPlayer、SVG、代码高亮、灯箱等,严格按参考主题调用 `tbquire()`。
- 删除 `AksrtFrontend.register('aksrt-legacy-main', ...)`、`window.auto_fun` 到 `AksrtFrontend.rehydrate()` 的 wrapper、`z_auto_fun()` 中对新 registry 的依赖。
- 将原先局部更新后的 `AksrtFrontend.rehydrate(root, 'ajax'/'modal')` 全部改为参考主题调用顺序:注入完成后触发 `post_ajax.ed`(列表场景)或 `loaded.bs.modal`(远程 modal),随后调用防抖 `auto_fun()`。
- 保留必要的幂等 class/data 标记(如 `is-on`、`swiper-container-initialized`、`dplayer-initialized`),但删除无法再被调用的 registry 保护代码。
3. **删除新生命周期核心**
- 删除 `assets/js/rehydrate.js`。
- 将 `assets/js/aksrt-runtime.js` 中仍需的实际业务行为迁移到 `runtime/main.js` 或对应按需模块后,删除该文件及其作为公共入口的职责;不得留下空壳 `aksrt-runtime` 适配层。
- 清理所有 `AksrtFrontend.register/boot/rehydrate`、`aksrt.autoFun`、`aksrt:ready`、`aksrt:rehydrated` 引用和注释。
## 二、公共构建与 PHP 入队重构
1. **调整 `tools/build-public-assets.mjs`**
- 不再把整个 canonical 模块集合拼成一个 `public.js` 再依赖 `AksrtFrontend`。
- 以参考主题的独立 loader + 独立动态模块为构建图;对仍需生成的压缩文件只从唯一源码生成,避免 source/min 双 owner。
- 保留 CSS bundle 构建;JS 构建只生成 loader/main 及实际 mapping 所需的 min 产物。
- 生成后执行静态顺序检查,确保 loader 先于 main 动态加载,且没有 `tbquire(['main'])` 之外的第二个 boot。
2. **调整 `src/Core/Scripts.php`、`ScriptsPart1b.php`、`ScriptsPart1c.php` 及 WidgetSystem AssetManager**
- 前台基础依赖改为 jQuery → Bootstrap → `runtime/loader.js`,不再将 `build/public.js` 作为全局生命周期入口。
- 删除所有 routed script 对 `aksrt-runtime` 的依赖;路由模块改由 loader/main 按 DOM 条件加载,直接遵循参考主题的条件加载方式。
- 将 `_win` 作为唯一前台运行时配置对象;把必须保留的 AKSRT 数据字段合并进去,避免同时维护 `_win`、`aksrtData` 两套 DOM 生命周期配置。
- WidgetSystem 只保留一个前端 owner:将 widget 的初始化接入 `auto_fun`/对应动态模块,禁止 `assets/widgets/frontend.js` 与旧 widget-set/main 重复初始化同一节点;同步调整第三方资源注册和依赖。
- 清理已迁移到 loader/main 的独立 enqueue、重复 inline boot、重复条件判断和无实际动作的 Swiper 注册。
## 三、局部更新协议统一为参考主题行为
1. **列表 AJAX:`assets/js/core/ajax-list.js`、`homepage-fixed.js`、`pagination.js`、`comments.js`、`routes/user-center.js`、`author.js`、`widgets/frontend.js` 等**
- 保留现有请求、安全、错误提示和业务响应格式。
- 删除直接 `AksrtFrontend.rehydrate()` 的调用。
- 按场景统一为:before 状态 → HTML parse/replace/append → 触发容器上的 `post_ajax.ed` → `auto_fun()`;保留参考主题的 `ing`/loading/分页/route 顺序。
- 对复杂 panel 继续使用现有 destroy 能力(例如订单中心),但 destroy 只是业务实例清理,不再扩展成第二套全局 lifecycle。
- 明确旧 `.ajax-next` 与新 `data-aksrt-*` 的唯一 ownership;最终删除重复 click handler,控制只由一个 `post_ajax` 实现处理。
2. **JSON AJAX:`comment-ajax.js`、`auth.js`、`follow.js`、`credits.js`、`reward.js`、`messages.js` 等**
- 统一使用参考主题的 `zib_ajax()` 语义:按钮状态、错误/成功提示、`zib_ajax.success` 事件、业务 callback、可选 modal/reload。
- 删除以 `aksrt:rehydrated` 或 registry 事件作为成功信号的路径;只有真正 HTML 注入后才调用 `auto_fun()`。
3. **远程 modal 与历史路由**
- modal HTML 更新改为 Bootstrap `loaded.bs.modal`,由 main 的全局监听调用 `auto_fun()`。
- 保留 `history.pushState/replaceState` 与 Tab `popstate` 行为,但删除 `AksrtFrontend` 路由 API 和独立页面 boot;不引入 PJAX、Turbolinks、InstantClick 或全局 MutationObserver 重初始化。
## 四、灯箱、懒加载和 DOM 标记恢复为参考主题契约
1. **灯箱硬切换**
- 删除 `AksrtLightbox`、`data-aksrt-lightbox*`、`aksrt-lightbox` overlay 和其专用 CSS/配置桥接。
- 从参考主题恢复单一 `imgbox` owner:新增/恢复 `assets/js/runtime/imgbox.js`(压缩产物由构建生成),使用 `Miniimgbox`、`imgbox-container`、`a[data-imgbox]`、`imgbox_data` 和参考主题的幂等标记。
- `main.js` 只在 `_win.imgbox` 且存在目标 DOM 时 `tbquire(['imgbox'])`;不得再有第二个灯箱监听器。
- 更新 `single.php`、`page.php`、gallery/content renderer、图片 helper、相关 CSS 和设置输出,使 DOM 属性、分组、缩略图、zoom/full/play/down 开关与参考主题一致。
2. **懒加载单一 owner**
- 删除 `lazy-image-attr.js` bridge 及其 enqueue/build/test 依赖;lazySizes、`lazybeforeunveil`、`data-src`/`data-srcset`/`data-bg`、兼容属性处理全部由 main/lazySizes owner 负责。
- 保留必要的 PHP 图片 URL 生成和占位图能力,但统一输出参考主题可识别的 DOM,不再并行维护第二套 lazy switch 或 IntersectionObserver 初始化。
- 保留 lazySizes 自有 MutationObserver;禁止把它扩展成全局组件 rehydrate 机制。
## 五、所有业务模块去 registry 化
逐一处理当前已注册的 public/routed 文件(article、comments、comment-fixed-input、pagination、archive、footer-tabbar、misc、follow、enhancements、enhancements2、color-extract、effects、captcha、auth、author、page-navs、section-navs、messages、front-submit、mini-upload、page-template、page-edit、post-share、poster-share、pay-vip、video-player、download-status、ban-appeal、docs-navigation、input-expand、waterfall、yiyan、site-notice、user-badge-actions、widgets 等):
- 删除 `AksrtFrontend.register()` 包装和对 registry API 的防御性分支。
- 将脚本改成参考主题式:被 `tbquire` 加载后立即定义/绑定,或暴露单一模块初始化函数供 main 调用;依赖 DOM 委托和 `is-on`/class/data 标记实现重复调用安全。
- 任何需要窗口/文档监听的模块只绑定一次,并使用参考主题的全局 owner;不新增 destroy registry、root 广播或双入口 ready。
- 保留 AKSRT 的 nonce、REST 权限、输出转义和业务接口,不改变安全边界。
## 六、死代码清理
在改造前后用 `rg/git grep` 建立引用图,仅删除已确认无入口的代码,避免把仍被动态 loader 使用的资源误删:
- 删除 `assets/js/revenue.js`(确认无 PHP、JS、构建和模板引用后)。
- 删除当前 `rehydrate.js`、`aksrt-runtime.js` 生命周期专用代码及其测试夹具。
- 删除 `runtime/main.js` 中已迁移功能的 registry 适配、注释掉的 waterfall 分支、未使用 `auto_fun` 转发代码和重复事件派发。
- 删除确认无 mapping、enqueue、模板或第三方引用的 `runtime/widget-set.*`、`runtime/mini-touch.*`、`waterfall.*` 等 orphan;若仍被参考主题式 loader 使用,则保留唯一映射,不凭文件名删除。
- 删除 `AksrtLightbox`、旧 overlay、runtime/imgbox 的冲突副本和无消费者的 `aksrt.*` API;`_win`、`tbquire`、main、loader、Swiper/DPlayer/lazySizes 只在确认所有消费者迁移后保留。
- 同步清理文档、注释、PHP handles、构建清单、测试中的旧 token,确保仓库不存在“旧入口还能被重新接回”的死配置。
## 七、测试与非视觉验证改造
1. 删除/重写锁定旧架构的测试:
- `global-dom-init-contract.test.cjs` → `legacy-loader-runtime-contract.test.cjs`:验证 loader config、mapping、`tbquire(['main'])`、唯一 boot owner。
- `rehydrate-runtime-contract.test.cjs` → `legacy-auto-fun-contract.test.cjs`:VM 验证 `auto_fun` 防抖、重复调用、`auto_fun` 事件、异常隔离和 DOM 标记幂等。
- `public-bundle-parity-contract.test.cjs`:改为验证 loader/main 构建图,不再断言 `AksrtFrontend`。
- `homepage-dom-contract.test.cjs`:改为验证 HTML 注入后 `post_ajax.ed → auto_fun` 顺序和唯一 delegated owner。
- `lightbox-contract.test.cjs`:改为 `Miniimgbox`/`data-imgbox`/`imgbox` mapping 契约。
- `lazyload-contract.test.cjs`:改为 main/lazySizes 单 owner 契约。
- `legacy-navigation-contract.test.cjs`:验证 loader 的 page-navs/section-navs mapping 和 `auto_fun` 条件加载。
2. 新增/保留 VM 与静态契约:
- loader URL 解析和模块缓存只发生一次。
- 同一 DOM 多次 `auto_fun()` 不重复绑定 tooltip、Swiper、DPlayer、imgbox、上传和 widget。
- `post_ajax.ed`、`zib_ajax.success`、`loaded.bs.modal` 三种事件不混用。
- 全仓库禁止 `AksrtFrontend`、`aksrt.autoFun`、`aksrt:ready`、`aksrt:rehydrated`、第二套 `DOMContentLoaded` 页面 boot。
- 静态检查没有 PJAX/InstantClick/全局 MutationObserver reinit 新入口。
3. 继续运行与改造后的验证:
- Node widget/navigation 契约测试。
- `tools/test-credits-rehydrate.mjs`(改名或改断言为 auto_fun 重入 harness)。
- WidgetSystem PHP audit、asset ledger、业务安全/支付/评论相关测试。
- `node --check` 检查所有改动后的 JS 和构建脚本。
- `php -l` 检查所有改动后的 PHP。
- `node tools/build-public-assets.mjs` 后再次运行 bundle/source parity、旧入口扫描和 `git diff --check`。
## 八、实施顺序与完成标准
1. 先完成只读引用图和测试基线,记录当前失败/通过集合。
2. 先改 loader/main/构建/PHP 入队,建立唯一运行时入口。
3. 再批量去掉所有 registry 调用并统一 AJAX/modal 事件顺序。
4. 再恢复参考主题灯箱和 lazySizes owner,更新模板/CSS/配置。
5. 最后删除确认死代码与旧测试,重建生成产物。
6. 完成标准:
- 前台只有 `loader → main → auto_fun` 一条初始 DOM 路径。
- 没有任何 `AksrtFrontend` 或其双路兼容代码。
- 列表、JSON、modal 三类更新分别遵循参考主题事件协议。
- Swiper、DPlayer、lazySizes、imgbox、WidgetSystem 各只有一个 owner。
- 所有已确认死代码和旧契约被删除或改写。
- 非视觉测试、语法检查、构建和静态引用审计通过;不执行真实视觉验收。
@@ -1,95 +0,0 @@
# 第三方人机验证优化改造计划
约束:不写双路/兼容迁移代码;顺手清理死代码;不做真实视觉效果验收(用语法检查、契约测试、构建产物校验代替)。
已确认决策:范围全做(P0+P1+P2);下载场景 pass 放行 10 分钟;凭据不齐时仅加后台警告、前台保持放行。
## 改动文件总览
| 文件 | 动作 |
|---|---|
| `inc/modules/captcha.php` | 重写校验层 + 瘦身渲染 + 后台警告 + 静态缓存 |
| `assets/js/captcha-front.js` | 新建(从内联脚本迁移,~340 行) |
| `assets/css/shared/captcha.css` | 保留不动(改为进 bundle) |
| `tools/build-public-assets.mjs` | publicScripts 加 captcha-front.js;public.css 加 captcha.css |
| `src/Admin/Schema/SchemaPermissions.php` | 删除 'mode' 死字段(L32-34) |
| `assets/admin/src/schema/sections/permissions/index.js` | 删除 'mode' 死字段(L18 defaults、L50-56) |
| `src/Admin/SchemaProvider.php` | L403 白名单删 'mode';保存后 flush 配置静态缓存 |
| `tests/widget-system/captcha-cloud-contract.test.cjs` | 新建契约测试 |
不动的文件及原因:`aksrt-header-userbox.php`(toast-contract 测试锁定 signin 接线)、`data-aksrt-download-captcha` 挂载点(`templates/parts/resource-download-box.php:467`、`inc/helpers/aksrt-pay.php:1586`)、所有业务 JS(auth/signin/comments/cms-download/user-center/front-submit 只消费 `AKSRTCaptchaState.scenes[scene]` 与 mount 内 token input,接口不变即零改动)。
## 1. inc/modules/captcha.php — 校验层
**P0-1 腾讯云改走 WP HTTP API**:`aksrt_verify_tencent()` 删除 curl 块(L374-390,含 `CURLOPT_SSL_VERIFYPEER => false`),改用 `wp_remote_post('https://captcha.tencentcloudapi.com/', ['timeout'=>5, 'headers'=>aksrt_tencent_tc3_sign(...)生成的一维数组, 'body'=>$payload])`,与另外三家一致。签名函数不动。
**P1-5 错误信息收敛**:四家服务商的传输层错误(wp_remote_post 返回 WP_Error、响应不可解析)统一 `error_log('[AKSRT Captcha] {provider}: ' . 详情)` 后返回通用文案 `__('人机验证服务暂不可用,请稍后重试', 'aksrt')`;保留对用户有行动意义的映射文案(腾讯 CaptchaCode 表、阿里 F0xx 表)。腾讯 API Error 分支(签名错误等)也走 log+通用文案。
**P1-4**:阿里云 `SignatureNonce` 由 `uniqid('', true)` 改 `wp_generate_uuid4()`。
**P1-8**:四家 `'timeout'` 统一 5 秒(现 10 秒)。
**P1-6 Turnstile 绑定场景**:`aksrt_verify_captcha($post_data, $scene = '')` 增加第二参数(唯一调用方是 `aksrt_require_captcha`,无散落调用点);Cloudflare 成功响应校验:`action !== 'aksrt-verify'` 或 `cdata !== $scene`(存在才比对,lenient)→ 失败。客户端配套见 §2。
**P0-2 下载 pass 放行(10 分钟)**:`aksrt_require_captcha()` 内,scene 命中放行名单时先查 transient、验证通过后写 transient:
- `aksrt_captcha_pass_ttl($scene)`:仅 `download` 返回 `10 * MINUTE_IN_SECONDS`,其余返回 0(单一代码路径,无散落分支)
- key = `'aksrt_captcha_pass_' . md5($scene . '|' . get_current_user_id() . '|' . aksrt_get_client_ip() . '|' . md5(UA))`,仿 `aksrt_auth_rate_` 既有模式
## 2. assets/js/captcha-front.js — 新建(P2-9 + P1-7 客户端)
把 `aksrt_render_captcha()` 内联 `<script>`(L632-969)整体迁移为静态文件,逻辑不改名不重写:`loadScriptOnce`、四家 `load*`、`AKSRTCaptchaState.scenes[scene].verify/reset` 注册、`setField/resetVerificationState/syncDownloadSiblings` 等原样搬移。仅两处行为修正:
- 极验 `onClose` 空回调改为 `cancelPending('已取消验证')`(P1-7,与腾讯/CF 取消体验对齐)
- Turnstile render 配置加 `cdata: C.scene`(P1-6 客户端配套)
激活模型:扫描 `document.querySelectorAll('[data-aksrt-captcha-config]')`,`JSON.parse(el.getAttribute(...))` 逐个 init;DOMContentLoaded 后再补扫一次并暴露 `window.AKSRTCaptchaState.rescan()` 供未来 ajax 注入使用。保持 vanilla JS、无新依赖。
对应 PHP 渲染端瘦身:`aksrt_render_captcha()` 只输出
1. 原 wrapper 结构不变(`.aksrt-captcha-wrap` + `#uid` box + `#vid` captcha_verified 隐藏域),
2. wrapper 上挂 `data-aksrt-captcha-config`(`esc_attr(wp_json_encode($js_config))`),
3. 删除内联 `<script>` 与逐次 `<link rel="stylesheet">`。
TOKEN_FIELDS 常量、i18n 字符串原样进 JS 文件(与仓库其它 bundle 内硬编码中文一致)。
## 3. 构建与后台(P2-10 + bundle 接线)
- `tools/build-public-assets.mjs`:`publicScripts` 追加 `'assets/js/captcha-front.js'`;`cssBundles['public.css']` 追加 `'assets/css/shared/captcha.css'`。走既有 bundle 版本机制,无需新增 enqueue 代码。
- `SchemaPermissions.php`:删除 'mode' 字段定义(L32-34,单选项 always 的死字段)。
- `assets/admin/src/schema/sections/permissions/index.js`:删除 `mode` 的 defaults(L18)与字段定义(L50-56)。
- `SchemaProvider.php` L403:归一化白名单去掉 `'mode'`;`write_section_settings()` 更新 `aksrt_captcha_config` 后调用新助手 `aksrt_captcha_config_reset()` 清静态缓存(function_exists 守卫,与现有 captcha 专用块并排)。
- DB 里残留的 `mode` 键不做迁移清洗(读路径白名单已忽略它,符合"不做迁移"约束)。
## 4. inc/modules/captcha.php — 后台警告与缓存
- **P0-3**:新增顶层 `add_action('admin_notices', ...)`:`manage_options` 权限 + `enabled` 为真 + `active_provider` 非空 + `aksrt_captcha_provider_is_configured()` 为假时,输出 `notice-error` 横幅,文案指向「功能&权限 → 人机验证」补全配置(复用现有 misconfig 提示文案)。前台行为不变(保持放行)。
- **P2-11**:`aksrt_get_captcha_config()` 加 `static $cache`;新增 `aksrt_captcha_config_reset()`;写入路径(SchemaProvider)调用 reset。`aksrt_get_captcha_status_summary()` 的 12 场景循环随之受益。
## 5. 新契约测试 tests/widget-system/captcha-cloud-contract.test.cjs
仿 toast-contract 的正则断言风格:
- `captcha.php`:不匹配 `curl_init|CURLOPT_SSL_VERIFYPEER|machine-verification|slidercaptcha|imagecaptcha`;匹配 `wp_remote_post`(≥4 处)、`wp_generate_uuid4`、`'timeout'\s*=>\s*5`、`aksrt_captcha_pass_`;渲染函数不匹配 `/<script|<link/`(内联抽取完成)
- `captcha-front.js` 存在且含 `AKSRTCaptchaState`、`data-aksrt-captcha-config`
- `build-public-assets.mjs` 的 publicScripts 含 captcha-front.js、public.css 含 shared/captcha.css
- `SchemaPermissions.php` 与 admin `index.js` 的 captcha 段不匹配 `mode`
- `SchemaProvider.php` 不匹配 `'mode'`
## 6. 验证(不做视觉验收)
1. `php -l` 全部改动的 PHP 文件
2. `node --check` captcha-front.js
3. `node tools/build-public-assets.mjs` 重建 bundle
4. `grep` 全仓清扫:无 `curl_init`、无 `'mode'`(captcha 相关文件)、无 legacy 标记
5. `node --test tests/widget-system/*.test.cjs` 全量跑,预期仅存量 page-display-contract 一项失败(与本次无关,已验证在 HEAD 上同样失败)
## 明确不做
- 不做视觉验收/浏览器实测(按要求)
- 不写新旧配置迁移、不清洗 DB 残留 `mode` 键
- 不动腾讯 trerror 容灾票据策略(维持拒绝)
- 不做阿里云签名 V3 升级、Turnstile appearance/retry 配置项(列为后续可选项)
- 不动 `ExtensionController` 的 extensions 状态端点(SPA 未消费属另一话题)
## 建议提交拆分(如需提交)
1. `fix(captcha): use WP HTTP API with TLS verification for tencent`
2. `feat(captcha): short-lived download pass + turnstile binding + error hardening`
3. `refactor(captcha): extract inline runtime to captcha-front.js bundle`
4. `chore(captcha): drop dead mode field, config cache, contract tests`
@@ -1,47 +0,0 @@
# 侧栏空槽/挤压修复(全站,单一决策源)
## 根因
"是否渲染侧栏列"有两套脱节的决策:
- **body 布局类**(`site-layout-1/2/3`,决定 CSS 留白)来自 `aksrt_is_show_sidebar_filter()`,但"小工具是否为空"检查只对 `is_single()` 生效(`inc/helpers/template-tags/site-layout.php:150-152`)。
- **aside 渲染**各自为政:固定首页 `aksrt_homepage_render_sidebar()` 额外检查小工具(`inc/helpers/homepage/content.php:146-162`),空时整个不渲染;`sidebar.php`(page/search/archive 用)却渲染空列 div("legacy parity")。
于是"开关开 + 无小工具":首页 body 仍是 `site-layout-2` → `layout.css:156-162` 保留 `margin-right: 侧栏宽+15px` → 主内容挤压、右侧 326px 空槽;其他页面则是空列占位同样挤压。后台关开关的路径本身自洽(layout-1),但因上述不一致,任何"列被保留而无内容"的场景都出现挤压。
参考 Zibll(`zib-theme.php` 判断 + `sidebar.php:14-16` 早退 + `main.css` 留白只挂 `site-layout-2/3`):判断集中一处,body class 承载几何,渲染与判定同源。AKSRT 骨架已具备,只缺"小工具为空"一维的全局化。
## 修改
### 1. `inc/helpers/template-tags/site-layout.php` — 核心(覆盖所有页面)
- `aksrt_is_show_sidebar_filter()`:把 `is_single() && !aksrt_has_current_sidebar_widgets()` 提升为无条件 `if (!aksrt_has_current_sidebar_widgets()) { return false; }`(空侧栏列 = 空槽,全部场景折叠为 layout-1;single 既有行为不变,home/category/tag/search/page/日期归档一并对齐)。
- `aksrt_resolve_sidebar_id()`:把 `is_home() || is_front_page()` 分支移到 `is_page()` 之前——静态首页目前解析成 `page_sidebar_{id}` 但渲染器输出 `home_sidebar`,判定/渲染不匹配;重排后一致(该函数仅 sidebar.php 与小工具检查两处调用,重排安全)。
### 2. `inc/helpers/homepage/content.php` — 删双路
- 删除 `aksrt_homepage_has_sidebar()`(修复后与 `aksrt_is_show_sidebar()` 完全等价);`aksrt_homepage_render_sidebar()` 守卫直接用 `!aksrt_is_show_sidebar()`。
### 3. `sidebar.php` — Zibll 式早退
- 顶部加 `if (!function_exists('aksrt_is_show_sidebar') || !aksrt_is_show_sidebar()) { return; }`(覆盖 `search.php:126` 的无条件 `get_sidebar()`)。
- 删除"Keep the column even if widgets are empty (legacy parity)"注释与空列行为(此后不可能渲染空列:is_show_sidebar 为真 ⇔ 至少一个 zone 有小工具);删除 `elseif ((is_home() || is_front_page()) && ...home_sidebar...)` 死分支与 `$sidebar_id` 兜底。
### 4. `inc/helpers/aksrt-archive.php` + `category.php` / `tag.php` — 死参数清理
- `aksrt_archive_has_sidebar(string $preferred = '', bool $enabled = true)` → `(bool $enabled = true)`(`$preferred` 自注释 "compat only" 后即 unset,死参数);删除 `return !wp_is_mobile();` 死兜底(主题已明确弃用 wp_is_mobile 布局门控,见 site-layout.php:141-143)。
- 更新调用点:`category.php:49`、`tag.php:30` 改为 `aksrt_archive_has_sidebar(!empty($archive_settings['show_sidebar']))`。
### 5. 死类/死代码清理
- `single.php:45`:删除无任何 CSS/JS 引用的 `container--no-sidebar` 输出。
- `front-page.php:14-29`:删除不可达的 `function_exists('aksrt_homepage_render_layout')` 兜底双路(`inc/bootstrap.php:33` 无条件加载),模板直接 `get_header(); aksrt_homepage_render_layout(); get_footer();`。
- `index.php:23-25`:注释"widget activity never changes the body layout after render"与新契约矛盾,更新为单一决策源描述。
- 保留:`layout.css:149-153` 的 `content-layout--no-sidebar` compat 规则(go.php/archive-resources.php/page-ban-appeal.php/page-user-home.php 仍在用)。
### 6. 测试
- `tests/widget-system/post-visual-contract.test.cjs:359-367`:更新测试名与 :364 正则为全局检查 `if \(!aksrt_has_current_sidebar_widgets\(\)\) \{\s*return false;`;新增断言钉住:homepage 渲染守卫用 `aksrt_is_show_sidebar()` 且 `aksrt_homepage_has_sidebar` 已不存在、sidebar.php 早退、resolver 中 home 分支先于 page 分支、archive helper 无 `$preferred` 参数。
- 运行 `node --test tests/widget-system/`(至少 post-visual-contract)与 `php -l` 全部改动 PHP 文件。
## 修复后行为矩阵(每场景验证推演)
| 状态 | body 类 | aside |
|---|---|---|
| 开关开 + 有小工具 | layout-2/3 | 渲染,留白匹配 |
| 开关开 + 无小工具 | **layout-1** | 不渲染,主内容占满(首页/分类/标签/页面/搜索/归档统一) |
| 开关关 | layout-1 | 不渲染 |
## 不改动
SPA schema(`sidebar_home_enabled` 等字段已齐全)、`get_sidebar_config()` 缺省语义、主题的响应式 CSS 隐藏策略(弃用 wp_is_mobile 的既有决定)、single 页既有判定(已被测试钉住,保持原样)。
@@ -1,53 +0,0 @@
## 现状结论(影响方案的既有事实)
- 下载系统集中在 `src/Content/Download.php`:路由前缀硬编码为常量 `ENDPOINT='aksrt-dl'`(同时用于 rewrite 正则 :68 和 URL 拼接 :226)、TTL 硬编码 `TOKEN_TTL=600`(:12)、`max_uses` 固定写死 1(:422)、`expires_in` 返回固定值(:242)。
- 扣次在"点击兑换"时发生(`Download.php:286-292`),无 HEAD 特判、无断开检测,需按需求重构。
- 过期/耗尽已是 `fail_410()`(HTTP 410 + wp_die 提示"回到资源页重新获取"),**不自动生成新链接** —— 该行为现状已满足,保持不变。
- `aksest_dl_token` 表(`src/Core/DBMigratorPart2.php:133-150`)的 token 列已是 `PRIMARY KEY`(即唯一索引),重复密钥不可能入库;需补的是 issue_token 插入失败时的换号重试。
- 设置体系是双端 schema:PHP 权威在 `src/Admin/Schema/*.php`,Vue 镜像在 `assets/admin/src/schema/sections/**`,菜单在 `assets/admin/src/constants/sections/permissions.js`,REST 用泛型路由自动生效。"功能&权限"= SPA 内 permissions 域。
- `Auth\Router::maybe_flush_rewrite_rules()`(:78-84) 已有"版本标记式 flush_rewrite_rules()"先例,可直接套用。
## 一、新增设置分区(permissions 域 → "下载配置")
存储为独立 option `aksrt_download_config`(沿用 captcha/moderation 的 standalone 模式),三个字段:
| key | 类型 | 默认 | 校验 |
|---|---|---|---|
| `url_alias` | input | `download` | 必填;trim 后非空;仅允许 `[A-Za-z0-9_-]`(禁中文/空格// \ ? # & 等);≤48 字符;拒绝保留字黑名单(wp-json、wp-admin、wp-content、wp-includes、admin、login、logout、register、lostpassword、feed、search、author、page、comments、sitemap、sign 等,防路由冲突/404) |
| `default_max_uses` | number | `1` | min 1 / max 100 |
| `ttl_minutes` | number | `10` | min 1 / max 1440 |
修改文件:
1. **`src/Admin/Schema/SchemaPermissions.php`**:新增 `download` section(version 1.0.0,option_key `aksrt_download_config`,单组 basic 三字段,含中文标签/help 说明"完整下载才计次、到期或耗尽后 410 需重新获取")。
2. **`src/Admin/SchemaProvider.php` `validate()`**:仿照 payment/withdrawal 特例先例(:269-292),对 `permissions/download` 增加中文错误消息校验块(空值/非法字符/过长/保留字)——422 错误经前端 `message.error('保存失败: …')` 弹窗呈现,满足"后台弹窗提示"。
3. **`src/Settings/Settings.php`**:`STANDALONE_OPTION_KEYS` 追加 `aksrt_download_config`(导入导出完整性 + 缓存失效覆盖)。
4. **`assets/admin/src/schema/sections/permissions/index.js`**:镜像新增 `download` 分区(defaults 与 PHP 完全一致)。
5. **`assets/admin/src/constants/sections/permissions.js`**:追加子页 `schemaPage({ key:'permissions-download', route:'/permissions/download', label:'下载配置', section:'download' })`,置于附件配置之后。泛型 REST 路由与 SchemaPage 无需改动。
## 二、Download.php 改造(`src/Content/Download.php`)
1. **动态前缀**:删掉 `const ENDPOINT`;新增私有 `endpoint()` 读配置(无效/被污染时回落 `download`);`register_rewrite()` 用 `preg_quote($endpoint)` 拼正则(:66-72);URL 拼接(:226)同源。
2. **别名变更刷新路由**:仿 `Auth\Router` 版本标记法——`init` 尾部挂 `maybe_flush_rewrite()`(priority 99):比较 `AKSRT_VERSION + 当前别名` 与 option `aksrt_dl_rewrite_version`,不一致则 `flush_rewrite_rules(false)` 并回写。导入/重置/直接改库都覆盖,无双路兼容逻辑。
3. **动态 TTL / 次数**:删掉 `TOKEN_TTL` 常量引用;`issue_token()` 写入 `max_uses = 配置.default_max_uses`、`expires_at = now + 配置.ttl_minutes*60`;`rest_request` 的 `expires_in` 改为动态秒数。配置读取做 clamp 兜底(别名非法回落 download、次数 1..100、分钟 1..1440)。
4. **完整下载才扣次**:
- 将扣次(UPDATE used_count)与文章计数 `_aksrt_res_download_count`(:286-300) 移到交付成功之后的新方法 `consume_token()`;UPDATE 加原子条件 `WHERE token=%s AND used_count < max_uses`,并发下计数封顶不超发。
- `stream_local_file()` 改为返回是否完整送达:输出循环中 `connection_aborted()` 为真立即中断并返回 false;收尾再核验一次。仅当完整送达且非 HEAD 才 `consume_token()`——网络波动中断不消耗次数,用户可重新点同一链接续下。
- **HEAD 探测**:`REQUEST_METHOD === 'HEAD'` 时照常走校验/输出 headers 但不扣次。
- **外链 302 源**(远端主机无法感知"是否下完"):把 302 交接视为一次交付,重定向发出后扣次(HEAD 时同样跳过)。此为唯一可实现语义,计划中明示。
- 过期/耗尽仍走现有 `fail_410()`(改动前后行为一致)。
5. **密钥冲突重试**:`issue_token()` 插入失败时用全新随机串重试至多 3 次(PRIMARY KEY 已保证唯一,此为极低概率兜底;表缺失仍在入口提前返回 WP_Error 不进循环)。
## 三、清理与文档
- 删除不再使用的 `Download::ENDPOINT` / `Download::TOKEN_TTL` 常量及全部残留引用(实现时全库 grep 复核;已知其余命中仅为 CSS 类名巧合,不动)。
- 更新 `CLAUDE.md:132` 文档行:rewrite 前缀改为"可配置别名(默认 download)",并补充"完整送达才计次"契约。
- 不写任何迁移/双路:现有表结构不动,存量过期 token 由每日 cron 自然清理。
- **关于唯一索引**:PRIMARY KEY(token) 本身即唯一索引且已存在(不加冗余 UNIQUE KEY,也不做表结构变更——与"不写迁移"一致),以插入重试落实需求的防冲突意图。
## 四、验证
1. 新增 Node 契约测试 `tests/widget-system/download-config-contract.test.cjs`:断言双端 schema 的字段/默认值/option_key 一致、菜单子项注册、Download.php 关键契约(动态 endpoint、流式完成后才条件 UPDATE 计次、HEAD 跳过、410 不自动续签、无 ENDPOINT/TOKEN_TTL 常量残留)。
2. `node --test tests/widget-system/download-config-contract.test.cjs` + 跑现有相关契约测试确认无回归。
3. `php -l` 全部改动的 PHP 文件;`php tools/audit-admin-contracts.php` 通过双端合同审计。
4. `cd assets/admin && npm ci && npm run build`,确认 build 产物完整(PHP 端有 chunk 缺失探测)。
5. 视觉不做真实验收(按约定跳过 Docker 对比环境)。
@@ -1,72 +0,0 @@
# 评论区 Zibll 1:1 全量复刻计划
目标:将 AKSRT 评论区渲染层(DOM、类名、布局、CSS、交互行为)替换为与 Zibll 完全一致,不保留旧类名、不写兼容/迁移代码。现有 REST 后端(`src/Core/CommentActions.php`)保留,仅前端选择器随之更新。
## 参考源(Zibll 侧,只读对照)
- `zibll/template/comments.php`(整体结构 L21–210)
- `zibll/inc/functions/zib-comments-list.php`(`zib_comments_list` 回调 L14、分页 L569–678)
- `zibll/inc/functions/functions.php` L1775 `zib_get_input_expand_but()`(表情/代码/图片/链接/快捷回复工具栏)
- `zibll/css/main.css` L9411–9660(桌面端)+ L12470–12560(移动端)
- `zibll/js/comment.js`(交互行为参照)
- `zibll/img/smilies/*.gif`(约 100 个表情资源)
## 改动清单
### 1. 评论列表项 DOM — `inc/helpers/comments/item.php` 重写
输出 Zibll 原始结构(去掉所有 `aksrt-comment__*` 类):
```
li.comment#comment-{id}(用 WP comment_class(),含 depth-N)
└ ul.list-inline > li.comt-main#div-comment-{id}
├ div.comment-header.mb10 > div.author-box.flex.ac
│ ├ span.avatar-img.comt-avatar > img.avatar(含嵌套 ul.avatar-icontag 徽章位)
│ ├ <name>(置顶 badge.badge-topping.jb-blue + a.text-ellipsis.font-bold / b.mr6 + 作者 badg)
│ └ a.action.action-comment-like.muted-2-color.flex0.ml10(svg like + <count>)
└ div.comment-footer
├ div.mb10.comment-content#comment-content-{id}
└ div.comt-meta.muted-2-color
├ span.comt-author(time-ago,title=完整时间)· IP 属地 badg · span @父级作者
├ span.badge-approve > span.badg.c-red.badg-sm「待审核」
├ span.reply-link > a.comment-reply-link(data-commentid / data-postid)
└ span.dropdown.drop-fixed-sm.padding-6(menu_2 ⋮ 菜单:置顶/编辑/通过/删除,wp-ajax-submit 类)
子级:li.comment > ul.children > li.comment…
```
保留 Zibll 的自定义语义标签 `<name>` `<badge>` `<text>` `<count>` 以确保 CSS 选择器 1:1 命中。置顶/点赞/神评等数据仍取自现有 `inc/helpers/comments/actions.php`。
### 2. 评论区外壳与表单 — `inc/helpers/aksrt-comments.php` + `comments/form-parts.php` 重写
- 外壳:`div.theme-box#comments` > `div.box-body.notop > div.title-theme`(+ `<small>共N条</small>`/抢沙发)> `div.no_webshot.main-bg.theme-box.box-body.radius8.main-shadow`
- 登录门槛:`div.comment-signarea.text-center` > h3 + 登录/注册按钮 + 社交登录(复用现有 oauth 输出,仅换外壳类名)
- 移动端假输入:`div.flex.ac.jsb.virtual-input`(`fixed-input="#respond"`)
- 表单 `div#respond.mobile-fixed > form#commentform`:
- `div.flex.ac > div.comt-title.text-center.flex0.mr10`(comt-avatar 60px + 昵称)+ `div.comt-box.grow1`
- `div.action-text.mb10.em09.muted-2-color`(回复@xxx)
- `textarea.form-control.grin#comment`(Ctrl+Enter 提交)
- `div.comt-ctrl.relative`:comt-tips(隐藏字段+nonce)、comt-tips-right(`a.but.c-red#cancel-comment-reply-link` 取消 + `button.but.c-blue.pw-1em.comment-send#submit`)、comt-tips-left(`span.dropup#comment-user-info` 游客姓名/邮箱/网址 line-form 下拉 + 工具栏)
- 工具栏按 Zibll `zib_get_input_expand_but()` 结构:`span.dropup.relative > a.but.btn-input-expand.input-{smilie|code|image|link|quick}.mr6` + `div.dropdown-menu`(表情面板 `div.dropdown-smilie.scroll-y.mini-scrollbar > a.smilie-icon[data-smilie] > img`),按现有设置项开关
- 列表:`div#postcomments > ol.commentlist.list-unstyled`,过滤器 `div.comment-filter.tab-nav-theme.flex.ac.jsb`(最新/最热 + 只看作者)放在 commentlist 之前、#postcomments 内
- 分页:AJAX 模式 `div.theme-pagination.lazyload.ias-pagenav.pagenav > div.order-ajax-next > a.ias-btn 加载更多`;数字模式 `div.pagenav` + paginate_links(>8 页带 `a.pag-jump` 跳页 input)
- 空状态:`zib_get_null()` 同款 `img/null.svg` 结构
### 3. CSS 全量移植 — `assets/css/frontend/comments/`
- 把 `zibll/css/main.css` L9411–9660 与移动端 L12470–12560 规则逐条移植为新文件(按现有 shell/list/form/mobile 拆分或单文件 `zibll-comment.css`),选择器保持原样(`#comments .comt-title`、`#postcomments .commentlist > .comment > .list-inline .comment-footer {margin-left:65px; margin-top:-25px}`、`.children {margin-left:86px}`、`.children .children {margin-left:23px}`、`.smilie-icon {width:30px!important}` 等)
- 依赖的通用变量(`--main-shadow/--main-border-color/--main-radius/--main-bg-color`)与基础类(`.avatar-img`、`.but`、`.badg`、`.line-form`、`.pagenav`)核对 AKSRT 是否已有;缺的补进对应基础样式文件,值与 Zibll 一致
- **删除** 现有 `comments/shell.css`、`comments/mobile-item.css`、`comment-tools.css` 中所有旧评论样式与 `--aksrt-comment-*` 变量,无兼容层
- 保持 enqueue 结构(`src/Core/ScriptsPart1b.php` / `ScriptsPart1c.php`)指向新文件
### 4. JS 选择器对齐
- `assets/js/core/comments.js`、`comment-ajax.js`、`comment-tools.js`、`comment-fixed-input.js`、`comment-move-form.js`:全部选择器改为 Zibll 目标(`.commentlist`、`.comment-reply-link`、`#div-comment-{id}`、`.comment-send`、`#respond.mobile-fixed`、`[fixed-input]` 等),提交后的局部插入逻辑改为 Zibll 同款(prepend 到 `.children` 或 `.commentlist`,`slideDown`,15s 冷却倒计时按钮禁用)
- REST 端点与 nonce 机制不变;AJAX 分页/排序沿用现有 fragment 管线,仅换容器选择器
### 5. 资源(按用户确认)
- **删除 `assets/img/smilies/` 中现有全部旧表情包资源**,从 `zibll/img/smilies/` 拷贝全套 gif 替换;`content.php` 的 `[g=]` 转换与 data-smilie 表情面板统一指向该目录的 Zibll 文件名列表
- 空状态 `null.svg`、点赞/置顶/菜单等 svg 图标按 Zibll 形状补齐到 `assets/img/`
### 6. 清理与验证
- 全库 grep `aksrt-comment__`、`--aksrt-comment-`、`.comments-area`(评论语境)确保零残留(用户中心页的评论条目不在本次范围)
- 检查 `tests/widget-system/post-visual-contract.test.cjs` 是否引用旧评论类名,如有则同步更新契约
- `php -l` 校验全部改动 PHP 文件;运行 `node --test tests/widget-system/post-visual-contract.test.cjs`
- 桌面+移动端断点目测核对 Zibll 截图/本地对照环境(`tests/visual/README.md`)
## 不做的事
- 不写任何双路渲染、旧类名兼容或数据迁移代码
- 不动 `src/Core/CommentActions.php` REST 逻辑、管理后台 Vue 设置页(除非契约字段需对齐)
- 不动论坛/商铺评论(Zibll 中亦为独立模块)
@@ -1,51 +0,0 @@
## 修复方案
### 1. 删除废弃的静态 action 入口
修改 `assets/js/runtime/main.js`:
- 完整删除全局 `_win.bd.on('click', '[data-action]', ...)` 委托。
- 完整删除仅被该委托调用的 `action_ajax()` 函数。
- 保留紧随其后的独立 `[ajax-action]` 处理器以及其他合法的 `data-action` 属性读取。
- 不创建 `assets/action/action.php`,不增加兼容代理,不把旧协议映射到新接口。
仓库内文章互动、评论点赞、关注等已有 WordPress AJAX/REST 实现,因此这是单一路径硬切换。
### 2. 精确同步运行时压缩产物
修改 `assets/js/runtime/main.min.js`:
- 从唯一 `_win.bd.on("click","[data-action]"` 起点,精确删除到下一个 `_win.bd.on("click","[ajax-action]"` 起点之前的旧压缩块。
- 不执行全量前端构建,以免覆盖或重新格式化其他已有运行时产物。
- 删除后确认 `[ajax-action]` 处理器仍存在,`action_ajax` 和 `/action/action.php` 均消失。
当前该文件虽然出现在状态列表中,但内容与 HEAD 完全一致,因此可安全形成仅针对旧块的内容差异。
### 3. 增加硬切换契约测试
新增 `tests/widget-system/runtime-action-hard-cut-contract.test.cjs`:
- 同时检查 `main.js` 与 `main.min.js`。
- 断言旧全局 `[data-action]` 委托不存在。
- 断言 `action_ajax` 不存在。
- 断言 `/action/action.php` 不存在。
- 断言独立 `[ajax-action]` 处理器仍保留且只有一份。
- 不禁止整个仓库使用 `data-action`,避免误伤下载、弹窗和其他新业务属性。
### 4. 验证
- 运行新增的 runtime action 契约测试。
- 运行全部 `tests/widget-system/*.test.cjs`。
- 使用 `rg` 检查源码和压缩产物中不存在 `action/action.php`、`action_ajax` 和旧委托。
- 执行 `git diff --check`,确认没有格式问题。
- 不做真实视觉验收,不恢复静态 PHP 直连入口。
### 5. 提交
从当前 `origin/main` 创建 `worktree/fix-legacy-action-404` 分支,只提交:
- `assets/js/runtime/main.js`
- `assets/js/runtime/main.min.js`
- `tests/widget-system/runtime-action-hard-cut-contract.test.cjs`
现有其他 CSS、JavaScript 状态项和 `.zcode/plans` 文件不进入提交。验证通过后推送该分支到远端。
@@ -1,51 +0,0 @@
## 目标
在后台「AKSRT → 功能&权限 → 下载配置」中新增总开关 `enabled`(switch,**默认开启**)。开启 = 现有本站 token 中转下载(`/download/{token}`,别名/次数/有效期生效);关闭 = 直连模式,下载按钮直接渲染原始下载地址,不再生成本站中转链接。**权限门控保留**(未登录/未购买/非会员仍显示解锁提示,已确认)。不写迁移、不留死代码、不做真实视觉效果验收。
## 现状(调研结论)
- 「下载配置」是 SPA 通用 schema 页(`SchemaPage.vue`),PHP schema 在 `src/Admin/Schema/SchemaPermissions.php:73-85`(option `aksrt_download_config`),前端镜像在 `assets/admin/src/schema/sections/permissions/index.js:246-269`
- 下载链路:按钮 `data-action="aksrt-download"` → `POST /aksrt/v1/download/request`(`src/Content/Download.php:215`)签发 token → 返回 `home_url('/别名/{token}')` → rewrite + `maybe_serve_download()` 中转(本地流式/302 外链)
- 发现死代码:`rest_policy()` + `/download/policy` 路由(全库无调用方)、`aksrt_is_download_alone_page_enabled()`(`inc/helpers/aksrt-pay.php:921`,无调用方)
## 变更清单
**1. `src/Admin/Schema/SchemaPermissions.php`(PHP 权威 schema)**
- download section version 1.0.0 → 1.1.0
- groups 重构(仿 login/moderation 模式):新 `basic` 组「下载总开关」,字段 `enabled`(switch,default true,help 说明「关闭后下载采用直连,下方链接规则不生效」);原 `url_alias`/`default_max_uses`/`ttl_minutes` 移入 `rules` 组「链接规则」,各加 `dependsOn: {key: 'enabled', value: true}`
**2. `assets/admin/src/schema/sections/permissions/index.js`(前端镜像)**
- `download.defaults` 增加 `enabled: true`;groups 同步 PHP 双组结构(switch + dependsOn);更新 description
**3. `src/Content/Download.php`**
- 新增 `public function is_enabled(): bool`:读 `aksrt_download_config`,`enabled` 键缺失视为开启(默认 true),否则 `!empty()` 判定(与 PostDisplay 等现有默认 true 惯用法一致)
- 关闭时提前 return:`register_rewrite()`(不注册路由)、`register_routes()`(不注册 `/download/request`)、`maybe_serve_download()`(旧 token 链接失效,含 `?aksrt_dl_token=` 直查)
- **删除死代码**:`rest_policy()` 方法与 `/download/policy` 路由注册
- token 签发/流式输出等开启路径逻辑不动(现有契约测试正则不受影响)
**4. `templates/parts/resource-download-box.php`**
- 顶部计算 `$direct_download`(`class_exists` 守卫 + `is_enabled()` 取反)
- 下载源循环:直连模式且非 need_login 时,渲染 `<a href="{原始url}" target="_blank" rel="nofollow noopener">`(保留网盘识别徽章/着色),提取码旁复用全局 clipboard 委托渲染复制按钮(`data-clipboard-text`,main.min.js 已全局处理);need_login 分支不变
- 直连模式下 `$captcha_active` 置 false(验证码容器成死 UI)
- 独立下载页入口(`down_alone_page`)是独立功能,不与本开关耦合
**5. `inc/helpers/aksrt-pay.php`**
- `aksrt_get_paid_download_actions_html()`:直连模式渲染 `<a class="but ...">` 直链替代 token 按钮;提取码/复制按钮逻辑不变
- **删除死代码** `aksrt_is_download_alone_page_enabled()`
**6. `tests/widget-system/download-config-contract.test.cjs`(扩展契约测试)**
- 字段对齐测试加入 `enabled => true`(PHP)/ `enabled: true`(JS)
- 新增断言:Download.php 含 `is_enabled` 守卫;`/download/policy` 已移除;直连模式下盒模板与付费卡函数含直链 `<a>` 渲染分支
## 行为语义(写入代码注释与测试预期)
- 默认开启,存量站点零感知(不迁移 option,键缺失即 true)
- 关闭后:无下载计次、无免费额度扣减、无下载就绪通知、无验证码——直连语义的自然结果
- 关闭后旧 `/download/{token}` 链接全部失效
## 验证
- `php -l` 检查所有改动的 PHP 文件
- `node --test tests/widget-system/download-config-contract.test.cjs`,并跑 pay-card-contract、post-visual-contract 确认无回归
- `cd assets/admin && npm run build` 重建 SPA(前端 schema 变更需构建生效);若 `assets/admin/build/` 被 git 跟踪则一并纳入变更
- 按要求跳过真实视觉效果验收
@@ -1,33 +0,0 @@
## 目标
把"点关注"从同步跑完整结算链(积分+勋章×3遍+等级,约几十条 SQL)优化为:接口只写关注表立即返回,结算在响应送达客户端之后执行。
## 改动内容(3 个文件)
### 1. `src/User/Metrics.php` — 删除冗余订阅
`on_user_followed()`(Metrics.php:93-98)只做 `reevaluate_medals(following_id)`,而 `BadgeAutoAward::on_follow` 已经评估过该用户——这是同一请求内的第 3 遍重复评估。删除该方法和它的 `add_action` 注册(Metrics.php:34)。`reevaluate_medals` 方法本身保留(点赞/浏览等其他钩子仍在用)。
### 2. `src/User/BadgeAutoAward.php` — 删除对"关注者"的无效评估
`on_follow()`(BadgeAutoAward.php:61-68)同时评估关注者和被关注者,但 `Badges::metric_value` 的全部指标分支(registration_time/checkin/new_post/comment/**followed(被关注数)**/like/views/pay_income/pay_rebate)没有任何一个依赖"关注了多少人"——关注者评估必然零产出。改为只评估 `$following_id`(保留双参数签名以兼容钩子,`unset($follower_id)`)。
### 3. `src/User/Follow.php` — 结算钩子延迟到响应之后
`follow()` 里的 `do_action('aksrt_user_followed', ...)`(Follow.php:50)改为经 `register_shutdown_function` 延迟执行:关闭回调里先调 `fastcgi_finish_request()`(PHP-FPM 下存在)把响应立即送达客户端,再触发钩子跑积分/勋章/等级结算。新增一个 `private static function defer_action(string $hook, int ...$args)` 辅助方法。
- 非-FPM 环境(无 `fastcgi_finish_request`):退化为 shutdown 时机同步执行,行为与现状等价,无回归。
- 不用 `wp_schedule_single_event`:WP-Cron 粒度不可控(禁用循环回执的站点要等系统 cron,积分勋章到账会延迟数分钟),shutdown 方案在所有环境都能即时结算。
- `aksrt_user_followed` 仅 Follow.php:50 一处触发,4 个订阅者全部按传入 ID 工作、不依赖请求上下文,延迟执行安全。
- 效果:关注接口响应路径只剩 WP REST 启动 + 1 次 SELECT + 1 次 INSERT + 1 次 COUNT(全部命中索引),勋章评估从 3 遍降为 1 遍且不再阻塞响应。
## 不在本次范围
前端乐观更新(点击瞬间翻转按钮、失败回滚)——上轮标注为可选项,用户未明确要求;异步化后反馈延迟已大幅下降,且与全站点赞/收藏的交互模式保持一致。需要可后续单独做。
## 验证
- `php -l` 检查 3 个改动文件。
- 浏览器实测:关注点击后按钮/Toast 应明显变快(仅剩接口往返);稍后刷新可见积分/勋章/等级照常到账(结算仍在同一请求生命周期内完成,只是后置)。
- 取消关注路径不受影响(本就无钩子)。
## 提交
单条提交:`perf(user): 关注结算异步化并去除冗余勋章评估`。
@@ -1,48 +0,0 @@
# 修复前台文章卡片无刷新加载出现空位的问题
## 根因
规范分页控件(`aksrt_render_pagination()` 输出的 `.ajax-pag[data-aksrt-pagination]`,内含 `.next-page.ajax-next`)携带了遗留引擎的触发类 `ajax-next`。全站加载的 `main.min.js` 在 body 上委托监听 `.ajax-next` 点击(另有 IAS 无限滚动路径用 jQuery 模拟点击 `.ajax-next/.ias-btn`),导致每次"加载更多"被两套引擎同时处理:
1. **规范引擎**(pagination.js):POST `aksrt_load_more_posts`,把卡片正确插入 `.ajax-pag` 之前;
2. **遗留引擎**(main.min.js `post_ajax()`):抓取整页 `?paged=2` HTML,**先删除 `.ajax-pag`**,把占位加载器、重复的一批卡片和一个新的**块级** `.ajax-pag` div 插到最后一张卡之后。
卡片网格是 inline-block 布局(`width: calc(33.333% - 16px)`),块级分页 div / 重复批次插入流中会强制换行 → 网格中出现空位(即"隔出一个卡片位置"),且第 2 页内容被加载两次。评论分页(`inc/helpers/template-tags/search-meta.php:533`)已用 `order-ajax-next` + `ias-btn` 避开遗留引擎,文章分页是唯一例外。
另经确认:生产环境实际生效的懒加载运行时是 main.min.js 内置版(`assets/js/lazysizes.js` 无任何 enqueue),其 IAS 自动加载走 jQuery 模拟点击,只到得了遗留引擎——所以无限滚动模式目前完全由遗留引擎执行。若只摘掉 `ajax-next` 类而不接管 IAS 触发,无限滚动会失效,因此两处改动必须配套。
## 修改内容(4 个文件)
### 1. `inc/helpers/template-tags/pagination-permissions.php:123`
`.next-page` 包装类的 `ajax-next` → `order-ajax-next ias-btn`:
- 移除遗留引擎的点击触发 token(手动点击与 IAS 模拟点击双双失效于遗留引擎);
- `order-ajax-next` 复用现有按钮样式 `.theme-pagination .order-ajax-next a`(main.css:9659+),**零 CSS 改动**;
- `ias-btn` 保持 main.min.js 的 IAS 匹配器仍能找到元素(其后续 jQuery 点击成为无害空操作),且 pagination.js 的 clickTarget 选择器 `.next-page.ajax-next, .ias-btn` 原生支持,**零 JS 选择器改动**;
- 与评论分页的既有模式完全一致。
### 2. `assets/js/core/pagination.js`
在点击绑定块后新增容器级 `lazybeforeunveil` 监听(仅当服务端输出了 `lazyload` 契约类时挂载):unveil 事件由 main.min.js 内置 lazySizes 原生派发(可冒泡),监听到后设置 `lazyAuto` 并原生 `click()`,把无限滚动自动加载**收归规范引擎**的既有点击管线(复用 loading 守卫、iasMax 上限、按钮状态机):
```js
if (container.classList.contains("lazyload") || clickTarget.classList.contains("lazyload")) {
container.addEventListener("lazybeforeunveil", function (e) {
if (e.target !== container && e.target !== clickTarget) return;
clickTarget.dataset.lazyAuto = "1";
clickTarget.click();
});
}
```
附带修复:评论分页的 IAS 自动加载此前同样到不了规范引擎(jQuery 模拟点击不可见),此监听一并接管。
### 3. `search.php:112-118` — 消除双层包裹
去掉外层冗余 `<div class="posts-row mtn6">`,把 `mtn6` 直接传入 `aksrt_render_post_list(['wrapper_class' => 'mtn6'])`。这样规范引擎的 `previousElementSibling` 目标解析落在真正的卡片网格上,追加的卡片与首页批在同一 inline-block 网格内连续排布(此前新卡片会追加为内层网格块的兄弟节点,卡片模式下在分页边界处出现空位)。ajax-list.js(搜索 tab 切换的实际引擎)用 `querySelectorAll('.ajax-item')` 提取、其替换流程本身就会把卡片拍平到容器层,结构兼容。
### 4. `tests/widget-system/post-visual-contract.test.cjs:238`
- 断言 `/class="next-page ajax-next/` → `/class="next-page order-ajax-next ias-btn/`;
- 新增 `assert.doesNotMatch(php, /next-page ajax-next/)` 锁定规范控件不再携带遗留触发类;
- 新增 `assert.match(js, /lazybeforeunveil/)` 锁定 IAS 接管。
## 验证
- `php -l inc/helpers/template-tags/pagination-permissions.php` 和 `php -l search.php`
- `node --test tests/widget-system/post-visual-contract.test.cjs`
- 浏览器实测(桌面 3 列卡片网格):首页点"加载更多"只发一个 `admin-ajax` 请求、无重复批次、无网格空位、`.ajax-pag` 始终位于网格下方;无限滚动模式每次自动加载一页并在 iasMax 后转手动按钮;搜索页卡片模式追加后与首批网格连续。
@@ -1,46 +0,0 @@
# 评论区硬切换:1:1 复刻 Zibll(不含表情包/快捷回复)
## 现状与结论
- AKSRT 评论区的 PHP 渲染(`inc/helpers/comments/item.php`、`form-parts.php`、`aksrt-comments.php`)与 JS(`assets/js/core/comments.js` 等走 `aksrt/v1` REST)已是自有代码,DOM 与 Zibll 基本一致(`li#comment-{id} > ul.list-inline > li.comt-main`、`.comment-header/.comment-content/.comt-meta`、`#commentform/.comt-title/.comt-box/.comt-ctrl` 均已对齐)。
- **样式仍由 vendored `assets/css/main.min.css` 提供**(拼进全站 `build/public.css`;`comments/shell.css` 头部明文"以 vendor 正版样式为准、禁止复制")——这是本次硬切换的主体:样式归属权切到自有文件,vendor 副本剥离。
- 快捷回复链路完整存在(schema/PHP/JS/CSS),需全链路删除;表情包已被此前的 commit 删除并有契约测试锁定,保持。
- 传输层不动:提交/点赞/编辑/删除/置顶仍走 AKSRT REST(含权限回调),不改回 Zibll 的 admin-ajax 协议;UI 与 DOM 对齐 Zibll。
## 1. CSS 硬切换(主体)
- 以参考主题 `C:\Users\Xing\Desktop\zibll\css\main.css` 为准移植评论区规则(与 AKSRT vendored 副本 diff 后以参考版为准):
- 新建 `assets/css/frontend/comments/layout.css`:桌面主段(AKSRT main.css 约 9273–9510 对应段:`#comments/#postcomments/#respond/#comment/.commentlist/.comt-*/.comment-*/.comment-signarea/.children/.comment-footer 缩进` 等)+ 散点规则(`.action-comment-like.action-animation`、`.comment-content .box-img`、`.comment-content a`、`.comment-content pre`)。
- 填充 `assets/css/frontend/comments/mobile-item.css`(现为空壳占位):移动端 `@media` 内评论规则(`.mobile-fixed/.virtual-input/头像缩进/分页` 等,main.css 约 12208 起)。
- `shell.css` 重写文件头教条(不再"vendor 提供"),保留 identity chip/medal 补充规则。
- **剔除**:`.dropdown-smilie/.smilie-icon/.dropdown-quick-often` 与 `img:not(.smilie-icon)` 类选择器。
- 从 vendored `assets/css/main.css`/`main.min.css` 剥离同批评论区域专属规则(死代码清理、消除双路)。保留:`.comment-widget-item/.comment-mini-lists`(侧栏小部件在非 singular 页使用)及共享选择器(`.pagenav/.theme-pagination/.line-form/.author-box/.avatar-img` 等别处仍用)。
- 先校验 `main.min.css ≙ minify(main.css)`(esbuild dry-run diff,防漂移),剥离后重新生成 `main.min.css`,再跑 `node tools/build-public-assets.mjs` 重出 bundle。
## 2. Markup 1:1 对齐(PHP 小增量)
- `item.php`:点赞按钮对齐 Zibll `zib_get_comment_like`(SVG 图标 + `<text></text><count>N</count>`,保留 AKSRT `data-comment-action` 接线);置顶/批准/菜单/注册图标由 FA 换成 Zibll SVG——新增小助手(如 `inc/helpers/comments/icons.php`)移植 `zib_get_svg` 所需 glyph(like/topping/approve/menu_2/signup);时间/回复链接 tooltip 属性对齐。
- `aksrt-comments.php` + `form-parts.php`:
- `!comments_open()` 时整体不渲染(Zibll 行为;当前会渲染标题+列表)。
- 工具栏渲染 `code→image→link`,补齐缺失的"链接"按钮(新设置 `comment_link`,默认关——Zibll 默认值即 false)。
- 游客 dropup 补"博客地址"字段(新设置 `comment_user_url_s`,默认关,同 Zibll)。
- `comt-title` 登录态昵称旁渲染 AKSRT 身份徽章(对应 Zibll VIP 图标位)。
## 3. 快捷回复全链路删除 + 死代码清理
- PHP:`form-parts.php` quick case 与工具栏分支;`aksrt-comments.php` context 的 `quick_s/quick_phrases`;`PostDisplay.php` 的 `quick_phrases` 字段与 `parse_comment_quick_phrases()`;`SchemaPostList.php` 镜像字段。
- Vue schema:`assets/admin/src/schema/sections/post-list/comment.js` 删 `comment_quick_s/comment_quick_often`。
- JS:`comment-tools.js` 快捷短语插入逻辑;`ScriptsPart1c.php` 删 `aksrtData.commentQuickPhrases`。
- 删除死文件:`assets/js/runtime/comment.js(+.min)`、`input-expand.js(+.min)`(已确认无代码引用)。
- `aksrt-runtime.js` 的 vendor comment 模块 stub 与委托解绑**保留**(vendor `main.min.js` 仍在全国 bundle,防回潮)。
## 4. 设置 schema(前后端镜像)
- 新增 `comment_link`、`comment_user_url_s` 开关(默认 false),镜像到 `SchemaPostList.php` 与 `PostDisplay::get_comment_display_settings()`;`npm run build` 重出 admin SPA。
## 5. 测试
- 更新 `comment-smilies-removal-contract.test.cjs`(文件清单移除已删 runtime 存档)。
- 新增 `tests/widget-system/comment-hard-cut-contract.test.cjs`:自有 comments CSS 含关键选择器清单;vendor main.css/min 不再含评论区选择器、仍含 `.comment-widget-item`;全库无快捷回复 token(`comment_quick_s/quick-reply-item/dropdown-quick-often/commentQuickPhrases/quick_phrases` 等);死文件不存在;markup 契约(`<count>`/SVG、工具栏 code/image/link 且无 quick/smilie)。
## 6. 构建与验证(不做视觉验收)
- `php -l` 全部改动 PHP;`node --test` 相关契约测试;`node tools/build-public-assets.mjs`;`npm run build`(admin)。
- 不启用 Docker 视觉对比环境(按用户要求)。
## 风险与对策
- vendor main.css 手术:先 min-sync 校验;剥离仅限评论专属选择器,逐个 grep AKSRT 模板确认无跨区使用;剥离后选择器清单复核。
- 评论区规则的 CSS 变量(`var(--…)`)与 bootstrap 类来自 bundle 中更早加载的 tokens.css/bootstrap,`aksrt-comments` 在其后加载,无影响。
@@ -1,51 +0,0 @@
# 公开用户主页去参数化 + 全链路无刷新加载
## 目标
`/author/demo/` 的 URL 永远干净(无 `?tab=`、`follow_sub`、`follow_page`、`posts_page` 等),Tab 切换、粉丝/关注子切换、各面板分页全部走 REST AJAX 无刷新加载。单路径实现,不写双路/兼容/迁移代码。
现状:主 Tab 已是 DOM 切换,但 `author.js` 会把 `?tab=` 写进 URL;follow 子切换和 comments/follow 分页是带参整页链接;posts/favorites 分页走 `aksrt_render_pagination()`(numbered 模式产出 `?posts_page=N` 链接;load_more/infinite 走 admin-ajax)。
## 服务端
### 1. 上下文构建 — `inc/helpers/aksrt-user-home.php`
- `aksrt_get_user_home_page_context()` 删除全部 `$_GET` 读取(第 104、134-136 行):`active_tab` 恒等于 `default_tab`,三个 page 恒为 1(保留键,供 REST 覆写);新增 `follow_page => 1`、`follow_sub => 'followers'` 键。
- 新增 `aksrt_render_user_home_pagination(int $total, int $current)`:输出 `<nav class="profile-pagination uh-pagination">` + `<button type="button" class="page-numbers" data-uh-page="N">`(当前页带 `current` 类,含 `‹ ›` 前后页),复用现有 `.profile-pagination` 样式(lists.css:331+)。
- 新增 `aksrt_capture_user_home_panel(string $tab, array $ctx): string`:`locate_template('templates/parts/user-home/panel-{$tab}.php')` + ob_start 捕获(仿 `aksrt_capture_user_center_panel`,aksrt-user-center.php:199)。
### 2. 面板模板重构 — `templates/parts/user-home/`
- 删除 `panels-core.php`,拆为按面板 body 模板:`panel-posts.php`、`panel-comments.php`、`panel-favorites.php`、`panel-credits.php`;`panel-follow.php` 改为纯 body(去掉外层 wrapper,第 21-23 行 `$_GET` 读取改为从 `$args` 取 `$follow_page`/`$follow_sub`)。
- `page-user-home.php`:dashboard 根节点加 `data-user-id="<?php echo (int) $user_id; ?>"`;循环 `$enabled_tabs`,对存在 body 模板的 tab 输出 `<div id="panel-{tab}" class="dashboard-panel…">` wrapper + `get_template_part('templates/parts/user-home/panel', $tab, $ctx)`。
- 各 body 分页统一改用 `aksrt_render_user_home_pagination()`:
- posts/favorites 不再用 `aksrt_render_pagination()`(本页退出全站三模式分页体系);
- comments 不再用 `aksrt_render_profile_pagination()`(该函数保留,用户中心仍在用)。
- `panel-follow.php` 子切换 `<a href="?tab=follow&…">`(第 40、47 行)→ `<button type="button" data-uh-sub="followers|following">`;`paginate_links`(第 63-69 行)→ 新分页渲染器。
### 3. REST 端点 — `src/User/Home.php`(仿 `src/User/Center.php:34-110`)
- `init()` 挂 `rest_api_init`;路由 `GET aksrt/v1/user-home/panel/(?P<user_id>\d+)/(?P<tab>[a-z0-9_-]+)`,query 参数 `page`(int ≥1)、`sub`(followers|following),`permission_callback => '__return_true'`(公开数据)。
- `rest_panel()`:校验用户存在 → `aksrt_get_user_home_page_context()` 重建上下文 → 用请求参数覆写 `active_tab`、对应 page、`follow_sub` → tab 不在 `enabled_tabs`(如 credits 非本人)返回 404 → 捕获 body → 返回 `{success:true, data:{tab, html}}`。
## 前端 — `assets/js/author.js`
- 删除 `updateTabUrl()`(第 15-24 行)与 `resolveInitialTab()`(第 70-73 行):不再读写任何 URL 参数,初始态以服务端渲染的 `is-active`/`data-default-tab` 为准。
- 新增 `loadPanelBody(tab, page, sub)`:`fetch(aksrtData.apiUrl + '/user-home/panel/{data-user-id}/{tab}?page=N(&sub=X)', {credentials:'same-origin', headers:{'X-WP-Nonce': aksrtData.restNonce}})` → `panel.innerHTML = data.html` → `AksrtFrontend.rehydrate(panel, 'ajax')`;per-panel 请求去重防竞态(仿 user-center.js `_aksrtPanelRequest`);`is-loading` 态 + 失败 notyf 提示。
- dashboard 根上事件委托(覆盖 AJAX 注入的新按钮):
- `[data-uh-sub]` → 加载 follow 面板 `sub=X, page=1`;
- `[data-uh-page]` → `btn.closest('.dashboard-panel')` 得 tab,`panel.dataset.uhSub`(默认 followers)得子项,加载对应页。
- URL 永不变更(无 pushState/replaceState)。`follow.js` 是 document 级委托,注入的 minicard 关注按钮自动可用,无需改动。
## 附带清理与 CSS
- `templates/parts/submit/editor-main.php:106-108`:「查看草稿」现指向公开主页 + `?tab=post&post_status=draft`(公开主页从未支持该参数,本就错位)。改为 `add_query_arg('post_status', 'draft', aksrt_get_user_center_tab_url('posts'))`(用户中心支持该过滤,posts-list.php:14-16)。
- `assets/css/routes/user-home.css`:`button.page-numbers` 复位 + hover 态(现 hover 选择器只覆盖 `a.page-numbers`);`.uh-follow__subtab` 改 button 后的复位;`.dashboard-panel.is-loading` 半透明过渡。
## 测试与验证
- 更新 `tests/widget-system/post-visual-contract.test.cjs`:移除 `panels-core.php`/`page_var` 断言(第 39、205、222-224、328-330 行),改为断言:各 `panel-*.php` body 模板存在且调用 `aksrt_render_user_home_pagination`;user-home 模板无 `$_GET`/`add_query_arg`;author.js 无 `searchParams`(防回归契约)。
- `php -l` 全部改动 PHP 文件;`node --test tests/widget-system/post-visual-contract.test.cjs`。
## 明确的行为变化(单路径的直接结果)
- 公开主页 posts/favorites 分页不再跟随后台「文章分页模式」(numbered/加载更多/无限滚动)设置,统一为本页 AJAX 数字分页。
- 刷新/分享/回退永远回到默认 Tab 第 1 页(URL 无状态,需求本身决定)。
- 旧外链 `?tab=follow` 不解析不重定向,按默认 Tab 加载(无兼容代码)。
@@ -1,42 +0,0 @@
# 复刻 Zibll 粉丝/关注用户列表(公开主页 follow TAB)
## 问题根源
`templates/parts/user-home/panel-follow.php:46-65` 手写的列表 markup(`.uh-follow__list/item/avatar/name`)在全仓库没有任何 CSS 定义,头像+用户名以内联文本堆叠显示。列表项也只有头像和用户名,没有 Zibll 的卡片结构。
关键事实:AKSRT 已 vendored Zibll 的 `main.css`(`.author-minicard` 卡片规则、工具类齐全),且 `inc/helpers/aksrt-graphic-card.php:215` 已有 `aksrt_author_minicard()` —— Zibll `zib_author_card()` 的移植版(42px 懒加载头像、用户名+勋章/认证/等级图标、逐项关注按钮、个性签名一行省略、默认文案"这家伙很懒,什么都没有写…"),搜索页在用。**修复 = 粉丝/关注列表改走这条既有链路**,不新写卡片样式。
## 改动清单
### 1. `templates/parts/user-home/panel-follow.php`(核心)
- 列表循环改为 `aksrt_author_minicard((int) $row['user_id'])`,删除手写的 `.uh-follow__item/avatar/name` markup(渲染链路与搜索页用户列表完全一致)。
- 子切换复刻 Zibll 结构(`zib-author.php:407-410`):居中文字链接「粉丝 N | 关注 N」(`list-inline splitters` + 激活 `focus-color` / 非激活 `muted-color`),**顺序改为粉丝在前、默认子列表改为粉丝**(Zibll 逻辑:TAB 名"粉丝"、默认显示粉丝列表,`zib_get_follow_user_list` 默认 `type='followed'`)。链接仍走现有 `?follow_sub=&follow_page=` 路由。
- 空状态图标换心形 SVG(见第 6 条),继续用 `aksrt_render_profile_empty` 组件(对齐 Zibll null-love 爱心语义)。
- 分页保持 `paginate_links` 服务端翻页:Zibll 的 AJAX"加载更多"框架(post_ajax/ajax-next)未移植进 AKSRT(main.js 仅 27 行),且公开主页所有 TAB 统一为服务端渲染 + URL 参数翻页,不为此引入独立 AJAX 体系。
### 2. `inc/helpers/aksrt-graphic-card.php` — `aksrt_author_minicard()`
- 关注按钮改为**服务端渲染真实状态**(复刻 Zibll 逻辑):已登录且 `\AKSRT\User\Follow::is_following(current, target)` 为真时输出 `is-following` class +「已关注」文案。消除每次进页"全部显示关注→异步闪变"的问题。搜索页同样受益。
### 3. `templates/parts/user/author-hero.php`
- hero 关注按钮(L151-163)同样服务端渲染真实初始状态(`is-following` +「已关注」),与卡片按钮同一逻辑。
### 4. `assets/js/follow.js`
- 删除逐按钮 `/follow/status` 轮询(L54-69):服务端已输出真值,不留双路。保留点击 toggle、`updateFollowButton` 状态切换、用户中心取关移除行、计数更新。
### 5. `assets/css/routes/user-home.css` — follow 区样式替换
- `.uh-follow__list { text-align: center; }`:对齐 Zibll pane 的 `text-center`(卡片为 inline-block,自动 3 列居中换行,移动端单列规则 main.css 已有)。
- 子切换:删除旧 pill 按钮样式块(L486-519),替换为 Zibll 风格的少量布局规则(居中、间距),颜色/激活态复用 vendored 工具类。
- 同步删除不再被引用的 `__subtab-count` 等旧规则。
### 6. `inc/helpers/aksrt-user-home.php`
- context 数组新增 `svg_empty_follow`(心形 stroke SVG,风格同现有 `svg_empty_post` 等)。
## 不做的事(明确排除)
- 不引入 AJAX"加载更多"(无框架基础,破坏公开主页统一服务端分页架构)。
- 不动数据层 `src/User/Follow.php`(`get_user_list` 已提供所需 user_id;徽章/签名由 minicard 自行取)。
- 不写任何双路、兼容、迁移代码:旧的手写列表 markup 和 pill 子切换样式直接替换删除。
## 验证
- `php -l` 检查全部改动的 PHP 文件。
- `node --test tests/widget-system/*.test.cjs` 确认不破坏既有契约(无该面板专属测试)。
- 桌面 + 移动宽度下目视核对:卡片 3 列/单列、关注按钮初始状态、子切换、空状态、分页。
@@ -1,34 +0,0 @@
## 目标
将 `inc/core-hooks/` 下的三个分片收口为唯一实现文件 `inc/core-hooks.php`,不保留兼容 loader、旧分片 stub 或双路加载;同时清理有充分证据确认无效的代码,不做真实浏览器/视觉效果验收。
## 实施步骤
1. **合并实现文件**
- 将 `inc/core-hooks/part-1.php`、`part-2.php`、`part-3.php` 的有效实现按现有加载顺序拼接到 `inc/core-hooks.php`。
- 保留现有 `defined('ABSPATH') || exit;` 保护和原有 hook 注册顺序,避免改变 WordPress 回调优先级、字符串回调和匿名闭包的行为。
- 在合并文件中保留评论过滤、SMTP/邮件、分类 term meta、资源归档/搜索、Enlighter 白名单、后台互动列、Gutenberg、付费内容等仍被运行时代码或测试覆盖的实现。
- 去除原文件的 “Shell loader / split into sub-files” 说明和三个 `require_once`,将文件头改为描述单一核心 hooks 文件的注释,避免留下已不存在的拆分结构说明。
2. **清理明确死代码**
- 删除 `part-2.php` 中注册 `wp_head` 调用未定义 `aksrt_print_win_var()` 的 legacy runtime bridge;仓库内只有该调用,没有定义、生产者或测试,且 `function_exists()` 使其当前为空操作。
- 删除 `part-3.php` 中原样返回模板的 `single_template` no-op 过滤器;它不改变模板选择,且没有其他实现依赖。
- 删除随之失去运行时意义的纯注释占位块(评论提交说明),不触碰实际由 `Moderation` 等模块实现的评论校验。
- 不删除后台互动列,尽管它读取旧 meta key;这是可见后台行为,且证据只支持“过时/可能失配”,不足以在本次合并中安全移除或重写。也不删除分类旧字段、资源路由、块注册等看似重复但仍有明确消费者的代码。
3. **删除旧分片**
- 删除 `inc/core-hooks/part-1.php`、`inc/core-hooks/part-2.php`、`inc/core-hooks/part-3.php`,使 `inc/core-hooks.php` 成为唯一实现,不做迁移层或双路兼容。
4. **同步测试引用**
- 将以下契约测试中对分片的直接读取改为 `inc/core-hooks.php`:
- `tests/widget-system/email-system-contract.test.cjs`
- `tests/widget-system/pay-card-contract.test.cjs`
- `tests/widget-system/post-visual-contract.test.cjs`
- 不改测试断言语义,仅修正因文件收口导致的路径。
5. **验证**
- 对新的 `inc/core-hooks.php` 运行 `php -l`。
- 运行上述三个 Node 契约测试,并运行 `tests/widget-system/run.php` 作为已有回归合同检查(若环境依赖导致失败,如实报告)。
- 用 `rg` 检查生产代码和测试中不再残留 `core-hooks/part-*.php` 路径;排除 `.git`、`vendor`、`node_modules` 和历史 `.zcode/plans` 文件。
- 不启动浏览器、不执行真实视觉效果验收,符合请求约束。
## 工作区安全边界
当前未提交修改仅涉及 `assets/css/build/*`、`assets/js/runtime/*` 和 `.zcode/plans/*`,不涉及本次目标文件;实施时只修改上述 core hooks 文件及三个测试文件,不覆盖或清理现有其他改动。
@@ -1,65 +0,0 @@
# assets/vendor 全量迁移 + 全仓去 zibll 化
## 目标布局(类型归位,`git mv` 保留历史)
| 源 | 目标 | 说明 |
|---|---|---|
| `assets/vendor/fontawesome5/` | `assets/fontawesome5/` | css/ + webfonts/ 原结构,`../webfonts` 相对引用不变 |
| `assets/vendor/zibll/css/`(22 文件) | `assets/css/` 顶层 | bootstrap/animate/swiper/main 等,无命名冲突;`main(.min).css` 的 `url(../img/playing.svg)` 迁后恰好仍指向 `assets/img`,无需改 |
| `assets/vendor/zibll/js/**` | `assets/js/runtime/**` | **必须子目录**:assets/js 已有 14 个同名自有文件(author/sign-register/page-navs…)和一个 enlighter/ 目录 |
| `assets/vendor/zibll/img/captcha/` | `assets/img/captcha/` | slidercaptcha.js 运行时按 `_win.uri + '/img/captcha/'` 拼接,新 uri=assets 根后自然命中 |
| `assets/vendor/zibll/img/{medal,smilies}` | 与 `assets/img/` 哈希比对 | 相同跳过、缺失补齐;**内容不同时以当前实际渲染源为准**(medal/smilies 保现有,user_t.jpg/slider-bg.jpg 以 vendor 版覆盖,因 PHP 默认值当前渲染的是 vendor 版) |
| `assets/vendor/zibll/img/playing.svg` 等散图 | `assets/img/` | |
| `assets/vendor/zibll/fonts/`(孤儿 2 文件) | `assets/fonts/` | 按"全部迁移"保留 |
| `js/libs/{DPlayer,dash.all,flv,hls,vanilla-tilt}.min.js` | **不迁移**(智能去重) | 先哈希验证与 `assets/widgets/third-party/` 字节一致(探索已确认大小一致);loader.js paths 改指 `'../widgets/third-party/DPlayer.min'` 等(相对 baseUrl);若任一哈希不符则该文件照常迁入 `runtime/libs/` |
完成后删除 `assets/vendor/`。
## 运行时切换(核心链路 `_win.uri`)
PHP 将 `'uri'` 从 `…/assets/vendor/zibll` 改为 `…/assets`,loader(改造版 RequireJS)以 `baseUrl = _win.uri + '/js'` 解析懒加载模块:
1. `assets/js/runtime/loader.js`:`baseUrl` 改为 `_win.uri + '/js/runtime'`(paths 表 `'libs/swiper.min'`、`'enlighter/enlighterjs.min'` 等相对值全部随之命中,无需逐条改,除 5 个去重库改 `../widgets/third-party/`)
2. `main.js` + `main.min.js`:enlighter CSS 注入 `_win.uri + '/js/enlighter/…'` → `'/js/runtime/enlighter/…'`(swiper CSS 注入 `/css/swiper.min.css` 迁后自然命中,不改)
3. 已核实的死引用(`/css/dplayer.min.css` 404、`/action/action.php`、`/yiyan/…`、paths 中 `shop—main`)保持原状不修不扩
## PHP 引用切换(7 个文件)
- `src/Core/ScriptsPart1b.php:16-17,23,38-41`、`src/Core/ScriptsPart1c.php:35-42,53`:新变量 `$runtime_uri = $base_uri . '/assets'`、`$fa5_uri = $base_uri . '/assets/fontawesome5'`;jquery/bootstrap/loader/main 路径改 `assets/js/runtime/…`
- `inc/core-hooks/part-3.php:52`、`src/Admin/Admin.php:116`:FA5 新路径
- `src/Admin/PageConfigMetaBox.php:275,277`、`src/Settings/DisplayPageGroups.php:96,98`、`src/Admin/Schema/SchemaPageDisplay.php:77,123,138,163,190`:`assets/vendor/zibll/img/` → `assets/img/`
## 全仓去 zibll 清扫(用户已确认含功能性标识符)
先跑全仓盘点(排除 .git、composer vendor、node_modules、tests/visual、assets/admin/build):`zibll/Zibll、zib__、zib_ajax、zibllblock、wp-block-zibllblock、.zib-、_zb、Qinver、zibll.com、子比`,产出替换映射并核对目标名无既有冲突(如 `aksrt_ajax`)。统一替换规则:
- **品牌头注释**:`@Author: Qinver / @Url: zibll.com / @Project: Zibll子比主题` → `@package AKSRT`;swiper 内 `//zibll : 修改内容` 等注释中性化;`main.js:4866` 的 `console.log(' Zibll Theme …')` 及 min 等价物删除
- **功能性标识符重命名**(生产端+消费端原子同步,含 min 文件 sed,改后 diff 抽查只允许替换点变化):
- `zibllblock/*` → `aksrtblock/*`;`zibll_block_cat/var` → `aksrt_block_cat/var`;`.wp-block-zibllblock-*` → `.wp-block-aksrtblock-*`(⚠️ 数据库已发布内容中的旧块名将无法渲染,用户已接受且明确不写兼容层)
- `'zibll' + type` localStorage 键 → `'aksrt' + type`(游客点赞记录重置,已接受)
- `zib__()` i18n 函数 → `aksrt__()`(先定位定义处,全量替换所有调用,含各 .min.js)
- `zib_ajax` 等运行时函数按盘点结果同步改名
- **AKSRT 自有资产**:`assets/css/frontend/zibll-utilities.css → legacy-utilities.css`、`zibll-compat-vars.css → compat-vars.css`、`footer/zibll-shell.css → footer/shell.css`(含 enqueue_module 调用、handle 派生、测试引用);`.zib-widget`(19处)/`.zib-slider` → `.aksrt-widget`/`.aksrt-slider`(CSS + PHP 输出标记 + vendor js 选择器同步)
- **PHP 变量/注释**:`$zibll_uri` 等改名;所触及文件注释中 Zibll 措辞改写为"legacy runtime"
- **admin SPA**:Vue 源内用户可见 zibll 文案改写后 `cd assets/admin && npm run build`
- **例外保留**:`tests/visual/`(docker-compose.zibll.yml、zibll-Caddyfile、baseline)指向外部真实 Zibll 对照主题,属产品名引用,不改
## 测试同步
- `tests/navigation/zibll-navigation-contract.test.cjs`(本地未跟踪):所有 vendor 路径 → 新路径;walk 根改 `assets/js/runtime`(js)与 `assets/css` 顶层(css);新增断言 `assets/vendor` 目录不存在;文件更名 `legacy-navigation-contract.test.cjs`
- `tests/widget-system/{post-visual,toast,page-display,lazyload}-contract.test.cjs`:loader/main 读取路径更新;toast 的 `$zibll_loader_version`/`$zibll_runtime_version` 正则同步新变量名;lazyload `sourceFiles` skip 列表把 `'vendor'` 换成/加上 `'runtime'` 保持语义
- 其他 widget-system 测试中引用 zibll 路径/字样的断言同步更新
## 验证与收尾
1. `php -l` 逐个检查所有改动 PHP
2. `node --test tests/widget-system/post-visual-contract.test.cjs` 等全部受影响 contract 测试 + 更名后的 navigation 测试
3. grep 审计:全仓(按上述排除项)`zibll|Qinver|子比|zib__` 零残留;`assets/vendor` 零引用
4. 手工 QA 清单:首页/文章页/评论区、FA 图标、滑块验证码图、勋章、enlighter 高亮、DPlayer 视频播放
5. 不主动提交;建议单原子 commit:`refactor(assets): vendor 资产迁入标准目录并全仓去除 zibll 标识`(移动+切换+清扫+测试必须同 commit 才能保持可用)
## 风险提示(用户已确认接受)
- 数据库中已存 `zibllblock/*` 块的旧文章渲染损坏(无兼容层)
- 游客 localStorage 点赞/收藏记录重置
- min 文件 sed 属精细操作,逐文件 diff 验证
@@ -1,23 +0,0 @@
1. 余额页改为单一路径 DOM 记录切换
- 以 `inc/helpers/user-center-renderers/profile-credits.php` 现有 `data-aksrt-asset-tab` / `data-aksrt-asset-pane` 结构为唯一 UI 状态源,保持积分记录、余额记录、积分任务都通过按钮事件切换,不再依赖 URL hash 或 query。
- 在 `assets/js/credits.js` 扩展现有 `initAssetTabs()`:拦截记录分页链接,阻止整页导航;读取分页链接的页码/资产类型,调用当前已有的 `/user-center/panel/credits` REST 面板接口,通过响应 HTML 更新 `#panel-credits` 的 DOM;更新后重新绑定 tab,并清理地址栏中 `wallet_page`、`credits_page`、`points_page` 等历史参数。
- 将 PHP 记录渲染改为读取由 REST 请求传入的分页参数,而不是直接读取 `$_GET`;移除 `aksrt_render_profile_pagination()` 生成的 query 链接,改为输出带 `data-aksrt-asset-page`、`data-aksrt-asset-key` 的按钮/链接,供 `credits.js` 处理。
- 保留服务端 REST 面板的单一 HTML 输出,不新增 JSON/HTML 双协议;分页请求只使用现有 credits panel endpoint,并在响应后触发既有 `post_ajax.ed → auto_fun` 生命周期。
- 用户中心初始化时不再把 `reward_tab`、`commission_page`、`user_page` 等记录参数写回用户地址;对 `/user/balance/` 初次加载清理历史记录参数,避免切换后地址继续携带旧参数。
2. 首页排序固定为 DOM/AJAX,不保留参数刷新分支
- `assets/js/homepage-fixed.js` 删除 `SORT_QUERY_KEY`、`getSortFromUrl()`、`normalizeHomepageUrl()`、`replaceUrlSort()` 及对 `window.location.search` 的依赖;当前排序只从活动排序控件或 `aksrtData.homepageDefaultSort` 读取。
- `initSortbar()` 点击排序项后只更新 active 状态并调用现有 `requestHomepagePanel()`,不再调用 `history.replaceState()`,因此地址栏保持无 `home_sort` 参数。
- `inc/helpers/aksrt-homepage.php` 排序控件改为无参数链接(或按钮化标记,沿用现有 `data-sort`),删除 `add_query_arg('home_sort', ...)`、`data-sort-ajax` 双路开关和服务端 query override;服务端首页排序只使用 canonical 默认/运行时 override,避免生成旧的参数刷新 URL。
- 清理 `src/Core/ScriptsPart1c.php`、`src/Settings/HomeCms.php`、`src/Settings/HomepageReader.php` 中仅服务于可选排序 AJAX 开关的 `homepageSortAjax` / `homepage_sort_ajax_enabled` 死代码及数据注入。
- 删除首页排序请求中仅为 URL 刷新服务的 `page_url` 参数,或将其固定为当前无 query 的页面路径;保留 REST 请求内部用于确定上下文的必要字段。
3. 清理历史 query 分支和测试
- 通过 `rg` 找到并删除余额记录的 `wallet_page` / `credits_page` 旧 `$_GET` 分支、首页 `home_sort` query 读取/写入分支,以及相关旧注释/配置字段。
- 增加/更新 `tests/widget-system` 契约:余额记录控件必须是 DOM data 事件、分页不得生成 `add_query_arg`/query href;首页排序必须调用 `requestHomepagePanel` 且不得使用 `home_sort`、`replaceState`、`location.search`、`data-sort-ajax`。
- 只保留真正需要的 `user-center` REST 参数(例如 revenue 内部分页若仍有明确业务需求则保留在请求体/URL 内部,不写浏览器地址);不把现有其他用户中心 tab 的必要内部请求参数误删。
4. 验证
- 仅使用 `rg`、Node 契约测试和 PHP 语法检查,不做真实视觉效果验收。
- 运行余额/用户中心、首页、生命周期、资产账本相关测试;对所有改动 PHP/JS 执行 `php -l` / `node --check`。
- 运行 `git diff --check`,并确认不覆盖工作树中已有的 VIP、runtime、CSS、imgbox 或计划文件改动。
+15 -15
View File
@@ -2,15 +2,15 @@
/**
* 404 template.
*
* @package AKSRT
* @package Jio
*/
defined('ABSPATH') || exit;
get_header();
$search_placeholder = function_exists('aksrt_get_search_placeholder') ? aksrt_get_search_placeholder() : __('开启精彩搜索', 'aksrt');
$hot_keywords = function_exists('aksrt_get_search_hot_keywords') ? aksrt_get_search_hot_keywords() : [];
$search_placeholder = function_exists('jio_get_search_placeholder') ? jio_get_search_placeholder() : __('开启精彩搜索', 'jio');
$hot_keywords = function_exists('jio_get_search_hot_keywords') ? jio_get_search_hot_keywords() : [];
$hot_query = new WP_Query([
'post_type' => 'post',
@@ -39,33 +39,33 @@ $top_cats = get_terms([
<i class="fa fas fa-compass"></i>
</p>
<p class="error-hero__code" id="error-title">404</p>
<h1 class="error-hero__title"><?php esc_html_e('页面未找到', 'aksrt'); ?></h1>
<h1 class="error-hero__title"><?php esc_html_e('页面未找到', 'jio'); ?></h1>
<p class="error-hero__desc">
<?php esc_html_e('你访问的页面不存在或已被删除。试试搜索,或者从下方常用入口继续浏览。', 'aksrt'); ?>
<?php esc_html_e('你访问的页面不存在或已被删除。试试搜索,或者从下方常用入口继续浏览。', 'jio'); ?>
</p>
</div>
<?php if (function_exists('aksrt_render_admin_quick_link')) { aksrt_render_admin_quick_link('page-display/other', __('404 页设置', 'aksrt')); } ?>
<?php if (function_exists('jio_render_admin_quick_link')) { jio_render_admin_quick_link('page-display/other', __('404 页设置', 'jio')); } ?>
</div>
<form role="search" method="get" class="error-hero__search" action="<?php echo esc_url(home_url('/')); ?>">
<span class="error-hero__search-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</span>
<label class="screen-reader-text" for="error-search-input"><?php esc_html_e('搜索', 'aksrt'); ?></label>
<label class="screen-reader-text" for="error-search-input"><?php esc_html_e('搜索', 'jio'); ?></label>
<input type="search" id="error-search-input" name="s" placeholder="<?php echo esc_attr($search_placeholder); ?>" autocomplete="off">
<button type="submit" class="btn-primary btn-sm"><?php esc_html_e('搜索', 'aksrt'); ?></button>
<button type="submit" class="btn-primary btn-sm"><?php esc_html_e('搜索', 'jio'); ?></button>
</form>
<?php aksrt_render_search_hot_keywords($hot_keywords, 'aksrt-search-hot-keywords'); ?>
<?php jio_render_search_hot_keywords($hot_keywords, 'jio-search-hot-keywords'); ?>
<div class="error-hero__actions">
<a href="<?php echo esc_url(home_url('/')); ?>" class="btn-primary"><?php esc_html_e('返回首页', 'aksrt'); ?></a>
<a href="javascript:history.back()" class="btn-outline"><?php esc_html_e('返回上一页', 'aksrt'); ?></a>
<a href="<?php echo esc_url(home_url('/')); ?>" class="btn-primary"><?php esc_html_e('返回首页', 'jio'); ?></a>
<a href="javascript:history.back()" class="btn-outline"><?php esc_html_e('返回上一页', 'jio'); ?></a>
</div>
</section>
<?php if (!empty($top_cats) && !is_wp_error($top_cats)) : ?>
<section class="error-section" aria-label="<?php esc_attr_e('热门分类', 'aksrt'); ?>">
<h2 class="error-section__title"><?php esc_html_e('热门分类', 'aksrt'); ?></h2>
<section class="error-section" aria-label="<?php esc_attr_e('热门分类', 'jio'); ?>">
<h2 class="error-section__title"><?php esc_html_e('热门分类', 'jio'); ?></h2>
<div class="error-section__chips">
<?php foreach ($top_cats as $cat) : ?>
<a class="error-section__chip" href="<?php echo esc_url(get_category_link($cat->term_id)); ?>">
@@ -78,8 +78,8 @@ $top_cats = get_terms([
<?php endif; ?>
<?php if ($hot_query->have_posts()) : ?>
<section class="error-section" aria-label="<?php esc_attr_e('热门内容', 'aksrt'); ?>">
<h2 class="error-section__title"><?php esc_html_e('热门内容', 'aksrt'); ?></h2>
<section class="error-section" aria-label="<?php esc_attr_e('热门内容', 'jio'); ?>">
<h2 class="error-section__title"><?php esc_html_e('热门内容', 'jio'); ?></h2>
<ul class="error-section__list">
<?php while ($hot_query->have_posts()) : $hot_query->the_post(); ?>
<li>
+32 -35
View File
@@ -1,4 +1,4 @@
# AKSRT WordPress Theme
# Jio WordPress Theme
> **版本**: 3.4.0-dev | **架构**: 原生 PHP + WordPress (非 headless)
> **要求**: WordPress >= 6.4 | PHP >= 8.1
@@ -7,17 +7,14 @@
## 项目概述
AKSRT 是一款现代化的 WordPress 主题,采用**原生 WordPress 主题架构**开发(PHP + HTML + CSS + JavaScript),不依赖 REST API 进行前端渲染。
Jio 是一款现代化的 WordPress 主题,采用**原生 WordPress 主题架构**开发(PHP + HTML + CSS + JavaScript),不依赖 REST API 进行前端渲染。
### 核心特性
- **自定义管理面板 (SPA)** — Vue3 + Vite + Naive UI 的 12 个一级业务域后台
- **统一设置契约** — Schema 驱动表单、域控制器、导入导出、按域重置、迁移状态与健康检查
- **支付/下载主线** — 下载/付费内容中心、充值/VIP/分成/返佣/网关结构、10 分钟下载 token
- **统一设置契约** — Schema 驱动表单、域控制器、导入导出
- **用户与增长** — 统一登录注册、OAuth 登录、签到奖励、邀请奖励、等级/徽章/认证/举报结构
- **内容与交互** — 首页模块化、归档/搜索/404、单篇资源页、评论审核与通知中心
- **运维工具** — 配置导出导入、dry-run 校验、备份、数据库与环境诊断
- **自定义页面模板**:About / Friends / Login / Profile / Register
- **内容与交互** — 首页模块化、归档/搜索/404、单篇资源页、评论审核与通知
- **响应式设计**
---
@@ -25,14 +22,14 @@ AKSRT 是一款现代化的 WordPress 主题,采用**原生 WordPress 主题
## 文件结构
```
AKSRT-theme/
Jio/
├── functions.php # 入口:常量 + Composer autoload + bootstrap + 单例 init
├── style.css # WordPress 主题声明头
├── composer.json # 第三方依赖 + 主题 PSR-4(AKSRT\ → src/)
├── composer.json # 第三方依赖 + 主题 PSR-4(Jio\ → src/)
│
├── *.php # WP 核心模板保留根目录
│
├── src/ # Composer PSR-4 类型根(namespace AKSRT\)
├── src/ # Composer PSR-4 类型根(namespace Jio\)
│ ├── Admin/ # SPA 编排 + 域控制器 + Schema + Native ops
│ ├── Auth/ # 登录 / OAuth / 审核 / 邮箱
│ ├── Content/ # 下载 token / 付费内容 / VIP / 可见性
@@ -41,7 +38,7 @@ AKSRT-theme/
│ ├── Extensions/ # MeiliSearch / 推送 / IP / 内容审核
│ ├── Frontend/ # 前端渲染辅助
│ ├── Messages/ # 站内信 / 公告 / 举报
│ ├── Payment/ # 订单 / 网关 / 售后 / 物流 / 分成
│ ├── Payment/ # 订单 / 网关 / 物流 / 分成
│ │ └── Gateways/ # 支付宝 / 微信 / PayPal / Stripe
│ ├── Settings/ # 设置门面 + 运行时 delegates
│ ├── Shop/ # 商城服务 + meta box
@@ -92,7 +89,7 @@ AKSRT-theme/
## 安装方式
主题类型(`AKSRT\…`)通过 Composer PSR-4 从 `src/` 自动加载;`vendor/` 不进 Git。**部署或更新主题后必须在主题根目录生成 autoload**,否则会出现 `Class "AKSRT\Core\DomainModel" not found` 等致命错误。
主题类型(`Jio\…`)通过 Composer PSR-4 从 `src/` 自动加载;`vendor/` 不进 Git。**部署或更新主题后必须在主题根目录生成 autoload**,否则会出现 `Class "Jio\Core\DomainModel" not found` 等致命错误。
### 前置:Composer(必做)
@@ -109,16 +106,16 @@ composer dump-autoload -o
确认:
```bash
php -r "require 'vendor/autoload.php'; echo class_exists('AKSRT\\\\Core\\\\DomainModel') ? \"ok\\n\" : \"missing\\n\";"
php -r "require 'vendor/autoload.php'; echo class_exists('Jio\\\\Core\\\\DomainModel') ? \"ok\\n\" : \"missing\\n\";"
```
应输出 `ok`。若 PHP 开启了 OPcache,重载 php-fpm / 重启 PHP 后再访问站点。
### 方式一:直接上传(推荐)
1. 将整个 `AKSRT-theme` 文件夹上传到 `wp-content/themes/`
1. 将整个 `Jio` 文件夹上传到 `wp-content/themes/`
2. 在服务器主题目录执行上一节的 `composer install`(若打包时已带上完整 `vendor/` 且已 `dump-autoload`,可跳过)
3. WordPress 后台 → **外观** → **主题** → 找到 **AKSRT** → **激活**
3. WordPress 后台 → **外观** → **主题** → 找到 **Jio** → **激活**
### 方式二:ZIP 打包上传
@@ -129,7 +126,7 @@ php -r "require 'vendor/autoload.php'; echo class_exists('AKSRT\\\\Core\\\\Domai
SSH 进入主题目录后:
```bash
cd /path/to/wp-content/themes/AKsrt-theme # 以实际路径为准
cd /path/to/wp-content/themes/Jio # 以实际路径为准
composer install --no-dev --optimize-autoloader
# 或仅刷新映射:composer dump-autoload -o
# 可选:reload php-fpm / 清 OPcache
@@ -153,9 +150,9 @@ composer install --no-dev --optimize-autoloader
### 2. 后台配置路径
WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
WordPress 后台左侧 **Admin Bar** → ⚡ **Jio 主题设置**
或直接访问:`/wp-admin/admin.php?page=aksrt-settings`
或直接访问:`/wp-admin/admin.php?page=jio-settings`
### 3. 当前后台域
@@ -170,13 +167,13 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
### 4. 推广、邀请与佣金(用户中心 → 收益)
- **推广链接**:用户中心「收益」页展示带 `?ref=` 的推广链接、二维码(可下载 PNG)与推广海报(按「推广返佣」设置中的海报文案生成)。归因模式(首触/末触)在「支付&付费 → 推广返佣 → 推广归因模式」配置。
- **一次性邀请码**:后台「用户 → 邀请码管理」三视图运营页——列表(状态筛选/搜索/批量删除/排序/分页/点击复制)、添加(系统自动生成:数量/标识/四类奖励[经验值、积分、余额、VIP]/高级选项自定义位数;或 Zibll 兼容文本导入:一行一码 `邀请码 奖励 标识`,奖励参数串 `points=20&balance=30&vip=1&vip_time=5&level_integral=100`,分隔符可自定义)、导出(状态筛选 + 文本文档[可回流导入]/Excel表格 双格式)。注册门控模式在「用户&互动 → 登录注册 → 邀请码注册」。消费为原子操作,并发注册不会超发;开启注册验证时奖励延迟到邮箱验证后发放。
- **一次性邀请码**:后台「用户 → 邀请码管理」三视图运营页——列表(状态筛选/搜索/批量删除/排序/分页/点击复制)、添加(系统自动生成:数量/标识/四类奖励[经验值、积分、余额、VIP]/高级选项自定义位数;或行格式文本导入:一行一码 `邀请码 奖励 标识`,奖励参数串 `points=20&balance=30&vip=1&vip_time=5&level_integral=100`,分隔符可自定义)、导出(状态筛选 + 文本文档[可回流导入]/Excel表格 双格式)。注册门控模式在「用户&互动 → 登录注册 → 邀请码注册」。消费为原子操作,并发注册不会超发;开启注册验证时奖励延迟到邮箱验证后发放。
- **佣金**:订单支付成功后按推广者等级分档比例记录到佣金账本,经「结算观察期」后自动结算入钱包,支持提现与退款联动回滚。「推广码禁用后停止返佣」开关默认关闭。
### 5. 部署注意事项
- **佣金结算时效**:结算依赖小时级 wp-cron,并有收益页访问触发的懒结算兜底。高流量站点建议禁用 `WP_CRON_LOCK` 场景下的默认 WP-Cron,改用系统 crontab 定时请求 `wp-cron.php`,保证结算与过期任务准时执行。
- **反代 / CDN 与 IP 风控**:主题默认按 Cloudflare → 通用代理头顺序识别真实 IP。若需收紧伪造风险,添加 `add_filter('aksrt_trusted_proxies', fn() => ['10.0.0.0/8', '173.245.48.0/20'])`(填入你的代理/CDN 网段);名单非空时仅当 `REMOTE_ADDR` 命中名单才采纳转发头,否则直接使用 `REMOTE_ADDR`(推广奖励的 IP 日上限、验证码等均依赖该函数)。
- **反代 / CDN 与 IP 风控**:主题默认按 Cloudflare → 通用代理头顺序识别真实 IP。若需收紧伪造风险,添加 `add_filter('jio_trusted_proxies', fn() => ['10.0.0.0/8', '173.245.48.0/20'])`(填入你的代理/CDN 网段);名单非空时仅当 `REMOTE_ADDR` 命中名单才采纳转发头,否则直接使用 `REMOTE_ADDR`(推广奖励的 IP 日上限、验证码等均依赖该函数)。
---
@@ -184,20 +181,20 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
### Settings Facade 分层
- `AKSRT_Settings` 仍是主题设置的**唯一静态 facade**,外部调用方式保持 `AKSRT_Settings::*`
- `JIO_Settings` 仍是主题设置的**唯一静态 facade**,外部调用方式保持 `JIO_Settings::*`
- 具体运行时 getter 已按领域拆分到 `inc/classes/settings/`,例如:
- `class-aksrt-settings-theme-search.php`
- `class-aksrt-settings-post-display.php`
- `class-aksrt-settings-home-cms.php`
- `class-aksrt-settings-user-runtime.php`
- `class-aksrt-settings-integration.php`
- `class-aksrt-settings-display-runtime.php`
- `class-aksrt-settings-extension-runtime.php`
- `class-aksrt-settings-credits-runtime.php`
- `class-aksrt-settings-category-runtime.php`
- `class-aksrt-settings-capabilities.php`
- `inc/bootstrap.php` 会先加载 `settings/class-aksrt-settings.php`,再按领域加载这些 delegate 类
- 新增设置运行时逻辑时,优先放入对应 settings 子类;仅把聚合输出、统一入口和跨域装配保留在 `AKSRT_Settings`
- `class-jio-settings-theme-search.php`
- `class-jio-settings-post-display.php`
- `class-jio-settings-home-cms.php`
- `class-jio-settings-user-runtime.php`
- `class-jio-settings-integration.php`
- `class-jio-settings-display-runtime.php`
- `class-jio-settings-extension-runtime.php`
- `class-jio-settings-credits-runtime.php`
- `class-jio-settings-category-runtime.php`
- `class-jio-settings-capabilities.php`
- `inc/bootstrap.php` 会先加载 `settings/class-jio-settings.php`,再按领域加载这些 delegate 类
- 新增设置运行时逻辑时,优先放入对应 settings 子类;仅把聚合输出、统一入口和跨域装配保留在 `JIO_Settings`
### 添加新辅助函数
@@ -205,14 +202,14 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
### 添加新页面模板
将 `page-{name}.php` 放入 `templates/pages/`,并在 `class-aksrt-templates.php` 中注册。
将 `page-{name}.php` 放入 `templates/pages/`,并在 `class-jio-templates.php` 中注册。
### CSS 组织规范
| 目录 | 用途 | 加载场景 |
|------|------|---------|
| `css/frontend/` | 前端公共样式 | 所有前台页面 |
| `css/admin/` | 后台管理样式 | 仅 AKSRT 设置页面 |
| `css/admin/` | 后台管理样式 | 仅 Jio 设置页面 |
| `css/shared/` | 共用组件样式 | 前后端按需加载(如验证码) |
---
+26 -26
View File
@@ -2,9 +2,9 @@
/**
* Resource Archive Template
*
* Virtual list at /resources/ — ordinary posts marked with _aksrt_is_resource.
* Virtual list at /resources/ — ordinary posts marked with _jio_is_resource.
*
* @package AKSRT
* @package Jio
*/
defined('ABSPATH') || exit;
@@ -25,18 +25,18 @@ if (!in_array($current_price, ['all', 'free', 'paid'], true)) {
$price_min = isset($_GET['price_min']) && $_GET['price_min'] !== '' ? (float) $_GET['price_min'] : '';
$price_max = isset($_GET['price_max']) && $_GET['price_max'] !== '' ? (float) $_GET['price_max'] : '';
$archive_url = function_exists('aksrt_resource_archive_url') ? aksrt_resource_archive_url() : home_url('/resources/');
$archive_url = function_exists('jio_resource_archive_url') ? jio_resource_archive_url() : home_url('/resources/');
$sort_options = [
'date' => __('最新', 'aksrt'),
'downloads' => __('热门下载', 'aksrt'),
'views' => __('阅读最多', 'aksrt'),
'date' => __('最新', 'jio'),
'downloads' => __('热门下载', 'jio'),
'views' => __('阅读最多', 'jio'),
];
$price_options = [
'all' => __('全部', 'aksrt'),
'free' => __('免费', 'aksrt'),
'paid' => __('付费', 'aksrt'),
'all' => __('全部', 'jio'),
'free' => __('免费', 'jio'),
'paid' => __('付费', 'jio'),
];
$preserve = [];
@@ -59,18 +59,18 @@ $has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_
<main id="main-content" class="site-main container" tabindex="-1">
<header class="resource-archive-header page-cover">
<div class="container">
<?php if (function_exists('aksrt_breadcrumbs')) : ?>
<nav class="resource-archive-header__breadcrumbs" aria-label="<?php esc_attr_e('面包屑', 'aksrt'); ?>">
<?php aksrt_breadcrumbs(); ?>
<?php if (function_exists('jio_breadcrumbs')) : ?>
<nav class="resource-archive-header__breadcrumbs" aria-label="<?php esc_attr_e('面包屑', 'jio'); ?>">
<?php jio_breadcrumbs(); ?>
</nav>
<?php endif; ?>
<h1 class="resource-archive-header__title"><?php esc_html_e('资源', 'aksrt'); ?></h1>
<h1 class="resource-archive-header__title"><?php esc_html_e('资源', 'jio'); ?></h1>
<p class="resource-archive-header__sub">
<?php
global $wp_query;
/* translators: %s: resource count */
printf(esc_html__('共 %s 个资源', 'aksrt'), esc_html(number_format_i18n((int) $wp_query->found_posts)));
printf(esc_html__('共 %s 个资源', 'jio'), esc_html(number_format_i18n((int) $wp_query->found_posts)));
?>
</p>
</div>
@@ -79,9 +79,9 @@ $has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_
<div class="content-wrap">
<div class="content-layout content-layout--no-sidebar">
<div class="content-layout__primary resource-archive">
<nav class="resource-archive__toolbar" aria-label="<?php esc_attr_e('筛选', 'aksrt'); ?>">
<nav class="resource-archive__toolbar" aria-label="<?php esc_attr_e('筛选', 'jio'); ?>">
<div class="resource-archive__filter-group">
<span class="resource-archive__filter-label"><?php esc_html_e('排序', 'aksrt'); ?></span>
<span class="resource-archive__filter-label"><?php esc_html_e('排序', 'jio'); ?></span>
<ul class="resource-archive__sort">
<?php foreach ($sort_options as $key => $label) :
$args = $preserve;
@@ -105,7 +105,7 @@ $has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_
</div>
<div class="resource-archive__filter-group">
<span class="resource-archive__filter-label"><?php esc_html_e('价格', 'aksrt'); ?></span>
<span class="resource-archive__filter-label"><?php esc_html_e('价格', 'jio'); ?></span>
<ul class="resource-archive__sort">
<?php foreach ($price_options as $key => $label) :
$args = $preserve;
@@ -139,43 +139,43 @@ $has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_
}
?>
<label class="resource-archive__price-input">
<span><?php esc_html_e('最低', 'aksrt'); ?></span>
<span><?php esc_html_e('最低', 'jio'); ?></span>
<input type="number" name="price_min" min="0" step="1" value="<?php echo esc_attr((string) $price_min); ?>">
</label>
<span class="resource-archive__price-sep">—</span>
<label class="resource-archive__price-input">
<span><?php esc_html_e('最高', 'aksrt'); ?></span>
<span><?php esc_html_e('最高', 'jio'); ?></span>
<input type="number" name="price_max" min="0" step="1" value="<?php echo esc_attr((string) $price_max); ?>">
</label>
<button type="submit" class="btn-primary btn-sm"><?php esc_html_e('应用', 'aksrt'); ?></button>
<button type="submit" class="btn-primary btn-sm"><?php esc_html_e('应用', 'jio'); ?></button>
</form>
<?php if ($has_filter) : ?>
<a class="resource-archive__reset" href="<?php echo esc_url($archive_url); ?>">
<i class="fa fas fa-times-circle" aria-hidden="true"></i>
<?php esc_html_e('清除筛选', 'aksrt'); ?>
<?php esc_html_e('清除筛选', 'jio'); ?>
</a>
<?php endif; ?>
</nav>
<?php if (have_posts()) : ?>
<?php
$render_config = aksrt_resolve_post_list_render_config([
$render_config = jio_resolve_post_list_render_config([
'context' => 'archive',
'overrides' => ['type' => 'card'],
]);
aksrt_render_post_list(array_merge($render_config, ['wrapper_class' => 'content-stream__list']));
aksrt_render_pagination(null, [
jio_render_post_list(array_merge($render_config, ['wrapper_class' => 'content-stream__list']));
jio_render_pagination(null, [
'query_context' => 'archive',
'render_config' => $render_config,
]);
?>
<?php else : ?>
<div class="no-results">
<p><?php esc_html_e('暂无资源。', 'aksrt'); ?></p>
<p><?php esc_html_e('暂无资源。', 'jio'); ?></p>
<?php if ($has_filter) : ?>
<p><a class="btn-outline btn-sm" href="<?php echo esc_url($archive_url); ?>"><?php esc_html_e('清除筛选条件', 'aksrt'); ?></a></p>
<p><a class="btn-outline btn-sm" href="<?php echo esc_url($archive_url); ?>"><?php esc_html_e('清除筛选条件', 'jio'); ?></a></p>
<?php endif; ?>
</div>
<?php endif; ?>
+19 -19
View File
@@ -2,7 +2,7 @@
/**
* Generic archive template.
*
* @package AKSRT
* @package Jio
*/
defined('ABSPATH') || exit;
@@ -11,31 +11,31 @@ get_header();
global $wp_query;
$render_config = function_exists('aksrt_archive_get_render_config')
? aksrt_archive_get_render_config(['context' => 'archive'])
$render_config = function_exists('jio_archive_get_render_config')
? jio_archive_get_render_config(['context' => 'archive'])
: ['context' => 'archive', 'mode' => 'list'];
$has_sidebar = function_exists('aksrt_archive_has_sidebar')
? aksrt_archive_has_sidebar()
: (function_exists('aksrt_is_show_sidebar') ? aksrt_is_show_sidebar() : false);
$has_sidebar = function_exists('jio_archive_has_sidebar')
? jio_archive_has_sidebar()
: (function_exists('jio_is_show_sidebar') ? jio_is_show_sidebar() : false);
$archive_description = trim(wp_strip_all_tags((string) get_the_archive_description()));
$archive_summary = '';
if (is_author()) {
$author = get_queried_object();
if ($author instanceof WP_User) {
$archive_summary = sprintf(__('作者:%s', 'aksrt'), $author->display_name);
$archive_summary = sprintf(__('作者:%s', 'jio'), $author->display_name);
}
} elseif ($archive_description !== '') {
$archive_summary = $archive_description;
} elseif (isset($wp_query->found_posts)) {
$archive_summary = sprintf(
esc_html(_n('共 %d 篇内容', '共 %d 篇内容', (int) $wp_query->found_posts, 'aksrt')),
esc_html(_n('共 %d 篇内容', '共 %d 篇内容', (int) $wp_query->found_posts, 'jio')),
(int) $wp_query->found_posts
);
}
if (function_exists('aksrt_archive_open_shell')) {
aksrt_archive_open_shell([
if (function_exists('jio_archive_open_shell')) {
jio_archive_open_shell([
'main_class' => 'site-main container',
'has_sidebar' => $has_sidebar,
'page_kind' => 'archive',
@@ -45,16 +45,16 @@ if (function_exists('aksrt_archive_open_shell')) {
}
?>
<div class="content-main content-main--archive">
<?php if (function_exists('aksrt_breadcrumbs')) { aksrt_breadcrumbs(); } ?>
<?php if (function_exists('jio_breadcrumbs')) { jio_breadcrumbs(); } ?>
<?php
ob_start();
if (function_exists('aksrt_render_admin_quick_link')) {
aksrt_render_admin_quick_link('post-list/list', __('列表样式设置', 'aksrt'));
if (function_exists('jio_render_admin_quick_link')) {
jio_render_admin_quick_link('post-list/list', __('列表样式设置', 'jio'));
}
$admin_link = trim((string) ob_get_clean());
get_template_part('templates/parts/page-header', null, [
'eyebrow' => __('内容归档', 'aksrt'),
'eyebrow' => __('内容归档', 'jio'),
'title' => get_the_archive_title(),
'desc' => $archive_summary,
'extra' => $admin_link,
@@ -62,19 +62,19 @@ if (function_exists('aksrt_archive_open_shell')) {
?>
<?php
if (function_exists('aksrt_archive_render_stream')) {
aksrt_archive_render_stream(null, $render_config, [
if (function_exists('jio_archive_render_stream')) {
jio_archive_render_stream(null, $render_config, [
'page_kind' => 'archive',
'query_context' => 'archive',
'stream_class' => 'content-stream--archive',
'empty_text' => __('暂无文章', 'aksrt'),
'empty_text' => __('暂无文章', 'jio'),
]);
}
?>
</div>
<?php
if (function_exists('aksrt_archive_close_shell')) {
aksrt_archive_close_shell(['has_sidebar' => $has_sidebar]);
if (function_exists('jio_archive_close_shell')) {
jio_archive_close_shell(['has_sidebar' => $has_sidebar]);
} else {
echo '</div></div>';
if ($has_sidebar) { get_sidebar(); }
+43 -41
View File
@@ -27,8 +27,7 @@ const COMPONENT_STYLES = [
// 内部基础组件
['_internal/scrollbar/src/styles/index.cssr.mjs'],
// 布局
['layout/src/styles/index.cssr.mjs'],
['layout-sider/src/styles/index.cssr.mjs'],
['layout/src/styles/layout.cssr.mjs'],
// 导航
['menu/src/styles/index.cssr.mjs'],
['breadcrumb/src/styles/index.cssr.mjs'],
@@ -36,17 +35,19 @@ const COMPONENT_STYLES = [
['steps/src/styles/index.cssr.mjs'],
['pagination/src/styles/index.cssr.mjs'],
// 表单
['form/src/styles/index.cssr.mjs'],
['form-item/src/styles/index.cssr.mjs'],
['input/src/styles/index.cssr.mjs'],
['input-number/src/styles/index.cssr.mjs'],
['form/src/styles/form.cssr.mjs'],
['form/src/styles/form-item.cssr.mjs'],
['input/src/styles/input.cssr.mjs'],
['input/src/styles/input-group.cssr.mjs'],
['input-number/src/styles/input-number.cssr.mjs'],
['switch/src/styles/index.cssr.mjs'],
['select/src/styles/index.cssr.mjs'],
['radio/src/styles/index.cssr.mjs'],
['radio/src/styles/radio.cssr.mjs'],
['radio/src/styles/radio-group.cssr.mjs'],
['checkbox/src/styles/index.cssr.mjs'],
['slider/src/styles/index.cssr.mjs'],
['rate/src/styles/index.cssr.mjs'],
['date-picker/src/styles/indexcode/index.cssr.mjs'],
['date-picker/src/styles/index.cssr.mjs'],
['time-picker/src/styles/index.cssr.mjs'],
// 展示
['card/src/styles/index.cssr.mjs'],
@@ -68,12 +69,12 @@ const COMPONENT_STYLES = [
['notification/src/styles/index.cssr.mjs'],
['popconfirm/src/styles/index.cssr.mjs'],
['popover/src/styles/index.cssr.mjs'],
['tooltip/src/styles/index.cssr.mjs'],
// tooltip 复用 popover 的共享样式(自身没有独立 cssr 文件)。
['dropdown/src/styles/index.cssr.mjs'],
// 其他
['icon/src/styles/index.cssr.mjs'],
['icon-wrapper/src/styles/index.cssr.mjs'],
['space/src/styles/index.cssr.mjs'],
// space 全部用内联 style 布局,没有 cssr 文件,故不在此列。
['divider/src/styles/index.cssr.mjs'],
['gradient-text/src/styles/index.cssr.mjs'],
['ellipsis/src/styles/index.cssr.mjs'],
@@ -86,24 +87,24 @@ const COMPONENT_STYLES = [
['tree-select/src/styles/index.cssr.mjs'],
['tree/src/styles/index.cssr.mjs'],
['thing/src/styles/index.cssr.mjs'],
['color-picker/src/styles/indexcode/index.cssr.mjs'],
['typography/src/styles/index.cssr.mjs'],
['color-picker/src/styles/index.cssr.mjs'],
['typography/src/styles/p.cssr.mjs'],
['typography/src/styles/a.cssr.mjs'],
['typography/src/styles/blockquote.cssr.mjs'],
['typography/src/styles/list.cssr.mjs'],
['typography/src/styles/hr.cssr.mjs'],
['typography/src/styles/header.cssr.mjs'],
['auto-complete/src/styles/index.cssr.mjs'],
['cascader/src/styles/index.cssr.mjs'],
['dynamic-input/src/styles/index.cssr.mjs'],
['dynamic-tags/src/styles/index.cssr.mjs'],
['input-otp/src/styles/indexcode/index.cssr.mjs'],
// 过渡动画
['_styles/transitions/fade-in-width-expand.cssr.mjs'],
['_styles/transitions/fade-in-height-expand.cssr.mjs'],
['_styles/transitions/icon-switch.cssr.mjs'],
['_styles/transitions/scale-transition.cssr.mjs'],
['input-otp/src/styles/index.cssr.mjs'],
// 全局
['global-style/src/styles/index.cssr.mjs'],
['_styles/global/index.cssr.mjs'],
]
async function extractStyles() {
console.log('\n[AKSRT] 开始提取 NaiveUI 组件样式...\n')
console.log('\n[Jio] 开始提取 NaiveUI 组件样式...\n')
const allCss = []
let successCount = 0
@@ -118,27 +119,28 @@ async function extractStyles() {
const defaultExport = styleModule.default || styleModule
if (typeof defaultExport === 'function') {
// 直接调用 render() 提取 CSS
const css = defaultExport({
clsPrefix: 'n',
// 使用 light 主题变量
})
// cssr 模块有两种导出形态:函数本体,或带 render() 的 CNode 对象。
// 两者都必须接收 props 对象(至少 {}),否则 render 内部读
// props.bPrefix 会抛错——card / data-table / dialog 等组件的样式
// 曾因漏传参数被整体跳过。
const css = typeof defaultExport === 'function'
? defaultExport({ clsPrefix: 'n' })
: defaultExport.render({ clsPrefix: 'n' })
if (typeof css === 'string' && css.trim()) {
allCss.push(`/* ${componentPath} */\n${css}`)
successCount++
}
} else if (defaultExport && typeof defaultExport.render === 'function') {
// 如果是 CNode 对象,调用 render()
const css = defaultExport.render()
if (typeof css === 'string' && css.trim()) {
allCss.push(`/* ${componentPath} */\n${css}`)
successCount++
}
if (typeof css === 'string' && css.trim()) {
allCss.push(`/* ${componentPath} */\n${css}`)
successCount++
}
} catch (err) {
// 某些路径可能不存在或结构不同,跳过但不中断
const code = err && err.code
// 路径写错时 import 会直接抛 MODULE_NOT_FOUND。这类错误必须显式报出来:
// 静默跳过的后果是组件样式整体缺失(取色器曾因此没有尺寸和边框,糊成一团),
// 而页面不报错,只能靠肉眼发现。
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
console.error(`[Jio] 样式路径不存在,已跳过: ${componentPath}`)
} else {
console.error(`[Jio] 样式提取异常,已跳过: ${componentPath}: ${err && err.message}`)
}
failCount++
}
}
@@ -146,7 +148,7 @@ async function extractStyles() {
const cssOutput = allCss.join('\n\n')
if (!cssOutput.trim()) {
console.warn('[AKSRT] 警告:未提取到任何 NaiveUI 样式!')
console.warn('[Jio] 警告:未提取到任何 NaiveUI 样式!')
return null
}
@@ -163,7 +165,7 @@ ${cssOutput}`
await writeFile(cssFile, fullContent, 'utf-8')
console.log(`[AKSRT] NaiveUI 样式提取完成:`)
console.log(`[Jio] NaiveUI 样式提取完成:`)
console.log(` 成功: ${successCount} 个组件`)
console.log(` 跳过: ${failCount} 个组件`)
console.log(` 输出: ${cssFile}`)
@@ -174,6 +176,6 @@ ${cssOutput}`
// 执行
extractStyles().catch((err) => {
console.error('[AKSRT] 样式提取失败:', err)
console.error('[Jio] 样式提取失败:', err)
process.exit(1)
})
+2 -2
View File
@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AKSRT 主题设置</title>
<title>Jio 主题设置</title>
</head>
<body>
<div id="aksrt-admin-app"></div>
<div id="jio-admin-app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+121 -94
View File
@@ -1,11 +1,11 @@
{
"name": "aksrt-admin",
"name": "jio-admin",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aksrt-admin",
"name": "jio-admin",
"version": "1.0.0",
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
@@ -21,35 +21,36 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"echarts": "^6.1.0",
"esbuild": "^0.25.12",
"vite": "^6.0.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.11",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz",
"integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"version": "7.29.12",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz",
"integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.11"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -59,13 +60,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.11",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz",
"integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.11",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -676,9 +677,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
},
"node_modules/@juggle/resize-observer": {
@@ -1137,53 +1138,53 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
"integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz",
"integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/shared": "3.5.33",
"@babel/parser": "^7.29.9",
"@vue/shared": "3.5.44",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
"integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz",
"integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/compiler-core": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
"integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz",
"integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.33",
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33",
"@babel/parser": "^7.29.9",
"@vue/compiler-core": "3.5.44",
"@vue/compiler-dom": "3.5.44",
"@vue/compiler-ssr": "3.5.44",
"@vue/shared": "3.5.44",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.10",
"source-map-js": "^1.2.1"
"postcss": "^8.5.29",
"source-map-js": "^1.2.2"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
"integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz",
"integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/compiler-dom": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/devtools-api": {
@@ -1193,53 +1194,51 @@
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
"integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz",
"integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.33"
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
"integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz",
"integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/reactivity": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
"integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz",
"integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.33",
"@vue/runtime-core": "3.5.33",
"@vue/shared": "3.5.33",
"@vue/reactivity": "3.5.44",
"@vue/runtime-core": "3.5.44",
"@vue/shared": "3.5.44",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
"integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz",
"integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33"
},
"peerDependencies": {
"vue": "3.5.33"
"@vue/compiler-ssr": "3.5.44",
"@vue/runtime-dom": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/shared": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz",
"integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==",
"license": "MIT"
},
"node_modules/async-validator": {
@@ -1310,6 +1309,17 @@
"date-fns": "^3.0.0 || ^4.0.0"
}
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -1472,9 +1482,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"funding": [
{
"type": "github",
@@ -1531,9 +1541,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"version": "8.5.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
"funding": [
{
"type": "opencollective",
@@ -1550,9 +1560,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -1616,9 +1626,9 @@
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -1653,6 +1663,13 @@
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"dev": true,
"license": "0BSD"
},
"node_modules/vdirs": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz",
@@ -1666,9 +1683,9 @@
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"version": "6.4.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz",
"integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1753,16 +1770,16 @@
}
},
"node_modules/vue": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz",
"integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-sfc": "3.5.33",
"@vue/runtime-dom": "3.5.33",
"@vue/server-renderer": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/compiler-dom": "3.5.44",
"@vue/compiler-sfc": "3.5.44",
"@vue/runtime-dom": "3.5.44",
"@vue/server-renderer": "3.5.44",
"@vue/shared": "3.5.44"
},
"peerDependencies": {
"typescript": "*"
@@ -1849,6 +1866,16 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}
+1 -1
View File
@@ -1 +1 @@
{"name":"aksrt-admin","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build && node extract-naive-styles.mjs","build:public":"node ../../tools/build-public-assets.mjs","preview":"vite preview"},"dependencies":{"@codemirror/lang-css":"^6.3.1","@codemirror/lang-html":"^6.4.11","@codemirror/lang-javascript":"^6.2.5","@codemirror/theme-one-dark":"^6.1.3","codemirror":"^6.0.2","naive-ui":"^2.40.0","pinia":"^2.2.0","vue":"^3.5.0","vue-router":"^4.4.0","vuedraggable":"^4.1.0"},"devDependencies":{"@vitejs/plugin-vue":"^5.1.0","esbuild":"^0.25.12","vite":"^6.0.0"}}
{"name":"jio-admin","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build && node extract-naive-styles.mjs","build:public":"node ../../tools/build-public-assets.mjs","build:pay-charts":"node ../../tools/build-pay-charts.mjs","preview":"vite preview"},"dependencies":{"@codemirror/lang-css":"^6.3.1","@codemirror/lang-html":"^6.4.11","@codemirror/lang-javascript":"^6.2.5","@codemirror/theme-one-dark":"^6.1.3","codemirror":"^6.0.2","naive-ui":"^2.40.0","pinia":"^2.2.0","vue":"^3.5.0","vue-router":"^4.4.0","vuedraggable":"^4.1.0"},"devDependencies":{"@vitejs/plugin-vue":"^5.1.0","echarts":"^6.1.0","esbuild":"^0.25.12","vite":"^6.0.0"},"allowScripts":{"esbuild@0.25.12":true,"vue-demi@0.14.10":true}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

+45 -45
View File
@@ -6,9 +6,9 @@
<n-message-provider>
<n-dialog-provider>
<n-notification-provider>
<n-layout v-if="!isMobile" class="aksrt-admin-layout" has-sider>
<n-layout v-if="!isMobile" class="jio-admin-layout" has-sider>
<n-layout-sider
class="aksrt-admin-sider"
class="jio-admin-sider"
bordered
collapse-mode="width"
:collapsed-width="64"
@@ -19,16 +19,16 @@
@collapse="collapsed = true"
@expand="collapsed = false"
>
<div class="aksrt-logo">
<div class="jio-logo">
<span class="logo-icon">AK</span>
<span v-if="!collapsed" class="aksrt-logo__copy">
<span class="logo-text">AKSRT</span>
<span class="aksrt-logo__version">v{{ themeVersion }}</span>
<span v-if="!collapsed" class="jio-logo__copy">
<span class="logo-text">Jio</span>
<span class="jio-logo__version">v{{ themeVersion }}</span>
</span>
</div>
<n-menu
class="aksrt-admin-menu"
class="jio-admin-menu"
:value="activeMenuKey"
:collapsed="collapsed"
:collapsed-width="64"
@@ -37,39 +37,39 @@
/>
</n-layout-sider>
<n-layout :native-scrollbar="false" class="aksrt-admin-content">
<div class="aksrt-admin-content__inner">
<n-layout :native-scrollbar="false" class="jio-admin-content">
<div class="jio-admin-content__inner">
<router-view v-slot="{ Component }">
<transition name="fade-slide" mode="out-in">
<div class="aksrt-page-wrapper" :key="route.fullPath">
<div class="jio-page-wrapper" :key="route.fullPath">
<component :is="Component" />
</div>
</transition>
</router-view>
<div v-if="pageError" class="aksrt-error-fallback">
<p class="aksrt-error-title">页面渲染异常</p>
<p class="aksrt-error-msg">{{ pageError }}</p>
<div v-if="pageError" class="jio-error-fallback">
<p class="jio-error-title">页面渲染异常</p>
<p class="jio-error-msg">{{ pageError }}</p>
<n-button size="small" @click="pageError = null">重试</n-button>
</div>
</div>
</n-layout>
</n-layout>
<div v-else class="aksrt-admin-mobile">
<div v-else class="jio-admin-mobile">
<MobileTopBar :title="currentPageTitle" @toggle="drawerActive = true" />
<n-drawer v-model:show="drawerActive" placement="left" :width="280">
<n-drawer-content :native-scrollbar="false" body-content-style="padding:0;">
<div class="aksrt-logo aksrt-logo--drawer">
<div class="jio-logo jio-logo--drawer">
<span class="logo-icon">AK</span>
<span class="aksrt-logo__copy">
<span class="logo-text">AKSRT</span>
<span class="aksrt-logo__version">v{{ themeVersion }}</span>
<span class="jio-logo__copy">
<span class="logo-text">Jio</span>
<span class="jio-logo__version">v{{ themeVersion }}</span>
</span>
</div>
<n-menu
class="aksrt-admin-menu"
class="jio-admin-menu"
:value="activeMenuKey"
:options="menuOptions"
@update:value="handleMenuClick"
@@ -77,18 +77,18 @@
</n-drawer-content>
</n-drawer>
<div class="aksrt-admin-mobile__body">
<div class="jio-admin-mobile__body">
<router-view v-slot="{ Component }">
<transition name="fade-slide" mode="out-in">
<div class="aksrt-page-wrapper" :key="route.fullPath">
<div class="jio-page-wrapper" :key="route.fullPath">
<component :is="Component" />
</div>
</transition>
</router-view>
<div v-if="pageError" class="aksrt-error-fallback">
<p class="aksrt-error-title">页面渲染异常</p>
<p class="aksrt-error-msg">{{ pageError }}</p>
<div v-if="pageError" class="jio-error-fallback">
<p class="jio-error-title">页面渲染异常</p>
<p class="jio-error-msg">{{ pageError }}</p>
<n-button size="small" @click="pageError = null">重试</n-button>
</div>
</div>
@@ -113,7 +113,7 @@ const collapsed = ref(false)
const pageError = ref(null)
const isMobile = ref(false)
const drawerActive = ref(false)
const themeVersion = window.aksrtAdminData?.themeVersion || window.aksrtAdminData?.version || '9.0'
const themeVersion = window.jioAdminData?.themeVersion || window.jioAdminData?.version || '9.0'
const MOBILE_BREAKPOINT = 960
const adminThemeOverrides = {
@@ -184,7 +184,7 @@ onBeforeUnmount(() => {
provide('collapsed', collapsed)
onErrorCaptured((err) => {
console.error('[AKSRT] 组件渲染错误:', err)
console.error('[Jio] 组件渲染错误:', err)
pageError.value = err?.message || String(err)
return false
})
@@ -224,7 +224,7 @@ const menuOptions = ADMIN_SECTIONS.filter((section) => !section.hidden).map((sec
children: section.children.map((child) => ({
label: child.secondaryEntry ? `${child.label} · 次级` : child.label,
key: child.key,
props: child.secondaryEntry ? { class: 'aksrt-menu-item--secondary' } : undefined,
props: child.secondaryEntry ? { class: 'jio-menu-item--secondary' } : undefined,
})),
}
})
@@ -254,7 +254,7 @@ const activeMenuKey = computed(() => {
return ADMIN_SECTIONS[0]?.children?.[0]?.key || 'global-common'
})
const currentPageTitle = computed(() => route.meta?.title || 'AKSRT')
const currentPageTitle = computed(() => route.meta?.title || 'Jio')
function handleMenuClick(key) {
if (keyToPath[key]) {
@@ -267,17 +267,17 @@ function handleMenuClick(key) {
</script>
<style scoped>
.aksrt-logo {
.jio-logo {
display: flex;
align-items: center;
justify-content: flex-start;
height: 64px;
gap: 11px;
padding: 0 18px;
border-bottom: 1px solid var(--aksrt-color-border);
border-bottom: 1px solid var(--jio-color-border);
}
.aksrt-logo--drawer {
.jio-logo--drawer {
height: 64px;
}
@@ -289,14 +289,14 @@ function handleMenuClick(key) {
height: 28px;
flex: 0 0 28px;
border-radius: 7px;
background: var(--aksrt-color-primary);
background: var(--jio-color-primary);
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
}
.aksrt-logo__copy {
.jio-logo__copy {
display: flex;
align-items: baseline;
gap: 7px;
@@ -306,56 +306,56 @@ function handleMenuClick(key) {
.logo-text {
font-size: 17px;
font-weight: 700;
color: var(--aksrt-color-text);
color: var(--jio-color-text);
letter-spacing: .08em;
}
.aksrt-logo__version {
color: var(--aksrt-color-muted);
.jio-logo__version {
color: var(--jio-color-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
letter-spacing: .02em;
}
.aksrt-admin-content__inner {
.jio-admin-content__inner {
width: 100%;
max-width: 1236px;
margin: 0 auto;
padding: 26px 28px 42px;
}
.aksrt-admin-mobile {
background: var(--aksrt-color-canvas);
.jio-admin-mobile {
background: var(--jio-color-canvas);
min-height: 100vh;
}
.aksrt-admin-mobile__body {
.jio-admin-mobile__body {
padding: 14px 12px 32px;
}
.aksrt-page-wrapper {
.jio-page-wrapper {
min-height: calc(100vh - 120px);
}
.aksrt-error-fallback {
.jio-error-fallback {
padding: 48px 20px;
text-align: center;
}
.aksrt-error-title {
.jio-error-title {
margin: 0 0 10px;
font-size: 16px;
color: #d03050;
}
.aksrt-error-msg {
.jio-error-msg {
margin: 0 0 16px;
font-size: 13px;
color: #777;
}
@media (max-width: 1100px) {
.aksrt-admin-content__inner {
.jio-admin-content__inner {
padding: 22px 20px 38px;
}
}
+17 -17
View File
@@ -1,16 +1,16 @@
/**
* AKSRT Admin API Client
* Jio Admin API Client
*
* Communicates with the WordPress REST API at /wp-json/aksrt/v1
* (or index.php?rest_route=/aksrt/v1 when pretty permalinks are off).
* Communicates with the WordPress REST API at /wp-json/jio/v1
* (or index.php?rest_route=/jio/v1 when pretty permalinks are off).
* Methods are organized into domain modules while the client keeps the shared
* request pipeline and schema-domain normalization contract.
*/
import { apiMethods } from './modules/index.js'
const API_ROOT = window.aksrtAdminData?.apiUrl || '/wp-json/aksrt/v1'
const NONCE = window.aksrtAdminData?.nonce || ''
const API_ROOT = window.jioAdminData?.apiUrl || '/wp-json/jio/v1'
const NONCE = window.jioAdminData?.nonce || ''
function createIdempotencyKey() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
@@ -34,11 +34,11 @@ function normalizeDomainKey(domain) {
/**
* Join REST base + endpoint correctly for both pretty and plain permalinks.
*
* Pretty: https://site/wp-json/aksrt/v1 + /settings/schema/global/common
* → https://site/wp-json/aksrt/v1/settings/schema/global/common
* Pretty: https://site/wp-json/jio/v1 + /settings/schema/global/common
* → https://site/wp-json/jio/v1/settings/schema/global/common
*
* Plain: https://site/index.php?rest_route=/aksrt/v1 + /settings/schema/global/common
* → https://site/index.php?rest_route=/aksrt/v1/settings/schema/global/common
* Plain: https://site/index.php?rest_route=/jio/v1 + /settings/schema/global/common
* → https://site/index.php?rest_route=/jio/v1/settings/schema/global/common
*
* @param {string} baseUrl
* @param {string} endpoint path starting with /, may include ?query
@@ -56,7 +56,7 @@ function joinRestUrl(baseUrl, endpoint) {
path = '/' + path
}
const base = String(baseUrl || '').trim() || '/wp-json/aksrt/v1'
const base = String(baseUrl || '').trim() || '/wp-json/jio/v1'
// Plain permalinks: rest_route query mode
if (base.includes('rest_route=')) {
@@ -68,11 +68,11 @@ function joinRestUrl(baseUrl, endpoint) {
return base.replace(/\/?$/, '') + path + (extraQuery ? (base.includes('?') ? '&' : '?') + extraQuery : '')
}
const baseRoute = (u.searchParams.get('rest_route') || '/aksrt/v1').replace(/\/$/, '') || '/aksrt/v1'
const baseRoute = (u.searchParams.get('rest_route') || '/jio/v1').replace(/\/$/, '') || '/jio/v1'
let fullRoute
if (path === '/') {
fullRoute = baseRoute
} else if (path.startsWith('/aksrt/')) {
} else if (path.startsWith('/jio/')) {
fullRoute = path
} else {
fullRoute = baseRoute + path
@@ -110,7 +110,7 @@ class ApiClient {
async request(endpointOrConfig, options = {}) {
// Dual-signature support:
// request('/users/1/restrictions')
// request({ url: '/aksrt/v1/users/1/restrictions', method: 'GET' })
// request({ url: '/jio/v1/users/1/restrictions', method: 'GET' })
let endpoint, method, headers, params, body, rest
if (typeof endpointOrConfig === 'object' && endpointOrConfig !== null) {
@@ -134,10 +134,10 @@ class ApiClient {
delete rest.data; delete rest.headers
}
if (endpoint.startsWith('/wp-json/aksrt/v1')) {
endpoint = endpoint.replace('/wp-json/aksrt/v1', '') || '/'
} else if (endpoint.startsWith('/aksrt/v1')) {
endpoint = endpoint.replace('/aksrt/v1', '') || '/'
if (endpoint.startsWith('/wp-json/jio/v1')) {
endpoint = endpoint.replace('/wp-json/jio/v1', '') || '/'
} else if (endpoint.startsWith('/jio/v1')) {
endpoint = endpoint.replace('/jio/v1', '') || '/'
}
let url = joinRestUrl(this.baseUrl, endpoint)
@@ -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 }) },
}
-6
View File
@@ -11,12 +11,6 @@ export const globalApiMethods = {
getFloatingButtons() { return this.request('/settings/floating-buttons') },
saveFloatingButtons(data) { return this.request('/settings/floating-buttons', { method: 'POST', body: JSON.stringify(data) }) },
reorderFloatingButtons(ids) { return this.request('/settings/floating-buttons/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
getFriendLinks() { return this.request('/friend-links') },
addFriendLink(data) { return this.request('/friend-links', { method: 'POST', body: JSON.stringify(data) }) },
deleteFriendLink(id) { return this.request(`/friend-links/${id}`, { method: 'DELETE' }) },
reorderFriendLinks(ids) { return this.request('/friend-links/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
getCmsFeatures() { return this.request('/settings/cms-features') },
saveCmsFeatures(cms_features) { return this.request('/settings/cms-features', { method: 'POST', body: JSON.stringify({ cms_features }) }) },
+2
View File
@@ -6,6 +6,7 @@ import { extensionsApiMethods } from './extensions.js'
import { toolsApiMethods } from './tools.js'
import { schemaApiMethods } from './schema.js'
import { iconsApiMethods } from './icons.js'
import { ticketsApiMethods } from './tickets.js'
export const apiMethods = {
...globalApiMethods,
@@ -16,4 +17,5 @@ export const apiMethods = {
...toolsApiMethods,
...schemaApiMethods,
...iconsApiMethods,
...ticketsApiMethods,
}
@@ -3,14 +3,6 @@ export const permissionsApiMethods = {
* 功能&权限 (Permissions)
* ================================================================ */
getPermissionsAttachmentSettings() { return this.getSchemaPage('permissions', 'attachment') },
savePermissionsAttachmentSettings(data) { return this.saveSchemaPage('permissions', 'attachment', data) },
getPermissionsLoginSettings() { return this.getSchemaPage('permissions', 'login') },
savePermissionsLoginSettings(data) { return this.saveSchemaPage('permissions', 'login', data) },
getPermissionsModerationSettings() { return this.getSchemaPage('permissions', 'moderation') },
getCapabilities() { return this.request('/permissions/capabilities') },
saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) },
}
+55
View File
@@ -0,0 +1,55 @@
/**
* 工单管理端 REST(命名空间 jio/v1/admin)。
*
* TicketRestAdmin 把三块字典 CRUD(工单状态 / 部门 / 预设回复)挂在
* jio/v1/admin 下,全部要求 manage_options。api 客户端的 baseUrl 只到
* jio/v1,因此这里必须用 /jio/ 全路径形式(joinRestUrl 的
* startsWith('/jio/') 分支会原样拼接,不再追加 base 前缀)。
*/
const ADMIN = '/jio/v1/admin/tickets'
export const ticketsApiMethods = {
/* ---------------- 工单状态 ---------------- */
listTicketStatuses() { return this.request(`${ADMIN}/statuses`) },
createTicketStatus(data) {
return this.request(`${ADMIN}/statuses`, { method: 'POST', body: JSON.stringify(data) })
},
updateTicketStatus(id, data) {
return this.request(`${ADMIN}/statuses/${id}`, { method: 'POST', body: JSON.stringify(data) })
},
deleteTicketStatus(id) {
return this.request(`${ADMIN}/statuses/${id}/delete`, { method: 'POST', body: '{}' })
},
/* ---------------- 工单部门 ---------------- */
listTicketTypes() { return this.request(`${ADMIN}/types`) },
createTicketType(data) {
return this.request(`${ADMIN}/types`, { method: 'POST', body: JSON.stringify(data) })
},
updateTicketType(id, data) {
return this.request(`${ADMIN}/types/${id}`, { method: 'POST', body: JSON.stringify(data) })
},
deleteTicketType(id) {
return this.request(`${ADMIN}/types/${id}/delete`, { method: 'POST', body: '{}' })
},
/* ---------------- 预设回复 ---------------- */
listTicketPrereplies() { return this.request(`${ADMIN}/prereplies`) },
createTicketPrereply(data) {
return this.request(`${ADMIN}/prereplies`, { method: 'POST', body: JSON.stringify(data) })
},
updateTicketPrereply(id, data) {
return this.request(`${ADMIN}/prereplies/${id}`, { method: 'POST', body: JSON.stringify(data) })
},
deleteTicketPrereply(id) {
return this.request(`${ADMIN}/prereplies/${id}/delete`, { method: 'POST', body: '{}' })
},
/** 排序入参必须是全量 ID,数量不符后端直接拒绝。 */
reorderTicketPrereplies(ids) {
return this.request(`${ADMIN}/prereplies/order`, { method: 'POST', body: JSON.stringify({ ids }) })
},
}
+1 -12
View File
@@ -5,6 +5,7 @@ export const usersApiMethods = {
getAuthSettings() { return this.request('/settings/auth') },
saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) },
testSms(phone) { return this.request('/sms/test', { method: 'POST', body: JSON.stringify({ phone }) }) },
getNotificationSettings() { return this.request('/settings/notifications') },
saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) },
@@ -17,14 +18,6 @@ export const usersApiMethods = {
deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) },
runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) },
getIdentitySettings() { return this.request('/identity/settings') },
saveIdentitySettings(data) {
return this.request('/identity/settings', { method: 'POST', body: JSON.stringify(data) })
},
testIdentitySettings(data = {}) {
return this.request('/identity/settings/test', { method: 'POST', body: JSON.stringify(data) })
},
getCreditSettings() { return this.request('/assets/settings') },
saveCreditSettings(data) { return this.request('/assets/settings', { method: 'POST', body: JSON.stringify(data) }) },
listCreditUsers(params = {}) {
@@ -34,8 +27,4 @@ export const usersApiMethods = {
adjustUserCredits(id, asset, delta, remark) {
return this.request(`/assets/accounts/${id}/adjust`, { method: 'POST', body: JSON.stringify({ asset, delta, remark }) })
},
getCreditLog(params = {}) {
const qs = new URLSearchParams(params).toString()
return this.request('/assets/transactions' + (qs ? `?${qs}` : ''))
},
}
+32 -32
View File
@@ -1,35 +1,35 @@
<template>
<div class="aksrt-deferred-page">
<div class="aksrt-deferred-page__icon">
<div class="jio-deferred-page">
<div class="jio-deferred-page__icon">
<FaIcon :icon="resolvedIcon" :size="48" />
</div>
<h2 class="aksrt-deferred-page__title">{{ resolvedTitle }}</h2>
<p class="aksrt-deferred-page__desc">{{ resolvedDesc }}</p>
<div class="aksrt-deferred-page__meta">
<h2 class="jio-deferred-page__title">{{ resolvedTitle }}</h2>
<p class="jio-deferred-page__desc">{{ resolvedDesc }}</p>
<div class="jio-deferred-page__meta">
<n-tag size="small" type="info">{{ resolvedStatus }}</n-tag>
<n-tag v-if="resolvedPhase" size="small" type="warning">Phase {{ resolvedPhase }}</n-tag>
</div>
<div v-if="resolvedDependsOn.length" class="aksrt-deferred-page__depends">
<span class="aksrt-deferred-page__label">依赖</span>
<div class="aksrt-deferred-page__depends-list">
<div v-if="resolvedDependsOn.length" class="jio-deferred-page__depends">
<span class="jio-deferred-page__label">依赖</span>
<div class="jio-deferred-page__depends-list">
<n-tag v-for="item in resolvedDependsOn" :key="item" size="small" type="default">{{ item }}</n-tag>
</div>
</div>
<div v-if="resolvedReason" class="aksrt-deferred-page__reason">
<span class="aksrt-deferred-page__label">原因</span>
<div v-if="resolvedReason" class="jio-deferred-page__reason">
<span class="jio-deferred-page__label">原因</span>
<p>{{ resolvedReason }}</p>
</div>
<div v-if="resolvedFeatures && resolvedFeatures.length" class="aksrt-deferred-page__features">
<div v-if="resolvedFeatures && resolvedFeatures.length" class="jio-deferred-page__features">
<div
v-for="(feat, i) in resolvedFeatures"
:key="i"
class="aksrt-deferred-page__feature"
class="jio-deferred-page__feature"
>
<FaIcon icon="fa fas fa-check-circle" :size="14" />
<span>{{ feat }}</span>
</div>
</div>
<div class="aksrt-deferred-page__badge">
<div class="jio-deferred-page__badge">
<n-tag type="info" size="small">即将推出</n-tag>
</div>
</div>
@@ -78,7 +78,7 @@ const resolvedFeatures = computed(() => {
</script>
<style scoped>
.aksrt-deferred-page {
.jio-deferred-page {
display: flex;
flex-direction: column;
align-items: center;
@@ -88,75 +88,75 @@ const resolvedFeatures = computed(() => {
padding: 40px 20px;
}
.aksrt-deferred-page__icon {
color: var(--aksrt-color-border-strong);
.jio-deferred-page__icon {
color: var(--jio-color-border-strong);
margin-bottom: 20px;
}
.aksrt-deferred-page__title {
.jio-deferred-page__title {
margin: 0 0 10px;
font-size: 20px;
font-weight: 600;
color: var(--aksrt-color-text);
color: var(--jio-color-text);
}
.aksrt-deferred-page__desc {
.jio-deferred-page__desc {
margin: 0 0 20px;
font-size: 14px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
max-width: 400px;
line-height: 1.6;
}
.aksrt-deferred-page__meta {
.jio-deferred-page__meta {
display: flex;
gap: 10px;
margin-bottom: 18px;
}
.aksrt-deferred-page__depends,
.aksrt-deferred-page__reason {
.jio-deferred-page__depends,
.jio-deferred-page__reason {
width: min(520px, 100%);
margin-bottom: 18px;
text-align: left;
}
.aksrt-deferred-page__label {
.jio-deferred-page__label {
display: inline-block;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: var(--aksrt-color-text-secondary);
color: var(--jio-color-text-secondary);
}
.aksrt-deferred-page__depends-list {
.jio-deferred-page__depends-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.aksrt-deferred-page__reason p {
.jio-deferred-page__reason p {
margin: 0;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
line-height: 1.7;
}
.aksrt-deferred-page__features {
.jio-deferred-page__features {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.aksrt-deferred-page__feature {
.jio-deferred-page__feature {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
}
.aksrt-deferred-page__feature :deep(i) {
.jio-deferred-page__feature :deep(i) {
color: #52c41a;
}
</style>
+11 -11
View File
@@ -1,10 +1,10 @@
<template>
<div class="aksrt-empty-state">
<div class="aksrt-empty-state__icon">
<div class="jio-empty-state">
<div class="jio-empty-state__icon">
<i :class="icon" style="font-size: 48px;"></i>
</div>
<h3 class="aksrt-empty-state__title">{{ title }}</h3>
<p class="aksrt-empty-state__desc">{{ desc }}</p>
<h3 class="jio-empty-state__title">{{ title }}</h3>
<p class="jio-empty-state__desc">{{ desc }}</p>
</div>
</template>
@@ -17,7 +17,7 @@ defineProps({
</script>
<style scoped>
.aksrt-empty-state {
.jio-empty-state {
display: flex;
flex-direction: column;
align-items: center;
@@ -26,22 +26,22 @@ defineProps({
text-align: center;
}
.aksrt-empty-state__icon {
.jio-empty-state__icon {
margin-bottom: 20px;
color: var(--aksrt-color-border-strong);
color: var(--jio-color-border-strong);
}
.aksrt-empty-state__title {
.jio-empty-state__title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: var(--aksrt-color-text);
color: var(--jio-color-text);
}
.aksrt-empty-state__desc {
.jio-empty-state__desc {
margin: 0;
font-size: 13px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
max-width: 360px;
line-height: 1.65;
}
+16 -16
View File
@@ -1,7 +1,7 @@
<template>
<header class="aksrt-mobile-topbar">
<header class="jio-mobile-topbar">
<button
class="aksrt-mobile-topbar__menu"
class="jio-mobile-topbar__menu"
type="button"
aria-label="打开菜单"
@click="$emit('toggle')"
@@ -13,35 +13,35 @@
</svg>
</button>
<div class="aksrt-mobile-topbar__title">{{ title }}</div>
<div class="jio-mobile-topbar__title">{{ title }}</div>
<div class="aksrt-mobile-topbar__brand">AK</div>
<div class="jio-mobile-topbar__brand">AK</div>
</header>
</template>
<script setup>
defineProps({
title: { type: String, default: 'AKSRT' },
title: { type: String, default: 'Jio' },
})
defineEmits(['toggle'])
</script>
<style scoped>
.aksrt-mobile-topbar {
.jio-mobile-topbar {
display: flex;
align-items: center;
gap: 10px;
height: 48px;
padding: 0 14px;
background: var(--aksrt-color-surface);
border-bottom: 1px solid var(--aksrt-color-border);
background: var(--jio-color-surface);
border-bottom: 1px solid var(--jio-color-border);
position: sticky;
top: 0;
z-index: 50;
}
.aksrt-mobile-topbar__menu {
.jio-mobile-topbar__menu {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -50,26 +50,26 @@ defineEmits(['toggle'])
border: none;
border-radius: 8px;
background: transparent;
color: var(--aksrt-color-text);
color: var(--jio-color-text);
cursor: pointer;
margin-left: -6px;
}
.aksrt-mobile-topbar__menu:hover {
background: var(--aksrt-color-primary-soft);
.jio-mobile-topbar__menu:hover {
background: var(--jio-color-primary-soft);
}
.aksrt-mobile-topbar__title {
.jio-mobile-topbar__title {
flex: 1 1 auto;
font-size: 15px;
font-weight: 600;
color: var(--aksrt-color-text);
color: var(--jio-color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aksrt-mobile-topbar__brand {
.jio-mobile-topbar__brand {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
@@ -77,7 +77,7 @@ defineEmits(['toggle'])
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--aksrt-color-primary);
background: var(--jio-color-primary);
color: #fff;
font-size: 11px;
font-weight: 700;
+17 -17
View File
@@ -1,15 +1,15 @@
<template>
<header class="aksrt-page-header">
<div class="aksrt-page-header__main">
<h2 class="aksrt-page-header__title">{{ title }}</h2>
<p v-if="desc" class="aksrt-page-header__desc">
<header class="jio-page-header">
<div class="jio-page-header__main">
<h2 class="jio-page-header__title">{{ title }}</h2>
<p v-if="desc" class="jio-page-header__desc">
<slot name="desc">{{ desc }}</slot>
</p>
<div v-else-if="$slots.desc" class="aksrt-page-header__desc">
<div v-else-if="$slots.desc" class="jio-page-header__desc">
<slot name="desc" />
</div>
</div>
<div v-if="$slots.actions" class="aksrt-page-header__actions">
<div v-if="$slots.actions" class="jio-page-header__actions">
<slot name="actions" />
</div>
</header>
@@ -23,7 +23,7 @@ defineProps({
</script>
<style scoped>
.aksrt-page-header {
.jio-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
@@ -32,28 +32,28 @@ defineProps({
flex-wrap: wrap;
}
.aksrt-page-header__main {
.jio-page-header__main {
flex: 1 1 auto;
min-width: 0;
}
.aksrt-page-header__title {
.jio-page-header__title {
margin: 0 0 6px;
color: var(--aksrt-color-text);
color: var(--jio-color-text);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.3;
}
.aksrt-page-header__desc {
.jio-page-header__desc {
margin: 0;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
font-size: 13px;
line-height: 1.65;
}
.aksrt-page-header__actions {
.jio-page-header__actions {
flex: 0 0 auto;
display: flex;
align-items: center;
@@ -61,20 +61,20 @@ defineProps({
}
@media (max-width: 768px) {
.aksrt-page-header {
.jio-page-header {
margin-bottom: 14px;
gap: 10px;
}
.aksrt-page-header__title {
.jio-page-header__title {
font-size: 18px;
}
.aksrt-page-header__desc {
.jio-page-header__desc {
font-size: 12px;
}
.aksrt-page-header__actions {
.jio-page-header__actions {
width: 100%;
}
}
@@ -38,8 +38,9 @@
<n-form-item label="AccessKey Secret">
<n-input
v-model:value="model.access_key_secret"
type="password"
type="text"
show-password-on="click"
class="jio-secret-input"
autocomplete="new-password"
placeholder="阿里云 RAM 用户 AccessKey Secret"
@update:value="onSecretInput('access_key_secret', $event)"
@@ -5,7 +5,7 @@
<n-input v-model:value="model.site_key" placeholder="Cloudflare Turnstile Site Key" autocomplete="off" />
</n-form-item>
<n-form-item label="Secret Key">
<n-input v-model:value="model.secret_key" type="password" show-password-on="click" autocomplete="new-password" @update:value="onSecretInput('secret_key', $event)" />
<n-input v-model:value="model.secret_key" type="text" show-password-on="click" class="jio-secret-input" autocomplete="new-password" @update:value="onSecretInput('secret_key', $event)" />
<n-button v-if="model.secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('secret_key')">清除</n-button>
</n-form-item>
<n-form-item label="主题">
@@ -5,7 +5,7 @@
<n-input v-model:value="model.captcha_id" placeholder="极验 GeeTest CaptchaID" autocomplete="off" />
</n-form-item>
<n-form-item label="Captcha Key">
<n-input v-model:value="model.captcha_key" type="password" show-password-on="click" autocomplete="new-password" @update:value="onSecretInput('captcha_key', $event)" />
<n-input v-model:value="model.captcha_key" type="text" show-password-on="click" class="jio-secret-input" autocomplete="new-password" @update:value="onSecretInput('captcha_key', $event)" />
<n-button v-if="model.captcha_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('captcha_key')">清除</n-button>
</n-form-item>
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
@@ -9,7 +9,7 @@
<n-input v-model:value="model.secret_id" placeholder="腾讯云 API SecretId" autocomplete="off" />
</n-form-item>
<n-form-item label="SecretKey">
<n-input v-model:value="model.secret_key" type="password" placeholder="腾讯云 API SecretKey" autocomplete="new-password" show-password-on="click" @update:value="onSecretInput('secret_key', $event)" />
<n-input v-model:value="model.secret_key" type="text" class="jio-secret-input" placeholder="腾讯云 API SecretKey" autocomplete="off" @update:value="onSecretInput('secret_key', $event)" />
<n-button v-if="model.secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('secret_key')">清除</n-button>
</n-form-item>
@@ -18,7 +18,7 @@
<n-input v-model:value="model.app_id" placeholder="从验证码控制台获取 CaptchaAppId" autocomplete="off" />
</n-form-item>
<n-form-item label="AppSecretKey">
<n-input v-model:value="model.app_secret_key" type="password" placeholder="从验证码控制台获取 AppSecretKey" autocomplete="new-password" show-password-on="click" @update:value="onSecretInput('app_secret_key', $event)" />
<n-input v-model:value="model.app_secret_key" type="text" class="jio-secret-input" placeholder="从验证码控制台获取 AppSecretKey" autocomplete="off" @update:value="onSecretInput('app_secret_key', $event)" />
<n-button v-if="model.app_secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('app_secret_key')">清除</n-button>
<template #feedback>请从「验证管理 → 基础配置」完整复制,注意区分 I/l/1/O/0</template>
</n-form-item>
@@ -0,0 +1,84 @@
<template>
<n-form label-placement="left" label-width="110" class="jio-form float-common-fields">
<n-form-item label="PC端显示" :show-feedback="false">
<n-switch :value="!!modelValue.pc_s" @update:value="update('pc_s', $event)" />
</n-form-item>
<n-form-item label="移动端显示" :show-feedback="false">
<n-switch :value="!!modelValue.m_s" @update:value="update('m_s', $event)" />
</n-form-item>
<n-form-item label="颜色" :show-feedback="false">
<div class="float-common-fields__colors">
<div class="float-common-fields__color">
<span>自定义图标颜色</span>
<JioColorPicker
:model-value="modelValue.color?.color || ''"
alpha
clearable
@update:model-value="updateColor('color', $event)"
/>
</div>
<div class="float-common-fields__color">
<span>自定义背景颜色</span>
<JioColorPicker
:model-value="modelValue.color?.bg || ''"
alpha
clearable
@update:model-value="updateColor('bg', $event)"
/>
</div>
</div>
</n-form-item>
<div class="float-common-fields__desc">自定义颜色请注意日间、夜间模式的适配</div>
</n-form>
</template>
<script setup>
import JioColorPicker from '@/components/form/JioColorPicker.vue'
const props = defineProps({
modelValue: { type: Object, required: true },
})
const emit = defineEmits(['update:modelValue'])
function update(key, value) {
emit('update:modelValue', { ...props.modelValue, [key]: value })
}
function updateColor(key, value) {
emit('update:modelValue', {
...props.modelValue,
color: { ...(props.modelValue.color || { color: '', bg: '' }), [key]: value || '' },
})
}
</script>
<style scoped>
.float-common-fields {
display: flex;
flex-direction: column;
gap: 10px;
}
.float-common-fields__colors {
display: flex;
gap: 16px;
flex-wrap: wrap;
width: 100%;
}
.float-common-fields__color {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 180px;
flex: 1;
font-size: 12px;
color: var(--n-text-color-3);
}
.float-common-fields__desc {
font-size: 12px;
color: var(--n-text-color-3);
padding-left: 110px;
}
</style>
@@ -0,0 +1,109 @@
<template>
<div class="float-btn-panel">
<div class="jio-sortable__head" v-bind="headAttrs">
<JioSortHandle @click.stop />
<div class="jio-sortable__main">
<div class="jio-sortable__title-row">
<span class="jio-sortable__title">{{ meta.title }}</span>
<template v-if="modelValue.type !== 'more'">
<n-tag v-if="modelValue.pc_s" size="small" type="info" :bordered="false">PC</n-tag>
<n-tag v-if="modelValue.m_s" size="small" type="warning" :bordered="false">移动</n-tag>
<n-tag v-if="!modelValue.pc_s && !modelValue.m_s" size="small" :bordered="false">未显示</n-tag>
</template>
<n-tag v-else size="small" :bordered="false">{{ modelValue.buttons.length }} 个</n-tag>
</div>
<span v-if="meta.warn" class="jio-sortable__desc float-btn-panel__warn">
<i class="fa fas fa-info-circle" aria-hidden="true" /> {{ meta.warn }}
</span>
</div>
<i
class="fa fas jio-sortable__toggle"
:class="expanded ? 'fa-chevron-down' : 'fa-chevron-right'"
aria-hidden="true"
/>
</div>
<div v-show="expanded" class="jio-sortable__body">
<div class="float-btn-panel__subtitle">{{ meta.panel }}</div>
<FloatMoreButtonsEditor
v-if="modelValue.type === 'more'"
:model-value="modelValue.buttons"
@update:model-value="patch({ buttons: $event })"
/>
<template v-else>
<FloatBtnCommonFields :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" />
<n-form label-placement="left" label-width="110" class="jio-form float-btn-panel__extra">
<n-form-item v-if="modelValue.type === 'qrcode'" label="一句话简介">
<n-input :value="modelValue.desc" @update:value="patch({ desc: $event })" />
</n-form-item>
<n-form-item v-if="modelValue.type === 'add'" label="按钮图标">
<FaIconPicker v-model="addIcon" placeholder="选择图标" />
</n-form-item>
<n-form-item v-if="modelValue.type === 'service_qq'" label="QQ号码">
<n-input :value="modelValue.qq" placeholder="仅填写数字" @update:value="patch({ qq: $event })" />
</n-form-item>
<n-form-item v-if="modelValue.type === 'service_wechat'" label="微信二维码">
<JioMediaField
:model-value="modelValue.wechat_img"
placeholder="选择二维码图片"
@update:model-value="patch({ wechat_img: $event })"
/>
</n-form-item>
</n-form>
</template>
</div>
</div>
</template>
<script setup>
/** 悬浮按钮排序列表中的单个卡片;外层 .jio-sortable__item 由 FloatingButtonsSettings 提供。 */
import { computed, ref } from 'vue'
import FaIconPicker from '@/components/form/FaIconPicker.vue'
import JioMediaField from '@/components/form/JioMediaField.vue'
import JioSortHandle from '@/components/form/JioSortHandle.vue'
import { collapseHeadAttrs } from '@/components/form/useCollapsibleItems.js'
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
import FloatMoreButtonsEditor from './FloatMoreButtonsEditor.vue'
import { FLOAT_BTN_META } from './floating-defaults.js'
const props = defineProps({
modelValue: { type: Object, required: true },
})
const emit = defineEmits(['update:modelValue'])
const expanded = ref(false)
const headAttrs = computed(() => collapseHeadAttrs(expanded.value, () => { expanded.value = !expanded.value }))
const meta = computed(() => FLOAT_BTN_META[props.modelValue.type] || { title: props.modelValue.type, panel: '' })
const addIcon = computed({
get: () => props.modelValue.icon || '',
set: (value) => patch({ icon: value || '' }),
})
function patch(partial) {
emit('update:modelValue', { ...props.modelValue, ...partial })
}
</script>
<style scoped>
.float-btn-panel__warn {
color: var(--jio-color-warning, #D97706);
}
.float-btn-panel__subtitle {
margin-bottom: 12px;
color: var(--jio-color-text);
font-size: 13px;
font-weight: 600;
}
.float-btn-panel__extra {
margin-top: 12px;
}
</style>
@@ -0,0 +1,158 @@
<template>
<div class="float-more-editor">
<n-collapse :default-expanded-names="[]" accordion>
<n-collapse-item
v-for="(btn, index) in modelValue"
:key="index"
:name="index"
>
<template #header>
<span class="float-more-editor__head">
<IconValue v-if="btn.icon" :value="btn.icon" />
<span>{{ btn.desc || `按钮 ${index + 1}` }}</span>
</span>
</template>
<template #header-extra>
<n-button
size="tiny"
quaternary
type="error"
:disabled="modelValue.length <= 1"
@click.stop="remove(index)"
>
<template #icon><i class="fa fas fa-trash-alt" /></template>
</n-button>
</template>
<n-form label-placement="left" label-width="110" class="jio-form">
<n-form-item label="按钮简介">
<n-input :value="btn.desc" @update:value="patch(index, { desc: $event })" />
</n-form-item>
<n-form-item label="按钮图标">
<FaIconPicker :model-value="btn.icon" placeholder="选择图标" @update:model-value="patch(index, { icon: $event || '' })" />
</n-form-item>
<n-form-item v-if="!btn.hover" label="按钮链接">
<div class="float-more-editor__link">
<n-input
:value="btn.link.url"
placeholder="https://"
@update:value="patch(index, { link: { ...btn.link, url: $event } })"
/>
<n-checkbox
:checked="btn.link.target === '_blank'"
@update:checked="patch(index, { link: { ...btn.link, target: $event ? '_blank' : '' } })"
>
新窗口打开
</n-checkbox>
</div>
</n-form-item>
<n-form-item label="悬浮内容">
<div class="float-more-editor__block">
<div class="float-more-editor__subtitle">鼠标移到此处时显示内容</div>
<n-input
type="textarea"
:value="btn.hover"
:autosize="{ minRows: 2, maxRows: 8 }"
@update:value="patch(index, { hover: $event })"
/>
<div class="float-more-editor__desc">设置此处后,按钮链接将失效<br>支持HTML代码,请注意代码规范及标签闭合</div>
</div>
</n-form-item>
<n-form-item v-if="btn.hover" label="悬浮内容宽度">
<div class="float-more-editor__block">
<n-input-number
:value="btn.hover_width"
:min="50"
:max="500"
:step="10"
style="width: 180px;"
@update:value="patch(index, { hover_width: $event ?? 200 })"
>
<template #suffix>PX</template>
</n-input-number>
<div class="float-more-editor__desc">移动端宽度最大240px</div>
</div>
</n-form-item>
</n-form>
<FloatBtnCommonFields :model-value="btn" @update:model-value="replace(index, $event)" />
</n-collapse-item>
</n-collapse>
<div class="float-more-editor__footer">
<n-button size="small" type="primary" @click="add">
<template #icon><i class="fa fas fa-plus" /></template>
添加按钮
</n-button>
<span class="float-more-editor__desc">此处内容至少保留一个,如一个都无需显示请关闭PC端和移动端显示即可</span>
</div>
</div>
</template>
<script setup>
import FaIconPicker from '@/components/form/FaIconPicker.vue'
import IconValue from '@/components/form/IconValue.vue'
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
import { defaultMoreButton } from './floating-defaults.js'
const props = defineProps({
modelValue: { type: Array, required: true },
})
const emit = defineEmits(['update:modelValue'])
function replace(index, value) {
const next = props.modelValue.slice()
next.splice(index, 1, value)
emit('update:modelValue', next)
}
function patch(index, partial) {
replace(index, { ...props.modelValue[index], ...partial })
}
function add() {
emit('update:modelValue', [...props.modelValue, defaultMoreButton()])
}
function remove(index) {
if (props.modelValue.length <= 1) return
const next = props.modelValue.slice()
next.splice(index, 1)
emit('update:modelValue', next)
}
</script>
<style scoped>
.float-more-editor__head {
display: inline-flex;
align-items: center;
gap: 8px;
}
.float-more-editor__link {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.float-more-editor__block {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.float-more-editor__subtitle,
.float-more-editor__desc {
font-size: 12px;
color: var(--n-text-color-3);
}
.float-more-editor__footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,103 @@
/**
* 右侧悬浮按钮:类型元数据与默认值(与 src/Settings/FloatButtons.php 保持一致)。
*/
export const emptyColor = () => ({ color: '', bg: '' })
/** 类型元数据:面板标题、折叠标题、黄色提示。 */
export const FLOAT_BTN_META = {
back_top: { title: '返回顶部', panel: '返回顶部', warn: '此按钮只能放置在最顶部或者最底部!' },
qrcode: { title: '当前页面二维码', panel: '当前页面二维码' },
add: { title: 'Add发布内容', panel: '发布投稿', warn: '此按钮尽量放在上方,否则会显示遮挡' },
theme_mode: { title: '日夜主题切换', panel: '日夜主题切换' },
pay_vip: { title: 'VIP购买', panel: 'VIP购买' },
service_qq: { title: 'QQ客服', panel: 'QQ客服' },
service_wechat: { title: '微信客服', panel: '微信客服' },
more: { title: '更多按钮', panel: '添加更多自定义按钮' },
}
/** 「更多按钮」出厂默认项:不预设任何第三方指向,只给一个图标按钮。 */
export function giftMoreButton() {
return {
desc: '',
icon: 'jiosvg-gift-color',
link: { url: '', target: '' },
hover: '',
hover_width: 200,
pc_s: true,
m_s: true,
color: { color: '', bg: 'rgba(255, 111, 6, 0.2)' },
}
}
/** 「添加按钮」新增项默认值。 */
export function defaultMoreButton() {
return {
desc: '',
icon: 'fa fas fa-heart',
link: { url: '', target: '' },
hover: '',
hover_width: 200,
pc_s: true,
m_s: true,
color: { color: '', bg: '' },
}
}
export function defaultItems() {
return [
{ type: 'more', buttons: [giftMoreButton()] },
{ type: 'pay_vip', pc_s: true, m_s: true, color: { color: '#f2c97d', bg: 'rgba(62,62,67,0.9)' } },
{ type: 'add', pc_s: true, m_s: false, color: emptyColor(), icon: 'fa fas fa-pencil-alt' },
{ type: 'service_qq', pc_s: false, m_s: false, color: emptyColor(), qq: '' },
{ type: 'service_wechat', pc_s: true, m_s: true, color: emptyColor(), wechat_img: '' },
{ type: 'theme_mode', pc_s: false, m_s: false, color: emptyColor() },
{ type: 'qrcode', pc_s: true, m_s: false, color: emptyColor(), desc: '在手机上浏览此页面' },
{ type: 'back_top', pc_s: true, m_s: true, color: emptyColor() },
]
}
export function defaultSettings() {
return {
style: 'round',
position: 'bottom',
filter_blur: ['m_s'],
scroll_hide: false,
scroll_down_hide: true,
items: defaultItems(),
}
}
/** 规范化颜色组:确保 color/bg 都为字符串。 */
export function normalizeColor(color) {
const c = color && typeof color === 'object' ? color : {}
return { color: c.color || '', bg: c.bg || '' }
}
/** 用默认值补齐服务端返回的按钮(按服务端顺序)。 */
export function normalizeItems(items) {
const defaults = Object.fromEntries(defaultItems().map((item) => [item.type, item]))
const source = Array.isArray(items) && items.length ? items : defaultItems()
const out = []
const seen = new Set()
for (const raw of source) {
const base = defaults[raw?.type]
if (!base || seen.has(raw.type)) continue
seen.add(raw.type)
if (raw.type === 'more') {
const buttons = (Array.isArray(raw.buttons) && raw.buttons.length ? raw.buttons : base.buttons).map((btn) => ({
...defaultMoreButton(),
...btn,
link: { url: btn?.link?.url || '', target: btn?.link?.target || '' },
color: normalizeColor(btn?.color),
}))
out.push({ type: 'more', buttons })
continue
}
out.push({ ...base, ...raw, color: normalizeColor(raw.color) })
}
for (const item of defaultItems()) {
if (!seen.has(item.type)) out.push(item)
}
return out
}
@@ -1,128 +0,0 @@
<template>
<div class="aksrt-array-table">
<n-data-table
:columns="tableColumns"
:data="modelValue || []"
:bordered="false"
size="small"
max-height="400"
/>
<n-button
size="small"
dashed
block
class="aksrt-array-table__add"
@click="addItem"
>
<FaIcon icon="fa fas fa-plus" :size="12" />
{{ addText || '添加行' }}
</n-button>
</div>
</template>
<script setup>
/**
* AksArrayTable — table-style editor for array data.
* Renders rows in a data table with inline editing.
*/
import { computed, h } from 'vue'
import { NInput, NSwitch, NButton, NSelect, NInputNumber } from 'naive-ui'
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
/** Column definitions: [{ key, label, type?, options?, width? }] */
columns: { type: Array, required: true },
defaults: { type: Object, default: () => ({}) },
addText: { type: String, default: '' },
maxRows: { type: Number, default: Infinity },
})
const emit = defineEmits(['update:modelValue'])
function updateCell(rowIndex, key, value) {
const list = [...(props.modelValue || [])]
list[rowIndex] = { ...list[rowIndex], [key]: value }
emit('update:modelValue', list)
}
function removeItem(index) {
const list = (props.modelValue || []).filter((_, i) => i !== index)
emit('update:modelValue', list)
}
function addItem() {
if ((props.modelValue || []).length >= props.maxRows) return
const item = {}
for (const col of props.columns) {
item[col.key] = col.default ?? (col.type === 'switch' ? false : '')
}
emit('update:modelValue', [...(props.modelValue || []), { ...item, ...props.defaults }])
}
function moveUp(index) {
if (index <= 0) return
const list = [...(props.modelValue || [])]
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
emit('update:modelValue', list)
}
function moveDown(index) {
const list = [...(props.modelValue || [])]
if (index >= list.length - 1) return
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
emit('update:modelValue', list)
}
const tableColumns = computed(() => {
const cols = props.columns.map(col => ({
title: col.label,
key: col.key,
width: col.width || 150,
render(row, rowIndex) {
const val = row[col.key]
const update = (v) => updateCell(rowIndex, col.key, v)
if (col.type === 'switch') {
return h(NSwitch, { value: val, size: 'small', 'onUpdate:value': update })
}
if (col.type === 'select') {
return h(NSelect, { value: val, options: col.options || [], size: 'small', 'onUpdate:value': update })
}
if (col.type === 'number') {
return h(NInputNumber, { value: val, size: 'small', style: 'width:100%', 'onUpdate:value': update })
}
return h(NInput, { value: val, size: 'small', 'onUpdate:value': update })
},
}))
// Add actions column
cols.push({
title: '操作',
key: '_actions',
width: 100,
render(row, rowIndex) {
return h('div', { style: 'display:flex;gap:2px' }, [
rowIndex > 0 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveUp(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fa fas fa-arrow-up', size: 12 }),
}) : null,
rowIndex < (props.modelValue?.length || 0) - 1 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveDown(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fa fas fa-arrow-down', size: 12 }),
}) : null,
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => removeItem(rowIndex) }, {
default: () => h(FaIcon, { icon: 'fa fas fa-trash-alt', size: 12 }),
}),
])
},
})
return cols
})
</script>
<style scoped>
.aksrt-array-table__add {
margin-top: 10px;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -1,185 +0,0 @@
<template>
<div class="aksrt-repeater">
<div v-if="modelValue && modelValue.length" class="aksrt-repeater__list">
<div
v-for="(item, index) in modelValue"
:key="index"
class="aksrt-repeater__item"
>
<div class="aksrt-repeater__item-header">
<span class="aksrt-repeater__item-index">#{{ index + 1 }}</span>
<div class="aksrt-repeater__item-actions">
<n-button
v-if="index > 0"
size="tiny"
quaternary
@click="moveUp(index)"
>
<FaIcon icon="fa fas fa-arrow-up" :size="12" />
</n-button>
<n-button
v-if="index < modelValue.length - 1"
size="tiny"
quaternary
@click="moveDown(index)"
>
<FaIcon icon="fa fas fa-arrow-down" :size="12" />
</n-button>
<n-button
size="tiny"
quaternary
type="error"
@click="removeItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button>
</div>
</div>
<div class="aksrt-repeater__item-fields">
<AksField
v-for="field in fields"
:key="field.key"
:schema="field"
:model-value="item[field.key]"
@update:model-value="updateField(index, field.key, $event)"
/>
</div>
</div>
</div>
<div v-else class="aksrt-repeater__empty">
<FaIcon icon="fa fas fa-inbox" :size="20" />
<span>{{ emptyText || '暂无数据' }}</span>
</div>
<n-button
size="small"
dashed
block
class="aksrt-repeater__add"
@click="addItem"
>
<FaIcon icon="fa fas fa-plus" :size="12" />
{{ addText || '添加项目' }}
</n-button>
</div>
</template>
<script setup>
/**
* AksRepeaterField — dynamic add/remove rows of structured data.
* Each row renders fields based on the provided field schemas.
*/
import AksField from './AksField.vue'
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
/** Array of field schemas for each row */
fields: { type: Array, required: true },
/** Default value for a new item */
defaults: { type: Object, default: () => ({}) },
emptyText: { type: String, default: '' },
addText: { type: String, default: '' },
maxItems: { type: Number, default: Infinity },
})
const emit = defineEmits(['update:modelValue'])
function createItem() {
const item = {}
for (const field of props.fields) {
item[field.key] = field.default ?? (field.type === 'switch' ? false : '')
}
return { ...item, ...props.defaults }
}
function addItem() {
if (props.modelValue.length >= props.maxItems) return
const list = [...props.modelValue, createItem()]
emit('update:modelValue', list)
}
function removeItem(index) {
const list = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', list)
}
function moveUp(index) {
if (index <= 0) return
const list = [...props.modelValue]
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
emit('update:modelValue', list)
}
function moveDown(index) {
if (index >= props.modelValue.length - 1) return
const list = [...props.modelValue]
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
emit('update:modelValue', list)
}
function updateField(index, key, value) {
const list = [...props.modelValue]
list[index] = { ...list[index], [key]: value }
emit('update:modelValue', list)
}
</script>
<style scoped>
.aksrt-repeater__list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.aksrt-repeater__item {
border: 1px solid var(--aksrt-color-border);
border-radius: 8px;
padding: 12px;
background: var(--aksrt-color-canvas);
}
.aksrt-repeater__item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.aksrt-repeater__item-index {
font-size: 12px;
font-weight: 600;
color: var(--aksrt-color-primary);
background: var(--aksrt-color-primary-soft);
padding: 2px 8px;
border-radius: 4px;
}
.aksrt-repeater__item-actions {
display: flex;
gap: 2px;
}
.aksrt-repeater__item-fields {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-repeater__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px;
color: var(--aksrt-color-border-strong);
font-size: 13px;
}
.aksrt-repeater__add {
margin-top: 4px;
}
</style>
@@ -1,5 +1,5 @@
<template>
<div class="aksrt-fa-icon-picker">
<div class="jio-fa-icon-picker">
<n-popover v-model:show="popoverShow" trigger="click" placement="bottom-start" :width="460" :style="{ padding: 0 }">
<template #trigger>
<n-input
@@ -19,8 +19,8 @@
</n-input>
</template>
<div class="aksrt-icon-picker__panel" @click="onPanelClick">
<div class="aksrt-icon-picker__search">
<div class="jio-icon-picker__panel" @click="onPanelClick">
<div class="jio-icon-picker__search">
<n-input v-model:value="search" placeholder="搜索图标..." size="small" clearable>
<template #prefix>
<FaIcon icon="fa fas fa-search" :size="12" />
@@ -28,12 +28,12 @@
</n-input>
</div>
<div class="aksrt-icon-picker__tabs">
<div class="jio-icon-picker__tabs">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="aksrt-icon-picker__tab"
class="jio-icon-picker__tab"
:class="{ 'is-active': activeTab === tab.id }"
@click="activeTab = tab.id"
>
@@ -42,23 +42,23 @@
</div>
<!-- Custom SVG tab -->
<div v-if="activeTab === 'custom'" class="aksrt-icon-picker__custom">
<div v-if="activeTab === 'custom'" class="jio-icon-picker__custom">
<n-input v-model:value="customCode" type="textarea" placeholder='&lt;svg viewBox="0 0 1024 1024"&gt;&lt;path d="…"&gt;&lt;/path&gt;&lt;/svg&gt;' :rows="4" />
<div class="aksrt-icon-picker__custom-actions">
<span class="aksrt-icon-picker__custom-preview" v-html="customPreview"></span>
<div class="jio-icon-picker__custom-actions">
<span class="jio-icon-picker__custom-preview" v-html="customPreview"></span>
<n-button size="small" type="primary" :disabled="!customPreview" @click="insertCustom">
确认插入
</n-button>
</div>
<div
v-if="sessionCustom.length"
class="aksrt-icon-picker__grid aksrt-icon-picker__grid--session"
data-aksrt-icon-grid="custom"
class="jio-icon-picker__grid jio-icon-picker__grid--session"
data-jio-icon-grid="custom"
>
<div
v-for="(svg, i) in sessionCustom"
:key="i"
class="aksrt-icon-picker__item"
class="jio-icon-picker__item"
:class="{ 'is-active': modelValue === `customsvg-${svg}` }"
title="customsvg"
:data-icon="`customsvg-${svg}`"
@@ -68,11 +68,11 @@
</div>
<!-- Catalog tab -->
<div v-else class="aksrt-icon-picker__grid" data-aksrt-icon-grid="catalog">
<div v-else class="jio-icon-picker__grid" data-jio-icon-grid="catalog">
<div
v-for="icon in filteredIcons"
:key="icon"
class="aksrt-icon-picker__item"
class="jio-icon-picker__item"
:class="{ 'is-active': modelValue === composeIconValue(activeGroup, icon) }"
:title="icon"
:data-icon="composeIconValue(activeGroup, icon)"
@@ -80,11 +80,11 @@
<i v-if="activeGroup.type === 'fa'" :class="[activeTab, 'fa-' + icon]"></i>
<span
v-else
class="aksrt-icon-picker__preview-svg"
v-html="zibsvgMarkup(`zibsvg-${icon}`)"
class="jio-icon-picker__preview-svg"
v-html="jiosvgMarkup(`jiosvg-${icon}`)"
></span>
</div>
<div v-if="filteredIcons.length === 0" class="aksrt-icon-picker__empty">
<div v-if="filteredIcons.length === 0" class="jio-icon-picker__empty">
未找到匹配图标
</div>
</div>
@@ -96,7 +96,7 @@
<script setup>
/**
* FaIconPicker — universal icon picker backed by the server catalog
* (GET /aksrt/v1/icons/catalog): full FA5 free set, the theme's built-in
* (GET /jio/v1/icons/catalog): full FA5 free set, the theme's built-in
* SVG sprite keys and inline custom SVG values.
*/
import { ref, computed, onMounted } from 'vue'
@@ -105,7 +105,7 @@ import IconValue from './IconValue.vue'
import api from '@/api/index.js'
import {
cleanSvg,
zibsvgMarkup,
jiosvgMarkup,
composeIconValue,
} from '@/utils/icons.js'
@@ -120,7 +120,7 @@ const FALLBACK_TABS = [
{ id: 'fas', title: 'Solid', type: 'fa', icons: [] },
{ id: 'far', title: 'Regular', type: 'fa', icons: [] },
{ id: 'fab', title: 'Brands', type: 'fa', icons: [] },
{ id: 'zibsvg', title: '内置 SVG', type: 'svg', icons: [] },
{ id: 'jiosvg', title: '内置 SVG', type: 'svg', icons: [] },
{ id: 'custom', title: '自定义', type: 'custom', icons: [] },
]
@@ -176,53 +176,53 @@ onMounted(async () => {
</script>
<style scoped>
.aksrt-icon-picker__panel {
/* The popover teleports to <body>, outside #aksrt-admin-app where the
.jio-icon-picker__panel {
/* The popover teleports to <body>, outside #jio-admin-app where the
palette is declared — re-declare every token the panel consumes. */
--aksrt-color-primary: #18A058;
--aksrt-color-primary-hover: #36AD6A;
--aksrt-color-primary-soft: rgba(24, 160, 88, 0.12);
--aksrt-color-text-secondary: #465149;
--aksrt-color-muted: #6F7A72;
--aksrt-color-border: #E2E8E3;
--jio-color-primary: #18A058;
--jio-color-primary-hover: #36AD6A;
--jio-color-primary-soft: rgba(24, 160, 88, 0.12);
--jio-color-text-secondary: #465149;
--jio-color-muted: #6F7A72;
--jio-color-border: #E2E8E3;
padding: 12px;
}
.aksrt-icon-picker__search {
.jio-icon-picker__search {
margin-bottom: 10px;
}
.aksrt-icon-picker__tabs {
.jio-icon-picker__tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.aksrt-icon-picker__tab {
.jio-icon-picker__tab {
padding: 3px 10px;
font-size: 13px;
line-height: 20px;
border: 1px solid var(--aksrt-color-border);
border: 1px solid var(--jio-color-border);
border-radius: 4px;
background: transparent;
color: var(--aksrt-color-text-secondary);
color: var(--jio-color-text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.aksrt-icon-picker__tab:hover {
border-color: var(--aksrt-color-primary);
color: var(--aksrt-color-primary);
.jio-icon-picker__tab:hover {
border-color: var(--jio-color-primary);
color: var(--jio-color-primary);
}
.aksrt-icon-picker__tab.is-active {
border-color: var(--aksrt-color-primary);
background: var(--aksrt-color-primary);
.jio-icon-picker__tab.is-active {
border-color: var(--jio-color-primary);
background: var(--jio-color-primary);
color: #fff;
}
.aksrt-icon-picker__grid {
.jio-icon-picker__grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
@@ -230,7 +230,7 @@ onMounted(async () => {
overflow-y: auto;
}
.aksrt-icon-picker__item {
.jio-icon-picker__item {
display: flex;
align-items: center;
justify-content: center;
@@ -239,60 +239,60 @@ onMounted(async () => {
border-radius: 6px;
cursor: pointer;
font-size: 16px;
color: var(--aksrt-color-text-secondary);
color: var(--jio-color-text-secondary);
transition: all 0.15s;
}
.aksrt-icon-picker__item:hover {
background: var(--aksrt-color-primary-soft);
color: var(--aksrt-color-primary);
.jio-icon-picker__item:hover {
background: var(--jio-color-primary-soft);
color: var(--jio-color-primary);
}
.aksrt-icon-picker__item.is-active {
background: var(--aksrt-color-primary);
.jio-icon-picker__item.is-active {
background: var(--jio-color-primary);
color: #fff;
}
.aksrt-icon-picker__item :deep(svg),
.aksrt-icon-picker__preview-svg :deep(svg),
.aksrt-icon-picker__preview-svg svg {
.jio-icon-picker__item :deep(svg),
.jio-icon-picker__preview-svg :deep(svg),
.jio-icon-picker__preview-svg svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.aksrt-icon-picker__preview-svg {
.jio-icon-picker__preview-svg {
display: inline-flex;
}
.aksrt-icon-picker__empty {
.jio-icon-picker__empty {
grid-column: 1 / -1;
text-align: center;
padding: 20px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
font-size: 13px;
}
.aksrt-icon-picker__custom-actions {
.jio-icon-picker__custom-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.aksrt-icon-picker__custom-preview {
.jio-icon-picker__custom-preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px dashed var(--aksrt-color-muted);
border: 1px dashed var(--jio-color-muted);
border-radius: 4px;
}
.aksrt-icon-picker__grid--session {
.jio-icon-picker__grid--session {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--aksrt-color-muted);
border-top: 1px solid var(--jio-color-muted);
}
</style>
@@ -3,25 +3,25 @@
<i v-if="isFaValue(value)" :class="faClass" aria-hidden="true"></i>
<!-- Built-in sprite value -->
<span
v-else-if="isZibsvgValue(value)"
class="aksrt-icon-value__svg"
v-html="zibsvgMarkup(value)"
v-else-if="isJiosvgValue(value)"
class="jio-icon-value__svg"
v-html="jiosvgMarkup(value)"
></span>
<!-- Inline custom SVG value -->
<span
v-else-if="isCustomSvgValue(value)"
class="aksrt-icon-value__svg"
class="jio-icon-value__svg"
v-html="cleanSvg(value.slice(10))"
></span>
</template>
<script setup>
/**
* IconValue — renders any icon-picker value ("fa fas fa-star", "zibsvg-key",
* IconValue — renders any icon-picker value ("fa fas fa-star", "jiosvg-key",
* "customsvg-<svg>") as inline preview markup.
*/
import { computed } from 'vue'
import { cleanSvg, isFaValue, isZibsvgValue, isCustomSvgValue, zibsvgMarkup } from '@/utils/icons.js'
import { cleanSvg, isFaValue, isJiosvgValue, isCustomSvgValue, jiosvgMarkup } from '@/utils/icons.js'
const props = defineProps({
value: { type: String, default: '' },
@@ -35,12 +35,12 @@ const faClass = computed(() => {
</script>
<style scoped>
.aksrt-icon-value__svg {
.jio-icon-value__svg {
display: inline-flex;
}
.aksrt-icon-value__svg :deep(svg),
.aksrt-icon-value__svg svg {
.jio-icon-value__svg :deep(svg),
.jio-icon-value__svg svg {
width: 1em;
height: 1em;
fill: currentColor;
@@ -1,5 +1,5 @@
<template>
<div ref="editorRef" class="aks-code-editor" :class="{ 'is-disabled': disabled }" />
<div ref="editorRef" class="jio-code-editor" :class="{ 'is-disabled': disabled }" />
</template>
<script setup>
@@ -86,19 +86,19 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.aks-code-editor {
.jio-code-editor {
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 4px;
overflow: hidden;
min-height: calc(var(--rows, 8) * 22px);
}
.aks-code-editor :deep(.cm-editor) {
.jio-code-editor :deep(.cm-editor) {
font-size: 13px;
}
.aks-code-editor :deep(.cm-editor.cm-focused) {
.jio-code-editor :deep(.cm-editor.cm-focused) {
outline: none;
}
.aks-code-editor.is-disabled {
.jio-code-editor.is-disabled {
opacity: 0.6;
pointer-events: none;
}
@@ -0,0 +1,41 @@
<template>
<n-color-picker
:value="pickerValue"
:modes="modes"
:show-alpha="alpha"
:swatches="swatches"
:actions="clearable ? ['clear'] : undefined"
:disabled="disabled"
@update:value="onUpdate"
>
<template v-if="$slots.trigger" #trigger="slotProps">
<slot name="trigger" v-bind="slotProps" />
</template>
</n-color-picker>
</template>
<script setup>
/**
* 后台唯一取色器:所有颜色字段都经由此组件,输出与
* PHP `Jio\Core\Color::sanitize($value, $allow_alpha)` 同一规则的规范值。
* 清空 / 非法值统一回传空串。
*/
import { computed } from 'vue'
import { normalizeColor } from '@/utils/color.js'
const props = defineProps({
modelValue: { type: String, default: '' },
alpha: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
swatches: { type: Array, default: undefined },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const modes = computed(() => (props.alpha ? ['hex', 'rgb'] : ['hex']))
const pickerValue = computed(() => normalizeColor(props.modelValue, props.alpha) || null)
function onUpdate(value) {
emit('update:modelValue', normalizeColor(value, props.alpha))
}
</script>
@@ -0,0 +1,75 @@
<template>
<!-- Between number (min/max pair) -->
<div class="jio-composite jio-composite--between">
<n-input-number
:value="betweenValue('min')"
:min="schema.min"
:max="schema.max"
:step="schema.step || 1"
:placeholder="schema.minPlaceholder || '最小'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="update('min', $event)"
/>
<span class="jio-composite__sep">—</span>
<n-input-number
:value="betweenValue('max')"
:min="schema.min"
:max="schema.max"
:step="schema.step || 1"
:placeholder="schema.maxPlaceholder || '最大'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="update('max', $event)"
/>
<span v-if="schema.unit" class="jio-composite__unit">{{ schema.unit }}</span>
</div>
</template>
<script setup>
/** 对象型复合字段:between_number(min / max 数值区间)。 */
import { computed } from 'vue'
const props = defineProps({
schema: { type: Object, required: true },
modelValue: { default: null },
})
const emit = defineEmits(['update:modelValue'])
const current = computed(() => (
props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
? props.modelValue
: {}
))
function update(part, value) {
emit('update:modelValue', { ...current.value, [part]: value })
}
function betweenValue(part) {
return current.value[part] !== undefined ? current.value[part] : (props.schema.default?.[part] ?? 0)
}
</script>
<style scoped>
.jio-composite {
display: flex;
align-items: center;
gap: 8px;
}
.jio-composite--between {
max-width: 360px;
}
.jio-composite__sep {
color: var(--jio-color-border-strong);
font-size: 14px;
}
.jio-composite__unit {
color: var(--jio-color-muted);
font-size: 13px;
white-space: nowrap;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -1,26 +1,26 @@
<template>
<div class="aksrt-field-group" :class="{ 'is-collapsible': collapsible, 'is-collapsed': collapsible && collapsed }">
<div class="jio-field-group" :class="{ 'is-collapsible': collapsible, 'is-collapsed': collapsible && collapsed }">
<div
v-if="title"
class="aksrt-field-group__header"
class="jio-field-group__header"
:class="{ 'is-clickable': collapsible }"
@click="collapsible ? toggleCollapse() : undefined"
>
<div class="aksrt-field-group__header-left">
<FaIcon v-if="icon" :icon="icon" :size="16" class="aksrt-field-group__icon" />
<div class="jio-field-group__header-left">
<FaIcon v-if="icon" :icon="icon" :size="16" class="jio-field-group__icon" />
<div>
<h3 class="aksrt-field-group__title">{{ title }}</h3>
<p v-if="description" class="aksrt-field-group__desc">{{ description }}</p>
<h3 class="jio-field-group__title">{{ title }}</h3>
<p v-if="description" class="jio-field-group__desc">{{ description }}</p>
</div>
</div>
<div class="aksrt-field-group__header-right">
<div v-if="$slots.actions" class="aksrt-field-group__actions" @click.stop>
<div class="jio-field-group__header-right">
<div v-if="$slots.actions" class="jio-field-group__actions" @click.stop>
<slot name="actions" />
</div>
<button
v-if="collapsible"
type="button"
class="aksrt-field-group__collapse-btn"
class="jio-field-group__collapse-btn"
:aria-expanded="(!collapsed).toString()"
:title="collapsed ? '展开配置' : '收起配置'"
@click.stop="toggleCollapse"
@@ -34,8 +34,8 @@
</div>
</div>
<transition name="aksrt-collapse">
<div v-show="!collapsed" class="aksrt-field-group__body">
<transition name="jio-collapse">
<div v-show="!collapsed" class="jio-field-group__body">
<slot />
</div>
</transition>
@@ -44,11 +44,10 @@
<script setup>
/**
* AksFieldGroup — card-style container for grouping related form fields.
* JioFieldGroup — card-style container for grouping related form fields.
* Supports collapsible sections, icon headers, and description text.
*
* defaultCollapsed only seeds initial state. Parent should remount the group
* (e.g. via :key when a channel turns on) if it wants to force expand again.
* defaultCollapsed only seeds the initial state; afterwards the user toggles freely.
*/
import { ref } from 'vue'
import FaIcon from './FaIcon.vue'
@@ -70,16 +69,16 @@ function toggleCollapse() {
</script>
<style scoped>
.aksrt-field-group {
.jio-field-group {
margin-bottom: 20px;
border: 1px solid var(--aksrt-color-border);
border: 1px solid var(--jio-color-border);
border-radius: 12px;
background: var(--aksrt-color-surface);
box-shadow: var(--aksrt-shadow-card);
background: var(--jio-color-surface);
box-shadow: var(--jio-shadow-card);
overflow: hidden;
}
.aksrt-field-group__header {
.jio-field-group__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
@@ -87,66 +86,66 @@ function toggleCollapse() {
gap: 12px;
}
.aksrt-field-group__header.is-clickable {
.jio-field-group__header.is-clickable {
cursor: pointer;
user-select: none;
}
.aksrt-field-group__header.is-clickable:hover .aksrt-field-group__title {
color: var(--aksrt-color-primary);
.jio-field-group__header.is-clickable:hover .jio-field-group__title {
color: var(--jio-color-primary);
}
.aksrt-field-group.is-collapsed .aksrt-field-group__header {
.jio-field-group.is-collapsed .jio-field-group__header {
padding-bottom: 18px;
}
.aksrt-field-group__header-left {
.jio-field-group__header-left {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.aksrt-field-group__header-right {
.jio-field-group__header-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.aksrt-field-group__icon {
color: var(--aksrt-color-primary);
.jio-field-group__icon {
color: var(--jio-color-primary);
margin-top: 2px;
}
.aksrt-field-group__title {
.jio-field-group__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--aksrt-color-text);
color: var(--jio-color-text);
transition: color 0.15s;
}
.aksrt-field-group__desc {
.jio-field-group__desc {
margin: 4px 0 0;
font-size: 12px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
line-height: 1.5;
}
.aksrt-field-group__actions {
.jio-field-group__actions {
display: flex;
align-items: center;
gap: 8px;
}
.aksrt-field-group__collapse-btn {
.jio-field-group__collapse-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--aksrt-color-border);
background: var(--aksrt-color-canvas);
color: var(--aksrt-color-text-secondary);
border: 1px solid var(--jio-color-border);
background: var(--jio-color-canvas);
color: var(--jio-color-text-secondary);
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
@@ -154,25 +153,25 @@ function toggleCollapse() {
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.aksrt-field-group__collapse-btn:hover {
color: var(--aksrt-color-primary);
border-color: var(--aksrt-color-primary);
background: var(--aksrt-color-primary-soft);
.jio-field-group__collapse-btn:hover {
color: var(--jio-color-primary);
border-color: var(--jio-color-primary);
background: var(--jio-color-primary-soft);
}
.aksrt-field-group__body {
.jio-field-group__body {
padding: 16px 20px 20px;
}
/* Collapse transition */
.aksrt-collapse-enter-active,
.aksrt-collapse-leave-active {
.jio-collapse-enter-active,
.jio-collapse-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.aksrt-collapse-enter-from,
.aksrt-collapse-leave-to {
.jio-collapse-enter-from,
.jio-collapse-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
@@ -1,20 +1,22 @@
<template>
<div class="aksrt-form-renderer">
<div class="jio-form-renderer">
<!-- Page header with title and description -->
<div v-if="title" class="aksrt-form-renderer__header">
<div class="aksrt-form-renderer__header-left">
<FaIcon v-if="icon" :icon="icon" :size="22" class="aksrt-form-renderer__header-icon" />
<div v-if="title" class="jio-form-renderer__header">
<div class="jio-form-renderer__header-left">
<FaIcon v-if="icon" :icon="icon" :size="22" class="jio-form-renderer__header-icon" />
<div>
<h1 class="aksrt-form-renderer__title">{{ title }}</h1>
<p v-if="description" class="aksrt-form-renderer__desc">{{ description }}</p>
<h1 class="jio-form-renderer__title">{{ title }}</h1>
<p v-if="description" class="jio-form-renderer__desc">{{ description }}</p>
</div>
</div>
<div v-if="showSearch" class="aksrt-form-renderer__search">
<div v-if="showSearch" class="jio-form-renderer__search">
<n-input
v-model:value="searchQuery"
placeholder="搜索设置项..."
size="small"
clearable
name="jio-settings-search"
:input-props="searchInputProps"
>
<template #prefix>
<FaIcon icon="fa fas fa-search" :size="12" />
@@ -24,7 +26,7 @@
</div>
<!-- Loading state -->
<div v-if="loading" class="aksrt-form-renderer__loading">
<div v-if="loading" class="jio-form-renderer__loading">
<n-spin size="medium" />
<span>加载中...</span>
</div>
@@ -45,7 +47,7 @@
<!-- Form content -->
<template v-else-if="schema && schema.length">
<template v-for="group in filteredSchema" :key="groupKey(group)">
<AksFieldGroup
<JioFieldGroup
v-if="isGroupVisible(group)"
:title="group.title"
:description="group.description"
@@ -59,20 +61,21 @@
<!-- Tabbed: renders tabs whose sub-fields are flat top-level keys -->
<div
v-if="field.type === 'tabbed' && (field.tabs || []).length"
class="aksrt-form-renderer__tabbed"
class="jio-form-renderer__tabbed"
>
<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
<JioField
v-if="isFieldVisible(sub)"
:schema="sub"
:model-value="getFieldValue(sub.key)"
:form-data="formData"
:error-message="errors?.[sub.key]"
@update:model-value="setFieldValue(sub.key, $event)"
@action="emit('field-action', $event)"
@@ -81,23 +84,31 @@
</n-tab-pane>
</n-tabs>
</div>
<JioSorterField
v-else-if="field.type === 'custom' && field.renderer === 'sorter'"
:model-value="getFieldValue(field.key) || {}"
:options="field.options || []"
@update:model-value="setFieldValue(field.key, $event)"
/>
<slot
v-else-if="field.type === 'custom'"
:name="`field-${field.key}`"
v-bind="{ field, value: getFieldValue(field.key), setValue: (value) => setFieldValue(field.key, value), formData }"
>
<AksField
<JioField
:schema="field"
:model-value="getFieldValue(field.key)"
:form-data="formData"
:error-message="errors?.[field.key]"
@update:model-value="setFieldValue(field.key, $event)"
@action="emit('field-action', $event)"
@action="emit('field-action', $event)"
/>
</slot>
<AksField
<JioField
v-else
:schema="field"
:model-value="getFieldValue(field.key)"
:form-data="formData"
:error-message="errors?.[field.key]"
@update:model-value="setFieldValue(field.key, $event)"
@action="emit('field-action', $event)"
@@ -108,43 +119,24 @@
<!-- Custom group content slot -->
<slot :name="`group-${group.key}`" v-bind="{ group, formData }" />
</AksFieldGroup>
</JioFieldGroup>
</template>
</template>
<!-- Empty state -->
<div v-else class="aksrt-form-renderer__empty">
<div v-else class="jio-form-renderer__empty">
<FaIcon icon="fa fas fa-cog" :size="32" />
<p>暂无配置项</p>
</div>
<!-- Sticky save bar -->
<div v-if="dirty" class="aksrt-form-renderer__save-bar">
<div class="aksrt-form-renderer__save-bar__inner">
<div class="aksrt-form-renderer__save-bar__info">
<FaIcon icon="fa fas fa-exclamation-circle" :size="14" />
<span>您有未保存的更改</span>
</div>
<div class="aksrt-form-renderer__save-bar__actions">
<n-button size="small" @click="$emit('reload')">放弃更改</n-button>
<n-button
size="small"
type="primary"
:loading="saving"
@click="$emit('save')"
>
<FaIcon v-if="!saving" icon="fa fas fa-save" :size="12" />
保存设置
</n-button>
</div>
</div>
</div>
<JioSaveBar :dirty="dirty" :saving="saving" @save="$emit('save')" @reload="$emit('reload')" />
</div>
</template>
<script setup>
/**
* AksFormRenderer — the core schema-driven form engine.
* JioFormRenderer — the core schema-driven form engine.
*
* Takes a schema definition (array of field groups) and a reactive form data object,
* and renders a complete settings page with:
@@ -156,8 +148,10 @@
*/
import { ref, computed, useSlots } from 'vue'
import FaIcon from './FaIcon.vue'
import AksFieldGroup from './AksFieldGroup.vue'
import AksField from './AksField.vue'
import JioSaveBar from './JioSaveBar.vue'
import JioFieldGroup from './JioFieldGroup.vue'
import JioField from './JioField.vue'
import JioSorterField from './JioSorterField.vue'
const props = defineProps({
/** Page title */
@@ -193,6 +187,18 @@ const slots = useSlots()
const searchQuery = ref('')
// 同一页面上存在大量 type="password" 的凭证字段,Chrome 会把整页当成登录
// 表单,并把保存的账号灌进第一个文本框(也就是这个搜索框)。改名成搜索语义,
// 并显式关掉各类自动填充,才能让浏览器不再识别它。
const searchInputProps = {
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'off',
spellcheck: 'false',
'data-lpignore': 'true',
'data-1p-ignore': 'true',
}
/** Evaluate dependsOn rule against current form data */
function matchDependsOn(dependsOn) {
if (!dependsOn) return true
@@ -239,31 +245,30 @@ 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)
}
/**
* Collapse preference for a group.
* - expandWhen: form key; when truthy, start expanded (channel just enabled → open form)
* - defaultCollapsed: static default
*/
/** 可折叠分组的初始状态:defaultCollapsed 为真时默认收起。 */
function resolveGroupCollapsed(group) {
if (group.expandWhen) {
const on = !!props.formData?.[group.expandWhen]
return !on
}
return !!group.defaultCollapsed
}
/** Stable key; remount when expandWhen flips so newly enabled channels open expanded. */
function groupKey(group) {
const base = group.key || group.title || 'group'
if (group.expandWhen) {
return `${base}:${props.formData?.[group.expandWhen] ? 'on' : 'off'}`
}
return base
return group.key || group.title || 'group'
}
/** Get field value from formData */
@@ -317,7 +322,7 @@ const filteredSchema = computed(() => {
</script>
<style scoped>
.aksrt-form-renderer__header {
.jio-form-renderer__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
@@ -326,136 +331,75 @@ const filteredSchema = computed(() => {
flex-wrap: wrap;
}
.aksrt-form-renderer__header-left {
.jio-form-renderer__header-left {
display: flex;
align-items: flex-start;
gap: 12px;
}
.aksrt-form-renderer__header-icon {
color: var(--aksrt-color-primary);
.jio-form-renderer__header-icon {
color: var(--jio-color-primary);
margin-top: 4px;
}
.aksrt-form-renderer__title {
.jio-form-renderer__title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--aksrt-color-text);
color: var(--jio-color-text);
letter-spacing: -0.02em;
}
.aksrt-form-renderer__desc {
.jio-form-renderer__desc {
margin: 4px 0 0;
font-size: 13px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
line-height: 1.6;
}
.aksrt-form-renderer__tabbed {
.jio-form-renderer__tabbed {
margin-bottom: 8px;
}
.aksrt-form-renderer__search {
.jio-form-renderer__search {
min-width: 200px;
max-width: 280px;
}
.aksrt-form-renderer__loading {
.jio-form-renderer__loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
font-size: 14px;
}
.aksrt-form-renderer__empty {
.jio-form-renderer__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: var(--aksrt-color-border-strong);
color: var(--jio-color-border-strong);
}
.aksrt-form-renderer__empty p {
.jio-form-renderer__empty p {
margin: 0;
font-size: 14px;
}
/* Sticky save bar */
.aksrt-form-renderer__save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background: var(--aksrt-color-surface);
border-top: 1px solid var(--aksrt-color-border);
box-shadow: var(--aksrt-shadow-floating);
animation: slideUp 0.2s ease;
}
.aksrt-form-renderer__save-bar__inner {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1100px;
margin: 0 auto;
padding: 12px 28px;
gap: 16px;
}
.aksrt-form-renderer__save-bar__info {
display: flex;
align-items: center;
gap: 8px;
color: #d48806;
font-size: 13px;
}
.aksrt-form-renderer__save-bar__actions {
display: flex;
gap: 8px;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Mobile */
@media (max-width: 768px) {
.aksrt-form-renderer__header {
.jio-form-renderer__header {
flex-direction: column;
gap: 10px;
}
.aksrt-form-renderer__search {
.jio-form-renderer__search {
max-width: 100%;
width: 100%;
}
.aksrt-form-renderer__save-bar__inner {
flex-direction: column;
padding: 10px 14px;
gap: 8px;
}
.aksrt-form-renderer__save-bar__actions {
width: 100%;
}
.aksrt-form-renderer__save-bar__actions .n-button {
flex: 1;
}
}
</style>
@@ -1,23 +1,23 @@
<template>
<div class="aksrt-gallery-field">
<div v-if="attachmentIds.length" class="aksrt-gallery-field__grid">
<div class="jio-gallery-field">
<div v-if="attachmentIds.length" class="jio-gallery-field__grid">
<article
v-for="(attachmentId, index) in attachmentIds"
:key="attachmentId"
class="aksrt-gallery-field__item"
class="jio-gallery-field__item"
>
<img
v-if="previews[attachmentId]?.url"
:src="previews[attachmentId].url"
:alt="previews[attachmentId].alt || `附件 ${attachmentId}`"
class="aksrt-gallery-field__image"
class="jio-gallery-field__image"
/>
<div v-else class="aksrt-gallery-field__placeholder">
<div v-else class="jio-gallery-field__placeholder">
<FaIcon icon="fa fas fa-image" :size="22" />
<span>#{{ attachmentId }}</span>
</div>
<div class="aksrt-gallery-field__actions">
<div class="jio-gallery-field__actions">
<n-button
size="tiny"
quaternary
@@ -49,12 +49,12 @@
</article>
</div>
<div v-else class="aksrt-gallery-field__empty">
<div v-else class="jio-gallery-field__empty">
<FaIcon icon="fa fas fa-images" :size="28" />
<span>尚未选择图片</span>
</div>
<div class="aksrt-gallery-field__buttons">
<div class="jio-gallery-field__buttons">
<n-button type="primary" secondary @click="openMediaLibrary">
<FaIcon icon="fa fas fa-plus" />
{{ attachmentIds.length ? '编辑图片' : '选择图片' }}
@@ -64,7 +64,7 @@
</n-button>
</div>
<p v-if="mediaError" class="aksrt-gallery-field__error">{{ mediaError }}</p>
<p v-if="mediaError" class="jio-gallery-field__error">{{ mediaError }}</p>
</div>
</template>
@@ -124,7 +124,7 @@ async function hydratePreviews() {
const sizes = attachment.get('sizes') || {}
return [attachmentId, {
url: sizes.thumbnail?.url || sizes.medium?.url || attachment.get('url') || '',
url: sizes.medium?.url || attachment.get('url') || '',
alt: attachment.get('alt') || attachment.get('title') || '',
}]
}))
@@ -172,61 +172,70 @@ watch(attachmentIds, hydratePreviews, { immediate: true })
</script>
<style scoped>
.aksrt-gallery-field__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 10px;
}
.aksrt-gallery-field__item {
overflow: hidden;
border: 1px solid var(--aksrt-color-border);
border-radius: 8px;
background: var(--aksrt-color-canvas);
}
.aksrt-gallery-field__image,
.aksrt-gallery-field__placeholder {
.jio-gallery-field__grid {
display: flex;
width: 100%;
aspect-ratio: 4 / 3;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
}
.jio-gallery-field__item {
display: inline-flex;
flex-direction: column;
max-width: 100%;
padding: 4px 4px 0;
overflow: hidden;
border: 1px solid var(--jio-color-border);
border-radius: 8px;
background: var(--jio-color-canvas);
}
.jio-gallery-field__image {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 90px;
border-radius: 4px;
}
.jio-gallery-field__placeholder {
display: flex;
flex-direction: column;
width: 90px;
height: 90px;
align-items: center;
justify-content: center;
object-fit: cover;
}
.aksrt-gallery-field__placeholder {
flex-direction: column;
gap: 4px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
}
.aksrt-gallery-field__actions {
.jio-gallery-field__actions {
display: flex;
justify-content: center;
padding: 4px;
border-top: 1px solid var(--aksrt-color-border);
border-top: 1px solid var(--jio-color-border);
}
.aksrt-gallery-field__empty {
.jio-gallery-field__empty {
display: flex;
min-height: 116px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
border: 1px dashed var(--aksrt-color-border-strong);
border: 1px dashed var(--jio-color-border-strong);
border-radius: 8px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
}
.aksrt-gallery-field__buttons {
.jio-gallery-field__buttons {
display: flex;
gap: 8px;
margin-top: 10px;
}
.aksrt-gallery-field__error {
.jio-gallery-field__error {
margin: 8px 0 0;
color: #d03050;
font-size: 12px;
@@ -0,0 +1,115 @@
<template>
<div class="jio-image-select" role="radiogroup">
<button
v-for="opt in options"
:key="opt.value"
type="button"
role="radio"
class="jio-image-select__item"
:class="{ 'is-active': modelValue === opt.value }"
:aria-checked="modelValue === opt.value"
:disabled="disabled"
@click="emit('update:modelValue', opt.value)"
>
<img v-if="opt.image" :src="opt.image" :alt="opt.label" class="jio-image-select__img" :style="imgStyle(opt)" />
<span v-else class="jio-image-select__placeholder" :style="imgStyle(opt)">
<FaIcon :icon="opt.icon || 'fa fas fa-image'" :size="20" />
</span>
<span class="jio-image-select__label">{{ opt.label }}</span>
</button>
</div>
</template>
<script setup>
/** image_select:带预览图的单选。 */
import FaIcon from './FaIcon.vue'
const props = defineProps({
modelValue: { default: null },
options: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
// Preview width in px. Layout strips (header/footer) are very wide and only
// need enough width to be legible; near-square previews want more room.
previewWidth: { type: [Number, String], default: 140 },
})
const emit = defineEmits(['update:modelValue'])
// An option may override the group width via its own `width`.
function imgStyle(opt) {
const width = opt && opt.width ? opt.width : props.previewWidth
return { '--jio-image-select-max': typeof width === 'number' ? `${width}px` : width }
}
</script>
<style scoped>
.jio-image-select {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.jio-image-select__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 80px;
padding: 8px;
border: 2px solid var(--jio-color-border);
border-radius: 8px;
background: var(--jio-color-surface);
cursor: pointer;
transition: border-color .15s ease, background-color .15s ease;
}
.jio-image-select__item:hover,
.jio-image-select__item:focus-visible {
outline: none;
border-color: var(--jio-color-primary);
}
.jio-image-select__item.is-active {
border-color: var(--jio-color-primary);
background: var(--jio-color-primary-soft);
}
.jio-image-select__item:disabled {
cursor: not-allowed;
}
.jio-image-select__img {
display: block;
/* Preview images are wide layout strips (header 597x57, footer 800x190, slide
align 200x101), so let them keep their own aspect ratio inside the card.
A fixed square box with object-fit:cover crops the layout to an
unrecognisable sliver — the width is capped instead and the height follows. */
width: 100%;
max-width: var(--jio-image-select-max, 140px);
height: auto;
border-radius: 4px;
background: var(--jio-color-canvas);
}
.jio-image-select__placeholder {
display: flex;
align-items: center;
justify-content: center;
width: var(--jio-image-select-max, 140px);
height: 60px;
border-radius: 4px;
background: var(--jio-color-canvas);
color: var(--jio-color-border-strong);
}
.jio-image-select__label {
color: var(--jio-color-text-secondary);
font-size: 11px;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.jio-image-select__item {
transition: none;
}
}
</style>
@@ -1,27 +1,27 @@
<template>
<div class="aksrt-media-field">
<div v-if="modelValue" class="aksrt-media-field__preview">
<div class="jio-media-field">
<div v-if="modelValue" class="jio-media-field__preview">
<img
v-if="isImage"
:src="modelValue"
:alt="placeholder"
class="aksrt-media-field__image"
class="jio-media-field__image"
/>
<div v-else class="aksrt-media-field__file">
<div v-else class="jio-media-field__file">
<FaIcon icon="fa fas fa-file" :size="24" />
<span class="aksrt-media-field__filename">{{ fileName }}</span>
<span class="jio-media-field__filename">{{ fileName }}</span>
</div>
<div class="aksrt-media-field__overlay">
<n-button size="tiny" quaternary @click="openMediaLibrary">
<FaIcon icon="fa fas fa-exchange-alt" /> 更换
<div class="jio-media-field__overlay">
<n-button size="tiny" secondary circle title="更换" @click="openMediaLibrary">
<FaIcon icon="fa fas fa-exchange-alt" />
</n-button>
<n-button size="tiny" quaternary type="error" @click="clearMedia">
<FaIcon icon="fa fas fa-trash-alt" /> 移除
<n-button size="tiny" secondary circle type="error" title="移除" @click="clearMedia">
<FaIcon icon="fa fas fa-trash-alt" />
</n-button>
</div>
</div>
<div v-else class="aksrt-media-field__empty" @click="openMediaLibrary">
<div v-else class="jio-media-field__empty" @click="openMediaLibrary">
<FaIcon icon="fa fas fa-cloud-upload-alt" :size="28" />
<span>{{ placeholder }}</span>
</div>
@@ -30,7 +30,7 @@
:value="modelValue"
:placeholder="'或直接输入 URL'"
size="small"
class="aksrt-media-field__url"
class="jio-media-field__url"
@update:value="emit('update:modelValue', $event)"
/>
</div>
@@ -38,7 +38,7 @@
<script setup>
/**
* AksMediaField — image/file selector with preview.
* JioMediaField — image/file selector with preview.
*
* In the WordPress admin context, this can open the WP Media Library.
* For now, it supports direct URL input with preview.
@@ -95,73 +95,82 @@ function clearMedia() {
</script>
<style scoped>
.aksrt-media-field__preview {
.jio-media-field__preview {
position: relative;
display: inline-flex;
max-width: 100%;
min-width: 72px;
min-height: 44px;
padding: 4px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--aksrt-color-border);
border: 1px solid var(--jio-color-border);
background: var(--jio-color-canvas);
margin-bottom: 8px;
vertical-align: top;
}
.aksrt-media-field__image {
.jio-media-field__image {
display: block;
width: 100%;
max-height: 200px;
object-fit: cover;
width: auto;
height: auto;
max-width: 100%;
max-height: 120px;
border-radius: 4px;
}
.aksrt-media-field__file {
.jio-media-field__file {
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
background: var(--aksrt-color-canvas);
background: var(--jio-color-canvas);
}
.aksrt-media-field__filename {
.jio-media-field__filename {
font-size: 13px;
color: var(--aksrt-color-text-secondary);
color: var(--jio-color-text-secondary);
word-break: break-all;
}
.aksrt-media-field__overlay {
.jio-media-field__overlay {
position: absolute;
top: 0;
right: 0;
display: flex;
gap: 4px;
padding: 6px;
padding: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.aksrt-media-field__preview:hover .aksrt-media-field__overlay {
.jio-media-field__preview:hover .jio-media-field__overlay {
opacity: 1;
}
.aksrt-media-field__empty {
.jio-media-field__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
border: 2px dashed var(--aksrt-color-border-strong);
border: 2px dashed var(--jio-color-border-strong);
border-radius: 8px;
cursor: pointer;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
font-size: 13px;
transition: all 0.2s;
margin-bottom: 8px;
}
.aksrt-media-field__empty:hover {
border-color: var(--aksrt-color-primary);
color: var(--aksrt-color-primary);
background: var(--aksrt-color-primary-soft);
.jio-media-field__empty:hover {
border-color: var(--jio-color-primary);
color: var(--jio-color-primary);
background: var(--jio-color-primary-soft);
}
.aksrt-media-field__url {
.jio-media-field__url {
margin-top: 4px;
}
</style>
@@ -0,0 +1,183 @@
<template>
<div class="jio-repeater-list">
<draggable
v-if="rows.length"
v-model="rows"
v-bind="SORTABLE_LIST_ATTRS"
:item-key="rowKey"
:disabled="schema.disabled"
class="jio-sortable"
>
<template #item="{ element, index }">
<div class="jio-sortable__item">
<div class="jio-sortable__head">
<JioSortHandle />
<span class="jio-sortable__icon" :class="preview(element).iconClass">
<span v-if="preview(element).customIcon" class="jio-repeater-list__icon-html" v-html="preview(element).customIcon" />
<i v-else-if="preview(element).icon" :class="preview(element).icon" aria-hidden="true" />
<FaIcon v-else icon="fa fas fa-star" :size="16" />
</span>
<div class="jio-sortable__main">
<span class="jio-sortable__title">{{ preview(element).title }}</span>
<span class="jio-sortable__desc">{{ preview(element).desc }}</span>
</div>
<div class="jio-sortable__actions">
<n-button size="tiny" quaternary type="primary" :disabled="schema.disabled" @click="openModal(index)">
<template #icon><FaIcon icon="fa fas fa-edit" :size="12" /></template>
编辑
</n-button>
<n-button size="tiny" quaternary type="error" :disabled="schema.disabled || reachedMinimum" @click="remove(index)">
<template #icon><FaIcon icon="fa fas fa-trash-alt" :size="12" /></template>
删除
</n-button>
</div>
</div>
</div>
</template>
</draggable>
<div v-else class="jio-sortable__empty">
<FaIcon icon="fa fas fa-inbox" :size="18" />
<span>{{ schema.emptyText || '暂无数据' }}</span>
</div>
<n-button size="small" dashed block :disabled="schema.disabled || reachedMaximum" @click="openModal(-1)">
<template #icon><FaIcon icon="fa fas fa-plus" :size="12" /></template>
{{ schema.button_title || schema.addText || '添加一项' }}
</n-button>
<n-modal
v-model:show="modalShow"
:mask-closable="false"
preset="card"
:title="editingIndex >= 0 ? (schema.editText || '编辑项目') : (schema.addText || '添加项目')"
class="jio-modal-card"
style="max-width: 640px; width: 92vw;"
>
<div class="jio-repeater-list__modal-fields">
<JioField
v-for="field in draftFields"
:key="field.key"
:schema="field"
:model-value="draft[field.key]"
:form-data="formData"
@update:model-value="draft = { ...draft, [field.key]: $event }"
/>
</div>
<div class="jio-modal-footer">
<n-button @click="closeModal">取消</n-button>
<n-button type="primary" @click="confirmModal">确定</n-button>
</div>
</n-modal>
</div>
</template>
<script setup>
/** repeater / group 的 list-modal 形态:可拖拽卡片列表 + 弹窗编辑单行。 */
import { computed, ref } from 'vue'
import draggable from 'vuedraggable'
import FaIcon from './FaIcon.vue'
import JioField from './JioField.vue'
import JioSortHandle from './JioSortHandle.vue'
import { matchDependency } from './field-dependency.js'
import { SORTABLE_LIST_ATTRS } from './sortable-options.js'
import { createRepeaterRow, createRepeaterRowId, repeaterRowPreview } from './repeater-rows.js'
const props = defineProps({
schema: { type: Object, required: true },
modelValue: { default: null },
formData: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:modelValue'])
const rows = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
set: (value) => emit('update:modelValue', Array.isArray(value) ? value : []),
})
const reachedMinimum = computed(() => rows.value.length <= Number(props.schema.min || 0))
const reachedMaximum = computed(() => Number(props.schema.max || 0) > 0 && rows.value.length >= Number(props.schema.max))
const keys = new WeakMap()
let keySeed = 0
function rowKey(row) {
if (!row || typeof row !== 'object') return String(row)
if (row._id) return row._id
if (!keys.has(row)) keys.set(row, `rpt_list_${++keySeed}`)
return keys.get(row)
}
function preview(row) {
return repeaterRowPreview(row, props.schema.listPreview || {})
}
function remove(index) {
if (reachedMinimum.value) return
const next = [...rows.value]
next.splice(index, 1)
emit('update:modelValue', next)
}
const modalShow = ref(false)
const editingIndex = ref(-1)
const draft = ref({})
const draftFields = computed(() => (props.schema.fields || []).filter(field => matchDependency(field.dependsOn, draft.value)))
function openModal(index) {
if (props.schema.disabled) return
if (index < 0 && reachedMaximum.value) return
const base = createRepeaterRow(props.schema.fields)
draft.value = index >= 0 ? { ...base, ...(rows.value[index] || {}) } : base
editingIndex.value = index
modalShow.value = true
}
function closeModal() {
modalShow.value = false
editingIndex.value = -1
draft.value = {}
}
function confirmModal() {
const next = [...rows.value]
const row = { ...draft.value }
if (!row._id) row._id = createRepeaterRowId()
if (editingIndex.value >= 0 && editingIndex.value < next.length) {
next[editingIndex.value] = row
} else {
next.push(row)
}
emit('update:modelValue', next)
closeModal()
}
</script>
<style scoped>
.jio-repeater-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.jio-repeater-list__icon-html {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.jio-repeater-list__icon-html :deep(svg),
.jio-repeater-list__icon-html :deep(img) {
width: 20px;
height: 20px;
object-fit: contain;
}
.jio-repeater-list__modal-fields {
display: flex;
flex-direction: column;
gap: 4px;
max-height: min(70vh, 560px);
overflow: auto;
padding-right: 2px;
}
</style>
@@ -0,0 +1,101 @@
<template>
<div v-if="dirty" class="jio-form-renderer__save-bar">
<div class="jio-form-renderer__save-bar__inner">
<div class="jio-form-renderer__save-bar__info">
<FaIcon icon="fa fas fa-exclamation-circle" :size="14" />
<span>您有未保存的更改</span>
</div>
<div class="jio-form-renderer__save-bar__actions">
<n-button size="small" :disabled="saving" @click="emit('reload')">放弃更改</n-button>
<n-button
size="small"
type="primary"
:loading="saving"
@click="emit('save')"
>
<FaIcon v-if="!saving" icon="fa fas fa-save" :size="12" />
保存设置
</n-button>
</div>
</div>
</div>
</template>
<script setup>
/**
* JioSaveBar — the admin-wide sticky save bar (shown only when dirty).
* Shared by JioFormRenderer (schema pages) and custom settings pages.
*/
import FaIcon from './FaIcon.vue'
defineProps({
dirty: { type: Boolean, default: false },
saving: { type: Boolean, default: false },
})
const emit = defineEmits(['save', 'reload'])
</script>
<style scoped>
.jio-form-renderer__save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background: var(--jio-color-surface);
border-top: 1px solid var(--jio-color-border);
box-shadow: var(--jio-shadow-floating);
animation: slideUp 0.2s ease;
}
.jio-form-renderer__save-bar__inner {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1100px;
margin: 0 auto;
padding: 12px 28px;
gap: 16px;
}
.jio-form-renderer__save-bar__info {
display: flex;
align-items: center;
gap: 8px;
color: #d48806;
font-size: 13px;
}
.jio-form-renderer__save-bar__actions {
display: flex;
gap: 8px;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (max-width: 768px) {
.jio-form-renderer__save-bar__inner {
flex-direction: column;
padding: 10px 14px;
gap: 8px;
}
.jio-form-renderer__save-bar__actions {
width: 100%;
}
.jio-form-renderer__save-bar__actions .n-button {
flex: 1;
}
}
</style>
@@ -0,0 +1,36 @@
<template>
<div v-if="visible" class="jio-secret-clear">
<n-button size="small" secondary :type="marked ? 'warning' : 'error'" @click="$emit('toggle')">
{{ marked ? '取消清除' : '清除已配置密钥' }}
</n-button>
<span v-if="marked" class="jio-secret-clear__hint">保存后将删除此密钥</span>
</div>
</template>
<script setup>
/**
* 已配置密钥的「清除 / 取消清除」操作,input 与 textarea 两种 secret 字段共用。
*/
defineProps({
/** 当前存在已配置密钥('***')或已标记清除时显示 */
visible: { type: Boolean, default: false },
/** 是否已标记为保存时清除 */
marked: { type: Boolean, default: false },
})
defineEmits(['toggle'])
</script>
<style scoped>
.jio-secret-clear {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.jio-secret-clear__hint {
color: var(--jio-color-warning, #d97706);
font-size: 12px;
}
</style>
@@ -0,0 +1,53 @@
<template>
<draggable
v-model="values"
v-bind="SORTABLE_LIST_ATTRS"
:item-key="valueKey"
:disabled="disabled"
class="jio-sortable jio-sortable--compact jio-selected-order"
>
<template #item="{ element, index }">
<div class="jio-sortable__item">
<div class="jio-sortable__head">
<JioSortHandle />
<span class="jio-sortable__index">{{ index + 1 }}</span>
<span class="jio-sortable__title">{{ labelOf(element) }}</span>
</div>
</div>
</template>
</draggable>
</template>
<script setup>
/** 多选 + sortable 的已选值排序列表(select / async_select 下方)。 */
import { computed } from 'vue'
import draggable from 'vuedraggable'
import JioSortHandle from './JioSortHandle.vue'
import { SORTABLE_LIST_ATTRS } from './sortable-options.js'
const props = defineProps({
modelValue: { type: Array, required: true },
options: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const values = computed({
get: () => props.modelValue,
set: (next) => emit('update:modelValue', next),
})
function valueKey(value) {
return String(value)
}
function labelOf(value) {
return props.options.find(option => option.value === value)?.label || String(value)
}
</script>
<style scoped>
.jio-selected-order {
margin-top: 8px;
}
</style>
@@ -0,0 +1,12 @@
<template>
<button type="button" class="jio-sortable__handle" :title="label" :aria-label="label">
<i class="fa fas fa-grip-vertical" aria-hidden="true"></i>
</button>
</template>
<script setup>
/** 拖拽排序手柄;SORTABLE_LIST_ATTRS.handle 指向它的类名。 */
defineProps({
label: { type: String, default: '拖拽排序' },
})
</script>
@@ -0,0 +1,143 @@
<template>
<div class="jio-sorter">
<div
v-for="column in COLUMNS"
:key="column.key"
class="jio-sorter__column"
:class="`jio-sorter__column--${column.key}`"
>
<div class="jio-sorter__title">
<span>{{ column.title }}</span>
<n-tag size="small" :type="column.tagType" :bordered="false">{{ columns[column.key].length }}</n-tag>
</div>
<draggable
v-model="columns[column.key]"
v-bind="SORTABLE_LIST_ATTRS"
item-key="value"
group="jio-sorter"
class="jio-sortable jio-sortable--compact jio-sorter__list"
@change="commit"
>
<template #item="{ element }">
<div class="jio-sortable__item">
<div class="jio-sortable__head">
<JioSortHandle />
<i v-if="element.icon" :class="element.icon" class="jio-sorter__icon" aria-hidden="true"></i>
<span class="jio-sortable__title">{{ element.label }}</span>
</div>
</div>
</template>
</draggable>
<div v-if="columns[column.key].length === 0" class="jio-sortable__empty jio-sorter__empty">
{{ column.empty }}
</div>
</div>
</div>
</template>
<script setup>
/** 启用 / 未启用两列拖拽排序器;样式见 styles/sortable-list.css。 */
import { reactive, watch } from 'vue'
import draggable from 'vuedraggable'
import JioSortHandle from './JioSortHandle.vue'
import { SORTABLE_LIST_ATTRS } from './sortable-options.js'
const COLUMNS = [
{ key: 'enabled', title: '已启用', tagType: 'success', empty: '拖拽到此处启用' },
{ key: 'disabled', title: '未启用', tagType: 'default', empty: '拖拽到此处停用' },
]
const props = defineProps({
modelValue: { type: Object, default: () => ({}) },
options: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue'])
const columns = reactive({ enabled: [], disabled: [] })
let syncing = false
function keysOf(value) {
if (Array.isArray(value)) return value.map(String)
return value && typeof value === 'object' ? Object.keys(value) : []
}
function syncColumns() {
syncing = true
const byKey = new Map(props.options.map(option => [String(option.value), option]))
const used = new Set()
const normalize = keys => keys.filter(key => byKey.has(key) && !used.has(key)).map(key => {
used.add(key)
return byKey.get(key)
})
columns.enabled = normalize(keysOf(props.modelValue?.enabled))
columns.disabled = normalize(keysOf(props.modelValue?.disabled))
for (const option of props.options) {
if (!used.has(String(option.value))) columns.disabled.push(option)
}
syncing = false
}
function commit() {
if (syncing) return
const toMap = items => Object.fromEntries(items.map(item => [String(item.value), String(item.label)]))
emit('update:modelValue', {
enabled: toMap(columns.enabled),
disabled: toMap(columns.disabled),
})
}
watch(() => [props.modelValue, props.options], syncColumns, { immediate: true, deep: true })
</script>
<style scoped>
.jio-sorter {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.jio-sorter__column {
position: relative;
min-height: 210px;
padding: 12px;
border: 1px solid var(--jio-color-border);
border-radius: 8px;
background: var(--jio-color-canvas);
}
.jio-sorter__title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
color: var(--jio-color-text);
font-size: 13px;
font-weight: 600;
}
/* 空列也要留出可放置区域,拖拽目标不能塌成 0 高度。 */
.jio-sorter__list {
min-height: 44px;
}
.jio-sorter__empty {
position: absolute;
right: 12px;
bottom: 12px;
left: 12px;
top: 46px;
pointer-events: none;
}
.jio-sorter__icon {
flex: 0 0 auto;
width: 16px;
color: var(--jio-color-primary);
text-align: center;
}
@media (max-width: 720px) {
.jio-sorter {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,18 @@
/**
* schema 字段的 dependsOn 规则求值。sourceValue 是规则引用的数据对象
* (fieldset 值 / repeater 行 / 弹窗草稿)。
*/
export function matchDependency(rule, sourceValue) {
if (!rule) return true
const source = sourceValue && typeof sourceValue === 'object' ? sourceValue : {}
if (rule.all) return rule.all.every(item => matchDependency(item, source))
if (rule.any) return rule.any.some(item => matchDependency(item, source))
const value = source[rule.key]
if (rule.operator === 'truthy') return !!value
if (rule.operator === 'falsy') return !value
if (rule.operator === 'not_empty') return Array.isArray(value) ? value.length > 0 : value !== '' && value != null
if (rule.operator === 'empty') return Array.isArray(value) ? value.length === 0 : value === '' || value == null
if (rule.operator === 'eq') return value === rule.value
if (rule.operator === 'neq') return value !== rule.value
return rule.value === undefined || value === rule.value
}
@@ -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),
}))
}
@@ -0,0 +1,76 @@
/**
* repeater / group 行的纯函数:新行默认值、行 ID、listPreview 摘要与预览。
* inline 与 list-modal 两种渲染共用。
*/
let rowIdCounter = 0
export function createRepeaterRowId() {
return `rpt_${Date.now()}_${++rowIdCounter}`
}
export function createRepeaterRow(fields) {
const row = { _id: createRepeaterRowId() }
for (const field of (Array.isArray(fields) ? fields : [])) {
// UI-only chrome rows (notice / alert) are presentation only.
if (field.ui_only) continue
if (field.default !== undefined) {
row[field.key] = field.default
} else if (field.type === 'switch') {
row[field.key] = false
} else if (field.type === 'checkbox' || field.type === 'repeater') {
row[field.key] = []
} else {
row[field.key] = ''
}
}
return row
}
/** time/unit 形态的时长摘要:0 视为永久,其余按 day/month 补单位。 */
function rowDuration(row, cfg) {
if (!cfg.descKey) return ''
const raw = row[cfg.descKey]
if (raw === '' || raw == null) return ''
const amount = Number(raw)
if (!Number.isFinite(amount)) return String(raw)
if (amount <= 0) return '永久'
if (!cfg.descUnitKey) return String(raw)
const unit = String(row[cfg.descUnitKey] || '')
if (unit === 'month') return `${amount} 个月`
if (unit === 'day') return `${amount} 天`
return String(raw)
}
/** 未配置 titleKey 时,取首个值为非空字符串/数字的字段作为标题。 */
function fallbackTitleKey(row, fields) {
if (row.title != null && String(row.title).trim() !== '') return 'title'
for (const field of (Array.isArray(fields) ? fields : [])) {
if (field.ui_only) continue
const value = row[field.key]
if ((typeof value === 'string' && value.trim() !== '') || typeof value === 'number') return field.key
}
return 'title'
}
/** 收起态标题行上的一行摘要,取自 listPreview 配置。 */
export function repeaterRowSummary(row, cfg = {}, fields = []) {
if (!row) return ''
const title = String(row[cfg.titleKey || fallbackTitleKey(row, fields)] ?? '').trim()
const prefix = String(cfg.prefix || '')
if (title === '') return prefix
const duration = rowDuration(row, cfg)
return duration ? `${prefix}${title} · ${duration}` : `${prefix}${title}`
}
/** list-modal 卡片预览:图标 / 自定义图标 HTML / 图标类 / 标题 / 描述。 */
export function repeaterRowPreview(row, cfg = {}) {
const read = (key) => String(row?.[key] || '').trim()
const desc = read(cfg.descKey || 'desc') || read(cfg.urlKey || 'url')
return {
icon: read(cfg.iconKey || 'icon'),
customIcon: read(cfg.customIconKey || 'custom_icon'),
iconClass: read(cfg.iconClassKey || 'icon_class'),
title: read(cfg.titleKey || 'title') || '未命名卡片',
desc: desc || '暂无描述',
}
}
@@ -0,0 +1,15 @@
/**
* 所有后台 vuedraggable 列表共用的 Sortable 选项(v-bind 到 <draggable>)。
* 配套样式见 src/styles/sortable-list.css,拖拽手柄统一用 JioSortHandle。
*/
const prefersReducedMotion = typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
export const SORTABLE_LIST_ATTRS = Object.freeze({
handle: '.jio-sortable__handle',
ghostClass: 'jio-sortable__ghost',
chosenClass: 'jio-sortable__chosen',
dragClass: 'jio-sortable__drag',
animation: prefersReducedMotion ? 0 : 180,
})
@@ -0,0 +1,46 @@
/**
* 可排序卡片列表的折叠状态:所有带展开区的卡片默认收起,点击行头展开/收起。
*
* 状态按行的稳定 key 记录(而不是下标),拖拽换位后每张卡片保持自己的开合状态。
* 行头属性统一由 collapseHeadAttrs 生成:可点击、可键盘操作(Enter / Space)、
* 带 aria-expanded,样式见 styles/sortable-list.css。
*/
import { ref } from 'vue'
export function collapseHeadAttrs(open, toggle) {
return {
class: 'is-clickable',
role: 'button',
tabindex: 0,
'aria-expanded': open ? 'true' : 'false',
onClick: toggle,
onKeydown: (event) => {
// 只响应行头本身的按键,行内按钮 / 输入框的 Enter、空格不触发折叠。
if (event.target !== event.currentTarget) return
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
toggle()
},
}
}
export function useCollapsibleItems() {
const openKeys = ref(new Set())
function isOpen(key) {
return openKeys.value.has(key)
}
function toggle(key) {
const next = new Set(openKeys.value)
if (next.has(key)) next.delete(key)
else next.add(key)
openKeys.value = next
}
function headAttrs(key) {
return collapseHeadAttrs(isOpen(key), () => toggle(key))
}
return { isOpen, toggle, headAttrs }
}
@@ -0,0 +1,120 @@
import { onMounted, ref } from 'vue'
import api from '@/api'
/**
* JioField 的远程选项加载:async_select / taxonomy_select / user_select / page_select。
* 仅按 schema.type 在挂载时拉取对应数据源。
*/
export function useFieldRemoteOptions(props) {
const asyncOptions = ref([])
const asyncLoading = ref(false)
const taxonomyOptions = ref([])
const taxonomyLoading = ref(false)
const userOptions = ref([])
const userLoading = ref(false)
const pageOptions = ref([])
const pageLoading = ref(false)
let asyncTimer = null
let userTimer = null
let pageTimer = null
async function loadAsyncOptions(query = '') {
asyncLoading.value = true
try {
const selected = Array.isArray(props.modelValue) ? props.modelValue : (props.modelValue ? [props.modelValue] : [])
const response = await api.searchPageDisplayOptions(props.schema.source, query, query ? [] : selected)
asyncOptions.value = response?.data || []
} catch (error) {
asyncOptions.value = []
} finally {
asyncLoading.value = false
}
}
function handleAsyncSearch(query) {
clearTimeout(asyncTimer)
asyncTimer = setTimeout(() => loadAsyncOptions(query), 250)
}
async function loadTaxonomyTerms() {
if (!props.schema.taxonomy) return
taxonomyLoading.value = true
try {
const res = await api.getCategories(props.schema.taxonomy || 'category')
const items = res?.data || res || []
taxonomyOptions.value = (Array.isArray(items) ? items : []).map(t => ({
label: t.name + (t.count ? ' (' + t.count + ')' : ''),
value: t.term_id,
}))
} catch (e) {
console.warn('[JioField] taxonomy load failed:', e)
taxonomyOptions.value = []
} finally {
taxonomyLoading.value = false
}
}
async function loadUsers(query = '') {
userLoading.value = true
try {
const res = await api.searchUsers(query, 20)
const items = res?.data || res || []
userOptions.value = (Array.isArray(items) ? items : []).map(u => ({
label: u.display_name + ' (' + u.user_login + ')',
value: u.id,
}))
} catch (e) {
console.warn('[JioField] user load failed:', e)
userOptions.value = []
} finally {
userLoading.value = false
}
}
function handleUserSearch(query) {
clearTimeout(userTimer)
userTimer = setTimeout(() => loadUsers(query || ''), 300)
}
async function loadPages(query = '') {
pageLoading.value = true
try {
const selected = props.modelValue ? [props.modelValue] : []
const res = await api.searchPageDisplayOptions('pages', query, query ? [] : selected)
const items = res?.data || res || []
pageOptions.value = Array.isArray(items) ? items : []
} catch (e) {
console.warn('[JioField] page load failed:', e)
pageOptions.value = []
} finally {
pageLoading.value = false
}
}
function handlePageSearch(query) {
clearTimeout(pageTimer)
pageTimer = setTimeout(() => loadPages(query), 250)
}
onMounted(() => {
const type = props.schema.type
if (type === 'taxonomy_select') loadTaxonomyTerms()
if (type === 'user_select') loadUsers()
if (type === 'async_select') loadAsyncOptions()
if (type === 'page_select') loadPages()
})
return {
asyncOptions,
asyncLoading,
handleAsyncSearch,
taxonomyOptions,
taxonomyLoading,
userOptions,
userLoading,
handleUserSearch,
pageOptions,
pageLoading,
handlePageSearch,
}
}
@@ -1,4 +1,4 @@
import { renderFaIcon, schemaPage, customPage } from './helpers'
import { renderFaIcon, schemaPage } from './helpers'
export const extensionsSection = {
key: 'extensions',
@@ -6,6 +6,16 @@ export const extensionsSection = {
icon: renderFaIcon('fa fas fa-puzzle-piece'),
route: '/extensions',
children: [
schemaPage({
key: 'extensions-system',
name: 'ExtensionsSystem',
label: '系统工具',
route: '/extensions/system',
title: '系统工具',
desc: 'WordPress 优化、上传优化与编辑器显示的系统级开关。',
canonicalDomain: 'extensions',
section: 'system',
}),
schemaPage({
key: 'extensions-ip',
name: 'ExtensionsIp',
@@ -32,19 +42,19 @@ export const extensionsSection = {
label: '百度推送',
route: '/extensions/push',
title: '百度推送',
desc: '配置搜索引擎主动推送、批量提交和状态追踪。',
desc: '配置百度自动推送、收录通道与触发规则;推送结果与「重新提交」在文章/分类编辑页查看。',
canonicalDomain: 'extensions',
section: 'push',
}),
customPage({
key: 'extensions-ai',
name: 'ExtensionAI',
label: 'AI 连接配置',
route: '/extensions/ai',
component: 'ExtensionAI',
status: 'available',
title: 'AI 连接配置',
desc: '配置 AI 生成连接;SEO 在文章独立 SEO 框,摘要/标签在前台投稿页。',
schemaPage({
key: 'extensions-security',
name: 'ExtensionsSecurity',
label: '网站安全',
route: '/extensions/security',
title: '网站安全',
desc: '防刷限制、验证码发送限制与站点安全加固。',
canonicalDomain: 'extensions',
section: 'security',
}),
],
}
+11 -1
View File
@@ -12,7 +12,7 @@ export const globalSection = {
label: '常用功能',
route: '/global/common',
title: '常用功能',
desc: '评论、性能、头像源与备案等全局基础配置。',
desc: '评论、性能、头像源等全局基础配置。',
canonicalDomain: 'global',
section: 'common',
}),
@@ -84,6 +84,16 @@ export const globalSection = {
canonicalDomain: 'global',
section: 'i18n',
}),
schemaPage({
key: 'global-share',
name: 'GlobalShare',
label: '社交分享',
route: '/global/share',
title: '社交分享',
desc: '配置分享平台、微信 JSAPI 与海报分享。',
canonicalDomain: 'global',
section: 'share',
}),
schemaPage({
key: 'global-email',
name: 'GlobalEmail',
+1 -1
View File
@@ -16,7 +16,7 @@ const SECTION_DEFAULT_CHILDREN = {
permissions: 'permissions-comment',
users: 'users-auth',
payment: 'payment-knowledge',
extensions: 'extensions-ip',
extensions: 'extensions-system',
tools: 'tools-import-export',
'theme-info': 'theme-info-dashboard',
docs: 'docs-dashboard',
@@ -37,26 +37,15 @@ export const permissionsSection = {
canonicalDomain: 'permissions',
section: 'moderation',
}),
customPage({
schemaPage({
key: 'permissions-attachment',
name: 'PermissionsAttachment',
label: '附件配置',
route: '/permissions/attachment',
component: 'AttachmentPermissions',
title: '附件配置',
desc: '管理文件大小、类型、安全限制和审核策略。',
desc: '头像图片、上传大小、上传格式与上传行为配置。',
canonicalDomain: 'permissions',
section: 'attachment',
}),
schemaPage({
key: 'permissions-download',
name: 'PermissionsDownload',
label: '下载配置',
route: '/permissions/download',
title: '下载配置',
desc: '下载链接别名、默认使用次数与有效期限规则。',
canonicalDomain: 'permissions',
section: 'download',
}),
],
}
@@ -57,16 +57,6 @@ export const postListSection = {
canonicalDomain: 'post_list',
section: 'feature',
}),
schemaPage({
key: 'post-list-share',
name: 'PostListShare',
label: '分享设置',
route: '/post-list/share',
title: '分享设置',
desc: '配置微信分享凭据、海报分享和社交分享选项。',
canonicalDomain: 'post_list',
section: 'share',
}),
schemaPage({
key: 'post-list-comment',
name: 'PostListComment',
+12 -11
View File
@@ -60,7 +60,7 @@ export const usersSection = {
route: '/users/verify',
component: 'UsersVerify',
title: '身份认证',
desc: '配置认证展示、申请类型与申请门槛;审核、驳回和取消认证统一在 WordPress 用户菜单处理。',
desc: '配置通用认证申请规则;申请审核和认证资料管理位于 WordPress 用户菜单。',
canonicalDomain: 'users',
section: 'verify',
}),
@@ -108,16 +108,6 @@ export const usersSection = {
canonicalDomain: 'users',
section: 'sms',
}),
schemaPage({
key: 'users-wechat',
name: 'UsersWechat',
label: '微信通知',
route: '/users/wechat',
title: '微信通知',
desc: '配置微信模板消息、订阅/解绑和站外通知策略。',
canonicalDomain: 'users',
section: 'wechat',
}),
customPage({
key: 'users-wechat-template',
name: 'UsersWechatTemplate',
@@ -140,5 +130,16 @@ export const usersSection = {
canonicalDomain: 'users',
section: 'report',
}),
customPage({
key: 'users-ticket',
name: 'UsersTicket',
label: '工单中心',
route: '/users/ticket',
title: '工单中心',
desc: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置,以及部门、状态、预设回复维护;工单处理在 WordPress 用户菜单的「工单管理」中进行。',
component: 'UsersTicket',
canonicalDomain: 'users',
section: 'ticket',
}),
],
}
+12 -11
View File
@@ -4,6 +4,7 @@ import NaiveUI from 'naive-ui'
import App from './App.vue'
import router from './router'
import './style.css'
import './styles/sortable-list.css'
import { styleMountTarget } from './style-target'
const app = createApp(App)
@@ -13,35 +14,35 @@ app.use(NaiveUI)
// 全局错误处理
app.config.errorHandler = (err, vm, info) => {
console.error('[AKSRT] 全局错误:', err, info)
console.error('[Jio] 全局错误:', err, info)
}
// 兜底:立即显示加载状态(防止白屏)
function showFallback(el) {
el.innerHTML = '<div style="padding:40px;text-align:center;color:#666;font-family:sans-serif;">'
+ '<p style="font-size:18px;margin-bottom:8px;">AKSRT 正在加载...</p>'
+ '<p style="font-size:18px;margin-bottom:8px;">Jio 正在加载...</p>'
+ '<p style="font-size:13px;color:#999;">如果长时间停留在此页面,请按 F12 查看控制台错误</p>'
+ '</div>'
}
showFallback(document.getElementById('aksrt-admin-app') || document.body.appendChild(
Object.assign(document.createElement('div'), { id: 'aksrt-admin-app' })
showFallback(document.getElementById('jio-admin-app') || document.body.appendChild(
Object.assign(document.createElement('div'), { id: 'jio-admin-app' })
))
/**
* 获取或创建一个干净的挂载容器
* 核心问题:WordPress 后台的 jQuery/脚本会在页面加载后持续操作 DOM,
* 导致 PHP 输出的 <div id="aksrt-admin-app"> 可能被外部修改或其兄弟节点被移除,
* 导致 PHP 输出的 <div id="jio-admin-app"> 可能被外部修改或其兄弟节点被移除,
* Vue patch 时遍历到 null 节点 → nextSibling 报错。
*
* 解决方案:在挂载前一刻,动态创建一个全新的容器元素替换旧的,
* 确保该节点及其父级在 Vue 接管前不被任何外部脚本污染。
*/
function getCleanContainer() {
const existing = document.getElementById('aksrt-admin-app')
const existing = document.getElementById('jio-admin-app')
if (!existing) {
// 极端情况:PHP 没有输出容器,手动创建
const fresh = document.createElement('div')
fresh.id = 'aksrt-admin-app'
fresh.id = 'jio-admin-app'
document.body.appendChild(fresh)
return fresh
}
@@ -52,13 +53,13 @@ function getCleanContainer() {
if (!parent) {
// 旧容器已脱离文档树,重新创建
const fresh = document.createElement('div')
fresh.id = 'aksrt-admin-app'
fresh.id = 'jio-admin-app'
document.body.appendChild(fresh)
return fresh
}
const fresh = document.createElement('div')
fresh.id = 'aksrt-admin-app'
fresh.id = 'jio-admin-app'
// 将旧容器的位置信息转移给新容器(保留 fallback 内容)
fresh.innerHTML = existing.innerHTML || ''
@@ -90,12 +91,12 @@ router.isReady().then(() => {
styleMountTarget.value = container
app.mount(container)
} catch (err) {
console.error('[AKSRT] Vue 挂载失败:', err)
console.error('[Jio] Vue 挂载失败:', err)
showError(getCleanContainer(), err)
}
})
})
})
}).catch((err) => {
console.error('[AKSRT] Router 初始化失败:', err)
console.error('[Jio] Router 初始化失败:', err)
})
+22 -129
View File
@@ -1,20 +1,20 @@
<template>
<div class="aksrt-page-container">
<h2 class="aksrt-page-title">积分配置</h2>
<p class="aksrt-page-desc">
<div class="jio-page-container">
<h2 class="jio-page-title">积分配置</h2>
<p class="jio-page-desc">
配置积分规则与发放策略。可自定义货币名称(如”金币”、”魔石”等)。用户余额与手工调整在 WordPress 原生”用户”菜单中管理。
</p>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>主开关 + 基础设置</h3></div>
<n-form :model="cfg" label-placement="left" label-width="200" class="aksrt-form">
<n-card class="jio-section-card">
<div class="jio-section-header"><h3>主开关 + 基础设置</h3></div>
<n-form :model="cfg" label-placement="left" label-width="200" class="jio-form">
<n-form-item label="启用积分系统">
<n-switch v-model:value="cfg.enabled" />
</n-form-item>
<n-form-item label="自定义货币名称">
<n-input v-model:value="cfg.currency_name" placeholder="积分" maxlength="20" />
<template #feedback>
<span class="aksrt-hint-text">自定义货币显示名称,如"金币"、"魔石"等。留空则使用默认名称"积分"。</span>
<span class="jio-hint-text">自定义货币显示名称,如"金币"、"魔石"等。留空则使用默认名称"积分"。</span>
</template>
</n-form-item>
<n-form-item label="注册赠送积分">
@@ -23,38 +23,9 @@
</n-form>
</n-card>
<n-card title="统一资产账本" class="aksrt-section-card">
<n-space vertical :size="12">
<n-space wrap>
<n-select v-model:value="ledgerFilters.asset" :options="assetOptions" clearable placeholder="资产" style="width: 130px" />
<n-input v-model:value="ledgerFilters.transaction_id" placeholder="交易号" clearable style="width: 130px" />
<n-input v-model:value="ledgerFilters.operation_key" placeholder="幂等键" clearable style="width: 240px" />
<n-input v-model:value="ledgerFilters.ref_type" placeholder="引用类型" clearable style="width: 130px" />
<n-input v-model:value="ledgerFilters.ref_key" placeholder="引用键" clearable style="width: 180px" />
<n-input v-model:value="ledgerFilters.actor_user_id" placeholder="操作者 ID" clearable style="width: 130px" />
<n-button type="primary" :loading="ledgerLoading" @click="searchLedger">查询</n-button>
<n-button @click="resetLedger">重置</n-button>
</n-space>
<n-data-table
:columns="ledgerColumns"
:data="ledgerRows"
:loading="ledgerLoading"
:row-key="row => row.id"
:scroll-x="1600"
size="small"
/>
<n-pagination
v-model:page="ledgerPage"
:page-count="ledgerPageCount"
:page-size="20"
@update:page="loadLedger"
/>
</n-space>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>每日签到</h3></div>
<n-form :model="cfg.daily_signin" label-placement="left" label-width="200" class="aksrt-form">
<n-card class="jio-section-card">
<div class="jio-section-header"><h3>每日签到</h3></div>
<n-form :model="cfg.daily_signin" label-placement="left" label-width="200" class="jio-form">
<n-form-item label="启用">
<n-switch v-model:value="cfg.daily_signin.enabled" />
</n-form-item>
@@ -69,15 +40,15 @@
:autosize="{ minRows: 2 }"
/>
<template #feedback>
<span class="aksrt-hint-text">连续 N 天时额外发放的积分。</span>
<span class="jio-hint-text">连续 N 天时额外发放的积分。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>评论奖励</h3></div>
<n-form :model="cfg.comment_reward" label-placement="left" label-width="200" class="aksrt-form">
<n-card class="jio-section-card">
<div class="jio-section-header"><h3>评论奖励</h3></div>
<n-form :model="cfg.comment_reward" label-placement="left" label-width="200" class="jio-form">
<n-form-item label="启用">
<n-switch v-model:value="cfg.comment_reward.enabled" />
</n-form-item>
@@ -96,9 +67,9 @@
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>Ref 推广奖励</h3></div>
<n-form :model="cfg.referral_reward" label-placement="left" label-width="200" class="aksrt-form">
<n-card class="jio-section-card">
<div class="jio-section-header"><h3>Ref 推广奖励</h3></div>
<n-form :model="cfg.referral_reward" label-placement="left" label-width="200" class="jio-form">
<n-form-item label="启用">
<n-switch v-model:value="cfg.referral_reward.enabled" />
</n-form-item>
@@ -120,19 +91,19 @@
</n-form>
</n-card>
<n-card class="aksrt-section-card">
<div class="aksrt-section-header"><h3>下载消费(参考)</h3></div>
<n-form :model="cfg" label-placement="left" label-width="200" class="aksrt-form">
<n-card class="jio-section-card">
<div class="jio-section-header"><h3>下载消费(参考)</h3></div>
<n-form :model="cfg" label-placement="left" label-width="200" class="jio-form">
<n-form-item :label="`默认积分价参考(${cfg.currency_name || '积分'})`">
<n-input-number v-model:value="cfg.download_default_cost" :min="0" :max="100000" />
<template #feedback>
<span class="aksrt-hint-text">实际价格以文章「付费内容设置」的积分价或现金价为准;下载解锁走收银台订单,不按次扣费。</span>
<span class="jio-hint-text">实际价格以文章「付费内容设置」的积分价或现金价为准;下载解锁走收银台订单,不按次扣费。</span>
</template>
</n-form-item>
</n-form>
</n-card>
<div class="aksrt-form-footer">
<div class="jio-form-footer">
<n-button type="primary" :loading="saving" @click="saveCfg">保存配置</n-button>
</div>
</div>
@@ -164,83 +135,6 @@ const cfg = reactive({
})
const streakJson = ref('{}')
const saving = ref(false)
const ledgerLoading = ref(false)
const ledgerRows = ref([])
const ledgerPage = ref(1)
const ledgerPageCount = ref(1)
const ledgerFilters = reactive({
asset: null,
transaction_id: '',
operation_key: '',
ref_type: '',
ref_key: '',
actor_user_id: '',
})
const assetOptions = [
{ label: '积分', value: 'points' },
{ label: '余额', value: 'balance' },
]
const ledgerColumns = [
{ title: '交易号', key: 'transaction_label', width: 100, fixed: 'left' },
{ title: '幂等键', key: 'operation_key', width: 260, ellipsis: { tooltip: true } },
{ title: '资产', key: 'assets_label', width: 100 },
{ title: '原因', key: 'reason', width: 150, ellipsis: { tooltip: true } },
{ title: '业务引用', key: 'reference_label', width: 200, ellipsis: { tooltip: true } },
{ title: '操作者', key: 'actor_label', width: 100 },
{ title: '增减 / 手续费', key: 'delta_label', width: 180, ellipsis: { tooltip: true } },
{ title: '变动后余额', key: 'balance_label', width: 150, ellipsis: { tooltip: true } },
{ title: '备注', key: 'remark', width: 180, ellipsis: { tooltip: true } },
{ title: 'UTC 时间', key: 'created_at', width: 190 },
]
function formatAssetValue(asset, value) {
if (asset === 'balance') return typeof value === 'string' && /^[+-]?\d+\.\d{2}$/.test(value) ? value : '0.00'
const raw = String(value ?? '0')
return /^-?\d+$/.test(raw) ? raw : '0'
}
async function loadLedger() {
ledgerLoading.value = true
try {
const params = { page: ledgerPage.value, per_page: 20 }
Object.entries(ledgerFilters).forEach(([key, value]) => {
if (value !== null && String(value).trim() !== '') params[key] = value
})
const response = await api.getCreditLog(params)
const data = response?.data || response || {}
ledgerRows.value = (data.items || []).map((row) => {
const entries = Array.isArray(row.entries) ? row.entries : []
return {
...row,
transaction_label: `#${row.id}`,
assets_label: (row.assets || []).join(' / '),
reference_label: [row.ref_type, row.ref_key].filter(Boolean).join(':') || '—',
actor_label: row.actor_user_id ? `#${row.actor_user_id}` : '系统',
delta_label: entries.map((entry) => {
const value = formatAssetValue(entry.asset, entry.delta)
const signed = entry.asset === 'points' && !value.startsWith('-') && value !== '0' ? `+${value}` : value
return `${entry.asset}:${signed}`
}).join(';'),
balance_label: entries.map((entry) => `${entry.asset}:${formatAssetValue(entry.asset, entry.balance_after)}`).join(';'),
}
})
ledgerPageCount.value = Math.max(1, Math.ceil(Number(data.total || 0) / Number(data.per_page || 20)))
} catch (e) {
message.error(e.message || '加载账本失败')
} finally {
ledgerLoading.value = false
}
}
function searchLedger() {
ledgerPage.value = 1
loadLedger()
}
function resetLedger() {
Object.assign(ledgerFilters, { asset: null, transaction_id: '', operation_key: '', ref_type: '', ref_key: '', actor_user_id: '' })
searchLedger()
}
async function loadCfg() {
try {
@@ -270,6 +164,5 @@ async function saveCfg() {
onMounted(() => {
loadCfg()
loadLedger()
})
</script>
+52 -16
View File
@@ -1,12 +1,12 @@
<template>
<div class="aksrt-schema-page">
<AksFormRenderer
<div class="jio-schema-page">
<JioFormRenderer
:title="pageMeta.title"
:description="pageMeta.description"
:icon="pageMeta.icon"
:schema="pageMeta.groups"
:form-data="formData"
:dirty="dirty"
:dirty="isSmsPage ? isSmsSettingsDirty : dirty"
:loading="loading"
:saving="saving"
:load-error="loadError"
@@ -24,7 +24,7 @@
v-model:value="captchaProviderTab"
type="segment"
animated
class="aksrt-tabs aksrt-captcha-provider-tabs"
class="jio-tabs jio-captcha-provider-tabs"
>
<n-tab-pane
v-for="provider in captchaProviders"
@@ -48,18 +48,18 @@
#field-geoip_block_countries="{ value, setValue }"
>
<n-dynamic-tags :value="Array.isArray(value) ? value : []" @update:value="setValue" />
<p class="aksrt-schema-page__field-hint">使用 ISO 国家代码,如 CN / US / RU。</p>
<p class="jio-schema-page__field-hint">使用 ISO 国家代码,如 CN / US / RU。</p>
</template>
<!-- Slot for custom group content (override per page if needed) -->
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</AksFormRenderer>
</JioFormRenderer>
<div
v-if="isPermissionsLogin && !loading"
class="aksrt-schema-page__tool-actions"
class="jio-schema-page__tool-actions"
>
<n-button
:disabled="!formData?.enabled || !formData?.active_provider"
@@ -80,7 +80,7 @@
<n-spin />
<p style="margin-top:10px;color:#999;">加载中...</p>
</div>
<div v-if="testResult && !testLoading" :style="{color: testResult.success ? 'var(--aksrt-color-primary)' : '#d03050', textAlign:'center', padding:'20px'}">
<div v-if="testResult && !testLoading" :style="{color: testResult.success ? 'var(--jio-color-primary)' : '#d03050', textAlign:'center', padding:'20px'}">
<p style="font-size:16px;margin:0;">{{ testResult.message }}</p>
</div>
<div v-if="!testLoading && !testResult" id="captcha-test-box" style="min-height:150px;"></div>
@@ -95,7 +95,7 @@
* SchemaPage — generic Schema-driven settings page.
*
* Reads the schema from the Vue-side schema registry, fetches/saves settings
* via the API client, and renders using AksFormRenderer.
* via the API client, and renders using JioFormRenderer.
*
* Usage (in router or parent):
* <SchemaPage domain="global" section="logo" />
@@ -106,7 +106,7 @@
import { computed, watch, ref, nextTick, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
import JioFormRenderer from '@/components/form/JioFormRenderer.vue'
import { useSectionForm } from '@/composables/useSectionForm'
import { getSchema, normalizeSchemaDomain } from '@/schema/index.js'
import api from '@/api/index.js'
@@ -131,6 +131,7 @@ const showTestModal = ref(false)
const testLoading = ref(false)
const testResult = ref(null)
const serverSchema = ref(null)
const smsSavedSettings = ref('')
const captchaSecretsToClear = ref(new Set())
const captchaProviders = [
{ key: 'tencent', label: '腾讯云 Captcha', component: TencentConfig },
@@ -144,8 +145,17 @@ const captchaProviderTab = ref('tencent')
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
const isEmailPage = computed(() => resolvedDomain.value === 'global' && resolvedSection.value === 'email')
const isSmsPage = computed(() => resolvedDomain.value === 'users' && resolvedSection.value === 'sms')
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
const isSmsSettingsDirty = computed(() => smsSavedSettings.value !== '' && smsSavedSettings.value !== smsSettingsSnapshot(formData.value))
function smsSettingsSnapshot(data) {
const settings = { ...(data || {}) }
delete settings.sms_test_phone
delete settings.sms_test_send
return JSON.stringify(settings)
}
function resolveDomainFromPath(path) {
const parts = path.split('/').filter(Boolean)
@@ -211,11 +221,15 @@ const {
const payload = { ...data }
const clearSecrets = []
Object.keys(payload).forEach((key) => {
if (payload[key] === '__AKSRT_CLEAR_SECRET__') {
if (payload[key] === '__JIO_CLEAR_SECRET__') {
clearSecrets.push(key)
delete payload[key]
}
})
if (isSmsPage.value) {
delete payload.sms_test_phone
delete payload.sms_test_send
}
if (clearSecrets.length) payload.__clear_secrets = clearSecrets
if (isPermissionsLogin.value && captchaSecretsToClear.value.size) {
payload.__clear_secrets = [...(payload.__clear_secrets || []), ...captchaSecretsToClear.value]
@@ -226,12 +240,34 @@ const {
return response
},
defaults: defaults.value,
onLoaded: (data) => {
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(data)
},
onSaved: () => {
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(formData.value)
message.success('设置已保存')
},
})
async function handleFieldAction(key) {
if (key === 'sms_test_send' && isSmsPage.value) {
const phone = String(formData.value?.sms_test_phone || '').trim()
if (!phone) {
message.warning('请先填写测试手机号')
return
}
if (isSmsSettingsDirty.value) {
message.warning('请先保存当前配置,再发送测试短信')
return
}
try {
const response = await api.testSms(phone)
message.success(response?.message || '测试短信已发送')
} catch (error) {
message.error(error?.message || '测试短信发送失败')
}
return
}
if (key !== 'email_test_send' || !isEmailPage.value) return
const to = String(formData.value?.email_test_to || '').trim()
if (!to) {
@@ -381,7 +417,7 @@ function loadProviderSDK(provider, cfg) {
sitekey: cfg.site_key,
theme: cfg.theme || 'auto',
size: cfg.size || 'normal',
action: 'aksrt-admin-test',
action: 'jio-admin-test',
cdata: 'admin-captcha-test',
callback: function(token) {
submitCaptchaTest('cloudflare', { cf_turnstile_response: token })
@@ -503,21 +539,21 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.aksrt-schema-page {
.jio-schema-page {
max-width: none;
margin: 0;
}
.aksrt-schema-page__tool-actions {
.jio-schema-page__tool-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.aksrt-schema-page__field-hint {
.jio-schema-page__field-hint {
margin: 8px 0 0;
font-size: 12px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
}
.captcha-test-overlay {
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
<template>
<div class="aksrt-page-container content-page">
<div class="jio-page-container content-page">
<PageHeader title="底部Tab配置" desc="自定义移动端底部导航栏的按钮,支持排序、图标配置和返回顶部功能。">
<template #actions>
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
@@ -8,24 +8,24 @@
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<!-- 基础设置 -->
<n-card class="aksrt-section-card">
<n-card class="jio-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<div class="jio-card-header-row">
<div class="jio-card-header-row__left">
<span class="header-icon"><i class="fa fas fa-cog"></i></span>
<strong class="aksrt-card-header-row__title">基础设置</strong>
<strong class="jio-card-header-row__title">基础设置</strong>
</div>
</div>
</template>
<n-form :model="form" label-placement="left" label-width="140" class="aksrt-form">
<n-form :model="form" label-placement="left" label-width="140" class="jio-form">
<n-form-item label="启用底部Tab">
<n-switch v-model:value="form.enabled" />
<span class="aksrt-hint-text" style="margin-left: 12px;">移动端底部导航栏总开关</span>
<span class="jio-hint-text" style="margin-left: 12px;">移动端底部导航栏总开关</span>
</n-form-item>
<n-form-item label="向下滚动隐藏">
<n-switch v-model:value="form.scroll_down_hide" />
<span class="aksrt-hint-text" style="margin-left: 12px;">页面向下滚动时隐藏,向上滚动时显示</span>
<span class="jio-hint-text" style="margin-left: 12px;">页面向下滚动时隐藏,向上滚动时显示</span>
</n-form-item>
<n-form-item label="文章页专属Tab">
<n-select
@@ -36,18 +36,18 @@
]"
style="width: 240px;"
/>
<span class="aksrt-hint-text" style="margin-left: 12px;">文章详情页底部显示的Tab按钮类型</span>
<span class="jio-hint-text" style="margin-left: 12px;">文章详情页底部显示的Tab按钮类型</span>
</n-form-item>
</n-form>
</n-card>
<!-- Tab按钮配置 -->
<n-card class="aksrt-section-card">
<n-card class="jio-section-card">
<template #header>
<div class="aksrt-card-header-row">
<div class="aksrt-card-header-row__left">
<div class="jio-card-header-row">
<div class="jio-card-header-row__left">
<span class="header-icon"><i class="fa fas fa-list-ul"></i></span>
<strong class="aksrt-card-header-row__title">Tab按钮配置 ({{ form.items.length }})</strong>
<strong class="jio-card-header-row__title">Tab按钮配置 ({{ form.items.length }})</strong>
</div>
<n-button type="primary" size="small" @click="addItem">
<template #icon><i class="fa fas fa-plus"></i></template>
@@ -61,42 +61,38 @@
<draggable
v-if="form.items.length"
v-model="form.items"
v-bind="SORTABLE_LIST_ATTRS"
item-key="id"
handle=".tabbar-sort__handle"
ghost-class="tabbar-sort__ghost"
animation="180"
class="tabbar-sort"
class="jio-sortable"
@end="resetSortOrder"
>
<template #item="{ element, index }">
<div class="tabbar-sort__item">
<div class="tabbar-sort__main">
<button type="button" class="tabbar-sort__handle" aria-label="拖拽排序">
<i class="fa fas fa-bars"></i>
</button>
<span class="tabbar-sort__icon">
<div class="jio-sortable__item">
<div class="jio-sortable__head">
<JioSortHandle />
<span class="jio-sortable__icon">
<IconValue v-if="element.icon" :value="element.icon" />
</span>
<div class="tabbar-sort__meta">
<div class="tabbar-sort__title-row">
<strong>{{ element.text || '按钮' }}</strong>
<div class="jio-sortable__main">
<div class="jio-sortable__title-row">
<span class="jio-sortable__title">{{ element.text || '按钮' }}</span>
<n-tag size="small" :bordered="false">{{ typeLabelMap[element.type] || element.type }}</n-tag>
<n-tag v-if="element.badge" size="small" type="error" :bordered="false">徽标 {{ element.badge }}</n-tag>
<n-tag v-if="element.type === 'home' && element.ontop" size="small" type="success" :bordered="false">返回顶部</n-tag>
<n-tag size="small" :type="element.enabled ? 'success' : 'default'" :bordered="false">{{ element.enabled ? '启用' : '禁用' }}</n-tag>
</div>
<div class="tabbar-sort__desc">{{ getItemSummary(element) }}</div>
<div class="jio-sortable__desc">{{ getItemSummary(element) }}</div>
</div>
<div class="jio-sortable__actions">
<n-button size="tiny" quaternary type="primary" @click="editItem(index)">
<template #icon><i class="fa fas fa-edit" style="font-size:12px;" /></template>
编辑
</n-button>
<n-button size="tiny" quaternary type="error" @click="deleteItem(index)">
<template #icon><i class="fa fas fa-trash-alt" style="font-size:12px;" /></template>
删除
</n-button>
</div>
</div>
<div class="tabbar-sort__actions">
<n-button size="tiny" quaternary type="primary" @click="editItem(index)">
<template #icon><i class="fa fas fa-edit" style="font-size:12px;" /></template>
编辑
</n-button>
<n-button size="tiny" quaternary type="error" @click="deleteItem(index)">
<template #icon><i class="fa fas fa-trash-alt" style="font-size:12px;" /></template>
删除
</n-button>
</div>
</div>
</template>
@@ -104,7 +100,7 @@
<n-empty v-else description="暂无Tab按钮,请点击上方按钮添加" />
</n-card>
<div class="aksrt-form-actions">
<div class="jio-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存底部Tab配置</n-button>
</div>
</n-spin>
@@ -115,10 +111,10 @@
:mask-closable="false"
preset="card"
:title="editingIndex >= 0 ? '编辑Tab按钮' : '添加Tab按钮'"
class="aksrt-modal-card"
class="jio-modal-card"
style="max-width: 520px;"
>
<n-form :model="editingItem" label-placement="top" class="aksrt-form">
<n-form :model="editingItem" label-placement="top" class="jio-form">
<n-form-item label="按钮类型">
<n-select
v-model:value="editingItem.type"
@@ -216,22 +212,21 @@
<div v-if="editingItem.add_items.length" class="add-items-editor__list">
<draggable
v-model="editingItem.add_items"
v-bind="SORTABLE_LIST_ATTRS"
item-key="id"
handle=".add-items-editor__drag"
ghost-class="add-items-editor__ghost"
animation="180"
class="jio-sortable jio-sortable--compact"
>
<template #item="{ element, index: subIndex }">
<div class="add-items-editor__row">
<button type="button" class="add-items-editor__drag" aria-label="拖拽排序">
<i class="fa fas fa-bars"></i>
</button>
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
<FaIconPicker v-model="element.icon" placeholder="选择图标" />
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
<n-button quaternary type="error" @click="removeAddItem(subIndex)">
<template #icon><i class="fa fas fa-trash-alt" /></template>
</n-button>
<div class="jio-sortable__item">
<div class="jio-sortable__head add-items-editor__row">
<JioSortHandle />
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
<FaIconPicker v-model="element.icon" placeholder="选择图标" />
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
<n-button quaternary type="error" aria-label="删除子项" @click="removeAddItem(subIndex)">
<template #icon><i class="fa fas fa-trash-alt" /></template>
</n-button>
</div>
</div>
</template>
</draggable>
@@ -241,7 +236,7 @@
<n-form-item v-if="editingItem.type === 'home'" label="返回顶部功能">
<n-switch v-model:value="editingItem.ontop" />
<span class="aksrt-hint-text" style="margin-left: 12px;">页面滚动后切换为返回顶部按钮</span>
<span class="jio-hint-text" style="margin-left: 12px;">页面滚动后切换为返回顶部按钮</span>
</n-form-item>
<n-form-item label="启用状态">
@@ -250,7 +245,7 @@
</n-form>
<template #footer>
<div class="aksrt-modal-footer">
<div class="jio-modal-footer">
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" @click="saveItem">保存</n-button>
</div>
@@ -267,6 +262,8 @@ import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import FaIconPicker from '@/components/form/FaIconPicker.vue'
import IconValue from '@/components/form/IconValue.vue'
import JioSortHandle from '@/components/form/JioSortHandle.vue'
import { SORTABLE_LIST_ATTRS } from '@/components/form/sortable-options.js'
const message = useMessage()
const dialog = useDialog()
@@ -609,7 +606,7 @@ onMounted(loadSettings)
}
.icon-preview-row a {
color: var(--n-primary-color);
color: var(--jio-color-primary, #18A058);
text-decoration: none;
}
@@ -618,18 +615,12 @@ onMounted(loadSettings)
}
.muted-text {
color: #999;
color: var(--jio-color-muted, #6F7A72);
font-size: 12px;
}
.aksrt-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.section-desc {
color: var(--n-text-color-3);
color: var(--jio-color-muted, #6F7A72);
font-size: 13px;
margin: 0 0 16px;
}
@@ -659,127 +650,19 @@ onMounted(loadSettings)
.add-items-editor__presets-label {
font-size: 12px;
color: var(--n-text-color-3, #999);
}
.add-items-editor__list {
display: flex;
flex-direction: column;
gap: 10px;
color: var(--jio-color-muted, #6F7A72);
}
.add-items-editor__row {
display: grid;
grid-template-columns: 32px 1fr 1fr 1.2fr 40px;
grid-template-columns: 28px 1fr 1fr 1.2fr 34px;
gap: 8px;
align-items: center;
}
.add-items-editor__drag {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
background: #f3f5f7;
color: #6b7280;
cursor: grab;
}
.add-items-editor__ghost {
opacity: 0.5;
}
.tabbar-sort {
display: flex;
flex-direction: column;
gap: 12px;
}
.tabbar-sort__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--n-border-color);
border-radius: 14px;
background: #fff;
}
.tabbar-sort__main {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.tabbar-sort__handle {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
background: #f3f5f7;
color: #6b7280;
cursor: grab;
}
.tabbar-sort__icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(24, 160, 88, 0.12);
color: var(--aksrt-color-primary);
font-size: 18px;
flex: 0 0 auto;
}
.tabbar-sort__meta {
min-width: 0;
flex: 1;
}
.tabbar-sort__title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
.tabbar-sort__desc {
color: var(--n-text-color-3);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tabbar-sort__actions {
display: flex;
gap: 6px;
flex: 0 0 auto;
}
.tabbar-sort__ghost {
opacity: 0.5;
}
@media (max-width: 1023px) {
.tabbar-sort__item {
flex-direction: column;
align-items: stretch;
}
.tabbar-sort__actions {
justify-content: flex-end;
}
.add-items-editor__row {
grid-template-columns: 32px 1fr;
grid-template-columns: 28px 1fr;
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<div class="aksrt-page-container content-page">
<div class="jio-page-container content-page">
<PageHeader title="通知管理" desc="管理通知内容开关、user 徽标统计口径和自动清理策略。">
<template #actions>
<n-space>
@@ -10,7 +10,7 @@
</PageHeader>
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
<n-card title="通知内容开关" class="aksrt-section-card">
<n-card title="通知内容开关" class="jio-section-card">
<n-form :model="settings" label-placement="left" label-width="180">
<n-form-item label="评论回复通知">
<n-switch v-model:value="settings.enabled_types.comment_reply" />
@@ -35,21 +35,21 @@
<n-grid :cols="2" :x-gap="16" responsive="screen" item-responsive style="margin-top: 16px;">
<n-grid-item span="24 l:10">
<n-card title="user 徽标规则" class="aksrt-section-card">
<n-card title="user 徽标规则" class="jio-section-card">
<n-form :model="settings" label-placement="left" label-width="180">
<n-form-item label="启用 user 动态徽标">
<n-switch v-model:value="settings.user_badge_enabled" />
</n-form-item>
<n-form-item label="徽标包含状态提醒">
<n-switch v-model:value="settings.badge_include_stateful" />
<span class="aksrt-hint-text" style="margin-left: 12px;">未读持久化通知之外,叠加邮箱待验证、待审核评论等即时提醒</span>
<span class="jio-hint-text" style="margin-left: 12px;">未读持久化通知之外,叠加邮箱待验证、待审核评论等即时提醒</span>
</n-form-item>
</n-form>
</n-card>
</n-grid-item>
<n-grid-item span="24 l:14">
<n-card title="通知清理策略" class="aksrt-section-card">
<n-card title="通知清理策略" class="jio-section-card">
<n-form :model="settings" label-placement="left" label-width="180">
<n-form-item label="已读通知保留天数">
<n-input-number v-model:value="settings.read_retention_days" :min="1" :max="365" style="width: 180px;" />
+42 -42
View File
@@ -1,12 +1,12 @@
<template>
<div class="aksrt-page-container">
<div class="jio-page-container">
<PageHeader title="文档中心" desc="主题使用文档、开发指南、迁移说明和更新日志。" />
<!-- Quick Actions -->
<n-card title="常用操作" size="small" class="aksrt-docs__section">
<n-card title="常用操作" size="small" class="jio-docs__section">
<n-grid :cols="2" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="aksrt-docs__action">
<div class="jio-docs__action">
<FaIcon icon="fa fas fa-paint-brush" :size="16" />
<div>
<strong>全局设置</strong>
@@ -16,7 +16,7 @@
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs__action">
<div class="jio-docs__action">
<FaIcon icon="fa fas fa-home" :size="16" />
<div>
<strong>首页配置</strong>
@@ -26,7 +26,7 @@
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs__action">
<div class="jio-docs__action">
<FaIcon icon="fa fas fa-users" :size="16" />
<div>
<strong>用户管理</strong>
@@ -36,7 +36,7 @@
</div>
</n-grid-item>
<n-grid-item>
<div class="aksrt-docs__action">
<div class="jio-docs__action">
<FaIcon icon="fa fas fa-wrench" :size="16" />
<div>
<strong>工具</strong>
@@ -49,30 +49,30 @@
</n-card>
<!-- Reference Docs -->
<div class="aksrt-docs__grid">
<div class="jio-docs__grid">
<n-card
v-for="doc in docs"
:key="doc.key"
class="aksrt-docs__card"
class="jio-docs__card"
size="small"
hoverable
@click="openDoc(doc)"
>
<div class="aksrt-docs__card-inner">
<div class="aksrt-docs__card-icon" :style="{ background: doc.color }">
<div class="jio-docs__card-inner">
<div class="jio-docs__card-icon" :style="{ background: doc.color }">
<FaIcon :icon="doc.icon" :size="20" color="#fff" />
</div>
<div class="aksrt-docs__card-body">
<h4 class="aksrt-docs__card-title">{{ doc.title }}</h4>
<p class="aksrt-docs__card-desc">{{ doc.description }}</p>
<div class="jio-docs__card-body">
<h4 class="jio-docs__card-title">{{ doc.title }}</h4>
<p class="jio-docs__card-desc">{{ doc.description }}</p>
</div>
<FaIcon icon="fa fas fa-external-link-alt" :size="12" class="aksrt-docs__card-arrow" />
<FaIcon icon="fa fas fa-external-link-alt" :size="12" class="jio-docs__card-arrow" />
</div>
</n-card>
</div>
<!-- Version Info -->
<n-card title="当前版本" size="small" class="aksrt-docs__section">
<n-card title="当前版本" size="small" class="jio-docs__section">
<n-descriptions :column="2" bordered label-placement="left" size="small">
<n-descriptions-item label="主题版本">
<n-tag type="success" size="small">{{ themeVersion }}</n-tag>
@@ -91,12 +91,12 @@ import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
import api from '@/api/index.js'
const themeVersion = ref(window.aksrtAdminData?.themeVersion || '3.4.0-dev')
const themeVersion = ref(window.jioAdminData?.themeVersion || '3.4.0-dev')
const dbSchemaVersion = ref('—')
const docs = ref([
{ key: 'admin-ia-map', title: '后台 IA 蓝图', description: '完整的后台信息架构映射,12 个顶级分区及子页面清单。', icon: 'fa fas fa-sitemap', color: 'var(--aksrt-color-primary)', file: 'admin-ia-map.md' },
{ key: 'legacy-gap-matrix', title: '对标差异矩阵', description: 'AKSRT 当前 → 原主题参考 → AKSRT 目标三层对照表。', icon: 'fa fas fa-columns', color: '#722ed1', file: 'legacy-gap-matrix.md' },
{ key: 'admin-ia-map', title: '后台 IA 蓝图', description: '完整的后台信息架构映射,12 个顶级分区及子页面清单。', icon: 'fa fas fa-sitemap', color: 'var(--jio-color-primary)', file: 'admin-ia-map.md' },
{ key: 'legacy-gap-matrix', title: '对标差异矩阵', description: 'Jio 当前 → 原主题参考 → Jio 目标三层对照表。', icon: 'fa fas fa-columns', color: '#722ed1', file: 'legacy-gap-matrix.md' },
{ key: 'frontend-page-matrix', title: '前端页面矩阵', description: '前端模板、CSS、JS 资产映射与覆盖范围。', icon: 'fa fas fa-desktop', color: '#13c2c2', file: 'frontend-page-matrix.md' },
{ key: 'manual-acceptance-baseline', title: '验收基线', description: '每个 Phase 的人工验收标准和检查清单。', icon: 'fa fas fa-clipboard', color: '#52c41a', file: 'manual-acceptance-baseline.md' },
{ key: 'feature-domain-map', title: '功能域地图', description: '业务域划分、依赖关系和模块边界说明。', icon: 'fa fas fa-map', color: '#fa8c16', file: 'feature-domain-map.md' },
@@ -117,35 +117,35 @@ onMounted(async () => {
function openDoc(doc) {
// Link to local docs directory (admin can open in new tab)
const base = window.aksrtAdminData?.siteUrl || window.location.origin
window.open(`${base}/wp-content/themes/AKSRT-theme/docs/${doc.file}`, '_blank')
const base = window.jioAdminData?.siteUrl || window.location.origin
window.open(`${base}/wp-content/themes/Jio/docs/${doc.file}`, '_blank')
}
</script>
<style scoped>
.aksrt-docs__grid {
.jio-docs__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.aksrt-docs__card {
.jio-docs__card {
cursor: pointer;
transition: box-shadow 0.2s;
}
.aksrt-docs__card:hover {
.jio-docs__card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.aksrt-docs__card-inner {
.jio-docs__card-inner {
display: flex;
align-items: center;
gap: 12px;
}
.aksrt-docs__card-icon {
.jio-docs__card-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
@@ -155,38 +155,38 @@ function openDoc(doc) {
justify-content: center;
}
.aksrt-docs__card-body {
.jio-docs__card-body {
flex: 1;
min-width: 0;
}
.aksrt-docs__card-title {
.jio-docs__card-title {
margin: 0 0 2px;
font-size: 14px;
font-weight: 600;
color: var(--aksrt-color-text);
color: var(--jio-color-text);
}
.aksrt-docs__card-desc {
.jio-docs__card-desc {
margin: 0;
font-size: 12px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aksrt-docs__card-arrow {
.jio-docs__card-arrow {
flex-shrink: 0;
color: #c0c4cc;
}
.aksrt-docs__section {
.jio-docs__section {
margin-bottom: 16px;
}
.aksrt-docs__action {
.jio-docs__action {
display: flex;
gap: 12px;
align-items: flex-start;
@@ -195,31 +195,31 @@ function openDoc(doc) {
background: #fafafa;
}
.aksrt-docs__action i {
color: var(--aksrt-color-primary);
.jio-docs__action i {
color: var(--jio-color-primary);
margin-top: 2px;
}
.aksrt-docs__action strong {
.jio-docs__action strong {
display: block;
font-size: 14px;
color: var(--aksrt-color-text);
color: var(--jio-color-text);
margin-bottom: 2px;
}
.aksrt-docs__action p {
.jio-docs__action p {
margin: 0 0 4px;
font-size: 12px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
}
.aksrt-docs__action a {
.jio-docs__action a {
font-size: 12px;
color: var(--aksrt-color-primary);
color: var(--jio-color-primary);
text-decoration: none;
}
.aksrt-docs__action a:hover {
.jio-docs__action a:hover {
text-decoration: underline;
}
</style>
@@ -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,5 +1,5 @@
<template>
<div class="aksrt-page-container">
<div class="jio-page-container">
<PageHeader :title="title" :desc="desc">
<template #actions>
<n-space v-if="links.length">
@@ -10,7 +10,7 @@
</template>
</PageHeader>
<n-card v-if="note" class="aksrt-section-card">
<n-card v-if="note" class="jio-section-card">
<n-alert type="info" :show-icon="false">
{{ note }}
</n-alert>
@@ -1,174 +0,0 @@
<template>
<div class="aksrt-page-container">
<PageHeader
title="附件治理"
desc="管理各类上传文件的大小、安全限制和审核策略;准入权限请在“基本权限”中配置。"
>
<template #actions>
<n-space>
<n-button quaternary @click="goModeration">审核策略</n-button>
</n-space>
</template>
</PageHeader>
<n-spin :show="loading" style="width:100%; min-height:200px;">
<!-- Summary -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>上传策略总览</h3>
</div>
<n-grid :cols="3" :x-gap="12" :y-gap="12">
<n-grid-item v-for="item in summaryItems" :key="item.key">
<div class="aksrt-attach__summary">
<FaIcon :icon="item.icon" :size="16" :color="item.enabled ? '#52c41a' : 'var(--aksrt-color-muted)'" />
<div>
<strong>{{ item.label }}</strong>
<p>{{ item.enabled ? '已启用' : '已关闭' }}</p>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Avatar & Cover -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>用户资料卡背景图</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="头像/用户资料卡背景图大小上限 (MB)">
<n-input-number v-model:value="form.avatar_max_size" :min="1" :max="10" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<!-- Content uploads -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>内容上传</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="投稿图片大小上限 (MB)">
<n-input-number v-model:value="form.submit_image_max_size" :min="1" :max="20" style="max-width:160px;" />
</n-form-item>
<n-form-item label="评论图片大小上限 (MB)">
<n-input-number v-model:value="form.comment_image_max_size" :min="1" :max="10" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<!-- Resource attachments -->
<n-card class="aksrt-section-card">
<div class="aksrt-section-header">
<h3>资源附件</h3>
</div>
<n-form :model="form" label-placement="left" label-width="200" class="aksrt-form">
<n-form-item label="资源附件大小上限 (MB)">
<n-input-number v-model:value="form.resource_max_size" :min="1" :max="200" style="max-width:160px;" />
</n-form-item>
</n-form>
</n-card>
<div class="aksrt-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存配置</n-button>
</div>
</n-spin>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api'
import PageHeader from '@/components/PageHeader.vue'
import FaIcon from '@/components/form/FaIcon.vue'
const router = useRouter()
const message = useMessage()
const loading = ref(true)
const saving = ref(false)
const form = reactive({
avatar_max_size: 2,
submit_image_max_size: 5,
comment_image_max_size: 2,
resource_max_size: 50,
})
const summaryItems = computed(() => [
{ key: 'profile', icon: 'fa fas fa-id-card', label: '资料卡图片', enabled: true },
{ key: 'submit', icon: 'fa fas fa-edit', label: '投稿图片', enabled: true },
{ key: 'comment', icon: 'fa fas fa-comment-dots', label: '评论图片', enabled: true },
{ key: 'resource', icon: 'fa fas fa-file-archive', label: '资源附件', enabled: true },
])
function go(path) {
router.push(path)
}
function goModeration() { router.push('/permissions/moderation') }
async function load() {
loading.value = true
try {
const permissionRes = await api.getPermissionsAttachmentSettings()
const permission = permissionRes?.data ?? permissionRes ?? {}
// Map permission fields
form.avatar_max_size = Number(permission.avatar_max_size || 2)
form.submit_image_max_size = Number(permission.submit_image_max_size || 5)
form.comment_image_max_size = Number(permission.comment_image_max_size || 2)
form.resource_max_size = Number(permission.resource_max_size || 50)
} catch (error) {
message.error(error.message || '加载附件权限失败')
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await api.savePermissionsAttachmentSettings({
avatar_max_size: form.avatar_max_size,
submit_image_max_size: form.submit_image_max_size,
comment_image_max_size: form.comment_image_max_size,
resource_max_size: form.resource_max_size,
})
message.success('附件治理配置已保存')
} catch (error) {
message.error(error.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.aksrt-attach__summary {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 6px;
background: #fafafa;
}
.aksrt-attach__summary strong {
display: block;
font-size: 13px;
color: var(--aksrt-color-text);
}
.aksrt-attach__summary p {
margin: 2px 0 0;
font-size: 12px;
color: var(--aksrt-color-muted);
}
</style>
@@ -1,5 +1,5 @@
<template>
<div class="aksrt-page-container">
<div class="jio-page-container">
<PageHeader
title="基本权限 / 能力矩阵"
desc="统一管理用户治理、投稿和评论的角色能力矩阵。"
@@ -17,8 +17,8 @@
<n-spin :show="loading" style="width: 100%; min-height: 240px;">
<n-card class="aksrt-section-card" v-if="summary.cap_count">
<div class="aksrt-section-header">
<n-card class="jio-section-card" v-if="summary.cap_count">
<div class="jio-section-header">
<h3>矩阵概览</h3>
</div>
<n-space>
@@ -28,12 +28,12 @@
</n-space>
</n-card>
<n-card v-for="group in groups" :key="group.key" class="aksrt-section-card">
<n-card v-for="group in groups" :key="group.key" class="jio-section-card">
<template #header>
<div class="aksrt-cap-matrix__header">
<div class="jio-cap-matrix__header">
<div>
<h3 class="aksrt-cap-matrix__title">{{ group.title }}</h3>
<p class="aksrt-cap-matrix__desc">共 {{ group.items.length }} 项能力</p>
<h3 class="jio-cap-matrix__title">{{ group.title }}</h3>
<p class="jio-cap-matrix__desc">共 {{ group.items.length }} 项能力</p>
</div>
</div>
</template>
@@ -48,7 +48,7 @@
/>
</n-card>
<div class="aksrt-form-actions">
<div class="jio-form-actions">
<n-button type="primary" :loading="saving" @click="handleSave">保存能力矩阵</n-button>
</div>
</n-spin>
@@ -117,7 +117,7 @@ function buildColumns(group) {
key: 'label',
fixed: 'left',
width: 280,
render: (row) => h('div', { class: 'aksrt-cap-matrix__cap' }, [
render: (row) => h('div', { class: 'jio-cap-matrix__cap' }, [
h('strong', row.label),
h('code', row.cap_id),
]),
@@ -148,7 +148,7 @@ function buildColumns(group) {
function renderCapCell(row, col) {
const hidden = !(row.visible_columns || []).includes(col)
if (hidden) {
return h('span', { class: 'aksrt-cap-matrix__empty' }, '—')
return h('span', { class: 'jio-cap-matrix__empty' }, '—')
}
if (!userCaps[row.cap_id]) {
@@ -209,40 +209,40 @@ async function handleSave() {
</script>
<style scoped>
.aksrt-cap-matrix__header {
.jio-cap-matrix__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.aksrt-cap-matrix__title {
.jio-cap-matrix__title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.aksrt-cap-matrix__desc {
.jio-cap-matrix__desc {
margin: 4px 0 0;
font-size: 12px;
color: var(--aksrt-color-muted);
color: var(--jio-color-muted);
}
.aksrt-cap-matrix__cap {
.jio-cap-matrix__cap {
display: flex;
flex-direction: column;
gap: 4px;
}
.aksrt-cap-matrix__cap code {
.jio-cap-matrix__cap code {
font-size: 11px;
color: #667085;
}
.aksrt-cap-matrix__empty {
.jio-cap-matrix__empty {
color: #c0c4cc;
}
.aksrt-form-actions {
.jio-form-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
@@ -1,5 +1,5 @@
<template>
<div class="aks-native-ops-redirect">
<div class="jio-native-ops-redirect">
<h2>{{ title }}</h2>
<p class="desc">
此运营功能已迁移到 WordPress 原生后台侧栏(对齐 legacy)。正在跳转…
@@ -21,7 +21,7 @@ const title = computed(() => route.meta?.title || '运营功能已迁移')
const nativeUrl = computed(() => {
const path = route.meta?.nativeAdminPath || ''
if (!path) return ''
const base = (window.aksrtAdminData?.adminUrl || '/wp-admin/').replace(/\/?$/, '/')
const base = (window.jioAdminData?.adminUrl || '/wp-admin/').replace(/\/?$/, '/')
return base + path.replace(/^\//, '')
})
@@ -33,13 +33,13 @@ onMounted(() => {
</script>
<style scoped>
.aks-native-ops-redirect {
.jio-native-ops-redirect {
max-width: 640px;
margin: 48px auto;
padding: 24px;
background: var(--aks-surface, #fff);
background: var(--jio-surface, #fff);
border-radius: 8px;
border: 1px solid var(--aks-border, #e5e7eb);
border: 1px solid var(--jio-border, #e5e7eb);
}
.desc {
color: #646970;
@@ -1,5 +1,5 @@
<template>
<div class="aksrt-page-container">
<div class="jio-page-container">
<PageHeader
title="主题授权"
desc="当前版本按产品决策不实现在线授权/撤销授权,仅保留主题信息与更新能力。"
@@ -12,14 +12,14 @@
</template>
</PageHeader>
<n-card class="aksrt-section-card">
<n-card class="jio-section-card">
<n-alert type="info" title="按设计省略" :show-icon="true">
本主题当前不实现 原主题风格的授权码校验、域名绑定、撤销授权、本地免授权等机制。
`AKSRT_Update_Checker` 仅负责匿名版本检测与更新提示,不与授权系统耦合。
`JIO_Update_Checker` 仅负责匿名版本检测与更新提示,不与授权系统耦合。
</n-alert>
</n-card>
<n-card class="aksrt-section-card" title="当前策略" size="small">
<n-card class="jio-section-card" title="当前策略" size="small">
<n-list bordered>
<n-list-item>不提供授权码输入框或“立即授权”按钮</n-list-item>
<n-list-item>不提供“已授权 / 未授权 / 本地免授权”三态逻辑</n-list-item>
+27 -27
View File
@@ -1,14 +1,14 @@
<template>
<div class="aksrt-page-container">
<div class="jio-page-container">
<PageHeader title="主题信息" desc="主题版本、运行环境、已启用模块和关键系统信息。" />
<div v-if="loading" class="aksrt-theme-info__loading">
<div v-if="loading" class="jio-theme-info__loading">
<n-spin size="medium" />
</div>
<template v-else>
<!-- Version Card -->
<n-card class="aksrt-theme-info__card" title="版本信息" size="small">
<n-card class="jio-theme-info__card" title="版本信息" size="small">
<n-descriptions :column="2" bordered label-placement="left" size="small">
<n-descriptions-item label="主题版本">
<n-tag type="success" size="small">{{ info.themeVersion }}</n-tag>
@@ -33,7 +33,7 @@
</n-card>
<!-- Environment Card -->
<n-card class="aksrt-theme-info__card" title="运行环境" size="small">
<n-card class="jio-theme-info__card" title="运行环境" size="small">
<n-descriptions :column="2" bordered label-placement="left" size="small">
<n-descriptions-item label="站点地址">
<n-text code>{{ info.siteUrl }}</n-text>
@@ -61,14 +61,14 @@
</n-card>
<!-- Modules Card -->
<n-card class="aksrt-theme-info__card" title="已启用模块" size="small">
<div class="aksrt-theme-info__modules">
<n-card class="jio-theme-info__card" title="已启用模块" size="small">
<div class="jio-theme-info__modules">
<n-tag
v-for="mod in info.modules"
:key="mod.key"
:type="mod.enabled ? 'success' : 'default'"
size="small"
class="aksrt-theme-info__module-tag"
class="jio-theme-info__module-tag"
>
<FaIcon :icon="mod.enabled ? 'fa fas fa-check-circle' : 'fa fas fa-circle'" :size="10" />
{{ mod.label }}
@@ -80,7 +80,7 @@
</n-card>
<!-- Data Tables Card -->
<n-card class="aksrt-theme-info__card" title="数据表状态" size="small">
<n-card class="jio-theme-info__card" title="数据表状态" size="small">
<n-data-table
:columns="tableColumns"
:data="info.tables"
@@ -91,25 +91,25 @@
</n-card>
<!-- Docs Links Card -->
<n-card class="aksrt-theme-info__card" title="快速入口" size="small">
<div class="aksrt-theme-info__links">
<router-link to="/docs" class="aksrt-theme-info__link">
<n-card class="jio-theme-info__card" title="快速入口" size="small">
<div class="jio-theme-info__links">
<router-link to="/docs" class="jio-theme-info__link">
<FaIcon icon="fa fas fa-book" :size="16" />
<span>文档中心</span>
</router-link>
<router-link to="/tools/update" class="aksrt-theme-info__link">
<router-link to="/tools/update" class="jio-theme-info__link">
<FaIcon icon="fa fas fa-cloud-download-alt" :size="16" />
<span>更新中心</span>
</router-link>
<router-link to="/tools/backup" class="aksrt-theme-info__link">
<router-link to="/tools/backup" class="jio-theme-info__link">
<FaIcon icon="fa fas fa-archive" :size="16" />
<span>数据备份</span>
</router-link>
<router-link to="/tools/health" class="aksrt-theme-info__link">
<router-link to="/tools/health" class="jio-theme-info__link">
<FaIcon icon="fa fas fa-heartbeat" :size="16" />
<span>健康检查</span>
</router-link>
<router-link to="/tools/import-export" class="aksrt-theme-info__link">
<router-link to="/tools/import-export" class="jio-theme-info__link">
<FaIcon icon="fa fas fa-exchange-alt" :size="16" />
<span>导入导出</span>
</router-link>
@@ -173,13 +173,13 @@ onMounted(async () => {
const data = res?.data || res || {}
info.value = {
themeVersion: window.aksrtAdminData?.themeVersion || data.theme_version || '3.4.0-dev',
themeVersion: window.jioAdminData?.themeVersion || data.theme_version || '3.4.0-dev',
wpVersion: data.wp_version || '',
phpVersion: data.php_version || '',
dbSchemaVersion: data.db_schema_version || '',
emailBackfillDone: !!data.email_backfill_done,
siteUrl: data.site_url || window.aksrtAdminData?.siteUrl || '',
apiUrl: data.api_url || window.aksrtAdminData?.apiUrl || '',
siteUrl: data.site_url || window.jioAdminData?.siteUrl || '',
apiUrl: data.api_url || window.jioAdminData?.apiUrl || '',
themeDir: data.theme_dir || '',
uploadDir: data.upload_dir || '',
isMultisite: !!data.is_multisite,
@@ -191,8 +191,8 @@ onMounted(async () => {
console.error('[ThemeInfo] Failed to load health data:', err)
// Fallback with minimal info
info.value.themeVersion = '3.4.0-dev'
info.value.siteUrl = window.aksrtAdminData?.siteUrl || ''
info.value.apiUrl = window.aksrtAdminData?.apiUrl || ''
info.value.siteUrl = window.jioAdminData?.siteUrl || ''
info.value.apiUrl = window.jioAdminData?.apiUrl || ''
} finally {
loading.value = false
}
@@ -218,35 +218,35 @@ function normalizeTables(raw) {
</script>
<style scoped>
.aksrt-theme-info__loading {
.jio-theme-info__loading {
display: flex;
justify-content: center;
padding: 60px 0;
}
.aksrt-theme-info__card {
.jio-theme-info__card {
margin-bottom: 16px;
}
.aksrt-theme-info__modules {
.jio-theme-info__modules {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.aksrt-theme-info__module-tag {
.jio-theme-info__module-tag {
display: inline-flex;
align-items: center;
gap: 5px;
}
.aksrt-theme-info__links {
.jio-theme-info__links {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.aksrt-theme-info__link {
.jio-theme-info__link {
display: inline-flex;
align-items: center;
gap: 6px;
@@ -259,7 +259,7 @@ function normalizeTables(raw) {
transition: background 0.2s;
}
.aksrt-theme-info__link:hover {
.jio-theme-info__link:hover {
background: #e8eaed;
}
</style>
Loaded 100 of 1360 files, more files were not shown because too many files have changed in this diff. Show more