From 638b3c4032876d3ef404ae6919fb709d0acc425a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 2 Jul 2026 03:39:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=90=8C=E6=AD=A5=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=BA=E5=BC=82=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/admin/controller/split/Ticket.php | 94 +++++++-- application/admin/lang/zh-cn/split/ticket.php | 6 + .../SplitTicketSyncDispatchService.php | 193 ++++++++++++++++++ .../service/SplitTicketSyncLockService.php | 14 ++ .../admin/controller/split/Ticket.php | 94 +++++++-- .../admin/lang/zh-cn/split/ticket.php | 6 + .../SplitTicketSyncDispatchService.php | 193 ++++++++++++++++++ .../service/SplitTicketSyncLockService.php | 14 ++ .../public/assets/js/backend/split/ticket.js | 78 ++++++- public/assets/js/backend/split/ticket.js | 78 ++++++- 10 files changed, 724 insertions(+), 46 deletions(-) create mode 100644 application/common/service/SplitTicketSyncDispatchService.php create mode 100644 patches/application/common/service/SplitTicketSyncDispatchService.php diff --git a/application/admin/controller/split/Ticket.php b/application/admin/controller/split/Ticket.php index 6c890be..66d181e 100755 --- a/application/admin/controller/split/Ticket.php +++ b/application/admin/controller/split/Ticket.php @@ -8,8 +8,8 @@ use app\admin\model\split\Link as LinkModel; use app\admin\model\split\Ticket as TicketModel; use app\common\controller\Backend; use app\common\service\SplitTicketRuleService; +use app\common\service\SplitTicketSyncDispatchService; use app\common\service\SplitTicketSyncLogger; -use app\common\service\SplitTicketSyncService; use think\Db; use think\Lang; use think\Loader; @@ -39,7 +39,7 @@ class Ticket extends Backend protected $modelSceneValidate = true; /** @var string[] 无需鉴权的方法 */ - protected $noNeedRight = ['script']; + protected $noNeedRight = ['script', 'syncpolling']; /** @var string patches 视图目录 */ private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/'; @@ -64,6 +64,7 @@ class Ticket extends Backend 'syncBackgroundStartedMsg' => __('Sync background started'), 'syncInProgressMsg' => __('Sync in progress'), 'syncTicketStartedMsg' => __('Sync ticket started'), + 'syncDoneMsg' => __('Sync done'), ]); $this->setupPatchFrontend(); @@ -284,7 +285,7 @@ class Ticket extends Backend } /** - * 手动同步选中工单 + * 手动同步选中工单(投递 CLI 后台执行,Web 请求立即返回) */ public function sync(): void { @@ -307,31 +308,84 @@ class Ticket extends Backend 'appDebug' => SplitTicketSyncLogger::isEnabled(), ]); - $service = new SplitTicketSyncService(); - $ok = 0; - $fail = 0; - $messages = []; - + /** @var int[] $allowedIds */ + $allowedIds = []; + $denied = 0; foreach ($list as $row) { if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) { - $fail++; - $messages[] = '#' . $row['id'] . ': 无权限'; + $denied++; continue; } - $result = $service->syncOne((int) $row['id'], true); - if ($result['success']) { - $ok++; - } else { - $fail++; - $messages[] = '#' . $row['id'] . ': ' . ($result['message'] ?? '失败'); + $allowedIds[] = (int) $row['id']; + } + + if ($allowedIds === []) { + $this->error(__('You have no permission')); + } + + // 尽早释放 Session 锁,避免投递等待期间阻塞同账号其它后台请求(如编辑/新增弹窗) + if (function_exists('session_write_close')) { + session_write_close(); + } + + $dispatch = new SplitTicketSyncDispatchService(); + $result = $dispatch->dispatchManual($allowedIds); + + $queuedCount = count($result['queued']); + $skippedCount = count($result['skipped']); + $failedCount = count($result['failed']); + + if ($queuedCount === 0) { + if ($skippedCount > 0) { + $this->error(__('Sync all skipped busy')); + } + $this->error(__('Sync dispatch failed')); + } + + $summary = sprintf(__('Sync dispatch queued'), $queuedCount); + if ($skippedCount > 0) { + $summary .= sprintf(__('Sync dispatch skipped suffix'), $skippedCount); + } + if ($failedCount > 0) { + $summary .= sprintf(__('Sync dispatch failed suffix'), $failedCount); + } + if ($denied > 0) { + $summary .= sprintf(__('Sync dispatch denied suffix'), $denied); + } + + $this->success($summary, null, [ + 'queued' => $result['queued'], + 'syncing' => $result['queued'], + ]); + } + + /** + * 轮询手工同步进度(依据 runtime 文件锁) + */ + public function syncpolling(): void + { + $idsRaw = trim((string) $this->request->request('ids', '')); + if ($idsRaw === '') { + $this->success('', null, ['syncing' => []]); + } + + /** @var int[] $ticketIds */ + $ticketIds = []; + foreach (explode(',', $idsRaw) as $part) { + $part = trim($part); + if ($part !== '' && ctype_digit($part)) { + $ticketIds[] = (int) $part; } } - $summary = sprintf('成功 %d 条,失败 %d 条', $ok, $fail); - if ($fail > 0) { - $summary .= ';' . implode(';', array_slice($messages, 0, 5)); + if ($ticketIds === []) { + $this->success('', null, ['syncing' => []]); } - $this->success($summary); + + $dispatch = new SplitTicketSyncDispatchService(); + $syncing = $dispatch->filterSyncingIds($ticketIds); + + $this->success('', null, ['syncing' => $syncing]); } /** diff --git a/application/admin/lang/zh-cn/split/ticket.php b/application/admin/lang/zh-cn/split/ticket.php index 05db317..b0a30e6 100755 --- a/application/admin/lang/zh-cn/split/ticket.php +++ b/application/admin/lang/zh-cn/split/ticket.php @@ -31,6 +31,12 @@ return [ 'Sync in progress' => '同步中', 'Sync ticket started' => '正在同步工单', 'Sync done' => '同步完成', + 'Sync dispatch queued' => '已提交 %d 条同步任务到后台执行', + 'Sync dispatch skipped suffix' => ',%d 条已在同步中已跳过', + 'Sync dispatch failed suffix' => ',%d 条启动失败', + 'Sync dispatch denied suffix' => ',%d 条无权限未提交', + 'Sync all skipped busy' => '所选工单均在同步中,请稍后再试', + 'Sync dispatch failed' => '未能启动同步任务,请检查服务器是否允许后台执行 CLI(exec/shell_exec)', 'Sync status success' => '同步成功', 'Sync status error' => '同步异常', 'Sync status pending' => '待同步', diff --git a/application/common/service/SplitTicketSyncDispatchService.php b/application/common/service/SplitTicketSyncDispatchService.php new file mode 100644 index 0000000..c62a386 --- /dev/null +++ b/application/common/service/SplitTicketSyncDispatchService.php @@ -0,0 +1,193 @@ +resolvePhpBinary(); + $think = $this->resolveThinkScript(); + $root = $this->resolveRootPath(); + $lockService = new SplitTicketSyncLockService(); + + foreach ($ticketIds as $rawId) { + $ticketId = (int) $rawId; + if ($ticketId <= 0) { + continue; + } + + if ($lockService->isLocked($ticketId)) { + $skipped[] = $ticketId; + continue; + } + + if ($this->spawnCli($php, $think, $root, $ticketId)) { + $queued[] = $ticketId; + } else { + $failed[] = $ticketId; + } + } + + SplitTicketSyncLogger::log('web', 'manual sync dispatched', [ + 'queued' => $queued, + 'skipped' => $skipped, + 'failed' => $failed, + ]); + + return [ + 'queued' => $queued, + 'skipped' => $skipped, + 'failed' => $failed, + ]; + } + + /** + * 查询仍在同步中的工单 ID(依据 runtime 文件锁) + * + * @param int[] $ticketIds + * @return int[] + */ + public function filterSyncingIds(array $ticketIds): array + { + $lockService = new SplitTicketSyncLockService(); + $syncing = []; + foreach ($ticketIds as $rawId) { + $ticketId = (int) $rawId; + if ($ticketId > 0 && $lockService->isLocked($ticketId)) { + $syncing[] = $ticketId; + } + } + + return $syncing; + } + + /** + * 后台启动 split:sync-tickets CLI + */ + private function spawnCli(string $php, string $think, string $root, int $ticketId): bool + { + $logDir = $this->resolveLogDir(); + $logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log'; + + $inner = sprintf( + '%s %s split:sync-tickets --ticket=%d >> %s 2>&1', + escapeshellarg($php), + escapeshellarg($think), + $ticketId, + escapeshellarg($logFile) + ); + + if ($this->isWindows()) { + $command = sprintf('start /B cmd /C %s', $inner); + } else { + $command = sprintf('cd %s && nohup %s &', escapeshellarg($root), $inner); + } + + if ($this->canUseExec()) { + @exec($command, $output, $exitCode); + SplitTicketSyncLogger::log('web', 'cli spawn exec', [ + 'ticketId' => $ticketId, + 'command' => $command, + 'exitCode' => $exitCode, + ]); + return true; + } + + if ($this->canUseShellExec()) { + @shell_exec($command); + SplitTicketSyncLogger::log('web', 'cli spawn shell_exec', [ + 'ticketId' => $ticketId, + 'command' => $command, + ]); + return true; + } + + SplitTicketSyncLogger::log('web', 'cli spawn failed: exec disabled', [ + 'ticketId' => $ticketId, + ]); + + return false; + } + + private function resolvePhpBinary(): string + { + if (defined('PHP_BINARY') && PHP_BINARY !== '') { + return PHP_BINARY; + } + + return 'php'; + } + + private function resolveThinkScript(): string + { + $root = $this->resolveRootPath(); + return rtrim($root, '/\\') . DIRECTORY_SEPARATOR . 'think'; + } + + private function resolveRootPath(): string + { + if (defined('ROOT_PATH') && ROOT_PATH !== '') { + return rtrim(ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR; + } + + return rtrim(dirname(__DIR__, 3), '/\\') . DIRECTORY_SEPARATOR; + } + + private function resolveLogDir(): string + { + $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : ($this->resolveRootPath() . 'runtime' . DIRECTORY_SEPARATOR); + $dir = rtrim($runtime, '/\\') . DIRECTORY_SEPARATOR . 'split_ticket_sync' . DIRECTORY_SEPARATOR; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + + return $dir; + } + + private function isWindows(): bool + { + return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + } + + /** + * @return string[] + */ + private function disabledFunctions(): array + { + $raw = (string) ini_get('disable_functions'); + if ($raw === '') { + return []; + } + + return array_filter(array_map('trim', explode(',', $raw))); + } + + private function canUseExec(): bool + { + return function_exists('exec') && !in_array('exec', $this->disabledFunctions(), true); + } + + private function canUseShellExec(): bool + { + return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true); + } +} diff --git a/application/common/service/SplitTicketSyncLockService.php b/application/common/service/SplitTicketSyncLockService.php index cef85d5..faa62cc 100755 --- a/application/common/service/SplitTicketSyncLockService.php +++ b/application/common/service/SplitTicketSyncLockService.php @@ -43,6 +43,20 @@ class SplitTicketSyncLockService } } + /** + * 工单是否处于同步中(锁文件存在且未过期) + */ + public function isLocked(int $ticketId): bool + { + $path = $this->lockPath($ticketId); + if ($this->isStaleLock($path)) { + @unlink($path); + return false; + } + + return is_file($path); + } + private function lockPath(int $ticketId): string { $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); diff --git a/patches/application/admin/controller/split/Ticket.php b/patches/application/admin/controller/split/Ticket.php index 6c890be..66d181e 100755 --- a/patches/application/admin/controller/split/Ticket.php +++ b/patches/application/admin/controller/split/Ticket.php @@ -8,8 +8,8 @@ use app\admin\model\split\Link as LinkModel; use app\admin\model\split\Ticket as TicketModel; use app\common\controller\Backend; use app\common\service\SplitTicketRuleService; +use app\common\service\SplitTicketSyncDispatchService; use app\common\service\SplitTicketSyncLogger; -use app\common\service\SplitTicketSyncService; use think\Db; use think\Lang; use think\Loader; @@ -39,7 +39,7 @@ class Ticket extends Backend protected $modelSceneValidate = true; /** @var string[] 无需鉴权的方法 */ - protected $noNeedRight = ['script']; + protected $noNeedRight = ['script', 'syncpolling']; /** @var string patches 视图目录 */ private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/'; @@ -64,6 +64,7 @@ class Ticket extends Backend 'syncBackgroundStartedMsg' => __('Sync background started'), 'syncInProgressMsg' => __('Sync in progress'), 'syncTicketStartedMsg' => __('Sync ticket started'), + 'syncDoneMsg' => __('Sync done'), ]); $this->setupPatchFrontend(); @@ -284,7 +285,7 @@ class Ticket extends Backend } /** - * 手动同步选中工单 + * 手动同步选中工单(投递 CLI 后台执行,Web 请求立即返回) */ public function sync(): void { @@ -307,31 +308,84 @@ class Ticket extends Backend 'appDebug' => SplitTicketSyncLogger::isEnabled(), ]); - $service = new SplitTicketSyncService(); - $ok = 0; - $fail = 0; - $messages = []; - + /** @var int[] $allowedIds */ + $allowedIds = []; + $denied = 0; foreach ($list as $row) { if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) { - $fail++; - $messages[] = '#' . $row['id'] . ': 无权限'; + $denied++; continue; } - $result = $service->syncOne((int) $row['id'], true); - if ($result['success']) { - $ok++; - } else { - $fail++; - $messages[] = '#' . $row['id'] . ': ' . ($result['message'] ?? '失败'); + $allowedIds[] = (int) $row['id']; + } + + if ($allowedIds === []) { + $this->error(__('You have no permission')); + } + + // 尽早释放 Session 锁,避免投递等待期间阻塞同账号其它后台请求(如编辑/新增弹窗) + if (function_exists('session_write_close')) { + session_write_close(); + } + + $dispatch = new SplitTicketSyncDispatchService(); + $result = $dispatch->dispatchManual($allowedIds); + + $queuedCount = count($result['queued']); + $skippedCount = count($result['skipped']); + $failedCount = count($result['failed']); + + if ($queuedCount === 0) { + if ($skippedCount > 0) { + $this->error(__('Sync all skipped busy')); + } + $this->error(__('Sync dispatch failed')); + } + + $summary = sprintf(__('Sync dispatch queued'), $queuedCount); + if ($skippedCount > 0) { + $summary .= sprintf(__('Sync dispatch skipped suffix'), $skippedCount); + } + if ($failedCount > 0) { + $summary .= sprintf(__('Sync dispatch failed suffix'), $failedCount); + } + if ($denied > 0) { + $summary .= sprintf(__('Sync dispatch denied suffix'), $denied); + } + + $this->success($summary, null, [ + 'queued' => $result['queued'], + 'syncing' => $result['queued'], + ]); + } + + /** + * 轮询手工同步进度(依据 runtime 文件锁) + */ + public function syncpolling(): void + { + $idsRaw = trim((string) $this->request->request('ids', '')); + if ($idsRaw === '') { + $this->success('', null, ['syncing' => []]); + } + + /** @var int[] $ticketIds */ + $ticketIds = []; + foreach (explode(',', $idsRaw) as $part) { + $part = trim($part); + if ($part !== '' && ctype_digit($part)) { + $ticketIds[] = (int) $part; } } - $summary = sprintf('成功 %d 条,失败 %d 条', $ok, $fail); - if ($fail > 0) { - $summary .= ';' . implode(';', array_slice($messages, 0, 5)); + if ($ticketIds === []) { + $this->success('', null, ['syncing' => []]); } - $this->success($summary); + + $dispatch = new SplitTicketSyncDispatchService(); + $syncing = $dispatch->filterSyncingIds($ticketIds); + + $this->success('', null, ['syncing' => $syncing]); } /** diff --git a/patches/application/admin/lang/zh-cn/split/ticket.php b/patches/application/admin/lang/zh-cn/split/ticket.php index 05db317..b0a30e6 100755 --- a/patches/application/admin/lang/zh-cn/split/ticket.php +++ b/patches/application/admin/lang/zh-cn/split/ticket.php @@ -31,6 +31,12 @@ return [ 'Sync in progress' => '同步中', 'Sync ticket started' => '正在同步工单', 'Sync done' => '同步完成', + 'Sync dispatch queued' => '已提交 %d 条同步任务到后台执行', + 'Sync dispatch skipped suffix' => ',%d 条已在同步中已跳过', + 'Sync dispatch failed suffix' => ',%d 条启动失败', + 'Sync dispatch denied suffix' => ',%d 条无权限未提交', + 'Sync all skipped busy' => '所选工单均在同步中,请稍后再试', + 'Sync dispatch failed' => '未能启动同步任务,请检查服务器是否允许后台执行 CLI(exec/shell_exec)', 'Sync status success' => '同步成功', 'Sync status error' => '同步异常', 'Sync status pending' => '待同步', diff --git a/patches/application/common/service/SplitTicketSyncDispatchService.php b/patches/application/common/service/SplitTicketSyncDispatchService.php new file mode 100644 index 0000000..c62a386 --- /dev/null +++ b/patches/application/common/service/SplitTicketSyncDispatchService.php @@ -0,0 +1,193 @@ +resolvePhpBinary(); + $think = $this->resolveThinkScript(); + $root = $this->resolveRootPath(); + $lockService = new SplitTicketSyncLockService(); + + foreach ($ticketIds as $rawId) { + $ticketId = (int) $rawId; + if ($ticketId <= 0) { + continue; + } + + if ($lockService->isLocked($ticketId)) { + $skipped[] = $ticketId; + continue; + } + + if ($this->spawnCli($php, $think, $root, $ticketId)) { + $queued[] = $ticketId; + } else { + $failed[] = $ticketId; + } + } + + SplitTicketSyncLogger::log('web', 'manual sync dispatched', [ + 'queued' => $queued, + 'skipped' => $skipped, + 'failed' => $failed, + ]); + + return [ + 'queued' => $queued, + 'skipped' => $skipped, + 'failed' => $failed, + ]; + } + + /** + * 查询仍在同步中的工单 ID(依据 runtime 文件锁) + * + * @param int[] $ticketIds + * @return int[] + */ + public function filterSyncingIds(array $ticketIds): array + { + $lockService = new SplitTicketSyncLockService(); + $syncing = []; + foreach ($ticketIds as $rawId) { + $ticketId = (int) $rawId; + if ($ticketId > 0 && $lockService->isLocked($ticketId)) { + $syncing[] = $ticketId; + } + } + + return $syncing; + } + + /** + * 后台启动 split:sync-tickets CLI + */ + private function spawnCli(string $php, string $think, string $root, int $ticketId): bool + { + $logDir = $this->resolveLogDir(); + $logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log'; + + $inner = sprintf( + '%s %s split:sync-tickets --ticket=%d >> %s 2>&1', + escapeshellarg($php), + escapeshellarg($think), + $ticketId, + escapeshellarg($logFile) + ); + + if ($this->isWindows()) { + $command = sprintf('start /B cmd /C %s', $inner); + } else { + $command = sprintf('cd %s && nohup %s &', escapeshellarg($root), $inner); + } + + if ($this->canUseExec()) { + @exec($command, $output, $exitCode); + SplitTicketSyncLogger::log('web', 'cli spawn exec', [ + 'ticketId' => $ticketId, + 'command' => $command, + 'exitCode' => $exitCode, + ]); + return true; + } + + if ($this->canUseShellExec()) { + @shell_exec($command); + SplitTicketSyncLogger::log('web', 'cli spawn shell_exec', [ + 'ticketId' => $ticketId, + 'command' => $command, + ]); + return true; + } + + SplitTicketSyncLogger::log('web', 'cli spawn failed: exec disabled', [ + 'ticketId' => $ticketId, + ]); + + return false; + } + + private function resolvePhpBinary(): string + { + if (defined('PHP_BINARY') && PHP_BINARY !== '') { + return PHP_BINARY; + } + + return 'php'; + } + + private function resolveThinkScript(): string + { + $root = $this->resolveRootPath(); + return rtrim($root, '/\\') . DIRECTORY_SEPARATOR . 'think'; + } + + private function resolveRootPath(): string + { + if (defined('ROOT_PATH') && ROOT_PATH !== '') { + return rtrim(ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR; + } + + return rtrim(dirname(__DIR__, 3), '/\\') . DIRECTORY_SEPARATOR; + } + + private function resolveLogDir(): string + { + $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : ($this->resolveRootPath() . 'runtime' . DIRECTORY_SEPARATOR); + $dir = rtrim($runtime, '/\\') . DIRECTORY_SEPARATOR . 'split_ticket_sync' . DIRECTORY_SEPARATOR; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + + return $dir; + } + + private function isWindows(): bool + { + return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + } + + /** + * @return string[] + */ + private function disabledFunctions(): array + { + $raw = (string) ini_get('disable_functions'); + if ($raw === '') { + return []; + } + + return array_filter(array_map('trim', explode(',', $raw))); + } + + private function canUseExec(): bool + { + return function_exists('exec') && !in_array('exec', $this->disabledFunctions(), true); + } + + private function canUseShellExec(): bool + { + return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true); + } +} diff --git a/patches/application/common/service/SplitTicketSyncLockService.php b/patches/application/common/service/SplitTicketSyncLockService.php index cef85d5..faa62cc 100755 --- a/patches/application/common/service/SplitTicketSyncLockService.php +++ b/patches/application/common/service/SplitTicketSyncLockService.php @@ -43,6 +43,20 @@ class SplitTicketSyncLockService } } + /** + * 工单是否处于同步中(锁文件存在且未过期) + */ + public function isLocked(int $ticketId): bool + { + $path = $this->lockPath($ticketId); + if ($this->isStaleLock($path)) { + @unlink($path); + return false; + } + + return is_file($path); + } + private function lockPath(int $ticketId): string { $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/'); diff --git a/patches/public/assets/js/backend/split/ticket.js b/patches/public/assets/js/backend/split/ticket.js index 9aaac25..07f1ab5 100755 --- a/patches/public/assets/js/backend/split/ticket.js +++ b/patches/public/assets/js/backend/split/ticket.js @@ -244,6 +244,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin /** @type {number[]} 正在手动同步的工单 ID */ syncingTicketIds: [], + syncPollTimer: null, + syncPollStartedAt: 0, + syncPollGraceMs: 8000, /** * 渲染筛选结果汇总行(全量筛选数据,非当前页) @@ -269,7 +272,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }, /** - * 后台同步:标记「同步中」并请求 sync 接口 + * 后台同步:标记「同步中」、投递 CLI,并轮询直至锁释放 * * @param {object} table bootstrapTable 实例 * @param {number[]} ids 工单 ID @@ -292,13 +295,81 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin url: 'split.ticket/sync', data: {ids: ids.join(',')}, loading: false - }, function () { - Controller.api.finishTicketsSync(table); + }, function (data, ret) { + var queued = (ret.data && ret.data.queued) ? ret.data.queued : ids; + Controller.api.syncingTicketIds = (queued || []).map(function (id) { + return parseInt(id, 10); + }).filter(function (id) { + return !isNaN(id) && id > 0; + }); + if (Controller.api.syncingTicketIds.length) { + Controller.api.startSyncPolling(table); + } else { + Controller.api.finishTicketsSync(table); + } }, function () { Controller.api.finishTicketsSync(table); }); }, + /** + * 轮询同步锁状态,全部完成后刷新列表 + */ + startSyncPolling: function (table) { + Controller.api.stopSyncPolling(); + if (!Controller.api.syncingTicketIds.length) { + return; + } + Controller.api.syncPollStartedAt = Date.now(); + Controller.api.syncPollTimer = setInterval(function () { + if (!Controller.api.syncingTicketIds.length) { + Controller.api.stopSyncPolling(); + return; + } + Fast.api.ajax({ + url: 'split.ticket/syncpolling', + data: {ids: Controller.api.syncingTicketIds.join(',')}, + loading: false + }, function (data, ret) { + var still = (ret.data && ret.data.syncing) ? ret.data.syncing : []; + still = (still || []).map(function (id) { + return parseInt(id, 10); + }).filter(function (id) { + return !isNaN(id) && id > 0; + }); + var elapsed = Date.now() - (Controller.api.syncPollStartedAt || 0); + var graceMs = Controller.api.syncPollGraceMs || 8000; + if (still.length === 0 && elapsed < graceMs) { + table.bootstrapTable('refresh', {silent: true}); + return false; + } + if (still.length === 0) { + Controller.api.stopSyncPolling(); + var doneMsg = (typeof Config.syncDoneMsg !== 'undefined' && Config.syncDoneMsg) + ? Config.syncDoneMsg + : '同步完成'; + Toastr.success(doneMsg); + Controller.api.finishTicketsSync(table); + return false; + } + if (still.join(',') !== Controller.api.syncingTicketIds.join(',')) { + Controller.api.syncingTicketIds = still; + var rowData = table.bootstrapTable('getData'); + table.bootstrapTable('load', rowData); + } + table.bootstrapTable('refresh', {silent: true}); + return false; + }); + }, 3000); + }, + + stopSyncPolling: function () { + if (Controller.api.syncPollTimer) { + clearInterval(Controller.api.syncPollTimer); + Controller.api.syncPollTimer = null; + } + }, + /** * 将选中工单标记为「同步中」并刷新列表展示(不请求后端) */ @@ -316,6 +387,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin * 同步结束:清除标记并刷新列表数据 */ finishTicketsSync: function (table) { + Controller.api.stopSyncPolling(); Controller.api.syncingTicketIds = []; table.bootstrapTable('refresh', { silent: true diff --git a/public/assets/js/backend/split/ticket.js b/public/assets/js/backend/split/ticket.js index 9aaac25..07f1ab5 100755 --- a/public/assets/js/backend/split/ticket.js +++ b/public/assets/js/backend/split/ticket.js @@ -244,6 +244,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin /** @type {number[]} 正在手动同步的工单 ID */ syncingTicketIds: [], + syncPollTimer: null, + syncPollStartedAt: 0, + syncPollGraceMs: 8000, /** * 渲染筛选结果汇总行(全量筛选数据,非当前页) @@ -269,7 +272,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin }, /** - * 后台同步:标记「同步中」并请求 sync 接口 + * 后台同步:标记「同步中」、投递 CLI,并轮询直至锁释放 * * @param {object} table bootstrapTable 实例 * @param {number[]} ids 工单 ID @@ -292,13 +295,81 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin url: 'split.ticket/sync', data: {ids: ids.join(',')}, loading: false - }, function () { - Controller.api.finishTicketsSync(table); + }, function (data, ret) { + var queued = (ret.data && ret.data.queued) ? ret.data.queued : ids; + Controller.api.syncingTicketIds = (queued || []).map(function (id) { + return parseInt(id, 10); + }).filter(function (id) { + return !isNaN(id) && id > 0; + }); + if (Controller.api.syncingTicketIds.length) { + Controller.api.startSyncPolling(table); + } else { + Controller.api.finishTicketsSync(table); + } }, function () { Controller.api.finishTicketsSync(table); }); }, + /** + * 轮询同步锁状态,全部完成后刷新列表 + */ + startSyncPolling: function (table) { + Controller.api.stopSyncPolling(); + if (!Controller.api.syncingTicketIds.length) { + return; + } + Controller.api.syncPollStartedAt = Date.now(); + Controller.api.syncPollTimer = setInterval(function () { + if (!Controller.api.syncingTicketIds.length) { + Controller.api.stopSyncPolling(); + return; + } + Fast.api.ajax({ + url: 'split.ticket/syncpolling', + data: {ids: Controller.api.syncingTicketIds.join(',')}, + loading: false + }, function (data, ret) { + var still = (ret.data && ret.data.syncing) ? ret.data.syncing : []; + still = (still || []).map(function (id) { + return parseInt(id, 10); + }).filter(function (id) { + return !isNaN(id) && id > 0; + }); + var elapsed = Date.now() - (Controller.api.syncPollStartedAt || 0); + var graceMs = Controller.api.syncPollGraceMs || 8000; + if (still.length === 0 && elapsed < graceMs) { + table.bootstrapTable('refresh', {silent: true}); + return false; + } + if (still.length === 0) { + Controller.api.stopSyncPolling(); + var doneMsg = (typeof Config.syncDoneMsg !== 'undefined' && Config.syncDoneMsg) + ? Config.syncDoneMsg + : '同步完成'; + Toastr.success(doneMsg); + Controller.api.finishTicketsSync(table); + return false; + } + if (still.join(',') !== Controller.api.syncingTicketIds.join(',')) { + Controller.api.syncingTicketIds = still; + var rowData = table.bootstrapTable('getData'); + table.bootstrapTable('load', rowData); + } + table.bootstrapTable('refresh', {silent: true}); + return false; + }); + }, 3000); + }, + + stopSyncPolling: function () { + if (Controller.api.syncPollTimer) { + clearInterval(Controller.api.syncPollTimer); + Controller.api.syncPollTimer = null; + } + }, + /** * 将选中工单标记为「同步中」并刷新列表展示(不请求后端) */ @@ -316,6 +387,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin * 同步结束:清除标记并刷新列表数据 */ finishTicketsSync: function (table) { + Controller.api.stopSyncPolling(); Controller.api.syncingTicketIds = []; table.bootstrapTable('refresh', { silent: true