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']), + ]; + } +}