feat(ticket): redesign status color picker and show swatches in color column

- Move table cell styles out of scoped block so h()-rendered swatches get size
- Replace the bare color-picker trigger with preset swatch radios + custom picker
- Disable alpha so picked colors sync to the hex input (was #rrggbbaa, dropped)
- Normalize hex via toHex() matching TicketStatusService::sanitize_color()
- Add live badge preview, hex input chip, inline closed-state hint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-26 22:02:52 +08:00
1 parent 1a88498d57
commit af4fb87e6d
1 file changed
+235 -58
@@ -1,5 +1,5 @@
<template> <template>
<n-card class="aksrt-section-card"> <n-card class="aksrt-section-card aksrt-ticket-status-panel">
<div class="aksrt-section-header"> <div class="aksrt-section-header">
<h3>工单状态</h3> <h3>工单状态</h3>
<n-button size="small" type="primary" :disabled="saving" @click="openCreate">新增状态</n-button> <n-button size="small" type="primary" :disabled="saving" @click="openCreate">新增状态</n-button>
@@ -24,30 +24,78 @@
v-model:show="visible" v-model:show="visible"
:title="editing ? '编辑状态' : '新增状态'" :title="editing ? '编辑状态' : '新增状态'"
preset="card" preset="card"
class="aksrt-status-modal"
style="width: 460px; max-width: 92vw;" style="width: 460px; max-width: 92vw;"
:mask-closable="false" :mask-closable="false"
> >
<div class="aksrt-status-preview" aria-live="polite">
<span class="aksrt-status-preview__label">预览</span>
<span class="aksrt-ticket-badge" :style="{ '--aksrt-ticket-color': previewColor }">
{{ form.name.trim() || '状态名称' }}
</span>
</div>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top"> <n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<n-form-item label="状态名称" path="name"> <n-form-item label="状态名称" path="name">
<n-input v-model:value="form.name" placeholder="如:处理中" maxlength="60" /> <n-input v-model:value="form.name" placeholder="如:处理中" maxlength="60" show-count />
</n-form-item> </n-form-item>
<n-form-item label="状态颜色" path="color"> <n-form-item label="状态颜色" path="color">
<n-space align="center" :wrap="true"> <div class="aksrt-status-color">
<n-color-picker <div class="aksrt-status-color__swatches" role="radiogroup" aria-label="状态颜色">
:value="previewColor" <button
:modes="['hex']" v-for="swatch in SWATCHES"
:swatches="SWATCHES" :key="swatch"
@update:value="setColor" type="button"
/> role="radio"
<n-input v-model:value="form.color" style="width: 120px;" placeholder="#6b7280" /> class="aksrt-status-color__swatch"
<span class="aksrt-ticket-badge" :style="{ '--aksrt-ticket-color': previewColor }"> :class="{ 'is-active': currentHex === swatch }"
{{ form.name || '预览' }} :style="{ '--swatch': swatch }"
</span> :aria-checked="currentHex === swatch"
</n-space> :aria-label="`颜色 ${swatch}`"
:title="swatch"
@click="form.color = swatch"
/>
<!-- 自定义色:未选中时显示色环,选中后显示当前色并带选中环。 -->
<n-color-picker
:value="previewColor"
:modes="['hex']"
:show-alpha="false"
@update:value="setColor"
>
<template #trigger="{ onClick, ref: setTriggerRef }">
<button
:ref="setTriggerRef"
type="button"
role="radio"
class="aksrt-status-color__swatch aksrt-status-color__swatch--custom"
:class="{ 'is-active': isCustomColor }"
:style="{ '--swatch': previewColor }"
:aria-checked="isCustomColor"
aria-label="自定义颜色"
title="自定义颜色"
@click="onClick"
/>
</template>
</n-color-picker>
</div>
<n-input
v-model:value="form.color"
class="aksrt-status-color__hex"
maxlength="7"
placeholder="#6b7280"
@blur="form.color = toHex(form.color) || form.color.trim()"
>
<template #prefix>
<i class="aksrt-status-color__chip" :style="{ background: previewColor }" />
</template>
</n-input>
</div>
</n-form-item> </n-form-item>
<n-form-item label="完结状态" path="is_closed"> <n-form-item label="完结状态" path="is_closed" :show-feedback="false">
<n-switch v-model:value="form.is_closed" /> <div class="aksrt-status-closed">
<span class="aksrt-ticket-status__hint">开启后该状态视为终态,工单不可再流转</span> <n-switch v-model:value="form.is_closed" />
<span class="aksrt-ticket-status__hint">开启后该状态视为终态,工单不可再流转</span>
</div>
</n-form-item> </n-form-item>
</n-form> </n-form>
<template #footer> <template #footer>
@@ -81,7 +129,20 @@ const formRef = ref(null)
const editing = ref(null) const editing = ref(null)
const rows = ref([]) const rows = ref([])
const form = ref({ name: '', color: '#6b7280', is_closed: false }) const DEFAULT_COLOR = '#6b7280'
const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
const form = ref({ name: '', color: DEFAULT_COLOR, is_closed: false })
/**
* 归一化为 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 = { const rules = {
name: [ name: [
@@ -90,29 +151,24 @@ const rules = {
], ],
color: [ color: [
{ {
validator: (_rule, value) => /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || '')), validator: (_rule, value) => toHex(value) !== '',
message: '颜色需为 #rgb 或 #rrggbb', message: '颜色需为 #rgb 或 #rrggbb',
trigger: 'blur', trigger: ['blur', 'input'],
}, },
], ],
} }
const previewColor = computed(() => const currentHex = computed(() => toHex(form.value.color))
/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(form.value.color || '')) const previewColor = computed(() => currentHex.value || DEFAULT_COLOR)
? form.value.color const isCustomColor = computed(() => currentHex.value !== '' && !SWATCHES.includes(currentHex.value))
: '#6b7280'
)
/** /**
* 取色器在开启 alpha 时会回传 rgba() 字符串,而 TicketStatusService::sanitize_color() * 取色器已关闭 alpha(默认开启时回传 #rrggbbaa,会被后端回落成灰色),
* 只接受 #rgb / #rrggbb(其余静默回落灰色)。这里只接十六进制值,其余一律忽略, * 这里仍只接受合法十六进制,其余忽略。
* 避免「选了色却存成灰色」的静默失败。
*/ */
function setColor(value) { function setColor(value) {
const hex = typeof value === 'string' ? value.trim() : '' const hex = toHex(value)
if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) { if (hex) form.value.color = hex
form.value.color = hex
}
} }
const columns = computed(() => [ const columns = computed(() => [
@@ -189,7 +245,7 @@ async function load() {
function openCreate() { function openCreate() {
editing.value = null editing.value = null
form.value = { name: '', color: '#6b7280', is_closed: false } form.value = { name: '', color: DEFAULT_COLOR, is_closed: false }
visible.value = true visible.value = true
} }
@@ -197,7 +253,7 @@ function openEdit(row) {
editing.value = row editing.value = row
form.value = { form.value = {
name: String(row.name || ''), name: String(row.name || ''),
color: String(row.color || '#6b7280'), color: toHex(row.color) || DEFAULT_COLOR,
is_closed: !!row.is_closed, is_closed: !!row.is_closed,
} }
visible.value = true visible.value = true
@@ -213,7 +269,7 @@ async function handleSubmit() {
try { try {
const payload = { const payload = {
name: form.value.name.trim(), name: form.value.name.trim(),
color: form.value.color, color: currentHex.value,
is_closed: form.value.is_closed ? 1 : 0, is_closed: form.value.is_closed ? 1 : 0,
} }
if (editing.value) { if (editing.value) {
@@ -255,42 +311,37 @@ onMounted(load)
defineExpose({ reload: load }) defineExpose({ reload: load })
</script> </script>
<style scoped> <!--
.aksrt-ticket-status__hint { 表格单元格由 h() 在 NDataTable 的渲染上下文中创建,拿不到本组件的 scoped 属性,
margin-left: 10px; 所以单元格样式放在非 scoped 块里,并以面板根类限定作用域。
color: var(--aksrt-color-muted); (此前色块 <i> 因此没有尺寸,颜色列只剩色值文本。)
font-size: 12px; -->
} <style>
.aksrt-ticket-status-panel .aksrt-ticket-color-cell {
/* 颜色列:色块 + 十六进制值,色块边界在浅色主题下也能看清。 */
.aksrt-ticket-color-cell {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
} }
.aksrt-ticket-color-dot { .aksrt-ticket-status-panel .aksrt-ticket-color-dot {
width: 14px; display: inline-block;
height: 14px; width: 16px;
height: 16px;
border-radius: 4px; border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
flex: 0 0 auto; flex: 0 0 auto;
} }
.aksrt-ticket-color-value { .aksrt-ticket-status-panel .aksrt-ticket-color-value,
font-family: var(--n-font-family-mono, monospace); .aksrt-ticket-status-panel .aksrt-ticket-key {
font-family: var(--n-font-family-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-size: 12px; font-size: 12px;
color: var(--aksrt-color-muted); color: var(--aksrt-color-muted);
} }
.aksrt-ticket-key { /* 徽章与前台 assets/css/routes/tickets.css 的 .aksrt-ticket-badge 同构;表格与弹窗预览共用。 */
font-family: var(--n-font-family-mono, monospace); .aksrt-ticket-status-panel .aksrt-ticket-badge,
font-size: 12px; .aksrt-status-modal .aksrt-ticket-badge {
color: var(--aksrt-color-muted);
}
/* 徽章与前台 assets/css/routes/tickets.css 的 .aksrt-ticket-badge 同构。 */
.aksrt-ticket-badge {
display: inline-block; display: inline-block;
padding: 2px 10px; padding: 2px 10px;
border-radius: 999px; border-radius: 999px;
@@ -299,5 +350,131 @@ defineExpose({ reload: load })
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
line-height: 1.6; line-height: 1.6;
transition: background-color 0.15s ease, color 0.15s ease;
}
</style>
<style scoped>
/* —— 弹窗:顶部实时预览 —— */
.aksrt-status-preview {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
padding: 14px 16px;
border: 1px dashed var(--aksrt-color-border);
border-radius: 8px;
background: rgba(0, 0, 0, 0.02);
}
.aksrt-status-preview__label {
color: var(--aksrt-color-muted);
font-size: 12px;
}
.aksrt-status-preview .aksrt-ticket-badge {
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* —— 弹窗:颜色选择(预设色板 + 自定义 + 十六进制输入) —— */
.aksrt-status-color {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.aksrt-status-color__swatches {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.aksrt-status-color__swatch {
position: relative;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--swatch);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.aksrt-status-color__swatch:hover {
transform: scale(1.1);
}
.aksrt-status-color__swatch:focus-visible {
outline: 2px solid var(--aksrt-color-primary, #2271b1);
outline-offset: 2px;
}
/* 选中态:白色内圈 + 同色外环,深浅主题都清晰。 */
.aksrt-status-color__swatch.is-active {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--swatch);
}
.aksrt-status-color__swatch.is-active::after {
content: '';
position: absolute;
left: 50%;
top: 45%;
width: 6px;
height: 11px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: translate(-50%, -50%) rotate(45deg);
}
/* 自定义:未选中时显示色环,提示「更多颜色」。 */
.aksrt-status-color__swatch--custom:not(.is-active) {
background: conic-gradient(#ef4444, #f59e0b, #eab308, #22c55e, #06b6d4, #3b82f6, #8b5cf6, #ec4899, #ef4444);
}
.aksrt-status-color__swatch--custom:not(.is-active)::after {
content: '+';
position: absolute;
inset: 5px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #fff;
color: #4b5563;
font-size: 14px;
font-weight: 600;
line-height: 1;
}
.aksrt-status-color__hex {
width: 160px;
font-family: var(--n-font-family-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
}
.aksrt-status-color__chip {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 3px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
}
/* —— 弹窗:完结开关 —— */
.aksrt-status-closed {
display: flex;
align-items: center;
gap: 10px;
}
.aksrt-ticket-status__hint {
color: var(--aksrt-color-muted);
font-size: 12px;
} }
</style> </style>