feat(extensions): 新增系统工具页面与前台投稿分片上传

This commit is contained in:
2026-10-06 11:09:05 +08:00
parent 613b8a57a3
commit b75bf197a3
25 changed files with 1982 additions and 58 deletions
@@ -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',
+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',
@@ -3,12 +3,14 @@
* Each key corresponds to a sub-page under the "扩展&增强" section.
*/
import { systemSchema } from './system.js'
import { ipSchema } from './ip.js'
import { auditSchema } from './audit.js'
import { pushSchema } from './push.js'
import { securitySchema } from './security.js'
export const extensionsSchemas = {
system: systemSchema,
ip: ipSchema,
audit: auditSchema,
push: pushSchema,
@@ -0,0 +1,91 @@
export const systemSchema = {
key: 'extensions.system',
title: '系统工具',
icon: 'fa fas fa-gavel',
description: 'WordPress 优化、上传优化与编辑器显示的系统级开关。',
defaults: {
hide_admin_bar: true,
remove_emoji: true,
remove_open_sans: true,
remove_more_wp_head: true,
remove_dashboard_widgets: true,
display_wp_update: true,
newfilename: false,
newfilename_type: 'time',
split_upload_s: true,
split_minimum_size: 20,
disabled_large_thumbnail: true,
admin_user_del_fields: true,
admin_frontend_set: true,
close_gutenberg: false,
disabled_autoembed: true,
mce_float_toolbar: ['pc_s', 'm_s'],
time_ago_s: true,
time_format: 'n月j日 H:i',
},
groups: [
{
key: 'optimize',
title: 'WordPress 优化',
icon: 'fa fas fa-bolt',
description: '精简WordPress自带的冗余功能,提升前台与后台的加载速度。',
fields: [
{ key: 'hide_admin_bar', label: '前台关闭顶部admin_bar', type: 'switch', default: true, help: '开启后前台不显示WordPress顶部黑条' },
{ key: 'remove_emoji', label: '删除WordPress自带Emoji表情', type: 'switch', default: true },
{ key: 'remove_open_sans', label: '禁用Google字体', type: 'switch', default: true },
{ key: 'remove_more_wp_head', label: '清理多余的头部Meta标签', type: 'switch', default: true },
{ key: 'remove_dashboard_widgets', label: 'WordPress 后台加速', type: 'switch', default: true, help: '移除仪表盘的站点健康和WordPress活动等不必要功能,并禁止检测浏览器和PHP版本(这两个非常耗时),优化后台加载速度' },
{ key: 'display_wp_update', label: '禁止WordPress检测更新', type: 'switch', default: true, help: '开启后将禁用WordPress核心、插件和主题的自动更新及更新检测' },
],
},
{
key: 'upload',
title: '上传优化',
icon: 'fa fas fa-cloud-upload-alt',
description: '上传文件的命名与缩略图裁剪优化,减少硬盘占用并提高上传效率。',
fields: [
{ key: 'split_upload_s', label: '大文件分片上传', type: 'switch', default: true, help: '分片上传大文件,优化大文件上传效率(支持断点续传)' },
{ key: 'split_minimum_size', label: '分片上传最小文件', type: 'number', default: 20, min: 4, max: 100, unit: 'M', dependsOn: { key: 'split_upload_s', value: true }, help: '大于此大小的文件才会分片上传,请根据服务器性能合理设置;较大文件直接上传会比较耗时且无法突破系统上传大小限制,分片上传可以有效解决此问题;前端上传大小限制在「功能权限 → 上传权限」中设置。注意:能否顺利上传大文件还取决于服务器性能、带宽、CDN等因素,如果网站使用了OSS/COS等第三方云储存,则不建议在网站上传大文件' },
{ key: 'newfilename', label: '上传文件自动重命名', type: 'switch', default: false, help: '上传文件时系统会查询重名文件,开启此功能后可减少重名文件,从而提高上传效率' },
{
key: 'newfilename_type',
label: '重命名格式',
type: 'radio',
buttonStyle: false,
default: 'time',
dependsOn: { key: 'newfilename', value: true },
options: [
{ label: '随机字串符', value: 'random' },
{ label: '时间+原文件名', value: 'time' },
],
},
{ key: 'disabled_large_thumbnail', label: '禁止WP大尺寸缩略图裁剪', type: 'switch', default: true, help: '开启后将禁用WordPress自动裁剪medium_large、1536x1536、2048x2048的缩略图,可减少服务器硬盘压力,同时提高上传效率;如果您使用的插件必须要这些尺寸的缩略图,或使用了云储存的图片处理功能,则请关闭' },
],
},
{
key: 'editor',
title: '编辑器与显示',
icon: 'fa fas fa-edit',
description: '编辑器模式、用户资料页与前台时间显示格式的定制。',
fields: [
{ key: 'admin_user_del_fields', label: '关闭后台用户编辑多余的选项', type: 'switch', default: true, help: '开启后在后台编辑用户资料时将不显示无用的多余选项' },
{ key: 'admin_frontend_set', label: '前端页面编辑', type: 'switch', default: true, help: '管理员可在前台快速修改页面、文章参数' },
{ key: 'close_gutenberg', label: '禁用古腾堡编辑器', type: 'switch', default: false, help: '后台文章编辑器将退回使用经典编辑器;禁用后主题的编辑器增强功能将不能使用,请酌情禁用!' },
{ key: 'disabled_autoembed', label: '禁用Auto Embed', type: 'switch', default: true, help: 'Auto Embed是WordPress自动检测并嵌入链接的一种功能,主要适配海外媒体网站,国内几乎用不上,建议禁用,可大幅度提高页面渲染速度' },
{
key: 'mce_float_toolbar',
label: '经典编辑器增强浮动工具栏',
type: 'checkbox',
default: ['pc_s', 'm_s'],
options: [
{ label: 'PC端开启', value: 'pc_s' },
{ label: '移动端开启', value: 'm_s' },
],
help: '开启后前端编辑器会显示浮动的工具栏,更加方便书写',
},
{ key: 'time_ago_s', label: '倒计时显示', type: 'switch', default: true, help: '时间格式化为:X分钟前,X小时前,X天前....' },
{ key: 'time_format', label: '自定义时间格式', type: 'input', default: 'n月j日 H:i', dependsOn: { key: 'time_ago_s', operator: 'falsy' }, help: '关闭倒计时显示后生效,时间格式接受标准PHP时间格式,请注意控制长度!' },
],
},
],
}
@@ -238,8 +238,6 @@ export const permissionsSchemas = {
{ type: 'admin', pattern: 'allow', val: 'svg=image/svg+xml' },
{ type: 'all', pattern: 'allow', val: '' },
],
newfilename: false,
newfilename_type: 'time',
},
groups: [
{
@@ -314,26 +312,6 @@ export const permissionsSchemas = {
},
],
},
{
key: 'behavior',
title: '上传行为',
icon: 'fa fas fa-cogs',
fields: [
{ key: 'newfilename', label: '上传文件自动重命名', type: 'switch', help: '开启后上传文件按规则重命名,减少重名文件查询,提高上传效率。' },
{
key: 'newfilename_type',
label: '重命名格式',
type: 'radio',
buttonStyle: false,
default: 'time',
dependsOn: { key: 'newfilename', value: true },
options: [
{ label: '时间 + 原文件名', value: 'time' },
{ label: '随机字符串', value: 'random' },
],
},
],
},
],
},
+196 -5
View File
@@ -121,27 +121,100 @@
return true;
}
var SPLIT_CHUNK_BYTES = 2 * 1048576;
function splitUploadConfig() {
var split = cfg.splitUpload || {};
var minSize = parseInt(split.minSize, 10);
if (!isFinite(minSize) || minSize < 4) minSize = 4;
return { enabled: !!split.enabled, minSize: minSize };
}
function requestJson(url, options) {
var init = options || {};
init.credentials = 'same-origin';
init.headers = { 'X-WP-Nonce': cfg.restNonce || '' };
return fetch(url, init).then(function (response) {
return response.json().catch(function () { return {}; }).then(function (payload) {
if (!response.ok) {
throw new Error((payload && (payload.message || (payload.data && payload.data.message))) || i18n.error || '文件上传失败');
}
return payload;
});
});
}
// 分片上传:状态(断点续传)→ 逐片上传 → 合并走标准落库管线,响应与整传一致。
function uploadInChunks(file, type) {
var uploadKey = type === 'file' ? 'resource' : type;
var base = String(cfg.restUrl || '').replace(/\/$/, '');
if (!base) return Promise.reject(new Error(i18n.uploadUnavailable || '当前账号不能上传此类文件'));
var postId = currentPostId();
function chunkUrl(action) {
var target = base + '/front-submit/' + action + '/' + uploadKey;
if (postId) target += (target.indexOf('?') === -1 ? '?' : '&') + 'post_id=' + encodeURIComponent(String(postId));
return target;
}
var total = Math.ceil(file.size / SPLIT_CHUNK_BYTES);
return requestJson(chunkUrl('upload-status')).catch(function () { return { uploaded: [] }; }).then(function (status) {
var uploaded = {};
(Array.isArray(status.uploaded) ? status.uploaded : []).forEach(function (index) { uploaded[index] = true; });
var chain = Promise.resolve();
for (var index = 0; index < total; index++) {
(function (i) {
chain = chain.then(function () {
if (uploaded[i]) return;
var body = new FormData();
body.append('file', file.slice(i * SPLIT_CHUNK_BYTES, Math.min(file.size, (i + 1) * SPLIT_CHUNK_BYTES)));
body.append('chunk', String(i));
body.append('chunks', String(total));
body.append('name', file.name);
body.append('size', String(file.size));
return requestJson(chunkUrl('upload-chunk'), { method: 'POST', body: body });
});
})(index);
}
return chain;
}).then(function () {
var body = new FormData();
body.append('chunks', String(total));
body.append('name', file.name);
body.append('size', String(file.size));
return requestJson(chunkUrl('upload-merge'), { method: 'POST', body: body });
}).then(function (payload) {
if (!payload || !payload.success || !payload.url) {
throw new Error((payload && (payload.message || (payload.data && payload.data.message))) || i18n.error || '文件上传失败');
}
return payload;
});
}
function uploadMedia(file, type) {
var uploadKey = type === 'file' ? 'resource' : type;
var upload = (cfg.uploads || {})[uploadKey] || null;
if (!upload || !upload.url) return Promise.reject(new Error(i18n.uploadUnavailable || '当前账号不能上传此类文件'));
if (!acceptsFile(file, type)) return Promise.reject(new Error(i18n.invalidFile || '请选择有效文件'));
if (upload.maxSize && file.size > upload.maxSize) return Promise.reject(new Error(i18n.fileTooLarge || '文件大小超过限制'));
var split = splitUploadConfig();
if (split.enabled && typeof file.slice === 'function' && file.size > split.minSize * 1048576) {
if (Math.ceil(file.size / SPLIT_CHUNK_BYTES) > 1) {
return uploadInChunks(file, type);
}
}
var body = new FormData();
body.append('file', file);
var uploadUrl = String(upload.url);
if (currentPostId()) {
uploadUrl += (uploadUrl.indexOf('?') === -1 ? '?' : '&') + 'post_id=' + encodeURIComponent(String(currentPostId()));
}
return fetch(uploadUrl, { method: 'POST', credentials: 'same-origin', headers: { 'X-WP-Nonce': cfg.restNonce || '' }, body: body })
.then(function (response) {
return response.json().catch(function () { return {}; }).then(function (payload) {
if (!response.ok || !payload || !payload.success || !payload.url) {
return requestJson(uploadUrl, { method: 'POST', body: body }).then(function (payload) {
if (!payload || !payload.success || !payload.url) {
throw new Error((payload && (payload.message || (payload.data && payload.data.message))) || i18n.error || '文件上传失败');
}
return payload;
});
});
}
function imageBatchLimit() {
@@ -578,6 +651,120 @@
});
}
function toggleBlockFormat(editor, format) {
editor.formatter.toggle(format);
editor.nodeChanged();
}
function removeEditorBlock(editor, node) {
if (!node || node === editor.getBody()) return;
editor.dom.remove(node);
editor.nodeChanged();
}
function replaceButtonIconWithSvg(button, pathData) {
var node = button && button.$el && button.$el[0];
var icon = node ? node.querySelector('.mce-i-c-svg') : null;
if (!icon) return;
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 1024 1024');
svg.setAttribute('style', 'width:21px;height:21px;fill:currentColor;');
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', pathData);
svg.appendChild(path);
icon.parentNode.replaceChild(svg, icon);
}
// 经典编辑器增强浮动工具栏(后台「扩展&增强 → 系统工具」mce_float_toolbar):
// 复刻参考主题 editextend.js 的 wptoolbar 上下文工具条。
function registerFloatToolbar(editor) {
editor.addButton('aksrt_h2', {
title: 'Heading 2',
icon: 'c-svg heading2',
stateSelector: 'h2',
onclick: function () { toggleBlockFormat(editor, 'h2'); },
onPostRender: function () {
replaceButtonIconWithSvg(this, 'M143.616 219.648v228.864h278.016V219.648h89.856V768H421.632v-242.688H143.616V768H53.76V219.648h89.856z m660.48-10.752c52.992 0 96.768 15.36 131.328 46.08 33.792 30.72 50.688 69.888 50.688 119.04 0 47.616-18.432 90.624-53.76 129.792-16.554667 17.706667-43.093333 39.082667-78.933333 64.426667l-22.613334 15.701333-12.117333 8.192c-52.309333 34.389333-85.248 64.810667-99.413333 91.178667l-2.730667 5.589333h270.336V768h-382.464c0-56.064 17.664-104.448 54.528-145.92 8.746667-10.069333 21.76-22.186667 38.912-36.352l15.786667-12.586667c5.589333-4.352 11.52-8.874667 17.834666-13.568l19.84-14.506666 21.888-15.488 11.690667-8.106667c35.328-24.576 59.904-45.312 75.264-61.44 23.808-26.88 36.096-56.064 36.096-86.784 0-29.952-8.448-52.224-23.808-66.816-16.128-14.592-39.936-21.504-71.424-21.504-33.792 0-59.136 11.52-76.032 34.56-15.36 19.541333-24.362667 48.64-27.050667 86.058667l-0.597333 11.477333h-89.856c0.768-61.44 18.432-110.592 53.76-148.224 36.096-39.936 83.712-59.904 142.848-59.904z');
},
});
editor.addButton('aksrt_h3', {
title: 'Heading 3',
icon: 'c-svg heading3',
stateSelector: 'h3',
onclick: function () { toggleBlockFormat(editor, 'h3'); },
onPostRender: function () {
replaceButtonIconWithSvg(this, 'M801.024 208.896c55.296 0 100.608 13.056 134.4 39.936 33.024 26.88 49.92 63.744 49.92 111.36 0 59.904-30.72 99.84-91.392 119.808 32.256 9.984 57.6 24.576 74.496 44.544 18.432 20.736 27.648 47.616 27.648 79.872 0 50.688-17.664 92.16-52.992 124.416-36.864 33.024-85.248 49.92-145.152 49.92-56.832 0-102.912-14.592-137.472-43.776-38.4-32.256-59.904-79.872-64.512-141.312h91.392c1.536 35.328 12.288 62.976 33.792 82.176 19.2 17.664 44.544 26.88 76.032 26.88 34.56 0 62.208-9.984 82.176-29.184 17.664-17.664 26.88-39.168 26.88-65.28 0-31.488-9.984-54.528-28.416-69.12-18.432-15.36-45.312-22.272-80.64-22.272h-38.4V449.28h38.4c32.256 0 56.832-6.912 73.728-20.736 16.128-13.824 24.576-34.56 24.576-61.44 0-26.88-7.68-46.848-22.272-60.672-16.128-13.824-39.936-20.736-71.424-20.736-32.256 0-56.832 7.68-74.496 23.808-18.432 16.128-29.184 40.704-32.256 73.728h-88.32c4.608-55.296 24.576-98.304 61.44-129.024 34.56-30.72 79.104-45.312 132.864-45.312z m-657.408 10.752v228.864h278.016V219.648h89.856V768H421.632v-242.688H143.616V768H53.76V219.648h89.856z');
},
});
editor.addButton('aksrt_remove', {
tooltip: i18n.removeBlock || '删除',
icon: 'dashicon dashicons-no-alt',
onclick: function () {
removeEditorBlock(editor, editor.selection.getNode());
},
});
editor.on('wptoolbar', function (event) {
if (event.toolbar) return;
var probe = document.createElement('div');
probe.innerHTML = editor.getContent();
if (probe.querySelectorAll('p,h1,h2,h3,h4,h5,h6,ul,ol,div,blockquote,pre,img,iframe,table,hr').length <= 4) return;
var bars = floatToolbarButtons(editor, event);
if (bars.length && editor.wp && typeof editor.wp._createToolbar === 'function') {
event.toolbar = editor.wp._createToolbar(bars);
}
});
}
// 复刻参考主题 get_all_toolbar:按光标所在块与是否选中文字给出上下文按钮。
function floatToolbarButtons(editor, event) {
var element = event.element;
if (!element) return [];
var nodeName = element.nodeName;
var innerText = element.innerText ? String(element.innerText).trim() : '';
var hasSelection = !!editor.selection.getContent();
var parent = element.parentElement;
var inRoot = !!(parent && parent.id === 'tinymce');
var bars = [];
if (['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'UL', 'LI', 'STRONG', 'SPAN', 'CODE'].indexOf(nodeName) !== -1) {
if (inRoot || (nodeName === 'SPAN' && parent && parent.parentElement && parent.parentElement.id === 'tinymce')) {
bars.push('aksrt_h2', 'aksrt_h3');
}
if (hasSelection) {
bars = ['H1', 'H2', 'H3', 'H4', 'H5'].indexOf(nodeName) === -1 ? ['bold', 'link', 'wp_code'] : [];
bars.push('forecolor');
if (nodeName === 'P' && editorCfg.canHide) bars.push('aksrt_hide');
bars.push('aksrt_remove');
return bars;
}
if (innerText) {
bars.push('bullist', 'numlist', 'aligncenter');
if (nodeName === 'P' && inRoot) {
bars.push('aksrt_quote');
if (editorCfg.canHide) bars.push('aksrt_hide');
}
bars.push('aksrt_remove');
} else {
bars.push('bullist', 'aksrt_quote');
if (editorCfg.canHide) bars.push('aksrt_hide');
if (editorCfg.canUploadImage) bars.push('aksrt_image');
bars.push('aksrt_video', 'aksrt_precode');
}
}
if (!bars.length && (nodeName === 'DIV' || nodeName === 'PRE') && !(element.classList && element.classList.contains('file-download-name'))) {
bars.push('aksrt_remove');
}
return bars;
}
function registerEditorTools() {
if (!window.tinymce || !window.tinymce.PluginManager || window.aksrtSubmitEditorToolsRegistered) {
return false;
@@ -711,6 +898,10 @@
menu: menu,
});
}
if (editorCfg.floatToolbar) {
registerFloatToolbar(editor);
}
});
return true;
}
+63
View File
@@ -0,0 +1,63 @@
/**
* AKSRT Page Admin Set — 前端页面编辑弹窗提交(后台「扩展&增强 → 系统工具」)。
*
* 弹窗内容由 PageAdminSet::ajax_modal() 经 aksrt-modal-runtime 注入,
* 这里只负责表单提交:序列化 → admin-ajax → 成功后刷新页面。
*
* @package AKSRT
*/
(function (window, document) {
'use strict';
function notify(message, type) {
if (typeof window.notyf === 'function') {
window.notyf(message, type || 'error', 4, 'aksrt-page-admin-set-toast');
}
}
document.addEventListener('submit', function (event) {
var form = event.target.closest('form.aksrt-admin-set-form');
if (!form) return;
event.preventDefault();
var submitButton = form.querySelector('.aksrt-admin-set-submit');
if (submitButton && submitButton.disabled) return;
if (submitButton) submitButton.disabled = true;
var action = (form.querySelector('input[name="action"]') || {}).value || '';
var ajaxUrl = (window.aksrtData && window.aksrtData.ajaxUrl) || (window._win && window._win.ajax_url) || '';
if (!ajaxUrl) {
notify('请求地址缺失', 'error');
if (submitButton) submitButton.disabled = false;
return;
}
var url = ajaxUrl + (ajaxUrl.indexOf('?') === -1 ? '?' : '&') + 'action=' + encodeURIComponent(action);
fetch(url, {
method: 'POST',
credentials: 'same-origin',
body: new FormData(form),
})
.then(function (response) {
return response.json().catch(function () { return {}; }).then(function (payload) {
if (!response.ok || !payload || !payload.success) {
throw new Error((payload && payload.data && payload.data.msg) || '保存失败,请重试');
}
return payload;
});
})
.then(function (payload) {
var data = payload.data || {};
notify(data.msg || '保存成功', 'success');
if (data.reload !== false) {
window.setTimeout(function () { window.location.reload(); }, 600);
} else if (submitButton) {
submitButton.disabled = false;
}
})
.catch(function (error) {
notify(error.message || '保存失败,请重试', 'error');
if (submitButton) submitButton.disabled = false;
});
});
})(window, document);
+6
View File
@@ -170,6 +170,12 @@ if (class_exists('\AKSRT\Settings\MailNotifications')) {
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
\AKSRT\Extensions\SecurityHardening::get_instance()->init();
}
if (class_exists('\AKSRT\Extensions\SystemTools')) {
\AKSRT\Extensions\SystemTools::get_instance()->init();
}
if (class_exists('\AKSRT\Frontend\PageAdminSet')) {
\AKSRT\Frontend\PageAdminSet::get_instance()->init();
}
// 短代码统一注册入口(registry)
if (class_exists('\AKSRT\Core\Shortcodes')) {
+2 -3
View File
@@ -62,11 +62,10 @@ add_action('save_post', static function ($post_id, $post = null): void {
// ============================================================
// 前端隐藏 WordPress Admin Bar
// 前端隐藏 WordPress Admin Bar:由 SystemTools 按「扩展&增强 → 系统工具」
// 的 hide_admin_bar 设置门控(默认开启),不再无条件注入。
// ============================================================
add_filter('show_admin_bar', '__return_false');
// ============================================================
// SMTP 邮件配置注入
// ============================================================
+6 -3
View File
@@ -155,9 +155,6 @@ if (!function_exists('aksrt_float_right')) :
}
$settings = \AKSRT\Settings\Settings::get_floating_buttons_settings();
$items = is_array($settings['items'] ?? null) ? $settings['items'] : [];
if (!$items) {
return;
}
$is_mobile = wp_is_mobile();
$btn = '';
@@ -224,6 +221,12 @@ if (!function_exists('aksrt_float_right')) :
return;
}
// 扩展点(对齐参考主题 zib_float_right filter):模块可注入自定义悬浮按钮。
$btn = (string) apply_filters('aksrt_float_right_buttons', $btn, $is_mobile);
if ($btn === '') {
return;
}
$filter = (array) ($settings['filter_blur'] ?? []);
$class = ($settings['style'] ?? 'round') . ' position-' . ($settings['position'] ?? 'bottom');
$class .= (($is_mobile && in_array('m_s', $filter, true)) || (!$is_mobile && in_array('pc_s', $filter, true))) ? ' filter' : '';
@@ -480,6 +480,14 @@ if (!function_exists('aksrt_time_ago')) :
$ctime = (int) strtotime(current_time('mysql'));
$t = $ctime - $time;
// 关闭倒计时显示时按「系统工具」自定义时间格式输出(legacy zib_get_time_ago parity)。
$system = class_exists('\AKSRT\Settings\ExtensionRuntime')
? \AKSRT\Settings\ExtensionRuntime::get_system_tools_settings()
: [];
if (empty($system['time_ago_s']) && ($system['time_format'] ?? '') !== '') {
return date($system['time_format'], $time);
}
if ($t < 0) {
return date('Y-m-d H:i', $time);
}
+1
View File
@@ -14,6 +14,7 @@ class SchemaExtensions {
'domain' => 'extensions',
'title' => '扩展&增强',
'sections' => [
'system' => SchemaExtensionsSystem::build(),
'ip' => SchemaExtensionsIP::build(),
'audit' => SchemaExtensionsAudit::build(),
'push' => SchemaExtensionsPush::build(),
@@ -0,0 +1,52 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin\Schema;
defined('ABSPATH') || exit;
class SchemaExtensionsSystem {
public static function build(): array {
return [
'key' => 'system',
'title' => '系统工具',
'version' => '1.0.0',
'option_key' => 'aksrt_settings',
'groups' => [
['key' => 'optimize', 'title' => 'WordPress 优化', 'fields' => [
['key' => 'hide_admin_bar', 'label' => '前台关闭顶部admin_bar', 'type' => 'switch', 'default' => true, 'help' => '开启后前台不显示WordPress顶部黑条'],
['key' => 'remove_emoji', 'label' => '删除WordPress自带Emoji表情', 'type' => 'switch', 'default' => true],
['key' => 'remove_open_sans', 'label' => '禁用Google字体', 'type' => 'switch', 'default' => true],
['key' => 'remove_more_wp_head', 'label' => '清理多余的头部Meta标签', 'type' => 'switch', 'default' => true],
['key' => 'remove_dashboard_widgets', 'label' => 'WordPress 后台加速', 'type' => 'switch', 'default' => true, 'help' => '移除仪表盘的站点健康和WordPress活动等不必要功能,并禁止检测浏览器和PHP版本(这两个非常耗时),优化后台加载速度'],
['key' => 'display_wp_update', 'label' => '禁止WordPress检测更新', 'type' => 'switch', 'default' => true, 'help' => '开启后将禁用WordPress核心、插件和主题的自动更新及更新检测'],
]],
['key' => 'upload', 'title' => '上传优化', 'fields' => [
['key' => 'split_upload_s', 'label' => '大文件分片上传', 'type' => 'switch', 'default' => true, 'help' => '分片上传大文件,优化大文件上传效率(支持断点续传)'],
['key' => 'split_minimum_size', 'label' => '分片上传最小文件', 'type' => 'number', 'default' => 20, 'min' => 4, 'max' => 100, 'unit' => 'M', 'dependsOn' => ['key' => 'split_upload_s', 'value' => true], 'help' => '大于此大小的文件才会分片上传,请根据服务器性能合理设置;较大文件直接上传会比较耗时且无法突破系统上传大小限制,分片上传可以有效解决此问题;前端上传大小限制在「功能权限 → 上传权限」中设置。注意:能否顺利上传大文件还取决于服务器性能、带宽、CDN等因素,如果网站使用了OSS/COS等第三方云储存,则不建议在网站上传大文件'],
['key' => 'newfilename', 'label' => '上传文件自动重命名', 'type' => 'switch', 'default' => false, 'help' => '上传文件时系统会查询重名文件,开启此功能后可减少重名文件,从而提高上传效率'],
['key' => 'newfilename_type', 'label' => '重命名格式', 'type' => 'radio', 'default' => 'time', 'dependsOn' => ['key' => 'newfilename', 'value' => true], 'options' => [
['label' => '随机字串符', 'value' => 'random'],
['label' => '时间+原文件名', 'value' => 'time'],
]],
['key' => 'disabled_large_thumbnail', 'label' => '禁止WP大尺寸缩略图裁剪', 'type' => 'switch', 'default' => true, 'help' => '开启后将禁用WordPress自动裁剪medium_large、1536x1536、2048x2048的缩略图,可减少服务器硬盘压力,同时提高上传效率;如果您使用的插件必须要这些尺寸的缩略图,或使用了云储存的图片处理功能,则请关闭'],
]],
['key' => 'editor', 'title' => '编辑器与显示', 'fields' => [
['key' => 'admin_user_del_fields', 'label' => '关闭后台用户编辑多余的选项', 'type' => 'switch', 'default' => true, 'help' => '开启后在后台编辑用户资料时将不显示无用的多余选项'],
['key' => 'admin_frontend_set', 'label' => '前端页面编辑', 'type' => 'switch', 'default' => true, 'help' => '管理员可在前台快速修改页面、文章参数'],
['key' => 'close_gutenberg', 'label' => '禁用古腾堡编辑器', 'type' => 'switch', 'default' => false, 'help' => '后台文章编辑器将退回使用经典编辑器;禁用后主题的编辑器增强功能将不能使用,请酌情禁用!'],
['key' => 'disabled_autoembed', 'label' => '禁用Auto Embed', 'type' => 'switch', 'default' => true, 'help' => 'Auto Embed是WordPress自动检测并嵌入链接的一种功能,主要适配海外媒体网站,国内几乎用不上,建议禁用,可大幅度提高页面渲染速度'],
['key' => 'mce_float_toolbar', 'label' => '经典编辑器增强浮动工具栏', 'type' => 'checkbox', 'default' => ['pc_s', 'm_s'], 'sanitize_type' => 'array', 'options' => [
['label' => 'PC端开启', 'value' => 'pc_s'],
['label' => '移动端开启', 'value' => 'm_s'],
], 'help' => '开启后前端编辑器会显示浮动的工具栏,更加方便书写'],
['key' => 'time_ago_s', 'label' => '倒计时显示', 'type' => 'switch', 'default' => true, 'help' => '时间格式化为:X分钟前,X小时前,X天前....'],
['key' => 'time_format', 'label' => '自定义时间格式', 'type' => 'input', 'default' => 'n月j日 H:i', 'dependsOn' => ['key' => 'time_ago_s', 'operator' => 'falsy'], 'help' => '关闭倒计时显示后生效,时间格式接受标准PHP时间格式,请注意控制长度!'],
]],
],
];
}
}
-7
View File
@@ -131,13 +131,6 @@ class SchemaPermissions {
['key' => 'val', 'label' => '格式', 'type' => 'textarea', 'default' => '', 'rows' => 1, 'placeholder' => '如:svg=image/svg+xml,mp3=audio/mpeg'],
]],
]],
['key' => 'behavior', 'title' => '上传行为', 'fields' => [
['key' => 'newfilename', 'label' => '上传文件自动重命名', 'type' => 'switch', 'default' => false, 'help' => '开启后上传文件按规则重命名,减少重名文件查询,提高上传效率。'],
['key' => 'newfilename_type', 'label' => '重命名格式', 'type' => 'radio', 'default' => 'time', 'dependsOn' => ['key' => 'newfilename', 'value' => true], 'options' => [
['label' => '时间 + 原文件名', 'value' => 'time'],
['label' => '随机字符串', 'value' => 'random'],
]],
]],
],
],
'moderation' => [
+20 -3
View File
@@ -17,6 +17,7 @@ class FrontSubmit {
use FrontSubmitPay;
use FrontSubmitFeatured;
use FrontSubmitMedia;
use FrontSubmitChunk;
private static $instance = null;
@@ -93,6 +94,8 @@ class FrontSubmit {
],
],
]);
$this->register_chunk_routes();
}
public function can_upload_attachment(\WP_REST_Request $request): bool {
@@ -129,7 +132,6 @@ class FrontSubmit {
public function rest_upload_attachment(\WP_REST_Request $request) {
$kind = (string) $request->get_param('kind');
$user_id = get_current_user_id();
$files = $request->get_file_params();
$file = $files['file'] ?? null;
@@ -137,6 +139,18 @@ class FrontSubmit {
return new \WP_Error('aksrt_upload_missing_file', __('请选择要上传的文件。', 'aksrt'), ['status' => 400]);
}
return $this->persist_uploaded_file($kind, $file, false);
}
/**
* 校验并把一个上传文件落库为附件;整传与分片合并共用同一条管线。
*
* @param string $kind image|video|resource
* @param array $file $_FILES 风格数组(name/tmp_name/size/error)
* @param bool $sideload true 时文件不是 HTTP 直传(分片合并产物),用 sideload 动作保存
* @return \WP_REST_Response|\WP_Error
*/
public function persist_uploaded_file(string $kind, array $file, bool $sideload = false) {
$max_size = self::upload_limit_bytes($kind);
$upload_error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($upload_error === UPLOAD_ERR_INI_SIZE || $upload_error === UPLOAD_ERR_FORM_SIZE) {
@@ -197,15 +211,18 @@ class FrontSubmit {
require_once ABSPATH . 'wp-admin/includes/image.php';
}
// 合并产物不是 HTTP 直传文件,走 sideload 动作(copy 而非 move_uploaded_file)。
$overrides = $sideload ? ['test_form' => false, 'action' => 'wp_handle_sideload'] : ['test_form' => false];
$_FILES['aksrt_front_submit_file'] = $file;
$attachment_id = media_handle_upload('aksrt_front_submit_file', 0, [], ['test_form' => false]);
$attachment_id = media_handle_upload('aksrt_front_submit_file', 0, [], $overrides);
unset($_FILES['aksrt_front_submit_file']);
if (is_wp_error($attachment_id)) {
return new \WP_Error('aksrt_upload_failed', $attachment_id->get_error_message(), ['status' => 400]);
}
$attachment_id = (int) $attachment_id;
wp_update_post(['ID' => $attachment_id, 'post_author' => $user_id]);
wp_update_post(['ID' => $attachment_id, 'post_author' => get_current_user_id()]);
update_post_meta($attachment_id, '_aksrt_front_submit_media', $kind);
$attachment = $this->prepare_attachment_response($attachment_id);
+278
View File
@@ -0,0 +1,278 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Core;
defined('ABSPATH') || exit;
/**
* 前台大文件分片上传(后台「扩展&增强 → 系统工具」的 split_upload_s / split_minimum_size)。
*
* 行为对齐参考主题 action/media.php 的 user_split_upload / user_split_upload_merge /
* user_split_uploaded_chunk 协议:2MB 一片、断点续传(已传分片按序跳过)、合并产物
* 经 sideload 动作重新走 persist_uploaded_file() 标准校验与落库管线。
*/
trait FrontSubmitChunk {
/** 每个分片的固定大小(参考主题 split_chunk_size = 2MB)。 */
const CHUNK_SIZE_BYTES = 2 * 1048576;
public function register_chunk_routes(): void {
$kind_arg = [
'validate_callback' => static function ($value): bool {
return in_array((string) $value, ['image', 'video', 'resource'], true);
},
];
register_rest_route('aksrt/v1', '/front-submit/upload-chunk/(?P<kind>image|video|resource)', [
'methods' => 'POST',
'callback' => [$this, 'rest_upload_chunk'],
'permission_callback' => [$this, 'can_upload_attachment'],
'args' => [
'kind' => $kind_arg,
'chunk' => ['default' => 0, 'sanitize_callback' => 'absint'],
'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'],
'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'],
'size' => ['default' => 0, 'sanitize_callback' => 'absint'],
],
]);
register_rest_route('aksrt/v1', '/front-submit/upload-merge/(?P<kind>image|video|resource)', [
'methods' => 'POST',
'callback' => [$this, 'rest_upload_chunk_merge'],
'permission_callback' => [$this, 'can_upload_attachment'],
'args' => [
'kind' => $kind_arg,
'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'],
'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'],
'size' => ['default' => 0, 'sanitize_callback' => 'absint'],
],
]);
register_rest_route('aksrt/v1', '/front-submit/upload-status/(?P<kind>image|video|resource)', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_upload_chunk_status'],
'permission_callback' => [$this, 'can_upload_attachment'],
'args' => [
'kind' => $kind_arg,
'chunks' => ['default' => 0, 'sanitize_callback' => 'absint'],
'name' => ['default' => '', 'sanitize_callback' => 'sanitize_file_name'],
],
]);
}
/**
* 上传单个分片;index 为 0 基,同时累加校验总大小不超上限。
*/
public function rest_upload_chunk(\WP_REST_Request $request) {
$file = $request->get_file_params()['file'] ?? null;
if (!is_array($file) || empty($file['tmp_name']) || (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return new \WP_Error('aksrt_chunk_missing_file', __('上传信息错误,请重新选择文件', 'aksrt'), ['status' => 400]);
}
$params = $this->chunk_params($request, true);
if (is_wp_error($params)) {
return $params;
}
$max_size = self::upload_limit_bytes($params['kind']);
if ($params['size'] <= 0 || $params['size'] > $max_size) {
return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]);
}
$chunk_bytes = (int) @filesize((string) $file['tmp_name']);
if ($chunk_bytes <= 0 || $chunk_bytes > self::CHUNK_SIZE_BYTES) {
return new \WP_Error('aksrt_chunk_invalid_size', __('分片大小异常,请重新上传', 'aksrt'), ['status' => 400]);
}
$temp_dir = $this->chunk_temp_dir();
if ($params['chunk'] === 0) {
$this->chunk_temp_cleanup($temp_dir);
}
$existing = $this->chunk_part_paths($temp_dir, $params['key']);
$uploaded = 0;
foreach ($existing as $path) {
$uploaded += (int) @filesize($path);
}
if ($uploaded + $chunk_bytes > $max_size) {
return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]);
}
$target = $temp_dir . '/' . $this->chunk_part_name($params['key'], $params['chunk']);
$moved = @move_uploaded_file((string) $file['tmp_name'], $target);
if (!$moved) {
$moved = @copy((string) $file['tmp_name'], $target);
@unlink((string) $file['tmp_name']);
}
if (!$moved) {
return new \WP_Error('aksrt_chunk_save_failed', __('分片保存失败,请重新上传', 'aksrt'), ['status' => 500]);
}
return new \WP_REST_Response([
'success' => true,
'chunk' => $params['chunk'],
'chunks' => $params['chunks'],
'uploaded_size' => $uploaded + $chunk_bytes,
], 200);
}
/**
* 断点续传:返回已落盘的分片序号,前端按序跳过。
*/
public function rest_upload_chunk_status(\WP_REST_Request $request) {
$params = $this->chunk_params($request);
if (is_wp_error($params)) {
return $params;
}
$parts = $this->chunk_part_paths($this->chunk_temp_dir(), $params['key']);
$uploaded = [];
foreach ($parts as $path) {
$uploaded[] = $this->chunk_index_from_name((string) $path);
}
return new \WP_REST_Response([
'success' => true,
'uploaded' => $uploaded,
'uploaded_count' => count($uploaded),
], 200);
}
/**
* 合并分片并按标准管线落库;成功或失败后都清理分片。
*/
public function rest_upload_chunk_merge(\WP_REST_Request $request) {
$params = $this->chunk_params($request);
if (is_wp_error($params)) {
return $params;
}
$max_size = self::upload_limit_bytes($params['kind']);
$temp_dir = $this->chunk_temp_dir();
$merge_path = $temp_dir . '/' . $this->chunk_part_name($params['key'], 'merge');
$fp = @fopen($merge_path, 'wb');
if (!$fp) {
return new \WP_Error('aksrt_chunk_merge_failed', __('分片合并失败,请重新上传', 'aksrt'), ['status' => 500]);
}
$missing = 0;
for ($i = 0; $i < $params['chunks']; $i++) {
$part = $temp_dir . '/' . $this->chunk_part_name($params['key'], $i);
if (!is_file($part)) {
$missing++;
break;
}
fwrite($fp, (string) file_get_contents($part));
}
fclose($fp);
if ($missing) {
@unlink($merge_path);
return new \WP_Error('aksrt_chunk_missing', __('文件缺少分片,正在重新上传缺失分片', 'aksrt'), ['status' => 409]);
}
$merged_size = (int) @filesize($merge_path);
if ($merged_size <= 0 || ($params['size'] > 0 && $merged_size !== $params['size'])) {
@unlink($merge_path);
return new \WP_Error('aksrt_chunk_size_mismatch', __('合并后的文件大小校验失败,请重新上传', 'aksrt'), ['status' => 400]);
}
if ($merged_size > $max_size) {
@unlink($merge_path);
return new \WP_Error('aksrt_upload_too_large', sprintf(__('文件大小不能超过 %s。', 'aksrt'), size_format($max_size)), ['status' => 413]);
}
$name = sanitize_file_name($params['name']);
$type = (string) (wp_check_filetype_and_ext($merge_path, $name, get_allowed_mime_types())['type'] ?? '');
$result = $this->persist_uploaded_file($params['kind'], [
'tmp_name' => $merge_path,
'name' => $name,
'size' => $merged_size,
'type' => $type,
'error' => UPLOAD_ERR_OK,
], true);
// 无论落库成败都清理本次分片(persist 成功时已 copy 走合并文件,失败时也要清场)。
foreach ($this->chunk_part_paths($temp_dir, $params['key']) as $path) {
@unlink($path);
}
@unlink($merge_path);
return $result;
}
/** 校验并归一分片参数(name/chunks/chunk/size/key)。 */
private function chunk_params(\WP_REST_Request $request, bool $with_index = false) {
$name = sanitize_file_name((string) $request->get_param('name'));
$chunks = (int) $request->get_param('chunks');
$chunk = (int) $request->get_param('chunk');
if ($name === '' || $chunks < 1 || $chunks > 20480) {
return new \WP_Error('aksrt_chunk_invalid_params', __('分片参数异常', 'aksrt'), ['status' => 400]);
}
if ($with_index && ($chunk < 0 || $chunk >= $chunks)) {
return new \WP_Error('aksrt_chunk_invalid_params', __('分片参数异常', 'aksrt'), ['status' => 400]);
}
return [
'kind' => (string) $request->get_param('kind'),
'name' => $name,
'chunks' => $chunks,
'chunk' => $chunk,
'size' => (int) $request->get_param('size'),
// 分片按用户隔离,避免同名文件互相覆盖、也防止探测他人分片。
'key' => md5(get_current_blog_id() . '|' . get_current_user_id() . '|' . $name),
];
}
/** 分片临时目录(参考主题为 wp-content/zib-temp,带 index.php 保护和 48h 清理)。 */
private function chunk_temp_dir(): string {
$temp_dir = (string) apply_filters('aksrt_temp_dir', WP_CONTENT_DIR . '/aksrt-temp');
if (!@is_dir($temp_dir)) {
wp_mkdir_p($temp_dir);
}
$index_file = $temp_dir . '/index.php';
if (!file_exists($index_file)) {
@file_put_contents($index_file, "<?php\n// Silence is golden.\n");
}
return $temp_dir;
}
/** 清理超过 48 小时的遗留分片。 */
private function chunk_temp_cleanup(string $temp_dir): void {
$expire = time() - 48 * HOUR_IN_SECONDS;
foreach ((array) glob($temp_dir . '/*.part') as $file) {
if (@filemtime((string) $file) < $expire) {
@unlink((string) $file);
}
}
}
/** 当前 key 的全部分片路径(不含 merge 产物)。 */
private function chunk_part_paths(string $temp_dir, string $key): array {
$paths = [];
foreach ((array) glob($temp_dir . '/' . $this->chunk_part_name($key, '*')) as $path) {
$path = (string) $path;
if (substr($path, -10) === 'merge.part') {
continue;
}
$paths[] = $path;
}
sort($paths, SORT_NATURAL);
return $paths;
}
private function chunk_part_name(string $key, $index): string {
return sprintf('%d-%s-%s.part', get_current_blog_id(), $key, (string) $index);
}
private function chunk_index_from_name(string $path): int {
$base = basename($path, '.part');
$dash = strrpos($base, '-');
return $dash === false ? -1 : (int) substr($base, $dash + 1);
}
}
-2
View File
@@ -213,8 +213,6 @@ trait ScriptsPart1c {
'upload_ext' => implode('|', array_keys(get_allowed_mime_types())),
'user_upload_nonce' => wp_create_nonce('user_upload'),
'post_action_nonce' => wp_create_nonce('post_action'),
'is_split_upload' => false,
'split_minimum_size' => 10,
'translate_config' => [],
'i18n' => [
'load_more' => __('加载更多', 'aksrt'),
+205
View File
@@ -0,0 +1,205 @@
<?php
/**
* 系统工具:WordPress 优化、上传优化与编辑器显示开关。
*
* 对应后台「扩展&增强 → 系统工具」设置(ExtensionRuntime::get_system_tools_settings()),
* 行为对齐参考主题 zib-theme.php / zib-head.php / admin-main.php / admin-set.php 的系统工具套件。
*
* @package AKSRT
*/
namespace AKSRT\Extensions;
defined('ABSPATH') || exit;
class SystemTools {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
$settings = \AKSRT\Settings\ExtensionRuntime::get_system_tools_settings();
if (!empty($settings['hide_admin_bar']) || !is_super_admin()) {
add_filter('show_admin_bar', '__return_false');
}
if (!empty($settings['remove_emoji'])) {
$this->remove_emoji();
}
if (!empty($settings['remove_open_sans'])) {
add_action('init', [$this, 'remove_open_sans']);
}
if (!empty($settings['remove_more_wp_head'])) {
add_action('after_setup_theme', [$this, 'remove_more_wp_head']);
}
if (!empty($settings['remove_dashboard_widgets'])) {
add_action('do_meta_boxes', [$this, 'remove_dashboard_widgets'], 10, 3);
add_filter('site_status_tests', [$this, 'filter_site_status_tests']);
add_filter('pre_http_request', [$this, 'block_site_health_requests'], 10, 3);
}
if (!empty($settings['display_wp_update'])) {
$this->disable_wp_update();
}
if (!empty($settings['disabled_large_thumbnail'])) {
add_action('after_setup_theme', [$this, 'disable_large_thumbnail']);
}
if (!empty($settings['close_gutenberg'])) {
add_filter('use_block_editor_for_post', '__return_false');
}
if (!empty($settings['disabled_autoembed'])) {
$this->disable_autoembed();
}
if (!empty($settings['admin_user_del_fields'])) {
add_action('show_user_profile', [$this, 'hide_user_profile_fields']);
add_action('edit_user_profile', [$this, 'hide_user_profile_fields']);
}
}
/** 删除WordPress自带Emoji表情(对齐参考主题 remove_emoji 套件)。 */
private function remove_emoji(): void {
remove_action('admin_print_scripts', 'print_emoji_detection_script');
remove_action('admin_print_styles', 'print_emoji_styles');
remove_action('wp_head', 'print_emoji_detection_script', 7);
remove_action('wp_print_styles', 'print_emoji_styles');
remove_filter('the_content_feed', 'wp_staticize_emoji');
remove_filter('comment_text_rss', 'wp_staticize_emoji');
remove_filter('wp_mail', 'wp_staticize_emoji_for_email');
}
/** 禁用Google字体(Open Sans)。 */
public function remove_open_sans(): void {
wp_deregister_style('open-sans');
wp_register_style('open-sans', false);
wp_enqueue_style('open-sans', '');
}
/** 清理wp_head带入的多余Meta标签。 */
public function remove_more_wp_head(): void {
remove_action('wp_head', 'feed_links_extra', 3);
remove_action('wp_head', 'feed_links', 2);
remove_action('wp_head', 'rsd_link');
remove_action('wp_head', 'wlwmanifest_link');
remove_action('wp_head', 'index_rel_link');
remove_action('wp_head', 'parent_post_rel_link', 10, 0);
remove_action('wp_head', 'start_post_rel_link', 10, 0);
remove_action('wp_head', 'adjacent_posts_rel_link', 10, 0);
remove_action('wp_head', 'wp_generator');
remove_action('wp_head', 'adjacent_posts_rel_link_wp_head', 10, 0);
remove_action('wp_head', 'wp_shortlink_wp_head', 10, 0);
remove_action('wp_head', 'rest_output_link_wp_head', 10, 0);
remove_action('wp_head', 'wp_oembed_add_discovery_links', 10, 1);
remove_action('wp_head', 'rel_canonical', 10, 0);
}
/** 移除仪表盘的站点健康、PHP/浏览器版本检查小组件。 */
public function remove_dashboard_widgets($screen_id, $context): void {
if ('dashboard' !== $screen_id || 'normal' !== $context) {
return;
}
global $wp_meta_boxes;
unset($wp_meta_boxes['dashboard']['normal']['high']['dashboard_php_nag']);
unset($wp_meta_boxes['dashboard']['normal']['core']['dashboard_site_health']);
unset($wp_meta_boxes['dashboard']['side']['core']['dashboard_primary']);
}
/** 站点健康检查中移除页面缓存与PHP版本检测(非常耗时)。 */
public function filter_site_status_tests(array $tests): array {
if (isset($tests['async']['page_cache'])) {
unset($tests['async']['page_cache']);
}
if (isset($tests['direct']['php_version'])) {
unset($tests['direct']['php_version']);
}
return $tests;
}
/** 禁止后台联网检测PHP版本、浏览器与贡献者信息,提高后台加载速度。 */
public function block_site_health_requests($pre, $parsed_args, $url) {
$blocked = [
'api.wordpress.org/core/serve-happy/',
'api.wordpress.org/core/browse-happy/',
'api.wordpress.org/core/credits/',
];
foreach ($blocked as $arg) {
if (strpos((string) $url, $arg) !== false) {
return [
'headers' => [],
'body' => '',
'response' => [
'code' => false,
'message' => false,
],
'cookies' => [],
'http_response' => null,
];
}
}
return $pre;
}
/** 禁止WordPress检测核心/插件/主题更新,并屏蔽更新通知。 */
private function disable_wp_update(): void {
remove_action('admin_init', '_maybe_update_core');
remove_action('admin_init', '_maybe_update_plugins');
remove_action('admin_init', '_maybe_update_themes');
add_action('admin_menu', [$this, 'hide_update_notices']);
add_filter('automatic_updater_disabled', '__return_true');
remove_action('init', 'wp_schedule_update_checks');
remove_action('wp_version_check', 'wp_version_check');
remove_action('wp_update_plugins', 'wp_update_plugins');
remove_action('wp_update_themes', 'wp_update_themes');
add_filter('auto_update_core', '__return_false');
add_filter('allow_major_auto_core_updates', '__return_false');
add_filter('allow_dev_auto_core_updates', '__return_false');
add_filter('allow_minor_auto_core_updates', '__return_false');
}
/** 禁用更新通知。 */
public function hide_update_notices(): void {
remove_action('admin_notices', 'update_nag', 3);
remove_action('network_admin_notices', 'update_nag', 3);
}
/** 禁止WP大尺寸缩略图裁剪(medium_large、1536x1536、2048x2048)。 */
public function disable_large_thumbnail(): void {
remove_image_size('1536x1536');
remove_image_size('2048x2048');
add_filter('intermediate_image_sizes', static function ($sizes) {
return array_merge(array_diff((array) $sizes, ['medium_large', '1536x1536', '2048x2048']));
}, 20);
}
/** 禁用Auto Embed自动嵌入功能,提高页面渲染速度。 */
private function disable_autoembed(): void {
remove_filter('the_content', [$GLOBALS['wp_embed'], 'autoembed'], 8);
remove_filter('widget_text_content', [$GLOBALS['wp_embed'], 'autoembed'], 8);
remove_filter('widget_block_content', [$GLOBALS['wp_embed'], 'autoembed'], 8);
remove_action('edit_form_advanced', [$GLOBALS['wp_embed'], 'maybe_run_ajax_cache']);
remove_action('edit_page_form', [$GLOBALS['wp_embed'], 'maybe_run_ajax_cache']);
}
/** 关闭后台用户编辑页面中的多余选项。 */
public function hide_user_profile_fields(): void {
echo '<style>.user-first-name-wrap,.user-last-name-wrap,.user-admin-bar-front-wrap,.user-comment-shortcuts-wrap,.user-syntax-highlighting-wrap,.user-rich-editing-wrap,.user-profile-picture .description,.user-language-wrap{display:none}</style>';
}
}
+305
View File
@@ -0,0 +1,305 @@
<?php
/**
* 前端页面编辑(后台「扩展&增强 → 系统工具」的 admin_frontend_set)。
*
* 对齐参考主题 zib-frontend-set.php:管理员在前台通过悬浮按钮打开弹窗,
* 快速修改当前文章/页面的标题、副标题、布局、计数、评论开关或分类术语的
* 名称与描述;保存走白名单,行为与后台 PostMetaBox / wp_update_term 一致。
*
* @package AKSRT
*/
namespace AKSRT\Frontend;
defined('ABSPATH') || exit;
class PageAdminSet {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_filter('aksrt_float_right_buttons', [$this, 'float_button'], 10, 2);
add_action('wp_ajax_aksrt_frontend_set_modal', [$this, 'ajax_modal']);
add_action('wp_ajax_aksrt_frontend_set_save', [$this, 'ajax_save']);
add_action('wp_enqueue_scripts', [$this, 'enqueue_script']);
}
/** 开启条件:设置开关 + 管理员 + 单页/术语页 + 有效对象(排除投稿与导航页模板)。 */
public static function enabled(): bool {
if (!class_exists('\AKSRT\Settings\ExtensionRuntime') || !is_user_logged_in()) {
return false;
}
$settings = \AKSRT\Settings\ExtensionRuntime::get_system_tools_settings();
if (empty($settings['admin_frontend_set']) || !current_user_can('manage_options')) {
return false;
}
if (!get_queried_object_id() || (!is_single() && !is_page() && !is_category() && !is_tag() && !is_tax())) {
return false;
}
if (is_page_template('templates/pages/page-submit.php') || is_page_template('templates/pages/page-posts-navs.php')) {
return false;
}
return true;
}
/** 悬浮按钮:注入右侧悬浮按钮栏(对齐参考主题 zib_admin_frontend_set_botton)。 */
public function float_button($buttons, $is_mobile) {
if (!self::enabled()) {
return $buttons;
}
$url = add_query_arg(
[
'action' => 'aksrt_frontend_set_modal',
'_wpnonce' => wp_create_nonce('aksrt_frontend_set'),
],
admin_url('admin-ajax.php')
);
$button = '<a class="float-btn admin-set-btn" href="javascript:;"'
. ' data-aksrt-modal="remote" data-aksrt-modal-cache="false" data-aksrt-modal-kind="admin-set"'
. ' data-aksrt-modal-url="' . esc_url($url) . '"'
. aksrt_float_btn_tooltip_attr(__('在此编辑此页面参数', 'aksrt'), (bool) $is_mobile)
. '><i class="fa fas fa-cog"></i></a>';
return $button . (string) $buttons;
}
/** 条件加载弹窗提交脚本。 */
public function enqueue_script(): void {
if (!self::enabled()) {
return;
}
$src = get_template_directory() . '/assets/js/page-admin-set.js';
wp_enqueue_script(
'aksrt-page-admin-set',
get_template_directory_uri() . '/assets/js/page-admin-set.js',
[],
file_exists($src) ? (string) filemtime($src) : null,
true
);
}
/** GET 弹窗内容(注入 aksrt-modal-runtime)。 */
public function ajax_modal(): void {
check_ajax_referer('aksrt_frontend_set', '_wpnonce');
if (!self::enabled()) {
wp_send_json_error(['msg' => __('权限不足', 'aksrt')]);
}
$object_id = (int) get_queried_object_id();
if (is_category() || is_tag() || is_tax()) {
$html = $this->term_modal_html($object_id, (string) get_queried_object()->taxonomy);
} else {
$html = $this->post_modal_html($object_id, get_post_type($object_id) ?: 'post');
}
wp_send_json_success(['html' => $html]);
}
/** POST 保存:仅接受白名单字段。 */
public function ajax_save(): void {
check_ajax_referer('aksrt_frontend_set', '_wpnonce');
if (!self::enabled()) {
wp_send_json_error(['msg' => __('权限不足', 'aksrt')]);
}
$object_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
if (!$object_id) {
wp_send_json_error(['msg' => __('页面数据出错', 'aksrt')]);
}
$type = isset($_POST['type']) ? sanitize_key((string) $_POST['type']) : '';
if ($type === 'tax') {
$taxonomy = isset($_POST['subtype']) ? sanitize_key((string) $_POST['subtype']) : '';
$this->save_term($object_id, $taxonomy);
} else {
$this->save_post($object_id);
}
wp_send_json_success(['msg' => __('保存成功,正在刷新页面', 'aksrt'), 'reload' => true]);
}
private function save_post(int $post_id): void {
$post = get_post($post_id);
if (!$post) {
wp_send_json_error(['msg' => __('未找到可用数据', 'aksrt')]);
}
if (!current_user_can('edit_post', $post_id)) {
wp_send_json_error(['msg' => __('权限不足', 'aksrt')]);
}
$postarr = ['ID' => $post_id];
if (isset($_POST['post_title'])) {
$title = sanitize_text_field(wp_unslash((string) $_POST['post_title']));
if ($title !== '') {
$postarr['post_title'] = $title;
}
}
if (isset($_POST['comments_open'])) {
$postarr['comment_status'] = !empty($_POST['comments_open']) ? 'open' : 'closed';
}
if (count($postarr) > 1) {
$result = wp_update_post($postarr, true);
if (is_wp_error($result)) {
wp_send_json_error(['msg' => $result->get_error_message()]);
}
}
if (isset($_POST['post_format']) && post_type_supports($post->post_type, 'post-formats')) {
$format = sanitize_key((string) wp_unslash($_POST['post_format']));
set_post_format($post_id, $format === 'standard' ? '' : $format);
}
if ($post->post_type === 'post' && class_exists('\AKSRT\Core\PostMetaBox')) {
$layout = isset($_POST['show_layout']) ? sanitize_key((string) wp_unslash($_POST['show_layout'])) : '';
if ($layout !== '' && !in_array($layout, ['false', 'no_sidebar', 'sidebar_left', 'sidebar_right'], true)) {
$layout = 'false';
}
// 保留白名单之外的既有配置键(封面、视频等),仅覆盖弹窗提交的字段。
// 复选框未勾选时浏览器不会提交键名,必须无条件写入两个开关。
$meta = get_post_meta($post_id, \AKSRT\Core\PostMetaBox::META_KEY, true);
$meta = is_array($meta) ? $meta : [];
if ($layout !== '') {
$meta['show_layout'] = $layout;
}
if (isset($_POST['subtitle'])) {
$meta['subtitle'] = sanitize_text_field(wp_unslash((string) $_POST['subtitle']));
}
$meta['no_article-navs'] = !empty($_POST['no_article-navs']);
$meta['article_maxheight_xz'] = !empty($_POST['article_maxheight_xz']);
update_post_meta($post_id, \AKSRT\Core\PostMetaBox::META_KEY, $meta);
if (isset($_POST['views'])) {
update_post_meta($post_id, \AKSRT\Core\DomainModel::POST_VIEW_COUNT_KEY, max(0, (int) $_POST['views']));
}
if (isset($_POST['like'])) {
update_post_meta($post_id, '_aksrt_like_count', max(0, (int) $_POST['like']));
}
}
}
private function save_term(int $term_id, string $taxonomy = ''): void {
$term = $taxonomy !== '' ? get_term($term_id, $taxonomy) : get_term($term_id);
if (!$term || is_wp_error($term)) {
wp_send_json_error(['msg' => __('未找到可用数据', 'aksrt')]);
}
if (!current_user_can('manage_categories') && !current_user_can('manage_terms')) {
wp_send_json_error(['msg' => __('权限不足', 'aksrt')]);
}
$data = [];
if (isset($_POST['term_name'])) {
$name = sanitize_text_field(wp_unslash((string) $_POST['term_name']));
if ($name !== '') {
$data['name'] = $name;
}
}
if (isset($_POST['term_description'])) {
$data['description'] = sanitize_textarea_field(wp_unslash((string) $_POST['term_description']));
}
if (!$data) {
return;
}
$result = wp_update_term($term_id, (string) $term->taxonomy, $data);
if (is_wp_error($result)) {
wp_send_json_error(['msg' => $result->get_error_message()]);
}
}
private function post_modal_html(int $post_id, string $post_type): string {
$meta = class_exists('\AKSRT\Core\PostMetaBox')
? \AKSRT\Core\PostMetaBox::get_meta($post_id)
: [];
$views = (int) ($meta['views'] ?? 0);
$like = (int) ($meta['like'] ?? 0);
$is_single = is_single();
$html = '<div class="aksrt-admin-set">';
$html .= '<p class="em09 muted-3-color mb10">' . esc_html(get_the_title($post_id)) . '</p>';
$html .= $this->text_field('post_title', __('标题', 'aksrt'), (string) get_the_title($post_id));
if ($is_single) {
$html .= $this->text_field('subtitle', __('副标题', 'aksrt'), (string) ($meta['subtitle'] ?? ''));
$layout = (string) ($meta['show_layout'] ?? 'false');
$html .= '<div class="relative line-form mb10 modal-line-field"><select name="show_layout" class="line-form-input">'
. '<option value="false"' . selected($layout, 'false', false) . '>' . esc_html__('跟随主题', 'aksrt') . '</option>'
. '<option value="no_sidebar"' . selected($layout, 'no_sidebar', false) . '>' . esc_html__('无侧边栏', 'aksrt') . '</option>'
. '<option value="sidebar_left"' . selected($layout, 'sidebar_left', false) . '>' . esc_html__('侧边栏靠左', 'aksrt') . '</option>'
. '<option value="sidebar_right"' . selected($layout, 'sidebar_right', false) . '>' . esc_html__('侧边栏靠右', 'aksrt') . '</option>'
. '</select><i class="line-form-line"></i></div>';
$html .= $this->number_field('like', __('点赞数', 'aksrt'), $like);
$html .= $this->number_field('views', __('阅读数', 'aksrt'), $views);
$html .= $this->checkbox_field('no_article-navs', __('不显示目录树', 'aksrt'), !empty($meta['no_article-navs']));
$html .= $this->checkbox_field('article_maxheight_xz', __('限制文章最大高度', 'aksrt'), !empty($meta['article_maxheight_xz']));
}
$html .= '<div class="relative line-form mb10 modal-line-field"><select name="comments_open" class="line-form-input">'
. '<option value="open"' . selected(comments_open($post_id), true, false) . '>' . esc_html__('开启评论', 'aksrt') . '</option>'
. '<option value=""' . selected(comments_open($post_id), false, false) . '>' . esc_html__('关闭评论', 'aksrt') . '</option>'
. '</select><i class="line-form-line"></i></div>';
$html .= $this->hidden_fields('post', $post_id, $post_type);
$html .= '<div class="flex jsb mt10"><button type="button" class="but" data-aksrt-modal-close>' . esc_html__('取消', 'aksrt') . '</button>'
. '<button type="submit" class="but jb-blue aksrt-admin-set-submit"><i class="fa fas fa-check"></i> ' . esc_html__('确认修改', 'aksrt') . '</button></div>';
$html .= '</div>';
return $this->wrap_form($html);
}
private function term_modal_html(int $term_id, string $taxonomy): string {
$term = get_term($term_id, $taxonomy);
if (!$term || is_wp_error($term)) {
return '<div class="aksrt-admin-set"><p class="muted-3-color">' . esc_html__('未找到可用数据', 'aksrt') . '</p></div>';
}
$html = '<div class="aksrt-admin-set">';
$html .= '<p class="em09 muted-3-color mb10">' . esc_html($term->name) . '</p>';
$html .= $this->text_field('term_name', __('名称', 'aksrt'), (string) $term->name);
$html .= $this->text_field('term_description', __('描述', 'aksrt'), (string) $term->description);
$html .= $this->hidden_fields('tax', $term_id, $taxonomy);
$html .= '<div class="flex jsb mt10"><button type="button" class="but" data-aksrt-modal-close>' . esc_html__('取消', 'aksrt') . '</button>'
. '<button type="submit" class="but jb-blue aksrt-admin-set-submit"><i class="fa fas fa-check"></i> ' . esc_html__('确认修改', 'aksrt') . '</button></div>';
$html .= '</div>';
return $this->wrap_form($html);
}
private function wrap_form(string $inner): string {
return '<form class="aksrt-admin-set-form">'
. '<input type="hidden" name="action" value="aksrt_frontend_set_save">'
. wp_nonce_field('aksrt_frontend_set', '_wpnonce', true, false)
. $inner
. '</form>';
}
private function hidden_fields(string $type, int $id, string $subtype): string {
return '<input type="hidden" name="type" value="' . esc_attr($type) . '">'
. '<input type="hidden" name="subtype" value="' . esc_attr($subtype) . '">'
. '<input type="hidden" name="id" value="' . esc_attr((string) $id) . '">';
}
private function text_field(string $name, string $label, string $value): string {
return '<div class="relative line-form mb10 modal-line-field"><input type="text" name="' . esc_attr($name) . '" value="' . esc_attr($value) . '" class="line-form-input">'
. '<div class="scale-placeholder">' . esc_html($label) . '</div><i class="line-form-line"></i></div>';
}
private function number_field(string $name, string $label, int $value): string {
return '<div class="relative line-form mb10 modal-line-field"><input type="number" min="0" step="1" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '" class="line-form-input">'
. '<div class="scale-placeholder">' . esc_html($label) . '</div><i class="line-form-line"></i></div>';
}
private function checkbox_field(string $name, string $label, bool $checked): string {
return '<div class="mb10 form-switch-label"><label><input type="checkbox" name="' . esc_attr($name) . '" value="1"' . checked($checked, true, false) . '> ' . esc_html($label) . '</label></div>';
}
}
+49
View File
@@ -189,6 +189,55 @@ class ExtensionRuntime {
];
}
/** 获取系统工具设置(WordPress 优化、上传优化、编辑器与显示)。 */
public static function get_system_tools_settings(): array {
$settings = Settings::get_all();
$switch = static function (string $key, bool $default) use ($settings): bool {
return !isset($settings[$key]) ? $default : !empty($settings[$key]);
};
$rename_type = sanitize_key((string) ($settings['newfilename_type'] ?? 'time'));
if (!in_array($rename_type, ['random', 'time'], true)) {
$rename_type = 'time';
}
// Only fill defaults when the key was never saved — empty array means "none".
$toolbar_scenes = ['pc_s', 'm_s'];
if (!array_key_exists('mce_float_toolbar', $settings)) {
$float_toolbar = $toolbar_scenes;
} else {
$float_toolbar = [];
foreach ((array) ($settings['mce_float_toolbar'] ?? []) as $scene) {
$scene = sanitize_key((string) $scene);
if (in_array($scene, $toolbar_scenes, true) && !in_array($scene, $float_toolbar, true)) {
$float_toolbar[] = $scene;
}
}
}
return [
'hide_admin_bar' => $switch('hide_admin_bar', true),
'remove_emoji' => $switch('remove_emoji', true),
'remove_open_sans' => $switch('remove_open_sans', true),
'remove_more_wp_head' => $switch('remove_more_wp_head', true),
'remove_dashboard_widgets' => $switch('remove_dashboard_widgets', true),
'display_wp_update' => $switch('display_wp_update', true),
'split_upload_s' => $switch('split_upload_s', true),
'split_minimum_size' => max(4, min(100, (int) ($settings['split_minimum_size'] ?? 20))),
'newfilename' => $switch('newfilename', false),
'newfilename_type' => $rename_type,
'disabled_large_thumbnail' => $switch('disabled_large_thumbnail', true),
'admin_user_del_fields' => $switch('admin_user_del_fields', true),
'admin_frontend_set' => $switch('admin_frontend_set', true),
'close_gutenberg' => $switch('close_gutenberg', false),
'disabled_autoembed' => $switch('disabled_autoembed', true),
'mce_float_toolbar' => $float_toolbar,
'time_ago_s' => $switch('time_ago_s', true),
'time_format' => (string) ($settings['time_format'] ?? 'n月j日 H:i'),
];
}
/** 获取短信接口设置。 */
public static function get_sms_settings(): array {
$settings = Settings::get_all();
+10
View File
@@ -80,6 +80,11 @@ if (file_exists($js)) {
$image_upload_multiple = class_exists('\AKSRT\Attachment\UploadLimits')
? \AKSRT\Attachment\UploadLimits::image_batch_limit()
: 6;
$system_tools = class_exists('\AKSRT\Settings\ExtensionRuntime')
? \AKSRT\Settings\ExtensionRuntime::get_system_tools_settings()
: [];
$float_scenes = is_array($system_tools['mce_float_toolbar'] ?? null) ? $system_tools['mce_float_toolbar'] : ['pc_s', 'm_s'];
$float_toolbar = wp_is_mobile() ? in_array('m_s', $float_scenes, true) : in_array('pc_s', $float_scenes, true);
wp_localize_script('aksrt-front-submit-editor', 'aksrtSubmitData', [
'ajaxUrl' => admin_url('admin-ajax.php'),
@@ -95,6 +100,10 @@ if (file_exists($js)) {
'file' => ['url' => esc_url_raw(rest_url('aksrt/v1/front-submit/media/file'))],
],
'splitUpload' => [
'enabled' => !empty($system_tools['split_upload_s']),
'minSize' => (int) ($system_tools['split_minimum_size'] ?? 20),
],
'imageUploadMultiple' => $image_upload_multiple,
'ai' => [
@@ -109,6 +118,7 @@ if (file_exists($js)) {
'canUploadFile' => !empty($ctx['can_upload_file']),
'canHide' => !empty($ctx['can_hide']),
'canPay' => !empty($ctx['can_pay']),
'floatToolbar' => $float_toolbar,
],
'uploads' => [
'image' => !empty($ctx['can_upload_img']) ? [
@@ -58,6 +58,7 @@ namespace {
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsAudit.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsPush.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSecurity.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSystem.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensions.php';
}
@@ -0,0 +1,167 @@
<?php
/** Split upload (分片上传) runtime contract harness: chunk params, part naming, upload/status/merge protocol. */
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
define('HOUR_IN_SECONDS', 3600);
define('WP_CONTENT_DIR', sys_get_temp_dir() . '/aksrt-split-test-' . getmypid());
function sanitize_file_name(string $name): string { return preg_replace('/[^A-Za-z0-9._\-]/', '', $name) ?? ''; }
function __(string $text, string $domain = ''): string { return $text; }
function sanitize_key(string $key): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($key)) ?? ''; }
function get_current_blog_id(): int { return 1; }
function get_current_user_id(): int { return 7; }
function apply_filters(string $tag, $value) { return $value; }
function wp_mkdir_p(string $dir): bool { return is_dir($dir) || mkdir($dir, 0777, true); }
function is_wp_error($thing): bool { return $thing instanceof \WP_Error; }
function size_format(int $bytes): string { return $bytes . 'B'; }
function get_allowed_mime_types(): array { return ['mp4' => 'video/mp4']; }
function wp_check_filetype_and_ext(string $file, string $name, array $mimes = []): array {
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return ['ext' => $ext, 'type' => $mimes[$ext] ?? ''];
}
class WP_Error {
private array $errors;
public function __construct($code = '', $message = '') { $this->errors = [$code => $message]; }
public function get_error_message(): string { return (string) reset($this->errors); }
public function get_error_code() { return (string) array_key_first($this->errors); }
}
class WP_REST_Server {
const READABLE = 'GET';
}
class WP_REST_Response {
private array $data;
private int $status;
public function __construct($data = [], int $status = 200) { $this->data = $data; $this->status = $status; }
public function get_data(): array { return $this->data; }
public function get_status(): int { return $this->status; }
}
class WP_REST_Request {
}
class FakeRestRequest extends \WP_REST_Request {
private array $params;
private array $files;
public function __construct(array $params = [], array $files = []) {
$this->params = $params;
$this->files = $files;
}
public function get_param(string $key) { return $this->params[$key] ?? null; }
public function get_file_params(): array { return $this->files; }
}
function split_upload_check(bool $condition, string $message): void {
if (!$condition) throw new \RuntimeException($message);
}
require dirname(__DIR__, 2) . '/src/Core/FrontSubmitChunk.php';
}
namespace AKSRT\Core {
final class FakeFrontSubmit {
use \AKSRT\Core\FrontSubmitChunk;
public static int $limit_bytes = 10 * 1048576;
public static array $persisted = [];
public static function upload_limit_bytes(string $kind): int {
return self::$limit_bytes;
}
/** Stand-in for FrontSubmit::persist_uploaded_file(): records and echoes merged content. */
public function persist_uploaded_file(string $kind, array $file, bool $sideload = false) {
self::$persisted[] = ['kind' => $kind, 'file' => $file, 'sideload' => $sideload];
return 'persisted:' . (string) file_get_contents((string) $file['tmp_name']);
}
}
}
namespace {
use AKSRT\Core\FakeFrontSubmit;
$submit = new FakeFrontSubmit();
$temp_dir = WP_CONTENT_DIR . '/aksrt-temp';
// 1) Chunk parameter validation.
$err = $submit->rest_upload_chunk(new FakeRestRequest(['name' => '', 'chunks' => 3, 'chunk' => 0, 'size' => 10]));
split_upload_check($err instanceof \WP_Error, 'empty name should be rejected');
$err = $submit->rest_upload_chunk(new FakeRestRequest(['name' => 'a.mp4', 'chunks' => 0, 'chunk' => 0, 'size' => 10]));
split_upload_check($err instanceof \WP_Error, 'zero chunk count should be rejected');
$err = $submit->rest_upload_chunk(new FakeRestRequest(['name' => 'a.mp4', 'chunks' => 3, 'chunk' => 5, 'size' => 10]));
split_upload_check($err instanceof \WP_Error, 'out-of-range chunk index should be rejected');
// 2) Chunk upload lands per-index .part files, oversized total is refused.
$make_file = static function (string $content): string {
$path = tempnam(sys_get_temp_dir(), 'chunk');
file_put_contents($path, $content);
return $path;
};
$params = static fn (int $chunk, int $chunks, int $size): array => [
'kind' => 'video', 'chunk' => $chunk, 'chunks' => $chunks, 'name' => 'movie.mp4', 'size' => $size,
];
$response = $submit->rest_upload_chunk(new FakeRestRequest($params(0, 3, 6), ['file' => ['tmp_name' => $make_file('AAA'), 'error' => 0]]));
split_upload_check($response instanceof \WP_REST_Response, 'first chunk should upload');
split_upload_check($response->get_data()['uploaded_size'] === 3, 'uploaded_size should count the stored chunk');
$submit->rest_upload_chunk(new FakeRestRequest($params(2, 3, 6), ['file' => ['tmp_name' => $make_file('CCC'), 'error' => 0]]));
$parts = glob($temp_dir . '/*.part');
split_upload_check(count($parts) === 2, 'two part files should exist');
foreach ($parts as $part) {
split_upload_check(strpos(basename($part), '1-') === 0, 'part name should be blog-id prefixed');
}
// 3) Status reports the uploaded indexes (resume contract).
$response = $submit->rest_upload_chunk_status(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3]));
split_upload_check($response instanceof \WP_REST_Response, 'status should respond');
split_upload_check($response->get_data()['uploaded'] === [0, 2], 'status should list uploaded chunk indexes');
// 4) Merge refuses when a chunk is missing.
$err = $submit->rest_upload_chunk_merge(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3, 'size' => 9]));
split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_chunk_missing', 'merge should refuse with missing chunks');
// 5) Merge with a size mismatch is refused.
$submit->rest_upload_chunk(new FakeRestRequest($params(1, 3, 6), ['file' => ['tmp_name' => $make_file('BBB'), 'error' => 0]]));
$err = $submit->rest_upload_chunk_merge(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3, 'size' => 999]));
split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_chunk_size_mismatch', 'size mismatch should refuse the merge');
// 6) Happy-path merge concatenates in order, flags sideload, and cleans up.
$response = $submit->rest_upload_chunk_merge(new FakeRestRequest(['kind' => 'video', 'name' => 'movie.mp4', 'chunks' => 3, 'size' => 9]));
split_upload_check(is_string($response) && $response === 'persisted:AAABBBCCC', 'merge should concatenate chunks in order');
split_upload_check(count(FakeFrontSubmit::$persisted) === 1, 'merge should persist exactly once');
$call = FakeFrontSubmit::$persisted[0];
split_upload_check($call['kind'] === 'video' && $call['sideload'] === true, 'merge should persist as a sideloaded video');
split_upload_check($call['file']['name'] === 'movie.mp4' && $call['file']['size'] === 9, 'merge should rebuild the $_FILES-style payload');
split_upload_check(glob($temp_dir . '/*.part') === [], 'merge should clean up all parts');
// 7) Per-user keying: a different file name never sees another name's parts.
$response = $submit->rest_upload_chunk_status(new FakeRestRequest(['kind' => 'video', 'name' => 'other.mp4', 'chunks' => 3]));
split_upload_check($response instanceof \WP_REST_Response && $response->get_data()['uploaded'] === [], 'a different name should start with no uploaded chunks');
// 8) Chunk size cap refuses oversized chunk blobs.
$big = $make_file(str_repeat('x', 3 * 1048576));
$err = $submit->rest_upload_chunk(new FakeRestRequest($params(0, 1, 3 * 1048576), ['file' => ['tmp_name' => $big, 'error' => 0]]));
split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_chunk_invalid_size', 'oversized chunk blobs should be refused');
// 9) Kind size limit caps cumulative uploads.
FakeFrontSubmit::$limit_bytes = 4;
$err = $submit->rest_upload_chunk(new FakeRestRequest($params(0, 2, 9), ['file' => ['tmp_name' => $make_file('AAAAA'), 'error' => 0]]));
split_upload_check($err instanceof \WP_Error && $err->get_error_code() === 'aksrt_upload_too_large', 'cumulative size over the kind limit should be refused');
// Cleanup.
FakeFrontSubmit::$persisted = [];
foreach ((array) glob(WP_CONTENT_DIR . '/*.part') as $leftover) {
@unlink((string) $leftover);
}
@unlink(WP_CONTENT_DIR . '/aksrt-temp/index.php');
@rmdir(WP_CONTENT_DIR . '/aksrt-temp');
@rmdir(WP_CONTENT_DIR);
echo "Split upload harness passed\n";
}
@@ -0,0 +1,229 @@
'use strict';
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const SYSTEM_FIELDS = [
'hide_admin_bar', 'remove_emoji', 'remove_open_sans', 'remove_more_wp_head',
'remove_dashboard_widgets', 'display_wp_update',
'split_upload_s', 'split_minimum_size', 'newfilename', 'newfilename_type',
'disabled_large_thumbnail',
'admin_user_del_fields', 'admin_frontend_set', 'close_gutenberg', 'disabled_autoembed',
'mce_float_toolbar', 'time_ago_s', 'time_format',
];
test('system tools schema registers the 系统工具 page under 扩展&增强', () => {
const schema = read('src/Admin/Schema/SchemaExtensionsSystem.php');
const registry = read('src/Admin/Schema/SchemaExtensions.php');
const clientMirror = read('assets/admin/src/schema/sections/extensions/system.js');
const clientIndex = read('assets/admin/src/schema/sections/extensions/index.js');
const nav = read('assets/admin/src/constants/sections/extensions.js');
const sectionDefaults = read('assets/admin/src/constants/sections/index.js');
assert.match(registry, /'system'\s+=> SchemaExtensionsSystem::build\(\)/);
assert.match(schema, /'key'\s+=> 'system'/);
assert.match(schema, /'title'\s+=> '系统工具'/);
for (const key of SYSTEM_FIELDS) {
assert.match(schema, new RegExp(`'${key}'`), `missing schema field ${key}`);
assert.match(clientMirror, new RegExp(`${key}`), `missing client mirror field ${key}`);
}
// Conditional fields mirror the reference theme's dependency rules.
assert.match(schema, /'newfilename_type'[^[]*'dependsOn'\s+=> \['key' => 'newfilename', 'value' => true\]/s);
assert.match(schema, /'split_minimum_size'[^[]*'dependsOn'\s+=> \['key' => 'split_upload_s', 'value' => true\]/s);
assert.match(schema, /'time_format'[^[]*'dependsOn'\s+=> \['key' => 'time_ago_s', 'operator' => 'falsy'\]/s);
assert.match(clientMirror, /split_minimum_size[\s\S]*dependsOn:\s*\{\s*key:\s*'split_upload_s',\s*value:\s*true\s*\}/);
assert.match(clientMirror, /time_format[\s\S]*dependsOn:\s*\{\s*key:\s*'time_ago_s',\s*operator:\s*'falsy'\s*\}/);
assert.match(clientIndex, /import \{ systemSchema \} from '\.\/system\.js'/);
assert.match(clientIndex, /system: systemSchema/);
assert.match(nav, /key: 'extensions-system'/);
assert.match(nav, /route: '\/extensions\/system'/);
assert.match(nav, /section: 'system'/);
// 系统工具 is the first child of 扩展&增强 (reference theme order) and the default landing page.
assert.ok(nav.indexOf('extensions-system') < nav.indexOf('extensions-ip'), '系统工具 should precede IP归属地');
assert.match(sectionDefaults, /extensions:\s*'extensions-system'/);
});
test('system tools settings are consumed at runtime and initialized on boot', () => {
const runtime = read('src/Settings/ExtensionRuntime.php');
const bootstrap = read('functions.php');
assert.match(runtime, /public static function get_system_tools_settings\(\): array/);
assert.match(runtime, /'newfilename_type'\s+=> \$rename_type/);
assert.match(runtime, /'split_minimum_size'\s+=> max\(4, min\(100, \(int\) \(\$settings\['split_minimum_size'\] \?\? 20\)\)\)/);
assert.match(bootstrap, /\\AKSRT\\Extensions\\SystemTools::get_instance\(\)->init\(\)/);
assert.match(bootstrap, /\\AKSRT\\Frontend\\PageAdminSet::get_instance\(\)->init\(\)/);
});
test('system tools switches wire the reference theme hook suite', () => {
const tools = read('src/Extensions/SystemTools.php');
assert.match(tools, /!empty\(\$settings\['hide_admin_bar'\]\) \|\| !is_super_admin\(\)/);
assert.match(tools, /remove_action\('wp_head', 'print_emoji_detection_script', 7\)/);
assert.match(tools, /remove_filter\('wp_mail', 'wp_staticize_emoji_for_email'\)/);
assert.match(tools, /add_action\('init', \[\$this, 'remove_open_sans'\]\)/);
assert.match(tools, /wp_deregister_style\('open-sans'\)/);
assert.match(tools, /add_action\('after_setup_theme', \[\$this, 'remove_more_wp_head'\]\)/);
assert.match(tools, /remove_action\('wp_head', 'wp_generator'\)/);
assert.match(tools, /add_action\('do_meta_boxes', \[\$this, 'remove_dashboard_widgets'\], 10, 3\)/);
assert.match(tools, /unset\(\$wp_meta_boxes\['dashboard'\]\['normal'\]\['high'\]\['dashboard_php_nag'\]\)/);
assert.match(tools, /add_filter\('site_status_tests', \[\$this, 'filter_site_status_tests'\]\)/);
assert.match(tools, /add_filter\('pre_http_request', \[\$this, 'block_site_health_requests'\], 10, 3\)/);
assert.match(tools, /'api\.wordpress\.org\/core\/serve-happy\/'/);
assert.match(tools, /remove_action\('admin_init', '_maybe_update_core'\)/);
assert.match(tools, /add_filter\('automatic_updater_disabled', '__return_true'\)/);
assert.match(tools, /remove_action\('wp_version_check', 'wp_version_check'\)/);
assert.match(tools, /add_action\('admin_menu', \[\$this, 'hide_update_notices'\]\)/);
assert.match(tools, /remove_action\('admin_notices', 'update_nag', 3\)/);
assert.match(tools, /remove_image_size\('1536x1536'\)/);
assert.match(tools, /add_filter\('intermediate_image_sizes', static function \(\$sizes\)/);
assert.match(tools, /add_filter\('use_block_editor_for_post', '__return_false'\)/);
assert.match(tools, /remove_filter\('the_content', \[\$GLOBALS\['wp_embed'\], 'autoembed'\], 8\)/);
assert.match(tools, /add_action\('show_user_profile', \[\$this, 'hide_user_profile_fields'\]\)/);
assert.match(tools, /add_action\('edit_user_profile', \[\$this, 'hide_user_profile_fields'\]\)/);
});
test('upload rename settings are canonical to 系统工具 and consumed by the upload pipeline', () => {
const permissionsSchema = read('src/Admin/Schema/SchemaPermissions.php');
const permissionsMirror = read('assets/admin/src/schema/sections/permissions/index.js');
const systemSchema = read('src/Admin/Schema/SchemaExtensionsSystem.php');
const uploadLimits = read('src/Attachment/UploadLimits.php');
assert.doesNotMatch(permissionsSchema, /newfilename/);
assert.doesNotMatch(permissionsMirror, /newfilename/);
assert.match(systemSchema, /'newfilename'/);
assert.match(uploadLimits, /add_filter\('wp_handle_upload_prefilter', \[\$this, 'rename_upload_file'\]/);
assert.match(uploadLimits, /\$settings\['newfilename_type'\] \?\? 'time'\) === 'random'/);
});
test('split upload ships a full chunk protocol: routes, merge pipeline, and client uploader', () => {
const chunk = read('src/Core/FrontSubmitChunk.php');
const frontSubmit = read('src/Core/FrontSubmit.php');
const editorJs = read('assets/js/front-submit-editor.js');
const submitPage = read('templates/pages/page-submit.php');
const schema = read('src/Admin/Schema/SchemaExtensionsSystem.php');
// The submit class consumes the chunk trait and registers its routes.
assert.match(frontSubmit, /use FrontSubmitChunk;/);
assert.match(frontSubmit, /\$this->register_chunk_routes\(\)/);
assert.match(frontSubmit, /public function persist_uploaded_file\(string \$kind, array \$file, bool \$sideload = false\)/);
// Backend mirrors the reference theme's user_split_upload protocol.
assert.match(chunk, /\/front-submit\/upload-chunk\/\(\?P<kind>image\|video\|resource\)/);
assert.match(chunk, /\/front-submit\/upload-merge\(\?P<kind>image\|video\|resource\)|\/front-submit\/upload-merge\/\(\?P<kind>image\|video\|resource\)/);
assert.match(chunk, /\/front-submit\/upload-status\/\(\?P<kind>image\|video\|resource\)/);
assert.match(chunk, /permission_callback' => \[\$this, 'can_upload_attachment'\]/);
assert.match(chunk, /const CHUNK_SIZE_BYTES = 2 \* 1048576;/);
assert.match(chunk, /md5\(get_current_blog_id\(\) \. '\|' \. get_current_user_id\(\) \. '\|' \. \$name\)/);
assert.match(chunk, /\$this->persist_uploaded_file\(\$params\['kind'\], \[/);
assert.match(chunk, /apply_filters\('aksrt_temp_dir', WP_CONTENT_DIR \. '\/aksrt-temp'\)/);
assert.match(chunk, /\$this->chunk_temp_cleanup\(\$temp_dir\)/);
// Whole-file upload and chunk merge share one persistence pipeline; merged
// files are not HTTP uploads, so the pipeline must use the sideload action.
assert.match(frontSubmit, /return \$this->persist_uploaded_file\(\$kind, \$file, false\);/);
assert.match(frontSubmit, /'action' => 'wp_handle_sideload'/);
assert.match(frontSubmit, /media_handle_upload\('aksrt_front_submit_file', 0, \[\], \$overrides\)/);
// Client uploader picks the chunked path past the threshold and resumes from status.
assert.match(editorJs, /function uploadInChunks\(file, type\)/);
assert.match(editorJs, /splitUploadConfig\(\)/);
assert.match(editorJs, /file\.size > split\.minSize \* 1048576/);
assert.match(editorJs, /chunkUrl\('upload-status'\)/);
assert.match(editorJs, /chunkUrl\('upload-chunk'\)/);
assert.match(editorJs, /chunkUrl\('upload-merge'\)/);
assert.match(editorJs, /'\/front-submit\/' \+ action \+ '\/' \+ uploadKey/);
// Config flows from the settings page into aksrtSubmitData.
assert.match(submitPage, /'splitUpload' => \[/);
assert.match(submitPage, /'enabled' => !empty\(\$system_tools\['split_upload_s'\]\)/);
assert.match(submitPage, /'minSize' => \(int\) \(\$system_tools\['split_minimum_size'\] \?\? 20\)/);
assert.match(schema, /'split_upload_s'/);
assert.match(schema, /'split_minimum_size'/);
// The legacy dead config keys stay out of the shared script payload.
assert.doesNotMatch(read('src/Core/ScriptsPart1c.php'), /is_split_upload|split_minimum_size/);
});
test('frontend page edit ships gate, float button, modal, and whitelisted save', () => {
const pageAdminSet = read('src/Frontend/PageAdminSet.php');
const pageAdminJs = read('assets/js/page-admin-set.js');
const floatButtons = read('inc/helpers/aksrt-float-buttons.php');
const bootstrap = read('functions.php');
assert.match(bootstrap, /\\AKSRT\\Frontend\\PageAdminSet::get_instance\(\)->init\(\)/);
assert.match(pageAdminSet, /public static function enabled\(\): bool/);
assert.match(pageAdminSet, /empty\(\$settings\['admin_frontend_set'\]/);
assert.match(pageAdminSet, /current_user_can\('manage_options'\)/);
assert.match(pageAdminSet, /is_page_template\('templates\/pages\/page-submit\.php'\)/);
assert.match(pageAdminSet, /is_page_template\('templates\/pages\/page-posts-navs\.php'\)/);
// Float bar gains the same extension point the reference theme hooks into.
assert.match(floatButtons, /apply_filters\('aksrt_float_right_buttons', \$btn, \$is_mobile\)/);
assert.match(pageAdminSet, /add_filter\('aksrt_float_right_buttons', \[\$this, 'float_button'\], 10, 2\)/);
assert.match(pageAdminSet, /data-aksrt-modal="remote"/);
assert.match(pageAdminSet, /check_ajax_referer\('aksrt_frontend_set', '_wpnonce'\)/);
// Modal + save endpoints; save is whitelisted and preserves other meta keys.
assert.match(pageAdminSet, /add_action\('wp_ajax_aksrt_frontend_set_modal', \[\$this, 'ajax_modal'\]\)/);
assert.match(pageAdminSet, /add_action\('wp_ajax_aksrt_frontend_set_save', \[\$this, 'ajax_save'\]\)/);
assert.match(pageAdminSet, /\\AKSRT\\Core\\PostMetaBox::META_KEY/);
assert.match(pageAdminSet, /\\AKSRT\\Core\\DomainModel::POST_VIEW_COUNT_KEY/);
assert.match(pageAdminSet, /update_post_meta\(\$post_id, '_aksrt_like_count', max\(0, \(int\) \$_POST\['like'\]\)\)/);
assert.match(pageAdminSet, /wp_update_term\(\$term_id, \(string\) \$term->taxonomy, \$data\)/);
assert.match(pageAdminSet, /set_post_format\(\$post_id, \$format === 'standard' \? '' : \$format\)/);
// Client submits through the shared modal runtime contract; the ajax action
// comes from the form's hidden field that the modal endpoint embeds.
assert.match(pageAdminJs, /form\.aksrt-admin-set-form/);
assert.match(pageAdminJs, /input\[name="action"\]/);
assert.match(pageAdminJs, /window\.location\.reload/);
assert.match(pageAdminSet, /'html' => \$html/);
});
test('float toolbar config reaches the tinymce plugin with reference-context buttons', () => {
const editorJs = read('assets/js/front-submit-editor.js');
const submitPage = read('templates/pages/page-submit.php');
const editorTemplate = read('templates/parts/submit/editor-main.php');
const schema = read('src/Admin/Schema/SchemaExtensionsSystem.php');
assert.match(submitPage, /\$float_toolbar = wp_is_mobile\(\) \? in_array\('m_s', \$float_scenes, true\) : in_array\('pc_s', \$float_scenes, true\)/);
assert.match(submitPage, /'floatToolbar'\s+=> \$float_toolbar/);
assert.match(editorJs, /if \(editorCfg\.floatToolbar\) \{\s*\n\s*registerFloatToolbar\(editor\);/);
assert.match(editorJs, /editor\.on\('wptoolbar', function \(event\)/);
assert.match(editorJs, /editor\.wp\._createToolbar\(bars\)/);
assert.match(editorJs, /editor\.addButton\('aksrt_h2'/);
assert.match(editorJs, /editor\.addButton\('aksrt_h3'/);
assert.match(editorJs, /editor\.addButton\('aksrt_remove'/);
assert.match(editorJs, /function floatToolbarButtons\(editor, event\)/);
assert.match(schema, /'mce_float_toolbar', 'label' => '经典编辑器增强浮动工具栏', 'type' => 'checkbox'/);
// The editor template still wires the plugin into wp_editor.
assert.match(editorTemplate, /aksrt_editor_tools/);
});
test('countdown display switch gates aksrt_time_ago with the custom format fallback', () => {
const helper = read('inc/helpers/template-tags/search-meta.php');
const coreHooks = read('inc/core-hooks.php');
assert.match(helper, /empty\(\$system\['time_ago_s'\]\) && \(\$system\['time_format'\] \?\? ''\) !== ''/);
assert.match(helper, /return date\(\$system\['time_format'\], \$time\)/);
// Admin bar gating moved out of the unconditional core hook into SystemTools.
assert.doesNotMatch(coreHooks, /add_filter\('show_admin_bar', '__return_false'\)/);
});
test('system tools runtime contract harness passes', () => {
const harness = path.join(root, 'tests', 'widget-system', 'system-tools-harness.php');
const output = execFileSync('php', [harness], { encoding: 'utf8' });
assert.match(output, /System tools harness passed/);
});
test('split upload runtime contract harness passes', () => {
const harness = path.join(root, 'tests', 'widget-system', 'split-upload-harness.php');
const output = execFileSync('php', [harness], { encoding: 'utf8' });
assert.match(output, /Split upload harness passed/);
});
@@ -0,0 +1,268 @@
<?php
/** System tools (系统工具) runtime contract harness: settings defaults, hook wiring, schema, time-format gate. */
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
$GLOBALS['aksrt_system_settings'] = [];
$GLOBALS['aksrt_system_added'] = [];
$GLOBALS['aksrt_system_removed'] = [];
function __(string $text, string $domain = ''): string { return $text; }
function sanitize_key(string $key): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($key)) ?? ''; }
function is_super_admin(): bool { return (bool) $GLOBALS['aksrt_system_is_super_admin']; }
function current_time(string $type): string { return date('Y-m-d H:i:s'); }
function add_filter(string $hook, $callback, int $priority = 10, int $args = 1): void {
$GLOBALS['aksrt_system_added'][] = ['filter', $hook, $callback, $priority];
}
function add_action(string $hook, $callback, int $priority = 10, int $args = 1): void {
$GLOBALS['aksrt_system_added'][] = ['action', $hook, $callback, $priority];
}
function remove_filter(string $hook, $callback, int $priority = 10): void {
$GLOBALS['aksrt_system_removed'][] = ['filter', $hook, $callback, $priority];
}
function remove_action(string $hook, $callback, int $priority = 10): void {
$GLOBALS['aksrt_system_removed'][] = ['action', $hook, $callback, $priority];
}
function wp_deregister_style(string $handle): void {}
function wp_register_style(string $handle, $src): void {}
function wp_enqueue_style(string $handle, $src = ''): void {}
function remove_image_size(string $name): void {
$GLOBALS['aksrt_system_added'][] = ['remove_image_size', $name, null, 10];
}
function system_tools_check(bool $condition, string $message): void {
if (!$condition) throw new \RuntimeException($message);
}
function system_tools_added(string $type, string $hook): bool {
foreach ($GLOBALS['aksrt_system_added'] as $row) {
if ($row[0] === $type && $row[1] === $hook) return true;
}
return false;
}
function system_tools_removed(string $type, string $hook, string $needle = ''): bool {
foreach ($GLOBALS['aksrt_system_removed'] as $row) {
if ($row[0] !== $type || $row[1] !== $hook) continue;
if ($needle === '') return true;
if (is_array($row[2])) {
$parts = [];
foreach ($row[2] as $part) {
$parts[] = is_object($part) ? get_class($part) : (string) $part;
}
$callback = implode('|', $parts);
} elseif (is_object($row[2])) {
$callback = get_class($row[2]);
} else {
$callback = (string) $row[2];
}
if (strpos($callback, $needle) !== false) return true;
}
return false;
}
function system_tools_reset(): void {
$GLOBALS['aksrt_system_added'] = [];
$GLOBALS['aksrt_system_removed'] = [];
}
require dirname(__DIR__, 2) . '/src/Settings/ExtensionRuntime.php';
require dirname(__DIR__, 2) . '/src/Extensions/SystemTools.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsIP.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsAudit.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsPush.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSecurity.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSystem.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensions.php';
require dirname(__DIR__, 2) . '/inc/helpers/template-tags/search-meta.php';
}
namespace AKSRT\Settings {
final class Settings {
public static function get_all(): array { return $GLOBALS['aksrt_system_settings']; }
}
}
namespace AKSRT\SystemToolsHarness {
final class WpEmbedStub {
public function autoembed($content) { return $content; }
public function maybe_run_ajax_cache(): void {}
}
}
namespace {
use AKSRT\Admin\Schema\SchemaExtensions;
use AKSRT\Admin\Schema\SchemaExtensionsSystem;
use AKSRT\Extensions\SystemTools;
use AKSRT\Settings\ExtensionRuntime;
$GLOBALS['aksrt_system_is_super_admin'] = false;
$GLOBALS['wp_embed'] = new \AKSRT\SystemToolsHarness\WpEmbedStub();
// 1) Defaults match the reference theme's 系统工具 defaults.
$defaults = ExtensionRuntime::get_system_tools_settings();
$expected = [
'hide_admin_bar' => true,
'remove_emoji' => true,
'remove_open_sans' => true,
'remove_more_wp_head' => true,
'remove_dashboard_widgets' => true,
'display_wp_update' => true,
'split_upload_s' => true,
'split_minimum_size' => 20,
'newfilename' => false,
'newfilename_type' => 'time',
'disabled_large_thumbnail' => true,
'admin_user_del_fields' => true,
'admin_frontend_set' => true,
'close_gutenberg' => false,
'disabled_autoembed' => true,
'mce_float_toolbar' => ['pc_s', 'm_s'],
'time_ago_s' => true,
'time_format' => 'n月j日 H:i',
];
foreach ($expected as $key => $value) {
system_tools_check(array_key_exists($key, $defaults), "system tools setting missing: {$key}");
system_tools_check($defaults[$key] === $value, "system tools default wrong for {$key}");
}
// 2) Stored values sanitize: rename type whitelist, switch coercion, passthrough format.
$GLOBALS['aksrt_system_settings'] = [
'newfilename_type' => 'bogus',
'newfilename' => 1,
'time_ago_s' => 0,
'time_format' => 'Y/m/d H:i',
'close_gutenberg' => 'yes',
'split_minimum_size' => 500,
'mce_float_toolbar' => ['m_s', 'bogus', 'm_s'],
];
$clean = ExtensionRuntime::get_system_tools_settings();
system_tools_check($clean['newfilename_type'] === 'time', 'bogus rename type should fall back to time');
system_tools_check($clean['newfilename'] === true, 'truthy rename switch should coerce to true');
system_tools_check($clean['time_ago_s'] === false, 'falsy time_ago_s should coerce to false');
system_tools_check($clean['time_format'] === 'Y/m/d H:i', 'time format should pass through');
system_tools_check($clean['close_gutenberg'] === true, 'truthy gutenberg switch should coerce to true');
system_tools_check($clean['split_minimum_size'] === 100, 'split minimum size should clamp to the 4..100 range');
system_tools_check($clean['mce_float_toolbar'] === ['m_s'], 'float toolbar scenes should whitelist and dedupe');
// 2b) Unsaved float toolbar key keeps the both-scenes default; explicit empty stays empty.
$GLOBALS['aksrt_system_settings'] = ['hide_admin_bar' => 0];
$clean = ExtensionRuntime::get_system_tools_settings();
system_tools_check($clean['mce_float_toolbar'] === ['pc_s', 'm_s'], 'float toolbar should default to pc+mobile');
system_tools_check($clean['hide_admin_bar'] === false, 'stored falsy value should not fall back to default');
$GLOBALS['aksrt_system_settings'] = ['mce_float_toolbar' => []];
$clean = ExtensionRuntime::get_system_tools_settings();
system_tools_check($clean['mce_float_toolbar'] === [], 'explicitly empty float toolbar should stay empty');
// 3) Hook wiring with every switch enabled.
$GLOBALS['aksrt_system_settings'] = array_fill_keys(array_keys($expected), true);
$GLOBALS['aksrt_system_settings']['time_format'] = 'n月j日 H:i';
$GLOBALS['aksrt_system_is_super_admin'] = false;
system_tools_reset();
SystemTools::get_instance()->init();
system_tools_check(system_tools_added('filter', 'show_admin_bar'), 'hide_admin_bar should gate show_admin_bar');
system_tools_check(system_tools_added('action', 'init'), 'remove_open_sans should hook init');
system_tools_check(system_tools_added('action', 'after_setup_theme'), 'head cleanup and thumbnail pruning should hook after_setup_theme');
system_tools_check(system_tools_added('action', 'do_meta_boxes'), 'dashboard widget removal should hook do_meta_boxes');
system_tools_check(system_tools_added('filter', 'site_status_tests'), 'site status tests should be filtered');
system_tools_check(system_tools_added('filter', 'pre_http_request'), 'site health http requests should be blocked');
system_tools_check(system_tools_added('action', 'admin_menu'), 'update notices should hook admin_menu');
system_tools_check(system_tools_added('filter', 'automatic_updater_disabled'), 'automatic updates should be disabled');
system_tools_check(system_tools_added('filter', 'use_block_editor_for_post'), 'close_gutenberg should gate the block editor');
system_tools_check(system_tools_added('action', 'show_user_profile'), 'user profile cleanup should hook show_user_profile');
system_tools_check(system_tools_added('action', 'edit_user_profile'), 'user profile cleanup should hook edit_user_profile');
system_tools_check(system_tools_removed('action', 'admin_print_scripts', 'print_emoji_detection_script'), 'emoji scripts should be removed');
system_tools_check(system_tools_removed('filter', 'the_content', 'autoembed'), 'auto embed should be removed from the_content');
system_tools_check(system_tools_removed('action', 'admin_init', '_maybe_update_core'), 'core update checks should be removed');
system_tools_check(system_tools_removed('action', 'init', 'wp_schedule_update_checks'), 'update cron schedule should be removed');
// 4) hide_admin_bar off keeps the admin bar for super admins.
$GLOBALS['aksrt_system_settings']['hide_admin_bar'] = false;
$GLOBALS['aksrt_system_is_super_admin'] = true;
system_tools_reset();
SystemTools::get_instance()->init();
system_tools_check(!system_tools_added('filter', 'show_admin_bar'), 'super admin should keep the admin bar when hide_admin_bar is off');
// 5) Dashboard widget removal edits the dashboard meta boxes only in the normal context.
$tools = SystemTools::get_instance();
$GLOBALS['wp_meta_boxes'] = [
'dashboard' => [
'normal' => [
'high' => ['dashboard_php_nag' => ['id' => 'dashboard_php_nag']],
'core' => ['dashboard_site_health' => ['id' => 'dashboard_site_health']],
],
'side' => [
'core' => ['dashboard_primary' => ['id' => 'dashboard_primary']],
],
],
];
$tools->remove_dashboard_widgets('dashboard', 'normal');
system_tools_check(!isset($GLOBALS['wp_meta_boxes']['dashboard']['normal']['high']['dashboard_php_nag']), 'php nag widget should be removed');
system_tools_check(!isset($GLOBALS['wp_meta_boxes']['dashboard']['normal']['core']['dashboard_site_health']), 'site health widget should be removed');
system_tools_check(!isset($GLOBALS['wp_meta_boxes']['dashboard']['side']['core']['dashboard_primary']), 'primary widget should be removed');
$GLOBALS['wp_meta_boxes']['dashboard']['side']['core']['dashboard_primary'] = ['id' => 'dashboard_primary'];
$tools->remove_dashboard_widgets('dashboard', 'side');
system_tools_check(isset($GLOBALS['wp_meta_boxes']['dashboard']['side']['core']['dashboard_primary']), 'non-normal context should be ignored');
// 6) Site status tests drop page cache and php version probes.
$tests = $tools->filter_site_status_tests([
'async' => ['page_cache' => [], 'dilation' => []],
'direct' => ['php_version' => [], 'updates' => []],
]);
system_tools_check(!isset($tests['async']['page_cache']) && isset($tests['async']['dilation']), 'page_cache test should be dropped');
system_tools_check(!isset($tests['direct']['php_version']) && isset($tests['direct']['updates']), 'php_version test should be dropped');
// 7) Health endpoint blocking is scoped to version/browser/credits checks.
$blocked = $tools->block_site_health_requests(null, [], 'https://api.wordpress.org/core/serve-happy/1.0/');
system_tools_check(is_array($blocked) && $blocked['body'] === '', 'serve-happy requests should be blocked');
$blocked = $tools->block_site_health_requests(null, [], 'https://api.wordpress.org/core/browse-happy/1.0/');
system_tools_check(is_array($blocked) && $blocked['body'] === '', 'browse-happy requests should be blocked');
$passed = $tools->block_site_health_requests('passthrough', [], 'https://api.wordpress.org/plugins/update-check/1.0/');
system_tools_check($passed === 'passthrough', 'update checks should pass through the blocker');
// 8) Large thumbnail pruning removes the reference theme's size list.
$GLOBALS['aksrt_system_added'] = [];
$tools->disable_large_thumbnail();
$sizes_filter = null;
foreach ($GLOBALS['aksrt_system_added'] as $row) {
if ($row[0] === 'filter' && $row[1] === 'intermediate_image_sizes') {
$sizes_filter = $row[2];
}
}
system_tools_check(is_callable($sizes_filter), 'intermediate_image_sizes filter should be registered');
system_tools_check($sizes_filter(['medium_large', '1536x1536', '2048x2048', 'thumbnail']) === ['thumbnail'], 'medium_large/1536x1536/2048x2048 should be pruned');
// 9) Schema registry exposes the 系统工具 page with all fields.
$sections = SchemaExtensions::build()['sections'];
system_tools_check(isset($sections['system']) && $sections['system']['title'] === '系统工具', 'system section should register under extensions');
$keys = [];
foreach ($sections['system']['groups'] as $group) {
foreach ($group['fields'] as $field) {
$keys[] = $field['key'];
}
}
system_tools_check(count($keys) === count(array_unique($keys)), 'system schema field keys must be unique');
foreach (array_keys($expected) as $key) {
system_tools_check(in_array($key, $keys, true), "system schema missing field {$key}");
}
$client_defaults = array_keys($expected);
system_tools_check(count(SchemaExtensionsSystem::build()['groups']) === 3, 'system schema should keep three groups');
// 10) aksrt_time_ago honors the 倒计时显示 switch and custom format.
$past = date('Y-m-d H:i:s', strtotime('-3 days'));
$GLOBALS['aksrt_system_settings'] = [];
system_tools_check(strpos(aksrt_time_ago($past), '天前') !== false, 'relative time should be used by default');
$GLOBALS['aksrt_system_settings'] = ['time_ago_s' => 0, 'time_format' => 'Y年n月j日'];
$stamp = strtotime('-3 days');
system_tools_check(aksrt_time_ago(date('Y-m-d H:i:s', $stamp)) === date('Y年n月j日', $stamp), 'custom time format should apply when countdown display is off');
$GLOBALS['aksrt_system_settings'] = ['time_ago_s' => 0, 'time_format' => ''];
system_tools_check(strpos(aksrt_time_ago($past), '天前') !== false, 'empty custom format should fall back to relative time');
$GLOBALS['aksrt_system_settings'] = ['time_ago_s' => 1];
system_tools_check(strpos(aksrt_time_ago($past), '天前') !== false, 'countdown display should stay relative when enabled');
echo "System tools harness passed\n";
}