diff --git a/assets/admin/src/pages/users/partials/ticket/TicketStatusPanel.vue b/assets/admin/src/pages/users/partials/ticket/TicketStatusPanel.vue index 9f31f06a..f7339fe5 100644 --- a/assets/admin/src/pages/users/partials/ticket/TicketStatusPanel.vue +++ b/assets/admin/src/pages/users/partials/ticket/TicketStatusPanel.vue @@ -39,6 +39,22 @@ + +
+ + + 小写字母开头,仅含小写字母、数字与下划线(2–24 位)。修改后,使用该状态的工单会同步改为新标识。 + +
+
@@ -132,7 +148,14 @@ const rows = ref([]) 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::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; @@ -149,6 +172,26 @@ const rules = { { 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) !== '', @@ -245,7 +288,7 @@ async function load() { function openCreate() { 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 } @@ -253,6 +296,7 @@ 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, } @@ -269,6 +313,7 @@ async function handleSubmit() { 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, } @@ -466,6 +511,18 @@ defineExpose({ reload: load }) 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 { display: flex; diff --git a/src/Ticket/TicketConfigRest.php b/src/Ticket/TicketConfigRest.php index 4c324e56..8a35336d 100644 --- a/src/Ticket/TicketConfigRest.php +++ b/src/Ticket/TicketConfigRest.php @@ -31,9 +31,18 @@ trait TicketConfigRest { if ($table === '') { 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 $inserted = $wpdb->insert($table, [ - 'status_key' => TicketStatusService::make_key($name), + 'status_key' => $key, 'name' => $name, 'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')), 'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0, @@ -60,21 +69,70 @@ trait TicketConfigRest { if ($name === '' || mb_strlen($name) > 60) { 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; $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, [ + 'status_key' => $key, 'name' => $name, 'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')), 'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0, '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 ($rename) { + $wpdb->query('ROLLBACK'); + } return new \WP_Error('aksrt_ticket_db', __('状态保存失败', 'aksrt'), ['status' => 500]); } + if ($rename) { + $wpdb->query('COMMIT'); + } TicketStatusService::flush(); 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) { $id = absint($request->get_param('id')); $row = $this->status_row($id); diff --git a/src/Ticket/TicketRestAdmin.php b/src/Ticket/TicketRestAdmin.php index 6c25f0be..ae24f79e 100644 --- a/src/Ticket/TicketRestAdmin.php +++ b/src/Ticket/TicketRestAdmin.php @@ -59,6 +59,7 @@ final class TicketRestAdmin { 'permission_callback' => [$this, 'require_admin'], 'args' => [ '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'], 'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'], ], @@ -71,6 +72,7 @@ final class TicketRestAdmin { 'permission_callback' => [$this, 'require_admin'], 'args' => [ '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'], 'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'], ], diff --git a/src/Ticket/TicketStatusService.php b/src/Ticket/TicketStatusService.php index 6105a670..731a8c72 100644 --- a/src/Ticket/TicketStatusService.php +++ b/src/Ticket/TicketStatusService.php @@ -141,10 +141,20 @@ final class TicketStatusService { 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 { - $key = sanitize_key($name); - if ($key === '') { + $key = substr(str_replace('-', '_', sanitize_key($name)), 0, 20); + if (!self::is_valid_key($key)) { $key = 'st' . substr(md5($name . (string) wp_rand(1, 999999)), 0, 8); } $base = $key;