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>
<n-card class="aksrt-section-card">
<n-card class="aksrt-section-card aksrt-ticket-status-panel">
<div class="aksrt-section-header">
<h3>工单状态</h3>
<n-button size="small" type="primary" :disabled="saving" @click="openCreate">新增状态</n-button>
@@ -24,30 +24,78 @@
v-model:show="visible"
:title="editing ? '编辑状态' : '新增状态'"
preset="card"
class="aksrt-status-modal"
style="width: 460px; max-width: 92vw;"
: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-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 label="状态颜色" path="color">
<n-space align="center" :wrap="true">
<n-color-picker
:value="previewColor"
:modes="['hex']"
:swatches="SWATCHES"
@update:value="setColor"
/>
<n-input v-model:value="form.color" style="width: 120px;" placeholder="#6b7280" />
<span class="aksrt-ticket-badge" :style="{ '--aksrt-ticket-color': previewColor }">
{{ form.name || '预览' }}
</span>
</n-space>
<div class="aksrt-status-color">
<div class="aksrt-status-color__swatches" role="radiogroup" aria-label="状态颜色">
<button
v-for="swatch in SWATCHES"
:key="swatch"
type="button"
role="radio"
class="aksrt-status-color__swatch"
:class="{ 'is-active': currentHex === swatch }"
:style="{ '--swatch': swatch }"
:aria-checked="currentHex === swatch"
: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 label="完结状态" path="is_closed">
<n-switch v-model:value="form.is_closed" />
<span class="aksrt-ticket-status__hint">开启后该状态视为终态,工单不可再流转</span>
<n-form-item label="完结状态" path="is_closed" :show-feedback="false">
<div class="aksrt-status-closed">
<n-switch v-model:value="form.is_closed" />
<span class="aksrt-ticket-status__hint">开启后该状态视为终态,工单不可再流转</span>
</div>
</n-form-item>
</n-form>
<template #footer>
@@ -81,7 +129,20 @@ const formRef = ref(null)
const editing = ref(null)
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 = {
name: [
@@ -90,29 +151,24 @@ const rules = {
],
color: [
{
validator: (_rule, value) => /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || '')),
validator: (_rule, value) => toHex(value) !== '',
message: '颜色需为 #rgb 或 #rrggbb',
trigger: 'blur',
trigger: ['blur', 'input'],
},
],
}
const previewColor = computed(() =>
/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(form.value.color || ''))
? form.value.color
: '#6b7280'
)
const currentHex = computed(() => toHex(form.value.color))
const previewColor = computed(() => currentHex.value || DEFAULT_COLOR)
const isCustomColor = computed(() => currentHex.value !== '' && !SWATCHES.includes(currentHex.value))
/**
* 取色器在开启 alpha 时会回传 rgba() 字符串,而 TicketStatusService::sanitize_color()
* 只接受 #rgb / #rrggbb(其余静默回落灰色)。这里只接十六进制值,其余一律忽略,
* 避免「选了色却存成灰色」的静默失败。
* 取色器已关闭 alpha(默认开启时回传 #rrggbbaa,会被后端回落成灰色),
* 这里仍只接受合法十六进制,其余忽略。
*/
function setColor(value) {
const hex = typeof value === 'string' ? value.trim() : ''
if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) {
form.value.color = hex
}
const hex = toHex(value)
if (hex) form.value.color = hex
}
const columns = computed(() => [
@@ -189,7 +245,7 @@ async function load() {
function openCreate() {
editing.value = null
form.value = { name: '', color: '#6b7280', is_closed: false }
form.value = { name: '', color: DEFAULT_COLOR, is_closed: false }
visible.value = true
}
@@ -197,7 +253,7 @@ function openEdit(row) {
editing.value = row
form.value = {
name: String(row.name || ''),
color: String(row.color || '#6b7280'),
color: toHex(row.color) || DEFAULT_COLOR,
is_closed: !!row.is_closed,
}
visible.value = true
@@ -213,7 +269,7 @@ async function handleSubmit() {
try {
const payload = {
name: form.value.name.trim(),
color: form.value.color,
color: currentHex.value,
is_closed: form.value.is_closed ? 1 : 0,
}
if (editing.value) {
@@ -255,42 +311,37 @@ onMounted(load)
defineExpose({ reload: load })
</script>
<style scoped>
.aksrt-ticket-status__hint {
margin-left: 10px;
color: var(--aksrt-color-muted);
font-size: 12px;
}
/* 颜色列:色块 + 十六进制值,色块边界在浅色主题下也能看清。 */
.aksrt-ticket-color-cell {
<!--
表格单元格由 h() 在 NDataTable 的渲染上下文中创建,拿不到本组件的 scoped 属性,
所以单元格样式放在非 scoped 块里,并以面板根类限定作用域。
(此前色块 <i> 因此没有尺寸,颜色列只剩色值文本。)
-->
<style>
.aksrt-ticket-status-panel .aksrt-ticket-color-cell {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 8px;
}
.aksrt-ticket-color-dot {
width: 14px;
height: 14px;
.aksrt-ticket-status-panel .aksrt-ticket-color-dot {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
flex: 0 0 auto;
}
.aksrt-ticket-color-value {
font-family: var(--n-font-family-mono, monospace);
.aksrt-ticket-status-panel .aksrt-ticket-color-value,
.aksrt-ticket-status-panel .aksrt-ticket-key {
font-family: var(--n-font-family-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-size: 12px;
color: var(--aksrt-color-muted);
}
.aksrt-ticket-key {
font-family: var(--n-font-family-mono, monospace);
font-size: 12px;
color: var(--aksrt-color-muted);
}
/* 徽章与前台 assets/css/routes/tickets.css 的 .aksrt-ticket-badge 同构。 */
.aksrt-ticket-badge {
/* 徽章与前台 assets/css/routes/tickets.css 的 .aksrt-ticket-badge 同构;表格与弹窗预览共用。 */
.aksrt-ticket-status-panel .aksrt-ticket-badge,
.aksrt-status-modal .aksrt-ticket-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
@@ -299,5 +350,131 @@ defineExpose({ reload: load })
font-size: 12px;
font-weight: 600;
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>