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:
kate522andClaude Fable 5 committed 2026-10-28 20:47:41 +08:00
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
+35
View File
@@ -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})`
}