diff --git a/assets/admin/src/schema/sections/users/ticket.js b/assets/admin/src/schema/sections/users/ticket.js index 5fb18fa7..bb9fe44c 100644 --- a/assets/admin/src/schema/sections/users/ticket.js +++ b/assets/admin/src/schema/sections/users/ticket.js @@ -8,7 +8,7 @@ export const ticketSchema = { key: 'users.ticket', title: '工单中心', icon: 'fa fas fa-headset', - description: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置;部门、状态与预设回复在本页其余分页中维护。', + description: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却、字数上限)配置;部门、状态与预设回复在本页其余分页中维护。', defaults: { ticket_s: true, ticket_num_prefix: 'T', @@ -18,6 +18,9 @@ export const ticketSchema = { ticket_attachment_max: 5, ticket_order_link_s: true, ticket_urge_interval: 15, + ticket_title_max: 200, + ticket_content_max: 5000, + ticket_reply_max: 3000, }, groups: [ { @@ -40,6 +43,9 @@ export const ticketSchema = { { key: 'ticket_attachment_max', label: '单个工单图片上限', type: 'number', default: 5, min: 1, max: 9, help: '工单内容与每条回复各自可上传的图片数量上限' }, { key: 'ticket_order_link_s', label: '允许关联订单', type: 'switch', default: true, help: '开启后新建工单时可选择关联本人一个已支付订单;关闭后工单与订单完全解耦' }, { key: 'ticket_urge_interval', label: '催单冷却(分钟)', type: 'number', default: 15, min: 1, max: 1440, help: '同一工单两次催单之间的最小间隔' }, + { key: 'ticket_title_max', label: '标题字数上限', type: 'number', default: 200, min: 10, max: 200, help: '工单标题最多可输入的字数(前台提交与后台代客提交共用)' }, + { key: 'ticket_content_max', label: '问题描述字数上限', type: 'number', default: 5000, min: 100, max: 16000, help: '新建工单时问题描述最多可输入的字数(前台提交与后台代客提交共用)' }, + { key: 'ticket_reply_max', label: '回复字数上限', type: 'number', default: 3000, min: 100, max: 16000, help: '用户与客服每条回复(含后台内部备注)最多可输入的字数' }, ], }, ], diff --git a/assets/js/ticket-center-core.js b/assets/js/ticket-center-core.js index aa9c6f37..4286cd5b 100644 --- a/assets/js/ticket-center-core.js +++ b/assets/js/ticket-center-core.js @@ -35,6 +35,12 @@ return parseInt(settings().attachmentMax, 10) || 5; } + /** 字数上限(后台「工单中心 → 提交设置」配置,PHP 侧已夹取边界)。 */ + function lengthMax(kind) { + var defaults = { title: 200, content: 5000, reply: 3000 }; + return parseInt(settings()[kind + 'Max'], 10) || defaults[kind]; + } + function escapeHtml(value) { return String(value === null || value === undefined ? '' : value) .replace(/&/g, '&') @@ -359,6 +365,7 @@ settings: settings, attachmentEnabled: attachmentEnabled, attachmentMax: attachmentMax, + lengthMax: lengthMax, escapeHtml: escapeHtml, notyf: notyf, request: request, diff --git a/assets/js/ticket-center.js b/assets/js/ticket-center.js index 2c4b70af..41b3ad19 100644 --- a/assets/js/ticket-center.js +++ b/assets/js/ticket-center.js @@ -22,6 +22,7 @@ var settings = core.settings; var attachmentEnabled = core.attachmentEnabled; var attachmentMax = core.attachmentMax; + var lengthMax = core.lengthMax; var escapeHtml = core.escapeHtml; var notyf = core.notyf; var request = core.request; @@ -448,11 +449,11 @@ + attachTrayHtml() + '
' + '' - + '
' + '
' + 'Ctrl + Enter 发送' - + '0 / 3000' + + '0 / ' + lengthMax('reply') + '' + '' + '
'; } @@ -642,8 +643,8 @@ html += field( '', - '', - '0 / 200' + '', + '0 / ' + lengthMax('title') + '' ); html += '
' @@ -667,8 +668,8 @@ html += field( '', - '', - '0 / 5000' + '', + '0 / ' + lengthMax('content') + '' ); if (attachmentEnabled()) { diff --git a/src/Admin/Native/UsersMenusTicketsCenter.php b/src/Admin/Native/UsersMenusTicketsCenter.php index 4295c7fb..e0627d83 100644 --- a/src/Admin/Native/UsersMenusTicketsCenter.php +++ b/src/Admin/Native/UsersMenusTicketsCenter.php @@ -23,7 +23,6 @@ trait UsersMenusTicketsCenter { private const TICKET_PAGE_SLUG = 'aksrt_tickets'; private const TICKET_PER_PAGE = 20; - private const TICKET_CONTENT_MAX = 3000; /** 待接单工单数(后台 admin_notices 用)。 */ private function count_pending_tickets(): int { @@ -194,7 +193,7 @@ trait UsersMenusTicketsCenter { return 'reply_failed'; } $content = trim($this->ticket_post_textarea('content')); - if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) { + if ($content === '' || mb_strlen($content) > \AKSRT\Ticket\TicketSettings::reply_max()) { return 'reply_failed'; } $reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, [], 0); @@ -213,7 +212,7 @@ trait UsersMenusTicketsCenter { return 'failed'; } $content = trim($this->ticket_post_textarea('content')); - if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) { + if ($content === '' || mb_strlen($content) > \AKSRT\Ticket\TicketSettings::reply_max()) { return 'failed'; } return \AKSRT\Ticket\TicketNoteService::create($id, $content, get_current_user_id()) > 0 @@ -235,10 +234,10 @@ trait UsersMenusTicketsCenter { $content = trim($this->ticket_post_textarea('content')); $type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0; - if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || mb_strlen($title) > 200) { + if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || mb_strlen($title) > \AKSRT\Ticket\TicketSettings::title_max()) { return 'create_failed'; } - if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) { + if ($content === '' || mb_strlen($content) > \AKSRT\Ticket\TicketSettings::content_max()) { return 'create_failed'; } if (!\AKSRT\Ticket\TicketTypeService::exists($type_id)) { diff --git a/src/Admin/Native/UsersMenusTicketsDetail.php b/src/Admin/Native/UsersMenusTicketsDetail.php index 7e939bb7..a1702716 100644 --- a/src/Admin/Native/UsersMenusTicketsDetail.php +++ b/src/Admin/Native/UsersMenusTicketsDetail.php @@ -108,7 +108,7 @@ trait UsersMenusTicketsDetail { $html .= $this->ticket_form_open('note', $id, 'aksrt-ticket-detail__note-form'); $html .= wp_nonce_field('aksrt_ticket_note_' . $id, '_wpnonce', true, false); $html .= ''; - $html .= ''; + $html .= ''; $html .= ''; $html .= ''; $html .= ''; @@ -123,7 +123,7 @@ trait UsersMenusTicketsDetail { $html = $this->ticket_form_open('reply', $id, 'aksrt-ticket-detail__reply-form'); $html .= wp_nonce_field('aksrt_ticket_reply_' . $id, '_wpnonce', true, false); $html .= ''; - $html .= ''; $html .= '
'; @@ -142,7 +142,7 @@ trait UsersMenusTicketsDetail { } $html .= ''; } - $html .= '0 / ' . (int) self::TICKET_CONTENT_MAX . ''; + $html .= '0 / ' . (int) \AKSRT\Ticket\TicketSettings::reply_max() . ''; $html .= ''; $html .= '
'; return $html; diff --git a/src/Admin/Native/UsersMenusTicketsList.php b/src/Admin/Native/UsersMenusTicketsList.php index 2df01744..c39cdf66 100644 --- a/src/Admin/Native/UsersMenusTicketsList.php +++ b/src/Admin/Native/UsersMenusTicketsList.php @@ -45,8 +45,9 @@ trait UsersMenusTicketsList { } echo ''; printf( - '', - esc_html__('标题', 'aksrt') + '', + esc_html__('标题', 'aksrt'), + \AKSRT\Ticket\TicketSettings::title_max() ); if (\AKSRT\Ticket\TicketSettings::order_link_enabled()) { printf( @@ -58,7 +59,7 @@ trait UsersMenusTicketsList { printf( '', esc_html__('问题描述', 'aksrt'), - (int) self::TICKET_CONTENT_MAX + \AKSRT\Ticket\TicketSettings::content_max() ); echo ''; printf( diff --git a/src/Admin/Schema/Users/SchemaUsersTicket.php b/src/Admin/Schema/Users/SchemaUsersTicket.php index c651d3a7..e00be593 100644 --- a/src/Admin/Schema/Users/SchemaUsersTicket.php +++ b/src/Admin/Schema/Users/SchemaUsersTicket.php @@ -33,6 +33,9 @@ class SchemaUsersTicket { ['key' => 'ticket_attachment_max', 'label' => '单个工单图片上限', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 9, 'help' => '工单内容与每条回复各自可上传的图片数量上限'], ['key' => 'ticket_order_link_s', 'label' => '允许关联订单', 'type' => 'switch', 'default' => true, 'help' => '开启后新建工单时可选择关联本人一个已支付订单;关闭后工单与订单完全解耦'], ['key' => 'ticket_urge_interval', 'label' => '催单冷却(分钟)', 'type' => 'number', 'default' => 15, 'min' => 1, 'max' => 1440, 'help' => '同一工单两次催单之间的最小间隔'], + ['key' => 'ticket_title_max', 'label' => '标题字数上限', 'type' => 'number', 'default' => 200, 'min' => 10, 'max' => 200, 'help' => '工单标题最多可输入的字数(前台提交与后台代客提交共用)'], + ['key' => 'ticket_content_max', 'label' => '问题描述字数上限', 'type' => 'number', 'default' => 5000, 'min' => 100, 'max' => 16000, 'help' => '新建工单时问题描述最多可输入的字数(前台提交与后台代客提交共用)'], + ['key' => 'ticket_reply_max', 'label' => '回复字数上限', 'type' => 'number', 'default' => 3000, 'min' => 100, 'max' => 16000, 'help' => '用户与客服每条回复(含后台内部备注)最多可输入的字数'], ]], ], ]; diff --git a/src/Core/ScriptsPart1c.php b/src/Core/ScriptsPart1c.php index c36097c9..911a673d 100644 --- a/src/Core/ScriptsPart1c.php +++ b/src/Core/ScriptsPart1c.php @@ -498,10 +498,10 @@ trait ScriptsPart1c { 'desc' => (string) ($report['desc'] ?? ''), ]; })(), - // 工单配置(ticket-center.js):附件开关与上限、提交公告、催单冷却。 + // 工单配置(ticket-center.js):附件开关与上限、提交公告、催单冷却、字数上限。 'ticket' => (function () { if (!class_exists('\AKSRT\Ticket\TicketSettings')) { - return ['enabled' => false, 'attachmentEnabled' => false, 'attachmentMax' => 5, 'notice' => ['open' => false, 'text' => ''], 'urgeInterval' => 15]; + return ['enabled' => false, 'attachmentEnabled' => false, 'attachmentMax' => 5, 'notice' => ['open' => false, 'text' => ''], 'urgeInterval' => 15, 'titleMax' => 200, 'contentMax' => 5000, 'replyMax' => 3000]; } return [ 'enabled' => \AKSRT\Ticket\TicketSettings::enabled(), @@ -512,6 +512,9 @@ trait ScriptsPart1c { 'text' => \AKSRT\Ticket\TicketSettings::notice_text(), ], 'urgeInterval' => \AKSRT\Ticket\TicketSettings::urge_interval(), + 'titleMax' => \AKSRT\Ticket\TicketSettings::title_max(), + 'contentMax' => \AKSRT\Ticket\TicketSettings::content_max(), + 'replyMax' => \AKSRT\Ticket\TicketSettings::reply_max(), ]; })(), 'stickyEnabled' => class_exists('\AKSRT\Settings\Settings') ? !empty(\AKSRT\Settings\Settings::get_post_list_style_settings()['sticky_s']) : true, diff --git a/src/Ticket/TicketConfigRest.php b/src/Ticket/TicketConfigRest.php index 98fa0a0f..4c324e56 100644 --- a/src/Ticket/TicketConfigRest.php +++ b/src/Ticket/TicketConfigRest.php @@ -250,7 +250,7 @@ trait TicketConfigRest { return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]); } $content = trim((string) $request->get_param('content')); - if ($content === '' || mb_strlen($content) > 2000) { + if ($content === '' || mb_strlen($content) > TicketSettings::reply_max()) { return new \WP_Error('aksrt_ticket_note_content', __('请填写有效的备注内容', 'aksrt'), ['status' => 400]); } if (TicketNoteService::create($id, $content, get_current_user_id()) <= 0) { @@ -265,7 +265,7 @@ trait TicketConfigRest { return new \WP_Error('aksrt_ticket_note_missing', __('备注不存在', 'aksrt'), ['status' => 404]); } $content = trim((string) $request->get_param('content')); - if ($content === '' || mb_strlen($content) > 2000) { + if ($content === '' || mb_strlen($content) > TicketSettings::reply_max()) { return new \WP_Error('aksrt_ticket_note_content', __('请填写有效的备注内容', 'aksrt'), ['status' => 400]); } if (!TicketNoteService::update($id, $content)) { diff --git a/src/Ticket/TicketRest.php b/src/Ticket/TicketRest.php index 97614a20..22f67622 100644 --- a/src/Ticket/TicketRest.php +++ b/src/Ticket/TicketRest.php @@ -252,10 +252,10 @@ final class TicketRest { $type_id = absint($request->get_param('ticket_type_id')); $order_number = sanitize_text_field((string) $request->get_param('order_number')); - if ($title === '' || mb_strlen($title) > 200) { + if ($title === '' || mb_strlen($title) > TicketSettings::title_max()) { return new \WP_Error('aksrt_ticket_title', __('请填写有效的工单标题', 'aksrt'), ['status' => 400]); } - if ($content === '' || mb_strlen($content) > 5000) { + if ($content === '' || mb_strlen($content) > TicketSettings::content_max()) { return new \WP_Error('aksrt_ticket_content', __('请填写有效的问题描述', 'aksrt'), ['status' => 400]); } if (!TicketTypeService::exists($type_id)) { @@ -308,7 +308,7 @@ final class TicketRest { } $content = trim((string) $request->get_param('content')); - if ($content === '' || mb_strlen($content) > 3000) { + if ($content === '' || mb_strlen($content) > TicketSettings::reply_max()) { return new \WP_Error('aksrt_ticket_reply_content', __('请填写有效的回复内容', 'aksrt'), ['status' => 400]); } $quote_id = absint($request->get_param('quote_reply_id')); diff --git a/src/Ticket/TicketRestAdmin.php b/src/Ticket/TicketRestAdmin.php index 3cd3269f..6c25f0be 100644 --- a/src/Ticket/TicketRestAdmin.php +++ b/src/Ticket/TicketRestAdmin.php @@ -311,10 +311,10 @@ final class TicketRestAdmin { if ($user_id <= 0 || !get_userdata($user_id)) { return new \WP_Error('aksrt_ticket_user', __('请选择工单所属用户', 'aksrt'), ['status' => 400]); } - if ($title === '' || mb_strlen($title) > 200) { + if ($title === '' || mb_strlen($title) > TicketSettings::title_max()) { return new \WP_Error('aksrt_ticket_title', __('请填写有效的工单标题', 'aksrt'), ['status' => 400]); } - if ($content === '' || mb_strlen($content) > 5000) { + if ($content === '' || mb_strlen($content) > TicketSettings::content_max()) { return new \WP_Error('aksrt_ticket_content', __('请填写有效的问题描述', 'aksrt'), ['status' => 400]); } if (!TicketTypeService::exists($type_id)) { @@ -356,7 +356,7 @@ final class TicketRestAdmin { return new \WP_Error('aksrt_ticket_closed', __('工单已关闭,无法回复', 'aksrt'), ['status' => 409]); } $content = trim((string) $request->get_param('content')); - if ($content === '' || mb_strlen($content) > 3000) { + if ($content === '' || mb_strlen($content) > TicketSettings::reply_max()) { return new \WP_Error('aksrt_ticket_reply_content', __('请填写有效的回复内容', 'aksrt'), ['status' => 400]); } $quote_id = absint($request->get_param('quote_reply_id')); diff --git a/src/Ticket/TicketSettings.php b/src/Ticket/TicketSettings.php index 73607203..09489176 100644 --- a/src/Ticket/TicketSettings.php +++ b/src/Ticket/TicketSettings.php @@ -62,4 +62,19 @@ final class TicketSettings { public static function urge_interval(): int { return max(1, min(1440, (int) (self::all()['ticket_urge_interval'] ?? 15))); } + + /** 工单标题字数上限(tickets.title 为 VARCHAR(200),故上界 200)。 */ + public static function title_max(): int { + return max(10, min(200, (int) (self::all()['ticket_title_max'] ?? 200))); + } + + /** 工单问题描述字数上限(TEXT 列 65535 字节,utf8mb4 每字最多 4 字节,故上界 16000)。 */ + public static function content_max(): int { + return max(100, min(16000, (int) (self::all()['ticket_content_max'] ?? 5000))); + } + + /** 每条回复 / 后台内部备注字数上限。 */ + public static function reply_max(): int { + return max(100, min(16000, (int) (self::all()['ticket_reply_max'] ?? 3000))); + } } diff --git a/tests/widget-system/ticket-schema-contract.test.cjs b/tests/widget-system/ticket-schema-contract.test.cjs index edc22603..bb3c90fc 100644 --- a/tests/widget-system/ticket-schema-contract.test.cjs +++ b/tests/widget-system/ticket-schema-contract.test.cjs @@ -26,6 +26,9 @@ const FIELD_ORDER = [ 'ticket_attachment_max', 'ticket_order_link_s', 'ticket_urge_interval', + 'ticket_title_max', + 'ticket_content_max', + 'ticket_reply_max', ]; const GROUPS = [ @@ -128,6 +131,11 @@ test('TicketSettings falls back to schema defaults and clamps numerics', () => { assert.match(body, /max\(1, min\(9, \(int\)/); assert.match(body, /max\(1, min\(1440, \(int\)/); + // 字数上限:标题 10..200(VARCHAR(200)),描述 / 回复 100..16000(TEXT 65535 字节 / utf8mb4)。 + assert.match(body, /max\(10, min\(200, \(int\) \(self::all\(\)\['ticket_title_max'\] \?\? 200\)\)\)/); + assert.match(body, /max\(100, min\(16000, \(int\) \(self::all\(\)\['ticket_content_max'\] \?\? 5000\)\)\)/); + assert.match(body, /max\(100, min\(16000, \(int\) \(self::all\(\)\['ticket_reply_max'\] \?\? 3000\)\)\)/); + // 公告文案走 kses(后台允许 HTML)。 assert.match(body, /wp_kses_post/); // 分区名与 schema key 一致(常量在 all() 之前声明,故对整份文件断言)。