Files
wordpress-theme-aksrt/assets/admin/src/pages/users/partials/ticket/TicketStatusPanel.vue
T
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00

524 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<n-card class="jio-section-card jio-ticket-status-panel">
<div class="jio-section-header">
<h3>工单状态</h3>
<n-button size="small" type="primary" :disabled="saving" @click="openCreate">新增状态</n-button>
</div>
<n-alert type="info" :show-icon="false" style="margin-bottom: 12px;">
默认状态不可修改或删除;删除非默认状态前需先清空该状态下的工单。标记为「完结」的状态不可再流转。
</n-alert>
<n-empty v-if="!rows.length && !loading" description="暂无工单状态" />
<n-data-table
v-else
:columns="columns"
:data="rows"
:bordered="false"
size="small"
:loading="loading"
:scroll-x="680"
/>
<n-modal
v-model:show="visible"
:title="editing ? '编辑状态' : '新增状态'"
preset="card"
class="jio-status-modal"
style="width: 460px; max-width: 92vw;"
:mask-closable="false"
>
<div class="jio-status-preview" aria-live="polite">
<span class="jio-status-preview__label">预览</span>
<span class="jio-ticket-badge" :style="{ '--jio-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" show-count />
</n-form-item>
<n-form-item label="标识" path="status_key">
<div class="jio-status-key">
<n-input
v-model:value="form.status_key"
class="jio-status-key__input"
:placeholder="editing ? '' : '留空则按名称自动生成'"
maxlength="24"
show-count
:input-props="{ autocomplete: 'off', spellcheck: false }"
@blur="form.status_key = normalizeKey(form.status_key)"
/>
<span class="jio-ticket-status__hint">
小写字母开头,仅含小写字母、数字与下划线(2–24 位)。修改后,使用该状态的工单会同步改为新标识。
</span>
</div>
</n-form-item>
<n-form-item label="状态颜色" path="color">
<div class="jio-status-color">
<div class="jio-status-color__swatches" role="radiogroup" aria-label="状态颜色">
<button
v-for="swatch in SWATCHES"
:key="swatch"
type="button"
role="radio"
class="jio-status-color__swatch"
:class="{ 'is-active': currentHex === swatch }"
:style="{ '--swatch': swatch }"
:aria-checked="currentHex === swatch"
:aria-label="`颜色 ${swatch}`"
:title="swatch"
@click="form.color = swatch"
/>
<!-- 自定义色:未选中时显示色环,选中后显示当前色并带选中环。 -->
<JioColorPicker
:model-value="previewColor"
@update:model-value="setColor"
>
<template #trigger="{ onClick, ref: setTriggerRef }">
<button
:ref="setTriggerRef"
type="button"
role="radio"
class="jio-status-color__swatch jio-status-color__swatch--custom"
:class="{ 'is-active': isCustomColor }"
:style="{ '--swatch': previewColor }"
:aria-checked="isCustomColor"
aria-label="自定义颜色"
title="自定义颜色"
@click="onClick"
/>
</template>
</JioColorPicker>
</div>
<n-input
v-model:value="form.color"
class="jio-status-color__hex"
maxlength="7"
placeholder="#6b7280"
@blur="form.color = toHex(form.color) || form.color.trim()"
>
<template #prefix>
<i class="jio-status-color__chip" :style="{ background: previewColor }" />
</template>
</n-input>
</div>
</n-form-item>
<n-form-item label="完结状态" path="is_closed" :show-feedback="false">
<div class="jio-status-closed">
<n-switch v-model:value="form.is_closed" />
<span class="jio-ticket-status__hint">开启后该状态视为终态,工单不可再流转</span>
</div>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button :disabled="saving" @click="visible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</n-card>
</template>
<script setup>
import { computed, h, onMounted, ref } from 'vue'
import { NButton, NSpace, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import JioColorPicker from '@/components/form/JioColorPicker.vue'
import { toHex } from '@/utils/color.js'
const message = useMessage()
const dialog = useDialog()
/** 与 TicketStatusService::sanitize_color() 的取值范围保持一致。 */
const SWATCHES = [
'#6b7280', '#f59e0b', '#2271b1', '#16a34a',
'#d03050', '#7c3aed', '#0891b2', '#ea580c',
]
const loading = ref(false)
const saving = ref(false)
const visible = ref(false)
const formRef = ref(null)
const editing = ref(null)
const rows = ref([])
const DEFAULT_COLOR = '#6b7280'
/** 与 TicketStatusService::KEY_PATTERN 保持一致。 */
const KEY_RE = /^[a-z][a-z0-9_]{1,23}$/
const form = ref({ name: '', status_key: '', color: DEFAULT_COLOR, is_closed: false })
function normalizeKey(value) {
return String(value || '').trim().toLowerCase()
}
const rules = {
name: [
{ required: true, message: '请填写状态名称', trigger: 'blur' },
{ max: 60, message: '状态名称不能超过 60 字', trigger: 'blur' },
],
status_key: [
{
// 新增时可留空(后端按名称生成);编辑时必须填写。
validator: (_rule, value) => {
const key = normalizeKey(value)
if (key === '') return !editing.value
return KEY_RE.test(key)
},
message: '标识需以小写字母开头,仅含小写字母、数字与下划线,长度 2–24 位',
trigger: ['blur', 'input'],
},
{
validator: (_rule, value) => {
const key = normalizeKey(value)
return key === '' || !rows.value.some((row) => row.status_key === key && row.id !== editing.value?.id)
},
message: '该标识已被其他状态使用',
trigger: ['blur', 'input'],
},
],
color: [
{
validator: (_rule, value) => toHex(value) !== '',
message: '颜色需为 #rgb 或 #rrggbb',
trigger: ['blur', 'input'],
},
],
}
const currentHex = computed(() => toHex(form.value.color))
const previewColor = computed(() => currentHex.value || DEFAULT_COLOR)
const isCustomColor = computed(() => currentHex.value !== '' && !SWATCHES.includes(currentHex.value))
/** JioColorPicker 已归一化为 hex;清空(空串)时保留当前色。 */
function setColor(value) {
const hex = toHex(value)
if (hex) form.value.color = hex
}
const columns = computed(() => [
{ title: 'ID', key: 'id', width: 80 },
{
title: '状态',
key: 'name',
minWidth: 160,
// 注意:原生元素用 h('span', ...) 时,文本必须作为 children 直接传入;
// 写成 () => row.name 的插槽函数对原生元素不生效,单元格会是空的。
render: (row) => h('span', { class: 'jio-ticket-badge', style: { '--jio-ticket-color': row.color } }, row.name),
},
{
title: '颜色',
key: 'color',
width: 140,
render: (row) => h('span', { class: 'jio-ticket-color-cell' }, [
h('i', { class: 'jio-ticket-color-dot', style: { background: row.color } }),
h('span', { class: 'jio-ticket-color-value' }, row.color),
]),
},
{
title: '完结',
key: 'is_closed',
width: 90,
render: (row) => (row.is_closed ? '是' : '否'),
},
{
title: '默认',
key: 'is_default',
width: 90,
render: (row) => (row.is_default ? '是' : '否'),
},
{
title: '标识',
key: 'status_key',
minWidth: 140,
render: (row) => h('span', { class: 'jio-ticket-key' }, row.status_key),
},
{
title: '操作',
key: 'actions',
width: 130,
render: (row) => h(NSpace, { size: 'small' }, () => [
h(NButton, {
size: 'tiny',
type: 'primary',
quaternary: true,
disabled: row.is_default,
onClick: () => openEdit(row),
}, () => '编辑'),
h(NButton, {
size: 'tiny',
type: 'error',
quaternary: true,
disabled: row.is_default,
onClick: () => handleDelete(row),
}, () => '删除'),
]),
},
])
async function load() {
loading.value = true
try {
const res = await api.listTicketStatuses()
rows.value = res?.data || []
} catch (error) {
message.error(error?.message || '加载工单状态失败')
} finally {
loading.value = false
}
}
function openCreate() {
editing.value = null
form.value = { name: '', status_key: '', color: DEFAULT_COLOR, is_closed: false }
visible.value = true
}
function openEdit(row) {
editing.value = row
form.value = {
name: String(row.name || ''),
status_key: String(row.status_key || ''),
color: toHex(row.color) || DEFAULT_COLOR,
is_closed: !!row.is_closed,
}
visible.value = true
}
async function handleSubmit() {
try {
await formRef.value?.validate()
} catch {
return
}
saving.value = true
try {
const payload = {
name: form.value.name.trim(),
status_key: normalizeKey(form.value.status_key),
color: currentHex.value,
is_closed: form.value.is_closed ? 1 : 0,
}
if (editing.value) {
await api.updateTicketStatus(editing.value.id, payload)
message.success('状态已保存')
} else {
await api.createTicketStatus(payload)
message.success('状态已创建')
}
visible.value = false
await load()
} catch (error) {
message.error(error?.message || '保存状态失败')
} finally {
saving.value = false
}
}
function handleDelete(row) {
dialog.warning({
title: '删除状态',
content: `确认删除状态「${row.name}」吗?该状态下仍有工单时将被拒绝。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.deleteTicketStatus(row.id)
message.success('状态已删除')
await load()
} catch (error) {
message.error(error?.message || '删除状态失败')
}
},
})
}
onMounted(load)
defineExpose({ reload: load })
</script>
<!--
表格单元格由 h() 在 NDataTable 的渲染上下文中创建,拿不到本组件的 scoped 属性,
所以单元格样式放在非 scoped 块里,并以面板根类限定作用域。
(此前色块 <i> 因此没有尺寸,颜色列只剩色值文本。)
-->
<style>
.jio-ticket-status-panel .jio-ticket-color-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.jio-ticket-status-panel .jio-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;
}
.jio-ticket-status-panel .jio-ticket-color-value,
.jio-ticket-status-panel .jio-ticket-key {
font-family: var(--n-font-family-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-size: 12px;
color: var(--jio-color-muted);
}
/* 徽章与前台 assets/css/routes/tickets.css 的 .jio-ticket-badge 同构;表格与弹窗预览共用。 */
.jio-ticket-status-panel .jio-ticket-badge,
.jio-status-modal .jio-ticket-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--jio-ticket-color, #6b7280) 16%, transparent);
color: var(--jio-ticket-color, #6b7280);
font-size: 12px;
font-weight: 600;
line-height: 1.6;
transition: background-color 0.15s ease, color 0.15s ease;
}
</style>
<style scoped>
/* —— 弹窗:顶部实时预览 —— */
.jio-status-preview {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
padding: 14px 16px;
border: 1px dashed var(--jio-color-border);
border-radius: 8px;
background: rgba(0, 0, 0, 0.02);
}
.jio-status-preview__label {
color: var(--jio-color-muted);
font-size: 12px;
}
.jio-status-preview .jio-ticket-badge {
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* —— 弹窗:颜色选择(预设色板 + 自定义 + 十六进制输入) —— */
.jio-status-color {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.jio-status-color__swatches {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.jio-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;
}
.jio-status-color__swatch:hover {
transform: scale(1.1);
}
.jio-status-color__swatch:focus-visible {
outline: 2px solid var(--jio-color-primary, #2271b1);
outline-offset: 2px;
}
/* 选中态:白色内圈 + 同色外环,深浅主题都清晰。 */
.jio-status-color__swatch.is-active {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--swatch);
}
.jio-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);
}
/* 自定义:未选中时显示色环,提示「更多颜色」。 */
.jio-status-color__swatch--custom:not(.is-active) {
background: conic-gradient(#ef4444, #f59e0b, #eab308, #22c55e, #06b6d4, #3b82f6, #8b5cf6, #ec4899, #ef4444);
}
.jio-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;
}
.jio-status-color__hex {
width: 160px;
font-family: var(--n-font-family-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
}
.jio-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);
}
/* —— 弹窗:标识 —— */
.jio-status-key {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.jio-status-key__input {
font-family: var(--n-font-family-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
}
/* —— 弹窗:完结开关 —— */
.jio-status-closed {
display: flex;
align-items: center;
gap: 10px;
}
.jio-ticket-status__hint {
color: var(--jio-color-muted);
font-size: 12px;
}
</style>