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:
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>
|
||||
Reference in new issue
Block a user