feat(tickets): allow custom status key in status create/edit
- Validate status_key (^[a-z][a-z0-9_]{1,23}$, unique) on create/update
- Renaming a key moves aksrt_ticket.status in the same transaction
- Default statuses remain immutable
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8d871c0828
commit
135fb4eaa4
4 files changed
+134
-7
No files matched your search
@@ -39,6 +39,22 @@
|
|||||||
<n-form-item label="状态名称" path="name">
|
<n-form-item label="状态名称" path="name">
|
||||||
<n-input v-model:value="form.name" placeholder="如:处理中" maxlength="60" show-count />
|
<n-input v-model:value="form.name" placeholder="如:处理中" maxlength="60" show-count />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
<n-form-item label="标识" path="status_key">
|
||||||
|
<div class="aksrt-status-key">
|
||||||
|
<n-input
|
||||||
|
v-model:value="form.status_key"
|
||||||
|
class="aksrt-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="aksrt-ticket-status__hint">
|
||||||
|
小写字母开头,仅含小写字母、数字与下划线(2–24 位)。修改后,使用该状态的工单会同步改为新标识。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
<n-form-item label="状态颜色" path="color">
|
<n-form-item label="状态颜色" path="color">
|
||||||
<div class="aksrt-status-color">
|
<div class="aksrt-status-color">
|
||||||
<div class="aksrt-status-color__swatches" role="radiogroup" aria-label="状态颜色">
|
<div class="aksrt-status-color__swatches" role="radiogroup" aria-label="状态颜色">
|
||||||
@@ -132,7 +148,14 @@ const rows = ref([])
|
|||||||
const DEFAULT_COLOR = '#6b7280'
|
const DEFAULT_COLOR = '#6b7280'
|
||||||
const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
|
const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
|
||||||
|
|
||||||
const form = ref({ name: '', color: DEFAULT_COLOR, is_closed: false })
|
/** 与 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()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 归一化为 TicketStatusService::sanitize_color() 接受的小写 #rgb / #rrggbb;
|
* 归一化为 TicketStatusService::sanitize_color() 接受的小写 #rgb / #rrggbb;
|
||||||
@@ -149,6 +172,26 @@ const rules = {
|
|||||||
{ required: true, message: '请填写状态名称', trigger: 'blur' },
|
{ required: true, message: '请填写状态名称', trigger: 'blur' },
|
||||||
{ max: 60, message: '状态名称不能超过 60 字', 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: [
|
color: [
|
||||||
{
|
{
|
||||||
validator: (_rule, value) => toHex(value) !== '',
|
validator: (_rule, value) => toHex(value) !== '',
|
||||||
@@ -245,7 +288,7 @@ async function load() {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editing.value = null
|
editing.value = null
|
||||||
form.value = { name: '', color: DEFAULT_COLOR, is_closed: false }
|
form.value = { name: '', status_key: '', color: DEFAULT_COLOR, is_closed: false }
|
||||||
visible.value = true
|
visible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +296,7 @@ function openEdit(row) {
|
|||||||
editing.value = row
|
editing.value = row
|
||||||
form.value = {
|
form.value = {
|
||||||
name: String(row.name || ''),
|
name: String(row.name || ''),
|
||||||
|
status_key: String(row.status_key || ''),
|
||||||
color: toHex(row.color) || DEFAULT_COLOR,
|
color: toHex(row.color) || DEFAULT_COLOR,
|
||||||
is_closed: !!row.is_closed,
|
is_closed: !!row.is_closed,
|
||||||
}
|
}
|
||||||
@@ -269,6 +313,7 @@ async function handleSubmit() {
|
|||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.value.name.trim(),
|
name: form.value.name.trim(),
|
||||||
|
status_key: normalizeKey(form.value.status_key),
|
||||||
color: currentHex.value,
|
color: currentHex.value,
|
||||||
is_closed: form.value.is_closed ? 1 : 0,
|
is_closed: form.value.is_closed ? 1 : 0,
|
||||||
}
|
}
|
||||||
@@ -466,6 +511,18 @@ defineExpose({ reload: load })
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— 弹窗:标识 —— */
|
||||||
|
.aksrt-status-key {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-status-key__input {
|
||||||
|
font-family: var(--n-font-family-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
/* —— 弹窗:完结开关 —— */
|
/* —— 弹窗:完结开关 —— */
|
||||||
.aksrt-status-closed {
|
.aksrt-status-closed {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -31,9 +31,18 @@ trait TicketConfigRest {
|
|||||||
if ($table === '') {
|
if ($table === '') {
|
||||||
return new \WP_Error('aksrt_ticket_db', __('状态表不可用', 'aksrt'), ['status' => 500]);
|
return new \WP_Error('aksrt_ticket_db', __('状态表不可用', 'aksrt'), ['status' => 500]);
|
||||||
}
|
}
|
||||||
|
$key = $this->requested_status_key($request);
|
||||||
|
if (is_wp_error($key)) {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
if ($key === '') {
|
||||||
|
$key = TicketStatusService::make_key($name);
|
||||||
|
} elseif (TicketStatusService::exists($key)) {
|
||||||
|
return new \WP_Error('aksrt_ticket_status_key_taken', __('该标识已被其他状态使用', 'aksrt'), ['status' => 409]);
|
||||||
|
}
|
||||||
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||||
$inserted = $wpdb->insert($table, [
|
$inserted = $wpdb->insert($table, [
|
||||||
'status_key' => TicketStatusService::make_key($name),
|
'status_key' => $key,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
|
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
|
||||||
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
|
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
|
||||||
@@ -60,21 +69,70 @@ trait TicketConfigRest {
|
|||||||
if ($name === '' || mb_strlen($name) > 60) {
|
if ($name === '' || mb_strlen($name) > 60) {
|
||||||
return new \WP_Error('aksrt_ticket_status_name', __('请填写有效的状态名称', 'aksrt'), ['status' => 400]);
|
return new \WP_Error('aksrt_ticket_status_name', __('请填写有效的状态名称', 'aksrt'), ['status' => 400]);
|
||||||
}
|
}
|
||||||
|
$old_key = (string) $row['status_key'];
|
||||||
|
$key = $this->requested_status_key($request);
|
||||||
|
if (is_wp_error($key)) {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
if ($key === '') {
|
||||||
|
$key = $old_key;
|
||||||
|
}
|
||||||
|
if ($key !== $old_key && TicketStatusService::exists($key)) {
|
||||||
|
return new \WP_Error('aksrt_ticket_status_key_taken', __('该标识已被其他状态使用', 'aksrt'), ['status' => 409]);
|
||||||
|
}
|
||||||
|
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
|
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
|
||||||
|
$ticket_table = \AKSRT\Core\DBMigrator::table('ticket');
|
||||||
|
$rename = $key !== $old_key;
|
||||||
|
// 改标识需同步工单表 aksrt_ticket.status,两步放进同一事务,避免工单指向不存在的状态。
|
||||||
|
if ($rename && ($ticket_table === '' || $wpdb->query('START TRANSACTION') === false)) {
|
||||||
|
return new \WP_Error('aksrt_ticket_db', __('状态保存失败', 'aksrt'), ['status' => 500]);
|
||||||
|
}
|
||||||
$updated = $wpdb->update($table, [
|
$updated = $wpdb->update($table, [
|
||||||
|
'status_key' => $key,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
|
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
|
||||||
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
|
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
|
||||||
'updated_at' => current_time('mysql'),
|
'updated_at' => current_time('mysql'),
|
||||||
], ['id' => $id], ['%s', '%s', '%d', '%s'], ['%d']);
|
], ['id' => $id], ['%s', '%s', '%s', '%d', '%s'], ['%d']);
|
||||||
|
if ($updated !== false && $rename) {
|
||||||
|
$moved = $wpdb->update($ticket_table, ['status' => $key], ['status' => $old_key], ['%s'], ['%s']);
|
||||||
|
$updated = $moved === false ? false : $updated;
|
||||||
|
}
|
||||||
if ($updated === false) {
|
if ($updated === false) {
|
||||||
|
if ($rename) {
|
||||||
|
$wpdb->query('ROLLBACK');
|
||||||
|
}
|
||||||
return new \WP_Error('aksrt_ticket_db', __('状态保存失败', 'aksrt'), ['status' => 500]);
|
return new \WP_Error('aksrt_ticket_db', __('状态保存失败', 'aksrt'), ['status' => 500]);
|
||||||
}
|
}
|
||||||
|
if ($rename) {
|
||||||
|
$wpdb->query('COMMIT');
|
||||||
|
}
|
||||||
TicketStatusService::flush();
|
TicketStatusService::flush();
|
||||||
return rest_ensure_response(['success' => true]);
|
return rest_ensure_response(['success' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取并校验请求里的自定义标识。空串表示「未填写」,由调用方决定生成或沿用。
|
||||||
|
*
|
||||||
|
* @return string|\WP_Error
|
||||||
|
*/
|
||||||
|
private function requested_status_key(\WP_REST_Request $request) {
|
||||||
|
$key = strtolower(trim((string) $request->get_param('status_key')));
|
||||||
|
if ($key === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (!TicketStatusService::is_valid_key($key)) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'aksrt_ticket_status_key',
|
||||||
|
__('标识需以小写字母开头,仅含小写字母、数字与下划线,长度 2–24 位', 'aksrt'),
|
||||||
|
['status' => 400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
public function delete_status(\WP_REST_Request $request) {
|
public function delete_status(\WP_REST_Request $request) {
|
||||||
$id = absint($request->get_param('id'));
|
$id = absint($request->get_param('id'));
|
||||||
$row = $this->status_row($id);
|
$row = $this->status_row($id);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ final class TicketRestAdmin {
|
|||||||
'permission_callback' => [$this, 'require_admin'],
|
'permission_callback' => [$this, 'require_admin'],
|
||||||
'args' => [
|
'args' => [
|
||||||
'name' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
'name' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||||
|
'status_key' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||||
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||||
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
|
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||||
],
|
],
|
||||||
@@ -71,6 +72,7 @@ final class TicketRestAdmin {
|
|||||||
'permission_callback' => [$this, 'require_admin'],
|
'permission_callback' => [$this, 'require_admin'],
|
||||||
'args' => [
|
'args' => [
|
||||||
'name' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
'name' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||||
|
'status_key' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||||
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||||
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
|
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -141,10 +141,20 @@ final class TicketStatusService {
|
|||||||
return $color;
|
return $color;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新建状态时生成唯一 status_key(ASCII 标签取 key,其余取短哈希)。 */
|
/**
|
||||||
|
* 管理员自定义标识的合法形态:小写字母开头,仅含小写字母 / 数字 / 下划线,
|
||||||
|
* 2–24 位(status_key 列为 VARCHAR(24))。前端 TicketStatusPanel 同步此规则。
|
||||||
|
*/
|
||||||
|
public const KEY_PATTERN = '/^[a-z][a-z0-9_]{1,23}$/';
|
||||||
|
|
||||||
|
public static function is_valid_key(string $key): bool {
|
||||||
|
return preg_match(self::KEY_PATTERN, $key) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建状态且未填标识时生成唯一 status_key(ASCII 标签取 key,其余取短哈希)。 */
|
||||||
public static function make_key(string $name): string {
|
public static function make_key(string $name): string {
|
||||||
$key = sanitize_key($name);
|
$key = substr(str_replace('-', '_', sanitize_key($name)), 0, 20);
|
||||||
if ($key === '') {
|
if (!self::is_valid_key($key)) {
|
||||||
$key = 'st' . substr(md5($name . (string) wp_rand(1, 999999)), 0, 8);
|
$key = 'st' . substr(md5($name . (string) wp_rand(1, 999999)), 0, 8);
|
||||||
}
|
}
|
||||||
$base = $key;
|
$base = $key;
|
||||||
|
|||||||
Reference in new issue
Block a user