$options
+ * @return mixed
+ */
+ private function match_option_value($value, array $options) {
+ foreach ($options as $opt) {
+ $opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt;
+ if ($opt_value === $value) {
+ return $opt_value;
+ }
+ }
+ if (is_bool($value) || !is_scalar($value)) {
+ return null;
+ }
+ $needle = (string) $value;
+ foreach ($options as $opt) {
+ $opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt;
+ if (is_bool($opt_value) || !is_scalar($opt_value)) {
+ continue;
+ }
+ if ((string) $opt_value === $needle) {
+ return $opt_value;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/Core/Color.php b/src/Core/Color.php
new file mode 100644
index 00000000..a35fe071
--- /dev/null
+++ b/src/Core/Color.php
@@ -0,0 +1,55 @@
+ 255) {
+ return '';
+ }
+ }
+ if (!isset($m[4]) || $m[4] === '') {
+ return 'rgb(' . implode(', ', $channels) . ')';
+ }
+ $alpha = rtrim(rtrim(number_format((float) $m[4], 3, '.', ''), '0'), '.');
+ return 'rgba(' . implode(', ', $channels) . ', ' . ($alpha === '' ? '0' : $alpha) . ')';
+ }
+}
diff --git a/src/Settings/DisplayPageGroups.php b/src/Settings/DisplayPageGroups.php
index 1f7ce5df..bf8695b1 100644
--- a/src/Settings/DisplayPageGroups.php
+++ b/src/Settings/DisplayPageGroups.php
@@ -77,7 +77,7 @@ trait DisplayPageGroups {
'slides' => is_array($s['header_slider'] ?? null) ? $s['header_slider'] : self::header_slider_default_slides(),
'show_on_pages' => $show_on,
'show_type' => $show_type,
- 'nav_color' => sanitize_hex_color((string) ($s['header_slider_nav_color'] ?? '')) ?: '',
+ 'nav_color' => \AKSRT\Core\Color::sanitize((string) ($s['header_slider_nav_color'] ?? '')) ?: '',
'slider_config' => $slider_option,
'search_show' => self::sanitize_device_visibility($s['header_slider_search_s'] ?? ['pc_s', 'm_s']),
'search_config' => $search_option,
diff --git a/src/Settings/FacadeBulk.php b/src/Settings/FacadeBulk.php
index c8e1942a..e419fefc 100644
--- a/src/Settings/FacadeBulk.php
+++ b/src/Settings/FacadeBulk.php
@@ -18,9 +18,9 @@ trait FacadeBulk {
return [
'custom' => !empty($s['header_theme_custom']),
- 'bg_custom' => sanitize_hex_color((string) ($s['header_theme_bg_custom'] ?? '')) ?: '',
+ 'bg_custom' => \AKSRT\Core\Color::sanitize((string) ($s['header_theme_bg_custom'] ?? '')) ?: '',
'bg' => self::normalize_palette_hex((string) ($s['header_theme_bg'] ?? '')),
- 'color_custom' => sanitize_hex_color((string) ($s['header_theme_color_custom'] ?? '')) ?: '',
+ 'color_custom' => \AKSRT\Core\Color::sanitize((string) ($s['header_theme_color_custom'] ?? '')) ?: '',
];
}
diff --git a/src/Settings/FloatButtons.php b/src/Settings/FloatButtons.php
index bfa96eb9..ffc1e89b 100644
--- a/src/Settings/FloatButtons.php
+++ b/src/Settings/FloatButtons.php
@@ -218,25 +218,8 @@ class FloatButtons {
private static function sanitize_color_group($group): array {
$group = is_array($group) ? $group : [];
return [
- 'color' => self::sanitize_color((string) ($group['color'] ?? '')),
- 'bg' => self::sanitize_color((string) ($group['bg'] ?? '')),
+ 'color' => \AKSRT\Core\Color::sanitize($group['color'] ?? '', true),
+ 'bg' => \AKSRT\Core\Color::sanitize($group['bg'] ?? '', true),
];
}
-
- /**
- * 允许 #hex(3/4/6/8 位)与 rgb()/rgba()。
- */
- public static function sanitize_color(string $value): string {
- $value = trim($value);
- if ($value === '') {
- return '';
- }
- if (preg_match('/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $value)) {
- return $value;
- }
- if (preg_match('/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\)$/', $value)) {
- return $value;
- }
- return '';
- }
}
diff --git a/src/Settings/HomeCms.php b/src/Settings/HomeCms.php
index edc8414d..b14293e9 100644
--- a/src/Settings/HomeCms.php
+++ b/src/Settings/HomeCms.php
@@ -61,9 +61,9 @@ class HomeCms {
$s = self::get_footer_settings();
return [
'custom' => !empty($s['footer_theme_custom']),
- 'bg_custom' => sanitize_hex_color((string) ($s['footer_theme_bg_custom'] ?? '')) ?: '',
+ 'bg_custom' => \AKSRT\Core\Color::sanitize((string) ($s['footer_theme_bg_custom'] ?? '')) ?: '',
'bg' => self::normalize_palette_hex((string) ($s['footer_theme_bg'] ?? '')),
- 'color_custom' => sanitize_hex_color((string) ($s['footer_theme_color_custom'] ?? '')) ?: '',
+ 'color_custom' => \AKSRT\Core\Color::sanitize((string) ($s['footer_theme_color_custom'] ?? '')) ?: '',
];
}
diff --git a/src/Settings/ThemeSearch.php b/src/Settings/ThemeSearch.php
index d0c18dbc..6cdb1343 100644
--- a/src/Settings/ThemeSearch.php
+++ b/src/Settings/ThemeSearch.php
@@ -388,12 +388,10 @@ class ThemeSearch {
return '';
}
- private const HEX_PATTERN = '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/';
-
/** 全局主题色:theme_skin_custom 优先,清空时使用 theme_skin 预置色(对齐参考主题 zib_head_css)。 */
public static function theme_color(): string {
- $custom = trim((string) Settings::get_value('theme_skin_custom', ''));
- if (preg_match(self::HEX_PATTERN, $custom)) {
+ $custom = \AKSRT\Core\Color::sanitize(Settings::get_value('theme_skin_custom', ''));
+ if ($custom !== '') {
return $custom;
}
$skin = ltrim(trim((string) Settings::get_value('theme_skin', 'f04494')), '#');
@@ -402,8 +400,7 @@ class ThemeSearch {
/** 夜间模式主题色;未设置返回空串,夜间沿用全局主题色。 */
public static function dark_theme_color(): string {
- $color = trim((string) Settings::get_value('dark_theme_skin_custom', ''));
- return preg_match(self::HEX_PATTERN, $color) ? $color : '';
+ return \AKSRT\Core\Color::sanitize(Settings::get_value('dark_theme_skin_custom', ''));
}
public static function get_theme_mode_button_positions(): array {
@@ -429,8 +426,7 @@ class ThemeSearch {
if (!is_array($bg)) {
return '';
}
- $color = trim((string) ($bg['color'] ?? ''));
- $color = preg_match(self::HEX_PATTERN, $color) ? $color : '';
+ $color = \AKSRT\Core\Color::sanitize($bg['color'] ?? '');
$image = esc_url_raw(trim((string) ($bg['image'] ?? '')));
if ($color === '' && $image === '') {
return '';
diff --git a/src/Ticket/TicketStatusService.php b/src/Ticket/TicketStatusService.php
index 731a8c72..d47a70e5 100644
--- a/src/Ticket/TicketStatusService.php
+++ b/src/Ticket/TicketStatusService.php
@@ -134,11 +134,8 @@ final class TicketStatusService {
/** 颜色只接受 #rgb / #rrggbb,其余回落默认灰。 */
public static function sanitize_color(string $color): string {
- $color = strtolower(trim($color));
- if (preg_match('/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/', $color) !== 1) {
- return '#6b7280';
- }
- return $color;
+ $color = \AKSRT\Core\Color::sanitize($color);
+ return $color !== '' ? $color : '#6b7280';
}
/**
diff --git a/src/WidgetSystem/FieldSchema.php b/src/WidgetSystem/FieldSchema.php
index 3965df25..a112e89f 100644
--- a/src/WidgetSystem/FieldSchema.php
+++ b/src/WidgetSystem/FieldSchema.php
@@ -79,7 +79,7 @@ final class FieldSchema {
case 'upload':
return esc_url_raw((string) $value);
case 'color':
- return self::color((string) $value);
+ return \AKSRT\Core\Color::sanitize($value, true);
case 'icon':
return function_exists('aksrt_sanitize_icon_value')
? aksrt_sanitize_icon_value((string) $value)
@@ -192,7 +192,7 @@ final class FieldSchema {
private static function background($value): array {
$value = is_array($value) ? $value : [];
return [
- 'color' => self::color((string) ($value['color'] ?? '')),
+ 'color' => \AKSRT\Core\Color::sanitize($value['color'] ?? '', true),
'image' => esc_url_raw((string) ($value['image'] ?? '')),
'position' => self::allow((string) ($value['position'] ?? 'center center'), ['center center', 'top center', 'bottom center', 'center left', 'center right'], 'center center'),
'repeat' => self::allow((string) ($value['repeat'] ?? 'no-repeat'), ['no-repeat', 'repeat', 'repeat-x', 'repeat-y'], 'no-repeat'),
@@ -213,18 +213,6 @@ final class FieldSchema {
return self::normalize($children, is_array($value) ? $value : []);
}
- private static function color(string $value): string {
- $value = trim($value);
- if ($value === '') {
- return '';
- }
- $hex = sanitize_hex_color($value);
- if ($hex) {
- return $hex;
- }
- return preg_match('/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/', $value) ? $value : '';
- }
-
private static function allow(string $value, array $allowed, string $fallback): string {
return in_array($value, $allowed, true) ? $value : $fallback;
}
diff --git a/src/WidgetSystem/FormRenderer.php b/src/WidgetSystem/FormRenderer.php
index 719a397e..f58fd9df 100644
--- a/src/WidgetSystem/FormRenderer.php
+++ b/src/WidgetSystem/FormRenderer.php
@@ -221,7 +221,7 @@ final class FormRenderer {
private static function background(SchemaWidget $widget, string $key, array $value): void {
echo '';
- self::plain_input($widget, $key . '[color]', (string) ($value['color'] ?? ''), 'text', '背景颜色');
+ self::plain_input($widget, $key . '[color]', (string) ($value['color'] ?? ''), 'text', '背景颜色', 'widefat aksrt-widget-color');
self::plain_input($widget, $key . '[image]', (string) ($value['image'] ?? ''), 'url', '背景图片 URL');
foreach ([
'position' => ['center center' => '居中', 'top center' => '顶部', 'bottom center' => '底部', 'center left' => '左侧', 'center right' => '右侧'],
@@ -273,9 +273,9 @@ final class FormRenderer {
echo '
';
}
- private static function plain_input(SchemaWidget $widget, string $key, string $value, string $type, string $placeholder): void {
+ private static function plain_input(SchemaWidget $widget, string $key, string $value, string $type, string $placeholder, string $class = 'widefat'): void {
$name = $widget->get_field_name($key);
$id = $widget->get_field_id(str_replace(['[', ']'], ['-', ''], $key));
- echo '';
+ echo '';
}
}
diff --git a/tests/widget-system/captcha-security-harness.php b/tests/widget-system/captcha-security-harness.php
index 8b67db1d..fa04c072 100644
--- a/tests/widget-system/captcha-security-harness.php
+++ b/tests/widget-system/captcha-security-harness.php
@@ -88,6 +88,9 @@ namespace {
require dirname(__DIR__, 2) . '/inc/modules/captcha.php';
require dirname(__DIR__, 2) . '/src/Attachment/UploadLimits.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPermissions.php';
+ require dirname(__DIR__, 2) . '/src/Core/Color.php';
+ require dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOAuthValidation.php';
+ require dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOptionMatch.php';
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
function check(bool $condition, string $message): void {
diff --git a/tests/widget-system/color-unification-contract.test.cjs b/tests/widget-system/color-unification-contract.test.cjs
new file mode 100644
index 00000000..3dae3e30
--- /dev/null
+++ b/tests/widget-system/color-unification-contract.test.cjs
@@ -0,0 +1,77 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { execFileSync } = require('node:child_process');
+
+const root = path.resolve(__dirname, '../..');
+const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
+
+function walk(dir, exts, out = []) {
+ for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
+ const rel = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (['node_modules', 'build', 'vendor'].includes(entry.name)) continue;
+ walk(rel, exts, out);
+ } else if (exts.some((ext) => entry.name.endsWith(ext))) {
+ out.push(rel);
+ }
+ }
+ return out;
+}
+
+test('n-color-picker is used only inside the shared AksColorPicker', () => {
+ const offenders = walk('assets/admin/src', ['.vue', '.js'])
+ .filter((file) => /n-color-picker|NColorPicker/.test(read(file)))
+ .map((file) => file.replace(/\\/g, '/'));
+ assert.deepEqual(offenders, ['assets/admin/src/components/form/AksColorPicker.vue']);
+});
+
+test('PHP color sanitizing goes through AKSRT\\Core\\Color only', () => {
+ const offenders = [...walk('src', ['.php']), ...walk('inc', ['.php'])]
+ .filter((file) => /sanitize_hex_color\(|rgba\?\\\(/.test(read(file)))
+ .map((file) => file.replace(/\\/g, '/'))
+ .filter((file) => file !== 'src/Core/Color.php');
+ assert.deepEqual(offenders, []);
+});
+
+test('schema color fields sanitize via Color with showAlpha opt-in', () => {
+ const provider = read('src/Admin/SchemaProvider.php');
+ assert.match(provider, /'color'\s*=>\s*'color'/);
+ assert.match(provider, /case 'color_alpha':\s*return \\AKSRT\\Core\\Color::sanitize\(\$value, true\)/);
+ assert.match(provider, /showAlpha/);
+ const field = read('assets/admin/src/components/form/AksField.vue');
+ assert.match(field, / {
+ const composite = read('assets/admin/src/components/form/AksCompositeField.vue');
+ assert.doesNotMatch(composite, /'dimensions'|'spacing'|'background'|AksMediaField/);
+ assert.doesNotMatch(read('assets/js/gutenberg-blocks.js'), /ColorPicker/);
+});
+
+test('JS normalizeColor mirrors PHP Color::sanitize', async () => {
+ const { normalizeColor } = await import(
+ 'file://' + path.join(root, 'assets/admin/src/utils/color.js').replace(/\\/g, '/')
+ );
+ const cases = [
+ ['#ABC', false], ['abcdef', false], ['#abcd', false], ['#11223344', false], ['rgb(1,2,3)', false],
+ ['#abcd', true], ['#11223344', true], ['rgb(1,2,3)', true], ['RGBA( 10 , 20 , 30 , .5 )', true],
+ ['rgba(1,2,3,0.12345)', true], ['rgba(1,2,3,1.0)', true], ['rgb(256,0,0)', true],
+ ['red', true], ['', true], [' #FFF ', false],
+ ];
+ const php = `require 'src/Core/Color.php';`
+ + `foreach (json_decode($argv[1], true) as [$v, $a]) { echo AKSRT\\Core\\Color::sanitize($v, $a), "\\n"; }`;
+ const out = execFileSync('php', ['-d', 'display_errors=1', '-r', `define('ABSPATH', '/');${php}`, JSON.stringify(cases)], {
+ cwd: root,
+ encoding: 'utf8',
+ }).split('\n').slice(0, cases.length);
+ assert.deepEqual(cases.map(([v, a]) => normalizeColor(v, a)), out);
+});
+
+test('widget background color input is wpColorPicker-enhanced like top-level color fields', () => {
+ const renderer = read('src/WidgetSystem/FormRenderer.php');
+ assert.match(renderer, /\$key \. '\[color\]'[^\n]+'widefat aksrt-widget-color'\)/);
+ assert.match(renderer, /class="' \. esc_attr\(\$class\) \. '"/);
+ assert.match(read('assets/widgets/admin.js'), /\.aksrt-widget-color'\)\.wpColorPicker\(\)/);
+});
diff --git a/tests/widget-system/oauth-security-harness.php b/tests/widget-system/oauth-security-harness.php
index 87cc3370..8abc9945 100644
--- a/tests/widget-system/oauth-security-harness.php
+++ b/tests/widget-system/oauth-security-harness.php
@@ -90,6 +90,9 @@ namespace {
require dirname(__DIR__, 2) . '/src/Auth/OAuth.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaUsers.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/Users/SchemaUsersTicket.php';
+ require dirname(__DIR__, 2) . '/src/Core/Color.php';
+ require dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOAuthValidation.php';
+ require dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOptionMatch.php';
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
function check(bool $condition, string $message): void {
diff --git a/tests/widget-system/page-display-save-harness.php b/tests/widget-system/page-display-save-harness.php
index 6ec6ce2f..265570df 100644
--- a/tests/widget-system/page-display-save-harness.php
+++ b/tests/widget-system/page-display-save-harness.php
@@ -147,6 +147,9 @@ namespace {
require_once dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php';
require_once dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPageDisplay.php';
require_once dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPayment.php';
+ require_once dirname(__DIR__, 2) . '/src/Core/Color.php';
+ require_once dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOAuthValidation.php';
+ require_once dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOptionMatch.php';
require_once dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
require_once dirname(__DIR__, 2) . '/src/Admin/ControllerBase.php';
require_once dirname(__DIR__, 2) . '/inc/helpers/homepage/header-components.php';
diff --git a/tests/widget-system/ticket-schema-version.test.cjs b/tests/widget-system/ticket-schema-version.test.cjs
index 4e42d84d..cc60b409 100644
--- a/tests/widget-system/ticket-schema-version.test.cjs
+++ b/tests/widget-system/ticket-schema-version.test.cjs
@@ -120,5 +120,6 @@ test('TicketStatusService seeds mirror the migrator and guard the default row',
assert.match(service, /if \(self::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
// 颜色只接受 #rgb / #rrggbb。
assert.match(service, /'#6b7280'/);
- assert.match(service, /preg_match\('\/\^#\(\?:\[0-9a-f\]\{3\}\|\[0-9a-f\]\{6\}\)\$\/'/);
+ assert.match(service, /\\AKSRT\\Core\\Color::sanitize\(\$color\)/);
+ assert.doesNotMatch(service, /preg_match\('\/\^#/);
});
diff --git a/tests/widget-system/ticket-spa-config-contract.test.cjs b/tests/widget-system/ticket-spa-config-contract.test.cjs
index f99d9aa7..862d1579 100644
--- a/tests/widget-system/ticket-spa-config-contract.test.cjs
+++ b/tests/widget-system/ticket-spa-config-contract.test.cjs
@@ -80,8 +80,12 @@ test('panels enforce the backend guards instead of trusting the client', () => {
// 默认状态的编辑/删除入口在前端禁用,但后端 TicketConfigRest 仍有 409 兜底。
assert.match(status, /disabled: row\.is_default/);
// 颜色必须落在 TicketStatusService::sanitize_color() 接受的范围内。
- assert.match(status, /\^#\(\?:\[0-9a-f\]\{3\}\|\[0-9a-f\]\{6\}\)\$/i);
+ assert.match(status, /import \{ toHex \} from '@\/utils\/color\.js'/);
+ assert.match(status, /