diff --git a/assets/admin/src/constants/sections/tools.js b/assets/admin/src/constants/sections/tools.js
index 253abd94..7b45ce81 100644
--- a/assets/admin/src/constants/sections/tools.js
+++ b/assets/admin/src/constants/sections/tools.js
@@ -25,6 +25,16 @@ export const toolsSection = {
canonicalDomain: 'tools',
section: 'backup',
}),
+ schemaPage({
+ key: 'tools-modal-rollout',
+ name: 'ToolsModalRollout',
+ label: '前台弹窗灰度',
+ route: '/tools/modal-rollout',
+ title: '前台弹窗灰度',
+ desc: '按领域控制前台弹窗新流程的分阶段发布。',
+ canonicalDomain: 'tools',
+ section: 'modal-rollout',
+ }),
customPage({
key: 'tools-health',
name: 'ToolsHealth',
diff --git a/assets/admin/src/schema/sections/tools/index.js b/assets/admin/src/schema/sections/tools/index.js
index 603b9d52..1a12528c 100644
--- a/assets/admin/src/schema/sections/tools/index.js
+++ b/assets/admin/src/schema/sections/tools/index.js
@@ -7,10 +7,12 @@ import { importExportSchema } from './import-export.js'
import { healthSchema } from './health.js'
import { emailRollbackSchema } from './email-rollback.js'
import { backupSchema } from './backup.js'
+import { modalRolloutSchema } from './modal-rollout.js'
export const toolsSchemas = {
'import-export': importExportSchema,
health: healthSchema,
'email-rollback': emailRollbackSchema,
backup: backupSchema,
+ 'modal-rollout': modalRolloutSchema,
}
diff --git a/assets/admin/src/schema/sections/tools/modal-rollout.js b/assets/admin/src/schema/sections/tools/modal-rollout.js
new file mode 100644
index 00000000..591705ea
--- /dev/null
+++ b/assets/admin/src/schema/sections/tools/modal-rollout.js
@@ -0,0 +1,45 @@
+export const modalRolloutSchema = {
+ key: 'tools.modal-rollout',
+ title: '前台弹窗灰度',
+ icon: 'fas fa-bolt',
+ description: '按领域控制前台弹窗新流程的分阶段发布。',
+ defaults: {
+ frontend_modal_rollout_identity: false,
+ frontend_modal_rollout_badges: false,
+ frontend_modal_rollout_orders: false,
+ frontend_modal_rollout_vip: false,
+ },
+ groups: [
+ {
+ key: 'modal-rollout',
+ title: '弹窗新流程开关',
+ icon: 'fas fa-toggle-on',
+ fields: [
+ {
+ key: 'frontend_modal_rollout_identity',
+ label: '官方认证新流程',
+ type: 'switch',
+ help: '启用统一弹窗运行时与认证快照流程。',
+ },
+ {
+ key: 'frontend_modal_rollout_badges',
+ label: '徽章新流程',
+ type: 'switch',
+ help: '启用统一 JSON 徽章流程和短缓存。',
+ },
+ {
+ key: 'frontend_modal_rollout_orders',
+ label: '订单新流程',
+ type: 'switch',
+ help: '启用订单详情快速开壳与状态同步流程。',
+ },
+ {
+ key: 'frontend_modal_rollout_vip',
+ label: '会员支付新流程',
+ type: 'switch',
+ help: '启用统一会员购买、支付和结果同步流程。',
+ },
+ ],
+ },
+ ],
+}
diff --git a/assets/js/core/modal-telemetry.js b/assets/js/core/modal-telemetry.js
new file mode 100644
index 00000000..43a713a9
--- /dev/null
+++ b/assets/js/core/modal-telemetry.js
@@ -0,0 +1,112 @@
+/**
+ * Frontend modal performance marks.
+ *
+ * Records only bounded domain/event names and timing data. Never include
+ * payment credentials, order contents, identity evidence, or user payloads.
+ *
+ * @package AKSRT
+ */
+(function (window) {
+ 'use strict';
+
+ var allowedKinds = {
+ identity: true,
+ badges: true,
+ orders: true,
+ vip: true,
+ };
+ var allowedEvents = {
+ click: true,
+ shell: true,
+ 'first-content': true,
+ ready: true,
+ 'action-start': true,
+ 'action-end': true,
+ unauthorized: true,
+ error: true,
+ };
+ var sessions = Object.create(null);
+ var sequence = 0;
+ var maxSessions = 100;
+
+ function normalize(value, fallback) {
+ var normalized = String(value || fallback || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
+ return normalized || fallback;
+ }
+
+ function now() {
+ return window.performance && typeof window.performance.now === 'function'
+ ? window.performance.now()
+ : 0;
+ }
+
+ function markName(session, event) {
+ return 'aksrt-modal-' + session.kind + '-' + session.id + '-' + event;
+ }
+
+ function emit(session, event) {
+ if (!window.performance || typeof window.performance.mark !== 'function') return;
+
+ var name = markName(session, event);
+ window.performance.mark(name);
+ session.marks[event] = name;
+
+ if (typeof window.performance.measure === 'function' && session.marks.click && event !== 'click') {
+ try {
+ window.performance.measure(
+ 'aksrt-modal-' + session.kind + '-' + session.id + '-click-to-' + event,
+ session.marks.click,
+ name
+ );
+ } catch (error) {
+ // A missing mark must never affect the modal interaction.
+ }
+ }
+ }
+
+ function start(kind) {
+ var safeKind = normalize(kind, 'unknown');
+ if (!allowedKinds[safeKind]) safeKind = 'unknown';
+
+ sequence += 1;
+ var id = String(sequence);
+ var session = {
+ id: id,
+ kind: safeKind,
+ startedAt: now(),
+ marks: Object.create(null),
+ };
+
+ sessions[id] = session;
+ emit(session, 'click');
+
+ var ids = Object.keys(sessions);
+ while (ids.length > maxSessions) {
+ delete sessions[ids.shift()];
+ }
+
+ return id;
+ }
+
+ function mark(id, event) {
+ var session = sessions[String(id || '')];
+ var safeEvent = normalize(event, 'error');
+ if (!session || !allowedEvents[safeEvent]) return;
+ emit(session, safeEvent);
+ }
+
+ function finish(id) {
+ mark(id, 'ready');
+ }
+
+ function fail(id) {
+ mark(id, 'error');
+ }
+
+ window.AksrtModalTelemetry = {
+ start: start,
+ mark: mark,
+ finish: finish,
+ error: fail,
+ };
+})(window);
diff --git a/assets/js/order-center.js b/assets/js/order-center.js
index 0cede03f..2549413f 100644
--- a/assets/js/order-center.js
+++ b/assets/js/order-center.js
@@ -98,18 +98,28 @@
function openDetail(orderNumber) {
if (!detailModal || !detailBody) return;
detailOrderNumber = orderNumber;
+ var telemetry = window.AksrtModalTelemetry;
+ var telemetryId = telemetry && typeof telemetry.start === "function" ? telemetry.start("orders") : "";
+ function telemetryMark(event) {
+ if (telemetry && telemetryId && typeof telemetry.mark === "function") telemetry.mark(telemetryId, event);
+ }
detailBody.innerHTML = '
加载中…
';
detailFooter.innerHTML = "";
detailModal.hidden = false;
document.documentElement.classList.add("aksrt-modal-open");
+ telemetryMark("shell");
request(orderPath(orderNumber), undefined, "GET").then(function (result) {
if (orderNumber !== detailOrderNumber) return;
if (!result.success || !result.data) {
+ telemetryMark("error");
detailBody.innerHTML = '' + escapeHtml(result.message || "订单不存在") + "
";
return;
}
+ telemetryMark("first-content");
renderDetail(result.data);
+ telemetryMark("ready");
}).catch(function () {
+ telemetryMark("error");
detailBody.innerHTML = '网络错误,请稍后重试
';
});
}
diff --git a/assets/js/pay-vip.js b/assets/js/pay-vip.js
index 78f64be3..9a32b42c 100644
--- a/assets/js/pay-vip.js
+++ b/assets/js/pay-vip.js
@@ -252,21 +252,29 @@
}
if (loading) return;
loading = true;
+ var telemetry = window.AksrtModalTelemetry;
+ var telemetryId = telemetry && typeof telemetry.start === "function" ? telemetry.start("vip") : "";
+ function telemetryMark(event) {
+ if (telemetry && telemetryId && typeof telemetry.mark === "function") telemetry.mark(telemetryId, event);
+ }
var shell = ensureModalShell();
var body = shell.querySelector("[data-aksrt-vip-modal-body]");
if (body) body.innerHTML = '加载中…
';
openModal();
+ telemetryMark("shell");
api("/pay/vip/modal", { query: { vip_level: level } })
.then(function (r) {
loading = false;
if (r.status === 401) {
+ telemetryMark("unauthorized");
closeModal();
var login = document.querySelector(".signin-loader");
if (login) login.click();
return;
}
if (!r.ok || !r.json || !r.json.html) {
+ telemetryMark("error");
if (body) {
body.innerHTML =
'' +
@@ -277,11 +285,14 @@
}
if (body) {
body.innerHTML = r.json.html;
+ telemetryMark("first-content");
bindModalUi(body);
+ telemetryMark("ready");
}
})
.catch(function () {
loading = false;
+ telemetryMark("error");
if (body) {
body.innerHTML = '
网络错误
';
}
diff --git a/assets/js/routes/user-center-auth.js b/assets/js/routes/user-center-auth.js
index 2901b1ea..2aa655ed 100644
--- a/assets/js/routes/user-center-auth.js
+++ b/assets/js/routes/user-center-auth.js
@@ -63,8 +63,20 @@
var currentUuid = config.currentUuid || '';
var pollTimer = null;
var shouldPoll = false;
+ var telemetry = window.AksrtModalTelemetry;
+ var telemetryId = '';
- function open() { modal.classList.add('is-open'); modal.setAttribute('aria-hidden', 'false'); document.body.style.overflow = 'hidden'; }
+ function telemetryMark(event) {
+ if (telemetry && telemetryId && typeof telemetry.mark === 'function') telemetry.mark(telemetryId, event);
+ }
+
+ function open() {
+ telemetryId = telemetry && typeof telemetry.start === 'function' ? telemetry.start('identity') : '';
+ modal.classList.add('is-open');
+ modal.setAttribute('aria-hidden', 'false');
+ document.body.style.overflow = 'hidden';
+ telemetryMark('shell');
+ }
function close() { modal.classList.remove('is-open'); modal.setAttribute('aria-hidden', 'true'); document.body.style.overflow = ''; }
root.querySelectorAll('[data-auth-modal-open]').forEach(function (button) { button.addEventListener('click', open); });
modal.querySelectorAll('[data-auth-modal-close]').forEach(function (button) { button.addEventListener('click', close); });
@@ -80,6 +92,7 @@
function render(data) {
if (!data) return;
+ telemetryMark('first-content');
currentUuid = data.uuid || currentUuid;
if (statusBox) statusBox.hidden = false;
if (createForm) createForm.hidden = true;
@@ -102,6 +115,7 @@
} else {
stopPolling();
}
+ telemetryMark('ready');
}
function refresh() {
diff --git a/assets/js/user-badges.js b/assets/js/user-badges.js
index 193b9e8d..7feac6fd 100644
--- a/assets/js/user-badges.js
+++ b/assets/js/user-badges.js
@@ -93,6 +93,12 @@
var wearSlot = modal.querySelector('[data-aksrt-badges-wear-slot]');
var state = { data: null, detailSlug: '' };
+ var telemetry = window.AksrtModalTelemetry;
+ var telemetryId = '';
+
+ function telemetryMark(event) {
+ if (telemetry && telemetryId && typeof telemetry.mark === 'function') telemetry.mark(telemetryId, event);
+ }
function findItem(slug) {
var cats = (state.data && state.data.categories) || [];
@@ -230,6 +236,7 @@
}
return api('/users/' + userId + '/badges').then(function (r) {
if (!r.ok || !r.json || !r.json.data) {
+ telemetryMark('error');
if (body) {
body.innerHTML = '
' +
'' + escapeHtml('加载失败') + '' +
@@ -239,8 +246,10 @@
return;
}
state.data = r.json.data;
+ telemetryMark('first-content');
renderHead();
renderGrid();
+ telemetryMark('ready');
});
}
@@ -271,7 +280,9 @@
openers.forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.preventDefault();
+ telemetryId = telemetry && typeof telemetry.start === 'function' ? telemetry.start('badges') : '';
openModal();
+ telemetryMark('shell');
showView('grid');
load();
});
diff --git a/src/Admin/Schema/SchemaTools.php b/src/Admin/Schema/SchemaTools.php
index 0384e554..3828e2c9 100644
--- a/src/Admin/Schema/SchemaTools.php
+++ b/src/Admin/Schema/SchemaTools.php
@@ -18,6 +18,7 @@ class SchemaTools {
'health' => SchemaToolsHealth::build(),
'backup' => SchemaToolsBackup::build(),
'email-rollback' => SchemaToolsEmailRollback::build(),
+ 'modal-rollout' => SchemaToolsModalRollout::build(),
],
];
}
diff --git a/src/Admin/Schema/SchemaToolsModalRollout.php b/src/Admin/Schema/SchemaToolsModalRollout.php
new file mode 100644
index 00000000..4f6574fa
--- /dev/null
+++ b/src/Admin/Schema/SchemaToolsModalRollout.php
@@ -0,0 +1,56 @@
+ 'modal-rollout',
+ 'title' => '前台弹窗灰度',
+ 'version' => '1.0.0',
+ 'option_key' => 'aksrt_settings',
+ 'groups' => [
+ [
+ 'key' => 'modal-rollout',
+ 'title' => '弹窗新流程开关',
+ 'fields' => [
+ [
+ 'key' => 'frontend_modal_rollout_identity',
+ 'label' => '官方认证新流程',
+ 'type' => 'switch',
+ 'default' => false,
+ 'help' => '启用统一弹窗运行时与认证快照流程。',
+ ],
+ [
+ 'key' => 'frontend_modal_rollout_badges',
+ 'label' => '徽章新流程',
+ 'type' => 'switch',
+ 'default' => false,
+ 'help' => '启用统一 JSON 徽章流程和短缓存。',
+ ],
+ [
+ 'key' => 'frontend_modal_rollout_orders',
+ 'label' => '订单新流程',
+ 'type' => 'switch',
+ 'default' => false,
+ 'help' => '启用订单详情快速开壳与状态同步流程。',
+ ],
+ [
+ 'key' => 'frontend_modal_rollout_vip',
+ 'label' => '会员支付新流程',
+ 'type' => 'switch',
+ 'default' => false,
+ 'help' => '启用统一会员购买、支付和结果同步流程。',
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Admin/ToolsController.php b/src/Admin/ToolsController.php
index 829e8e72..17c6456c 100644
--- a/src/Admin/ToolsController.php
+++ b/src/Admin/ToolsController.php
@@ -17,6 +17,8 @@ class ToolsController extends ControllerBase {
}
public function register_routes(): void {
+ $this->register_section_routes('tools', 'tools');
+
// Health: email verification status
register_rest_route(self::API_NAMESPACE, '/health/email-verification', [
'methods' => 'GET',
diff --git a/src/Core/ScriptsPart1c.php b/src/Core/ScriptsPart1c.php
index cef91583..b7378b3f 100644
--- a/src/Core/ScriptsPart1c.php
+++ b/src/Core/ScriptsPart1c.php
@@ -127,6 +127,7 @@ trait ScriptsPart1c {
'aksrt-core-comment-move' => 'comment-move-form.js',
// Shared zibll-parity list/modal engines (before pagination consumers).
'aksrt-core-ajax-list' => 'ajax-list.js',
+ 'aksrt-core-modal-telemetry' => 'modal-telemetry.js',
'aksrt-core-remote-modal' => 'remote-modal.js',
'aksrt-core-pagination' => 'pagination.js',
'aksrt-core-archive' => 'archive.js',
@@ -560,6 +561,12 @@ trait ScriptsPart1c {
'restUrl' => esc_url_raw(rest_url('aksrt/v1')),
// Alias used by modal-auth / follow / report (same value as restUrl).
'apiUrl' => esc_url_raw(rest_url('aksrt/v1')),
+ 'modalRollout' => class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_frontend_modal_rollout_settings() : [
+ 'identity' => false,
+ 'badges' => false,
+ 'orders' => false,
+ 'vip' => false,
+ ],
'stickyEnabled' => class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_post_list_style_settings()['sticky_enabled'] : true,
'stickyPages' => class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_post_list_style_settings()['sticky_pages'] : ['home', 'cat'],
'badgeShow' => class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_post_list_style_settings()['badge_show'] : true,
diff --git a/src/Settings/DomainGroups.php b/src/Settings/DomainGroups.php
index 7f8a2a16..c93b47f2 100644
--- a/src/Settings/DomainGroups.php
+++ b/src/Settings/DomainGroups.php
@@ -178,4 +178,13 @@ trait DomainGroups {
public static function get_system_tools_settings(): array {
return Capabilities::get_system_tools_settings();
}
+
+ /**
+ * 获取前台弹窗按领域灰度开关。
+ *
+ * @return array{identity:bool,badges:bool,orders:bool,vip:bool}
+ */
+ public static function get_frontend_modal_rollout_settings(): array {
+ return ModalRuntime::get_frontend_modal_rollout_settings();
+ }
}
diff --git a/src/Settings/ModalRuntime.php b/src/Settings/ModalRuntime.php
new file mode 100644
index 00000000..f7ff9e6b
--- /dev/null
+++ b/src/Settings/ModalRuntime.php
@@ -0,0 +1,29 @@
+ !empty($settings['frontend_modal_rollout_identity']),
+ 'badges' => !empty($settings['frontend_modal_rollout_badges']),
+ 'orders' => !empty($settings['frontend_modal_rollout_orders']),
+ 'vip' => !empty($settings['frontend_modal_rollout_vip']),
+ ];
+ }
+}