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 '';
+ },
+ 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 '';
+ },
+ 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)