diff --git a/application/admin/command/Install/split_ticket_sync_log_enhance.sql b/application/admin/command/Install/split_ticket_sync_log_enhance.sql new file mode 100644 index 0000000..5aa4221 --- /dev/null +++ b/application/admin/command/Install/split_ticket_sync_log_enhance.sql @@ -0,0 +1,15 @@ +-- 工单同步日志增强:最近成功时间/方式、完整失败原因 +SET NAMES utf8mb4; + +ALTER TABLE `fa_split_ticket` + MODIFY COLUMN `sync_message` text COMMENT '同步失败完整原因', + ADD COLUMN `sync_success_time` bigint(16) DEFAULT NULL COMMENT '最近一次同步成功时间' AFTER `sync_time`, + ADD COLUMN `sync_success_mode` enum('auto','manual') DEFAULT NULL COMMENT '最近成功同步方式:auto=自动,manual=手动' AFTER `sync_success_time`; + +-- 历史成功记录回填最近成功时间(方式未知留空) +UPDATE `fa_split_ticket` +SET `sync_success_time` = `sync_time` +WHERE `sync_status` = 'success' + AND `sync_success_time` IS NULL + AND `sync_time` IS NOT NULL + AND `sync_time` > 0; diff --git a/application/admin/command/SplitSyncTickets.php b/application/admin/command/SplitSyncTickets.php index 4868214..62bd888 100755 --- a/application/admin/command/SplitSyncTickets.php +++ b/application/admin/command/SplitSyncTickets.php @@ -62,12 +62,13 @@ class SplitSyncTickets extends Command try { $count = $service->syncDueTickets(); $output->writeln('本次处理工单数: ' . $count . ''); + $output->writeln('汇总日志已写入 runtime/log/split_sync.log'); } finally { $cronLock->release(); } if (SplitTicketSyncLogger::isEnabled()) { - $output->writeln('调试日志已写入 runtime/log/split_sync.log'); + $output->writeln('详细调试日志已写入 runtime/log/split_sync.log'); } } } diff --git a/application/admin/controller/split/Ticket.php b/application/admin/controller/split/Ticket.php index d4d456e..6c890be 100755 --- a/application/admin/controller/split/Ticket.php +++ b/application/admin/controller/split/Ticket.php @@ -273,6 +273,8 @@ class Ticket extends Backend $params['sync_status'], $params['sync_time'], $params['sync_message'], + $params['sync_success_time'], + $params['sync_success_mode'], $params['sync_fail_count'], $params['speed_snapshot_count'], $params['speed_snapshot_time'], diff --git a/application/admin/lang/zh-cn/split/ticket.php b/application/admin/lang/zh-cn/split/ticket.php index 67fafa4..05db317 100755 --- a/application/admin/lang/zh-cn/split/ticket.php +++ b/application/admin/lang/zh-cn/split/ticket.php @@ -37,6 +37,13 @@ return [ 'Sync display success' => '同步成功 / 在线 %s', 'Sync display pending' => '待同步', 'Sync display error' => '同步异常', + 'Sync display auto paused' => '自动同步已暂停', + 'Sync success time' => '最近同步成功', + 'Sync mode auto' => '自动', + 'Sync mode manual' => '手动', + 'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停', + 'Sync tooltip error prefix' => '异常原因:', + 'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log', 'Createtime' => '创建时间', 'Copy' => '拷贝', 'Summary row' => '汇总', diff --git a/application/admin/model/split/Ticket.php b/application/admin/model/split/Ticket.php index 576913c..53feb4d 100755 --- a/application/admin/model/split/Ticket.php +++ b/application/admin/model/split/Ticket.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace app\admin\model\split; +use app\common\service\SplitSyncConfigService; use think\Db; use think\Model; @@ -31,6 +32,9 @@ class Ticket extends Model 'ticket_progress_text', 'inbound_ratio_text', 'sync_display_text', + 'sync_success_text', + 'sync_tooltip_text', + 'sync_auto_paused', ]; /** @@ -152,13 +156,35 @@ class Ticket extends Model } /** - * 同步状态展示文案 + * 是否因连续失败暂停自动同步(工单仍为开启状态) + * + * @param array $data + */ + public static function isAutoSyncPaused(array $data): bool + { + if ((string) ($data['status'] ?? '') !== 'normal') { + return false; + } + $threshold = SplitSyncConfigService::getFailPauseThreshold(); + if ($threshold <= 0) { + return false; + } + return (int) ($data['sync_fail_count'] ?? 0) >= $threshold; + } + + /** + * 同步状态展示文案(列表列默认显示,异常详情见 tooltip) * * @param mixed $value * @param array $data */ public function getSyncDisplayTextAttr($value, $data): string { + if (self::isAutoSyncPaused($data)) { + $threshold = SplitSyncConfigService::getFailPauseThreshold(); + return sprintf((string) __('Sync display auto paused'), $threshold); + } + $status = (string) ($data['sync_status'] ?? 'pending'); $online = (int) ($data['online_count'] ?? 0); if ($status === 'success') { @@ -170,6 +196,66 @@ class Ticket extends Model return (string) __('Sync display error'); } + /** + * 同步状态列 tooltip 完整说明 + * + * @param mixed $value + * @param array $data + */ + public function getSyncTooltipTextAttr($value, $data): string + { + $parts = []; + if (self::isAutoSyncPaused($data)) { + $threshold = SplitSyncConfigService::getFailPauseThreshold(); + $parts[] = sprintf( + (string) __('Sync tooltip auto paused'), + (int) ($data['sync_fail_count'] ?? 0), + $threshold + ); + } + + $message = trim((string) ($data['sync_message'] ?? '')); + if ($message !== '') { + $parts[] = (string) __('Sync tooltip error prefix') . $message; + } + + return implode("\n", $parts); + } + + /** + * 是否暂停自动同步(供前端样式判断) + * + * @param mixed $value + * @param array $data + */ + public function getSyncAutoPausedAttr($value, $data): bool + { + return self::isAutoSyncPaused($data); + } + + /** + * 最近一次同步成功时间及方式 + * + * @param mixed $value + * @param array $data + */ + public function getSyncSuccessTextAttr($value, $data): string + { + $ts = (int) ($data['sync_success_time'] ?? 0); + if ($ts <= 0) { + return ''; + } + $timeText = date('Y-m-d H:i:s', $ts); + $mode = (string) ($data['sync_success_mode'] ?? ''); + if ($mode === 'auto') { + return $timeText . ' (' . (string) __('Sync mode auto') . ')'; + } + if ($mode === 'manual') { + return $timeText . ' (' . (string) __('Sync mode manual') . ')'; + } + return $timeText; + } + public function setStartTimeAttr($value): ?int { return self::parseTimeValue($value); diff --git a/application/admin/view/split/ticket/index.html b/application/admin/view/split/ticket/index.html index 3b445ad..2fcaebc 100755 --- a/application/admin/view/split/ticket/index.html +++ b/application/admin/view/split/ticket/index.html @@ -67,6 +67,24 @@ font-size: 14px; color: #0f172a; } + .split-sync-success-normal { + color: #3c763d; + font-weight: 600; + } + .split-sync-success-stopped { + color: #a94442; + font-weight: 600; + } + .split-ticket-sync-status-paused { + color: #8a6d3b; + font-weight: 600; + cursor: help; + border-bottom: 1px dotted #8a6d3b; + } + .split-ticket-sync-status-error { + cursor: help; + border-bottom: 1px dotted #a94442; + }
diff --git a/application/common/service/SplitTicketSyncLogger.php b/application/common/service/SplitTicketSyncLogger.php index fecd42b..685d63c 100755 --- a/application/common/service/SplitTicketSyncLogger.php +++ b/application/common/service/SplitTicketSyncLogger.php @@ -7,7 +7,9 @@ namespace app\common\service; use think\Config; /** - * 工单云控同步调试日志(仅 app_debug=true 时写入 runtime/log/split_sync.log) + * 工单云控同步日志 + * - logCron:定时任务汇总,app_debug 关闭时也写入 runtime/log/split_sync.log + * - log:详细调试日志,仅 app_debug=true 时写入 */ class SplitTicketSyncLogger { @@ -36,6 +38,18 @@ class SplitTicketSyncLogger } /** + * 定时任务汇总日志(始终写入) + * + * @param array $context + */ + public static function logCron(string $message, array $context = []): void + { + self::writeLine('cron', $message, $context); + } + + /** + * 详细调试日志(仅 app_debug=true) + * * @param array $context */ public static function log(string $stage, string $message, array $context = []): void @@ -43,7 +57,14 @@ class SplitTicketSyncLogger if (!self::isEnabled()) { return; } + self::writeLine($stage, $message, $context); + } + /** + * @param array $context + */ + private static function writeLine(string $stage, string $message, array $context = []): void + { $context = self::sanitize($context); $ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : ''; $line = sprintf( @@ -83,8 +104,8 @@ class SplitTicketSyncLogger $out[$key] = self::sanitize($value); continue; } - if (is_string($value) && mb_strlen($value) > 800) { - $out[$key] = mb_substr($value, 0, 800, 'UTF-8') . '...(truncated)'; + if (is_string($value) && mb_strlen($value) > 2000) { + $out[$key] = mb_substr($value, 0, 2000, 'UTF-8') . '...(truncated)'; continue; } $out[$key] = $value; diff --git a/application/common/service/SplitTicketSyncService.php b/application/common/service/SplitTicketSyncService.php index 9d7850e..80c9b73 100755 --- a/application/common/service/SplitTicketSyncService.php +++ b/application/common/service/SplitTicketSyncService.php @@ -30,10 +30,11 @@ class SplitTicketSyncService /** * 同步单条工单 * - * @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入) + * @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入) + * @param string $syncMode auto=定时自动 manual=手动触发 * @return array{success:bool,message:string,skipped?:bool} */ - public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false): array + public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false, string $syncMode = 'manual'): array { $ticket = Ticket::get($ticketId); if (!$ticket) { @@ -44,6 +45,7 @@ class SplitTicketSyncService SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']); SplitTicketSyncLogger::log('sync', 'syncOne start', [ 'force' => $force, + 'syncMode' => $syncMode, 'status' => (string) $ticket['status'], 'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0), 'syncTime' => (int) ($ticket['sync_time'] ?? 0), @@ -67,7 +69,7 @@ class SplitTicketSyncService } try { - $result = $this->doSync($ticket); + $result = $this->doSync($ticket, $syncMode); SplitTicketSyncLogger::log('sync', 'syncOne end', $result); return $result; } finally { @@ -83,65 +85,143 @@ class SplitTicketSyncService { $maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun(); $autoSyncTypes = $this->resolveAutoSyncTicketTypes(); + $failThreshold = SplitSyncConfigService::getFailPauseThreshold(); + if ($autoSyncTypes === []) { - SplitTicketSyncLogger::log('cron', 'scan skip', ['reason' => 'no auto-sync ticket types']); + SplitTicketSyncLogger::logCron('scan skip', ['reason' => 'no auto-sync ticket types']); return 0; } if (!SplitNodeHealthService::canAcceptWork()) { - SplitTicketSyncLogger::log('cron', 'scan skip', [ + SplitTicketSyncLogger::logCron('scan skip', [ 'reason' => 'node queue busy', 'queue' => SplitNodeHealthService::getQueueSnapshot(), ]); return 0; } - $failThreshold = SplitSyncConfigService::getFailPauseThreshold(); $query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes); if ($failThreshold > 0) { $query->where('sync_fail_count', '<', $failThreshold); } - // 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单 $list = $query->order('sync_time', 'asc')->order('id', 'asc') ->limit($maxPerRun * 5) ->select(); + $candidateCount = count($list); + SplitTicketSyncLogger::logCron('scan start', [ + 'candidateCount' => $candidateCount, + 'maxPerRun' => $maxPerRun, + 'autoSyncTypes' => $autoSyncTypes, + 'failThreshold' => $failThreshold, + 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), + ]); SplitTicketSyncLogger::log('cron', 'scan start', [ - 'candidateCount' => count($list), + 'candidateCount' => $candidateCount, 'maxPerRun' => $maxPerRun, 'autoSyncTypes' => $autoSyncTypes, 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), ]); + /** @var list> $processed */ + $processed = []; + /** @var list> $skipped */ + $skipped = []; $count = 0; - foreach ($list as $ticket) { + $stoppedByNodeBusy = false; + + foreach ($list as $index => $ticket) { + $ticketId = (int) $ticket['id']; + $ticketUrl = (string) $ticket['ticket_url']; + $ticketType = (string) $ticket['ticket_type']; + if ($count >= $maxPerRun) { - break; - } - $skip = $this->shouldSkip($ticket); - if ($skip !== null) { - SplitTicketSyncLogger::log('cron', 'candidate skipped', [ - 'ticketId' => (int) $ticket['id'], - 'ticketType' => (string) $ticket['ticket_type'], - 'reason' => $skip, - ]); + $reason = '本轮处理上限已满'; + $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason); + $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason); continue; } + + $skip = $this->shouldSkip($ticket); + if ($skip !== null) { + $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skip); + $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip); + continue; + } + if (!SplitNodeHealthService::canAcceptWork()) { + $reason = 'Node 队列繁忙,停止本轮后续同步'; + $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason); + $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason); + $stoppedByNodeBusy = true; + for ($i = $index + 1; $i < $candidateCount; $i++) { + $remain = $list[$i]; + $remainReason = 'Node 队列繁忙,本轮未执行'; + $skipped[] = $this->buildCronTicketEntry( + (int) $remain['id'], + (string) $remain['ticket_type'], + (string) $remain['ticket_url'], + $remainReason + ); + } SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [ 'processed' => $count, 'queue' => SplitNodeHealthService::getQueueSnapshot(), ]); break; } - $result = $this->syncOne((int) $ticket['id'], false, true); + + $result = $this->syncOne($ticketId, false, true, 'auto'); if (!empty($result['skipped'])) { + $skipReason = (string) ($result['message'] ?? '已跳过'); + $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason); + $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason); continue; } + + $processed[] = [ + 'ticketId' => $ticketId, + 'ticketType' => $ticketType, + 'ticketUrl' => $ticketUrl, + 'success' => !empty($result['success']), + 'message' => (string) ($result['message'] ?? ''), + ]; $count++; } - SplitTicketSyncLogger::log('cron', 'scan end', ['processedCount' => $count]); + $candidateIds = array_map(static function ($row) { + return (int) $row['id']; + }, $list instanceof \think\Collection ? $list->all() : (array) $list); + $openNotSynced = $this->buildOpenNotSyncedAudit( + $autoSyncTypes, + $failThreshold, + $maxPerRun, + $candidateIds, + $processed, + $skipped, + $stoppedByNodeBusy + ); + + $successCount = count(array_filter($processed, static function (array $row): bool { + return !empty($row['success']); + })); + $failedCount = count($processed) - $successCount; + + SplitTicketSyncLogger::logCron('scan end', [ + 'processedCount' => $count, + 'successCount' => $successCount, + 'failedCount' => $failedCount, + 'skippedCount' => count($skipped), + 'processed' => $processed, + 'skipped' => $skipped, + 'openNotSynced' => $openNotSynced, + ]); + SplitTicketSyncLogger::log('cron', 'scan end', [ + 'processedCount' => $count, + 'successCount' => $successCount, + 'failedCount' => $failedCount, + ]); + return $count; } @@ -164,7 +244,7 @@ class SplitTicketSyncService /** * @return array{success:bool,message:string} */ - private function doSync(Ticket $ticket): array + private function doSync(Ticket $ticket, string $syncMode): array { $ticketType = (string) $ticket['ticket_type']; $pageUrl = trim((string) $ticket['ticket_url']); @@ -212,7 +292,6 @@ class SplitTicketSyncService if ((string) $freshTicket['status'] === 'hidden') { $this->ruleService->cascadeTicketClosedToNumbers($freshTicket); } - // 号码开关最后统一由 applyNumberRules 判定(单号上限/下号比率/云控在线) $this->ruleService->applyNumberRules($freshTicket); $ticket = $freshTicket; @@ -232,13 +311,13 @@ class SplitTicketSyncService 'speed_snapshot_time' => $speed['snapshot_time'], ]; - $this->applySyncResult($ticket, $payload, true, ''); + $this->applySyncResult($ticket, $payload, true, '', $syncMode); Db::commit(); SplitTicketSyncLogger::log('sync', 'db commit ok', $payload); return ['success' => true, 'message' => '同步成功']; } catch (\Throwable $e) { Db::rollback(); - $msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8'); + $msg = $e->getMessage(); SplitTicketSyncLogger::log('sync', 'exception', [ 'type' => get_class($e), 'message' => $msg, @@ -282,8 +361,9 @@ class SplitTicketSyncService /** * @param array $payload */ - public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = ''): void + public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = '', string $syncMode = 'manual'): void { + $now = time(); $data = [ 'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)), 'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)), @@ -293,12 +373,16 @@ class SplitTicketSyncService 'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)), 'online_count' => max(0, (int) ($payload['online_count'] ?? 0)), 'sync_status' => $success ? 'success' : 'error', - 'sync_time' => time(), - 'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'), + 'sync_time' => $now, + 'sync_message' => $success ? '' : $message, 'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1), 'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0), 'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0), ]; + if ($success) { + $data['sync_success_time'] = $now; + $data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual'; + } if (!$ticket->allowField(array_keys($data))->save($data)) { throw new Exception('工单同步结果保存失败'); } @@ -313,10 +397,9 @@ class SplitTicketSyncService $update = [ 'sync_status' => 'error', 'sync_time' => time(), - 'sync_message' => mb_substr($message, 0, 255, 'UTF-8'), + 'sync_message' => $message, 'sync_fail_count' => $failCount, ]; - // 新建工单首次同步失败:立即关闭;已同步过的工单仍按连续失败阈值关闭 if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) { $update['status'] = 'hidden'; } @@ -365,4 +448,129 @@ class SplitTicketSyncService 'snapshot_time' => $snapshotTime, ]; } + + /** + * @return array{ticketId:int,ticketType:string,ticketUrl:string,reason:string} + */ + private function buildCronTicketEntry(int $ticketId, string $ticketType, string $ticketUrl, string $reason): array + { + return [ + 'ticketId' => $ticketId, + 'ticketType' => $ticketType, + 'ticketUrl' => $ticketUrl, + 'reason' => $reason, + ]; + } + + private function logCronCandidateSkipped(int $ticketId, string $ticketType, string $ticketUrl, string $reason): void + { + $ctx = [ + 'ticketId' => $ticketId, + 'ticketType' => $ticketType, + 'ticketUrl' => $ticketUrl, + 'reason' => $reason, + ]; + SplitTicketSyncLogger::logCron('candidate skipped', $ctx); + SplitTicketSyncLogger::log('cron', 'candidate skipped', $ctx); + } + + /** + * 开启但未在本轮同步的工单及原因(供 cron 汇总) + * + * @param list $candidateIds + * @param list> $processed + * @param list> $skipped + * @return list> + */ + private function buildOpenNotSyncedAudit( + array $autoSyncTypes, + int $failThreshold, + int $maxPerRun, + array $candidateIds, + array $processed, + array $skipped, + bool $stoppedByNodeBusy + ): array { + $handledIds = []; + foreach ($processed as $row) { + $handledIds[(int) ($row['ticketId'] ?? 0)] = true; + } + foreach ($skipped as $row) { + $handledIds[(int) ($row['ticketId'] ?? 0)] = true; + } + $candidateIdMap = array_fill_keys($candidateIds, true); + + $supportedTypes = SplitScrmSpiderFactory::listSupportedTypes(); + if ($supportedTypes === []) { + return []; + } + + $openList = Ticket::where('status', 'normal') + ->whereIn('ticket_type', $supportedTypes) + ->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status') + ->select(); + + $result = []; + foreach ($openList as $ticket) { + $ticketId = (int) $ticket['id']; + if (isset($handledIds[$ticketId])) { + continue; + } + + $ticketType = (string) $ticket['ticket_type']; + $ticketUrl = (string) $ticket['ticket_url']; + $reason = $this->resolveOpenNotSyncedReason( + $ticket, + $autoSyncTypes, + $failThreshold, + $maxPerRun, + $candidateIdMap, + $stoppedByNodeBusy + ); + if ($reason === null) { + continue; + } + + $result[] = [ + 'ticketId' => $ticketId, + 'ticketType' => $ticketType, + 'ticketUrl' => $ticketUrl, + 'reason' => $reason, + ]; + } + + return $result; + } + + /** + * @param array $ticket + * @param array $candidateIdMap + */ + private function resolveOpenNotSyncedReason( + array $ticket, + array $autoSyncTypes, + int $failThreshold, + int $maxPerRun, + array $candidateIdMap, + bool $stoppedByNodeBusy + ): ?string { + $ticketType = (string) ($ticket['ticket_type'] ?? ''); + $failCount = (int) ($ticket['sync_fail_count'] ?? 0); + + if ($failThreshold > 0 && $failCount >= $failThreshold) { + return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold); + } + if (!in_array($ticketType, $autoSyncTypes, true)) { + return '该类型未配置自动同步周期'; + } + if (!isset($candidateIdMap[(int) ($ticket['id'] ?? 0)])) { + return sprintf('未进入本轮候选队列(仅扫描最久未同步的前%d条)', $maxPerRun * 5); + } + if ($stoppedByNodeBusy) { + return 'Node 队列繁忙,本轮未执行'; + } + + $skip = $this->shouldSkip(Ticket::get((int) $ticket['id']) ?: new Ticket($ticket)); + return $skip ?? '未知原因,未进入执行队列'; + } } diff --git a/links.test_mZZS6.zip b/links.test_mZZS6.zip new file mode 100755 index 0000000..45ae0ab Binary files /dev/null and b/links.test_mZZS6.zip differ diff --git a/patches/application/admin/command/Install/split_ticket_sync_log_enhance.sql b/patches/application/admin/command/Install/split_ticket_sync_log_enhance.sql new file mode 100644 index 0000000..aaffa4e --- /dev/null +++ b/patches/application/admin/command/Install/split_ticket_sync_log_enhance.sql @@ -0,0 +1,14 @@ +-- 工单同步日志增强:最近成功时间/方式、完整失败原因 +SET NAMES utf8mb4; + +ALTER TABLE `fa_split_ticket` + MODIFY COLUMN `sync_message` text COMMENT '同步失败完整原因', + ADD COLUMN `sync_success_time` bigint(16) DEFAULT NULL COMMENT '最近一次同步成功时间' AFTER `sync_time`, + ADD COLUMN `sync_success_mode` enum('auto','manual') DEFAULT NULL COMMENT '最近成功同步方式:auto=自动,manual=手动' AFTER `sync_success_time`; + +UPDATE `fa_split_ticket` +SET `sync_success_time` = `sync_time` +WHERE `sync_status` = 'success' + AND `sync_success_time` IS NULL + AND `sync_time` IS NOT NULL + AND `sync_time` > 0; diff --git a/patches/application/admin/command/SplitSyncTickets.php b/patches/application/admin/command/SplitSyncTickets.php index 4868214..62bd888 100755 --- a/patches/application/admin/command/SplitSyncTickets.php +++ b/patches/application/admin/command/SplitSyncTickets.php @@ -62,12 +62,13 @@ class SplitSyncTickets extends Command try { $count = $service->syncDueTickets(); $output->writeln('本次处理工单数: ' . $count . ''); + $output->writeln('汇总日志已写入 runtime/log/split_sync.log'); } finally { $cronLock->release(); } if (SplitTicketSyncLogger::isEnabled()) { - $output->writeln('调试日志已写入 runtime/log/split_sync.log'); + $output->writeln('详细调试日志已写入 runtime/log/split_sync.log'); } } } diff --git a/patches/application/admin/controller/split/Ticket.php b/patches/application/admin/controller/split/Ticket.php index d4d456e..6c890be 100755 --- a/patches/application/admin/controller/split/Ticket.php +++ b/patches/application/admin/controller/split/Ticket.php @@ -273,6 +273,8 @@ class Ticket extends Backend $params['sync_status'], $params['sync_time'], $params['sync_message'], + $params['sync_success_time'], + $params['sync_success_mode'], $params['sync_fail_count'], $params['speed_snapshot_count'], $params['speed_snapshot_time'], diff --git a/patches/application/admin/lang/zh-cn/split/ticket.php b/patches/application/admin/lang/zh-cn/split/ticket.php index 67fafa4..05db317 100755 --- a/patches/application/admin/lang/zh-cn/split/ticket.php +++ b/patches/application/admin/lang/zh-cn/split/ticket.php @@ -37,6 +37,13 @@ return [ 'Sync display success' => '同步成功 / 在线 %s', 'Sync display pending' => '待同步', 'Sync display error' => '同步异常', + 'Sync display auto paused' => '自动同步已暂停', + 'Sync success time' => '最近同步成功', + 'Sync mode auto' => '自动', + 'Sync mode manual' => '手动', + 'Sync tooltip auto paused' => '连续失败 %s 次,已达暂停阈值 %s,自动同步已暂停', + 'Sync tooltip error prefix' => '异常原因:', + 'Cron log path hint' => '汇总日志已写入 runtime/log/split_sync.log', 'Createtime' => '创建时间', 'Copy' => '拷贝', 'Summary row' => '汇总', diff --git a/patches/application/admin/model/split/Ticket.php b/patches/application/admin/model/split/Ticket.php index 576913c..53feb4d 100755 --- a/patches/application/admin/model/split/Ticket.php +++ b/patches/application/admin/model/split/Ticket.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace app\admin\model\split; +use app\common\service\SplitSyncConfigService; use think\Db; use think\Model; @@ -31,6 +32,9 @@ class Ticket extends Model 'ticket_progress_text', 'inbound_ratio_text', 'sync_display_text', + 'sync_success_text', + 'sync_tooltip_text', + 'sync_auto_paused', ]; /** @@ -152,13 +156,35 @@ class Ticket extends Model } /** - * 同步状态展示文案 + * 是否因连续失败暂停自动同步(工单仍为开启状态) + * + * @param array $data + */ + public static function isAutoSyncPaused(array $data): bool + { + if ((string) ($data['status'] ?? '') !== 'normal') { + return false; + } + $threshold = SplitSyncConfigService::getFailPauseThreshold(); + if ($threshold <= 0) { + return false; + } + return (int) ($data['sync_fail_count'] ?? 0) >= $threshold; + } + + /** + * 同步状态展示文案(列表列默认显示,异常详情见 tooltip) * * @param mixed $value * @param array $data */ public function getSyncDisplayTextAttr($value, $data): string { + if (self::isAutoSyncPaused($data)) { + $threshold = SplitSyncConfigService::getFailPauseThreshold(); + return sprintf((string) __('Sync display auto paused'), $threshold); + } + $status = (string) ($data['sync_status'] ?? 'pending'); $online = (int) ($data['online_count'] ?? 0); if ($status === 'success') { @@ -170,6 +196,66 @@ class Ticket extends Model return (string) __('Sync display error'); } + /** + * 同步状态列 tooltip 完整说明 + * + * @param mixed $value + * @param array $data + */ + public function getSyncTooltipTextAttr($value, $data): string + { + $parts = []; + if (self::isAutoSyncPaused($data)) { + $threshold = SplitSyncConfigService::getFailPauseThreshold(); + $parts[] = sprintf( + (string) __('Sync tooltip auto paused'), + (int) ($data['sync_fail_count'] ?? 0), + $threshold + ); + } + + $message = trim((string) ($data['sync_message'] ?? '')); + if ($message !== '') { + $parts[] = (string) __('Sync tooltip error prefix') . $message; + } + + return implode("\n", $parts); + } + + /** + * 是否暂停自动同步(供前端样式判断) + * + * @param mixed $value + * @param array $data + */ + public function getSyncAutoPausedAttr($value, $data): bool + { + return self::isAutoSyncPaused($data); + } + + /** + * 最近一次同步成功时间及方式 + * + * @param mixed $value + * @param array $data + */ + public function getSyncSuccessTextAttr($value, $data): string + { + $ts = (int) ($data['sync_success_time'] ?? 0); + if ($ts <= 0) { + return ''; + } + $timeText = date('Y-m-d H:i:s', $ts); + $mode = (string) ($data['sync_success_mode'] ?? ''); + if ($mode === 'auto') { + return $timeText . ' (' . (string) __('Sync mode auto') . ')'; + } + if ($mode === 'manual') { + return $timeText . ' (' . (string) __('Sync mode manual') . ')'; + } + return $timeText; + } + public function setStartTimeAttr($value): ?int { return self::parseTimeValue($value); diff --git a/patches/application/admin/view/split/ticket/index.html b/patches/application/admin/view/split/ticket/index.html index 3b445ad..2fcaebc 100755 --- a/patches/application/admin/view/split/ticket/index.html +++ b/patches/application/admin/view/split/ticket/index.html @@ -67,6 +67,24 @@ font-size: 14px; color: #0f172a; } + .split-sync-success-normal { + color: #3c763d; + font-weight: 600; + } + .split-sync-success-stopped { + color: #a94442; + font-weight: 600; + } + .split-ticket-sync-status-paused { + color: #8a6d3b; + font-weight: 600; + cursor: help; + border-bottom: 1px dotted #8a6d3b; + } + .split-ticket-sync-status-error { + cursor: help; + border-bottom: 1px dotted #a94442; + }
diff --git a/patches/application/common/service/SplitTicketSyncLogger.php b/patches/application/common/service/SplitTicketSyncLogger.php index fecd42b..685d63c 100755 --- a/patches/application/common/service/SplitTicketSyncLogger.php +++ b/patches/application/common/service/SplitTicketSyncLogger.php @@ -7,7 +7,9 @@ namespace app\common\service; use think\Config; /** - * 工单云控同步调试日志(仅 app_debug=true 时写入 runtime/log/split_sync.log) + * 工单云控同步日志 + * - logCron:定时任务汇总,app_debug 关闭时也写入 runtime/log/split_sync.log + * - log:详细调试日志,仅 app_debug=true 时写入 */ class SplitTicketSyncLogger { @@ -36,6 +38,18 @@ class SplitTicketSyncLogger } /** + * 定时任务汇总日志(始终写入) + * + * @param array $context + */ + public static function logCron(string $message, array $context = []): void + { + self::writeLine('cron', $message, $context); + } + + /** + * 详细调试日志(仅 app_debug=true) + * * @param array $context */ public static function log(string $stage, string $message, array $context = []): void @@ -43,7 +57,14 @@ class SplitTicketSyncLogger if (!self::isEnabled()) { return; } + self::writeLine($stage, $message, $context); + } + /** + * @param array $context + */ + private static function writeLine(string $stage, string $message, array $context = []): void + { $context = self::sanitize($context); $ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : ''; $line = sprintf( @@ -83,8 +104,8 @@ class SplitTicketSyncLogger $out[$key] = self::sanitize($value); continue; } - if (is_string($value) && mb_strlen($value) > 800) { - $out[$key] = mb_substr($value, 0, 800, 'UTF-8') . '...(truncated)'; + if (is_string($value) && mb_strlen($value) > 2000) { + $out[$key] = mb_substr($value, 0, 2000, 'UTF-8') . '...(truncated)'; continue; } $out[$key] = $value; diff --git a/patches/application/common/service/SplitTicketSyncService.php b/patches/application/common/service/SplitTicketSyncService.php index 9d7850e..80c9b73 100755 --- a/patches/application/common/service/SplitTicketSyncService.php +++ b/patches/application/common/service/SplitTicketSyncService.php @@ -30,10 +30,11 @@ class SplitTicketSyncService /** * 同步单条工单 * - * @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入) + * @param bool $dueValidated 为 true 时跳过 shouldSkip(由 syncDueTickets 预筛后传入) + * @param string $syncMode auto=定时自动 manual=手动触发 * @return array{success:bool,message:string,skipped?:bool} */ - public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false): array + public function syncOne(int $ticketId, bool $force = false, bool $dueValidated = false, string $syncMode = 'manual'): array { $ticket = Ticket::get($ticketId); if (!$ticket) { @@ -44,6 +45,7 @@ class SplitTicketSyncService SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']); SplitTicketSyncLogger::log('sync', 'syncOne start', [ 'force' => $force, + 'syncMode' => $syncMode, 'status' => (string) $ticket['status'], 'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0), 'syncTime' => (int) ($ticket['sync_time'] ?? 0), @@ -67,7 +69,7 @@ class SplitTicketSyncService } try { - $result = $this->doSync($ticket); + $result = $this->doSync($ticket, $syncMode); SplitTicketSyncLogger::log('sync', 'syncOne end', $result); return $result; } finally { @@ -83,65 +85,143 @@ class SplitTicketSyncService { $maxPerRun = SplitSyncConfigService::getMaxTicketsPerCronRun(); $autoSyncTypes = $this->resolveAutoSyncTicketTypes(); + $failThreshold = SplitSyncConfigService::getFailPauseThreshold(); + if ($autoSyncTypes === []) { - SplitTicketSyncLogger::log('cron', 'scan skip', ['reason' => 'no auto-sync ticket types']); + SplitTicketSyncLogger::logCron('scan skip', ['reason' => 'no auto-sync ticket types']); return 0; } if (!SplitNodeHealthService::canAcceptWork()) { - SplitTicketSyncLogger::log('cron', 'scan skip', [ + SplitTicketSyncLogger::logCron('scan skip', [ 'reason' => 'node queue busy', 'queue' => SplitNodeHealthService::getQueueSnapshot(), ]); return 0; } - $failThreshold = SplitSyncConfigService::getFailPauseThreshold(); $query = Ticket::where('status', 'normal')->whereIn('ticket_type', $autoSyncTypes); if ($failThreshold > 0) { $query->where('sync_fail_count', '<', $failThreshold); } - // 多取候选:间隔过滤在 PHP 完成,优先同步最久未更新的工单 $list = $query->order('sync_time', 'asc')->order('id', 'asc') ->limit($maxPerRun * 5) ->select(); + $candidateCount = count($list); + SplitTicketSyncLogger::logCron('scan start', [ + 'candidateCount' => $candidateCount, + 'maxPerRun' => $maxPerRun, + 'autoSyncTypes' => $autoSyncTypes, + 'failThreshold' => $failThreshold, + 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), + ]); SplitTicketSyncLogger::log('cron', 'scan start', [ - 'candidateCount' => count($list), + 'candidateCount' => $candidateCount, 'maxPerRun' => $maxPerRun, 'autoSyncTypes' => $autoSyncTypes, 'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(), ]); + /** @var list> $processed */ + $processed = []; + /** @var list> $skipped */ + $skipped = []; $count = 0; - foreach ($list as $ticket) { + $stoppedByNodeBusy = false; + + foreach ($list as $index => $ticket) { + $ticketId = (int) $ticket['id']; + $ticketUrl = (string) $ticket['ticket_url']; + $ticketType = (string) $ticket['ticket_type']; + if ($count >= $maxPerRun) { - break; - } - $skip = $this->shouldSkip($ticket); - if ($skip !== null) { - SplitTicketSyncLogger::log('cron', 'candidate skipped', [ - 'ticketId' => (int) $ticket['id'], - 'ticketType' => (string) $ticket['ticket_type'], - 'reason' => $skip, - ]); + $reason = '本轮处理上限已满'; + $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason); + $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason); continue; } + + $skip = $this->shouldSkip($ticket); + if ($skip !== null) { + $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skip); + $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip); + continue; + } + if (!SplitNodeHealthService::canAcceptWork()) { + $reason = 'Node 队列繁忙,停止本轮后续同步'; + $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $reason); + $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $reason); + $stoppedByNodeBusy = true; + for ($i = $index + 1; $i < $candidateCount; $i++) { + $remain = $list[$i]; + $remainReason = 'Node 队列繁忙,本轮未执行'; + $skipped[] = $this->buildCronTicketEntry( + (int) $remain['id'], + (string) $remain['ticket_type'], + (string) $remain['ticket_url'], + $remainReason + ); + } SplitTicketSyncLogger::log('cron', 'node busy, stop batch', [ 'processed' => $count, 'queue' => SplitNodeHealthService::getQueueSnapshot(), ]); break; } - $result = $this->syncOne((int) $ticket['id'], false, true); + + $result = $this->syncOne($ticketId, false, true, 'auto'); if (!empty($result['skipped'])) { + $skipReason = (string) ($result['message'] ?? '已跳过'); + $skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason); + $this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason); continue; } + + $processed[] = [ + 'ticketId' => $ticketId, + 'ticketType' => $ticketType, + 'ticketUrl' => $ticketUrl, + 'success' => !empty($result['success']), + 'message' => (string) ($result['message'] ?? ''), + ]; $count++; } - SplitTicketSyncLogger::log('cron', 'scan end', ['processedCount' => $count]); + $candidateIds = array_map(static function ($row) { + return (int) $row['id']; + }, $list instanceof \think\Collection ? $list->all() : (array) $list); + $openNotSynced = $this->buildOpenNotSyncedAudit( + $autoSyncTypes, + $failThreshold, + $maxPerRun, + $candidateIds, + $processed, + $skipped, + $stoppedByNodeBusy + ); + + $successCount = count(array_filter($processed, static function (array $row): bool { + return !empty($row['success']); + })); + $failedCount = count($processed) - $successCount; + + SplitTicketSyncLogger::logCron('scan end', [ + 'processedCount' => $count, + 'successCount' => $successCount, + 'failedCount' => $failedCount, + 'skippedCount' => count($skipped), + 'processed' => $processed, + 'skipped' => $skipped, + 'openNotSynced' => $openNotSynced, + ]); + SplitTicketSyncLogger::log('cron', 'scan end', [ + 'processedCount' => $count, + 'successCount' => $successCount, + 'failedCount' => $failedCount, + ]); + return $count; } @@ -164,7 +244,7 @@ class SplitTicketSyncService /** * @return array{success:bool,message:string} */ - private function doSync(Ticket $ticket): array + private function doSync(Ticket $ticket, string $syncMode): array { $ticketType = (string) $ticket['ticket_type']; $pageUrl = trim((string) $ticket['ticket_url']); @@ -212,7 +292,6 @@ class SplitTicketSyncService if ((string) $freshTicket['status'] === 'hidden') { $this->ruleService->cascadeTicketClosedToNumbers($freshTicket); } - // 号码开关最后统一由 applyNumberRules 判定(单号上限/下号比率/云控在线) $this->ruleService->applyNumberRules($freshTicket); $ticket = $freshTicket; @@ -232,13 +311,13 @@ class SplitTicketSyncService 'speed_snapshot_time' => $speed['snapshot_time'], ]; - $this->applySyncResult($ticket, $payload, true, ''); + $this->applySyncResult($ticket, $payload, true, '', $syncMode); Db::commit(); SplitTicketSyncLogger::log('sync', 'db commit ok', $payload); return ['success' => true, 'message' => '同步成功']; } catch (\Throwable $e) { Db::rollback(); - $msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8'); + $msg = $e->getMessage(); SplitTicketSyncLogger::log('sync', 'exception', [ 'type' => get_class($e), 'message' => $msg, @@ -282,8 +361,9 @@ class SplitTicketSyncService /** * @param array $payload */ - public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = ''): void + public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = '', string $syncMode = 'manual'): void { + $now = time(); $data = [ 'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)), 'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)), @@ -293,12 +373,16 @@ class SplitTicketSyncService 'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)), 'online_count' => max(0, (int) ($payload['online_count'] ?? 0)), 'sync_status' => $success ? 'success' : 'error', - 'sync_time' => time(), - 'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'), + 'sync_time' => $now, + 'sync_message' => $success ? '' : $message, 'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1), 'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0), 'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0), ]; + if ($success) { + $data['sync_success_time'] = $now; + $data['sync_success_mode'] = in_array($syncMode, ['auto', 'manual'], true) ? $syncMode : 'manual'; + } if (!$ticket->allowField(array_keys($data))->save($data)) { throw new Exception('工单同步结果保存失败'); } @@ -313,10 +397,9 @@ class SplitTicketSyncService $update = [ 'sync_status' => 'error', 'sync_time' => time(), - 'sync_message' => mb_substr($message, 0, 255, 'UTF-8'), + 'sync_message' => $message, 'sync_fail_count' => $failCount, ]; - // 新建工单首次同步失败:立即关闭;已同步过的工单仍按连续失败阈值关闭 if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) { $update['status'] = 'hidden'; } @@ -365,4 +448,129 @@ class SplitTicketSyncService 'snapshot_time' => $snapshotTime, ]; } + + /** + * @return array{ticketId:int,ticketType:string,ticketUrl:string,reason:string} + */ + private function buildCronTicketEntry(int $ticketId, string $ticketType, string $ticketUrl, string $reason): array + { + return [ + 'ticketId' => $ticketId, + 'ticketType' => $ticketType, + 'ticketUrl' => $ticketUrl, + 'reason' => $reason, + ]; + } + + private function logCronCandidateSkipped(int $ticketId, string $ticketType, string $ticketUrl, string $reason): void + { + $ctx = [ + 'ticketId' => $ticketId, + 'ticketType' => $ticketType, + 'ticketUrl' => $ticketUrl, + 'reason' => $reason, + ]; + SplitTicketSyncLogger::logCron('candidate skipped', $ctx); + SplitTicketSyncLogger::log('cron', 'candidate skipped', $ctx); + } + + /** + * 开启但未在本轮同步的工单及原因(供 cron 汇总) + * + * @param list $candidateIds + * @param list> $processed + * @param list> $skipped + * @return list> + */ + private function buildOpenNotSyncedAudit( + array $autoSyncTypes, + int $failThreshold, + int $maxPerRun, + array $candidateIds, + array $processed, + array $skipped, + bool $stoppedByNodeBusy + ): array { + $handledIds = []; + foreach ($processed as $row) { + $handledIds[(int) ($row['ticketId'] ?? 0)] = true; + } + foreach ($skipped as $row) { + $handledIds[(int) ($row['ticketId'] ?? 0)] = true; + } + $candidateIdMap = array_fill_keys($candidateIds, true); + + $supportedTypes = SplitScrmSpiderFactory::listSupportedTypes(); + if ($supportedTypes === []) { + return []; + } + + $openList = Ticket::where('status', 'normal') + ->whereIn('ticket_type', $supportedTypes) + ->field('id,ticket_type,ticket_url,sync_fail_count,sync_time,sync_status') + ->select(); + + $result = []; + foreach ($openList as $ticket) { + $ticketId = (int) $ticket['id']; + if (isset($handledIds[$ticketId])) { + continue; + } + + $ticketType = (string) $ticket['ticket_type']; + $ticketUrl = (string) $ticket['ticket_url']; + $reason = $this->resolveOpenNotSyncedReason( + $ticket, + $autoSyncTypes, + $failThreshold, + $maxPerRun, + $candidateIdMap, + $stoppedByNodeBusy + ); + if ($reason === null) { + continue; + } + + $result[] = [ + 'ticketId' => $ticketId, + 'ticketType' => $ticketType, + 'ticketUrl' => $ticketUrl, + 'reason' => $reason, + ]; + } + + return $result; + } + + /** + * @param array $ticket + * @param array $candidateIdMap + */ + private function resolveOpenNotSyncedReason( + array $ticket, + array $autoSyncTypes, + int $failThreshold, + int $maxPerRun, + array $candidateIdMap, + bool $stoppedByNodeBusy + ): ?string { + $ticketType = (string) ($ticket['ticket_type'] ?? ''); + $failCount = (int) ($ticket['sync_fail_count'] ?? 0); + + if ($failThreshold > 0 && $failCount >= $failThreshold) { + return sprintf('连续同步失败超过%d次已暂停自动同步', $failThreshold); + } + if (!in_array($ticketType, $autoSyncTypes, true)) { + return '该类型未配置自动同步周期'; + } + if (!isset($candidateIdMap[(int) ($ticket['id'] ?? 0)])) { + return sprintf('未进入本轮候选队列(仅扫描最久未同步的前%d条)', $maxPerRun * 5); + } + if ($stoppedByNodeBusy) { + return 'Node 队列繁忙,本轮未执行'; + } + + $skip = $this->shouldSkip(Ticket::get((int) $ticket['id']) ?: new Ticket($ticket)); + return $skip ?? '未知原因,未进入执行队列'; + } } diff --git a/patches/public/assets/js/backend/split/ticket.js b/patches/public/assets/js/backend/split/ticket.js index 3d11d61..9aaac25 100755 --- a/patches/public/assets/js/backend/split/ticket.js +++ b/patches/public/assets/js/backend/split/ticket.js @@ -24,6 +24,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin sortOrder: 'desc', fixedColumns: true, fixedRightNumber: 1, + searchFormVisible: true, columns: [ [ {checkbox: true}, @@ -98,6 +99,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin operate: false, formatter: Controller.api.formatter.syncDisplay }, + { + field: 'sync_success_text', + title: __('Sync success time'), + operate: false, + formatter: Controller.api.formatter.syncSuccessDisplay + }, $.extend( {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'}, searchSelectMeta(false) @@ -136,6 +143,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin table.on('load-success.bs.table', function (e, res) { Controller.api.renderSummaryRow(res); + Controller.api.initSyncTooltips(table); var pendingIds = window.__splitTicketPendingPostAddSyncIds; if (!pendingIds || !pendingIds.length) { return; @@ -309,11 +317,26 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin */ finishTicketsSync: function (table) { Controller.api.syncingTicketIds = []; - table.bootstrapTable('refresh'); + table.bootstrapTable('refresh', { + silent: true + }); + Controller.api.initSyncTooltips(table); var ids = Table.api.selectedids(table); $('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0); }, + /** + * 初始化同步状态列 tooltip(完整异常原因) + */ + initSyncTooltips: function (table) { + var $container = table.closest('.bootstrap-table'); + $container.find('[data-sync-tooltip="1"]').tooltip({ + container: 'body', + placement: 'auto top', + html: false + }); + }, + formatter: { /** * 操作列:编辑 → 拷贝 → 删除 @@ -407,13 +430,38 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin return Controller.api.formatter.syncingDisplayHtml(); } var text = value || ''; + var tooltip = row.sync_tooltip_text ? String(row.sync_tooltip_text).trim() : ''; + if (tooltip !== '') { + tooltip = tooltip.replace(/\r?\n/g, ';'); + } + var hasTooltip = tooltip !== ''; var color = 'danger'; - if (row.sync_status === 'success') { + var extraClass = ''; + if (row.sync_auto_paused) { + color = 'warning'; + extraClass = ' split-ticket-sync-status-paused'; + } else if (row.sync_status === 'success') { color = 'success'; } else if (row.sync_status === 'pending') { color = 'muted'; + } else if (row.sync_status === 'error') { + extraClass = ' split-ticket-sync-status-error'; } - return '' + Fast.api.escape(text) + ''; + var attrs = ''; + if (hasTooltip) { + attrs = ' data-sync-tooltip="1" data-toggle="tooltip" title="' + Fast.api.escape(tooltip) + '"'; + } + return '' + Fast.api.escape(text) + ''; + }, + syncSuccessDisplay: function (value, row) { + var text = value == null ? '' : String(value).trim(); + if (text === '') { + return '-'; + } + var colorClass = row.status === 'normal' + ? 'split-sync-success-normal' + : 'split-sync-success-stopped'; + return '' + Fast.api.escape(text) + ''; }, syncingDisplayHtml: function () { var label = (typeof Config.syncInProgressMsg !== 'undefined' && Config.syncInProgressMsg) diff --git a/public/assets/js/backend/split/ticket.js b/public/assets/js/backend/split/ticket.js index 77598d8..9aaac25 100755 --- a/public/assets/js/backend/split/ticket.js +++ b/public/assets/js/backend/split/ticket.js @@ -99,6 +99,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin operate: false, formatter: Controller.api.formatter.syncDisplay }, + { + field: 'sync_success_text', + title: __('Sync success time'), + operate: false, + formatter: Controller.api.formatter.syncSuccessDisplay + }, $.extend( {field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'}, searchSelectMeta(false) @@ -137,6 +143,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin table.on('load-success.bs.table', function (e, res) { Controller.api.renderSummaryRow(res); + Controller.api.initSyncTooltips(table); var pendingIds = window.__splitTicketPendingPostAddSyncIds; if (!pendingIds || !pendingIds.length) { return; @@ -310,11 +317,26 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin */ finishTicketsSync: function (table) { Controller.api.syncingTicketIds = []; - table.bootstrapTable('refresh'); + table.bootstrapTable('refresh', { + silent: true + }); + Controller.api.initSyncTooltips(table); var ids = Table.api.selectedids(table); $('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0); }, + /** + * 初始化同步状态列 tooltip(完整异常原因) + */ + initSyncTooltips: function (table) { + var $container = table.closest('.bootstrap-table'); + $container.find('[data-sync-tooltip="1"]').tooltip({ + container: 'body', + placement: 'auto top', + html: false + }); + }, + formatter: { /** * 操作列:编辑 → 拷贝 → 删除 @@ -408,13 +430,38 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin return Controller.api.formatter.syncingDisplayHtml(); } var text = value || ''; + var tooltip = row.sync_tooltip_text ? String(row.sync_tooltip_text).trim() : ''; + if (tooltip !== '') { + tooltip = tooltip.replace(/\r?\n/g, ';'); + } + var hasTooltip = tooltip !== ''; var color = 'danger'; - if (row.sync_status === 'success') { + var extraClass = ''; + if (row.sync_auto_paused) { + color = 'warning'; + extraClass = ' split-ticket-sync-status-paused'; + } else if (row.sync_status === 'success') { color = 'success'; } else if (row.sync_status === 'pending') { color = 'muted'; + } else if (row.sync_status === 'error') { + extraClass = ' split-ticket-sync-status-error'; } - return '' + Fast.api.escape(text) + ''; + var attrs = ''; + if (hasTooltip) { + attrs = ' data-sync-tooltip="1" data-toggle="tooltip" title="' + Fast.api.escape(tooltip) + '"'; + } + return '' + Fast.api.escape(text) + ''; + }, + syncSuccessDisplay: function (value, row) { + var text = value == null ? '' : String(value).trim(); + if (text === '') { + return '-'; + } + var colorClass = row.status === 'normal' + ? 'split-sync-success-normal' + : 'split-sync-success-stopped'; + return '' + Fast.api.escape(text) + ''; }, syncingDisplayHtml: function () { var label = (typeof Config.syncInProgressMsg !== 'undefined' && Config.syncInProgressMsg)