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>
524 lines
15 KiB
Vue
524 lines
15 KiB
Vue
<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>
|