refactor(color): unify color sanitizing and picking behind shared components
- Add AKSRT\Core\Color::sanitize($value, $allow_alpha) as the single PHP color sanitizer; replace sanitize_hex_color calls and per-class regexes (FloatButtons, FieldSchema, ThemeSearch, icon cards, TicketStatusService) - SchemaProvider / ControllerBase sanitize `color` fields as colors; alpha kept only when the schema sets showAlpha - Split SchemaProvider OAuth validation and option matching into sibling traits to stay under the 1000-line limit - Add AksColorPicker.vue + utils/color.js mirroring the PHP rules; AksField, FloatBtnCommonFields and TicketStatusPanel use it - Widget background color input now uses wpColorPicker - Remove dead AksCompositeField dimensions/spacing/background branches and the unused Gutenberg ColorPicker var - Add color-unification contract test; update harness requires and tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
74021d26bb
commit
57ff405691
32 files changed
+522
-456
No files matched your search
@@ -10,22 +10,20 @@
|
||||
<div class="float-common-fields__colors">
|
||||
<div class="float-common-fields__color">
|
||||
<span>自定义图标颜色</span>
|
||||
<n-color-picker
|
||||
:value="modelValue.color?.color || null"
|
||||
:show-alpha="true"
|
||||
:modes="['hex', 'rgb']"
|
||||
<AksColorPicker
|
||||
:model-value="modelValue.color?.color || ''"
|
||||
alpha
|
||||
clearable
|
||||
@update:value="updateColor('color', $event)"
|
||||
@update:model-value="updateColor('color', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="float-common-fields__color">
|
||||
<span>自定义背景颜色</span>
|
||||
<n-color-picker
|
||||
:value="modelValue.color?.bg || null"
|
||||
:show-alpha="true"
|
||||
:modes="['hex', 'rgb']"
|
||||
<AksColorPicker
|
||||
:model-value="modelValue.color?.bg || ''"
|
||||
alpha
|
||||
clearable
|
||||
@update:value="updateColor('bg', $event)"
|
||||
@update:model-value="updateColor('bg', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,6 +33,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AksColorPicker from '@/components/form/AksColorPicker.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, required: true },
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<n-color-picker
|
||||
:value="pickerValue"
|
||||
:modes="modes"
|
||||
:show-alpha="alpha"
|
||||
:swatches="swatches"
|
||||
:actions="clearable ? ['clear'] : undefined"
|
||||
:disabled="disabled"
|
||||
@update:value="onUpdate"
|
||||
>
|
||||
<template v-if="$slots.trigger" #trigger="slotProps">
|
||||
<slot name="trigger" v-bind="slotProps" />
|
||||
</template>
|
||||
</n-color-picker>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 后台唯一取色器:所有颜色字段都经由此组件,输出与
|
||||
* PHP `AKSRT\Core\Color::sanitize($value, $allow_alpha)` 同一规则的规范值。
|
||||
* 清空 / 非法值统一回传空串。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { normalizeColor } from '@/utils/color.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
alpha: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: false },
|
||||
swatches: { type: Array, default: undefined },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const modes = computed(() => (props.alpha ? ['hex', 'rgb'] : ['hex']))
|
||||
const pickerValue = computed(() => normalizeColor(props.modelValue, props.alpha) || null)
|
||||
|
||||
function onUpdate(value) {
|
||||
emit('update:modelValue', normalizeColor(value, props.alpha))
|
||||
}
|
||||
</script>
|
||||
@@ -1,35 +1,6 @@
|
||||
<template>
|
||||
<!-- Dimensions (width x height) -->
|
||||
<div v-if="schema.type === 'dimensions'" class="aksrt-composite aksrt-composite--dimensions">
|
||||
<n-input-number
|
||||
:value="current.width"
|
||||
:min="0"
|
||||
:placeholder="schema.widthPlaceholder || '宽度'"
|
||||
:disabled="schema.disabled"
|
||||
style="flex: 1"
|
||||
@update:value="update('width', $event)"
|
||||
/>
|
||||
<span class="aksrt-composite__sep">×</span>
|
||||
<n-input-number
|
||||
:value="current.height"
|
||||
:min="0"
|
||||
:placeholder="schema.heightPlaceholder || '高度'"
|
||||
:disabled="schema.disabled"
|
||||
style="flex: 1"
|
||||
@update:value="update('height', $event)"
|
||||
/>
|
||||
<n-select
|
||||
v-if="schema.unit"
|
||||
:value="current.unit"
|
||||
:options="dimensionUnitOptions"
|
||||
:disabled="schema.disabled"
|
||||
style="width: 80px"
|
||||
@update:value="update('unit', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Between number (min/max pair) -->
|
||||
<div v-else-if="schema.type === 'between_number'" class="aksrt-composite aksrt-composite--between">
|
||||
<div class="aksrt-composite aksrt-composite--between">
|
||||
<n-input-number
|
||||
:value="betweenValue('min')"
|
||||
:min="schema.min"
|
||||
@@ -53,56 +24,11 @@
|
||||
/>
|
||||
<span v-if="schema.unit" class="aksrt-composite__unit">{{ schema.unit }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Spacing (top right bottom left) -->
|
||||
<div v-else-if="schema.type === 'spacing'" class="aksrt-composite--spacing">
|
||||
<template v-for="(side, i) in spacingSides" :key="side">
|
||||
<div class="aksrt-composite__label">{{ schema.spacingLabels?.[i] || spacingDefaults[i] }}</div>
|
||||
<n-input-number
|
||||
:value="current[side]"
|
||||
:min="0"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="update(side, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Background (color + image + size + position + repeat) -->
|
||||
<div v-else-if="schema.type === 'background'" class="aksrt-composite--background">
|
||||
<div class="aksrt-composite__row">
|
||||
<label class="aksrt-composite__label">背景颜色</label>
|
||||
<n-color-picker
|
||||
:value="current.color"
|
||||
:show-alpha="true"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="update('color', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="aksrt-composite__row">
|
||||
<label class="aksrt-composite__label">背景图片</label>
|
||||
<AksMediaField
|
||||
:model-value="current.image"
|
||||
:accept="'image/*'"
|
||||
:disabled="schema.disabled"
|
||||
@update:model-value="update('image', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-for="row in backgroundSelects" :key="row.key" class="aksrt-composite__row">
|
||||
<label class="aksrt-composite__label">{{ row.label }}</label>
|
||||
<n-select
|
||||
:value="current[row.key]"
|
||||
:options="row.options"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="update(row.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/** 对象型复合字段:dimensions / between_number / spacing / background。 */
|
||||
/** 对象型复合字段:between_number(min / max 数值区间)。 */
|
||||
import { computed } from 'vue'
|
||||
import AksMediaField from './AksMediaField.vue'
|
||||
|
||||
const props = defineProps({
|
||||
schema: { type: Object, required: true },
|
||||
@@ -123,43 +49,6 @@ function update(part, value) {
|
||||
function betweenValue(part) {
|
||||
return current.value[part] !== undefined ? current.value[part] : (props.schema.default?.[part] ?? 0)
|
||||
}
|
||||
|
||||
const dimensionUnitOptions = ['px', '%', 'em', 'rem', 'vw'].map(unit => ({ label: unit, value: unit }))
|
||||
const spacingSides = ['top', 'right', 'bottom', 'left']
|
||||
const spacingDefaults = ['上', '右', '下', '左']
|
||||
|
||||
const backgroundSelects = [
|
||||
{
|
||||
key: 'size',
|
||||
label: '图片尺寸',
|
||||
options: [
|
||||
{ label: '覆盖', value: 'cover' },
|
||||
{ label: '包含', value: 'contain' },
|
||||
{ label: '原始大小', value: 'auto' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'position',
|
||||
label: '图片位置',
|
||||
options: [
|
||||
{ label: '居中', value: 'center center' },
|
||||
{ label: '顶部居中', value: 'top center' },
|
||||
{ label: '底部居中', value: 'bottom center' },
|
||||
{ label: '左侧居中', value: 'center left' },
|
||||
{ label: '右侧居中', value: 'center right' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'repeat',
|
||||
label: '图片重复',
|
||||
options: [
|
||||
{ label: '不重复', value: 'no-repeat' },
|
||||
{ label: '平铺', value: 'repeat' },
|
||||
{ label: '水平平铺', value: 'repeat-x' },
|
||||
{ label: '垂直平铺', value: 'repeat-y' },
|
||||
],
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -183,49 +72,4 @@ const backgroundSelects = [
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aksrt-composite--spacing {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto 1fr auto 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-composite--background {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-composite__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-composite__label {
|
||||
min-width: 70px;
|
||||
color: var(--aksrt-color-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aksrt-composite--spacing .aksrt-composite__label {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.aksrt-composite--dimensions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.aksrt-composite--spacing {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.aksrt-composite__row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -199,13 +199,13 @@
|
||||
</n-checkbox-group>
|
||||
|
||||
<!-- Color picker -->
|
||||
<n-color-picker
|
||||
<AksColorPicker
|
||||
v-else-if="schema.type === 'color'"
|
||||
:value="modelValue"
|
||||
:modes="['hex']"
|
||||
:show-alpha="schema.showAlpha || false"
|
||||
:disabled="schema.disabled"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
:model-value="typeof modelValue === 'string' ? modelValue : ''"
|
||||
:alpha="!!schema.showAlpha"
|
||||
:disabled="!!schema.disabled"
|
||||
clearable
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<div v-else-if="schema.type === 'palette'" class="aksrt-field__palette">
|
||||
@@ -315,9 +315,9 @@
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<!-- Object composites: dimensions / between_number / spacing / background -->
|
||||
<!-- Object composite: between_number (min/max pair) -->
|
||||
<AksCompositeField
|
||||
v-else-if="['dimensions', 'between_number', 'spacing', 'background'].includes(schema.type)"
|
||||
v-else-if="schema.type === 'between_number'"
|
||||
:schema="schema"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@@ -525,6 +525,7 @@ import AksMediaField from './AksMediaField.vue'
|
||||
import AksGalleryField from './AksGalleryField.vue'
|
||||
import AksCodeEditor from './AksCodeEditor.vue'
|
||||
import AksCompositeField from './AksCompositeField.vue'
|
||||
import AksColorPicker from './AksColorPicker.vue'
|
||||
import AksImageSelectField from './AksImageSelectField.vue'
|
||||
import AksSortHandle from './AksSortHandle.vue'
|
||||
import AksSecretClear from './AksSecretClear.vue'
|
||||
|
||||
@@ -72,11 +72,9 @@
|
||||
@click="form.color = swatch"
|
||||
/>
|
||||
<!-- 自定义色:未选中时显示色环,选中后显示当前色并带选中环。 -->
|
||||
<n-color-picker
|
||||
:value="previewColor"
|
||||
:modes="['hex']"
|
||||
:show-alpha="false"
|
||||
@update:value="setColor"
|
||||
<AksColorPicker
|
||||
:model-value="previewColor"
|
||||
@update:model-value="setColor"
|
||||
>
|
||||
<template #trigger="{ onClick, ref: setTriggerRef }">
|
||||
<button
|
||||
@@ -92,7 +90,7 @@
|
||||
@click="onClick"
|
||||
/>
|
||||
</template>
|
||||
</n-color-picker>
|
||||
</AksColorPicker>
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="form.color"
|
||||
@@ -128,6 +126,8 @@
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { NButton, NSpace, useDialog, useMessage } from 'naive-ui'
|
||||
import api from '@/api'
|
||||
import AksColorPicker from '@/components/form/AksColorPicker.vue'
|
||||
import { toHex } from '@/utils/color.js'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
@@ -146,7 +146,6 @@ const editing = ref(null)
|
||||
const rows = ref([])
|
||||
|
||||
const DEFAULT_COLOR = '#6b7280'
|
||||
const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
|
||||
|
||||
/** 与 TicketStatusService::KEY_PATTERN 保持一致。 */
|
||||
const KEY_RE = /^[a-z][a-z0-9_]{1,23}$/
|
||||
@@ -157,16 +156,6 @@ function normalizeKey(value) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化为 TicketStatusService::sanitize_color() 接受的小写 #rgb / #rrggbb;
|
||||
* 允许省略 #。非法值返回空串。
|
||||
*/
|
||||
function toHex(value) {
|
||||
let hex = String(value || '').trim().toLowerCase()
|
||||
if (hex && !hex.startsWith('#')) hex = `#${hex}`
|
||||
return HEX_RE.test(hex) ? hex : ''
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请填写状态名称', trigger: 'blur' },
|
||||
@@ -205,10 +194,7 @@ const currentHex = computed(() => toHex(form.value.color))
|
||||
const previewColor = computed(() => currentHex.value || DEFAULT_COLOR)
|
||||
const isCustomColor = computed(() => currentHex.value !== '' && !SWATCHES.includes(currentHex.value))
|
||||
|
||||
/**
|
||||
* 取色器已关闭 alpha(默认开启时回传 #rrggbbaa,会被后端回落成灰色),
|
||||
* 这里仍只接受合法十六进制,其余忽略。
|
||||
*/
|
||||
/** AksColorPicker 已归一化为 hex;清空(空串)时保留当前色。 */
|
||||
function setColor(value) {
|
||||
const hex = toHex(value)
|
||||
if (hex) form.value.color = hex
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 颜色值归一化:与 PHP `AKSRT\Core\Color::sanitize()` 规则一致。
|
||||
*
|
||||
* - 不带 alpha:仅 #rgb / #rrggbb;
|
||||
* - 带 alpha:另允许 #rgba / #rrggbbaa 与 rgb()/rgba();
|
||||
* - 十六进制统一小写、补 #;非法值返回空串。
|
||||
*/
|
||||
|
||||
export const HEX_RE = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i
|
||||
const HEX_ALPHA_RE = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
|
||||
const RGB_RE = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(0(?:\.\d+)?|1(?:\.0+)?|\.\d+)\s*)?\)$/i
|
||||
|
||||
/** 小写 #rgb / #rrggbb;非法返回空串。 */
|
||||
export function toHex(value) {
|
||||
const match = String(value ?? '').trim().match(HEX_RE)
|
||||
return match ? `#${match[1].toLowerCase()}` : ''
|
||||
}
|
||||
|
||||
export function normalizeColor(value, alpha = false) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const raw = value.trim()
|
||||
if (raw === '') return ''
|
||||
|
||||
const hex = raw.match(alpha ? HEX_ALPHA_RE : HEX_RE)
|
||||
if (hex) return `#${hex[1].toLowerCase()}`
|
||||
if (!alpha) return ''
|
||||
|
||||
const rgb = raw.match(RGB_RE)
|
||||
if (!rgb) return ''
|
||||
const channels = rgb.slice(1, 4).map(Number)
|
||||
if (channels.some((c) => c > 255)) return ''
|
||||
if (rgb[4] === undefined) return `rgb(${channels.join(', ')})`
|
||||
const a = String(Math.round(parseFloat(rgb[4]) * 1000) / 1000)
|
||||
return `rgba(${channels.join(', ')}, ${a})`
|
||||
}
|
||||
@@ -23,7 +23,6 @@
|
||||
var InnerBlocks = blockEditor.InnerBlocks || null;
|
||||
var RichText = blockEditor.RichText || null;
|
||||
var MediaUpload = blockEditor.MediaUpload || null;
|
||||
var ColorPicker = wp.components.ColorPicker;
|
||||
var PanelBody = wp.components.PanelBody;
|
||||
var SelectControl = wp.components.SelectControl;
|
||||
var TextControl = wp.components.TextControl;
|
||||
|
||||
@@ -167,11 +167,8 @@ if (!function_exists('aksrt_icon_card')) :
|
||||
}
|
||||
|
||||
$style = '';
|
||||
$custom_color = sanitize_hex_color((string) $args['icon_custom_color']);
|
||||
if (!$custom_color && preg_match('/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/', (string) $args['icon_custom_color'])) {
|
||||
$custom_color = (string) $args['icon_custom_color'];
|
||||
}
|
||||
if (!$radius && $custom_color) {
|
||||
$custom_color = \AKSRT\Core\Color::sanitize($args['icon_custom_color'], true);
|
||||
if (!$radius && $custom_color !== '') {
|
||||
$style = ' style="color:' . esc_attr($custom_color) . ';"';
|
||||
}
|
||||
|
||||
@@ -327,8 +324,8 @@ if (!function_exists('aksrt_icon_cover_card')) :
|
||||
$icon_size = max(14, min(64, (int) $args['icon_size']));
|
||||
// legacy always sets font-size on the icon badge.
|
||||
$style = 'font-size: ' . $icon_size . 'px;';
|
||||
$custom_color = sanitize_hex_color((string) $args['icon_custom_color']);
|
||||
if ($custom_color) {
|
||||
$custom_color = \AKSRT\Core\Color::sanitize($args['icon_custom_color'], true);
|
||||
if ($custom_color !== '') {
|
||||
$style .= 'color:' . $custom_color . ';';
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ function aksrt_render_slider(array $args = []): string {
|
||||
$title = sanitize_text_field($slide['title'] ?? '');
|
||||
$desc = sanitize_text_field($slide['desc'] ?? '');
|
||||
$alt = sanitize_text_field($slide['alt'] ?? $title);
|
||||
$bg_color = sanitize_hex_color($slide['bg_color'] ?? '');
|
||||
$bg_color = \AKSRT\Core\Color::sanitize($slide['bg_color'] ?? '');
|
||||
$target = ($slide['target'] ?? '') === '_blank' ? '_blank' : '_self';
|
||||
|
||||
$slide_style = $bg_color ? 'background-color:' . esc_attr($bg_color) . ';' : '';
|
||||
|
||||
@@ -443,7 +443,7 @@ if (!function_exists('aksrt_header_slide')) {
|
||||
}
|
||||
}
|
||||
|
||||
$nav_color = sanitize_hex_color((string) ($args['header_slider_nav_color'] ?? ''));
|
||||
$nav_color = \AKSRT\Core\Color::sanitize((string) ($args['header_slider_nav_color'] ?? ''));
|
||||
if ($nav_color && $nav_color !== '#ffffff') {
|
||||
echo '<style>body.nav-fixed:not(.body-scroll):not(.mobile-navbar-show) .header.show-slide{--header-color:' . esc_html($nav_color) . ';}</style>';
|
||||
}
|
||||
|
||||
@@ -108,6 +108,12 @@ abstract class ControllerBase {
|
||||
$ids = is_array($val) ? array_filter(array_map('absint', $val)) : [];
|
||||
$out[$key] = array_values(array_unique($ids));
|
||||
break;
|
||||
case 'color':
|
||||
$out[$key] = \AKSRT\Core\Color::sanitize($val);
|
||||
break;
|
||||
case 'color_alpha':
|
||||
$out[$key] = \AKSRT\Core\Color::sanitize($val, true);
|
||||
break;
|
||||
default:
|
||||
$out[$key] = sanitize_text_field($val);
|
||||
}
|
||||
|
||||
@@ -365,8 +365,8 @@ class GlobalController extends ControllerBase {
|
||||
'icon_size' => max(16, min(32, (int) ($params['icon_size'] ?? 22))),
|
||||
'text_size' => max(8, min(14, (int) ($params['text_size'] ?? 10))),
|
||||
'show_text' => !empty($params['show_text']),
|
||||
'active_color' => sanitize_hex_color($params['active_color'] ?? '') ?: '#f04494',
|
||||
'bg_color' => sanitize_hex_color($params['bg_color'] ?? '') ?: '',
|
||||
'active_color' => \AKSRT\Core\Color::sanitize($params['active_color'] ?? '') ?: '#f04494',
|
||||
'bg_color' => \AKSRT\Core\Color::sanitize($params['bg_color'] ?? '') ?: '',
|
||||
'border' => !empty($params['border']),
|
||||
'shadow' => !empty($params['shadow']),
|
||||
'animation' => !empty($params['animation']),
|
||||
|
||||
@@ -122,7 +122,7 @@ final class PageConfigMetaBox {
|
||||
$config = [
|
||||
'header_slider_show' => !empty($raw['header_slider_show']),
|
||||
'header_slider_show_type' => self::enum((string) ($raw['header_slider_show_type'] ?? ''), ['', 'only_pc', 'only_sm']),
|
||||
'header_slider_nav_color' => sanitize_hex_color((string) ($raw['header_slider_nav_color'] ?? '')) ?: '#ffffff',
|
||||
'header_slider_nav_color' => \AKSRT\Core\Color::sanitize((string) ($raw['header_slider_nav_color'] ?? '')) ?: '#ffffff',
|
||||
'header_slider' => self::sanitize_slides(self::decode_json($raw['header_slider'] ?? '')),
|
||||
'header_slider_option' => self::sanitize_slider_options(self::decode_json($raw['header_slider_option'] ?? '')),
|
||||
'header_slider_search_s' => self::sanitize_devices($raw['header_slider_search_s'] ?? []),
|
||||
|
||||
+20
-190
@@ -9,6 +9,9 @@ defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaProvider {
|
||||
|
||||
use SchemaProviderOAuthValidation;
|
||||
use SchemaProviderOptionMatch;
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
@@ -304,132 +307,6 @@ class SchemaProvider {
|
||||
return ['valid' => empty($errors), 'errors' => $errors];
|
||||
}
|
||||
|
||||
private function validate_oauth_settings(array $settings, array $stored = []): array {
|
||||
if (empty($settings['oauth_s'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$mode = sanitize_key((string) ($settings['oauth_proxy_mode'] ?? 'close'));
|
||||
$secret_configured = $this->oauth_secret_is_configured($settings['oauth_proxy_secret'] ?? '', $stored['oauth_proxy_secret'] ?? '');
|
||||
|
||||
if ($mode === 'client') {
|
||||
$url = trim((string) ($settings['oauth_proxy_server_url'] ?? ''));
|
||||
if (!$this->is_secure_oauth_url($url)) {
|
||||
$errors[] = __('客户端代理模式需要有效的 HTTPS 服务端地址', 'aksrt');
|
||||
}
|
||||
if (!$secret_configured) {
|
||||
$errors[] = __('客户端代理模式需要代理 HMAC 密钥', 'aksrt');
|
||||
}
|
||||
if (empty($settings['oauth_proxy_client_providers']) || !is_array($settings['oauth_proxy_client_providers'])) {
|
||||
$errors[] = __('客户端代理模式至少需要启用一个登录平台', 'aksrt');
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
if ($mode === 'server') {
|
||||
if (!$secret_configured) {
|
||||
$errors[] = __('服务端代理模式需要代理 HMAC 密钥', 'aksrt');
|
||||
}
|
||||
$selected = is_array($settings['oauth_proxy_server_providers'] ?? null) ? $settings['oauth_proxy_server_providers'] : [];
|
||||
if ($selected === []) {
|
||||
$errors[] = __('服务端代理模式至少需要允许一个登录平台', 'aksrt');
|
||||
}
|
||||
$provider_fields = [
|
||||
'github' => ['GitHub', 'github_appid', 'github_appkey'],
|
||||
'gitee' => ['Gitee', 'gitee_appid', 'gitee_appkey'],
|
||||
'qq' => ['QQ', 'qq_appid', 'qq_appkey'],
|
||||
'google' => ['Google', 'google_client_id', 'google_client_secret'],
|
||||
'weibo' => ['微博', 'weibo_appid', 'weibo_appkey'],
|
||||
'weixin' => ['微信开放平台', 'weixin_appid', 'weixin_appsecret'],
|
||||
];
|
||||
foreach ($selected as $provider) {
|
||||
if (!isset($provider_fields[$provider])) {
|
||||
continue;
|
||||
}
|
||||
[$label, $id_key, $secret_key] = $provider_fields[$provider];
|
||||
if (trim((string) ($settings[$id_key] ?? '')) === '' || !$this->oauth_secret_is_configured($settings[$secret_key] ?? '', $stored[$secret_key] ?? '')) {
|
||||
$errors[] = sprintf(__('代理服务端的 %s 平台缺少完整凭据', 'aksrt'), $label);
|
||||
}
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
$providers = [
|
||||
'github' => ['GitHub', 'github_appid', 'github_appkey'],
|
||||
'gitee' => ['Gitee', 'gitee_appid', 'gitee_appkey'],
|
||||
'qq' => ['QQ', 'qq_appid', 'qq_appkey'],
|
||||
'google' => ['Google', 'google_client_id', 'google_client_secret'],
|
||||
'weibo' => ['微博', 'weibo_appid', 'weibo_appkey'],
|
||||
'weixin' => ['微信开放平台', 'weixin_appid', 'weixin_appsecret'],
|
||||
];
|
||||
$enabled = 0;
|
||||
foreach ($providers as $provider => [$label, $id_key, $secret_key]) {
|
||||
if (empty($settings['oauth_' . $provider . '_s'])) {
|
||||
continue;
|
||||
}
|
||||
$enabled++;
|
||||
if (trim((string) ($settings[$id_key] ?? '')) === '' || !$this->oauth_secret_is_configured($settings[$secret_key] ?? '', $stored[$secret_key] ?? '')) {
|
||||
$errors[] = sprintf(__('%s 登录已启用,请填写完整凭据', 'aksrt'), $label);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($settings['oauth_weixingzh_s'])) {
|
||||
$enabled++;
|
||||
$gzh_type = sanitize_key((string) ($settings['weixingzh_gzh_type'] ?? 'service'));
|
||||
$base_ready = $this->oauth_secret_is_configured($settings['weixingzh_token'] ?? '', $stored['weixingzh_token'] ?? '')
|
||||
&& trim((string) ($settings['weixingzh_appid'] ?? '')) !== '';
|
||||
$mode_ready = $gzh_type === 'subscribe'
|
||||
? trim((string) ($settings['weixingzh_code_qrcode'] ?? '')) !== ''
|
||||
&& trim((string) ($settings['weixingzh_code_keyword'] ?? '')) !== ''
|
||||
: $this->oauth_secret_is_configured($settings['weixingzh_appsecret'] ?? '', $stored['weixingzh_appsecret'] ?? '');
|
||||
if (!$base_ready || !$mode_ready) {
|
||||
$errors[] = $gzh_type === 'subscribe'
|
||||
? __('微信公众号订阅号登录已启用,请填写 Token、AppID、二维码和回复关键词', 'aksrt')
|
||||
: __('微信公众号服务号登录已启用,请填写 Token、AppID 和 AppSecret', 'aksrt');
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($settings['clogin_s'])) {
|
||||
$enabled++;
|
||||
if (!$this->is_secure_oauth_url((string) ($settings['clogin_url'] ?? ''))
|
||||
|| trim((string) ($settings['clogin_id'] ?? '')) === ''
|
||||
|| !$this->oauth_secret_is_configured($settings['clogin_key'] ?? '', $stored['clogin_key'] ?? '')
|
||||
|| empty($settings['clogin_oauth_s'])
|
||||
) {
|
||||
$errors[] = __('彩虹聚合登录已启用,请填写 HTTPS 接口地址、AppID、AppKey 并选择平台', 'aksrt');
|
||||
}
|
||||
}
|
||||
|
||||
if ($enabled === 0) {
|
||||
$errors[] = __('启用第三方登录后,至少需要启用一个本地登录提供商', 'aksrt');
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private function oauth_secret_is_configured($value, $stored_value = ''): bool {
|
||||
$value = trim((string) $value);
|
||||
if ($value === '***') {
|
||||
return trim((string) $stored_value) !== '';
|
||||
}
|
||||
return $value !== '';
|
||||
}
|
||||
|
||||
private function is_secure_oauth_url(string $url): bool {
|
||||
$url = trim($url);
|
||||
if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||
return false;
|
||||
}
|
||||
$parts = wp_parse_url($url);
|
||||
return is_array($parts)
|
||||
&& strtolower((string) ($parts['scheme'] ?? '')) === 'https'
|
||||
&& !empty($parts['host'])
|
||||
&& empty($parts['user'])
|
||||
&& empty($parts['pass'])
|
||||
&& empty($parts['fragment']);
|
||||
}
|
||||
|
||||
public function read_settings(string $domain, string $section = ''): array {
|
||||
if ($section !== '') {
|
||||
$schema = $this->get_section_schema($domain, $section);
|
||||
@@ -710,7 +587,9 @@ class SchemaProvider {
|
||||
private function collect_allowed_fields(array $schema): array {
|
||||
$allowed = [];
|
||||
foreach ($this->get_all_fields($schema) as $field) {
|
||||
$allowed[$field['key']] = $field['sanitize_type'] ?? $this->infer_sanitize_type($field['type'] ?? 'input');
|
||||
$type = (string) ($field['type'] ?? 'input');
|
||||
$allowed[$field['key']] = $field['sanitize_type']
|
||||
?? ($type === 'color' ? $this->color_sanitize_type($field) : $this->infer_sanitize_type($type));
|
||||
}
|
||||
return $allowed;
|
||||
}
|
||||
@@ -840,73 +719,15 @@ class SchemaProvider {
|
||||
return function_exists('aksrt_sanitize_icon_value')
|
||||
? aksrt_sanitize_icon_value((string) $value)
|
||||
: sanitize_text_field((string) $value);
|
||||
case 'color':
|
||||
return \AKSRT\Core\Color::sanitize($value);
|
||||
case 'color_alpha':
|
||||
return \AKSRT\Core\Color::sanitize($value, true);
|
||||
default:
|
||||
return sanitize_text_field((string) $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对大小写不敏感字段(case_insensitive),把值归一化为选项中的规范写法;
|
||||
* 匹配不到返回 null。
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param array<int,mixed> $options
|
||||
*/
|
||||
private function normalize_case_insensitive_option($value, array $options): ?string {
|
||||
if (!is_string($value) || $value === '') {
|
||||
return null;
|
||||
}
|
||||
$lower = strtolower(trim($value));
|
||||
foreach ($options as $opt) {
|
||||
$opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt;
|
||||
if (is_string($opt_value) && strtolower($opt_value) === $lower) {
|
||||
return $opt_value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a submitted enum value to the value declared by its option.
|
||||
*
|
||||
* Payloads reach the sanitizer as strings (or numbers), so a strict match
|
||||
* against the schema would reject a valid choice whenever the option value
|
||||
* is not itself a string -- e.g. 积分兑换明细.level declares 1 / 2 while the
|
||||
* SPA submits them as numbers and sanitize_text_field() then turns them into
|
||||
* "1" / "2". Returning the declared value keeps the stored type aligned with
|
||||
* the schema, which the radio/select controls compare with === when they
|
||||
* re-render a saved setting.
|
||||
*
|
||||
* Returns null when nothing matches, so validation still reports the
|
||||
* invalid value instead of silently rewriting the operator's input.
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param array<int,mixed> $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;
|
||||
}
|
||||
|
||||
private function sanitize_field_value($value, array $field) {
|
||||
if (!empty($field['captcha_provider'])) {
|
||||
return function_exists('aksrt_captcha_sanitize_provider_config')
|
||||
@@ -933,6 +754,10 @@ class SchemaProvider {
|
||||
: $this->sanitize_value($value, 'icon');
|
||||
}
|
||||
|
||||
if ($type === 'color') {
|
||||
return $this->sanitize_value($value, $this->color_sanitize_type($field));
|
||||
}
|
||||
|
||||
if (in_array($type, ['select', 'radio'], true) && !empty($field['case_insensitive']) && !empty($field['options'])) {
|
||||
// 旧自由文本值(如小写货币代码)归一化为选项规范写法。
|
||||
$normalized = $this->normalize_case_insensitive_option($value, (array) $field['options']);
|
||||
@@ -1146,7 +971,7 @@ class SchemaProvider {
|
||||
'json' => 'array',
|
||||
// 自定义面板(验证码服务商等)按对象存储
|
||||
'custom' => 'array',
|
||||
'color' => 'string',
|
||||
'color' => 'color',
|
||||
'icon' => 'icon',
|
||||
'media' => 'url',
|
||||
'upload' => 'url',
|
||||
@@ -1156,4 +981,9 @@ class SchemaProvider {
|
||||
|
||||
return $map[$field_type] ?? 'string';
|
||||
}
|
||||
|
||||
/** Color fields keep alpha only when the schema opts in via showAlpha. */
|
||||
private function color_sanitize_type(array $field): string {
|
||||
return !empty($field['showAlpha']) ? 'color_alpha' : 'color';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
/**
|
||||
* SchemaProvider OAuth 设置校验(代理模式 / 各平台凭据 / HTTPS 地址)。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait SchemaProviderOAuthValidation {
|
||||
|
||||
private function validate_oauth_settings(array $settings, array $stored = []): array {
|
||||
if (empty($settings['oauth_s'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$mode = sanitize_key((string) ($settings['oauth_proxy_mode'] ?? 'close'));
|
||||
$secret_configured = $this->oauth_secret_is_configured($settings['oauth_proxy_secret'] ?? '', $stored['oauth_proxy_secret'] ?? '');
|
||||
|
||||
if ($mode === 'client') {
|
||||
$url = trim((string) ($settings['oauth_proxy_server_url'] ?? ''));
|
||||
if (!$this->is_secure_oauth_url($url)) {
|
||||
$errors[] = __('客户端代理模式需要有效的 HTTPS 服务端地址', 'aksrt');
|
||||
}
|
||||
if (!$secret_configured) {
|
||||
$errors[] = __('客户端代理模式需要代理 HMAC 密钥', 'aksrt');
|
||||
}
|
||||
if (empty($settings['oauth_proxy_client_providers']) || !is_array($settings['oauth_proxy_client_providers'])) {
|
||||
$errors[] = __('客户端代理模式至少需要启用一个登录平台', 'aksrt');
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
if ($mode === 'server') {
|
||||
if (!$secret_configured) {
|
||||
$errors[] = __('服务端代理模式需要代理 HMAC 密钥', 'aksrt');
|
||||
}
|
||||
$selected = is_array($settings['oauth_proxy_server_providers'] ?? null) ? $settings['oauth_proxy_server_providers'] : [];
|
||||
if ($selected === []) {
|
||||
$errors[] = __('服务端代理模式至少需要允许一个登录平台', 'aksrt');
|
||||
}
|
||||
$provider_fields = [
|
||||
'github' => ['GitHub', 'github_appid', 'github_appkey'],
|
||||
'gitee' => ['Gitee', 'gitee_appid', 'gitee_appkey'],
|
||||
'qq' => ['QQ', 'qq_appid', 'qq_appkey'],
|
||||
'google' => ['Google', 'google_client_id', 'google_client_secret'],
|
||||
'weibo' => ['微博', 'weibo_appid', 'weibo_appkey'],
|
||||
'weixin' => ['微信开放平台', 'weixin_appid', 'weixin_appsecret'],
|
||||
];
|
||||
foreach ($selected as $provider) {
|
||||
if (!isset($provider_fields[$provider])) {
|
||||
continue;
|
||||
}
|
||||
[$label, $id_key, $secret_key] = $provider_fields[$provider];
|
||||
if (trim((string) ($settings[$id_key] ?? '')) === '' || !$this->oauth_secret_is_configured($settings[$secret_key] ?? '', $stored[$secret_key] ?? '')) {
|
||||
$errors[] = sprintf(__('代理服务端的 %s 平台缺少完整凭据', 'aksrt'), $label);
|
||||
}
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
$providers = [
|
||||
'github' => ['GitHub', 'github_appid', 'github_appkey'],
|
||||
'gitee' => ['Gitee', 'gitee_appid', 'gitee_appkey'],
|
||||
'qq' => ['QQ', 'qq_appid', 'qq_appkey'],
|
||||
'google' => ['Google', 'google_client_id', 'google_client_secret'],
|
||||
'weibo' => ['微博', 'weibo_appid', 'weibo_appkey'],
|
||||
'weixin' => ['微信开放平台', 'weixin_appid', 'weixin_appsecret'],
|
||||
];
|
||||
$enabled = 0;
|
||||
foreach ($providers as $provider => [$label, $id_key, $secret_key]) {
|
||||
if (empty($settings['oauth_' . $provider . '_s'])) {
|
||||
continue;
|
||||
}
|
||||
$enabled++;
|
||||
if (trim((string) ($settings[$id_key] ?? '')) === '' || !$this->oauth_secret_is_configured($settings[$secret_key] ?? '', $stored[$secret_key] ?? '')) {
|
||||
$errors[] = sprintf(__('%s 登录已启用,请填写完整凭据', 'aksrt'), $label);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($settings['oauth_weixingzh_s'])) {
|
||||
$enabled++;
|
||||
$gzh_type = sanitize_key((string) ($settings['weixingzh_gzh_type'] ?? 'service'));
|
||||
$base_ready = $this->oauth_secret_is_configured($settings['weixingzh_token'] ?? '', $stored['weixingzh_token'] ?? '')
|
||||
&& trim((string) ($settings['weixingzh_appid'] ?? '')) !== '';
|
||||
$mode_ready = $gzh_type === 'subscribe'
|
||||
? trim((string) ($settings['weixingzh_code_qrcode'] ?? '')) !== ''
|
||||
&& trim((string) ($settings['weixingzh_code_keyword'] ?? '')) !== ''
|
||||
: $this->oauth_secret_is_configured($settings['weixingzh_appsecret'] ?? '', $stored['weixingzh_appsecret'] ?? '');
|
||||
if (!$base_ready || !$mode_ready) {
|
||||
$errors[] = $gzh_type === 'subscribe'
|
||||
? __('微信公众号订阅号登录已启用,请填写 Token、AppID、二维码和回复关键词', 'aksrt')
|
||||
: __('微信公众号服务号登录已启用,请填写 Token、AppID 和 AppSecret', 'aksrt');
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($settings['clogin_s'])) {
|
||||
$enabled++;
|
||||
if (!$this->is_secure_oauth_url((string) ($settings['clogin_url'] ?? ''))
|
||||
|| trim((string) ($settings['clogin_id'] ?? '')) === ''
|
||||
|| !$this->oauth_secret_is_configured($settings['clogin_key'] ?? '', $stored['clogin_key'] ?? '')
|
||||
|| empty($settings['clogin_oauth_s'])
|
||||
) {
|
||||
$errors[] = __('彩虹聚合登录已启用,请填写 HTTPS 接口地址、AppID、AppKey 并选择平台', 'aksrt');
|
||||
}
|
||||
}
|
||||
|
||||
if ($enabled === 0) {
|
||||
$errors[] = __('启用第三方登录后,至少需要启用一个本地登录提供商', 'aksrt');
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private function oauth_secret_is_configured($value, $stored_value = ''): bool {
|
||||
$value = trim((string) $value);
|
||||
if ($value === '***') {
|
||||
return trim((string) $stored_value) !== '';
|
||||
}
|
||||
return $value !== '';
|
||||
}
|
||||
|
||||
private function is_secure_oauth_url(string $url): bool {
|
||||
$url = trim($url);
|
||||
if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||
return false;
|
||||
}
|
||||
$parts = wp_parse_url($url);
|
||||
return is_array($parts)
|
||||
&& strtolower((string) ($parts['scheme'] ?? '')) === 'https'
|
||||
&& !empty($parts['host'])
|
||||
&& empty($parts['user'])
|
||||
&& empty($parts['pass'])
|
||||
&& empty($parts['fragment']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
/**
|
||||
* SchemaProvider 枚举字段取值归一化(大小写不敏感匹配 / 选项声明值回写)。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait SchemaProviderOptionMatch {
|
||||
|
||||
/**
|
||||
* 对大小写不敏感字段(case_insensitive),把值归一化为选项中的规范写法;
|
||||
* 匹配不到返回 null。
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param array<int,mixed> $options
|
||||
*/
|
||||
private function normalize_case_insensitive_option($value, array $options): ?string {
|
||||
if (!is_string($value) || $value === '') {
|
||||
return null;
|
||||
}
|
||||
$lower = strtolower(trim($value));
|
||||
foreach ($options as $opt) {
|
||||
$opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt;
|
||||
if (is_string($opt_value) && strtolower($opt_value) === $lower) {
|
||||
return $opt_value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a submitted enum value to the value declared by its option.
|
||||
*
|
||||
* Payloads reach the sanitizer as strings (or numbers), so a strict match
|
||||
* against the schema would reject a valid choice whenever the option value
|
||||
* is not itself a string -- e.g. 积分兑换明细.level declares 1 / 2 while the
|
||||
* SPA submits them as numbers and sanitize_text_field() then turns them into
|
||||
* "1" / "2". Returning the declared value keeps the stored type aligned with
|
||||
* the schema, which the radio/select controls compare with === when they
|
||||
* re-render a saved setting.
|
||||
*
|
||||
* Returns null when nothing matches, so validation still reports the
|
||||
* invalid value instead of silently rewriting the operator's input.
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param array<int,mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/**
|
||||
* 全站唯一的颜色值净化器(SPA 设置、小工具、悬浮按钮、工单状态、前台渲染共用)。
|
||||
*
|
||||
* 前端对应实现:assets/admin/src/utils/color.js(normalizeColor),两端规则保持一致。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class Color {
|
||||
|
||||
private const HEX = '/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i';
|
||||
private const HEX_ALPHA = '/^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i';
|
||||
private const RGB = '/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(0(?:\.\d+)?|1(?:\.0+)?|\.\d+)\s*)?\)$/i';
|
||||
|
||||
/**
|
||||
* 返回规范化颜色;非法值返回空串。
|
||||
*
|
||||
* - 默认只接受 #rgb / #rrggbb(可省略 #),输出小写并补 #。
|
||||
* - $allow_alpha 时额外接受 #rgba / #rrggbbaa 与 rgb()/rgba(),
|
||||
* rgb 形态统一输出为 `rgb(r, g, b)` / `rgba(r, g, b, a)`。
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
public static function sanitize($value, bool $allow_alpha = false): string {
|
||||
if (!is_string($value)) {
|
||||
return '';
|
||||
}
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match($allow_alpha ? self::HEX_ALPHA : self::HEX, $value, $m) === 1) {
|
||||
return '#' . strtolower($m[1]);
|
||||
}
|
||||
if (!$allow_alpha || preg_match(self::RGB, $value, $m) !== 1) {
|
||||
return '';
|
||||
}
|
||||
$channels = [(int) $m[1], (int) $m[2], (int) $m[3]];
|
||||
foreach ($channels as $channel) {
|
||||
if ($channel > 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) . ')';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'] ?? '')) ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '')) ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ final class FormRenderer {
|
||||
|
||||
private static function background(SchemaWidget $widget, string $key, array $value): void {
|
||||
echo '<div class="aksrt-widget-background">';
|
||||
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 '</div></details>';
|
||||
}
|
||||
|
||||
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 '<input class="widefat" type="' . esc_attr($type) . '" id="' . esc_attr($id) . '" name="' . esc_attr($name) . '" value="' . esc_attr($value) . '" placeholder="' . esc_attr($placeholder) . '">';
|
||||
echo '<input class="' . esc_attr($class) . '" type="' . esc_attr($type) . '" id="' . esc_attr($id) . '" name="' . esc_attr($name) . '" value="' . esc_attr($value) . '" placeholder="' . esc_attr($placeholder) . '">';
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, /<AksColorPicker[\s\S]*?:alpha="!!schema\.showAlpha"/);
|
||||
});
|
||||
|
||||
test('dead composite branches are gone', () => {
|
||||
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\(\)/);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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\('\/\^#/);
|
||||
});
|
||||
@@ -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, /<AksColorPicker/);
|
||||
assert.doesNotMatch(status, /n-color-picker|HEX_RE/);
|
||||
assert.match(status, /#6b7280/);
|
||||
const color = read('assets/admin/src/utils/color.js');
|
||||
assert.match(color, /export const HEX_RE = \/\^#\?\(\[0-9a-f\]\{3\}\|\[0-9a-f\]\{6\}\)\$\/i/);
|
||||
|
||||
const prereply = read('assets/admin/src/pages/users/partials/ticket/TicketPrereplyPanel.vue');
|
||||
// 排序接口要求全量 ID。
|
||||
|
||||
@@ -62,6 +62,9 @@ namespace {
|
||||
require dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php';
|
||||
require dirname(__DIR__, 2) . '/src/Settings/PaymentRuntime.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPayment.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';
|
||||
require dirname(__DIR__, 2) . '/src/Content/VipServicePackages.php';
|
||||
|
||||
|
||||
Reference in new issue
Block a user