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})`
|
||||
}
|
||||
Reference in new issue
Block a user