#add jira & message sync
This commit is contained in:
38
.env.example
38
.env.example
@@ -63,3 +63,41 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# JIRA Configuration
|
||||
JIRA_HOST=http://jira.eainc.com:8080
|
||||
JIRA_USERNAME=
|
||||
JIRA_PASSWORD=
|
||||
JIRA_API_TOKEN=
|
||||
JIRA_AUTH_TYPE=basic
|
||||
JIRA_TIMEOUT=30
|
||||
JIRA_DEFAULT_USER=
|
||||
|
||||
# CRM Slave Database Configuration
|
||||
CRMSLAVE_DB_HOST=127.0.0.1
|
||||
CRMSLAVE_DB_PORT=3306
|
||||
CRMSLAVE_DB_DATABASE=crmslave
|
||||
CRMSLAVE_DB_USERNAME=root
|
||||
CRMSLAVE_DB_PASSWORD=
|
||||
|
||||
# Agent Slave Database Configuration
|
||||
AGENTSLAVE_DB_HOST=127.0.0.1
|
||||
AGENTSLAVE_DB_PORT=3306
|
||||
AGENTSLAVE_DB_DATABASE=agent
|
||||
AGENTSLAVE_DB_USERNAME=root
|
||||
AGENTSLAVE_DB_PASSWORD=
|
||||
|
||||
# Mono Slave Database Configuration
|
||||
MONOSLAVE_DB_HOST=127.0.0.1
|
||||
MONOSLAVE_DB_PORT=3306
|
||||
MONOSLAVE_DB_DATABASE=mono
|
||||
MONOSLAVE_DB_USERNAME=root
|
||||
MONOSLAVE_DB_PASSWORD=
|
||||
|
||||
# Agent Service Configuration
|
||||
AGENT_URL=http://localhost:8080
|
||||
AGENT_TIMEOUT=30
|
||||
|
||||
# Mono Service Configuration
|
||||
MONO_URL=http://localhost:8081
|
||||
MONO_TIMEOUT=30
|
||||
|
||||
37
app/Clients/AgentClient.php
Normal file
37
app/Clients/AgentClient.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clients;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
class AgentClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private PendingRequest $http;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = config('services.agent.url');
|
||||
$this->http = Http::timeout(config('services.agent.timeout', 30))
|
||||
->withoutVerifying();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分发消息到Agent
|
||||
*/
|
||||
public function dispatchMessage(array $data): Response
|
||||
{
|
||||
return $this->http->post($this->baseUrl . '/rpc/dispatchMessage', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
public function testConnection(): Response
|
||||
{
|
||||
return $this->http->get($this->baseUrl . '/health');
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Clients/MonoClient.php
Normal file
37
app/Clients/MonoClient.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clients;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
class MonoClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private PendingRequest $http;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = config('services.mono.url');
|
||||
$this->http = Http::timeout(config('services.mono.timeout', 30))
|
||||
->withoutVerifying();
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
public function testConnection(): Response
|
||||
{
|
||||
return $this->http->get($this->baseUrl . '/health');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新消息分发状态
|
||||
*/
|
||||
public function updateDispatch(array $data): Response
|
||||
{
|
||||
return $this->http->post($this->baseUrl . '/rpc/datadispatch/message/update-dispatch', $data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ use Illuminate\Console\Command;
|
||||
class EnvCommand extends Command
|
||||
{
|
||||
protected $signature = 'env:manage
|
||||
{action : 操作类型 (list|environments|apply|save|import|delete)}
|
||||
{action : 操作类型 (list|environments|apply|save|import|delete|backups|restore|delete-backup)}
|
||||
{--project= : 项目名称}
|
||||
{--environment= : 环境名称}
|
||||
{--backup= : 备份名称}
|
||||
{--content= : 环境文件内容}';
|
||||
|
||||
protected $description = '环境文件管理工具';
|
||||
@@ -41,6 +42,12 @@ class EnvCommand extends Command
|
||||
return $this->importEnvironment();
|
||||
case 'delete':
|
||||
return $this->deleteEnvironment();
|
||||
case 'backups':
|
||||
return $this->listBackups();
|
||||
case 'restore':
|
||||
return $this->restoreBackup();
|
||||
case 'delete-backup':
|
||||
return $this->deleteBackup();
|
||||
default:
|
||||
$this->error("未知操作: {$action}");
|
||||
$this->showUsage();
|
||||
@@ -234,6 +241,98 @@ class EnvCommand extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出项目备份
|
||||
*/
|
||||
private function listBackups(): int
|
||||
{
|
||||
$project = $this->option('project');
|
||||
|
||||
if (!$project) {
|
||||
$this->error('请指定项目名称: --project=项目名');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$backups = $this->envManager->getProjectBackups($project);
|
||||
|
||||
if (empty($backups)) {
|
||||
$this->info("项目 {$project} 没有备份文件");
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("项目 {$project} 的备份文件:");
|
||||
$this->table(
|
||||
['备份名称', '文件大小', '创建时间'],
|
||||
array_map(function ($backup) {
|
||||
return [
|
||||
$backup['name'],
|
||||
$this->formatBytes($backup['size']),
|
||||
$backup['created_at']
|
||||
];
|
||||
}, $backups)
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复备份
|
||||
*/
|
||||
private function restoreBackup(): int
|
||||
{
|
||||
$project = $this->option('project');
|
||||
$backup = $this->option('backup');
|
||||
|
||||
if (!$project || !$backup) {
|
||||
$this->error('请指定项目名称和备份名称: --project=项目名 --backup=备份名');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->confirm("确定要将备份 {$backup} 恢复到项目 {$project} 吗?")) {
|
||||
$success = $this->envManager->restoreBackup($project, $backup);
|
||||
|
||||
if ($success) {
|
||||
$this->info("成功将备份 {$backup} 恢复到项目 {$project}");
|
||||
return 0;
|
||||
} else {
|
||||
$this->error("恢复备份失败");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('操作已取消');
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除备份
|
||||
*/
|
||||
private function deleteBackup(): int
|
||||
{
|
||||
$project = $this->option('project');
|
||||
$backup = $this->option('backup');
|
||||
|
||||
if (!$project || !$backup) {
|
||||
$this->error('请指定项目名称和备份名称: --project=项目名 --backup=备份名');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->confirm("确定要删除备份 {$project}/{$backup} 吗?")) {
|
||||
$success = $this->envManager->deleteBackup($project, $backup);
|
||||
|
||||
if ($success) {
|
||||
$this->info("成功删除备份 {$project}/{$backup}");
|
||||
return 0;
|
||||
} else {
|
||||
$this->error("删除备份失败");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('操作已取消');
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示使用说明
|
||||
*/
|
||||
@@ -246,6 +345,9 @@ class EnvCommand extends Command
|
||||
$this->line(' php artisan env:manage save --project=项目名 --environment=环境名 # 保存环境');
|
||||
$this->line(' php artisan env:manage import --project=项目名 --environment=环境名 # 导入环境');
|
||||
$this->line(' php artisan env:manage delete --project=项目名 --environment=环境名 # 删除环境');
|
||||
$this->line(' php artisan env:manage backups --project=项目名 # 列出项目备份');
|
||||
$this->line(' php artisan env:manage restore --project=项目名 --backup=备份名 # 恢复备份');
|
||||
$this->line(' php artisan env:manage delete-backup --project=项目名 --backup=备份名 # 删除备份');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
30
app/Console/Commands/JiraTestCommand.php
Normal file
30
app/Console/Commands/JiraTestCommand.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class JiraTestCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'app:jira-test-command';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Command description';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
16
app/Http/Controllers/AdminController.php
Normal file
16
app/Http/Controllers/AdminController.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AdminController extends Controller
|
||||
{
|
||||
/**
|
||||
* 显示管理系统主页概览
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
return view('admin.index');
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,7 @@ class EnvController extends Controller
|
||||
$this->envManager = $envManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示环境管理页面
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('env.index');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取所有项目列表
|
||||
|
||||
135
app/Http/Controllers/JiraController.php
Normal file
135
app/Http/Controllers/JiraController.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\JiraService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class JiraController extends Controller
|
||||
{
|
||||
private JiraService $jiraService;
|
||||
|
||||
public function __construct(JiraService $jiraService)
|
||||
{
|
||||
$this->jiraService = $jiraService;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 生成上周周报
|
||||
*/
|
||||
public function generateWeeklyReport(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$username = $request->input('username') ?: config('jira.default_user');
|
||||
|
||||
if (!$username) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请提供用户名'
|
||||
], 400);
|
||||
}
|
||||
|
||||
$report = $this->jiraService->generateWeeklyReport($username);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'report' => $report,
|
||||
'username' => $username,
|
||||
'generated_at' => Carbon::now()->format('Y-m-d H:i:s')
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '生成周报失败: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工时记录
|
||||
*/
|
||||
public function getWorkLogs(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'username' => 'required|string',
|
||||
'start_date' => 'required|date',
|
||||
'end_date' => 'required|date|after_or_equal:start_date',
|
||||
]);
|
||||
|
||||
try {
|
||||
$username = $request->input('username');
|
||||
$startDate = Carbon::parse($request->input('start_date'))->startOfDay();
|
||||
$endDate = Carbon::parse($request->input('end_date'))->endOfDay();
|
||||
|
||||
$workLogs = $this->jiraService->getWorkLogs($username, $startDate, $endDate);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'work_logs' => $workLogs->values()->toArray(),
|
||||
'total_hours' => $workLogs->sum('hours'),
|
||||
'total_records' => $workLogs->count(),
|
||||
'date_range' => [
|
||||
'start' => $startDate->format('Y-m-d'),
|
||||
'end' => $endDate->format('Y-m-d')
|
||||
]
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '获取工时记录失败: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取JIRA配置信息
|
||||
*/
|
||||
public function getConfig(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'default_user' => config('jira.default_user', ''),
|
||||
'host' => config('jira.host', '')
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载周报文件
|
||||
*/
|
||||
public function downloadWeeklyReport(Request $request)
|
||||
{
|
||||
try {
|
||||
$username = $request->input('username') ?: config('jira.default_user');
|
||||
|
||||
if (!$username) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请提供用户名'
|
||||
], 400);
|
||||
}
|
||||
|
||||
$report = $this->jiraService->generateWeeklyReport($username);
|
||||
$filename = sprintf('weekly_report_%s_%s.md', $username, Carbon::now()->subWeek()->format('Y-m-d'));
|
||||
|
||||
return response($report)
|
||||
->header('Content-Type', 'text/markdown')
|
||||
->header('Content-Disposition', 'attachment; filename="' . $filename . '"');
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '下载周报失败: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
207
app/Http/Controllers/MessageDispatchController.php
Normal file
207
app/Http/Controllers/MessageDispatchController.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\MessageDispatchService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class MessageDispatchController extends Controller
|
||||
{
|
||||
private MessageDispatchService $messageDispatchService;
|
||||
|
||||
public function __construct(MessageDispatchService $messageDispatchService)
|
||||
{
|
||||
$this->messageDispatchService = $messageDispatchService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的服务列表
|
||||
*/
|
||||
public function getAvailableServices(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$services = $this->messageDispatchService->getAvailableServices();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $services,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '获取服务列表失败',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的国家代码列表
|
||||
*/
|
||||
public function getAvailableCountryCodes(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$codes = $this->messageDispatchService->getAvailableCountryCodes();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $codes,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '获取国家代码列表失败',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的域名列表
|
||||
*/
|
||||
public function getAvailableDomains(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$domains = $this->messageDispatchService->getAvailableDomains();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $domains,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '获取域名列表失败',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询异常的消息分发数据
|
||||
*/
|
||||
public function getAbnormalDispatches(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'msg_ids' => 'nullable|array',
|
||||
'msg_ids.*' => 'string',
|
||||
'request_status' => 'nullable|integer',
|
||||
'business_status' => 'nullable|integer',
|
||||
'target_services' => 'nullable|array',
|
||||
'target_services.*' => 'integer',
|
||||
'country_codes' => 'nullable|array',
|
||||
'domains' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$msgIds = $request->input('msg_ids');
|
||||
$requestStatus = $request->input('request_status');
|
||||
$businessStatus = $request->input('business_status');
|
||||
$targetServices = $request->input('target_services');
|
||||
$countryCodes = $request->input('country_codes');
|
||||
$domains = $request->input('domains');
|
||||
|
||||
$results = $this->messageDispatchService->getAbnormalDispatches(
|
||||
$msgIds,
|
||||
$requestStatus,
|
||||
$businessStatus,
|
||||
$targetServices,
|
||||
$countryCodes,
|
||||
$domains
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $results,
|
||||
'total' => count($results),
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请求参数验证失败',
|
||||
'errors' => $e->errors()
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '查询异常消息失败: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务路由列表
|
||||
*/
|
||||
public function getServiceRoutes(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$routes = $this->messageDispatchService->getServiceRoutes();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $routes
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新消息分发状态
|
||||
*/
|
||||
public function batchUpdateDispatch(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'updates' => 'required|array|min:1',
|
||||
'updates.*.id' => 'required',
|
||||
'updates.*.request_status' => 'nullable|in:0,1,2,3,4,5',
|
||||
'updates.*.business_status' => 'nullable|in:0,1,2',
|
||||
'updates.*.retry_count' => 'nullable|numeric|min:0',
|
||||
'updates.*.request_error_message' => 'nullable|string|max:1000',
|
||||
'updates.*.request_error_code' => 'nullable|string|max:50',
|
||||
'updates.*.business_error_message' => 'nullable|string|max:1000',
|
||||
'updates.*.business_error_code' => 'nullable|string|max:50',
|
||||
'updates.*.processing_time_ms' => 'nullable|numeric|min:0',
|
||||
'updates.*.country_code' => 'nullable|string|max:10',
|
||||
'updates.*.target_service' => 'nullable',
|
||||
]);
|
||||
|
||||
$updates = $request->input('updates');
|
||||
|
||||
$results = $this->messageDispatchService->batchUpdateDispatch($updates);
|
||||
|
||||
$successCount = count(array_filter($results, fn($r) => $r['success']));
|
||||
$failureCount = count($results) - $successCount;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'results' => $results,
|
||||
'summary' => [
|
||||
'total' => count($results),
|
||||
'success' => $successCount,
|
||||
'failure' => $failureCount,
|
||||
]
|
||||
]
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请求参数验证失败',
|
||||
'errors' => $e->errors()
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '批量更新失败: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
322
app/Http/Controllers/MessageSyncController.php
Normal file
322
app/Http/Controllers/MessageSyncController.php
Normal file
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\MessageSyncService;
|
||||
use App\Services\EventConsumerSyncService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MessageSyncController extends Controller
|
||||
{
|
||||
private MessageSyncService $messageSyncService;
|
||||
private EventConsumerSyncService $eventConsumerSyncService;
|
||||
|
||||
public function __construct(
|
||||
MessageSyncService $messageSyncService,
|
||||
EventConsumerSyncService $eventConsumerSyncService
|
||||
) {
|
||||
$this->messageSyncService = $messageSyncService;
|
||||
$this->eventConsumerSyncService = $eventConsumerSyncService;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 批量查询消息数据
|
||||
*/
|
||||
public function queryMessages(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'message_ids' => 'required|array|min:1',
|
||||
'message_ids.*' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$messageIds = array_filter(array_map('trim', $request->input('message_ids')));
|
||||
|
||||
if (empty($messageIds)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请提供有效的消息ID列表'
|
||||
], 400);
|
||||
}
|
||||
|
||||
// 验证消息ID格式
|
||||
$validationErrors = $this->messageSyncService->validateMessageIds($messageIds);
|
||||
if (!empty($validationErrors)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '消息ID格式验证失败',
|
||||
'errors' => $validationErrors
|
||||
], 400);
|
||||
}
|
||||
|
||||
// 查询消息数据
|
||||
$messages = $this->messageSyncService->getMessagesByIds($messageIds);
|
||||
$stats = $this->messageSyncService->getMessageStats($messageIds);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'messages' => $messages,
|
||||
'stats' => $stats
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请求参数验证失败',
|
||||
'errors' => $e->errors()
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '查询消息失败: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行消息同步
|
||||
*/
|
||||
public function syncMessages(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'message_ids' => 'required|array|min:1',
|
||||
'message_ids.*' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$messageIds = array_filter(array_map('trim', $request->input('message_ids')));
|
||||
|
||||
if (empty($messageIds)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请提供有效的消息ID列表'
|
||||
], 400);
|
||||
}
|
||||
|
||||
// 验证消息ID格式
|
||||
$validationErrors = $this->messageSyncService->validateMessageIds($messageIds);
|
||||
if (!empty($validationErrors)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '消息ID格式验证失败',
|
||||
'errors' => $validationErrors
|
||||
], 400);
|
||||
}
|
||||
|
||||
// 执行同步
|
||||
$results = $this->messageSyncService->syncMessages($messageIds);
|
||||
|
||||
// 统计同步结果
|
||||
$successCount = count(array_filter($results, fn($r) => $r['success']));
|
||||
$failureCount = count($results) - $successCount;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'results' => $results,
|
||||
'summary' => [
|
||||
'total' => count($results),
|
||||
'success' => $successCount,
|
||||
'failure' => $failureCount
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请求参数验证失败',
|
||||
'errors' => $e->errors()
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '消息同步失败: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取agent配置信息
|
||||
*/
|
||||
public function getAgentConfig(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$config = [
|
||||
'agent_url' => config('services.agent.url'),
|
||||
'timeout' => config('services.agent.timeout', 30),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $config
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '获取配置失败: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试数据库连接
|
||||
*/
|
||||
public function testConnection(): JsonResponse
|
||||
{
|
||||
try {
|
||||
// 测试crmslave数据库连接
|
||||
$connection = \DB::connection('crmslave');
|
||||
$connection->getPdo();
|
||||
|
||||
// 测试表是否存在
|
||||
$tableExists = $connection->getSchemaBuilder()->hasTable('system_publish_event');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'connection' => 'ok',
|
||||
'table_exists' => $tableExists
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '数据库连接测试失败: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比CRM和Agent的事件消费者消息同步状态
|
||||
*/
|
||||
public function compareEventConsumerSync(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'start_time' => 'nullable|date_format:Y-m-d H:i:s',
|
||||
'end_time' => 'nullable|date_format:Y-m-d H:i:s',
|
||||
'message_name' => 'nullable|string|max:255',
|
||||
'exclude_messages' => 'nullable|array',
|
||||
'exclude_messages.*' => 'string|max:255',
|
||||
]);
|
||||
|
||||
$startTime = $request->input('start_time')
|
||||
? Carbon::createFromFormat('Y-m-d H:i:s', $request->input('start_time'))
|
||||
: null;
|
||||
|
||||
$endTime = $request->input('end_time')
|
||||
? Carbon::createFromFormat('Y-m-d H:i:s', $request->input('end_time'))
|
||||
: null;
|
||||
|
||||
$messageName = $request->input('message_name');
|
||||
$excludeMessages = $request->input('exclude_messages', []);
|
||||
|
||||
$result = $this->eventConsumerSyncService->compareSyncStatus(
|
||||
$startTime,
|
||||
$endTime,
|
||||
$excludeMessages,
|
||||
$messageName
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result
|
||||
]);
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请求参数验证失败',
|
||||
'errors' => $e->errors()
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '对比消息同步状态失败: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出缺失的消息为Excel
|
||||
*/
|
||||
public function exportMissingMessages(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$request->validate([
|
||||
'start_time' => 'nullable|date_format:Y-m-d H:i:s',
|
||||
'end_time' => 'nullable|date_format:Y-m-d H:i:s',
|
||||
'exclude_messages' => 'nullable|array',
|
||||
'exclude_messages.*' => 'string|max:255',
|
||||
]);
|
||||
|
||||
$startTime = $request->input('start_time')
|
||||
? Carbon::createFromFormat('Y-m-d H:i:s', $request->input('start_time'))
|
||||
: null;
|
||||
|
||||
$endTime = $request->input('end_time')
|
||||
? Carbon::createFromFormat('Y-m-d H:i:s', $request->input('end_time'))
|
||||
: null;
|
||||
|
||||
$excludeMessages = $request->input('exclude_messages', []);
|
||||
|
||||
$result = $this->eventConsumerSyncService->compareSyncStatus(
|
||||
$startTime,
|
||||
$endTime,
|
||||
$excludeMessages
|
||||
);
|
||||
|
||||
$missingMessages = $result['missing_messages'];
|
||||
|
||||
if (empty($missingMessages)) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '没有缺失的消息',
|
||||
'data' => []
|
||||
]);
|
||||
}
|
||||
|
||||
// 生成CSV数据
|
||||
$csv = "msg_id,event_name,msg_body,created,updated\n";
|
||||
foreach ($missingMessages as $msg) {
|
||||
$csv .= sprintf(
|
||||
'"%s","%s","%s","%s","%s"' . "\n",
|
||||
$msg['msg_id'],
|
||||
$msg['event_name'],
|
||||
str_replace('"', '""', $msg['msg_body']),
|
||||
$msg['created'],
|
||||
$msg['updated']
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'csv' => $csv,
|
||||
'filename' => 'missing_messages_' . date('YmdHis') . '.csv',
|
||||
'count' => count($missingMessages)
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '请求参数验证失败',
|
||||
'errors' => $e->errors()
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '导出消息失败: ' . $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
// 注册 JIRA 服务
|
||||
$this->app->singleton(\App\Services\JiraService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
33
app/Providers/ClientServiceProvider.php
Normal file
33
app/Providers/ClientServiceProvider.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Clients\AgentClient;
|
||||
use App\Clients\MonoClient;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ClientServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(AgentClient::class, function () {
|
||||
return new AgentClient();
|
||||
});
|
||||
|
||||
$this->app->singleton(MonoClient::class, function () {
|
||||
return new MonoClient();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,23 @@ class EnvService
|
||||
{
|
||||
private string $projectsPath;
|
||||
private string $envStoragePath;
|
||||
private string $backupStoragePath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->projectsPath = '/var/www/Project';
|
||||
$this->projectsPath = '/home/tradewind/Projects';
|
||||
$this->envStoragePath = storage_path('app/env');
|
||||
$this->backupStoragePath = storage_path('app/env/backups');
|
||||
|
||||
// 确保env存储目录存在
|
||||
if (!File::exists($this->envStoragePath)) {
|
||||
File::makeDirectory($this->envStoragePath, 0755, true);
|
||||
}
|
||||
|
||||
// 确保备份存储目录存在
|
||||
if (!File::exists($this->backupStoragePath)) {
|
||||
File::makeDirectory($this->backupStoragePath, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,10 +132,9 @@ class EnvService
|
||||
$envContent = $this->getEnvContent($projectName, $env);
|
||||
$targetEnvPath = $projectPath . '/.env';
|
||||
|
||||
// 备份现有的.env文件
|
||||
// 备份现有的.env文件到当前项目的storage目录
|
||||
if (File::exists($targetEnvPath)) {
|
||||
$backupPath = $targetEnvPath . '.backup.' . Carbon::now()->format('Y-m-d-H-i-s');
|
||||
File::copy($targetEnvPath, $backupPath);
|
||||
$this->backupCurrentEnv($projectName);
|
||||
}
|
||||
|
||||
return File::put($targetEnvPath, $envContent) !== false;
|
||||
@@ -221,4 +227,113 @@ class EnvService
|
||||
|
||||
return File::put($filePath, $defaultContent) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份项目当前的.env文件到storage目录
|
||||
*/
|
||||
private function backupCurrentEnv(string $projectName): bool
|
||||
{
|
||||
$projectPath = $this->projectsPath . '/' . $projectName;
|
||||
$currentEnvPath = $projectPath . '/.env';
|
||||
|
||||
if (!File::exists($currentEnvPath)) {
|
||||
return true; // 没有.env文件,无需备份
|
||||
}
|
||||
|
||||
// 创建项目备份目录
|
||||
$projectBackupPath = $this->backupStoragePath . '/' . $projectName;
|
||||
if (!File::exists($projectBackupPath)) {
|
||||
File::makeDirectory($projectBackupPath, 0755, true);
|
||||
}
|
||||
|
||||
// 生成备份文件名
|
||||
$timestamp = Carbon::now()->format('Y-m-d-H-i-s');
|
||||
$backupFileName = "env-backup-{$timestamp}.env";
|
||||
$backupPath = $projectBackupPath . '/' . $backupFileName;
|
||||
|
||||
return File::copy($currentEnvPath, $backupPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目的所有备份文件
|
||||
*/
|
||||
public function getProjectBackups(string $projectName): array
|
||||
{
|
||||
$projectBackupPath = $this->backupStoragePath . '/' . $projectName;
|
||||
|
||||
if (!File::exists($projectBackupPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$backups = [];
|
||||
$files = File::files($projectBackupPath);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file->getExtension() === 'env' && str_starts_with($file->getFilename(), 'env-backup-')) {
|
||||
$backups[] = [
|
||||
'name' => $file->getFilenameWithoutExtension(),
|
||||
'file_path' => $file->getPathname(),
|
||||
'size' => $file->getSize(),
|
||||
'created_at' => Carbon::createFromTimestamp($file->getMTime())->format('Y-m-d H:i:s')
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 按创建时间倒序排列
|
||||
usort($backups, function ($a, $b) {
|
||||
return strcmp($b['created_at'], $a['created_at']);
|
||||
});
|
||||
|
||||
return $backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复备份文件到项目
|
||||
*/
|
||||
public function restoreBackup(string $projectName, string $backupName): bool
|
||||
{
|
||||
$projectPath = $this->projectsPath . '/' . $projectName;
|
||||
|
||||
if (!File::exists($projectPath)) {
|
||||
throw new InvalidArgumentException("项目不存在: {$projectName}");
|
||||
}
|
||||
|
||||
$backupPath = $this->backupStoragePath . '/' . $projectName . '/' . $backupName . '.env';
|
||||
|
||||
if (!File::exists($backupPath)) {
|
||||
throw new InvalidArgumentException("备份文件不存在: {$backupName}");
|
||||
}
|
||||
|
||||
$targetEnvPath = $projectPath . '/.env';
|
||||
|
||||
// 在恢复前先备份当前的.env文件
|
||||
if (File::exists($targetEnvPath)) {
|
||||
$this->backupCurrentEnv($projectName);
|
||||
}
|
||||
|
||||
$backupContent = File::get($backupPath);
|
||||
return File::put($targetEnvPath, $backupContent) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除备份文件
|
||||
*/
|
||||
public function deleteBackup(string $projectName, string $backupName): bool
|
||||
{
|
||||
$backupPath = $this->backupStoragePath . '/' . $projectName . '/' . $backupName . '.env';
|
||||
|
||||
if (!File::exists($backupPath)) {
|
||||
return true; // 文件不存在,视为删除成功
|
||||
}
|
||||
|
||||
return File::delete($backupPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取备份存储路径
|
||||
*/
|
||||
public function getBackupStoragePath(): string
|
||||
{
|
||||
return $this->backupStoragePath;
|
||||
}
|
||||
}
|
||||
|
||||
271
app/Services/EventConsumerSyncService.php
Normal file
271
app/Services/EventConsumerSyncService.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class EventConsumerSyncService
|
||||
{
|
||||
/**
|
||||
* Agent监听的所有topic列表
|
||||
*/
|
||||
private const AGENT_LISTENED_TOPICS = [
|
||||
// Case
|
||||
'CASE_CREATE',
|
||||
'CASE_FILE',
|
||||
'CASE_3D_SEND',
|
||||
'CASE_3D_CONFIRM',
|
||||
'CASE_3D_CANCEL_CONFIRM',
|
||||
'CASE_STAGE_FINISH',
|
||||
'CASE_STAGE_CREATE',
|
||||
'CASE_FINISH',
|
||||
'CASE_PAUSE',
|
||||
'CASE_DELETED',
|
||||
'CASE_CONTINUE',
|
||||
'CASE_MONEY_IN',
|
||||
'CASE_BASIC_INFO_CHANGE',
|
||||
'CASE_REFERRAL',
|
||||
'CASE_TAGS_CHANGE',
|
||||
'CASE_RECOVER',
|
||||
'CASE_PRODUCT_WAIT_CONFIRM',
|
||||
'CASE_PRODUCT_CONFIRM',
|
||||
'CASE_PRODUCT_CANCEL_CONFIRM',
|
||||
'CASE_START_DESIGN',
|
||||
'CASE_NOT_TREATED',
|
||||
'CASE_REOPEN',
|
||||
'MEDICAL_DESIGN_EVENT',
|
||||
// Production
|
||||
'PRODUCTION_CREATE',
|
||||
'PRODUCTION_DELIVER',
|
||||
'SHIPPING_STATUS',
|
||||
'PRODUCTION_INFO_CHANGE',
|
||||
// Doctor
|
||||
'DOCTOR_CREATE',
|
||||
'DOCTOR_INFO_CHANGE',
|
||||
'DOCTOR_STATUS_CHANGE',
|
||||
'DOCTOR_DELETE',
|
||||
// Hospital
|
||||
'ACCOUNT_CREATE',
|
||||
'ACCOUNT_INFO_CHANGE',
|
||||
'ACCOUNT_AUTH_CHANGE',
|
||||
'ACCOUNT_STATUS_CHANGE',
|
||||
// BA
|
||||
'BA_CREATE',
|
||||
'BA_INFO_CHANGE',
|
||||
// Business Document
|
||||
'BUSINESS_ORDER_CREATE',
|
||||
'BUSINESS_ORDER_DATA_CHANGE',
|
||||
'BUSINESS_ORDER_STATUS_CHANGE',
|
||||
'BUSINESS_ORDER_CHECK',
|
||||
// Sale Document
|
||||
'SALES_ORDER_CREATE',
|
||||
'SALES_ORDER_DATA_CHANGE',
|
||||
'SALES_ORDER_STATUS_CHANGE',
|
||||
// Contract
|
||||
'CONTRACT_CREATE',
|
||||
'CONTRACT_INFO_CHANGE',
|
||||
// Lead Hospital
|
||||
'LEAD_HOSPITAL_CREATE',
|
||||
'LEAD_HOSPITAL_CHANGE',
|
||||
// Lead Doctor
|
||||
'CREATE_LEADS',
|
||||
'LEAD_UPDATE',
|
||||
// GROUP
|
||||
'GROUP_CREATE',
|
||||
'GROUP_INFO_CHANGE',
|
||||
// Hospital Enter
|
||||
'ACCOUNT_ENTER_INFO_REJECT',
|
||||
// Orthodontic
|
||||
'APPLIANCE_CREATE',
|
||||
'APPLIANCE_CHANGE',
|
||||
'RETAINER_CHANGE',
|
||||
'SET_MENU_CHANGE',
|
||||
];
|
||||
|
||||
/**
|
||||
* 查询CRM事件消费者表的消息数据
|
||||
* 只查询Agent监听的topic
|
||||
*/
|
||||
public function getCrmEventConsumers(
|
||||
?Carbon $startTime = null,
|
||||
?Carbon $endTime = null,
|
||||
array $excludeMessages = []
|
||||
): array {
|
||||
try {
|
||||
$query = DB::connection('crmslave')
|
||||
->table('crm_api.crm_event_consumer');
|
||||
|
||||
// 只查询Agent监听的topic
|
||||
$query->whereIn('event_name', self::AGENT_LISTENED_TOPICS);
|
||||
|
||||
if ($startTime) {
|
||||
$query->where('created', '>=', $startTime);
|
||||
}
|
||||
|
||||
if ($endTime) {
|
||||
$query->where('created', '<=', $endTime);
|
||||
}
|
||||
|
||||
if (!empty($excludeMessages)) {
|
||||
$query->whereNotIn('event_name', $excludeMessages);
|
||||
}
|
||||
|
||||
$messages = $query->select([
|
||||
'msg_id',
|
||||
'event_name',
|
||||
'msg_body',
|
||||
'created',
|
||||
'updated'
|
||||
])->get();
|
||||
|
||||
return $messages->map(function ($msg) {
|
||||
return [
|
||||
'msg_id' => $msg->msg_id,
|
||||
'event_name' => $msg->event_name,
|
||||
'msg_body' => $msg->msg_body,
|
||||
'created' => $msg->created,
|
||||
'updated' => $msg->updated,
|
||||
];
|
||||
})->toArray();
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('查询CRM事件消费者表失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询Agent事件消费者表的消息数据(通过msg_id列表,分批查询)
|
||||
* @param array $msgIds msg_id列表
|
||||
* @param int $batchSize 每批查询的数量,默认1000
|
||||
*/
|
||||
public function getAgentEventConsumersByMsgIds(array $msgIds, int $batchSize = 1000): array {
|
||||
try {
|
||||
if (empty($msgIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
|
||||
// 分批查询,避免一次性查询过多数据导致慢查询
|
||||
$batches = array_chunk($msgIds, $batchSize);
|
||||
|
||||
foreach ($batches as $batch) {
|
||||
$batchMessages = DB::connection('agentslave')
|
||||
->table('crm_event_consumer')
|
||||
->whereIn('msg_id', $batch)
|
||||
->select([
|
||||
'msg_id',
|
||||
'event_name',
|
||||
'msg_body',
|
||||
'created',
|
||||
'updated'
|
||||
])->get();
|
||||
|
||||
foreach ($batchMessages as $msg) {
|
||||
$messages[] = [
|
||||
'msg_id' => $msg->msg_id,
|
||||
'event_name' => $msg->event_name,
|
||||
'msg_body' => $msg->msg_body,
|
||||
'created' => $msg->created,
|
||||
'updated' => $msg->updated,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $messages;
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('查询Agent事件消费者表失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比CRM和Agent的消息,找出缺失的消息
|
||||
* 策略:先查CRM的msg_id,然后用这些msg_id到Agent中查询,避免时间差异导致的缺失
|
||||
*/
|
||||
public function compareSyncStatus(
|
||||
?Carbon $startTime = null,
|
||||
?Carbon $endTime = null,
|
||||
array $excludeMessages = [],
|
||||
?string $messageName = null
|
||||
): array {
|
||||
// 1. 先查询CRM中的所有消息
|
||||
$crmMessages = $this->getCrmEventConsumers($startTime, $endTime, $excludeMessages);
|
||||
|
||||
// 如果指定了消息名称,则进一步过滤
|
||||
if ($messageName) {
|
||||
$crmMessages = array_filter($crmMessages, function ($msg) use ($messageName) {
|
||||
return $msg['event_name'] === $messageName;
|
||||
});
|
||||
}
|
||||
|
||||
$crmMsgIds = array_column($crmMessages, 'msg_id');
|
||||
|
||||
// 2. 用CRM的msg_id到Agent中查询(不受时间限制)
|
||||
$agentMessages = $this->getAgentEventConsumersByMsgIds($crmMsgIds);
|
||||
$agentMsgIds = array_column($agentMessages, 'msg_id');
|
||||
|
||||
// 3. 找出在CRM中但不在Agent中的消息
|
||||
$missingMsgIds = array_diff($crmMsgIds, $agentMsgIds);
|
||||
|
||||
$missingMessages = array_filter($crmMessages, function ($msg) use ($missingMsgIds) {
|
||||
return in_array($msg['msg_id'], $missingMsgIds);
|
||||
});
|
||||
|
||||
// 4. 按topic统计缺失消息数量
|
||||
$missingByTopic = $this->groupMissingMessagesByTopic($missingMessages);
|
||||
|
||||
return [
|
||||
'crm_total' => count($crmMessages),
|
||||
'agent_total' => count($agentMessages),
|
||||
'missing_count' => count($missingMessages),
|
||||
'sync_rate' => count($crmMessages) > 0
|
||||
? round((count($crmMessages) - count($missingMessages)) / count($crmMessages) * 100, 2)
|
||||
: 100,
|
||||
'missing_messages' => array_values($missingMessages),
|
||||
'missing_by_topic' => $missingByTopic,
|
||||
'summary' => [
|
||||
'start_time' => $startTime?->toDateTimeString(),
|
||||
'end_time' => $endTime?->toDateTimeString(),
|
||||
'message_name' => $messageName,
|
||||
'excluded_messages' => $excludeMessages,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Agent监听的所有topic列表
|
||||
*/
|
||||
public function getAgentListenedTopics(): array {
|
||||
return self::AGENT_LISTENED_TOPICS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按topic统计缺失消息数量
|
||||
*/
|
||||
private function groupMissingMessagesByTopic(array $missingMessages): array {
|
||||
$grouped = [];
|
||||
|
||||
foreach ($missingMessages as $msg) {
|
||||
$topic = $msg['event_name'] ?? 'unknown';
|
||||
|
||||
if (!isset($grouped[$topic])) {
|
||||
$grouped[$topic] = [
|
||||
'topic' => $topic,
|
||||
'count' => 0,
|
||||
'messages' => []
|
||||
];
|
||||
}
|
||||
|
||||
$grouped[$topic]['count']++;
|
||||
$grouped[$topic]['messages'][] = $msg['msg_id'];
|
||||
}
|
||||
|
||||
// 按缺失数量降序排序
|
||||
uasort($grouped, function ($a, $b) {
|
||||
return $b['count'] - $a['count'];
|
||||
});
|
||||
|
||||
return array_values($grouped);
|
||||
}
|
||||
}
|
||||
|
||||
742
app/Services/JiraService.php
Normal file
742
app/Services/JiraService.php
Normal file
@@ -0,0 +1,742 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use JiraRestApi\Configuration\ArrayConfiguration;
|
||||
use JiraRestApi\Issue\IssueService;
|
||||
use JiraRestApi\JiraException;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class JiraService
|
||||
{
|
||||
private IssueService $issueService;
|
||||
private array $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = config('jira');
|
||||
$this->initializeJiraClient();
|
||||
}
|
||||
|
||||
private function initializeJiraClient(): void
|
||||
{
|
||||
$jiraConfig = new ArrayConfiguration([
|
||||
'jiraHost' => $this->config['host'],
|
||||
'jiraUser' => $this->config['username'],
|
||||
'jiraPassword' => $this->config['auth_type'] === 'token'
|
||||
? $this->config['api_token']
|
||||
: $this->config['password'],
|
||||
'timeout' => $this->config['timeout'],
|
||||
]);
|
||||
|
||||
$this->issueService = new IssueService($jiraConfig);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 按项目组织任务数据
|
||||
*/
|
||||
private function organizeIssuesByProject(array $issues): Collection
|
||||
{
|
||||
$organized = collect();
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$projectKey = $issue->fields->project->key;
|
||||
$isSubtask = $issue->fields->issuetype->subtask ?? false;
|
||||
|
||||
if (!$organized->has($projectKey)) {
|
||||
$organized->put($projectKey, [
|
||||
'name' => $issue->fields->project->name,
|
||||
'tasks' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($isSubtask && isset($issue->fields->parent)) {
|
||||
// 子任务
|
||||
$parentKey = $issue->fields->parent->key;
|
||||
$this->addSubtask($organized[$projectKey]['tasks'], $parentKey, $issue);
|
||||
} else {
|
||||
// 主任务
|
||||
$this->addMainTask($organized[$projectKey]['tasks'], $issue);
|
||||
}
|
||||
}
|
||||
|
||||
return $organized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个任务的详细信息
|
||||
*/
|
||||
private function getIssueDetails(string $issueKey): ?object
|
||||
{
|
||||
try {
|
||||
return $this->issueService->get($issueKey, [
|
||||
'summary',
|
||||
'status',
|
||||
'project',
|
||||
'issuetype'
|
||||
]);
|
||||
} catch (JiraException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function addMainTask(Collection $tasks, $issue): void
|
||||
{
|
||||
$tasks->put($issue->key, [
|
||||
'key' => $issue->key,
|
||||
'summary' => $issue->fields->summary,
|
||||
'url' => $this->config['host'] . '/browse/' . $issue->key,
|
||||
'subtasks' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function addSubtask(Collection $tasks, string $parentKey, $issue): void
|
||||
{
|
||||
if (!$tasks->has($parentKey)) {
|
||||
// 获取父任务的真实信息
|
||||
$parentDetails = $this->getIssueDetails($parentKey);
|
||||
$parentSummary = $parentDetails ? $parentDetails->fields->summary : '父任务';
|
||||
|
||||
$tasks->put($parentKey, [
|
||||
'key' => $parentKey,
|
||||
'summary' => $parentSummary,
|
||||
'url' => $this->config['host'] . '/browse/' . $parentKey,
|
||||
'subtasks' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tasks[$parentKey]['subtasks']->put($issue->key, [
|
||||
'key' => $issue->key,
|
||||
'summary' => $issue->fields->summary,
|
||||
'url' => $this->config['host'] . '/browse/' . $issue->key,
|
||||
'created' => $issue->fields->created ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未来一周的任务
|
||||
*/
|
||||
public function getNextWeekTasks(?string $username = null): Collection
|
||||
{
|
||||
$username = $username ?: $this->config['default_user'];
|
||||
|
||||
if (!$username) {
|
||||
throw new \InvalidArgumentException('用户名不能为空');
|
||||
}
|
||||
|
||||
// 查询分配给用户且未完成的任务(不包括子任务)
|
||||
$jql = sprintf(
|
||||
'assignee = "%s" AND status != "Done" AND issuetype != "Sub-task" ORDER BY created ASC',
|
||||
$username
|
||||
);
|
||||
|
||||
try {
|
||||
$issues = $this->issueService->search($jql, 0, 50, [
|
||||
'summary',
|
||||
'status',
|
||||
'project',
|
||||
'issuetype',
|
||||
'created'
|
||||
]);
|
||||
|
||||
if (!empty($issues->issues)) {
|
||||
return $this->organizeIssuesByProject($issues->issues);
|
||||
}
|
||||
} catch (JiraException $e) {
|
||||
throw new \RuntimeException('获取未来任务失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Markdown 格式的周报
|
||||
*/
|
||||
public function generateWeeklyReport(?string $username = null): string
|
||||
{
|
||||
$username = $username ?: $this->config['default_user'];
|
||||
|
||||
// 获取上周的工时记录
|
||||
$now = Carbon::now();
|
||||
$startOfWeek = $now->copy()->subWeek()->startOfWeek();
|
||||
$endOfWeek = $now->copy()->subWeek()->endOfWeek();
|
||||
|
||||
$workLogs = $this->getWorkLogs($username, $startOfWeek, $endOfWeek);
|
||||
$organizedTasks = $this->organizeTasksForReport($workLogs);
|
||||
|
||||
$nextWeekTasks = $this->getNextWeekTasks($username);
|
||||
|
||||
$markdown = "# 过去一周的任务\n\n";
|
||||
|
||||
if ($organizedTasks->isEmpty()) {
|
||||
$markdown .= "本周暂无工时记录的任务。\n\n";
|
||||
} else {
|
||||
// 按Sprint分类的需求
|
||||
if ($organizedTasks->has('sprints') && $organizedTasks['sprints']->isNotEmpty()) {
|
||||
foreach ($organizedTasks['sprints'] as $sprintName => $tasks) {
|
||||
$markdown .= "### {$sprintName}\n";
|
||||
foreach ($tasks as $task) {
|
||||
$checkbox = $this->isTaskCompleted($task['status']) ? '[x]' : '[ ]';
|
||||
$markdown .= "- {$checkbox} [{$task['key']}]({$task['url']}) {$task['summary']}\n";
|
||||
|
||||
if ($task['subtasks']->isNotEmpty()) {
|
||||
// 按创建时间排序子任务
|
||||
$sortedSubtasks = $task['subtasks']->sortBy('created');
|
||||
foreach ($sortedSubtasks as $subtask) {
|
||||
$subtaskCheckbox = $this->isTaskCompleted($subtask['status']) ? '[x]' : '[ ]';
|
||||
$markdown .= " - {$subtaskCheckbox} [{$subtask['key']}]({$subtask['url']}) {$subtask['summary']}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
$markdown .= "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// 单独列出的任务
|
||||
if ($organizedTasks->has('tasks') && $organizedTasks['tasks']->isNotEmpty()) {
|
||||
$markdown .= "### 任务\n";
|
||||
foreach ($organizedTasks['tasks'] as $task) {
|
||||
$checkbox = $this->isTaskCompleted($task['status']) ? '[x]' : '[ ]';
|
||||
$markdown .= "- {$checkbox} [{$task['key']}]({$task['url']}) {$task['summary']}\n";
|
||||
|
||||
if ($task['subtasks']->isNotEmpty()) {
|
||||
// 按创建时间排序子任务
|
||||
$sortedSubtasks = $task['subtasks']->sortBy('created');
|
||||
foreach ($sortedSubtasks as $subtask) {
|
||||
$subtaskCheckbox = $this->isTaskCompleted($subtask['status']) ? '[x]' : '[ ]';
|
||||
$markdown .= " - {$subtaskCheckbox} [{$subtask['key']}]({$subtask['url']}) {$subtask['summary']}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
$markdown .= "\n";
|
||||
}
|
||||
|
||||
// 按发现阶段分类的Bug
|
||||
if ($organizedTasks->has('bugs') && $organizedTasks['bugs']->isNotEmpty()) {
|
||||
foreach ($organizedTasks['bugs'] as $stage => $bugs) {
|
||||
$markdown .= "### {$stage}\n";
|
||||
|
||||
// 按父任务分组Bug
|
||||
$groupedBugs = collect($bugs)->groupBy(function ($bug) {
|
||||
return $bug['parent_key'] ?? 'standalone';
|
||||
});
|
||||
|
||||
foreach ($groupedBugs as $parentKey => $bugGroup) {
|
||||
if ($parentKey === 'standalone') {
|
||||
// 独立的Bug
|
||||
foreach ($bugGroup as $bug) {
|
||||
$checkbox = $this->isTaskCompleted($bug['status']) ? '[x]' : '[ ]';
|
||||
$summary = $this->cleanSummary($bug['summary']);
|
||||
// 只标记非代码错误的Bug类型,并附加修复描述
|
||||
$bugTypeLabel = '';
|
||||
if ($bug['bug_type'] && $bug['bug_type'] !== '代码错误') {
|
||||
$bugTypeLabel = "\n - {$bug['bug_type']}";
|
||||
if ($bug['bug_description']) {
|
||||
$bugTypeLabel .= ";{$bug['bug_description']}";
|
||||
}
|
||||
}
|
||||
$markdown .= "- {$checkbox} [{$bug['key']}]({$bug['url']}) {$summary}{$bugTypeLabel}\n";
|
||||
}
|
||||
} else {
|
||||
// 有父任务的Bug
|
||||
$firstBug = $bugGroup->first();
|
||||
$markdown .= "- [x] {$firstBug['parent_summary']}\n";
|
||||
foreach ($bugGroup as $bug) {
|
||||
$checkbox = $this->isTaskCompleted($bug['status']) ? '[x]' : '[ ]';
|
||||
$summary = $this->cleanSummary($bug['summary']);
|
||||
// 只标记非代码错误的Bug类型,并附加修复描述
|
||||
$bugTypeLabel = '';
|
||||
if ($bug['bug_type'] && $bug['bug_type'] !== '代码错误') {
|
||||
$bugTypeLabel = "\n - {$bug['bug_type']}";
|
||||
if ($bug['bug_description']) {
|
||||
$bugTypeLabel .= ";{$bug['bug_description']}";
|
||||
}
|
||||
}
|
||||
$markdown .= " - {$checkbox} [{$bug['key']}]({$bug['url']}) {$summary}{$bugTypeLabel}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
$markdown .= "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$markdown .= "\n# 未来一周的任务\n\n";
|
||||
|
||||
foreach ($nextWeekTasks as $project) {
|
||||
foreach ($project['tasks'] as $task) {
|
||||
$markdown .= "- [ ] [{$task['key']}]({$task['url']}) {$task['summary']}\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定日期范围内的工时记录
|
||||
*/
|
||||
public function getWorkLogs(string $username, Carbon $startDate, Carbon $endDate): Collection
|
||||
{
|
||||
// 标准工时查询 - 注意:某些JIRA版本可能不支持worklogAuthor和worklogDate
|
||||
$jql = sprintf(
|
||||
'worklogAuthor = "%s" AND worklogDate >= "%s" AND worklogDate <= "%s" ORDER BY updated DESC',
|
||||
$username,
|
||||
$startDate->format('Y-m-d'),
|
||||
$endDate->format('Y-m-d')
|
||||
);
|
||||
|
||||
try {
|
||||
$issues = $this->issueService->search($jql, 0, 100, [
|
||||
'summary',
|
||||
'project',
|
||||
'worklog',
|
||||
'parent',
|
||||
'issuetype',
|
||||
'status',
|
||||
'created',
|
||||
'fixVersions',
|
||||
'labels',
|
||||
'customfield_10004', // Sprint字段
|
||||
'customfield_10900', // Bug发现阶段
|
||||
'customfield_12700', // Bug错误类型
|
||||
'customfield_10115', // Bug修复描述
|
||||
'customfield_14305', // 需求类型
|
||||
]);
|
||||
|
||||
if (!empty($issues->issues)) {
|
||||
$workLogs = $this->extractWorkLogs($issues->issues, $username, $startDate, $endDate);
|
||||
|
||||
if ($workLogs->isNotEmpty()) {
|
||||
return $workLogs;
|
||||
}
|
||||
}
|
||||
} catch (JiraException $e) {
|
||||
throw new \RuntimeException('获取工时记录失败: ' . $e->getMessage());
|
||||
}
|
||||
// 如果所有查询都没有结果,返回空集合
|
||||
return collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取工时记录
|
||||
*/
|
||||
private function extractWorkLogs(array $issues, string $username, Carbon $startDate, Carbon $endDate): Collection
|
||||
{
|
||||
$workLogs = collect();
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
try {
|
||||
$worklogData = $this->issueService->getWorklog($issue->key);
|
||||
|
||||
foreach ($worklogData->worklogs as $worklog) {
|
||||
try {
|
||||
$worklogDate = Carbon::parse($worklog->started);
|
||||
|
||||
// 处理 author 可能是数组或对象的情况
|
||||
$authorName = is_array($worklog->author) ? ($worklog->author['name'] ?? '') : ($worklog->author->name ?? '');
|
||||
|
||||
if (!empty($authorName) && $authorName === $username &&
|
||||
$worklogDate->between($startDate, $endDate)) {
|
||||
|
||||
// 获取父任务信息
|
||||
$parentTask = null;
|
||||
if (isset($issue->fields->parent)) {
|
||||
$parentTask = [
|
||||
'key' => $issue->fields->parent->key ?? '',
|
||||
'summary' => $issue->fields->parent->fields->summary ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
// 提取Sprint信息
|
||||
$sprint = $this->extractSprintInfo($issue);
|
||||
|
||||
// 提取Bug相关信息
|
||||
$bugStage = $this->extractBugStage($issue);
|
||||
$bugType = $this->extractBugType($issue);
|
||||
$bugDescription = $this->extractBugDescription($issue);
|
||||
|
||||
// 提取需求类型
|
||||
$requirementType = $this->extractRequirementType($issue);
|
||||
|
||||
$workLogs->push([
|
||||
'id' => $worklog->id ?? '',
|
||||
'project' => $issue->fields->project->name ?? '',
|
||||
'project_key' => $issue->fields->project->key ?? '',
|
||||
'issue_key' => $issue->key,
|
||||
'issue_summary' => $issue->fields->summary ?? '',
|
||||
'issue_url' => $this->config['host'] . '/browse/' . $issue->key,
|
||||
'issue_status' => $issue->fields->status->name ?? 'Unknown',
|
||||
'issue_type' => $issue->fields->issuetype->name ?? 'Unknown',
|
||||
'issue_created' => $issue->fields->created ?? null,
|
||||
'parent_task' => $parentTask,
|
||||
'sprint' => $sprint,
|
||||
'bug_stage' => $bugStage,
|
||||
'bug_type' => $bugType,
|
||||
'bug_description' => $bugDescription,
|
||||
'requirement_type' => $requirementType,
|
||||
'date' => $worklogDate->format('Y-m-d'),
|
||||
'time' => $worklogDate->format('H:i'),
|
||||
'hours' => round(($worklog->timeSpentSeconds ?? 0) / 3600, 2),
|
||||
'time_spent_seconds' => $worklog->timeSpentSeconds ?? 0,
|
||||
'time_spent' => $worklog->timeSpent ?? '',
|
||||
'comment' => $worklog->comment ?? '',
|
||||
'author' => [
|
||||
'name' => $authorName,
|
||||
'display_name' => is_array($worklog->author) ? ($worklog->author['displayName'] ?? '') : ($worklog->author->displayName ?? ''),
|
||||
'email' => is_array($worklog->author) ? ($worklog->author['emailAddress'] ?? '') : ($worklog->author->emailAddress ?? ''),
|
||||
],
|
||||
'created' => isset($worklog->created) ? Carbon::parse($worklog->created)->format('Y-m-d H:i:s') : '',
|
||||
'updated' => isset($worklog->updated) ? Carbon::parse($worklog->updated)->format('Y-m-d H:i:s') : '',
|
||||
'started' => $worklogDate->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
} catch (\Exception) {
|
||||
// 跳过有问题的工时记录
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (JiraException) {
|
||||
// 跳过无法获取工时记录的任务
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $workLogs->sortByDesc('date');
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取Sprint信息
|
||||
*/
|
||||
private function extractSprintInfo($issue): ?string
|
||||
{
|
||||
// 尝试从customfield_10004获取Sprint信息
|
||||
if (isset($issue->fields->customFields['customfield_10004'])) {
|
||||
$sprintField = $issue->fields->customFields['customfield_10004'];
|
||||
|
||||
// 处理数组情况
|
||||
if (is_array($sprintField) && !empty($sprintField)) {
|
||||
$lastSprint = end($sprintField);
|
||||
if (is_string($lastSprint)) {
|
||||
// 解析Sprint字符串,格式通常为: com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,...]
|
||||
if (preg_match('/name=([^,\]]+)/', $lastSprint, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
} elseif (is_object($lastSprint) && isset($lastSprint->name)) {
|
||||
return $lastSprint->name;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理对象情况
|
||||
if (is_object($sprintField) && isset($sprintField->name)) {
|
||||
return $sprintField->name;
|
||||
}
|
||||
|
||||
// 处理字符串情况
|
||||
if (is_string($sprintField)) {
|
||||
if (preg_match('/name=([^,\]]+)/', $sprintField, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
// 如果是纯文本,直接返回
|
||||
return $sprintField;
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从fixVersions获取版本信息作为备选
|
||||
if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions) && !empty($issue->fields->fixVersions)) {
|
||||
return $issue->fields->fixVersions[0]->name ?? null;
|
||||
}
|
||||
|
||||
// 尝试从summary中提取Sprint信息(如果summary包含【十月中需求】这样的标记)
|
||||
if (isset($issue->fields->summary)) {
|
||||
$summary = $issue->fields->summary;
|
||||
// 匹配【xxx需求】或【xxx】格式
|
||||
if (preg_match('/【([^】]*需求)】/', $summary, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取Bug发现阶段
|
||||
*/
|
||||
private function extractBugStage($issue): ?string
|
||||
{
|
||||
// 从customfield_10900获取Bug阶段
|
||||
if (isset($issue->fields->customFields['customfield_10900'])) {
|
||||
$stage = $issue->fields->customFields['customfield_10900'];
|
||||
|
||||
// 处理对象类型
|
||||
if (is_object($stage) && isset($stage->value)) {
|
||||
$stageValue = $stage->value;
|
||||
} elseif (is_string($stage)) {
|
||||
$stageValue = $stage;
|
||||
} else {
|
||||
$stageValue = null;
|
||||
}
|
||||
|
||||
if ($stageValue && !empty($stageValue)) {
|
||||
// 标准化阶段名称
|
||||
if (str_contains($stageValue, 'SIT') || str_contains($stageValue, 'sit') || $stageValue === '测试阶段') {
|
||||
return 'SIT环境BUG';
|
||||
}
|
||||
if (str_contains($stageValue, '生产') || str_contains($stageValue, 'PROD') || str_contains($stageValue, 'prod') || $stageValue === '生产环境') {
|
||||
return '生产环境BUG';
|
||||
}
|
||||
if (str_contains($stageValue, 'UAT') || str_contains($stageValue, 'uat')) {
|
||||
return 'UAT环境BUG';
|
||||
}
|
||||
// 如果不匹配标准格式,直接返回原值
|
||||
return $stageValue . 'BUG';
|
||||
}
|
||||
}
|
||||
|
||||
// 从labels中提取Bug阶段
|
||||
if (isset($issue->fields->labels) && is_array($issue->fields->labels)) {
|
||||
foreach ($issue->fields->labels as $label) {
|
||||
if (str_contains($label, 'SIT') || str_contains($label, 'sit')) {
|
||||
return 'SIT环境BUG';
|
||||
}
|
||||
if (str_contains($label, '生产') || str_contains($label, 'PROD') || str_contains($label, 'prod')) {
|
||||
return '生产环境BUG';
|
||||
}
|
||||
if (str_contains($label, 'UAT') || str_contains($label, 'uat')) {
|
||||
return 'UAT环境BUG';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取Bug错误类型
|
||||
*/
|
||||
private function extractBugType($issue): ?string
|
||||
{
|
||||
// 从customfield_12700获取Bug类型
|
||||
if (isset($issue->fields->customFields['customfield_12700'])) {
|
||||
$type = $issue->fields->customFields['customfield_12700'];
|
||||
|
||||
// 处理对象类型
|
||||
if (is_object($type) && isset($type->value)) {
|
||||
return $type->value;
|
||||
} elseif (is_string($type) && !empty($type)) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
|
||||
// 从labels中提取Bug类型
|
||||
if (isset($issue->fields->labels) && is_array($issue->fields->labels)) {
|
||||
$bugTypes = ['需求未说明', '沟通问题', '接口文档未说明', '数据问题', '配置问题', '环境问题'];
|
||||
foreach ($issue->fields->labels as $label) {
|
||||
foreach ($bugTypes as $bugType) {
|
||||
if (str_contains($label, $bugType)) {
|
||||
return $bugType;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取Bug修复描述
|
||||
*/
|
||||
private function extractBugDescription($issue): ?string
|
||||
{
|
||||
// 从customfield_10115获取Bug修复描述
|
||||
if (isset($issue->fields->customFields['customfield_10115'])) {
|
||||
$description = $issue->fields->customFields['customfield_10115'];
|
||||
|
||||
if (is_string($description) && !empty($description)) {
|
||||
return $description;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取需求类型
|
||||
*/
|
||||
private function extractRequirementType($issue): ?string
|
||||
{
|
||||
// 从customfield_14305获取需求类型
|
||||
if (isset($issue->fields->customFields['customfield_14305'])) {
|
||||
$type = $issue->fields->customFields['customfield_14305'];
|
||||
if (is_array($type) && !empty($type)) {
|
||||
$firstType = $type[0];
|
||||
if (is_object($firstType) && isset($firstType->value)) {
|
||||
return $firstType->value;
|
||||
}
|
||||
} elseif (is_object($type) && isset($type->value)) {
|
||||
return $type->value;
|
||||
} elseif (is_string($type)) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理摘要中的图片链接
|
||||
*/
|
||||
private function cleanSummary(string $summary): string
|
||||
{
|
||||
// 移除 Jira 图片标记 !image-xxx.png! 和 !https://xxx.png!
|
||||
$summary = preg_replace('/!([^!]+\.(png|jpg|jpeg|gif|bmp))!/i', '', $summary);
|
||||
// 移除多余的空格和换行
|
||||
$summary = preg_replace('/\s+/', ' ', $summary);
|
||||
return trim($summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断任务状态是否应该标记为完成
|
||||
*/
|
||||
private function isTaskCompleted(string $status): bool
|
||||
{
|
||||
// 不打勾的状态(未完成状态)
|
||||
$incompleteStatuses = [
|
||||
'开发中',
|
||||
'需求已排期',
|
||||
'需求已评审',
|
||||
'In Progress',
|
||||
'To Do',
|
||||
'Open',
|
||||
'Reopened',
|
||||
'In Review',
|
||||
'Code Review',
|
||||
'Testing',
|
||||
'Ready for Testing'
|
||||
];
|
||||
|
||||
return !in_array($status, $incompleteStatuses, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组织任务数据用于周报生成
|
||||
*/
|
||||
private function organizeTasksForReport(Collection $workLogs): Collection
|
||||
{
|
||||
$organized = collect([
|
||||
'sprints' => collect(),
|
||||
'tasks' => collect(),
|
||||
'bugs' => collect(),
|
||||
]);
|
||||
|
||||
$processedIssues = collect();
|
||||
|
||||
foreach ($workLogs as $workLog) {
|
||||
$issueKey = $workLog['issue_key'];
|
||||
$issueType = $workLog['issue_type'] ?? 'Unknown';
|
||||
|
||||
// 避免重复处理同一个任务
|
||||
if ($processedIssues->has($issueKey)) {
|
||||
continue;
|
||||
}
|
||||
$processedIssues->put($issueKey, true);
|
||||
|
||||
// 判断是否为Bug(通过issuetype判断)
|
||||
$isBug = in_array($issueType, ['Bug', 'BUG', 'bug', '缺陷', 'Defect']);
|
||||
|
||||
// 判断是否为需求(Story类型)
|
||||
$isStory = in_array($issueType, ['Story', 'story', '需求']);
|
||||
|
||||
// 判断是否为子任务
|
||||
$isSubtask = in_array($issueType, ['Sub-task', 'sub-task', '子任务']);
|
||||
|
||||
if ($isBug && $workLog['bug_stage']) {
|
||||
// Bug按发现阶段分类
|
||||
$stage = $workLog['bug_stage'];
|
||||
if (!$organized['bugs']->has($stage)) {
|
||||
$organized['bugs']->put($stage, collect());
|
||||
}
|
||||
|
||||
$bugData = [
|
||||
'key' => $workLog['issue_key'],
|
||||
'summary' => $workLog['issue_summary'],
|
||||
'url' => $workLog['issue_url'],
|
||||
'status' => $workLog['issue_status'],
|
||||
'bug_type' => $workLog['bug_type'],
|
||||
'bug_description' => $workLog['bug_description'],
|
||||
];
|
||||
|
||||
// 如果有父任务,添加父任务信息
|
||||
if ($workLog['parent_task']) {
|
||||
$bugData['parent_key'] = $workLog['parent_task']['key'];
|
||||
$bugData['parent_summary'] = $workLog['parent_task']['summary'];
|
||||
}
|
||||
|
||||
$organized['bugs'][$stage]->push($bugData);
|
||||
} elseif (($isStory || $isSubtask) && $workLog['sprint']) {
|
||||
// Story类型或子任务,且有Sprint的,按Sprint分类(需求)
|
||||
$sprintName = $workLog['sprint'];
|
||||
if (!$organized['sprints']->has($sprintName)) {
|
||||
$organized['sprints']->put($sprintName, collect());
|
||||
}
|
||||
|
||||
$this->addTaskToSprintOrTaskList($organized['sprints'][$sprintName], $workLog);
|
||||
} else {
|
||||
// 其他任务单独列出(非Story/子任务类型或没有Sprint的)
|
||||
$this->addTaskToSprintOrTaskList($organized['tasks'], $workLog);
|
||||
}
|
||||
}
|
||||
|
||||
return $organized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务到Sprint或任务列表
|
||||
*/
|
||||
private function addTaskToSprintOrTaskList(Collection $taskList, array $workLog): void
|
||||
{
|
||||
$status = $workLog['issue_status'] ?? 'Unknown';
|
||||
|
||||
if ($workLog['parent_task']) {
|
||||
// 子任务
|
||||
$parentKey = $workLog['parent_task']['key'];
|
||||
|
||||
if (!$taskList->has($parentKey)) {
|
||||
// 获取父任务的真实信息
|
||||
$parentDetails = $this->getIssueDetails($parentKey);
|
||||
$parentSummary = $parentDetails ? $parentDetails->fields->summary : $workLog['parent_task']['summary'];
|
||||
$parentStatus = $parentDetails ? $parentDetails->fields->status->name : 'Unknown';
|
||||
|
||||
$taskList->put($parentKey, [
|
||||
'key' => $parentKey,
|
||||
'summary' => $parentSummary,
|
||||
'url' => $this->config['host'] . '/browse/' . $parentKey,
|
||||
'status' => $parentStatus,
|
||||
'subtasks' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
$taskList[$parentKey]['subtasks']->put($workLog['issue_key'], [
|
||||
'key' => $workLog['issue_key'],
|
||||
'summary' => $workLog['issue_summary'],
|
||||
'url' => $workLog['issue_url'],
|
||||
'status' => $status,
|
||||
'created' => $workLog['issue_created'],
|
||||
]);
|
||||
} else {
|
||||
// 主任务
|
||||
if (!$taskList->has($workLog['issue_key'])) {
|
||||
$taskList->put($workLog['issue_key'], [
|
||||
'key' => $workLog['issue_key'],
|
||||
'summary' => $workLog['issue_summary'],
|
||||
'url' => $workLog['issue_url'],
|
||||
'status' => $status,
|
||||
'subtasks' => collect(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
349
app/Services/MessageDispatchService.php
Normal file
349
app/Services/MessageDispatchService.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Clients\MonoClient;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MessageDispatchService
|
||||
{
|
||||
private MonoClient $monoClient;
|
||||
|
||||
public function __construct(MonoClient $monoClient)
|
||||
{
|
||||
$this->monoClient = $monoClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询异常的消息分发数据
|
||||
*/
|
||||
public function getAbnormalDispatches(
|
||||
?array $msgIds = null,
|
||||
?int $requestStatus = null,
|
||||
?int $businessStatus = null,
|
||||
?array $targetServices = null,
|
||||
?array $countryCodes = null,
|
||||
?array $domains = null
|
||||
): array
|
||||
{
|
||||
try {
|
||||
$query = DB::connection('monoslave')
|
||||
->table('message_dispatch as md')
|
||||
->join('message_consumer as mc', 'mc.msg_id', '=', 'md.msg_id')
|
||||
->leftJoin('service_routes as sr', 'md.target_service', '=', 'sr.id')
|
||||
->where('md.request_status', '<>', 5)
|
||||
->where(function ($q) {
|
||||
$q->where('md.request_status', '<>', 1)
|
||||
->orWhere('md.business_status', '<>', 1);
|
||||
})
|
||||
->where('md.created', '<', Carbon::now()->subMinutes(5));
|
||||
|
||||
// 筛选条件
|
||||
if ($msgIds && count($msgIds) > 0) {
|
||||
$query->whereIn('md.msg_id', $msgIds);
|
||||
}
|
||||
|
||||
if ($requestStatus !== null) {
|
||||
$query->where('md.request_status', $requestStatus);
|
||||
}
|
||||
|
||||
if ($businessStatus !== null) {
|
||||
$query->where('md.business_status', $businessStatus);
|
||||
}
|
||||
|
||||
if ($targetServices && count($targetServices) > 0) {
|
||||
$query->whereIn('md.target_service', $targetServices);
|
||||
}
|
||||
|
||||
if ($countryCodes && count($countryCodes) > 0) {
|
||||
$query->where(function ($q) use ($countryCodes) {
|
||||
$hasNull = in_array('', $countryCodes) || in_array(null, $countryCodes);
|
||||
$nonNullCodes = array_filter($countryCodes, fn($c) => $c !== '' && $c !== null);
|
||||
|
||||
if ($hasNull) {
|
||||
$q->whereNull('sr.country_code');
|
||||
if (count($nonNullCodes) > 0) {
|
||||
$q->orWhereIn('sr.country_code', $nonNullCodes);
|
||||
}
|
||||
} else {
|
||||
$q->whereIn('sr.country_code', $nonNullCodes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($domains && count($domains) > 0) {
|
||||
$query->where(function ($q) use ($domains) {
|
||||
$hasNull = in_array('', $domains) || in_array(null, $domains);
|
||||
$nonNullDomains = array_filter($domains, fn($d) => $d !== '' && $d !== null);
|
||||
|
||||
if ($hasNull) {
|
||||
$q->whereNull('sr.service_endpoint');
|
||||
if (count($nonNullDomains) > 0) {
|
||||
$q->orWhere(function ($subQ) use ($nonNullDomains) {
|
||||
foreach ($nonNullDomains as $domain) {
|
||||
$subQ->orWhere('sr.service_endpoint', 'like', "%{$domain}%");
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
$q->where(function ($subQ) use ($nonNullDomains) {
|
||||
foreach ($nonNullDomains as $domain) {
|
||||
$subQ->orWhere('sr.service_endpoint', 'like', "%{$domain}%");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$results = $query->select([
|
||||
'md.id',
|
||||
'md.msg_id',
|
||||
'md.target_service',
|
||||
'md.request_status',
|
||||
'md.business_status',
|
||||
'md.retry_count',
|
||||
'md.request_error_message',
|
||||
'md.business_error_message',
|
||||
'mc.event_name',
|
||||
'mc.entity_code',
|
||||
DB::raw("mc.msg_body->>'$.data.delAccountList' as delAccountList"),
|
||||
DB::raw("mc.msg_body->>'$.data.afterStatus' as afterStatus"),
|
||||
'mc.msg_body',
|
||||
'md.created',
|
||||
'md.updated',
|
||||
'sr.country_code',
|
||||
'sr.service_endpoint'
|
||||
])->get();
|
||||
|
||||
// 获取所有msg_id,排除US域名的消息
|
||||
$nonUsMsgIds = $results->filter(function ($item) {
|
||||
$domain = $item->service_endpoint ? (parse_url($item->service_endpoint, PHP_URL_HOST) ?? $item->service_endpoint) : null;
|
||||
return $domain !== 'partner-us.eainc.com';
|
||||
})->pluck('msg_id')->unique()->toArray();
|
||||
|
||||
// 从Agent库查询consumer状态(仅非US域名)
|
||||
$consumerStatuses = $this->getConsumerStatuses($nonUsMsgIds);
|
||||
|
||||
return $results->map(function ($item) use ($consumerStatuses) {
|
||||
$domain = $item->service_endpoint ? (parse_url($item->service_endpoint, PHP_URL_HOST) ?? $item->service_endpoint) : null;
|
||||
$isUsDomain = $domain === 'partner-us.eainc.com';
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'msg_id' => $item->msg_id,
|
||||
'target_service' => $item->target_service,
|
||||
'country_code' => $item->country_code,
|
||||
'domain' => $domain,
|
||||
'request_status' => $item->request_status,
|
||||
'business_status' => $item->business_status,
|
||||
'retry_count' => $item->retry_count,
|
||||
'request_error_message' => $item->request_error_message,
|
||||
'request_error_code' => $item->request_error_code ?? null,
|
||||
'business_error_message' => $item->business_error_message,
|
||||
'business_error_code' => $item->business_error_code ?? null,
|
||||
'event_name' => $item->event_name,
|
||||
'entity_code' => $item->entity_code,
|
||||
'delAccountList' => $item->delAccountList,
|
||||
'afterStatus' => $item->afterStatus,
|
||||
'msg_body' => $item->msg_body,
|
||||
'created' => $item->created,
|
||||
'updated' => $item->updated,
|
||||
'consumer_status' => $isUsDomain ? null : ($consumerStatuses[$item->msg_id] ?? null),
|
||||
];
|
||||
})->toArray();
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('查询异常消息分发数据失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Agent库查询消费者状态
|
||||
*/
|
||||
private function getConsumerStatuses(array $msgIds): array
|
||||
{
|
||||
if (empty($msgIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$consumers = DB::connection('agentslave')
|
||||
->table('crm_event_consumer')
|
||||
->whereIn('msg_id', $msgIds)
|
||||
->select(['msg_id', 'status'])
|
||||
->get();
|
||||
|
||||
$statuses = [];
|
||||
foreach ($consumers as $consumer) {
|
||||
$statuses[$consumer->msg_id] = $consumer->status;
|
||||
}
|
||||
|
||||
return $statuses;
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化服务名称:country_code(域名)
|
||||
*/
|
||||
private function formatServiceName(?string $countryCode, ?string $serviceEndpoint): string
|
||||
{
|
||||
if (!$countryCode || !$serviceEndpoint) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
// 从URL中提取域名
|
||||
$domain = parse_url($serviceEndpoint, PHP_URL_HOST) ?? $serviceEndpoint;
|
||||
|
||||
return "{$countryCode}({$domain})";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的服务列表
|
||||
*/
|
||||
public function getAvailableServices(): array
|
||||
{
|
||||
try {
|
||||
$services = DB::connection('monoslave')
|
||||
->table('service_routes')
|
||||
->select(['id', 'country_code', 'service_endpoint'])
|
||||
->get();
|
||||
|
||||
return $services->map(function ($service) {
|
||||
return [
|
||||
'id' => $service->id,
|
||||
'name' => $this->formatServiceName($service->country_code, $service->service_endpoint),
|
||||
];
|
||||
})->toArray();
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有国家代码列表
|
||||
*/
|
||||
public function getAvailableCountryCodes(): array
|
||||
{
|
||||
try {
|
||||
$codes = DB::connection('monoslave')
|
||||
->table('service_routes')
|
||||
->select('country_code')
|
||||
->distinct()
|
||||
->orderBy('country_code')
|
||||
->get()
|
||||
->pluck('country_code')
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return $codes;
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有域名列表
|
||||
*/
|
||||
public function getAvailableDomains(): array
|
||||
{
|
||||
try {
|
||||
$endpoints = DB::connection('monoslave')
|
||||
->table('service_routes')
|
||||
->select('service_endpoint')
|
||||
->distinct()
|
||||
->get()
|
||||
->pluck('service_endpoint')
|
||||
->filter()
|
||||
->map(function ($endpoint) {
|
||||
return parse_url($endpoint, PHP_URL_HOST) ?? $endpoint;
|
||||
})
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return $endpoints;
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有服务路由列表
|
||||
*/
|
||||
public function getServiceRoutes(): array
|
||||
{
|
||||
try {
|
||||
$routes = DB::connection('monoslave')
|
||||
->table('service_routes')
|
||||
->select('id', 'country_code', 'service_endpoint')
|
||||
->orderBy('country_code')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(function ($route) {
|
||||
$domain = parse_url($route->service_endpoint, PHP_URL_HOST) ?? $route->service_endpoint;
|
||||
return [
|
||||
'id' => $route->id,
|
||||
'country_code' => $route->country_code,
|
||||
'domain' => $domain,
|
||||
'display_name' => ($route->country_code ?: '(空)') . ' - ' . $domain,
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
|
||||
return $routes;
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新消息分发状态
|
||||
*/
|
||||
public function batchUpdateDispatch(array $updates): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($updates as $update) {
|
||||
try {
|
||||
$result = $this->updateDispatch($update);
|
||||
$results[] = [
|
||||
'id' => $update['id'],
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'] ?? null,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$results[] = [
|
||||
'id' => $update['id'],
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个消息分发状态
|
||||
*/
|
||||
private function updateDispatch(array $data): array
|
||||
{
|
||||
$response = $this->monoClient->updateDispatch($data);
|
||||
|
||||
if ($response->successful()) {
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $response->json(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'HTTP ' . $response->status() . ': ' . $response->body(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
191
app/Services/MessageSyncService.php
Normal file
191
app/Services/MessageSyncService.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Clients\AgentClient;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Collection;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MessageSyncService
|
||||
{
|
||||
private AgentClient $agentClient;
|
||||
|
||||
public function __construct(AgentClient $agentClient)
|
||||
{
|
||||
$this->agentClient = $agentClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息ID列表从crmslave数据库获取消息数据
|
||||
*/
|
||||
public function getMessagesByIds(array $messageIds): Collection
|
||||
{
|
||||
if (empty($messageIds)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
try {
|
||||
$messages = DB::connection('crmslave')
|
||||
->table('system_publish_event')
|
||||
->whereIn('msg_id', $messageIds)
|
||||
->select([
|
||||
'msg_id',
|
||||
'event_type',
|
||||
'trace_id',
|
||||
'event_param',
|
||||
'event_property',
|
||||
'timestamp'
|
||||
])
|
||||
->get();
|
||||
|
||||
return $messages->map(function ($message) {
|
||||
return [
|
||||
'msg_id' => $message->msg_id,
|
||||
'event_type' => $message->event_type,
|
||||
'trace_id' => $message->trace_id,
|
||||
'event_param' => $message->event_param,
|
||||
'event_property' => $message->event_property,
|
||||
'timestamp' => $message->timestamp,
|
||||
'parsed_param' => $this->parseJsonField($message->event_param),
|
||||
'parsed_property' => $this->parseJsonField($message->event_property),
|
||||
];
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('从crmslave数据库获取消息失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步消息到agent
|
||||
*/
|
||||
public function syncMessages(array $messageIds): array
|
||||
{
|
||||
$messages = $this->getMessagesByIds($messageIds);
|
||||
$results = [];
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$result = $this->syncSingleMessage($message);
|
||||
$results[] = [
|
||||
'msg_id' => $message['msg_id'],
|
||||
'success' => $result['success'],
|
||||
'response' => $result['response'] ?? null,
|
||||
'error' => $result['error'] ?? null,
|
||||
'request_data' => $result['request_data'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单个消息到agent
|
||||
*/
|
||||
private function syncSingleMessage(array $message): array
|
||||
{
|
||||
try {
|
||||
$requestData = $this->buildAgentRequest($message);
|
||||
|
||||
$response = $this->agentClient->dispatchMessage($requestData);
|
||||
|
||||
if ($response->successful()) {
|
||||
return [
|
||||
'success' => true,
|
||||
'response' => $response->json(),
|
||||
'request_data' => $requestData,
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'HTTP ' . $response->status() . ': ' . $response->body(),
|
||||
'request_data' => $requestData,
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '请求失败: ' . $e->getMessage(),
|
||||
'request_data' => $requestData ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建agent接口请求数据
|
||||
*/
|
||||
private function buildAgentRequest(array $message): array
|
||||
{
|
||||
$parsedParam = $message['parsed_param'];
|
||||
$parsedProperty = $message['parsed_property'];
|
||||
|
||||
return [
|
||||
'topic_name' => $message['event_type'],
|
||||
'msg_body' => [
|
||||
'id' => $message['msg_id'],
|
||||
'data' => $parsedParam,
|
||||
'timestamp' => $message['timestamp'],
|
||||
'property' => $parsedProperty,
|
||||
],
|
||||
'target_service' => [1], // 默认目标服务
|
||||
'trace_id' => $message['trace_id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析JSON字段
|
||||
*/
|
||||
private function parseJsonField(?string $jsonString): mixed
|
||||
{
|
||||
if (empty($jsonString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return json_decode($jsonString, true);
|
||||
} catch (\Exception $e) {
|
||||
return $jsonString; // 如果解析失败,返回原始字符串
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证消息ID格式
|
||||
*/
|
||||
public function validateMessageIds(array $messageIds): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($messageIds as $index => $messageId) {
|
||||
if (empty($messageId)) {
|
||||
$errors[] = "第 " . ($index + 1) . " 行: 消息ID不能为空";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_string($messageId) && !is_numeric($messageId)) {
|
||||
$errors[] = "第 " . ($index + 1) . " 行: 消息ID格式无效";
|
||||
continue;
|
||||
}
|
||||
|
||||
// 可以添加更多的格式验证规则
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息统计信息
|
||||
*/
|
||||
public function getMessageStats(array $messageIds): array
|
||||
{
|
||||
$messages = $this->getMessagesByIds($messageIds);
|
||||
|
||||
$stats = [
|
||||
'total_requested' => count($messageIds),
|
||||
'total_found' => $messages->count(),
|
||||
'total_missing' => count($messageIds) - $messages->count(),
|
||||
'event_types' => $messages->groupBy('event_type')->map->count(),
|
||||
'missing_ids' => array_diff($messageIds, $messages->pluck('msg_id')->toArray()),
|
||||
];
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,18 @@ use Illuminate\Foundation\Configuration\Middleware;
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
// 为 API 路由添加 CSRF 豁免
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'api/jira/*',
|
||||
'api/env/*',
|
||||
'api/message-sync/*',
|
||||
'api/message-dispatch/*'
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\ClientServiceProvider::class,
|
||||
App\Providers\EnvServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"lesstif/php-jira-rest-client": "5.10.0",
|
||||
"ext-pdo": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
120
composer.lock
generated
120
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "88970a0117c062eed55fa8728fc43833",
|
||||
"content-hash": "5955fb8cae2c82cdb7352006103c8c0c",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -2006,6 +2006,73 @@
|
||||
],
|
||||
"time": "2024-12-08T08:18:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lesstif/php-jira-rest-client",
|
||||
"version": "5.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/lesstif/php-jira-rest-client.git",
|
||||
"reference": "46b20408c34138615acbdd58a86b8b624d864d0c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/lesstif/php-jira-rest-client/zipball/46b20408c34138615acbdd58a86b8b624d864d0c",
|
||||
"reference": "46b20408c34138615acbdd58a86b8b624d864d0c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"monolog/monolog": "^2.0|^3.0",
|
||||
"netresearch/jsonmapper": "^4.2",
|
||||
"php": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.0|^2.0",
|
||||
"phpstan/phpstan": "^1.0|^2.0",
|
||||
"phpunit/phpunit": "^9.0|^10.0",
|
||||
"symfony/var-dumper": "^5.0|^6.0|^7.0"
|
||||
},
|
||||
"suggest": {
|
||||
"vlucas/phpdotenv": "^5.0|^6.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"JiraRestApi\\JiraRestApiServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"JiraRestApi\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "KwangSeob Jeong",
|
||||
"email": "lesstif@gmail.com",
|
||||
"homepage": "https://lesstif.com/"
|
||||
}
|
||||
],
|
||||
"description": "JIRA REST API Client for PHP Users.",
|
||||
"keywords": [
|
||||
"jira",
|
||||
"jira-php",
|
||||
"jira-rest",
|
||||
"rest"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/lesstif/php-jira-rest-client/issues",
|
||||
"source": "https://github.com/lesstif/php-jira-rest-client/tree/5.10.0"
|
||||
},
|
||||
"time": "2025-04-05T12:50:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.9.0",
|
||||
@@ -2214,6 +2281,57 @@
|
||||
],
|
||||
"time": "2025-06-21T15:19:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "netresearch/jsonmapper",
|
||||
"version": "v4.5.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cweiske/jsonmapper.git",
|
||||
"reference": "8e76efb98ee8b6afc54687045e1b8dba55ac76e5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8e76efb98ee8b6afc54687045e1b8dba55ac76e5",
|
||||
"reference": "8e76efb98ee8b6afc54687045e1b8dba55ac76e5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-reflection": "*",
|
||||
"ext-spl": "*",
|
||||
"php": ">=7.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~7.5 || ~8.0 || ~9.0 || ~10.0",
|
||||
"squizlabs/php_codesniffer": "~3.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"JsonMapper": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"OSL-3.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christian Weiske",
|
||||
"email": "cweiske@cweiske.de",
|
||||
"homepage": "http://github.com/cweiske/jsonmapper/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Map nested JSON structures onto PHP classes",
|
||||
"support": {
|
||||
"email": "cweiske@cweiske.de",
|
||||
"issues": "https://github.com/cweiske/jsonmapper/issues",
|
||||
"source": "https://github.com/cweiske/jsonmapper/tree/v4.5.0"
|
||||
},
|
||||
"time": "2024-09-08T10:13:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nette/schema",
|
||||
"version": "v1.3.2",
|
||||
|
||||
@@ -112,6 +112,66 @@ return [
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
'crmslave' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('CRMSLAVE_DB_URL'),
|
||||
'host' => env('CRMSLAVE_DB_HOST', '127.0.0.1'),
|
||||
'port' => env('CRMSLAVE_DB_PORT', '3306'),
|
||||
'database' => env('CRMSLAVE_DB_DATABASE', 'crmslave'),
|
||||
'username' => env('CRMSLAVE_DB_USERNAME', 'root'),
|
||||
'password' => env('CRMSLAVE_DB_PASSWORD', ''),
|
||||
'unix_socket' => env('CRMSLAVE_DB_SOCKET', ''),
|
||||
'charset' => env('CRMSLAVE_DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('CRMSLAVE_DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('CRMSLAVE_MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'agentslave' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('AGENTSLAVE_DB_URL'),
|
||||
'host' => env('AGENTSLAVE_DB_HOST', '127.0.0.1'),
|
||||
'port' => env('AGENTSLAVE_DB_PORT', '3306'),
|
||||
'database' => env('AGENTSLAVE_DB_DATABASE', 'crm'),
|
||||
'username' => env('AGENTSLAVE_DB_USERNAME', 'root'),
|
||||
'password' => env('AGENTSLAVE_DB_PASSWORD', ''),
|
||||
'unix_socket' => env('AGENTSLAVE_DB_SOCKET', ''),
|
||||
'charset' => env('AGENTSLAVE_DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('AGENTSLAVE_DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('AGENTSLAVE_MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'monoslave' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('MONOSLAVE_DB_URL'),
|
||||
'host' => env('MONOSLAVE_DB_HOST', '127.0.0.1'),
|
||||
'port' => env('MONOSLAVE_DB_PORT', '3306'),
|
||||
'database' => env('MONOSLAVE_DB_DATABASE', 'mono'),
|
||||
'username' => env('MONOSLAVE_DB_USERNAME', 'root'),
|
||||
'password' => env('MONOSLAVE_DB_PASSWORD', ''),
|
||||
'unix_socket' => env('MONOSLAVE_DB_SOCKET', ''),
|
||||
'charset' => env('MONOSLAVE_DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('MONOSLAVE_DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MONOSLAVE_MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
32
config/jira.php
Normal file
32
config/jira.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| JIRA Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| JIRA REST API 配置信息
|
||||
|
|
||||
*/
|
||||
|
||||
'host' => env('JIRA_HOST', 'http://jira.eainc.com:8080'),
|
||||
'username' => env('JIRA_USERNAME'),
|
||||
'password' => env('JIRA_PASSWORD'),
|
||||
'api_token' => env('JIRA_API_TOKEN'),
|
||||
|
||||
// 认证方式: 'basic' 或 'token'
|
||||
'auth_type' => env('JIRA_AUTH_TYPE', 'basic'),
|
||||
|
||||
// 连接超时时间(秒)
|
||||
'timeout' => (int) env('JIRA_TIMEOUT', 30),
|
||||
|
||||
// 项目代码映射
|
||||
'project_codes' => [
|
||||
'WP' => 'WP',
|
||||
'AM' => 'AM',
|
||||
],
|
||||
|
||||
// 默认用户(如果未指定)
|
||||
'default_user' => env('JIRA_DEFAULT_USER'),
|
||||
];
|
||||
@@ -35,4 +35,14 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'agent' => [
|
||||
'url' => env('AGENT_URL'),
|
||||
'timeout' => env('AGENT_TIMEOUT', 30),
|
||||
],
|
||||
|
||||
'mono' => [
|
||||
'url' => env('MONO_URL'),
|
||||
'timeout' => env('MONO_TIMEOUT', 30),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
279
package-lock.json
generated
279
package-lock.json
generated
@@ -5,7 +5,17 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.3",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/language": "^6.10.6",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.38.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"vue": "^3.5.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -77,6 +87,157 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.18.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
|
||||
"integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz",
|
||||
"integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
|
||||
"integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
|
||||
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript/node_modules/@codemirror/lint": {
|
||||
"version": "6.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
|
||||
"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-php": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
|
||||
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/php": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz",
|
||||
"integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.1.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.5.11",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
|
||||
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
|
||||
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/theme-one-dark": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.38.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
|
||||
"integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
|
||||
@@ -544,6 +705,80 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
|
||||
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
|
||||
"integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
|
||||
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
|
||||
"integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz",
|
||||
"integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
|
||||
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/php": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.4.tgz",
|
||||
"integrity": "sha512-D2dJ0t8Z28/G1guztRczMFvPDUqzeMLSQbdWQmaiHV7urc8NlEOnjYk9UrZ531OcLiRxD4Ihcbv7AsDpNKDRaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.29",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
|
||||
@@ -1323,6 +1558,32 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror/node_modules/@codemirror/lint": {
|
||||
"version": "6.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
|
||||
"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1382,6 +1643,12 @@
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@@ -2296,6 +2563,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
|
||||
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
@@ -2499,6 +2772,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
||||
10
package.json
10
package.json
@@ -16,7 +16,17 @@
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.3",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/language": "^6.10.6",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.38.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"vue": "^3.5.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import './bootstrap';
|
||||
import { createApp } from 'vue';
|
||||
import EnvManager from './components/EnvManager.vue';
|
||||
import AdminDashboard from './components/admin/AdminDashboard.vue';
|
||||
|
||||
console.log('App.js loading...');
|
||||
|
||||
const app = createApp({});
|
||||
app.component('env-manager', EnvManager);
|
||||
app.component('admin-dashboard', AdminDashboard);
|
||||
|
||||
console.log('Mounting app...');
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
<template>
|
||||
<div class="admin-layout h-screen bg-gray-50">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg border-r border-gray-200 flex flex-col">
|
||||
<!-- Logo区域 -->
|
||||
<div class="flex items-center justify-center h-16 px-6 bg-gradient-to-r from-blue-600 to-blue-700 border-b border-blue-800 flex-shrink-0">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-white">
|
||||
<div class="text-lg font-bold">管理系统</div>
|
||||
<div class="text-xs text-blue-100">Environment Manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="mt-6 px-3 flex flex-col flex-1">
|
||||
<div class="space-y-1 flex-1">
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('env')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'env'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'env' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
环境配置管理
|
||||
</a>
|
||||
|
||||
<!-- 预留的其他菜单项 -->
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('settings')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'settings'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'settings' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
系统设置
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('logs')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'logs'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'logs' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
操作日志
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="mt-auto pt-6 border-t border-gray-200">
|
||||
<div class="px-3 py-2">
|
||||
<div class="text-xs text-gray-500 mb-1">系统信息</div>
|
||||
<div class="text-xs text-gray-400">版本 v1.0.0</div>
|
||||
<div class="text-xs text-gray-400">Laravel 12 + Vue 3</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="pl-64 h-screen">
|
||||
<!-- 页面内容 -->
|
||||
<main class="h-full">
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AdminLayout',
|
||||
props: {
|
||||
pageTitle: {
|
||||
type: String,
|
||||
default: '环境配置管理'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeMenu: 'env'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setActiveMenu(menu) {
|
||||
this.activeMenu = menu;
|
||||
this.$emit('menu-change', menu);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.admin-layout .pl-64 {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.admin-layout .fixed.w-64 {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.admin-layout .fixed.w-64.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,969 +0,0 @@
|
||||
<template>
|
||||
<div class="simple-code-editor">
|
||||
<!-- 编辑器头部工具栏 -->
|
||||
<div class="editor-header">
|
||||
<div class="editor-tabs">
|
||||
<div class="tab active">
|
||||
<div class="tab-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="tab-name">{{ getFileName() }}</span>
|
||||
<div class="tab-close">×</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-controls">
|
||||
<button class="control-btn" @click="formatCode" title="格式化代码">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="control-btn" @click="toggleWrap" title="切换自动换行">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M4,6H20V8H4V6M4,18V16H20V18H4M4,13H16L13,16H15L19,12L15,8H13L16,11H4V13Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器主体 - 单层渲染方案 -->
|
||||
<div class="editor-body" :class="{ 'dark-theme': theme === 'dark', 'word-wrap': wordWrap }">
|
||||
<!-- 行号区域 -->
|
||||
<div class="line-numbers-area" ref="lineNumbers">
|
||||
<div
|
||||
v-for="(line, index) in lines"
|
||||
:key="index"
|
||||
class="line-number"
|
||||
:class="{ 'active': index + 1 === currentLine }"
|
||||
>
|
||||
{{ String(index + 1).padStart(3, ' ') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码区域 - 只使用一个带语法高亮的可编辑div -->
|
||||
<div class="code-area">
|
||||
<div
|
||||
ref="codeEditor"
|
||||
class="code-editor-div"
|
||||
:contenteditable="!readonly"
|
||||
@input="handleDivInput"
|
||||
@scroll="handleScroll"
|
||||
@keydown="handleKeydown"
|
||||
@click="updateCursor"
|
||||
@keyup="updateCursor"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@paste="handlePaste"
|
||||
spellcheck="false"
|
||||
v-html="highlightedContent"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器底部状态栏 -->
|
||||
<div class="editor-footer">
|
||||
<div class="status-left">
|
||||
<span class="status-item">行 {{ currentLine }}</span>
|
||||
<span class="status-item">列 {{ currentColumn }}</span>
|
||||
<span class="status-item">{{ lines.length }} 行</span>
|
||||
<span class="status-item">{{ content.length }} 字符</span>
|
||||
</div>
|
||||
<div class="status-right">
|
||||
<span class="status-item">{{ language.toUpperCase() }}</span>
|
||||
<span class="status-item">UTF-8</span>
|
||||
<span class="status-item" :class="{ 'status-success': !readonly }">
|
||||
{{ readonly ? '只读' : '可编辑' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 纯原生实现,无外部依赖
|
||||
|
||||
export default {
|
||||
name: 'CodeEditor',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'env' // 'env', 'javascript', 'php'
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'light' // 'light', 'dark'
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
data() {
|
||||
return {
|
||||
content: this.modelValue,
|
||||
currentLine: 1,
|
||||
currentColumn: 1,
|
||||
focused: false,
|
||||
wordWrap: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
lines() {
|
||||
return this.content.split('\n');
|
||||
},
|
||||
highlightedContent() {
|
||||
if (!this.content) return '';
|
||||
|
||||
if (this.language === 'env') {
|
||||
return this.highlightEnvContent(this.content);
|
||||
} else if (this.language === 'javascript') {
|
||||
return this.highlightJavaScriptContent(this.content);
|
||||
} else if (this.language === 'php') {
|
||||
return this.highlightPhpContent(this.content);
|
||||
}
|
||||
|
||||
return this.escapeHtml(this.content);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 初始化时自动检测语言
|
||||
this.autoDetectLanguage();
|
||||
// 初始化编辑器
|
||||
this.initEditor();
|
||||
},
|
||||
watch: {
|
||||
modelValue(newValue) {
|
||||
if (newValue !== this.content) {
|
||||
this.content = newValue;
|
||||
this.$nextTick(() => {
|
||||
this.initEditor();
|
||||
});
|
||||
}
|
||||
},
|
||||
content() {
|
||||
this.$nextTick(() => {
|
||||
this.initEditor();
|
||||
});
|
||||
},
|
||||
language() {
|
||||
// 语言变化时重新渲染语法高亮
|
||||
this.$nextTick(() => {
|
||||
this.initEditor();
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
this.$emit('update:modelValue', this.content);
|
||||
this.$emit('change', this.content);
|
||||
this.updateCursor();
|
||||
},
|
||||
|
||||
handleDivInput(event) {
|
||||
// 获取纯文本内容
|
||||
const text = this.getPlainTextFromDiv(event.target);
|
||||
this.content = text;
|
||||
this.$emit('update:modelValue', text);
|
||||
this.$emit('change', text);
|
||||
|
||||
// 延迟更新语法高亮,避免光标跳动
|
||||
this.$nextTick(() => {
|
||||
this.updateSyntaxHighlight();
|
||||
this.updateCursor();
|
||||
});
|
||||
},
|
||||
|
||||
handleScroll() {
|
||||
// 同步行号滚动
|
||||
const codeEditor = this.$refs.codeEditor;
|
||||
const lineNumbers = this.$refs.lineNumbers;
|
||||
|
||||
if (codeEditor && lineNumbers) {
|
||||
lineNumbers.scrollTop = codeEditor.scrollTop;
|
||||
}
|
||||
},
|
||||
|
||||
handleFocus() {
|
||||
this.focused = true;
|
||||
},
|
||||
|
||||
handleBlur() {
|
||||
this.focused = false;
|
||||
},
|
||||
|
||||
handleKeydown(event) {
|
||||
// Tab键处理
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
this.insertText(' ');
|
||||
}
|
||||
},
|
||||
|
||||
handlePaste(event) {
|
||||
event.preventDefault();
|
||||
const text = (event.clipboardData || window.clipboardData).getData('text');
|
||||
this.insertText(text);
|
||||
},
|
||||
|
||||
insertText(text) {
|
||||
const selection = window.getSelection();
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(document.createTextNode(text));
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// 触发input事件
|
||||
this.handleDivInput({ target: this.$refs.codeEditor });
|
||||
}
|
||||
},
|
||||
|
||||
updateCursor() {
|
||||
const selection = window.getSelection();
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const beforeCursor = this.getTextBeforeCursor(range);
|
||||
const lines = beforeCursor.split('\n');
|
||||
this.currentLine = lines.length;
|
||||
this.currentColumn = lines[lines.length - 1].length + 1;
|
||||
}
|
||||
},
|
||||
|
||||
initEditor() {
|
||||
// 初始化编辑器内容
|
||||
const codeEditor = this.$refs.codeEditor;
|
||||
if (codeEditor && this.content) {
|
||||
codeEditor.innerHTML = this.highlightedContent;
|
||||
}
|
||||
},
|
||||
|
||||
getPlainTextFromDiv(div) {
|
||||
// 从contenteditable div中提取纯文本
|
||||
return div.innerText || div.textContent || '';
|
||||
},
|
||||
|
||||
getTextBeforeCursor(range) {
|
||||
// 获取光标前的文本
|
||||
const container = this.$refs.codeEditor;
|
||||
if (!container) return '';
|
||||
|
||||
const tempRange = document.createRange();
|
||||
tempRange.setStart(container, 0);
|
||||
tempRange.setEnd(range.startContainer, range.startOffset);
|
||||
|
||||
return tempRange.toString();
|
||||
},
|
||||
|
||||
updateSyntaxHighlight() {
|
||||
const codeEditor = this.$refs.codeEditor;
|
||||
if (!codeEditor) return;
|
||||
|
||||
// 保存当前光标位置
|
||||
const selection = window.getSelection();
|
||||
let savedRange = null;
|
||||
let cursorOffset = 0;
|
||||
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
cursorOffset = this.getTextBeforeCursor(range).length;
|
||||
}
|
||||
|
||||
// 更新语法高亮内容
|
||||
codeEditor.innerHTML = this.highlightedContent;
|
||||
|
||||
// 恢复光标位置
|
||||
this.$nextTick(() => {
|
||||
this.restoreCursorPosition(cursorOffset);
|
||||
});
|
||||
},
|
||||
|
||||
restoreCursorPosition(offset) {
|
||||
const codeEditor = this.$refs.codeEditor;
|
||||
if (!codeEditor) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
|
||||
try {
|
||||
let currentOffset = 0;
|
||||
const walker = document.createTreeWalker(
|
||||
codeEditor,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
let node;
|
||||
while (node = walker.nextNode()) {
|
||||
const nodeLength = node.textContent.length;
|
||||
if (currentOffset + nodeLength >= offset) {
|
||||
range.setStart(node, offset - currentOffset);
|
||||
range.setEnd(node, offset - currentOffset);
|
||||
break;
|
||||
}
|
||||
currentOffset += nodeLength;
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (e) {
|
||||
// 如果恢复失败,将光标放到末尾
|
||||
range.selectNodeContents(codeEditor);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
},
|
||||
|
||||
formatCode() {
|
||||
// 简单的代码格式化
|
||||
if (this.language === 'env') {
|
||||
const lines = this.content.split('\n');
|
||||
const formatted = lines.map(line => {
|
||||
line = line.trim();
|
||||
if (line.includes('=') && !line.startsWith('#')) {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
const value = valueParts.join('=');
|
||||
return `${key.trim()}=${value}`;
|
||||
}
|
||||
return line;
|
||||
}).join('\n');
|
||||
|
||||
this.content = formatted;
|
||||
this.handleInput();
|
||||
}
|
||||
},
|
||||
|
||||
toggleWrap() {
|
||||
this.wordWrap = !this.wordWrap;
|
||||
},
|
||||
|
||||
getFileName() {
|
||||
const fileNames = {
|
||||
'env': '.env',
|
||||
'javascript': 'script.js',
|
||||
'php': 'config.php'
|
||||
};
|
||||
return fileNames[this.language] || '.env';
|
||||
},
|
||||
|
||||
// 根据文件名自动检测语言
|
||||
detectLanguageFromFilename(filename) {
|
||||
if (!filename) return 'env';
|
||||
|
||||
const ext = filename.toLowerCase();
|
||||
|
||||
// .env 文件检测
|
||||
if (ext === '.env' || ext.startsWith('.env.') || ext.endsWith('.env')) {
|
||||
return 'env';
|
||||
}
|
||||
|
||||
// JavaScript 文件检测
|
||||
if (ext.endsWith('.js') || ext.endsWith('.jsx') || ext.endsWith('.ts') || ext.endsWith('.tsx') || ext.endsWith('.mjs')) {
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
// PHP 文件检测
|
||||
if (ext.endsWith('.php') || ext.endsWith('.phtml') || ext.endsWith('.php3') || ext.endsWith('.php4') || ext.endsWith('.php5')) {
|
||||
return 'php';
|
||||
}
|
||||
|
||||
// 默认返回 env
|
||||
return 'env';
|
||||
},
|
||||
|
||||
// 自动设置语言
|
||||
autoDetectLanguage() {
|
||||
const filename = this.getFileName();
|
||||
const detectedLanguage = this.detectLanguageFromFilename(filename);
|
||||
|
||||
if (detectedLanguage !== this.language) {
|
||||
this.$emit('language-change', detectedLanguage);
|
||||
}
|
||||
},
|
||||
|
||||
highlightEnvContent(content) {
|
||||
return content
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
if (line.trim().startsWith('#')) {
|
||||
// 注释
|
||||
return `<span class="env-comment">${this.escapeHtml(line)}</span>`;
|
||||
} else if (line.includes('=')) {
|
||||
// 键值对
|
||||
const equalIndex = line.indexOf('=');
|
||||
const key = line.substring(0, equalIndex);
|
||||
const value = line.substring(equalIndex + 1);
|
||||
return `<span class="env-key">${this.escapeHtml(key)}</span><span class="env-operator">=</span><span class="env-value">${this.escapeHtml(value)}</span>`;
|
||||
} else {
|
||||
return this.escapeHtml(line);
|
||||
}
|
||||
})
|
||||
.join('\n');
|
||||
},
|
||||
|
||||
highlightJavaScriptContent(content) {
|
||||
// 简单的JavaScript语法高亮
|
||||
const keywords = ['function', 'const', 'let', 'var', 'if', 'else', 'for', 'while', 'return', 'class', 'import', 'export', 'default'];
|
||||
const keywordRegex = new RegExp(`\\b(${keywords.join('|')})\\b`, 'g');
|
||||
|
||||
return content
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
let highlightedLine = this.escapeHtml(line);
|
||||
|
||||
// 高亮关键字
|
||||
highlightedLine = highlightedLine.replace(keywordRegex, '<span class="js-keyword">$1</span>');
|
||||
|
||||
// 高亮字符串
|
||||
highlightedLine = highlightedLine.replace(/(["'`])((?:\\.|(?!\1)[^\\])*?)\1/g, '<span class="js-string">$1$2$1</span>');
|
||||
|
||||
// 高亮注释
|
||||
highlightedLine = highlightedLine.replace(/(\/\/.*$)/g, '<span class="js-comment">$1</span>');
|
||||
|
||||
return highlightedLine;
|
||||
})
|
||||
.join('\n');
|
||||
},
|
||||
|
||||
highlightPhpContent(content) {
|
||||
// 简单的PHP语法高亮
|
||||
const keywords = ['function', 'class', 'if', 'else', 'elseif', 'while', 'for', 'foreach', 'return', 'public', 'private', 'protected', 'static'];
|
||||
const keywordRegex = new RegExp(`\\b(${keywords.join('|')})\\b`, 'g');
|
||||
|
||||
return content
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
let highlightedLine = this.escapeHtml(line);
|
||||
|
||||
// 高亮PHP标签
|
||||
highlightedLine = highlightedLine.replace(/(<\?php|<\?=|\?>)/g, '<span class="php-tag">$1</span>');
|
||||
|
||||
// 高亮关键字
|
||||
highlightedLine = highlightedLine.replace(keywordRegex, '<span class="php-keyword">$1</span>');
|
||||
|
||||
// 高亮变量
|
||||
highlightedLine = highlightedLine.replace(/(\$\w+)/g, '<span class="php-variable">$1</span>');
|
||||
|
||||
// 高亮字符串
|
||||
highlightedLine = highlightedLine.replace(/(["'])((?:\\.|(?!\1)[^\\])*?)\1/g, '<span class="php-string">$1$2$1</span>');
|
||||
|
||||
// 高亮注释
|
||||
highlightedLine = highlightedLine.replace(/(\/\/.*$|\/\*.*?\*\/)/g, '<span class="php-comment">$1</span>');
|
||||
|
||||
return highlightedLine;
|
||||
})
|
||||
.join('\n');
|
||||
},
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
focus() {
|
||||
if (this.$refs.textarea) {
|
||||
this.$refs.textarea.focus();
|
||||
}
|
||||
},
|
||||
|
||||
getContent() {
|
||||
return this.content;
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simple-code-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Monaco', 'Inconsolata', 'Consolas', monospace;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: visible; /* 移除容器级别的滚动条限制 */
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* 编辑器头部 */
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #ffffff;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
margin-right: 8px;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
color: #9ca3af;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 编辑器主体 */
|
||||
.editor-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #ffffff;
|
||||
overflow: visible; /* 允许内容正常显示,不被截断 */
|
||||
}
|
||||
|
||||
.editor-body.dark-theme {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
/* 行号区域 */
|
||||
.line-numbers-area {
|
||||
background: linear-gradient(180deg, #fafbfc 0%, #f4f6f8 100%);
|
||||
border-right: 1px solid #e2e8f0;
|
||||
padding: 16px 0;
|
||||
min-width: 60px;
|
||||
overflow: hidden; /* 行号区域保持隐藏溢出,避免水平滚动 */
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #8b949e;
|
||||
position: relative;
|
||||
flex-shrink: 0; /* 防止行号区域被压缩 */
|
||||
}
|
||||
|
||||
.dark-theme .line-numbers-area {
|
||||
background: linear-gradient(180deg, #2d3748 0%, #1a202c 100%);
|
||||
border-right-color: #4a5568;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
text-align: right;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.line-number.active {
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.dark-theme .line-number.active {
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
|
||||
/* 代码区域 */
|
||||
.code-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: visible; /* 确保代码区域不被截断 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 可编辑的代码编辑器div - 单层方案,无重影 */
|
||||
.code-editor-div {
|
||||
width: 100%;
|
||||
height: 100%; /* 改为100%高度以充分利用父容器空间 */
|
||||
min-height: 200px; /* 设置最小高度确保可见 */
|
||||
padding: 16px 20px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-family: inherit;
|
||||
color: #24292f;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
overflow: auto; /* 确保编辑器内部滚动条正常工作 */
|
||||
white-space: pre;
|
||||
caret-color: #3b82f6;
|
||||
font-variant-ligatures: common-ligatures;
|
||||
|
||||
/* 确保可编辑性 */
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
|
||||
/* 优化渲染性能 */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
|
||||
/* 防止内容溢出 */
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
/* 确保编辑器能够自适应父容器 */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.word-wrap .code-editor-div {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 确保编辑器在不同容器中都能正确显示 */
|
||||
.code-editor-div:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 自适应高度的额外样式 */
|
||||
.simple-code-editor {
|
||||
max-height: 100vh; /* 防止超出视口高度 */
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
min-height: 300px; /* 确保编辑器有足够的最小高度 */
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div {
|
||||
color: #f0f6fc;
|
||||
caret-color: #60a5fa;
|
||||
}
|
||||
|
||||
/* 编辑器底部状态栏 */
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.dark-theme .editor-footer {
|
||||
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
||||
border-top-color: #4a5568;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.status-left,
|
||||
.status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.dark-theme .status-success {
|
||||
color: #68d391;
|
||||
}
|
||||
|
||||
/* 语法高亮样式 */
|
||||
.code-editor-div :deep(.env-comment) {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.env-key) {
|
||||
color: #005cc5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.env-operator) {
|
||||
color: #d73a49;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.env-value) {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
/* 深色主题语法高亮 */
|
||||
.dark-theme .code-editor-div :deep(.env-comment) {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.env-key) {
|
||||
color: #79c0ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.env-operator) {
|
||||
color: #ff7b72;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.env-value) {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
/* JavaScript 语法高亮样式 */
|
||||
.code-editor-div :deep(.js-keyword) {
|
||||
color: #d73a49;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.js-string) {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.js-comment) {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* PHP 语法高亮样式 */
|
||||
.code-editor-div :deep(.php-tag) {
|
||||
color: #d73a49;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.php-keyword) {
|
||||
color: #6f42c1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.php-variable) {
|
||||
color: #e36209;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.php-string) {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
.code-editor-div :deep(.php-comment) {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 深色主题 JavaScript */
|
||||
.dark-theme .code-editor-div :deep(.js-keyword) {
|
||||
color: #ff7b72;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.js-string) {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.js-comment) {
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 深色主题 PHP */
|
||||
.dark-theme .code-editor-div :deep(.php-tag) {
|
||||
color: #ff7b72;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.php-keyword) {
|
||||
color: #d2a8ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.php-variable) {
|
||||
color: #ffa657;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.php-string) {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div :deep(.php-comment) {
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 滚动条样式 - 只为代码编辑器设置滚动条 */
|
||||
.code-editor-div::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.code-editor-div::-webkit-scrollbar-track {
|
||||
background: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.code-editor-div::-webkit-scrollbar-thumb {
|
||||
background: #d0d7de;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #f6f8fa;
|
||||
}
|
||||
|
||||
.code-editor-div::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8b3c1;
|
||||
}
|
||||
|
||||
/* 深色主题滚动条样式 */
|
||||
.dark-theme .code-editor-div::-webkit-scrollbar-track {
|
||||
background: #21262d;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div::-webkit-scrollbar-thumb {
|
||||
background: #484f58;
|
||||
border-color: #21262d;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div::-webkit-scrollbar-thumb:hover {
|
||||
background: #6e7681;
|
||||
}
|
||||
|
||||
/* 选择文本样式 */
|
||||
.code-editor-div::selection {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div::selection {
|
||||
background: rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
|
||||
/* placeholder样式 */
|
||||
.code-editor-div:empty:before {
|
||||
content: attr(data-placeholder);
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark-theme .code-editor-div:empty:before {
|
||||
color: #6e7681;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.editor-header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.line-numbers-area {
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.code-editor-div {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-left,
|
||||
.status-right {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,36 @@
|
||||
ref="envManagement"
|
||||
/>
|
||||
|
||||
<!-- 生成周报页面 -->
|
||||
<weekly-report
|
||||
v-else-if="currentPage === 'weekly-report'"
|
||||
ref="weeklyReport"
|
||||
/>
|
||||
|
||||
<!-- JIRA 工时查询页面 -->
|
||||
<jira-worklog
|
||||
v-else-if="currentPage === 'worklog'"
|
||||
ref="jiraWorklog"
|
||||
/>
|
||||
|
||||
<!-- 消息同步页面 -->
|
||||
<message-sync
|
||||
v-else-if="currentPage === 'message-sync'"
|
||||
ref="messageSync"
|
||||
/>
|
||||
|
||||
<!-- 事件消费者同步页面 -->
|
||||
<event-consumer-sync
|
||||
v-else-if="currentPage === 'event-consumer-sync'"
|
||||
ref="eventConsumerSync"
|
||||
/>
|
||||
|
||||
<!-- 消息分发异常查询页面 -->
|
||||
<message-dispatch
|
||||
v-else-if="currentPage === 'message-dispatch'"
|
||||
ref="messageDispatch"
|
||||
/>
|
||||
|
||||
<!-- 系统设置页面 -->
|
||||
<div v-else-if="currentPage === 'settings'" class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<div class="text-center py-12">
|
||||
@@ -32,18 +62,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</admin-layout>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AdminLayout from './AdminLayout.vue';
|
||||
import EnvManagement from './EnvManagement.vue';
|
||||
import EnvManagement from '../env/EnvManagement.vue';
|
||||
import WeeklyReport from '../jira/WeeklyReport.vue';
|
||||
import JiraWorklog from '../jira/JiraWorklog.vue';
|
||||
import MessageSync from '../message-sync/MessageSync.vue';
|
||||
import EventConsumerSync from '../message-sync/EventConsumerSync.vue';
|
||||
import MessageDispatch from '../message-sync/MessageDispatch.vue';
|
||||
|
||||
export default {
|
||||
name: 'EnvManager',
|
||||
name: 'AdminDashboard',
|
||||
components: {
|
||||
AdminLayout,
|
||||
EnvManagement
|
||||
EnvManagement,
|
||||
WeeklyReport,
|
||||
JiraWorklog,
|
||||
MessageSync,
|
||||
EventConsumerSync,
|
||||
MessageDispatch
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -52,7 +91,10 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('EnvManager mounted');
|
||||
console.log('AdminDashboard mounted');
|
||||
|
||||
// 根据 URL 路径设置初始页面
|
||||
this.setCurrentPageFromPath();
|
||||
},
|
||||
methods: {
|
||||
handleMenuChange(menu) {
|
||||
@@ -61,6 +103,11 @@ export default {
|
||||
// 更新页面标题
|
||||
const titles = {
|
||||
'env': '环境配置管理',
|
||||
'weekly-report': '生成周报',
|
||||
'worklog': 'JIRA 工时查询',
|
||||
'message-sync': '消息同步',
|
||||
'event-consumer-sync': '事件消费者同步对比',
|
||||
'message-dispatch': '消息分发异常查询',
|
||||
'settings': '系统设置',
|
||||
'logs': '操作日志'
|
||||
};
|
||||
@@ -68,9 +115,31 @@ export default {
|
||||
this.pageTitle = titles[menu] || '环境配置管理';
|
||||
},
|
||||
|
||||
setCurrentPageFromPath() {
|
||||
const path = window.location.pathname;
|
||||
let page = 'env'; // 默认页面
|
||||
|
||||
if (path === '/') {
|
||||
page = 'env';
|
||||
} else if (path === '/weekly-report') {
|
||||
page = 'weekly-report';
|
||||
} else if (path === '/worklog') {
|
||||
page = 'worklog';
|
||||
} else if (path === '/message-sync') {
|
||||
page = 'message-sync';
|
||||
} else if (path === '/event-consumer-sync') {
|
||||
page = 'event-consumer-sync';
|
||||
} else if (path === '/message-dispatch') {
|
||||
page = 'message-dispatch';
|
||||
} else if (path === '/settings') {
|
||||
page = 'settings';
|
||||
} else if (path === '/logs') {
|
||||
page = 'logs';
|
||||
}
|
||||
|
||||
|
||||
this.currentPage = page;
|
||||
this.handleMenuChange(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
322
resources/js/components/admin/AdminLayout.vue
Normal file
322
resources/js/components/admin/AdminLayout.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div class="admin-layout h-screen bg-gray-50">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg border-r border-gray-200 flex flex-col">
|
||||
<!-- Logo区域 -->
|
||||
<div class="flex items-center justify-center h-16 px-6 bg-gradient-to-r from-blue-600 to-blue-700 border-b border-blue-800 flex-shrink-0">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-white">
|
||||
<div class="text-lg font-bold">Tradewind Toolbox</div>
|
||||
<div class="text-xs text-blue-100">Development Tools</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="mt-6 px-3 flex flex-col flex-1">
|
||||
<div class="space-y-1 flex-1">
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('env')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'env'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'env' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
环境配置管理
|
||||
</a>
|
||||
|
||||
<!-- JIRA 相关菜单项 -->
|
||||
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('weekly-report')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'weekly-report'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'weekly-report' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
生成周报
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('worklog')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'worklog'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'worklog' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
JIRA 工时查询
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('message-sync')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'message-sync'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'message-sync' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||
</svg>
|
||||
消息同步
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('event-consumer-sync')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'event-consumer-sync'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'event-consumer-sync' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
事件消费者同步
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('message-dispatch')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'message-dispatch'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'message-dispatch' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
消息分发异常
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('settings')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'settings'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'settings' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
系统设置
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('logs')"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'logs'
|
||||
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
:class="[
|
||||
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||
activeMenu === 'logs' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||
]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
操作日志
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="mt-auto pt-6 border-t border-gray-200">
|
||||
<div class="px-3 py-2">
|
||||
<div class="text-xs text-gray-500 mb-1">系统信息</div>
|
||||
<div class="text-xs text-gray-400">版本 v1.0.0</div>
|
||||
<div class="text-xs text-gray-400">Laravel 12 + Vue 3</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="pl-64 h-screen">
|
||||
<!-- 页面内容 -->
|
||||
<main class="h-full">
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AdminLayout',
|
||||
props: {
|
||||
pageTitle: {
|
||||
type: String,
|
||||
default: '环境配置管理'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeMenu: 'env'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 根据 URL 路径设置初始菜单
|
||||
this.setActiveMenuFromPath();
|
||||
|
||||
// 监听浏览器前进后退按钮
|
||||
window.addEventListener('popstate', this.setActiveMenuFromPath);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('popstate', this.setActiveMenuFromPath);
|
||||
},
|
||||
methods: {
|
||||
setActiveMenu(menu) {
|
||||
this.activeMenu = menu;
|
||||
// 更新 URL 路径
|
||||
const path = menu === 'env' ? '/' : `/${menu}`;
|
||||
window.history.pushState({}, '', path);
|
||||
this.$emit('menu-change', menu);
|
||||
},
|
||||
|
||||
setActiveMenuFromPath() {
|
||||
const path = window.location.pathname;
|
||||
let menu = 'env'; // 默认页面
|
||||
|
||||
if (path === '/') {
|
||||
menu = 'env';
|
||||
} else if (path === '/weekly-report') {
|
||||
menu = 'weekly-report';
|
||||
} else if (path === '/worklog') {
|
||||
menu = 'worklog';
|
||||
} else if (path === '/message-sync') {
|
||||
menu = 'message-sync';
|
||||
} else if (path === '/event-consumer-sync') {
|
||||
menu = 'event-consumer-sync';
|
||||
} else if (path === '/message-dispatch') {
|
||||
menu = 'message-dispatch';
|
||||
} else if (path === '/settings') {
|
||||
menu = 'settings';
|
||||
} else if (path === '/logs') {
|
||||
menu = 'logs';
|
||||
}
|
||||
|
||||
this.activeMenu = menu;
|
||||
this.$emit('menu-change', menu);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.admin-layout .pl-64 {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.admin-layout .fixed.w-64 {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.admin-layout .fixed.w-64.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
289
resources/js/components/env/CodeEditor.vue
vendored
Normal file
289
resources/js/components/env/CodeEditor.vue
vendored
Normal file
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<div class="codemirror-editor">
|
||||
<div ref="editorContainer" class="editor-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { EditorView, keymap, highlightActiveLine, highlightActiveLineGutter, lineNumbers, scrollPastEnd } from '@codemirror/view'
|
||||
import { EditorState, Compartment } from '@codemirror/state'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
|
||||
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
|
||||
import { foldGutter, indentOnInput, indentUnit, bracketMatching } from '@codemirror/language'
|
||||
import { highlightSelectionMatches as highlightSelection } from '@codemirror/search'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { php } from '@codemirror/lang-php'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { env } from '../../lang-env.js'
|
||||
|
||||
export default {
|
||||
name: 'CodeEditor',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'env' // 'env', 'javascript', 'php'
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: 'light' // 'light', 'dark'
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
data() {
|
||||
return {
|
||||
editor: null,
|
||||
languageCompartment: new Compartment(),
|
||||
themeCompartment: new Compartment()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initEditor();
|
||||
},
|
||||
watch: {
|
||||
modelValue(newValue) {
|
||||
if (this.editor && newValue !== this.editor.state.doc.toString()) {
|
||||
this.editor.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: this.editor.state.doc.length,
|
||||
insert: newValue || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
language() {
|
||||
this.updateLanguage();
|
||||
},
|
||||
theme() {
|
||||
this.updateTheme();
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getLanguageExtension() {
|
||||
switch (this.language) {
|
||||
case 'javascript':
|
||||
return javascript();
|
||||
case 'php':
|
||||
return php();
|
||||
case 'env':
|
||||
return env();
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getThemeExtension() {
|
||||
return this.theme === 'dark' ? oneDark : [];
|
||||
},
|
||||
|
||||
initEditor() {
|
||||
const extensions = [
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightActiveLine(),
|
||||
foldGutter(),
|
||||
indentOnInput(),
|
||||
indentUnit.of(' '),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
highlightSelectionMatches(),
|
||||
history(),
|
||||
scrollPastEnd(),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
]),
|
||||
this.languageCompartment.of(this.getLanguageExtension()),
|
||||
this.themeCompartment.of(this.getThemeExtension()),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const content = update.state.doc.toString();
|
||||
this.$emit('update:modelValue', content);
|
||||
this.$emit('change', content);
|
||||
}
|
||||
}),
|
||||
// 确保编辑器可以滚动
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
height: '100%'
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'inherit',
|
||||
overflow: 'auto'
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '12px',
|
||||
minHeight: '100%'
|
||||
},
|
||||
'.cm-editor': {
|
||||
height: '100%'
|
||||
},
|
||||
'.cm-focused': {
|
||||
outline: 'none'
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
// 添加只读模式
|
||||
if (this.readonly) {
|
||||
extensions.push(EditorView.editable.of(false));
|
||||
}
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: this.modelValue || '',
|
||||
extensions
|
||||
});
|
||||
|
||||
this.editor = new EditorView({
|
||||
state,
|
||||
parent: this.$refs.editorContainer
|
||||
});
|
||||
},
|
||||
|
||||
updateLanguage() {
|
||||
if (!this.editor) return;
|
||||
|
||||
this.editor.dispatch({
|
||||
effects: this.languageCompartment.reconfigure(this.getLanguageExtension())
|
||||
});
|
||||
},
|
||||
|
||||
updateTheme() {
|
||||
if (!this.editor) return;
|
||||
|
||||
this.editor.dispatch({
|
||||
effects: this.themeCompartment.reconfigure(this.getThemeExtension())
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.codemirror-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* CodeMirror 自定义样式 */
|
||||
.codemirror-editor :deep(.cm-editor) {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Monaco', 'Inconsolata', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-scroller) {
|
||||
overflow: auto !important;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* 基本语法高亮样式 */
|
||||
.codemirror-editor :deep(.cm-variableName) {
|
||||
color: #0969da;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-string) {
|
||||
color: #0a3069;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-number) {
|
||||
color: #0550ae;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-keyword) {
|
||||
color: #8250df;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-operator) {
|
||||
color: #cf222e;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-comment) {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-content) {
|
||||
padding: 12px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.codemirror-editor :deep(.cm-scroller)::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-scroller)::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-scroller)::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-scroller)::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 深色主题样式 */
|
||||
.codemirror-editor.dark :deep(.cm-editor) {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.codemirror-editor.dark :deep(.cm-scroller)::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.codemirror-editor.dark :deep(.cm-scroller)::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.codemirror-editor.dark :deep(.cm-scroller)::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
</style>
|
||||
@@ -9,8 +9,13 @@
|
||||
</svg>
|
||||
项目选择
|
||||
</h2>
|
||||
<div class="text-xs text-gray-500">
|
||||
共 {{ projects.length }} 个项目
|
||||
<div class="text-right text-xs text-gray-500 space-y-0.5">
|
||||
<div>共 {{ projects.length }} 个项目</div>
|
||||
<div v-if="selectedProject">
|
||||
当前 APP_ENV:
|
||||
<span v-if="currentAppEnv">{{ currentAppEnv }}</span>
|
||||
<span v-else class="text-gray-400">未检测到</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,8 +148,19 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 cursor-pointer" @click="loadEnvContent(env.name)">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-2.5 h-2.5 bg-green-400 rounded-full"></div>
|
||||
<h4 class="font-medium text-gray-900 hover:text-blue-600 transition-colors duration-200 text-sm">{{ env.name }}</h4>
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full"
|
||||
:class="env.name === currentAppEnv ? 'bg-blue-500' : 'bg-green-400'"
|
||||
></div>
|
||||
<h4 class="font-medium text-gray-900 hover:text-blue-600 transition-colors duration-200 text-sm">
|
||||
{{ env.name }}
|
||||
<span
|
||||
v-if="env.name === currentAppEnv"
|
||||
class="ml-1 px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-700 align-middle"
|
||||
>
|
||||
当前
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5 space-x-3 ml-4.5">
|
||||
<span>{{ formatFileSize(env.size) }}</span>
|
||||
@@ -268,7 +284,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 新增配置文件模态框 -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div v-if="showCreateModal" class="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
@@ -313,7 +329,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 导入模态框 -->
|
||||
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div v-if="showImportModal" class="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
@@ -403,6 +419,7 @@ export default {
|
||||
importEnvName: '',
|
||||
editorLanguage: 'env',
|
||||
editorTheme: 'light',
|
||||
currentAppEnv: '',
|
||||
message: {
|
||||
text: '',
|
||||
type: ''
|
||||
@@ -457,7 +474,7 @@ export default {
|
||||
methods: {
|
||||
async loadProjects() {
|
||||
try {
|
||||
const response = await fetch('/env/api/projects');
|
||||
const response = await fetch('/api/env/projects');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -477,11 +494,12 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/env/api/projects/${this.selectedProject}/envs`);
|
||||
const response = await fetch(`/api/env/projects/${this.selectedProject}/envs`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.envs = data.data;
|
||||
this.envs = Array.isArray(data.data) ? data.data : [];
|
||||
this.tryAutoSelectCurrentEnv();
|
||||
} else {
|
||||
this.showMessage('加载环境列表失败', 'error');
|
||||
}
|
||||
@@ -490,12 +508,51 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async loadCurrentProjectEnv() {
|
||||
if (!this.selectedProject) {
|
||||
this.currentAppEnv = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/env/projects/${this.selectedProject}/current-env`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success || !data.data || typeof data.data.content !== 'string') {
|
||||
this.currentAppEnv = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const content = data.data.content;
|
||||
const match = content.match(/^\s*APP_ENV\s*=\s*([^\r\n#]+)/m);
|
||||
this.currentAppEnv = match ? match[1].trim() : '';
|
||||
this.tryAutoSelectCurrentEnv();
|
||||
} catch (error) {
|
||||
this.currentAppEnv = '';
|
||||
}
|
||||
},
|
||||
|
||||
tryAutoSelectCurrentEnv() {
|
||||
if (!this.currentAppEnv || !Array.isArray(this.envs) || this.envs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedEnv === this.currentAppEnv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matched = this.envs.find(env => env.name === this.currentAppEnv);
|
||||
if (matched) {
|
||||
this.loadEnvContent(matched.name);
|
||||
}
|
||||
},
|
||||
|
||||
async loadEnvContent(envName) {
|
||||
this.selectedEnv = envName;
|
||||
this.newEnvName = envName;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/env/api/projects/${this.selectedProject}/envs/${envName}`);
|
||||
const response = await fetch(`/api/env/projects/${this.selectedProject}/envs/${envName}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -521,7 +578,7 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/env/api/envs/save', {
|
||||
const response = await fetch('/api/env/envs/save', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -555,7 +612,7 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/env/api/envs/apply', {
|
||||
const response = await fetch('/api/env/envs/apply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -571,6 +628,8 @@ export default {
|
||||
|
||||
if (data.success) {
|
||||
this.showMessage('环境配置应用成功', 'success');
|
||||
await this.loadCurrentProjectEnv();
|
||||
await this.loadEnvs();
|
||||
} else {
|
||||
this.showMessage(data.message || '应用失败', 'error');
|
||||
}
|
||||
@@ -585,7 +644,7 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/env/api/projects/${this.selectedProject}/envs/${envName}`, {
|
||||
const response = await fetch(`/api/env/projects/${this.selectedProject}/envs/${envName}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
@@ -616,7 +675,7 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/env/api/envs/import', {
|
||||
const response = await fetch('/api/env/envs/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -650,7 +709,7 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/env/api/envs/create', {
|
||||
const response = await fetch('/api/env/envs/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -704,7 +763,12 @@ export default {
|
||||
this.projectSearchQuery = project.name;
|
||||
this.showProjectDropdown = false;
|
||||
this.highlightedIndex = -1;
|
||||
this.selectedEnv = '';
|
||||
this.envContent = '';
|
||||
this.newEnvName = '';
|
||||
this.currentAppEnv = '';
|
||||
this.loadEnvs();
|
||||
this.loadCurrentProjectEnv();
|
||||
},
|
||||
|
||||
handleProjectFocus() {
|
||||
503
resources/js/components/jira/JiraWorklog.vue
Normal file
503
resources/js/components/jira/JiraWorklog.vue
Normal file
@@ -0,0 +1,503 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">JIRA 工时查询</h1>
|
||||
<p class="text-gray-600 mt-2">查询指定时间范围内的工时记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 工时查询区域 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">工时记录查询</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="workLogs.username"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入 JIRA 用户名"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">开始日期</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="workLogs.startDate"
|
||||
:disabled="workLogs.activeQuickSelect"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">结束日期</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="workLogs.endDate"
|
||||
:disabled="workLogs.activeQuickSelect"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="getWorkLogs"
|
||||
:disabled="workLogs.loading"
|
||||
class="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="workLogs.loading">查询中...</span>
|
||||
<span v-else>查询工时</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速查询选项 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">快速查询</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="setQuickDateRange('lastWeek')"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
workLogs.activeQuickSelect === 'lastWeek'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
]"
|
||||
>
|
||||
查询上周数据
|
||||
</button>
|
||||
<button
|
||||
@click="setQuickDateRange('yesterday')"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
workLogs.activeQuickSelect === 'yesterday'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
]"
|
||||
>
|
||||
查询昨天数据
|
||||
</button>
|
||||
<button
|
||||
@click="setQuickDateRange('today')"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
workLogs.activeQuickSelect === 'today'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
]"
|
||||
>
|
||||
查询今天数据
|
||||
</button>
|
||||
<button
|
||||
@click="clearQuickSelect()"
|
||||
v-if="workLogs.activeQuickSelect"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-300 text-gray-700 hover:bg-gray-400 transition-colors"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工时统计 -->
|
||||
<div v-if="workLogs.data" class="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-blue-800">总工时</h3>
|
||||
<p class="text-2xl font-bold text-blue-600">{{ workLogs.data.total_hours }} 小时</p>
|
||||
</div>
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-green-800">记录数量</h3>
|
||||
<p class="text-2xl font-bold text-green-600">{{ workLogs.data.total_records }} 条</p>
|
||||
</div>
|
||||
<div class="bg-purple-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-purple-800">查询范围</h3>
|
||||
<p class="text-sm text-purple-600">{{ workLogs.data.date_range.start }} 至 {{ workLogs.data.date_range.end }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工时记录表格 -->
|
||||
<div v-if="workLogs.data && workLogs.data.work_logs && workLogs.data.work_logs.length > 0" class="w-full">
|
||||
<table class="w-full bg-white border border-gray-200 table-fixed">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th @click="sortBy('project')" class="w-16 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
|
||||
项目
|
||||
<span v-if="sortField === 'project'" class="ml-1">
|
||||
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</th>
|
||||
<th @click="sortBy('issue_key')" class="w-1/4 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
|
||||
任务
|
||||
<span v-if="sortField === 'issue_key'" class="ml-1">
|
||||
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</th>
|
||||
<th @click="sortBy('parent_task')" class="w-1/4 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
|
||||
父任务
|
||||
<span v-if="sortField === 'parent_task'" class="ml-1">
|
||||
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</th>
|
||||
<th @click="sortBy('date')" class="w-20 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
|
||||
日期时间
|
||||
<span v-if="sortField === 'date'" class="ml-1">
|
||||
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</th>
|
||||
<th @click="sortBy('hours')" class="w-16 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
|
||||
工时
|
||||
<span v-if="sortField === 'hours'" class="ml-1">
|
||||
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</th>
|
||||
<th @click="sortBy('comment')" class="w-1/6 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
|
||||
备注
|
||||
<span v-if="sortField === 'comment'" class="ml-1">
|
||||
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</th>
|
||||
<th class="w-16 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="log in sortedWorkLogs" :key="log.id">
|
||||
<td class="px-3 py-4 text-sm text-gray-900 break-words">{{ log.project_key }}</td>
|
||||
<td class="px-3 py-4 text-sm break-words">
|
||||
<a :href="log.issue_url" target="_blank" class="text-blue-600 hover:text-blue-800">
|
||||
{{ log.issue_key }} {{ log.issue_summary }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm break-words">
|
||||
<a v-if="log.parent_task" :href="getJiraUrl(log.parent_task.key)" target="_blank" class="text-blue-600 hover:text-blue-800">
|
||||
{{ log.parent_task.key }} {{ log.parent_task.summary }}
|
||||
</a>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-900 break-words">{{ log.date }}<br>{{ log.time }}</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-900">{{ log.hours }}h</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-900 break-words">{{ log.comment }}</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-900">
|
||||
<button
|
||||
@click="showWorklogDetail(log)"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<div v-else-if="workLogs.data && workLogs.data.work_logs && workLogs.data.work_logs.length === 0" class="text-center py-8">
|
||||
<p class="text-gray-500">指定日期范围内没有找到工时记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="workLogs.error" class="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p class="text-red-700">{{ workLogs.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工时详情模态框 -->
|
||||
<div v-if="showDetailModal" class="fixed inset-0 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">工时详情</h3>
|
||||
<button @click="closeDetailModal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedWorklog" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">工时ID</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">项目</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.project }} ({{ selectedWorklog.project_key }})</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">任务</label>
|
||||
<p class="mt-1 text-sm text-gray-900">
|
||||
<a :href="selectedWorklog.issue_url" target="_blank" class="text-blue-600 hover:text-blue-800">
|
||||
{{ selectedWorklog.issue_key }}: {{ selectedWorklog.issue_summary }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">父任务</label>
|
||||
<p class="mt-1 text-sm text-gray-900">
|
||||
<a v-if="selectedWorklog.parent_task" :href="getJiraUrl(selectedWorklog.parent_task.key)" target="_blank" class="text-blue-600 hover:text-blue-800">
|
||||
{{ selectedWorklog.parent_task.key }} {{ selectedWorklog.parent_task.summary }}
|
||||
</a>
|
||||
<span v-else class="text-gray-400">无父任务</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">工时</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.hours }}h ({{ selectedWorklog.time_spent }})</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">开始时间</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.started }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">记录者</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.author.display_name }} ({{ selectedWorklog.author.name }})</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">创建时间</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.created }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">更新时间</label>
|
||||
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.updated }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">备注</label>
|
||||
<p class="mt-1 text-sm text-gray-900 bg-gray-50 p-3 rounded-md">
|
||||
{{ selectedWorklog.comment || '无备注' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'JiraWorklog',
|
||||
data() {
|
||||
return {
|
||||
workLogs: {
|
||||
username: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
activeQuickSelect: null, // 'lastWeek', 'yesterday', 'today'
|
||||
loading: false,
|
||||
data: null,
|
||||
error: ''
|
||||
},
|
||||
showDetailModal: false,
|
||||
selectedWorklog: null,
|
||||
sortField: 'date',
|
||||
sortDirection: 'desc'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedWorkLogs() {
|
||||
if (!this.workLogs.data || !this.workLogs.data.work_logs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const logs = [...this.workLogs.data.work_logs];
|
||||
|
||||
return logs.sort((a, b) => {
|
||||
let aValue = a[this.sortField];
|
||||
let bValue = b[this.sortField];
|
||||
|
||||
// 特殊处理父任务排序
|
||||
if (this.sortField === 'parent_task') {
|
||||
aValue = a.parent_task ? a.parent_task.key : '';
|
||||
bValue = b.parent_task ? b.parent_task.key : '';
|
||||
}
|
||||
|
||||
// 处理空值
|
||||
if (!aValue && !bValue) return 0;
|
||||
if (!aValue) return this.sortDirection === 'asc' ? -1 : 1;
|
||||
if (!bValue) return this.sortDirection === 'asc' ? 1 : -1;
|
||||
|
||||
// 数字类型排序
|
||||
if (this.sortField === 'hours') {
|
||||
aValue = parseFloat(aValue) || 0;
|
||||
bValue = parseFloat(bValue) || 0;
|
||||
}
|
||||
|
||||
// 字符串排序
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) {
|
||||
return this.sortDirection === 'asc' ? -1 : 1;
|
||||
}
|
||||
if (aValue > bValue) {
|
||||
return this.sortDirection === 'asc' ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
// 获取默认用户名
|
||||
await this.loadDefaultUser();
|
||||
|
||||
// 设置默认日期范围:本周一到今天
|
||||
this.setDefaultDateRange();
|
||||
},
|
||||
methods: {
|
||||
async loadDefaultUser() {
|
||||
try {
|
||||
const response = await fetch('/api/jira/config');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data.default_user) {
|
||||
this.workLogs.username = data.data.default_user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取默认用户失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultDateRange() {
|
||||
const today = new Date();
|
||||
const currentDay = today.getDay(); // 0 = 周日, 1 = 周一, ...
|
||||
const mondayOffset = currentDay === 0 ? -6 : 1 - currentDay; // 计算到周一的偏移
|
||||
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() + mondayOffset);
|
||||
|
||||
this.workLogs.startDate = monday.toISOString().split('T')[0];
|
||||
this.workLogs.endDate = today.toISOString().split('T')[0];
|
||||
},
|
||||
|
||||
setLastWeekDateRange() {
|
||||
const today = new Date();
|
||||
const currentDay = today.getDay();
|
||||
const lastMondayOffset = currentDay === 0 ? -13 : -6 - currentDay; // 上周一的偏移
|
||||
const lastSundayOffset = currentDay === 0 ? -7 : -currentDay; // 上周日的偏移
|
||||
|
||||
const lastMonday = new Date(today);
|
||||
lastMonday.setDate(today.getDate() + lastMondayOffset);
|
||||
|
||||
const lastSunday = new Date(today);
|
||||
lastSunday.setDate(today.getDate() + lastSundayOffset);
|
||||
|
||||
this.workLogs.startDate = lastMonday.toISOString().split('T')[0];
|
||||
this.workLogs.endDate = lastSunday.toISOString().split('T')[0];
|
||||
},
|
||||
|
||||
setYesterdayDateRange() {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const dateStr = yesterday.toISOString().split('T')[0];
|
||||
this.workLogs.startDate = dateStr;
|
||||
this.workLogs.endDate = dateStr;
|
||||
},
|
||||
|
||||
setTodayDateRange() {
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().split('T')[0];
|
||||
|
||||
this.workLogs.startDate = dateStr;
|
||||
this.workLogs.endDate = dateStr;
|
||||
},
|
||||
|
||||
setQuickDateRange(type) {
|
||||
this.workLogs.activeQuickSelect = type;
|
||||
|
||||
switch (type) {
|
||||
case 'lastWeek':
|
||||
this.setLastWeekDateRange();
|
||||
break;
|
||||
case 'yesterday':
|
||||
this.setYesterdayDateRange();
|
||||
break;
|
||||
case 'today':
|
||||
this.setTodayDateRange();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
clearQuickSelect() {
|
||||
this.workLogs.activeQuickSelect = null;
|
||||
this.setDefaultDateRange();
|
||||
},
|
||||
|
||||
sortBy(field) {
|
||||
if (this.sortField === field) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortField = field;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
},
|
||||
|
||||
async getWorkLogs() {
|
||||
if (!this.workLogs.username.trim()) {
|
||||
this.workLogs.error = '请输入用户名';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.workLogs.startDate || !this.workLogs.endDate) {
|
||||
this.workLogs.error = '请选择日期范围';
|
||||
return;
|
||||
}
|
||||
|
||||
this.workLogs.loading = true;
|
||||
this.workLogs.error = '';
|
||||
this.workLogs.data = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/jira/work-logs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: this.workLogs.username,
|
||||
start_date: this.workLogs.startDate,
|
||||
end_date: this.workLogs.endDate
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.workLogs.data = data.data;
|
||||
} else {
|
||||
this.workLogs.error = data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
this.workLogs.error = '网络请求失败: ' + error.message;
|
||||
} finally {
|
||||
this.workLogs.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
showWorklogDetail(worklog) {
|
||||
this.selectedWorklog = worklog;
|
||||
this.showDetailModal = true;
|
||||
},
|
||||
|
||||
closeDetailModal() {
|
||||
this.showDetailModal = false;
|
||||
this.selectedWorklog = null;
|
||||
},
|
||||
|
||||
getJiraUrl(issueKey) {
|
||||
// 从现有的issue_url中提取host部分,或者使用默认的JIRA host
|
||||
if (this.workLogs.data && this.workLogs.data.work_logs && this.workLogs.data.work_logs.length > 0) {
|
||||
const firstLog = this.workLogs.data.work_logs[0];
|
||||
const baseUrl = firstLog.issue_url.replace(/\/browse\/.*$/, '');
|
||||
return `${baseUrl}/browse/${issueKey}`;
|
||||
}
|
||||
// 如果没有数据,使用默认配置
|
||||
return `http://jira.eainc.com:8080/browse/${issueKey}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
169
resources/js/components/jira/WeeklyReport.vue
Normal file
169
resources/js/components/jira/WeeklyReport.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">生成周报</h1>
|
||||
<p class="text-gray-600 mt-2">生成上周的工作周报</p>
|
||||
</div>
|
||||
|
||||
<!-- 周报生成区域 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">生成上周周报</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-4 mb-4">
|
||||
<div class="flex-1 min-w-64">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="weeklyReport.username"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入 JIRA 用户名"
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="generateWeeklyReport"
|
||||
:disabled="weeklyReport.loading"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="weeklyReport.loading">生成中...</span>
|
||||
<span v-else>生成周报</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 周报结果 -->
|
||||
<div v-if="weeklyReport.result" class="mt-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-700">周报内容</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="copyMarkdown"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
复制 Markdown
|
||||
</button>
|
||||
<button
|
||||
@click="downloadWeeklyReport"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
|
||||
>
|
||||
下载 Markdown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-md">
|
||||
<pre class="whitespace-pre-wrap text-sm">{{ weeklyReport.result }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="weeklyReport.error" class="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p class="text-red-700">{{ weeklyReport.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'WeeklyReport',
|
||||
data() {
|
||||
return {
|
||||
weeklyReport: {
|
||||
username: '',
|
||||
loading: false,
|
||||
result: '',
|
||||
error: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
// 获取默认用户名
|
||||
await this.loadDefaultUser();
|
||||
},
|
||||
methods: {
|
||||
async loadDefaultUser() {
|
||||
try {
|
||||
const response = await fetch('/api/jira/config');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data.default_user) {
|
||||
this.weeklyReport.username = data.data.default_user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取默认用户失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async generateWeeklyReport() {
|
||||
if (!this.weeklyReport.username.trim()) {
|
||||
this.weeklyReport.error = '请输入用户名';
|
||||
return;
|
||||
}
|
||||
|
||||
this.weeklyReport.loading = true;
|
||||
this.weeklyReport.error = '';
|
||||
this.weeklyReport.result = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/jira/weekly-report', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: this.weeklyReport.username
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.weeklyReport.result = data.data.report;
|
||||
} else {
|
||||
this.weeklyReport.error = data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
this.weeklyReport.error = '网络请求失败: ' + error.message;
|
||||
} finally {
|
||||
this.weeklyReport.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async copyMarkdown() {
|
||||
if (!this.weeklyReport.result) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.weeklyReport.result);
|
||||
// 可以添加一个成功提示
|
||||
alert('Markdown 内容已复制到剪贴板');
|
||||
} catch (error) {
|
||||
// 如果浏览器不支持 clipboard API,使用传统方法
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = this.weeklyReport.result;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
alert('Markdown 内容已复制到剪贴板');
|
||||
}
|
||||
},
|
||||
|
||||
downloadWeeklyReport() {
|
||||
if (!this.weeklyReport.username.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
username: this.weeklyReport.username
|
||||
});
|
||||
|
||||
window.open(`/api/jira/weekly-report/download?${params}`, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
373
resources/js/components/message-sync/EventConsumerSync.vue
Normal file
373
resources/js/components/message-sync/EventConsumerSync.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">事件消费者同步对比</h1>
|
||||
<p class="text-gray-600 mt-2">对比CRM和Agent的事件消费者消息,找出缺失的消息</p>
|
||||
</div>
|
||||
|
||||
<!-- 查询条件 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">查询条件</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">开始时间</label>
|
||||
<input
|
||||
v-model="startTime"
|
||||
type="datetime-local"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">结束时间</label>
|
||||
<input
|
||||
v-model="endTime"
|
||||
type="datetime-local"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">消息名称 (可选)</label>
|
||||
<input
|
||||
v-model="messageName"
|
||||
type="text"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="输入要查询的消息名称,留空则查询所有"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">排除的消息名称 (每行一个)</label>
|
||||
<textarea
|
||||
v-model="excludeMessagesText"
|
||||
rows="4"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="输入要排除的消息名称,每行一个"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<button
|
||||
@click="compareSync"
|
||||
:disabled="loading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
对比同步状态
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="exportMissing"
|
||||
:disabled="loading || !compareResult"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
导出缺失消息
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<svg class="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-red-800">错误</h3>
|
||||
<p class="text-sm text-red-700 mt-1">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对比结果 -->
|
||||
<div v-if="compareResult" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">对比结果</h2>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-blue-50 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ compareResult.crm_total }}</div>
|
||||
<div class="text-sm text-blue-600">CRM消息总数</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-green-600">{{ compareResult.agent_total }}</div>
|
||||
<div class="text-sm text-green-600">Agent消息总数</div>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-red-600">{{ compareResult.missing_count }}</div>
|
||||
<div class="text-sm text-red-600">缺失消息数</div>
|
||||
</div>
|
||||
<div class="bg-purple-50 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-purple-600">{{ compareResult.sync_rate }}%</div>
|
||||
<div class="text-sm text-purple-600">同步率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按Topic统计缺失消息 -->
|
||||
<div v-if="compareResult.missing_by_topic && compareResult.missing_by_topic.length > 0" class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-4">按Topic统计缺失消息</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Topic名称</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">缺失数量</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="item in compareResult.missing_by_topic" :key="item.topic">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ item.topic }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
|
||||
{{ item.count }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ ((item.count / compareResult.missing_count) * 100).toFixed(2) }}%
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 缺失消息列表 -->
|
||||
<div v-if="compareResult.missing_messages.length > 0">
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-4">缺失的消息详情</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息名称</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息体</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="msg in compareResult.missing_messages" :key="msg.msg_id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ msg.msg_id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ msg.event_name }}</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<button
|
||||
@click="showDetail(msg)"
|
||||
class="text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDateTime(msg.created) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p class="text-green-700">所有消息都已同步到Agent!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息详情弹窗 -->
|
||||
<div v-if="showDetailModal" class="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-96 overflow-y-auto">
|
||||
<div class="sticky top-0 bg-gray-50 border-b border-gray-200 px-6 py-4 flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900">消息详情</h3>
|
||||
<button
|
||||
@click="showDetailModal = false"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">消息ID</label>
|
||||
<p class="text-sm text-gray-900 font-mono break-all">{{ selectedMessage?.msg_id }}</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">消息名称</label>
|
||||
<p class="text-sm text-gray-900">{{ selectedMessage?.event_name }}</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">消息体</label>
|
||||
<div class="bg-gray-50 rounded p-3 max-h-48 overflow-y-auto">
|
||||
<pre class="text-sm text-gray-900 whitespace-pre-wrap break-words">{{ selectedMessage?.msg_body }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">创建时间</label>
|
||||
<p class="text-sm text-gray-900">{{ formatDateTime(selectedMessage?.created) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">更新时间</label>
|
||||
<p class="text-sm text-gray-900">{{ formatDateTime(selectedMessage?.updated) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'EventConsumerSync',
|
||||
data() {
|
||||
return {
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
messageName: '',
|
||||
excludeMessagesText: '',
|
||||
compareResult: null,
|
||||
loading: false,
|
||||
error: '',
|
||||
showDetailModal: false,
|
||||
selectedMessage: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
excludeMessages() {
|
||||
return this.excludeMessagesText
|
||||
.split('\n')
|
||||
.map(msg => msg.trim())
|
||||
.filter(msg => msg.length > 0);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async compareSync() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
exclude_messages: this.excludeMessages
|
||||
};
|
||||
|
||||
if (this.startTime) {
|
||||
payload.start_time = this.formatDateTimeForAPI(this.startTime);
|
||||
}
|
||||
|
||||
if (this.endTime) {
|
||||
payload.end_time = this.formatDateTimeForAPI(this.endTime);
|
||||
}
|
||||
|
||||
if (this.messageName.trim()) {
|
||||
payload.message_name = this.messageName.trim();
|
||||
}
|
||||
|
||||
const response = await fetch('/api/message-sync/compare-event-consumer', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.compareResult = data.data;
|
||||
} else {
|
||||
this.error = data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = '网络请求失败: ' + error.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
exportMissing() {
|
||||
if (!this.compareResult || this.compareResult.missing_messages.length === 0) {
|
||||
alert('没有缺失的消息可以导出');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 准备导出数据
|
||||
const exportData = {
|
||||
export_time: new Date().toLocaleString(),
|
||||
query_conditions: {
|
||||
start_time: this.startTime || '未设置',
|
||||
end_time: this.endTime || '未设置',
|
||||
message_name: this.messageName || '未设置',
|
||||
exclude_messages: this.excludeMessages.length > 0 ? this.excludeMessages : []
|
||||
},
|
||||
summary: {
|
||||
crm_total: this.compareResult.crm_total,
|
||||
agent_total: this.compareResult.agent_total,
|
||||
missing_count: this.compareResult.missing_count,
|
||||
sync_rate: this.compareResult.sync_rate + '%'
|
||||
},
|
||||
missing_messages: this.compareResult.missing_messages.map(msg => ({
|
||||
msg_id: msg.msg_id,
|
||||
event_name: msg.event_name,
|
||||
msg_body: msg.msg_body,
|
||||
created: msg.created,
|
||||
updated: msg.updated
|
||||
}))
|
||||
};
|
||||
|
||||
// 生成JSON文件
|
||||
const jsonString = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// 生成文件名
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, -5);
|
||||
const filename = `missing_messages_${timestamp}.json`;
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
alert(`成功导出 ${this.compareResult.missing_messages.length} 条缺失消息`);
|
||||
} catch (error) {
|
||||
this.error = '导出失败: ' + error.message;
|
||||
}
|
||||
},
|
||||
|
||||
showDetail(message) {
|
||||
this.selectedMessage = message;
|
||||
this.showDetailModal = true;
|
||||
},
|
||||
|
||||
formatDateTime(dateString) {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleString();
|
||||
},
|
||||
|
||||
formatDateTimeForAPI(dateTimeLocal) {
|
||||
if (!dateTimeLocal) return null;
|
||||
const date = new Date(dateTimeLocal);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
746
resources/js/components/message-sync/MessageDispatch.vue
Normal file
746
resources/js/components/message-sync/MessageDispatch.vue
Normal file
@@ -0,0 +1,746 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">消息分发异常查询</h1>
|
||||
<p class="text-gray-600 mt-2">查询和管理异常的消息分发数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">筛选条件</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">消息ID</label>
|
||||
<textarea
|
||||
v-model="filters.msgId"
|
||||
rows="3"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入消息ID,支持多个(用空格、换行或逗号分隔)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">国家代码</label>
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="showCountryDropdown = !showCountryDropdown"
|
||||
type="button"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-left focus:ring-2 focus:ring-blue-500 bg-white flex items-center justify-between"
|
||||
>
|
||||
<span class="text-sm text-gray-700">
|
||||
{{ filters.countryCodes.length > 0 ? `已选 ${filters.countryCodes.length} 个` : '请选择国家代码' }}
|
||||
</span>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showCountryDropdown"
|
||||
class="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded-lg shadow-lg max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div class="sticky top-0 bg-gray-50 border-b border-gray-200 p-2 flex gap-2">
|
||||
<button
|
||||
@click="selectAllCountries"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
@click="clearAllCountries"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<label class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
value=""
|
||||
v-model="filters.countryCodes"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-500 italic">(空)</span>
|
||||
</label>
|
||||
<label
|
||||
v-for="code in availableCountryCodes"
|
||||
:key="code"
|
||||
class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="code"
|
||||
v-model="filters.countryCodes"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700">{{ code }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">域名</label>
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="showDomainDropdown = !showDomainDropdown"
|
||||
type="button"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-left focus:ring-2 focus:ring-blue-500 bg-white flex items-center justify-between"
|
||||
>
|
||||
<span class="text-sm text-gray-700">
|
||||
{{ filters.domains.length > 0 ? `已选 ${filters.domains.length} 个` : '请选择域名' }}
|
||||
</span>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showDomainDropdown"
|
||||
class="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded-lg shadow-lg max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div class="sticky top-0 bg-gray-50 border-b border-gray-200 p-2 flex gap-2">
|
||||
<button
|
||||
@click="selectAllDomains"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
@click="clearAllDomains"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<label class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
value=""
|
||||
v-model="filters.domains"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-500 italic">(空)</span>
|
||||
</label>
|
||||
<label
|
||||
v-for="domain in availableDomains"
|
||||
:key="domain"
|
||||
class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="domain"
|
||||
v-model="filters.domains"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700">{{ domain }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">请求状态</label>
|
||||
<select
|
||||
v-model="filters.requestStatus"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="null">全部</option>
|
||||
<option :value="0">待处理</option>
|
||||
<option :value="1">成功</option>
|
||||
<option :value="2">失败</option>
|
||||
<option :value="3">重试中</option>
|
||||
<option :value="4">超时</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">回调状态</label>
|
||||
<select
|
||||
v-model="filters.businessStatus"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="null">全部</option>
|
||||
<option :value="0">待处理</option>
|
||||
<option :value="1">成功</option>
|
||||
<option :value="2">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="quickFilterCN"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
快速筛选 CN
|
||||
</button>
|
||||
<button
|
||||
@click="quickFilterUS"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
快速筛选 US
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="loadData"
|
||||
:disabled="loading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<svg class="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-red-800">错误</h3>
|
||||
<p class="text-sm text-red-700 mt-1">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div v-if="dispatches.length > 0" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-700">异常数据列表 ({{ dispatches.length }})</h2>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="copySelectedMsgIds"
|
||||
:disabled="selectedIds.length === 0"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
复制消息ID ({{ selectedIds.length }})
|
||||
</button>
|
||||
<button
|
||||
@click="showBatchUpdateModal"
|
||||
:disabled="selectedIds.length === 0"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
批量更新 ({{ selectedIds.length }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200" style="min-width: 1600px;">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-3 text-left sticky left-0 bg-gray-50 z-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
@change="toggleSelectAll"
|
||||
:checked="selectedIds.length === dispatches.length"
|
||||
class="rounded"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">消息ID</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">国家代码</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">域名</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">事件名称</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">实体代码</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">请求状态</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">回调状态</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">Agent状态</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">重试次数</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">请求错误</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">回调错误</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">创建时间</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">更新时间</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="item in dispatches" :key="item.id" class="hover:bg-gray-50">
|
||||
<td class="px-3 py-3 sticky left-0 bg-white z-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="item.id"
|
||||
v-model="selectedIds"
|
||||
class="rounded"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.msg_id }}</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.country_code || '-' }}</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.domain || '-' }}</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.event_name }}</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.entity_code }}</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap">
|
||||
<span :class="getStatusClass(item.request_status)">
|
||||
{{ getStatusText(item.request_status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap">
|
||||
<span :class="getBusinessStatusClass(item.business_status)">
|
||||
{{ getBusinessStatusText(item.business_status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap">
|
||||
<span v-if="!isUsDomain(item.domain)" :class="getConsumerStatusClass(item.consumer_status)">
|
||||
{{ getConsumerStatusText(item.consumer_status) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.retry_count }}</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-900 max-w-xs truncate" :title="item.request_error_message">
|
||||
{{ item.request_error_message || '-' }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-900 max-w-xs truncate" :title="item.business_error_message">
|
||||
{{ item.business_error_message || '-' }}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.created || '-' }}</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.updated || '-' }}</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap">
|
||||
<button
|
||||
@click="showDetail(item)"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<div v-if="detailItem" class="fixed inset-0 flex items-center justify-center z-50" @click.self="detailItem = null">
|
||||
<div class="bg-white rounded-lg p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto border border-gray-200 shadow-xl">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-semibold">消息详情</h3>
|
||||
<button @click="detailItem = null" class="text-gray-500 hover:text-gray-700">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">消息ID</label>
|
||||
<p class="text-sm text-gray-900">{{ detailItem.msg_id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">服务名称</label>
|
||||
<p class="text-sm text-gray-900">{{ detailItem.service_name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">请求错误信息</label>
|
||||
<p class="text-sm text-red-600">{{ detailItem.request_error_message || '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">业务错误信息</label>
|
||||
<p class="text-sm text-red-600">{{ detailItem.business_error_message || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-2 block">消息体</label>
|
||||
<pre class="bg-gray-50 p-4 rounded-lg text-xs overflow-x-auto">{{ formatJson(detailItem.msg_body) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量更新弹窗 -->
|
||||
<div v-if="showBatchUpdate" class="fixed inset-0 flex items-center justify-center z-50" @click.self="showBatchUpdate = false">
|
||||
<div class="bg-white rounded-lg p-6 max-w-2xl w-full border border-gray-200 shadow-xl">
|
||||
<h3 class="text-xl font-semibold mb-4">批量更新 ({{ selectedIds.length }} 条)</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">请求状态</label>
|
||||
<select v-model="batchUpdate.request_status" class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
<option :value="null">不修改</option>
|
||||
<option :value="0">待处理</option>
|
||||
<option :value="1">成功</option>
|
||||
<option :value="2">失败</option>
|
||||
<option :value="3">重试中</option>
|
||||
<option :value="4">超时</option>
|
||||
<option :value="5">已取消</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">回调状态</label>
|
||||
<select v-model="batchUpdate.business_status" class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
<option :value="null">不修改</option>
|
||||
<option :value="0">待处理</option>
|
||||
<option :value="1">成功</option>
|
||||
<option :value="2">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">重试次数</label>
|
||||
<input v-model.number="batchUpdate.retry_count" type="number" min="0" class="w-full border border-gray-300 rounded-lg px-3 py-2" placeholder="不修改" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">目标服务</label>
|
||||
<select v-model="batchUpdate.target_service" class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
<option :value="null">不修改</option>
|
||||
<option v-for="route in serviceRoutes" :key="route.id" :value="route.id">
|
||||
{{ route.display_name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-4 mt-6">
|
||||
<button @click="showBatchUpdate = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
取消
|
||||
</button>
|
||||
<button @click="executeBatchUpdate" :disabled="updating" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
确认更新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MessageDispatch',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
updating: false,
|
||||
error: null,
|
||||
availableCountryCodes: [],
|
||||
availableDomains: [],
|
||||
serviceRoutes: [],
|
||||
showCountryDropdown: false,
|
||||
showDomainDropdown: false,
|
||||
filters: {
|
||||
msgId: null,
|
||||
countryCodes: [],
|
||||
domains: [],
|
||||
requestStatus: null,
|
||||
businessStatus: null,
|
||||
},
|
||||
dispatches: [],
|
||||
selectedIds: [],
|
||||
detailItem: null,
|
||||
showBatchUpdate: false,
|
||||
batchUpdate: {
|
||||
request_status: null,
|
||||
business_status: null,
|
||||
retry_count: null,
|
||||
target_service: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadCountryCodes();
|
||||
this.loadDomains();
|
||||
this.loadServiceRoutes();
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
},
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
},
|
||||
methods: {
|
||||
async loadCountryCodes() {
|
||||
try {
|
||||
const response = await axios.get('/api/message-dispatch/country-codes');
|
||||
this.availableCountryCodes = response.data.data;
|
||||
} catch (err) {
|
||||
console.error('加载国家代码列表失败:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async loadDomains() {
|
||||
try {
|
||||
const response = await axios.get('/api/message-dispatch/domains');
|
||||
this.availableDomains = response.data.data;
|
||||
} catch (err) {
|
||||
console.error('加载域名列表失败:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async loadServiceRoutes() {
|
||||
try {
|
||||
const response = await axios.get('/api/message-dispatch/service-routes');
|
||||
this.serviceRoutes = response.data.data;
|
||||
} catch (err) {
|
||||
console.error('加载服务路由列表失败:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const params = {};
|
||||
|
||||
// 处理消息ID:按空格、换行、逗号分割
|
||||
if (this.filters.msgId) {
|
||||
const msgIds = this.filters.msgId
|
||||
.split(/[\s,]+/)
|
||||
.map(id => id.trim())
|
||||
.filter(id => id.length > 0);
|
||||
|
||||
if (msgIds.length > 0) {
|
||||
params.msg_ids = msgIds;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.filters.countryCodes.length > 0) params.country_codes = this.filters.countryCodes;
|
||||
if (this.filters.domains.length > 0) params.domains = this.filters.domains;
|
||||
if (this.filters.requestStatus !== null) params.request_status = this.filters.requestStatus;
|
||||
if (this.filters.businessStatus !== null) params.business_status = this.filters.businessStatus;
|
||||
|
||||
const response = await axios.get('/api/message-dispatch/abnormal', { params });
|
||||
this.dispatches = response.data.data;
|
||||
this.selectedIds = [];
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || err.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
selectAllCountries() {
|
||||
this.filters.countryCodes = ['', ...this.availableCountryCodes];
|
||||
},
|
||||
|
||||
clearAllCountries() {
|
||||
this.filters.countryCodes = [];
|
||||
},
|
||||
|
||||
selectAllDomains() {
|
||||
this.filters.domains = ['', ...this.availableDomains];
|
||||
},
|
||||
|
||||
clearAllDomains() {
|
||||
this.filters.domains = [];
|
||||
},
|
||||
|
||||
quickFilterCN() {
|
||||
// CN筛选:选择空和cnsha域名
|
||||
const cnDomains = this.availableDomains.filter(domain =>
|
||||
domain && domain.includes('cnsha')
|
||||
);
|
||||
this.filters.domains = ['', ...cnDomains];
|
||||
this.loadData();
|
||||
},
|
||||
|
||||
quickFilterUS() {
|
||||
// US筛选:选择us域名
|
||||
const usDomains = this.availableDomains.filter(domain =>
|
||||
domain && (domain.includes('partner-us') || domain.includes('.us.'))
|
||||
);
|
||||
this.filters.domains = usDomains;
|
||||
this.loadData();
|
||||
},
|
||||
|
||||
handleClickOutside(event) {
|
||||
const dropdown = event.target.closest('.relative');
|
||||
if (!dropdown || !dropdown.querySelector('button')) {
|
||||
this.showCountryDropdown = false;
|
||||
this.showDomainDropdown = false;
|
||||
}
|
||||
},
|
||||
|
||||
toggleSelectAll(event) {
|
||||
if (event.target.checked) {
|
||||
this.selectedIds = this.dispatches.map(item => item.id);
|
||||
} else {
|
||||
this.selectedIds = [];
|
||||
}
|
||||
},
|
||||
|
||||
copySelectedMsgIds() {
|
||||
const selectedItems = this.dispatches.filter(item => this.selectedIds.includes(item.id));
|
||||
const msgIds = selectedItems.map(item => `'${item.msg_id}'`).join(',');
|
||||
|
||||
// 使用降级方案,兼容HTTP环境
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = msgIds;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (successful) {
|
||||
this.showToast(`已复制 ${selectedItems.length} 个消息ID到剪贴板`, 'success');
|
||||
} else {
|
||||
this.showToast('复制失败,请重试', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
document.body.removeChild(textArea);
|
||||
console.error('复制失败:', err);
|
||||
this.showToast('复制失败,请重试', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
showToast(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 ${
|
||||
type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(toast);
|
||||
}, 300);
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
showDetail(item) {
|
||||
this.detailItem = item;
|
||||
},
|
||||
|
||||
showBatchUpdateModal() {
|
||||
this.showBatchUpdate = true;
|
||||
this.batchUpdate = {
|
||||
request_status: null,
|
||||
business_status: null,
|
||||
retry_count: null,
|
||||
target_service: null,
|
||||
};
|
||||
},
|
||||
|
||||
async executeBatchUpdate() {
|
||||
this.updating = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const selectedRoute = this.batchUpdate.target_service !== null
|
||||
? this.serviceRoutes.find(route => String(route.id) === String(this.batchUpdate.target_service))
|
||||
: null;
|
||||
|
||||
const updates = this.selectedIds.map(id => {
|
||||
const update = { id: String(id) };
|
||||
if (this.batchUpdate.request_status !== null) update.request_status = String(this.batchUpdate.request_status);
|
||||
if (this.batchUpdate.business_status !== null) update.business_status = String(this.batchUpdate.business_status);
|
||||
if (this.batchUpdate.retry_count !== null) update.retry_count = String(this.batchUpdate.retry_count);
|
||||
if (this.batchUpdate.target_service !== null) {
|
||||
update.target_service = String(this.batchUpdate.target_service);
|
||||
if (selectedRoute && selectedRoute.country_code) {
|
||||
update.country_code = selectedRoute.country_code;
|
||||
}
|
||||
}
|
||||
return update;
|
||||
});
|
||||
|
||||
const response = await axios.post('/api/message-dispatch/batch-update', { updates });
|
||||
|
||||
alert(`更新完成!成功: ${response.data.data.summary.success}, 失败: ${response.data.data.summary.failure}`);
|
||||
|
||||
this.showBatchUpdate = false;
|
||||
this.loadData();
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || err.message;
|
||||
} finally {
|
||||
this.updating = false;
|
||||
}
|
||||
},
|
||||
|
||||
isUsDomain(domain) {
|
||||
return domain === 'partner-us.eainc.com';
|
||||
},
|
||||
|
||||
formatJson(json) {
|
||||
try {
|
||||
const obj = typeof json === 'string' ? JSON.parse(json) : json;
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch {
|
||||
return json;
|
||||
}
|
||||
},
|
||||
|
||||
getStatusText(status) {
|
||||
const map = { 0: '待处理', 1: '成功', 2: '失败', 3: '重试中', 4: '超时', 5: '已取消' };
|
||||
return map[status] || '未知';
|
||||
},
|
||||
|
||||
getStatusClass(status) {
|
||||
const map = {
|
||||
0: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
|
||||
1: 'px-2 py-1 text-xs rounded-full bg-green-100 text-green-800',
|
||||
2: 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800',
|
||||
3: 'px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800',
|
||||
4: 'px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-800',
|
||||
5: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
|
||||
};
|
||||
return map[status] || 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800';
|
||||
},
|
||||
|
||||
getBusinessStatusText(status) {
|
||||
const map = { 0: '待处理', 1: '成功', 2: '失败' };
|
||||
return map[status] || '未知';
|
||||
},
|
||||
|
||||
getBusinessStatusClass(status) {
|
||||
const map = {
|
||||
0: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
|
||||
1: 'px-2 py-1 text-xs rounded-full bg-green-100 text-green-800',
|
||||
2: 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800',
|
||||
};
|
||||
return map[status] || 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800';
|
||||
},
|
||||
|
||||
getConsumerStatusText(status) {
|
||||
const map = {
|
||||
0: '待处理',
|
||||
1: '处理成功',
|
||||
2: '处理中',
|
||||
3: '处理失败',
|
||||
4: '等待重试',
|
||||
5: '处理忽略',
|
||||
};
|
||||
return map[status] ?? '未知';
|
||||
},
|
||||
|
||||
getConsumerStatusClass(status) {
|
||||
const map = {
|
||||
0: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
|
||||
1: 'px-2 py-1 text-xs rounded-full bg-green-100 text-green-800',
|
||||
2: 'px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800',
|
||||
3: 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800',
|
||||
4: 'px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800',
|
||||
5: 'px-2 py-1 text-xs rounded-full bg-purple-100 text-purple-800',
|
||||
};
|
||||
return map[status] ?? 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
358
resources/js/components/message-sync/MessageSync.vue
Normal file
358
resources/js/components/message-sync/MessageSync.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">消息同步</h1>
|
||||
<p class="text-gray-600 mt-2">批量输入消息ID,从crmslave数据库查询并同步到agent服务</p>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">消息ID输入</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
消息ID列表 (每行一个ID)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="messageIdsText"
|
||||
rows="8"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="请输入消息ID,每行一个 例如: af7e5ca7-2779-0e9e-93d1-68c79ceffd9033 bf8f6db8-3880-1f0f-a4e2-79d8adf00144"
|
||||
></textarea>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
共 {{ messageIdsList.length }} 个消息ID
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<button
|
||||
@click="queryMessages"
|
||||
:disabled="loading.query || messageIdsList.length === 0"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<svg v-if="loading.query" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
查询消息
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="syncMessages"
|
||||
:disabled="loading.sync || !queryResults || messageIdsList.length === 0"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<svg v-if="loading.sync" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
执行同步
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="testConnection"
|
||||
:disabled="loading.test"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
<svg v-if="loading.test" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
测试连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex">
|
||||
<svg class="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-red-800">错误</h3>
|
||||
<p class="text-sm text-red-700 mt-1">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查询结果 -->
|
||||
<div v-if="queryResults" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">查询结果</h2>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-blue-50 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ queryResults.stats.total_requested }}</div>
|
||||
<div class="text-sm text-blue-600">请求总数</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-green-600">{{ queryResults.stats.total_found }}</div>
|
||||
<div class="text-sm text-green-600">找到记录</div>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-red-600">{{ queryResults.stats.total_missing }}</div>
|
||||
<div class="text-sm text-red-600">缺失记录</div>
|
||||
</div>
|
||||
<div class="bg-purple-50 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-purple-600">{{ Object.keys(queryResults.stats.event_types).length }}</div>
|
||||
<div class="text-sm text-purple-600">事件类型</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">事件类型</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">跟踪ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间戳</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="message in queryResults.messages" :key="message.msg_id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ message.msg_id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ message.event_type }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-500">{{ message.trace_id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatTimestamp(message.timestamp) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<button
|
||||
@click="showMessageDetail(message)"
|
||||
class="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 同步结果 -->
|
||||
<div v-if="syncResults" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">同步结果</h2>
|
||||
|
||||
<!-- 同步统计 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-blue-50 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ syncResults.summary.total }}</div>
|
||||
<div class="text-sm text-blue-600">总计</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-green-600">{{ syncResults.summary.success }}</div>
|
||||
<div class="text-sm text-green-600">成功</div>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-red-600">{{ syncResults.summary.failure }}</div>
|
||||
<div class="text-sm text-red-600">失败</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 同步结果列表 -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">响应</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="result in syncResults.results" :key="result.msg_id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ result.msg_id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span v-if="result.success" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
成功
|
||||
</span>
|
||||
<span v-else class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
失败
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">
|
||||
{{ result.success ? '同步成功' : result.error }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<button
|
||||
@click="showSyncDetail(result)"
|
||||
class="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情模态框 -->
|
||||
<div v-if="showDetailModal" class="fixed inset-0 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">详细信息</h3>
|
||||
<button @click="closeDetailModal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<pre class="bg-gray-100 p-4 rounded-lg text-sm overflow-x-auto">{{ JSON.stringify(selectedDetail, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MessageSync',
|
||||
data() {
|
||||
return {
|
||||
messageIdsText: '',
|
||||
queryResults: null,
|
||||
syncResults: null,
|
||||
loading: {
|
||||
query: false,
|
||||
sync: false,
|
||||
test: false
|
||||
},
|
||||
error: '',
|
||||
showDetailModal: false,
|
||||
selectedDetail: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
messageIdsList() {
|
||||
return this.messageIdsText
|
||||
.split('\n')
|
||||
.map(id => id.trim())
|
||||
.filter(id => id.length > 0);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async queryMessages() {
|
||||
if (this.messageIdsList.length === 0) {
|
||||
this.error = '请输入至少一个消息ID';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.query = true;
|
||||
this.error = '';
|
||||
this.queryResults = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/message-sync/query', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message_ids: this.messageIdsList
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.queryResults = data.data;
|
||||
} else {
|
||||
this.error = data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = '网络请求失败: ' + error.message;
|
||||
} finally {
|
||||
this.loading.query = false;
|
||||
}
|
||||
},
|
||||
|
||||
async syncMessages() {
|
||||
if (this.messageIdsList.length === 0) {
|
||||
this.error = '请输入至少一个消息ID';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.sync = true;
|
||||
this.error = '';
|
||||
this.syncResults = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/message-sync/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message_ids: this.messageIdsList
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.syncResults = data.data;
|
||||
} else {
|
||||
this.error = data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = '网络请求失败: ' + error.message;
|
||||
} finally {
|
||||
this.loading.sync = false;
|
||||
}
|
||||
},
|
||||
|
||||
async testConnection() {
|
||||
this.loading.test = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/message-sync/test-connection');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('数据库连接测试成功!');
|
||||
} else {
|
||||
this.error = data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = '网络请求失败: ' + error.message;
|
||||
} finally {
|
||||
this.loading.test = false;
|
||||
}
|
||||
},
|
||||
|
||||
showMessageDetail(message) {
|
||||
this.selectedDetail = message;
|
||||
this.showDetailModal = true;
|
||||
},
|
||||
|
||||
showSyncDetail(result) {
|
||||
this.selectedDetail = result;
|
||||
this.showDetailModal = true;
|
||||
},
|
||||
|
||||
closeDetailModal() {
|
||||
this.showDetailModal = false;
|
||||
this.selectedDetail = null;
|
||||
},
|
||||
|
||||
formatTimestamp(timestamp) {
|
||||
if (!timestamp) return '-';
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
44
resources/js/lang-env.js
Normal file
44
resources/js/lang-env.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { StreamLanguage } from '@codemirror/language'
|
||||
|
||||
// 简单的.env语法高亮
|
||||
const envLanguage = StreamLanguage.define({
|
||||
token(stream) {
|
||||
// 注释
|
||||
if (stream.match(/^\s*#.*/)) {
|
||||
return 'comment'
|
||||
}
|
||||
|
||||
// 变量名
|
||||
if (stream.match(/^[A-Z_][A-Z0-9_]*/)) {
|
||||
return 'variableName'
|
||||
}
|
||||
|
||||
// 等号
|
||||
if (stream.match(/=/)) {
|
||||
return 'operator'
|
||||
}
|
||||
|
||||
// 字符串
|
||||
if (stream.match(/^"[^"]*"/) || stream.match(/^'[^']*'/)) {
|
||||
return 'string'
|
||||
}
|
||||
|
||||
// 布尔值
|
||||
if (stream.match(/^(true|false)\b/i)) {
|
||||
return 'keyword'
|
||||
}
|
||||
|
||||
// 数字
|
||||
if (stream.match(/^\d+(\.\d+)?/)) {
|
||||
return 'number'
|
||||
}
|
||||
|
||||
// 跳过其他字符
|
||||
stream.next()
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
export function env() {
|
||||
return envLanguage
|
||||
}
|
||||
9
resources/views/admin/index.blade.php
Normal file
9
resources/views/admin/index.blade.php
Normal file
@@ -0,0 +1,9 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Tradewind Toolbox')
|
||||
|
||||
@section('content')
|
||||
<div id="app">
|
||||
<admin-dashboard></admin-dashboard>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -4,12 +4,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>环境文件管理系统</title>
|
||||
<title>@yield('title', 'Tradewind Toolbox')</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<env-manager></env-manager>
|
||||
</div>
|
||||
@yield('content')
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
50
routes/api.php
Normal file
50
routes/api.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\EnvController;
|
||||
use App\Http\Controllers\JiraController;
|
||||
use App\Http\Controllers\MessageSyncController;
|
||||
use App\Http\Controllers\MessageDispatchController;
|
||||
|
||||
// 环境管理API路由
|
||||
Route::prefix('env')->group(function () {
|
||||
Route::get('/projects', [EnvController::class, 'getProjects']);
|
||||
Route::get('/projects/{project}/envs', [EnvController::class, 'getEnvs']);
|
||||
Route::get('/projects/{project}/envs/{env}', [EnvController::class, 'getEnvContent']);
|
||||
Route::get('/projects/{project}/current-env', [EnvController::class, 'getCurrentEnv']);
|
||||
|
||||
Route::post('/envs/save', [EnvController::class, 'saveEnv']);
|
||||
Route::post('/envs/apply', [EnvController::class, 'applyEnv']);
|
||||
Route::post('/envs/import', [EnvController::class, 'importEnv']);
|
||||
Route::post('/envs/create', [EnvController::class, 'createEnv']);
|
||||
|
||||
Route::delete('/projects/{project}/envs/{env}', [EnvController::class, 'deleteEnv']);
|
||||
});
|
||||
|
||||
// JIRA API路由
|
||||
Route::prefix('jira')->group(function () {
|
||||
Route::get('/config', [JiraController::class, 'getConfig']);
|
||||
Route::post('/weekly-report', [JiraController::class, 'generateWeeklyReport']);
|
||||
Route::post('/work-logs', [JiraController::class, 'getWorkLogs']);
|
||||
Route::get('/weekly-report/download', [JiraController::class, 'downloadWeeklyReport']);
|
||||
});
|
||||
|
||||
// 消息同步API路由
|
||||
Route::prefix('message-sync')->group(function () {
|
||||
Route::post('/query', [MessageSyncController::class, 'queryMessages']);
|
||||
Route::post('/sync', [MessageSyncController::class, 'syncMessages']);
|
||||
Route::get('/config', [MessageSyncController::class, 'getAgentConfig']);
|
||||
Route::get('/test-connection', [MessageSyncController::class, 'testConnection']);
|
||||
Route::post('/compare-event-consumer', [MessageSyncController::class, 'compareEventConsumerSync']);
|
||||
Route::post('/export-missing-messages', [MessageSyncController::class, 'exportMissingMessages']);
|
||||
});
|
||||
|
||||
// 消息分发API路由
|
||||
Route::prefix('message-dispatch')->group(function () {
|
||||
Route::get('/services', [MessageDispatchController::class, 'getAvailableServices']);
|
||||
Route::get('/country-codes', [MessageDispatchController::class, 'getAvailableCountryCodes']);
|
||||
Route::get('/domains', [MessageDispatchController::class, 'getAvailableDomains']);
|
||||
Route::get('/service-routes', [MessageDispatchController::class, 'getServiceRoutes']);
|
||||
Route::get('/abnormal', [MessageDispatchController::class, 'getAbnormalDispatches']);
|
||||
Route::post('/batch-update', [MessageDispatchController::class, 'batchUpdateDispatch']);
|
||||
});
|
||||
@@ -1,27 +1,17 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\EnvController;
|
||||
use App\Http\Controllers\AdminController;
|
||||
|
||||
Route::get('/', [EnvController::class, 'index']);
|
||||
// 首页 - 显示admin框架
|
||||
Route::get('/', [AdminController::class, 'index'])->name('home');
|
||||
|
||||
// 环境管理路由
|
||||
Route::prefix('env')->group(function () {
|
||||
// 页面路由
|
||||
Route::get('/', [EnvController::class, 'index'])->name('env.index');
|
||||
|
||||
// API路由
|
||||
Route::prefix('api')->group(function () {
|
||||
Route::get('/projects', [EnvController::class, 'getProjects']);
|
||||
Route::get('/projects/{project}/envs', [EnvController::class, 'getEnvs']);
|
||||
Route::get('/projects/{project}/envs/{env}', [EnvController::class, 'getEnvContent']);
|
||||
Route::get('/projects/{project}/current-env', [EnvController::class, 'getCurrentEnv']);
|
||||
|
||||
Route::post('/envs/save', [EnvController::class, 'saveEnv']);
|
||||
Route::post('/envs/apply', [EnvController::class, 'applyEnv']);
|
||||
Route::post('/envs/import', [EnvController::class, 'importEnv']);
|
||||
Route::post('/envs/create', [EnvController::class, 'createEnv']);
|
||||
|
||||
Route::delete('/projects/{project}/envs/{env}', [EnvController::class, 'deleteEnv']);
|
||||
});
|
||||
});
|
||||
// 前端路由 - 所有页面都通过admin框架显示
|
||||
Route::get('/env', [AdminController::class, 'index'])->name('admin.env');
|
||||
Route::get('/weekly-report', [AdminController::class, 'index'])->name('admin.weekly-report');
|
||||
Route::get('/worklog', [AdminController::class, 'index'])->name('admin.worklog');
|
||||
Route::get('/message-sync', [AdminController::class, 'index'])->name('admin.message-sync');
|
||||
Route::get('/event-consumer-sync', [AdminController::class, 'index'])->name('admin.event-consumer-sync');
|
||||
Route::get('/message-dispatch', [AdminController::class, 'index'])->name('admin.message-dispatch');
|
||||
Route::get('/settings', [AdminController::class, 'index'])->name('admin.settings');
|
||||
Route::get('/logs', [AdminController::class, 'index'])->name('admin.logs');
|
||||
|
||||
36
storage/env/test-syntax/development.env
vendored
Normal file
36
storage/env/test-syntax/development.env
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# 应用配置
|
||||
APP_NAME="测试应用"
|
||||
APP_ENV=development
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
# 数据库配置
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=test_db
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD="secret123"
|
||||
|
||||
# 缓存配置
|
||||
CACHE_DRIVER=redis
|
||||
SESSION_DRIVER=file
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Redis配置
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
# 邮件配置
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=test@example.com
|
||||
MAIL_PASSWORD="email_password"
|
||||
|
||||
# 其他配置
|
||||
ENABLE_FEATURE=true
|
||||
MAX_CONNECTIONS=100
|
||||
TIMEOUT=30.5
|
||||
LOG_LEVEL=info
|
||||
20
storage/env/test/development.env
vendored
Normal file
20
storage/env/test/development.env
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# 应用配置
|
||||
APP_NAME="测试应用"
|
||||
APP_ENV=development
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
# 数据库配置
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=test_db
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD="secret123"
|
||||
|
||||
# 其他配置
|
||||
CACHE_DRIVER=redis
|
||||
SESSION_DRIVER=file
|
||||
ENABLE_FEATURE=true
|
||||
MAX_CONNECTIONS=100
|
||||
TIMEOUT=30.5
|
||||
70
tests/Feature/EventConsumerSyncTest.php
Normal file
70
tests/Feature/EventConsumerSyncTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class EventConsumerSyncTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 测试对比事件消费者同步状态 API
|
||||
*/
|
||||
public function test_compare_event_consumer_sync_api()
|
||||
{
|
||||
$response = $this->postJson('/api/message-sync/compare-event-consumer', [
|
||||
'start_time' => Carbon::now()->subDays(7)->format('Y-m-d H:i:s'),
|
||||
'end_time' => Carbon::now()->format('Y-m-d H:i:s'),
|
||||
'exclude_messages' => []
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'crm_total',
|
||||
'agent_total',
|
||||
'missing_count',
|
||||
'sync_rate',
|
||||
'missing_messages',
|
||||
'summary'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试导出缺失消息 API
|
||||
*/
|
||||
public function test_export_missing_messages_api()
|
||||
{
|
||||
$response = $this->postJson('/api/message-sync/export-missing-messages', [
|
||||
'start_time' => Carbon::now()->subDays(7)->format('Y-m-d H:i:s'),
|
||||
'end_time' => Carbon::now()->format('Y-m-d H:i:s'),
|
||||
'exclude_messages' => []
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'csv',
|
||||
'filename',
|
||||
'count'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试无效的时间格式
|
||||
*/
|
||||
public function test_invalid_time_format()
|
||||
{
|
||||
$response = $this->postJson('/api/message-sync/compare-event-consumer', [
|
||||
'start_time' => 'invalid-date',
|
||||
'end_time' => 'invalid-date'
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
}
|
||||
|
||||
98
tests/Feature/MessageSyncTest.php
Normal file
98
tests/Feature/MessageSyncTest.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class MessageSyncTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 测试admin框架页面是否可以访问
|
||||
*/
|
||||
public function test_admin_page_accessible(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('admin.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试查询消息API端点
|
||||
*/
|
||||
public function test_query_messages_api_validation(): void
|
||||
{
|
||||
// 测试空请求
|
||||
$response = $this->postJson('/api/message-sync/query', []);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure([
|
||||
'success',
|
||||
'message',
|
||||
'errors'
|
||||
]);
|
||||
|
||||
// 测试无效的消息ID格式
|
||||
$response = $this->postJson('/api/message-sync/query', [
|
||||
'message_ids' => ['']
|
||||
]);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJson([
|
||||
'success' => false
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试同步消息API端点
|
||||
*/
|
||||
public function test_sync_messages_api_validation(): void
|
||||
{
|
||||
// 测试空请求
|
||||
$response = $this->postJson('/api/message-sync/sync', []);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure([
|
||||
'success',
|
||||
'message',
|
||||
'errors'
|
||||
]);
|
||||
|
||||
// 测试无效的消息ID格式
|
||||
$response = $this->postJson('/api/message-sync/sync', [
|
||||
'message_ids' => ['']
|
||||
]);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJson([
|
||||
'success' => false
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试获取agent配置API端点
|
||||
*/
|
||||
public function test_get_agent_config_api(): void
|
||||
{
|
||||
$response = $this->getJson('/api/message-sync/config');
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'agent_url',
|
||||
'timeout'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试数据库连接测试API端点
|
||||
*/
|
||||
public function test_database_connection_test_api(): void
|
||||
{
|
||||
$response = $this->getJson('/api/message-sync/test-connection');
|
||||
|
||||
// 由于测试环境可能没有配置crmslave数据库,这里测试端点是否存在
|
||||
// 可能返回200(连接成功)或500(连接失败),都是正常的
|
||||
$this->assertContains($response->status(), [200, 500]);
|
||||
$response->assertJsonStructure([
|
||||
'success'
|
||||
]);
|
||||
}
|
||||
}
|
||||
184
tests/Unit/JiraServiceTest.php
Normal file
184
tests/Unit/JiraServiceTest.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Services\JiraService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class JiraServiceTest extends TestCase
|
||||
{
|
||||
private JiraService $jiraService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 设置测试配置
|
||||
config([
|
||||
'jira.host' => 'https://test-jira.example.com',
|
||||
'jira.username' => 'test-user',
|
||||
'jira.password' => 'test-password',
|
||||
'jira.default_user' => 'test-user'
|
||||
]);
|
||||
|
||||
$this->jiraService = app(JiraService::class);
|
||||
}
|
||||
|
||||
public function test_is_task_completed_returns_false_for_incomplete_statuses()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('isTaskCompleted');
|
||||
|
||||
// 测试不完成的状态
|
||||
$incompleteStatuses = [
|
||||
'开发中',
|
||||
'需求已排期',
|
||||
'需求已评审',
|
||||
'In Progress',
|
||||
'To Do',
|
||||
'Open'
|
||||
];
|
||||
|
||||
foreach ($incompleteStatuses as $status) {
|
||||
$this->assertFalse(
|
||||
$method->invoke($this->jiraService, $status),
|
||||
"状态 '{$status}' 应该返回 false"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_is_task_completed_returns_true_for_complete_statuses()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('isTaskCompleted');
|
||||
|
||||
// 测试完成的状态
|
||||
$completeStatuses = [
|
||||
'Done',
|
||||
'Closed',
|
||||
'Resolved',
|
||||
'已完成',
|
||||
'Complete'
|
||||
];
|
||||
|
||||
foreach ($completeStatuses as $status) {
|
||||
$this->assertTrue(
|
||||
$method->invoke($this->jiraService, $status),
|
||||
"状态 '{$status}' 应该返回 true"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_organize_tasks_for_report_handles_empty_collection()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('organizeTasksForReport');
|
||||
|
||||
$emptyWorkLogs = collect();
|
||||
$result = $method->invoke($this->jiraService, $emptyWorkLogs);
|
||||
|
||||
$this->assertInstanceOf(Collection::class, $result);
|
||||
$this->assertTrue($result->has('sprints'));
|
||||
$this->assertTrue($result->has('tasks'));
|
||||
$this->assertTrue($result->has('bugs'));
|
||||
}
|
||||
|
||||
public function test_organize_tasks_for_report_categorizes_by_sprint()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('organizeTasksForReport');
|
||||
|
||||
$workLogs = collect([
|
||||
[
|
||||
'issue_key' => 'WP-1234',
|
||||
'issue_summary' => '测试需求',
|
||||
'issue_url' => 'https://test-jira.example.com/browse/WP-1234',
|
||||
'issue_status' => 'Done',
|
||||
'issue_type' => 'Story',
|
||||
'sprint' => '十月中需求',
|
||||
'bug_stage' => null,
|
||||
'bug_type' => null,
|
||||
'parent_task' => null,
|
||||
]
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->jiraService, $workLogs);
|
||||
|
||||
$this->assertTrue($result['sprints']->has('十月中需求'));
|
||||
$this->assertCount(1, $result['sprints']['十月中需求']);
|
||||
}
|
||||
|
||||
public function test_organize_tasks_for_report_categorizes_bugs_by_stage()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('organizeTasksForReport');
|
||||
|
||||
$workLogs = collect([
|
||||
[
|
||||
'issue_key' => 'WP-5678',
|
||||
'issue_summary' => '测试Bug',
|
||||
'issue_url' => 'https://test-jira.example.com/browse/WP-5678',
|
||||
'issue_status' => 'Done',
|
||||
'issue_type' => 'Bug',
|
||||
'sprint' => null,
|
||||
'bug_stage' => 'SIT环境BUG',
|
||||
'bug_type' => '需求未说明',
|
||||
'parent_task' => null,
|
||||
]
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->jiraService, $workLogs);
|
||||
|
||||
$this->assertTrue($result['bugs']->has('SIT环境BUG'));
|
||||
$this->assertCount(1, $result['bugs']['SIT环境BUG']);
|
||||
$this->assertEquals('需求未说明', $result['bugs']['SIT环境BUG'][0]['bug_type']);
|
||||
}
|
||||
|
||||
public function test_extract_sprint_info_from_string()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('extractSprintInfo');
|
||||
|
||||
$issue = (object)[
|
||||
'fields' => (object)[
|
||||
'customfield_10020' => [
|
||||
'com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,state=ACTIVE]'
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$result = $method->invoke($this->jiraService, $issue);
|
||||
$this->assertEquals('十月中需求', $result);
|
||||
}
|
||||
|
||||
public function test_extract_bug_stage_from_labels()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('extractBugStage');
|
||||
|
||||
$issue = (object)[
|
||||
'fields' => (object)[
|
||||
'labels' => ['SIT', 'bug']
|
||||
]
|
||||
];
|
||||
|
||||
$result = $method->invoke($this->jiraService, $issue);
|
||||
$this->assertEquals('SIT环境BUG', $result);
|
||||
}
|
||||
|
||||
public function test_extract_bug_type_from_labels()
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->jiraService);
|
||||
$method = $reflection->getMethod('extractBugType');
|
||||
|
||||
$issue = (object)[
|
||||
'fields' => (object)[
|
||||
'labels' => ['需求未说明', 'bug']
|
||||
]
|
||||
];
|
||||
|
||||
$result = $method->invoke($this->jiraService, $issue);
|
||||
$this->assertEquals('需求未说明', $result);
|
||||
}
|
||||
}
|
||||
@@ -27,5 +27,28 @@ export default defineConfig({
|
||||
alias: {
|
||||
'vue': 'vue/dist/vue.esm-bundler.js'
|
||||
}
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1000,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue'],
|
||||
'codemirror-vendor': [
|
||||
'codemirror',
|
||||
'@codemirror/autocomplete',
|
||||
'@codemirror/commands',
|
||||
'@codemirror/lang-javascript',
|
||||
'@codemirror/lang-php',
|
||||
'@codemirror/language',
|
||||
'@codemirror/search',
|
||||
'@codemirror/state',
|
||||
'@codemirror/theme-one-dark',
|
||||
'@codemirror/view'
|
||||
],
|
||||
'utils-vendor': ['axios']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user