From 8829db8ab2236169aef6d2fcb849099fbf47eb38 Mon Sep 17 00:00:00 2001 From: iwvw <2285740204@qq.com> Date: Sat, 7 Feb 2026 05:37:40 +0800 Subject: [PATCH 01/33] feat(server): refactor docker management with v2 task center --- modules/server-api/agent-service.js | 419 ++++++++++++-- modules/server-api/router.js | 486 +++++++++++++++- src/css/server.css | 399 ++++++++++++- src/js/main.js | 27 + src/js/modules/common.js | 87 ++- src/js/modules/host.js | 843 +++++++++++++++++++--------- src/templates/server.html | 349 ++++++------ 7 files changed, 2088 insertions(+), 522 deletions(-) diff --git a/modules/server-api/agent-service.js b/modules/server-api/agent-service.js index c76a8e0..16988be 100644 --- a/modules/server-api/agent-service.js +++ b/modules/server-api/agent-service.js @@ -45,6 +45,19 @@ class AgentService extends EventEmitter { this.legacyMetrics = new Map(); this.legacyStatus = new Map(); + // 统一任务注册表: taskId -> taskRecord + this.taskRegistry = new Map(); + // 等待中的任务 Promise 解析器: taskId -> { resolve, reject } + this.taskResolvers = new Map(); + // 任务进度轮询器: taskId -> intervalId + this.taskPollers = new Map(); + // 任务清理策略 + this.taskRetentionMs = 30 * 60 * 1000; // 30 分钟 + this.taskCleanupTimer = setInterval(() => this.cleanupTaskRegistry(), 60 * 1000); + if (typeof this.taskCleanupTimer.unref === 'function') { + this.taskCleanupTimer.unref(); + } + // 初始化加载或生成全局密钥 this.loadOrGenerateGlobalKey(); @@ -540,7 +553,13 @@ class AgentService extends EventEmitter { socket.on(Events.AGENT_TASK_RESULT, result => { if (!authenticated) return; this.log(`任务结果: ${serverId} -> ${result.id} (${result.successful ? '成功' : '失败'})`); - // TODO: 处理任务结果 (日志记录、通知等) + this.finishTaskRecord(result.id, { + id: result.id, + type: result.type, + successful: !!result.successful, + data: result.data, + delay: result.delay, + }); }); // 6. 接收 PTY 输出数据流 @@ -569,6 +588,7 @@ class AgentService extends EventEmitter { console.log(`[AgentService] ${msg}`); this.connections.delete(serverId); this.stopHeartbeat(serverId); + this.failActiveTasksByServer(serverId, `Agent 已离线: ${reason}`); this.updateServerStatus(serverId, 'offline'); this.broadcastServerStatus(serverId, 'offline'); this.triggerOfflineAlert(serverId); // Ensure offline alert is triggered @@ -668,6 +688,7 @@ class AgentService extends EventEmitter { */ handleAgentTimeout(serverId) { this.connections.delete(serverId); + this.failActiveTasksByServer(serverId, 'Agent 心跳超时'); this.updateServerStatus(serverId, 'offline'); this.broadcastServerStatus(serverId, 'offline'); @@ -825,28 +846,362 @@ class AgentService extends EventEmitter { // ==================== 任务下发 ==================== - /** - * 向 Agent 下发任务 - * @param {string} serverId - 目标主机 ID - * @param {Object} task - 任务对象 - * @returns {boolean} 是否成功发送 - */ - sendTask(serverId, task) { + isTaskFinalState(state) { + return ['success', 'failed', 'timeout', 'cancelled'].includes(state); + } + + snapshotTask(task) { + if (!task) return null; + return { + taskId: task.id, + id: task.id, + serverId: task.serverId, + domain: task.domain, + action: task.action, + type: task.type, + state: task.state, + progress: task.progress, + step: task.step, + message: task.message, + detail: task.detail, + result: task.result, + error: task.error, + timeoutMs: task.timeoutMs, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + }; + } + + emitTaskUpdate(task) { + const snapshot = this.snapshotTask(task); + if (!snapshot) return; + this.emit('task:update', snapshot); + } + + createTaskRecord(serverId, task, options = {}) { + const now = Date.now(); + const taskId = task.id || crypto.randomUUID(); + const timeoutMs = options.timeoutMs || 60000; + + const record = { + id: taskId, + serverId, + domain: options.domain || 'system', + action: options.action || '', + type: task.type, + state: 'running', + progress: 0, + step: 'queued', + message: '任务已下发', + detail: '', + result: null, + error: null, + timeoutMs, + createdAt: now, + updatedAt: now, + startedAt: now, + finishedAt: null, + _timeoutTimer: null, + }; + + this.taskRegistry.set(taskId, record); + return record; + } + + getTask(taskId) { + return this.snapshotTask(this.taskRegistry.get(taskId)); + } + + getRecentTasks(serverId = '', limit = 100) { + let tasks = Array.from(this.taskRegistry.values()); + if (serverId) { + tasks = tasks.filter(item => item.serverId === serverId); + } + tasks.sort((a, b) => b.createdAt - a.createdAt); + return tasks.slice(0, limit).map(item => this.snapshotTask(item)); + } + + cleanupTaskRegistry() { + const now = Date.now(); + for (const [taskId, task] of this.taskRegistry.entries()) { + if (!this.isTaskFinalState(task.state)) continue; + const finishedAt = task.finishedAt || task.updatedAt || task.createdAt; + if (now - finishedAt > this.taskRetentionMs) { + this.stopTaskProgressPolling(taskId); + if (task._timeoutTimer) { + clearTimeout(task._timeoutTimer); + } + this.taskRegistry.delete(taskId); + this.taskResolvers.delete(taskId); + } + } + } + + updateTaskProgress(taskId, payload) { + const task = this.taskRegistry.get(taskId); + if (!task || this.isTaskFinalState(task.state)) return; + + let progressData = payload; + if (typeof progressData === 'string') { + try { + progressData = JSON.parse(progressData); + } catch (e) { + return; + } + } + if (!progressData || typeof progressData !== 'object') return; + + if (typeof progressData.percentage === 'number') { + const bounded = Math.max(0, Math.min(100, Math.round(progressData.percentage))); + task.progress = bounded; + } + if (typeof progressData.name === 'string' && progressData.name.trim()) { + task.step = progressData.name.trim(); + } + if (typeof progressData.message === 'string' && progressData.message.trim()) { + task.message = progressData.message.trim(); + } + if (typeof progressData.detail_msg === 'string') { + task.detail = progressData.detail_msg; + } + if (progressData.is_done === true) { + task.progress = 100; + } + + task.updatedAt = Date.now(); + this.emitTaskUpdate(task); + } + + stopTaskProgressPolling(taskId) { + const timer = this.taskPollers.get(taskId); + if (timer) { + clearInterval(timer); + this.taskPollers.delete(taskId); + } + } + + startTaskProgressPolling(taskId, serverId, intervalMs = 1500) { + if (this.taskPollers.has(taskId)) return; + + const timer = setInterval(async () => { + const task = this.taskRegistry.get(taskId); + if (!task || this.isTaskFinalState(task.state)) { + this.stopTaskProgressPolling(taskId); + return; + } + if (!this.isOnline(serverId)) { + return; + } + + try { + const result = await this._sendTaskAndWaitLegacy( + serverId, + { + type: TaskTypes.DOCKER_TASK_PROGRESS, + data: JSON.stringify({ task_id: taskId }), + timeout: 10, + }, + 15000 + ); + + if (result.successful && result.data) { + this.updateTaskProgress(taskId, result.data); + } + } catch (error) { + // 进度查询失败不直接中断主任务 + } + }, Math.max(1000, intervalMs)); + + if (typeof timer.unref === 'function') { + timer.unref(); + } + this.taskPollers.set(taskId, timer); + } + + finishTaskRecord(taskId, result) { + const task = this.taskRegistry.get(taskId); + if (!task) return; + + if (task._timeoutTimer) { + clearTimeout(task._timeoutTimer); + task._timeoutTimer = null; + } + this.stopTaskProgressPolling(taskId); + + task.updatedAt = Date.now(); + task.finishedAt = task.updatedAt; + + if (result && result.successful) { + task.state = 'success'; + task.progress = 100; + task.message = '任务执行成功'; + task.result = result.data || ''; + task.error = null; + } else { + task.state = 'failed'; + task.message = '任务执行失败'; + task.error = result?.data || '未知错误'; + task.result = null; + } + + this.emitTaskUpdate(task); + + const resolver = this.taskResolvers.get(taskId); + if (resolver) { + this.taskResolvers.delete(taskId); + resolver.resolve(result); + } + } + + failActiveTasksByServer(serverId, reason = 'Agent 连接中断') { + for (const [taskId, task] of this.taskRegistry.entries()) { + if (task.serverId !== serverId || this.isTaskFinalState(task.state)) continue; + + if (task._timeoutTimer) { + clearTimeout(task._timeoutTimer); + task._timeoutTimer = null; + } + this.stopTaskProgressPolling(taskId); + + task.state = 'failed'; + task.error = reason; + task.message = reason; + task.updatedAt = Date.now(); + task.finishedAt = task.updatedAt; + this.emitTaskUpdate(task); + + const resolver = this.taskResolvers.get(taskId); + if (resolver) { + this.taskResolvers.delete(taskId); + resolver.reject(new Error(reason)); + } + } + } + + // 兼容内部短查询的旧实现(例如任务进度轮询) + _sendTaskAndWaitLegacy(serverId, task, timeout = 60000) { + return new Promise((resolve, reject) => { + const taskId = task.id || crypto.randomUUID(); + const socket = this.connections.get(serverId); + + if (!socket) { + return reject(new Error('主机不在线')); + } + + const timer = setTimeout(() => { + socket.off(Events.AGENT_TASK_RESULT, resultHandler); + reject(new Error('任务执行超时')); + }, timeout); + + const resultHandler = result => { + if (result.id === taskId) { + clearTimeout(timer); + socket.off(Events.AGENT_TASK_RESULT, resultHandler); + resolve(result); + } + }; + + socket.on(Events.AGENT_TASK_RESULT, resultHandler); + socket.emit(Events.DASHBOARD_TASK, { + id: taskId, + type: task.type, + data: task.data, + timeout: task.timeout || 0, + }); + }); + } + + submitTask(serverId, task, options = {}) { const socket = this.connections.get(serverId); if (!socket) { - console.warn(`[AgentService] 无法下发任务: ${serverId} 不在线`); - return false; + throw new Error('主机不在线'); + } + + const timeoutMs = options.timeoutMs || 60000; + const record = this.createTaskRecord(serverId, task, { + timeoutMs, + domain: options.domain, + action: options.action, + }); + + record._timeoutTimer = setTimeout(() => { + if (this.isTaskFinalState(record.state)) return; + + record.state = 'timeout'; + record.error = '任务执行超时'; + record.message = '任务执行超时'; + record.updatedAt = Date.now(); + record.finishedAt = record.updatedAt; + this.stopTaskProgressPolling(record.id); + this.emitTaskUpdate(record); + + const resolver = this.taskResolvers.get(record.id); + if (resolver) { + this.taskResolvers.delete(record.id); + resolver.reject(new Error('任务执行超时')); + } + }, timeoutMs); + + if (typeof record._timeoutTimer.unref === 'function') { + record._timeoutTimer.unref(); } socket.emit(Events.DASHBOARD_TASK, { - id: task.id || crypto.randomUUID(), + id: record.id, type: task.type, data: task.data, timeout: task.timeout || 0, }); - this.log(`任务已下发: ${serverId} -> ${task.type}`); - return true; + this.emitTaskUpdate(record); + this.log(`任务已下发: ${serverId} -> ${task.type} (id: ${record.id})`); + + if (options.trackProgress) { + this.startTaskProgressPolling(record.id, serverId, options.progressIntervalMs || 1500); + } + + if (options.waitForResult === false) { + return record.id; + } + + return new Promise((resolve, reject) => { + this.taskResolvers.set(record.id, { resolve, reject }); + }); + } + + /** + * 向 Agent 下发任务 + * @param {string} serverId - 目标主机 ID + * @param {Object} task - 任务对象 + * @returns {string|false} 任务 ID + */ + sendTask(serverId, task) { + // PTY 交互与非标准任务类型不进入任务注册表,避免高频输入污染任务中心 + if (task.type === TaskTypes.PTY_START || typeof task.type !== 'number') { + const socket = this.connections.get(serverId); + if (!socket) { + return false; + } + socket.emit(Events.DASHBOARD_TASK, { + id: task.id || crypto.randomUUID(), + type: task.type, + data: task.data, + timeout: task.timeout || 0, + }); + return task.id || true; + } + + try { + return this.submitTask(serverId, task, { + waitForResult: false, + timeoutMs: Math.max(30000, ((task.timeout || 60) + 5) * 1000), + }); + } catch (error) { + console.warn(`[AgentService] 无法下发任务: ${serverId} ${error.message}`); + return false; + } } /** @@ -885,41 +1240,9 @@ class AgentService extends EventEmitter { * @returns {Promise} */ sendTaskAndWait(serverId, task, timeout = 60000) { - return new Promise((resolve, reject) => { - const taskId = task.id || crypto.randomUUID(); - const socket = this.connections.get(serverId); - - if (!socket) { - return reject(new Error('主机不在线')); - } - - // 设置超时 - const timer = setTimeout(() => { - socket.off(Events.AGENT_TASK_RESULT, resultHandler); - reject(new Error('任务执行超时')); - }, timeout); - - // 结果处理器 - const resultHandler = result => { - if (result.id === taskId) { - clearTimeout(timer); - socket.off(Events.AGENT_TASK_RESULT, resultHandler); - resolve(result); - } - }; - - // 监听任务结果 - socket.on(Events.AGENT_TASK_RESULT, resultHandler); - - // 发送任务 - socket.emit(Events.DASHBOARD_TASK, { - id: taskId, - type: task.type, - data: task.data, - timeout: task.timeout || 0, - }); - - this.log(`同步任务已下发: ${serverId} -> ${task.type} (id: ${taskId})`); + return this.submitTask(serverId, task, { + waitForResult: true, + timeoutMs: timeout, }); } diff --git a/modules/server-api/router.js b/modules/server-api/router.js index 6ff79cd..d8dc4e6 100644 --- a/modules/server-api/router.js +++ b/modules/server-api/router.js @@ -532,6 +532,449 @@ router.post('/info', async (req, res) => { } }); +// ==================== V2 任务与 Docker 聚合 API ==================== + +function parseJsonSafe(value, fallback = []) { + if (value === null || value === undefined || value === '') return fallback; + if (Array.isArray(value) || typeof value === 'object') return value; + try { + return JSON.parse(value); + } catch (error) { + return fallback; + } +} + +function toTaskData(data) { + if (data === '' || data === null || data === undefined) return ''; + if (typeof data === 'string') return data; + return JSON.stringify(data); +} + +function buildDockerV2Task(action, payload = {}) { + const defaultTimeoutMs = 60000; + + switch (action) { + case 'container.start': + case 'container.stop': + case 'container.restart': + case 'container.pause': + case 'container.unpause': + case 'container.pull': + if (!payload.containerId) throw new Error('缺少 containerId'); + return { + type: DockerTaskTypes.DOCKER_ACTION, + data: { + action: action.split('.')[1], + container_id: payload.containerId, + image: payload.image || '', + }, + timeoutMs: 120000, + }; + case 'container.update': + if (!payload.containerId || !payload.containerName) { + throw new Error('缺少 containerId 或 containerName'); + } + return { + type: DockerTaskTypes.DOCKER_UPDATE_CONTAINER, + data: { + container_id: payload.containerId, + container_name: payload.containerName, + image: payload.image || '', + }, + agentTimeoutSec: 600, + timeoutMs: 10 * 60 * 1000, + trackProgress: true, + }; + case 'container.rename': + if (!payload.containerId || !payload.newName) { + throw new Error('缺少 containerId 或 newName'); + } + return { + type: DockerTaskTypes.DOCKER_RENAME_CONTAINER, + data: { + container_id: payload.containerId, + new_name: payload.newName, + }, + timeoutMs: defaultTimeoutMs, + }; + case 'container.logs': + if (!payload.containerId) throw new Error('缺少 containerId'); + return { + type: DockerTaskTypes.DOCKER_LOGS, + data: { + container_id: payload.containerId, + tail: payload.tail || 100, + since: payload.since || '', + }, + timeoutMs: defaultTimeoutMs, + }; + case 'container.checkUpdates': + return { + type: DockerTaskTypes.DOCKER_CHECK_UPDATE, + data: { + container_id: payload.containerId || '', + }, + timeoutMs: 180000, + }; + case 'container.create': + if (!payload.image) throw new Error('缺少镜像名称 image'); + return { + type: DockerTaskTypes.DOCKER_CREATE_CONTAINER, + data: { + name: payload.name || '', + image: payload.image, + ports: Array.isArray(payload.ports) ? payload.ports : [], + volumes: Array.isArray(payload.volumes) ? payload.volumes : [], + env: payload.env && typeof payload.env === 'object' ? payload.env : {}, + network: payload.network || '', + restart: payload.restart || 'unless-stopped', + privileged: !!payload.privileged, + extra_args: Array.isArray(payload.extraArgs) ? payload.extraArgs : [], + }, + agentTimeoutSec: 300, + timeoutMs: 300000, + }; + case 'image.list': + return { + type: DockerTaskTypes.DOCKER_IMAGES, + data: '', + timeoutMs: defaultTimeoutMs, + }; + case 'image.pull': + case 'image.remove': + case 'image.prune': + return { + type: DockerTaskTypes.DOCKER_IMAGE_ACTION, + data: { + action: action.split('.')[1], + image: payload.image || '', + }, + agentTimeoutSec: action === 'image.pull' ? 300 : 60, + timeoutMs: action === 'image.pull' ? 300000 : defaultTimeoutMs, + }; + case 'network.list': + return { + type: DockerTaskTypes.DOCKER_NETWORKS, + data: '', + timeoutMs: defaultTimeoutMs, + }; + case 'network.create': + case 'network.remove': + case 'network.connect': + case 'network.disconnect': + return { + type: DockerTaskTypes.DOCKER_NETWORK_ACTION, + data: { + action: action.split('.')[1], + name: payload.name || '', + driver: payload.driver || '', + subnet: payload.subnet || '', + gateway: payload.gateway || '', + container: payload.container || '', + }, + timeoutMs: defaultTimeoutMs, + }; + case 'volume.list': + return { + type: DockerTaskTypes.DOCKER_VOLUMES, + data: '', + timeoutMs: defaultTimeoutMs, + }; + case 'volume.create': + case 'volume.remove': + case 'volume.prune': + return { + type: DockerTaskTypes.DOCKER_VOLUME_ACTION, + data: { + action: action.split('.')[1], + name: payload.name || '', + driver: payload.driver || '', + }, + timeoutMs: defaultTimeoutMs, + }; + case 'stats.list': + return { + type: DockerTaskTypes.DOCKER_STATS, + data: '', + timeoutMs: defaultTimeoutMs, + }; + case 'compose.list': + return { + type: DockerTaskTypes.DOCKER_COMPOSE_LIST, + data: '', + timeoutMs: defaultTimeoutMs, + }; + case 'compose.up': + case 'compose.down': + case 'compose.restart': + case 'compose.pull': + if (!payload.project) throw new Error('缺少 project'); + return { + type: DockerTaskTypes.DOCKER_COMPOSE_ACTION, + data: { + action: action.split('.')[1], + project: payload.project, + config_dir: payload.configDir || '', + }, + agentTimeoutSec: action === 'compose.pull' ? 300 : 120, + timeoutMs: action === 'compose.pull' ? 300000 : 120000, + }; + default: + throw new Error(`不支持的 Docker action: ${action}`); + } +} + +async function loadDockerOverviewForServer(server) { + const serverId = server.id; + const metrics = agentService.getMetrics(serverId) || {}; + const docker = metrics.docker || {}; + + const runAgentTask = async (type, timeoutMs = 30000) => { + try { + const result = await agentService.sendTaskAndWait( + serverId, + { + type, + data: '', + timeout: Math.ceil(timeoutMs / 1000), + }, + timeoutMs + ); + if (!result.successful) { + return { ok: false, error: result.data || '任务执行失败', data: [] }; + } + return { ok: true, data: parseJsonSafe(result.data, []) }; + } catch (error) { + return { ok: false, error: error.message, data: [] }; + } + }; + + const [imagesRes, networksRes, volumesRes, statsRes, composeRes] = await Promise.all([ + runAgentTask(DockerTaskTypes.DOCKER_IMAGES), + runAgentTask(DockerTaskTypes.DOCKER_NETWORKS), + runAgentTask(DockerTaskTypes.DOCKER_VOLUMES), + runAgentTask(DockerTaskTypes.DOCKER_STATS), + runAgentTask(DockerTaskTypes.DOCKER_COMPOSE_LIST), + ]); + + return { + serverId, + serverName: server.name, + host: server.host, + online: true, + docker: { + installed: !!docker.installed, + running: docker.running || 0, + stopped: docker.stopped || 0, + containers: Array.isArray(docker.containers) ? docker.containers : [], + }, + resources: { + images: imagesRes.data, + networks: networksRes.data, + volumes: volumesRes.data, + stats: statsRes.data, + composeProjects: composeRes.data, + }, + errors: { + images: imagesRes.ok ? '' : imagesRes.error, + networks: networksRes.ok ? '' : networksRes.error, + volumes: volumesRes.ok ? '' : volumesRes.error, + stats: statsRes.ok ? '' : statsRes.error, + composeProjects: composeRes.ok ? '' : composeRes.error, + }, + }; +} + +router.post('/v2/tasks', async (req, res) => { + try { + const { serverId, domain, action, payload, requestId } = req.body || {}; + + if (!serverId || !domain || !action) { + return res.status(400).json({ success: false, error: '缺少 serverId/domain/action' }); + } + if (domain !== 'docker') { + return res.status(400).json({ success: false, error: `不支持的 domain: ${domain}` }); + } + if (!agentService.isOnline(serverId)) { + return res.status(400).json({ success: false, error: '主机不在线' }); + } + + if (requestId) { + const existing = agentService.getTask(requestId); + if (existing) { + return res.status(202).json({ + success: true, + data: { + taskId: existing.taskId, + serverId, + domain, + action, + acceptedAt: existing.createdAt, + deduped: true, + }, + }); + } + } + + const mapped = buildDockerV2Task(action, payload || {}); + const taskId = agentService.submitTask( + serverId, + { + id: requestId || undefined, + type: mapped.type, + data: toTaskData(mapped.data), + timeout: mapped.agentTimeoutSec || Math.ceil((mapped.timeoutMs || 60000) / 1000), + }, + { + waitForResult: false, + timeoutMs: mapped.timeoutMs || 60000, + trackProgress: !!mapped.trackProgress, + domain: 'docker', + action, + } + ); + + res.status(202).json({ + success: true, + data: { + taskId, + serverId, + domain, + action, + acceptedAt: Date.now(), + }, + }); + } catch (error) { + res.status(400).json({ success: false, error: error.message }); + } +}); + +router.get('/v2/tasks/stream', (req, res) => { + const serverId = req.query.serverId ? String(req.query.serverId) : ''; + const bootstrapLimit = Math.max(1, Math.min(200, parseInt(req.query.bootstrapLimit) || 50)); + + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } + + const writeEvent = (event, data) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + writeEvent('ready', { + connected: true, + timestamp: Date.now(), + serverId: serverId || null, + }); + + const recentTasks = agentService.getRecentTasks(serverId, bootstrapLimit); + for (const task of recentTasks) { + writeEvent('task.update', task); + } + + const onTaskUpdate = task => { + if (serverId && task.serverId !== serverId) return; + writeEvent('task.update', task); + }; + agentService.on('task:update', onTaskUpdate); + + const heartbeat = setInterval(() => { + writeEvent('ping', { timestamp: Date.now() }); + }, 15000); + if (typeof heartbeat.unref === 'function') { + heartbeat.unref(); + } + + req.on('close', () => { + clearInterval(heartbeat); + agentService.off('task:update', onTaskUpdate); + res.end(); + }); +}); + +router.get('/v2/tasks', (req, res) => { + const serverId = req.query.serverId ? String(req.query.serverId) : ''; + const limit = Math.max(1, Math.min(500, parseInt(req.query.limit) || 100)); + res.json({ + success: true, + data: agentService.getRecentTasks(serverId, limit), + }); +}); + +router.get('/v2/tasks/:taskId', (req, res) => { + const task = agentService.getTask(req.params.taskId); + if (!task) { + return res.status(404).json({ success: false, error: '任务不存在' }); + } + res.json({ success: true, data: task }); +}); + +router.get('/v2/docker/overview', async (req, res) => { + try { + const selectedServerId = req.query.serverId ? String(req.query.serverId) : ''; + const servers = serverStorage.getAll(); + + let targetServers = []; + if (selectedServerId) { + const server = servers.find(item => item.id === selectedServerId); + if (!server) { + return res.status(404).json({ success: false, error: '主机不存在' }); + } + if (!agentService.isOnline(server.id)) { + return res.status(400).json({ success: false, error: '主机不在线' }); + } + targetServers = [server]; + } else { + targetServers = servers.filter(item => agentService.isOnline(item.id)); + } + + const overviews = []; + for (const server of targetServers) { + overviews.push(await loadDockerOverviewForServer(server)); + } + + const summary = overviews.reduce( + (acc, item) => { + acc.hosts += 1; + acc.containers += item.docker.containers.length; + acc.running += item.docker.running || 0; + acc.stopped += item.docker.stopped || 0; + acc.images += item.resources.images.length; + acc.networks += item.resources.networks.length; + acc.volumes += item.resources.volumes.length; + acc.composeProjects += item.resources.composeProjects.length; + return acc; + }, + { + hosts: 0, + containers: 0, + running: 0, + stopped: 0, + images: 0, + networks: 0, + volumes: 0, + composeProjects: 0, + } + ); + + res.json({ + success: true, + data: { + generatedAt: Date.now(), + servers: overviews, + summary, + }, + }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + /** * Docker 容器操作 * POST /docker/action @@ -750,7 +1193,7 @@ router.post('/docker/images', async (req, res) => { }, 30000); if (result.successful) { - res.json({ success: true, data: JSON.parse(result.data) }); + res.json({ success: true, data: parseJsonSafe(result.data, []) }); } else { res.status(400).json({ success: false, error: result.data }); } @@ -807,7 +1250,7 @@ router.post('/docker/networks', async (req, res) => { }, 30000); if (result.successful) { - res.json({ success: true, data: JSON.parse(result.data) }); + res.json({ success: true, data: parseJsonSafe(result.data, []) }); } else { res.status(400).json({ success: false, error: result.data }); } @@ -863,7 +1306,7 @@ router.post('/docker/volumes', async (req, res) => { }, 30000); if (result.successful) { - res.json({ success: true, data: JSON.parse(result.data) }); + res.json({ success: true, data: parseJsonSafe(result.data, []) }); } else { res.status(400).json({ success: false, error: result.data }); } @@ -949,7 +1392,7 @@ router.post('/docker/stats', async (req, res) => { }, 30000); if (result.successful) { - res.json({ success: true, data: JSON.parse(result.data) }); + res.json({ success: true, data: parseJsonSafe(result.data, []) }); } else { res.status(400).json({ success: false, error: result.data }); } @@ -977,12 +1420,7 @@ router.post('/docker/compose/list', async (req, res) => { }, 30000); if (result.successful) { - let projects = []; - try { - projects = JSON.parse(result.data); - } catch (e) { - projects = []; - } + const projects = parseJsonSafe(result.data, []); res.json({ success: true, data: projects }); } else { res.status(400).json({ success: false, error: result.data }); @@ -1380,15 +1818,15 @@ router.post('/task/command/:serverId', async (req, res) => { return res.status(400).json({ success: false, error: '主机不在线' }); } - const taskId = require('crypto').randomUUID(); - const result = agentService.sendTask(serverId, { - id: taskId, + const requestedTaskId = require('crypto').randomUUID(); + const taskId = agentService.sendTask(serverId, { + id: requestedTaskId, type: TaskTypes.COMMAND, data: command, timeout, }); - if (!result) { + if (!taskId) { return res.status(500).json({ success: false, error: '任务下发失败' }); } @@ -1469,9 +1907,9 @@ router.post('/task/command/batch', async (req, res) => { continue; } - const taskId = require('crypto').randomUUID(); - const sent = agentService.sendTask(serverId, { - id: taskId, + const requestedTaskId = require('crypto').randomUUID(); + const sentTaskId = agentService.sendTask(serverId, { + id: requestedTaskId, type: TaskTypes.COMMAND, data: command, timeout, @@ -1479,8 +1917,8 @@ router.post('/task/command/batch', async (req, res) => { results.push({ serverId, - success: sent, - taskId: sent ? taskId : null, + success: !!sentTaskId, + taskId: sentTaskId || null, }); } @@ -1785,7 +2223,15 @@ router.post('/sftp/upload', async (req, res) => { : remotePath + '/' + file.name; } - await sftpService.uploadFile(serverId, fullPath, file.data); + let uploadData = file.data; + if ((!uploadData || uploadData.length === 0) && file.tempFilePath) { + uploadData = require('fs').createReadStream(file.tempFilePath); + } + if (!uploadData) { + return res.status(400).json({ success: false, error: '上传文件数据为空' }); + } + + await sftpService.uploadFile(serverId, fullPath, uploadData); res.json({ success: true, message: '上传成功', path: fullPath }); } catch (error) { res.status(500).json({ success: false, error: error.message }); diff --git a/src/css/server.css b/src/css/server.css index b60792f..7916de1 100644 --- a/src/css/server.css +++ b/src/css/server.css @@ -4270,4 +4270,401 @@ .modern-tab-btn.active i { opacity: 1; -} \ No newline at end of file +} + +/* Docker Console (refactor) */ +.docker-console-panel { + border: 1px solid var(--border-color); + border-radius: 16px; + background: linear-gradient(180deg, rgba(var(--bg-secondary-rgb), 0.55), rgba(var(--bg-primary-rgb), 0.35)); +} + +.docker-console-toolbar { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px; + border-bottom: 1px solid var(--border-color); + flex-wrap: wrap; +} + +.docker-console-tabs { + display: inline-flex; + gap: 4px; + padding: 4px; + border-radius: 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +.docker-console-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.docker-search { + position: relative; +} + +.docker-search i { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-tertiary); + font-size: 12px; +} + +.docker-search input { + width: 220px; + height: 34px; + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + padding: 0 12px 0 30px; + font-size: 12px; +} + +.docker-refresh-btn { + width: 34px; + height: 34px; + padding: 0; +} + +.docker-task-hub { + margin: 0 14px 12px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: linear-gradient(180deg, rgba(var(--bg-secondary-rgb), 0.6), rgba(var(--bg-primary-rgb), 0.6)); + padding: 10px 12px; +} + +.docker-task-hub-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.docker-task-stream-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--border-color); +} + +.docker-task-stream-indicator.online { + color: var(--success-color); + border-color: rgba(16, 185, 129, 0.4); + background: rgba(16, 185, 129, 0.1); +} + +.docker-task-count { + font-size: 11px; + color: var(--text-tertiary); +} + +.docker-task-stream-error { + font-size: 11px; + color: var(--warning-color); + margin-bottom: 8px; +} + +.docker-task-empty { + font-size: 12px; + color: var(--text-tertiary); + padding: 12px 4px 6px; +} + +.docker-task-list { + display: flex; + flex-direction: column; + gap: 7px; + max-height: 250px; + overflow-y: auto; + padding-right: 2px; +} + +.docker-task-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + border: 1px solid var(--border-color); + background: rgba(var(--bg-primary-rgb), 0.45); + border-radius: 10px; + padding: 8px 10px; +} + +.docker-task-state { + font-size: 10px; + font-weight: 700; + border-radius: 999px; + padding: 3px 7px; + border: 1px solid var(--border-color); + color: var(--text-secondary); + background: rgba(var(--bg-secondary-rgb), 0.5); +} + +.docker-task-state.success { + color: var(--success-color); + border-color: rgba(16, 185, 129, 0.35); + background: rgba(16, 185, 129, 0.1); +} + +.docker-task-state.danger { + color: var(--danger-color); + border-color: rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.1); +} + +.docker-task-state.warning { + color: var(--warning-color); + border-color: rgba(245, 158, 11, 0.35); + background: rgba(245, 158, 11, 0.1); +} + +.docker-task-main { + min-width: 0; +} + +.docker-task-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-primary); + font-weight: 600; +} + +.docker-task-server { + font-size: 10px; + color: var(--text-tertiary); + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 2px 6px; +} + +.docker-task-msg { + margin-top: 3px; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.docker-task-time { + font-size: 10px; + color: var(--text-tertiary); + font-family: var(--font-mono); + white-space: nowrap; +} + +.docker-console-view { + padding: 14px; +} + +.docker-kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 10px; +} + +.docker-kpi-card { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + padding: 10px 12px; +} + +.docker-kpi-card.is-running { + border-color: rgba(16, 185, 129, 0.35); +} + +.docker-kpi-card.is-warning { + border-color: rgba(245, 158, 11, 0.35); +} + +.docker-kpi-label { + color: var(--text-tertiary); + font-size: 11px; + margin-bottom: 4px; +} + +.docker-kpi-value { + color: var(--text-primary); + font-size: 22px; + line-height: 1; + font-weight: 800; + font-family: var(--font-mono); +} + +.docker-state-filters { + display: flex; + gap: 8px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.docker-main-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 12px; +} + +.docker-container-table-wrap { + min-width: 0; +} + +.docker-container-row { + cursor: pointer; +} + +.docker-container-row.active { + background: linear-gradient(90deg, rgba(var(--primary-rgb), 0.08), transparent); +} + +.docker-row-title { + color: var(--text-primary); + font-size: 13px; + font-weight: 700; +} + +.docker-row-sub { + color: var(--text-tertiary); + font-size: 11px; + margin-top: 2px; +} + +.docker-row-ellipsis { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + color: var(--text-secondary); +} + +.docker-row-actions { + width: 190px; + display: flex; + align-items: center; + gap: 8px; +} + +.docker-mono { + font-family: var(--font-mono); +} + +.docker-empty-row { + justify-content: center; + color: var(--text-tertiary); +} + +.docker-inspector { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + overflow: hidden; + min-width: 0; +} + +.docker-inspector-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 12px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.docker-inspector-body { + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.docker-inspector-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.docker-inspector-item span { + font-size: 11px; + color: var(--text-tertiary); +} + +.docker-inspector-item strong { + font-size: 13px; + color: var(--text-primary); +} + +.docker-inspector-item code { + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 7px 8px; + white-space: normal; + word-break: break-all; +} + +@media (max-width: 1200px) { + .docker-main-grid { + grid-template-columns: 1fr; + } + + .docker-inspector { + order: -1; + } +} + +@media (max-width: 768px) { + .docker-console-tabs { + width: 100%; + overflow-x: auto; + } + + .docker-console-actions { + width: 100%; + } + + .docker-search { + flex: 1; + min-width: 160px; + } + + .docker-search input { + width: 100%; + } + + .docker-kpi-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .docker-resource-header { + font-size: 10px; + } + + .docker-task-item { + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + } + + .docker-task-time { + grid-column: 2 / 3; + } +} diff --git a/src/js/main.js b/src/js/main.js index 2aac7d5..49f692a 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -461,6 +461,9 @@ const app = createApp({ expandedDockerHosts: [], // 展开的 Docker 主机列表 dockerSubTab: 'containers', // Docker 子标签页 dockerSelectedServer: '', // 当前选中的主机 ID + dockerSearchQuery: '', // Docker 搜索关键词 + dockerContainerStateFilter: 'all', // 容器状态筛选 all/running/paused/stopped + dockerFocusedContainerKey: '', // 当前选中的容器详情 key dockerResourceLoading: false, // 资源加载状态 dockerImages: [], // 镜像列表 dockerNetworks: [], // 网络列表 @@ -478,6 +481,11 @@ const app = createApp({ containerMenuData: { serverId: '', containerId: '', containerName: '' }, // 菜单数据 // Docker Compose dockerComposeProjects: [], // Compose 项目列表 + // Docker 任务中心 (v2) + dockerTasks: [], + dockerTaskStream: null, + dockerTaskStreamConnected: false, + dockerTaskStreamError: '', // 容器创建 showCreateContainerModal: false, createContainerForm: { @@ -1182,6 +1190,16 @@ const app = createApp({ serverCurrentTab: { handler(newVal) { + this.updateBrowserThemeColor(); + + if (newVal === 'docker') { + if (this.ensureDockerTaskStream) { + this.ensureDockerTaskStream(); + } + } else if (this.closeDockerTaskStream) { + this.closeDockerTaskStream(); + } + // 1. 指标流连接管理 - 仅在列表页时连接 if (newVal === 'list' && this.mainActiveTab === 'server') { this.connectMetricsStream(); @@ -1268,6 +1286,9 @@ const app = createApp({ // [离开保护] 如果离开主机管理模块,强制将 DOM 节点搬回仓库,防止被销毁 if (oldVal === 'server') { this.saveTerminalsToWarehouse(); + if (this.closeDockerTaskStream) { + this.closeDockerTaskStream(); + } // 离开时不要关闭指标流,保持后台更新 } @@ -1286,6 +1307,9 @@ const app = createApp({ if (this.serverCurrentTab === 'list') { this.connectMetricsStream(); } + if (this.serverCurrentTab === 'docker' && this.ensureDockerTaskStream) { + this.ensureDockerTaskStream(); + } } // 2. 浏览器与 UI 适配 @@ -1720,6 +1744,9 @@ const app = createApp({ if (this.logsRealTimeTimer) { clearInterval(this.logsRealTimeTimer); } + if (this.closeDockerTaskStream) { + this.closeDockerTaskStream(); + } this.stopServerPolling(); this.stopKoyebAutoRefresh(); }, diff --git a/src/js/modules/common.js b/src/js/modules/common.js index 9c8a842..386f46c 100644 --- a/src/js/modules/common.js +++ b/src/js/modules/common.js @@ -610,15 +610,88 @@ export const commonMethods = { updateBrowserThemeColor() { this.$nextTick(() => { - const bgColor = getComputedStyle(document.documentElement) - .getPropertyValue('--bg-primary') - .trim(); + const style = getComputedStyle(document.documentElement); + const bgColor = style.getPropertyValue('--bg-primary').trim(); + const currentPrimary = style.getPropertyValue('--current-primary').trim(); + const serverPrimary = style.getPropertyValue('--server-primary').trim(); + const globalPrimary = style.getPropertyValue('--primary-color').trim(); + + const inDocker = this.mainActiveTab === 'server' && this.serverCurrentTab === 'docker'; + const accentColor = inDocker + ? serverPrimary || currentPrimary || globalPrimary + : currentPrimary || globalPrimary || serverPrimary; + + const fallbackColor = bgColor || '#f4f6f8'; + const mixedColor = this._mixThemeColors( + fallbackColor, + accentColor, + inDocker ? 0.28 : 0.16 + ); - if (bgColor) { - this._setMetaThemeColor(bgColor); - } else { - this._setMetaThemeColor('#f4f6f8'); + this._setMetaThemeColor(mixedColor || accentColor || fallbackColor || '#f4f6f8'); + }); + }, + + _parseThemeColor(color) { + if (!color) return null; + const value = String(color).trim(); + + const hex3 = value.match(/^#([0-9a-f]{3})$/i); + if (hex3) { + const [r, g, b] = hex3[1].split(''); + return { + r: parseInt(r + r, 16), + g: parseInt(g + g, 16), + b: parseInt(b + b, 16), + }; + } + + const hex6 = value.match(/^#([0-9a-f]{6})$/i); + if (hex6) { + return { + r: parseInt(hex6[1].slice(0, 2), 16), + g: parseInt(hex6[1].slice(2, 4), 16), + b: parseInt(hex6[1].slice(4, 6), 16), + }; + } + + const rgb = value.match(/^rgba?\(([^)]+)\)$/i); + if (rgb) { + const parts = rgb[1] + .split(',') + .map(item => Number.parseFloat(item.trim())) + .filter(num => Number.isFinite(num)); + if (parts.length >= 3) { + return { + r: parts[0], + g: parts[1], + b: parts[2], + }; } + } + + return null; + }, + + _rgbToHex(rgb) { + if (!rgb) return ''; + const clamp = value => Math.max(0, Math.min(255, Math.round(value))); + const toHex = value => clamp(value).toString(16).padStart(2, '0'); + return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`; + }, + + _mixThemeColors(baseColor, accentColor, accentRatio = 0.16) { + const base = this._parseThemeColor(baseColor); + const accent = this._parseThemeColor(accentColor); + if (!base && !accent) return ''; + if (!base) return this._rgbToHex(accent); + if (!accent) return this._rgbToHex(base); + + const ratio = Math.max(0, Math.min(1, accentRatio)); + return this._rgbToHex({ + r: base.r * (1 - ratio) + accent.r * ratio, + g: base.g * (1 - ratio) + accent.g * ratio, + b: base.b * (1 - ratio) + accent.b * ratio, }); }, diff --git a/src/js/modules/host.js b/src/js/modules/host.js index 2627a4a..f48d147 100644 --- a/src/js/modules/host.js +++ b/src/js/modules/host.js @@ -865,35 +865,33 @@ export const hostMethods = { * @param {string} serverId - 服务器 ID */ async checkDockerUpdates(serverId) { + if (!serverId) { + return this.checkAllDockerUpdates(); + } if (this.dockerUpdateChecking) return; this.dockerUpdateChecking = true; this.dockerUpdateResults = []; try { - const response = await fetch('/api/server/docker/check-update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId }), - }); - - const data = await response.json(); - - if (data.success && Array.isArray(data.data)) { - this.dockerUpdateResults = data.data; + const task = await this.submitDockerTask( + 'container.checkUpdates', + { serverId }, + { timeoutMs: 240000 } + ); + const parsed = this.parseDockerTaskResult(task, []); + const results = Array.isArray(parsed) ? parsed : parsed ? [parsed] : []; + this.dockerUpdateResults = results; - const updatesAvailable = data.data.filter(r => r.has_update).length; - const errors = data.data.filter(r => r.error).length; + const updatesAvailable = results.filter(r => r.has_update).length; + const errors = results.filter(r => r.error).length; - if (updatesAvailable > 0) { - this.showGlobalToast(`发现 ${updatesAvailable} 个容器有更新可用`, 'success'); - } else if (errors > 0) { - this.showGlobalToast(`检测完成,${errors} 个容器检测失败 (可能是私有镜像)`, 'warning'); - } else { - this.showGlobalToast('所有容器镜像均为最新', 'info'); - } + if (updatesAvailable > 0) { + this.showGlobalToast(`发现 ${updatesAvailable} 个容器有更新可用`, 'success'); + } else if (errors > 0) { + this.showGlobalToast(`检测完成,${errors} 个容器检测失败 (可能是私有镜像)`, 'warning'); } else { - this.showGlobalToast('检测失败: ' + (data.error || '未知错误'), 'error'); + this.showGlobalToast('所有容器镜像均为最新', 'info'); } } catch (error) { console.error('检测更新失败:', error); @@ -1008,34 +1006,423 @@ export const hostMethods = { /** * 加载 Docker 概览数据 - * 从所有在线主机中提取 Docker 信息 */ - loadDockerOverview() { + async loadDockerOverview() { this.dockerOverviewLoading = true; - this.dockerUpdateResults = []; // 清除上次检测结果 + this.dockerUpdateResults = []; + this.ensureDockerTaskStream(); try { - // 从 serverList 中提取所有有 Docker 数据的主机 - const dockerServers = []; + const response = await fetch('/api/server/v2/docker/overview'); + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || '加载 Docker 概览失败'); + } + + const dockerServers = (data.data?.servers || []).map(server => ({ + id: server.serverId, + name: server.serverName, + host: server.host, + containers: server.docker?.containers || [], + docker: server.docker || {}, + resources: server.resources || {}, + })); - for (const server of this.serverList) { - if (server.status !== 'online') continue; - if (!server.info?.docker?.installed) continue; + this.dockerOverviewServers = dockerServers; - dockerServers.push({ - id: server.id, - name: server.name, - host: server.host, - containers: server.info.docker.containers || [], - }); + // 保持主机筛选有效,若当前值已失效则自动回退 + if ( + this.dockerSelectedServer && + !dockerServers.some(s => s.id === this.dockerSelectedServer) + ) { + this.dockerSelectedServer = ''; } - this.dockerOverviewServers = dockerServers; + // 如果当前聚焦的容器不在列表中,自动清空详情面板 + const allKeys = []; + for (const server of dockerServers) { + for (const container of server.containers || []) { + allKeys.push(this.getDockerContainerKey(server.id, container.id)); + } + } + if ( + this.dockerFocusedContainerKey && + !allKeys.includes(this.dockerFocusedContainerKey) + ) { + this.dockerFocusedContainerKey = ''; + } + } catch (error) { + this.showGlobalToast('加载 Docker 概览失败: ' + error.message, 'error'); } finally { this.dockerOverviewLoading = false; } }, + switchDockerSubTab(tab) { + this.dockerSubTab = tab; + this.ensureDockerTaskStream(); + this.loadDockerResources(); + }, + + ensureDockerTaskStream() { + if (this.dockerTaskStream) return; + + if (typeof window === 'undefined' || typeof window.EventSource !== 'function') { + this.dockerTaskStreamConnected = false; + this.dockerTaskStreamError = '当前浏览器不支持任务流'; + return; + } + + const stream = new window.EventSource('/api/server/v2/tasks/stream'); + this.dockerTaskStream = stream; + + stream.addEventListener('ready', () => { + this.dockerTaskStreamConnected = true; + this.dockerTaskStreamError = ''; + }); + + stream.addEventListener('task.update', event => { + try { + const task = JSON.parse(event.data); + this.upsertDockerTask(task); + } catch (error) { + console.warn('[Docker] 任务流解析失败:', error); + } + }); + + stream.onerror = () => { + this.dockerTaskStreamConnected = false; + this.dockerTaskStreamError = '任务流连接异常,正在重连...'; + }; + }, + + closeDockerTaskStream() { + if (this.dockerTaskStream) { + this.dockerTaskStream.close(); + this.dockerTaskStream = null; + } + this.dockerTaskStreamConnected = false; + }, + + upsertDockerTask(task) { + if (!task || task.domain !== 'docker') return; + + const list = this.dockerTasks || []; + const idx = list.findIndex(item => item.taskId === task.taskId); + if (idx >= 0) { + list[idx] = { ...list[idx], ...task }; + } else { + list.unshift(task); + } + + list.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + if (list.length > 200) { + list.length = 200; + } + + this.dockerTasks = [...list]; + }, + + isDockerTaskDone(task) { + return ['success', 'failed', 'timeout', 'cancelled'].includes(task?.state); + }, + + getDockerTaskStateLabel(state) { + const map = { + running: '执行中', + success: '成功', + failed: '失败', + timeout: '超时', + cancelled: '已取消', + }; + return map[state] || state || '未知'; + }, + + getDockerTaskStateClass(state) { + if (state === 'success') return 'success'; + if (state === 'failed' || state === 'timeout') return 'danger'; + if (state === 'running') return 'warning'; + return 'default'; + }, + + getDockerTaskActionLabel(action) { + const map = { + 'container.start': '启动容器', + 'container.stop': '停止容器', + 'container.restart': '重启容器', + 'container.pause': '暂停容器', + 'container.unpause': '恢复容器', + 'container.pull': '拉取容器镜像', + 'container.update': '更新容器', + 'container.rename': '重命名容器', + 'container.logs': '读取日志', + 'container.checkUpdates': '检测容器更新', + 'container.create': '创建容器', + 'image.list': '读取镜像列表', + 'image.pull': '拉取镜像', + 'image.remove': '删除镜像', + 'image.prune': '清理镜像', + 'network.list': '读取网络列表', + 'network.create': '创建网络', + 'network.remove': '删除网络', + 'network.connect': '连接网络', + 'network.disconnect': '断开网络', + 'volume.list': '读取存储卷列表', + 'volume.create': '创建存储卷', + 'volume.remove': '删除存储卷', + 'volume.prune': '清理存储卷', + 'stats.list': '读取资源监控', + 'compose.list': '读取 Compose 项目', + 'compose.up': '启动 Compose', + 'compose.down': '停止 Compose', + 'compose.restart': '重启 Compose', + 'compose.pull': '拉取 Compose 镜像', + }; + return map[action] || action || '未知动作'; + }, + + getDockerTaskServerName(serverId) { + const server = (this.dockerOverviewServers || []).find(item => item.id === serverId); + return server?.name || serverId || '未知主机'; + }, + + getDockerTaskMessage(task) { + return task?.error || task?.detail || task?.message || '-'; + }, + + parseDockerTaskResult(task, fallback) { + if (!task || task.result === undefined || task.result === null) return fallback; + if (Array.isArray(task.result) || typeof task.result === 'object') return task.result; + try { + return JSON.parse(task.result); + } catch (error) { + return fallback; + } + }, + + async waitForDockerTask(taskId, options = {}) { + const timeoutMs = options.timeoutMs || 120000; + const pollIntervalMs = options.pollIntervalMs || 1200; + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const fromStream = (this.dockerTasks || []).find(item => item.taskId === taskId); + if (fromStream && this.isDockerTaskDone(fromStream)) { + return fromStream; + } + + try { + const response = await fetch(`/api/server/v2/tasks/${taskId}`); + const data = await response.json(); + if (data.success && data.data) { + this.upsertDockerTask(data.data); + if (this.isDockerTaskDone(data.data)) { + return data.data; + } + } + } catch (error) { + // 网络抖动时继续轮询 + } + + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error('任务等待超时'); + }, + + async submitDockerTask(action, payload = {}, options = {}) { + const serverId = payload.serverId || this.dockerSelectedServer; + if (!serverId) { + throw new Error('请先选择主机'); + } + + this.ensureDockerTaskStream(); + + const response = await fetch('/api/server/v2/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverId, + domain: 'docker', + action, + payload, + }), + }); + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || '任务提交失败'); + } + + const taskId = data.data?.taskId; + if (!taskId) { + throw new Error('任务 ID 缺失'); + } + + if (options.wait === false) { + return { taskId }; + } + + const task = await this.waitForDockerTask(taskId, { + timeoutMs: options.timeoutMs || 180000, + pollIntervalMs: options.pollIntervalMs || 1200, + }); + if (task.state !== 'success') { + throw new Error(task.error || task.message || '任务执行失败'); + } + return task; + }, + + async fetchSelectedDockerOverview() { + if (!this.dockerSelectedServer) return null; + const response = await fetch( + `/api/server/v2/docker/overview?serverId=${encodeURIComponent(this.dockerSelectedServer)}` + ); + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || '加载主机 Docker 数据失败'); + } + return (data.data?.servers || [])[0] || null; + }, + + getDockerContainerKey(serverId, containerId) { + return `${serverId}::${containerId}`; + }, + + getDockerContainerState(container) { + const state = String(container?.state || '').toLowerCase(); + const status = String(container?.status || ''); + + if (state === 'running' || (status.includes('Up') && !status.includes('Paused'))) { + return 'running'; + } + if (state === 'paused' || status.includes('Paused')) { + return 'paused'; + } + return 'stopped'; + }, + + getDockerContainerStateLabel(container) { + const state = this.getDockerContainerState(container); + if (state === 'running') return '运行中'; + if (state === 'paused') return '已暂停'; + return '已停止'; + }, + + formatDockerPorts(container) { + if (!container) return '-'; + if (Array.isArray(container.ports) && container.ports.length > 0) { + return container.ports.join(', '); + } + if (typeof container.ports === 'string' && container.ports.trim()) { + return container.ports; + } + if (typeof container.port === 'string' && container.port.trim()) { + return container.port; + } + return '-'; + }, + + formatDockerContainerId(id) { + if (!id) return '-'; + return String(id).slice(0, 12); + }, + + getDockerSummaryMetrics() { + const hosts = this.dockerOverviewServers || []; + const allContainers = hosts.flatMap(s => s.containers || []); + const running = allContainers.filter(c => this.getDockerContainerState(c) === 'running').length; + const paused = allContainers.filter(c => this.getDockerContainerState(c) === 'paused').length; + const stopped = Math.max(0, allContainers.length - running - paused); + const updates = (this.dockerUpdateResults || []).filter(r => r.has_update).length; + + return { + hosts: hosts.length, + containers: allContainers.length, + running, + paused, + stopped, + updates, + }; + }, + + getDockerContainerRows() { + const query = (this.dockerSearchQuery || '').trim().toLowerCase(); + const stateFilter = this.dockerContainerStateFilter || 'all'; + const selectedServerId = this.dockerSelectedServer; + const rows = []; + + for (const server of this.dockerOverviewServers || []) { + if (selectedServerId && server.id !== selectedServerId) continue; + + for (const container of server.containers || []) { + const state = this.getDockerContainerState(container); + if (stateFilter !== 'all' && state !== stateFilter) continue; + + const row = { + serverId: server.id, + serverName: server.name, + serverHost: server.host, + container, + state, + stateLabel: this.getDockerContainerStateLabel(container), + portsText: this.formatDockerPorts(container), + shortId: this.formatDockerContainerId(container.id), + key: this.getDockerContainerKey(server.id, container.id), + searchableText: [ + server.name, + server.host, + container.name, + container.image, + container.id, + container.status, + this.formatDockerPorts(container), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(), + }; + + if (query && !row.searchableText.includes(query)) continue; + rows.push(row); + } + } + + return rows.sort((a, b) => { + if (a.state === b.state) { + return a.container.name.localeCompare(b.container.name); + } + // running > paused > stopped + const rank = { running: 0, paused: 1, stopped: 2 }; + return rank[a.state] - rank[b.state]; + }); + }, + + selectDockerContainer(serverId, container) { + this.dockerFocusedContainerKey = this.getDockerContainerKey(serverId, container.id); + }, + + getFocusedDockerContainer() { + if (!this.dockerFocusedContainerKey) return null; + + const [serverId, containerId] = String(this.dockerFocusedContainerKey).split('::'); + const server = (this.dockerOverviewServers || []).find(s => s.id === serverId); + if (!server) return null; + const container = (server.containers || []).find(c => c.id === containerId); + if (!container) return null; + + return { + serverId: server.id, + serverName: server.name, + serverHost: server.host, + container, + state: this.getDockerContainerState(container), + stateLabel: this.getDockerContainerStateLabel(container), + portsText: this.formatDockerPorts(container), + shortId: this.formatDockerContainerId(container.id), + key: this.getDockerContainerKey(server.id, container.id), + }; + }, + /** * 批量检查所有主机的 Docker 更新 */ @@ -1053,28 +1440,31 @@ export const hostMethods = { let totalErrors = 0; try { - // 逐个检测每台主机 - for (const dockerServer of this.dockerOverviewServers) { - try { - const response = await fetch('/api/server/docker/check-update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId: dockerServer.id }), - }); - - const data = await response.json(); + const jobs = this.dockerOverviewServers.map(server => + this.submitDockerTask( + 'container.checkUpdates', + { serverId: server.id }, + { timeoutMs: 240000 } + ) + ); - if (data.success && Array.isArray(data.data)) { - // 合并结果 - this.dockerUpdateResults = [...this.dockerUpdateResults, ...data.data]; - totalUpdates += data.data.filter(r => r.has_update).length; - totalErrors += data.data.filter(r => r.error).length; + const settled = await Promise.allSettled(jobs); + const merged = []; + settled.forEach((item, index) => { + if (item.status === 'fulfilled') { + const parsed = this.parseDockerTaskResult(item.value, []); + if (Array.isArray(parsed)) { + merged.push(...parsed); } - } catch (e) { - console.error(`检测主机 ${dockerServer.name} 失败:`, e); + } else { totalErrors++; + console.error(`检测主机 ${this.dockerOverviewServers[index].name} 失败:`, item.reason); } - } + }); + + this.dockerUpdateResults = merged; + totalUpdates = merged.filter(r => r.has_update).length; + totalErrors += merged.filter(r => r.error).length; if (totalUpdates > 0) { this.showGlobalToast(`发现 ${totalUpdates} 个容器有更新可用`, 'success'); @@ -1105,19 +1495,12 @@ export const hostMethods = { this.showGlobalToast('容器更新任务已启动...', 'info'); try { - const response = await fetch('/api/server/docker/container/update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId, containerId, containerName, image }), - }); - - const data = await response.json(); - if (data.success) { - this.showGlobalToast('更新任务已提交,请等待完成', 'success'); - // 后续可以通过 WebSocket 接收进度更新 - } else { - this.showGlobalToast('启动更新任务失败: ' + data.error, 'error'); - } + const { taskId } = await this.submitDockerTask( + 'container.update', + { serverId, containerId, containerName, image }, + { wait: false, timeoutMs: 10 * 60 * 1000 } + ); + this.showGlobalToast(`更新任务已提交(#${taskId.slice(0, 8)})`, 'success'); } catch (error) { this.showGlobalToast('请求失败: ' + error.message, 'error'); } @@ -1137,20 +1520,13 @@ export const hostMethods = { if (!newName || newName === currentName) return; try { - const response = await fetch('/api/server/docker/container/rename', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId, containerId, newName }), - }); - - const data = await response.json(); - if (data.success) { - this.showGlobalToast('容器已重命名为: ' + newName, 'success'); - // 刷新容器列表 - this.loadDockerOverview(); - } else { - this.showGlobalToast('重命名失败: ' + data.error, 'error'); - } + await this.submitDockerTask( + 'container.rename', + { serverId, containerId, newName }, + { timeoutMs: 60000 } + ); + this.showGlobalToast('容器已重命名为: ' + newName, 'success'); + await this.loadDockerOverview(); } catch (error) { this.showGlobalToast('请求失败: ' + error.message, 'error'); } @@ -1161,11 +1537,26 @@ export const hostMethods = { */ loadDockerResources() { switch (this.dockerSubTab) { - case 'images': this.loadDockerImages(); break; - case 'networks': this.loadDockerNetworks(); break; - case 'volumes': this.loadDockerVolumes(); break; - case 'stats': this.loadDockerStats(); break; - default: this.loadDockerOverview(); + case 'containers': + this.loadDockerOverview(); + break; + case 'compose': + this.loadDockerComposeProjects(); + break; + case 'images': + this.loadDockerImages(); + break; + case 'networks': + this.loadDockerNetworks(); + break; + case 'volumes': + this.loadDockerVolumes(); + break; + case 'stats': + this.loadDockerStats(); + break; + default: + this.loadDockerOverview(); } }, @@ -1178,19 +1569,10 @@ export const hostMethods = { this.dockerImages = []; try { - const response = await fetch('/api/server/docker/images', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId: this.dockerSelectedServer }), - }); - const data = await response.json(); - if (data.success) { - this.dockerImages = data.data || []; - } else { - this.showGlobalToast('加载镜像失败: ' + data.error, 'error'); - } - } catch (e) { - this.showGlobalToast('加载镜像失败: ' + e.message, 'error'); + const overview = await this.fetchSelectedDockerOverview(); + this.dockerImages = overview?.resources?.images || []; + } catch (error) { + this.showGlobalToast('加载镜像失败: ' + error.message, 'error'); } finally { this.dockerResourceLoading = false; } @@ -1203,20 +1585,17 @@ export const hostMethods = { if (!this.dockerSelectedServer) return; try { - const response = await fetch('/api/server/docker/image/action', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId: this.dockerSelectedServer, action, image }), - }); - const data = await response.json(); - if (data.success) { - this.showGlobalToast(data.message || '操作成功', 'success'); - this.loadDockerImages(); // 刷新列表 - } else { - this.showGlobalToast('操作失败: ' + data.error, 'error'); - } - } catch (e) { - this.showGlobalToast('操作失败: ' + e.message, 'error'); + const mappedAction = `image.${action}`; + await this.submitDockerTask( + mappedAction, + { serverId: this.dockerSelectedServer, image }, + { timeoutMs: action === 'pull' ? 300000 : 60000 } + ); + this.showGlobalToast('操作成功', 'success'); + await this.loadDockerImages(); + await this.loadDockerOverview(); + } catch (error) { + this.showGlobalToast('操作失败: ' + error.message, 'error'); } }, @@ -1229,19 +1608,10 @@ export const hostMethods = { this.dockerNetworks = []; try { - const response = await fetch('/api/server/docker/networks', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId: this.dockerSelectedServer }), - }); - const data = await response.json(); - if (data.success) { - this.dockerNetworks = data.data || []; - } else { - this.showGlobalToast('加载网络失败: ' + data.error, 'error'); - } - } catch (e) { - this.showGlobalToast('加载网络失败: ' + e.message, 'error'); + const overview = await this.fetchSelectedDockerOverview(); + this.dockerNetworks = overview?.resources?.networks || []; + } catch (error) { + this.showGlobalToast('加载网络失败: ' + error.message, 'error'); } finally { this.dockerResourceLoading = false; } @@ -1254,20 +1624,16 @@ export const hostMethods = { if (!this.dockerSelectedServer) return; try { - const response = await fetch('/api/server/docker/network/action', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId: this.dockerSelectedServer, action, name }), - }); - const data = await response.json(); - if (data.success) { - this.showGlobalToast(data.message || '操作成功', 'success'); - this.loadDockerNetworks(); - } else { - this.showGlobalToast('操作失败: ' + data.error, 'error'); - } - } catch (e) { - this.showGlobalToast('操作失败: ' + e.message, 'error'); + await this.submitDockerTask( + `network.${action}`, + { serverId: this.dockerSelectedServer, name }, + { timeoutMs: 60000 } + ); + this.showGlobalToast('操作成功', 'success'); + await this.loadDockerNetworks(); + await this.loadDockerOverview(); + } catch (error) { + this.showGlobalToast('操作失败: ' + error.message, 'error'); } }, @@ -1280,19 +1646,10 @@ export const hostMethods = { this.dockerVolumes = []; try { - const response = await fetch('/api/server/docker/volumes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId: this.dockerSelectedServer }), - }); - const data = await response.json(); - if (data.success) { - this.dockerVolumes = data.data || []; - } else { - this.showGlobalToast('加载 Volume 失败: ' + data.error, 'error'); - } - } catch (e) { - this.showGlobalToast('加载 Volume 失败: ' + e.message, 'error'); + const overview = await this.fetchSelectedDockerOverview(); + this.dockerVolumes = overview?.resources?.volumes || []; + } catch (error) { + this.showGlobalToast('加载 Volume 失败: ' + error.message, 'error'); } finally { this.dockerResourceLoading = false; } @@ -1305,20 +1662,16 @@ export const hostMethods = { if (!this.dockerSelectedServer) return; try { - const response = await fetch('/api/server/docker/volume/action', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId: this.dockerSelectedServer, action, name }), - }); - const data = await response.json(); - if (data.success) { - this.showGlobalToast(data.message || '操作成功', 'success'); - this.loadDockerVolumes(); - } else { - this.showGlobalToast('操作失败: ' + data.error, 'error'); - } - } catch (e) { - this.showGlobalToast('操作失败: ' + e.message, 'error'); + await this.submitDockerTask( + `volume.${action}`, + { serverId: this.dockerSelectedServer, name }, + { timeoutMs: 60000 } + ); + this.showGlobalToast('操作成功', 'success'); + await this.loadDockerVolumes(); + await this.loadDockerOverview(); + } catch (error) { + this.showGlobalToast('操作失败: ' + error.message, 'error'); } }, @@ -1331,19 +1684,10 @@ export const hostMethods = { this.dockerStats = []; try { - const response = await fetch('/api/server/docker/stats', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId: this.dockerSelectedServer }), - }); - const data = await response.json(); - if (data.success) { - this.dockerStats = data.data || []; - } else { - this.showGlobalToast('加载统计失败: ' + data.error, 'error'); - } - } catch (e) { - this.showGlobalToast('加载统计失败: ' + e.message, 'error'); + const overview = await this.fetchSelectedDockerOverview(); + this.dockerStats = overview?.resources?.stats || []; + } catch (error) { + this.showGlobalToast('加载统计失败: ' + error.message, 'error'); } finally { this.dockerResourceLoading = false; } @@ -1369,23 +1713,23 @@ export const hostMethods = { this.dockerLogsLoading = true; try { - const response = await fetch('/api/server/docker/logs', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const task = await this.submitDockerTask( + 'container.logs', + { serverId: this.dockerLogsServerId, containerId: this.dockerLogsContainerId, tail: this.dockerLogsTail, - }), - }); - const data = await response.json(); - if (data.success) { - this.dockerLogsContent = data.data || '(空日志)'; - } else { - this.dockerLogsContent = '加载失败: ' + data.error; - } - } catch (e) { - this.dockerLogsContent = '加载失败: ' + e.message; + }, + { timeoutMs: 60000 } + ); + + const content = + typeof task.result === 'string' + ? task.result + : JSON.stringify(task.result || '(空日志)', null, 2); + this.dockerLogsContent = content || '(空日志)'; + } catch (error) { + this.dockerLogsContent = '加载失败: ' + error.message; } finally { this.dockerLogsLoading = false; } @@ -1402,19 +1746,10 @@ export const hostMethods = { this.dockerComposeProjects = []; try { - const response = await fetch('/api/server/docker/compose/list', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId: this.dockerSelectedServer }), - }); - const data = await response.json(); - if (data.success) { - this.dockerComposeProjects = data.data || []; - } else { - this.showGlobalToast('加载 Compose 失败: ' + data.error, 'error'); - } - } catch (e) { - this.showGlobalToast('加载 Compose 失败: ' + e.message, 'error'); + const overview = await this.fetchSelectedDockerOverview(); + this.dockerComposeProjects = overview?.resources?.composeProjects || []; + } catch (error) { + this.showGlobalToast('加载 Compose 失败: ' + error.message, 'error'); } finally { this.dockerResourceLoading = false; } @@ -1428,25 +1763,19 @@ export const hostMethods = { try { this.showGlobalToast(`正在${action === 'up' ? '启动' : action === 'down' ? '停止' : '执行'} ${project}...`, 'info'); - - const response = await fetch('/api/server/docker/compose/action', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + await this.submitDockerTask( + `compose.${action}`, + { serverId: this.dockerSelectedServer, - action, - project - }), - }); - const data = await response.json(); - if (data.success) { - this.showGlobalToast(data.message || '操作成功', 'success'); - this.loadDockerComposeProjects(); - } else { - this.showGlobalToast('操作失败: ' + data.error, 'error'); - } - } catch (e) { - this.showGlobalToast('操作失败: ' + e.message, 'error'); + project, + }, + { timeoutMs: action === 'pull' ? 300000 : 120000 } + ); + this.showGlobalToast('操作成功', 'success'); + await this.loadDockerComposeProjects(); + await this.loadDockerOverview(); + } catch (error) { + this.showGlobalToast('操作失败: ' + error.message, 'error'); } }, @@ -1505,10 +1834,9 @@ export const hostMethods = { if (key) env[key.trim()] = valueParts.join('=').trim(); }); - const response = await fetch('/api/server/docker/container/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + await this.submitDockerTask( + 'container.create', + { serverId: this.dockerSelectedServer, name: this.createContainerForm.name, image: this.createContainerForm.image, @@ -1517,18 +1845,14 @@ export const hostMethods = { env, network: this.createContainerForm.network, restart: this.createContainerForm.restart, - }), - }); - const data = await response.json(); - if (data.success) { - this.showGlobalToast(data.message || '容器创建成功', 'success'); - this.showCreateContainerModal = false; - this.loadDockerOverview(); // 刷新容器列表 - } else { - this.showGlobalToast('创建失败: ' + data.error, 'error'); - } - } catch (e) { - this.showGlobalToast('创建失败: ' + e.message, 'error'); + }, + { timeoutMs: 300000 } + ); + this.showGlobalToast('容器创建成功', 'success'); + this.showCreateContainerModal = false; + await this.loadDockerOverview(); + } catch (error) { + this.showGlobalToast('创建失败: ' + error.message, 'error'); } finally { this.createContainerLoading = false; } @@ -1572,8 +1896,6 @@ export const hostMethods = { }, async handleDockerAction(serverId, containerId, action) { - const server = this.serverList.find(s => s.id === serverId); - // 找到目标容器并设置 loading 状态 const dockerServer = this.dockerOverviewServers.find(s => s.id === serverId); const container = dockerServer?.containers?.find(c => c.id === containerId); @@ -1582,39 +1904,16 @@ export const hostMethods = { } try { - const response = await fetch('/api/server/docker/action', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverId, containerId, action }), - }); - const data = await response.json(); - if (data.success) { - this.showGlobalToast(data.message || 'Docker 操作已执行', 'success'); - - // 立即更新本地状态(乐观更新) - if (this.currentTab === 'docker') { - if (container) { - // 根据操作类型预测新状态 - if (action === 'start') container.status = 'Up Just now'; - else if (action === 'stop') container.status = 'Exited'; - else if (action === 'restart') container.status = 'Up Just now'; - } - } - - // 500ms 后从服务器获取准确状态 - setTimeout(async () => { - await this.loadServerInfo(serverId); - if (this.currentTab === 'docker') { - this.loadDockerOverview(); - } - }, 500); - } else { - this.showGlobalToast('操作失败: ' + (data.error || data.message || '未知错误'), 'error'); - } + await this.submitDockerTask( + `container.${action}`, + { serverId, containerId }, + { timeoutMs: 120000 } + ); + this.showGlobalToast('操作成功', 'success'); + await this.loadDockerOverview(); } catch (error) { this.showGlobalToast('Docker 操作异常: ' + error.message, 'error'); } finally { - if (server) server.loading = false; if (container) container.actionPending = false; } }, diff --git a/src/templates/server.html b/src/templates/server.html index 917ea83..2fbcdb3 100644 --- a/src/templates/server.html +++ b/src/templates/server.html @@ -1348,214 +1348,215 @@

Docker 容器

-
- - -
- -
+
+
+
- -
- - + - -
- - - - - - +
- -
- - -
-
-

正在加载 Docker 信息...

+
+
+
+ + {{ dockerTaskStreamConnected ? '任务流已连接' : '任务流重连中' }} +
+ 最近 {{ Math.min(dockerTasks.length, 8) }} / {{ dockerTasks.length }} 条任务
- - -
- -

暂无 Docker 主机

-

在线主机中未检测到安装 Docker 的主机

+
{{ dockerTaskStreamError }}
+
+ 暂无 Docker 任务记录,执行任一操作后会在这里实时显示
+
+
+ + {{ getDockerTaskStateLabel(task.state) }} + +
+
+ {{ getDockerTaskActionLabel(task.action) }} + {{ getDockerTaskServerName(task.serverId) }} +
+
{{ getDockerTaskMessage(task) }}
+
+ {{ formatDateTime(task.updatedAt || task.createdAt) }} +
+
+
- -
-
+
+
+
+
主机
+
{{ getDockerSummaryMetrics().hosts }}
+
+
+
容器总数
+
{{ getDockerSummaryMetrics().containers }}
+
+
+
运行中
+
{{ getDockerSummaryMetrics().running }}
+
+
+
可更新
+
{{ getDockerSummaryMetrics().updates }}
+
+
- -
- -
- -
- -
+
+ + + + +
- -
-
- {{ dockerServer.name - }} +
+
+

正在加载 Docker 主机

+

正在同步主机侧容器信息...

+
- - - {{ getRunningContainers(dockerServer.containers) }}/{{ dockerServer.containers?.length || 0 }} 运行中 - +
+
+

暂无 Docker 主机

+

在线主机中未检测到已安装 Docker 的节点

+
- - - {{ - getDockerServerUpdateCount(dockerServer.id) }} 可更新 - -
-
+
+
+
+
+ 容器 + 镜像 + 主机 + 状态 + 端口 + 操作
- - -
- - - +
+ +
{{ row.container.name }}
+
{{ row.shortId }}
+
+ +
{{ row.container.image }}
+
+ +
{{ row.serverName }}
+
{{ row.serverHost }}
+
+ + {{ row.stateLabel }} + + +
{{ row.portsText }}
+
+ + + + + + +
+
+ 当前筛选条件下没有容器
+
- -
-
- -
- -
-
- -
-
-
-
{{ container.name }}
-
- 可更新 -
-
-
{{ container.image }}
-
-
- - -
-
- - {{ container.status }} -
-
- - -
- - - - -
- - - - -
-
- - -
- -

暂无容器

-
+
+
+
+
{{ getFocusedDockerContainer().container.name }}
+
{{ getFocusedDockerContainer().shortId }}
+
+ + {{ getFocusedDockerContainer().stateLabel }} + +
+
+
+ 主机 + {{ getFocusedDockerContainer().serverName }} +
+
+ 镜像 + {{ getFocusedDockerContainer().container.image }} +
+
+ 端口 + {{ getFocusedDockerContainer().portsText }} +
+
+ 原始状态 + {{ getFocusedDockerContainer().container.status || '-' }}
@@ -1792,7 +1793,7 @@

暂无 Docker 主机

@@ -3015,16 +3016,16 @@

暂无 Docker 主机

- \ No newline at end of file + From cb3c6b69eab7e6fb09388f0b4dce9a63b9df9ef7 Mon Sep 17 00:00:00 2001 From: iwvw <2285740204@qq.com> Date: Sat, 7 Feb 2026 05:38:22 +0800 Subject: [PATCH 02/33] chore: sync remaining local changes --- modules/filebox-api/service.js | 28 +- server.js | 18 +- src/css/ai-draw.css | 830 ++++++++++++++++++++++----------- src/css/dashboard.css | 2 +- src/css/filebox.css | 240 +++++++++- src/css/styles.css | 4 +- src/index.html | 5 +- src/js/modules/ai-chat.js | 11 +- src/js/modules/ai-draw.js | 32 +- src/js/modules/filebox.js | 242 +++++++--- src/templates/ai-draw.html | 271 +++++------ src/templates/filebox.html | 120 +++-- 12 files changed, 1257 insertions(+), 546 deletions(-) diff --git a/modules/filebox-api/service.js b/modules/filebox-api/service.js index c9d19d4..a8ce2b8 100644 --- a/modules/filebox-api/service.js +++ b/modules/filebox-api/service.js @@ -89,7 +89,8 @@ class FileBoxService { // Unique filename const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - const saveFilename = `${uniqueSuffix}-${fileObj.name}`; + const safeName = this._sanitizeFilename(fileObj.name || 'upload.bin'); + const saveFilename = `${uniqueSuffix}-${safeName}`; const savePath = path.join(this.uploadsDir, saveFilename); // Save file @@ -170,7 +171,9 @@ class FileBoxService { getAll() { this.cleanupExpired(); - return Object.values(this.fileStore).sort((a, b) => b.createdAt - a.createdAt); + return Object.values(this.fileStore) + .map(entry => this._toPublicEntry(entry)) + .sort((a, b) => b.createdAt - a.createdAt); } cleanupExpired() { @@ -181,6 +184,27 @@ class FileBoxService { } }); } + + _sanitizeFilename(name) { + // 清洗掉路径与危险字符,保留可读性 + return path + .basename(String(name)) + .replace(/[<>:\"/\\\\|?*\\x00-\\x1F]/g, '_') + .slice(0, 180); + } + + _toPublicEntry(entry) { + if (!entry) return entry; + const { path: _filePath, content, ...rest } = entry; + // 文本内容不在历史接口直接返回,避免大对象传输 + if (entry.type === 'text') { + return { + ...rest, + preview: typeof content === 'string' ? content.slice(0, 80) : '', + }; + } + return rest; + } } module.exports = new FileBoxService(); diff --git a/server.js b/server.js index 11be9fa..b03ec04 100644 --- a/server.js +++ b/server.js @@ -163,11 +163,17 @@ if (!fs.existsSync(distDir)) { // 文件上传中间件 const fileUpload = require('express-fileupload'); +const uploadTempDir = path.join(__dirname, 'data', 'tmp', 'uploads'); +if (!fs.existsSync(uploadTempDir)) { + fs.mkdirSync(uploadTempDir, { recursive: true }); +} app.use( fileUpload({ limits: { fileSize: 100 * 1024 * 1024 }, // 100MB 限制 abortOnLimit: true, createParentPath: true, + useTempFiles: true, + tempFileDir: uploadTempDir, }) ); @@ -211,7 +217,17 @@ app.post('/api/chat/upload-image', requireAuth, (req, res) => { const image = req.files.image; const crypto = require('crypto'); - const hash = crypto.createHash('md5').update(image.data).digest('hex'); + let hash = ''; + if (image.data && image.data.length > 0) { + hash = crypto.createHash('md5').update(image.data).digest('hex'); + } else if (image.tempFilePath && fs.existsSync(image.tempFilePath)) { + hash = crypto + .createHash('md5') + .update(fs.readFileSync(image.tempFilePath)) + .digest('hex'); + } else { + hash = crypto.randomBytes(16).toString('hex'); + } const ext = path.extname(image.name) || '.jpg'; const fileName = `${hash}${ext}`; const uploadPath = path.join(chatImagesDir, fileName); diff --git a/src/css/ai-draw.css b/src/css/ai-draw.css index 2fb253b..1c9b43b 100644 --- a/src/css/ai-draw.css +++ b/src/css/ai-draw.css @@ -1,425 +1,709 @@ /** - * AI Draw 模块样式 - 符合全站设计规范 + * AI Draw 模块样式 + * 目标:与全站 panel/form/button/table 体系保持一致 */ -/* ==================== 主题配色 ==================== */ .theme-ai-draw { - --current-primary: #8b5cf6; - --current-dark: #7c3aed; - --current-rgb: 139, 92, 246; + --current-primary: var(--paas-primary); + --current-dark: var(--paas-dark); + --current-rgb: 99, 102, 241; } .theme-ai-draw .tab-btn.active { - background: linear-gradient(135deg, var(--current-primary), var(--current-dark)) !important; - box-shadow: 0 2px 8px rgba(var(--current-rgb), 0.3); + background: linear-gradient(135deg, var(--current-primary), var(--current-dark)) !important; + box-shadow: 0 2px 8px rgba(var(--current-rgb), 0.28); +} + +.ai-draw-title i { + margin-right: 8px; + color: var(--current-primary); +} + +.ai-draw-counter { + font-weight: 400; + opacity: 0.65; + margin-left: 8px; +} + +.ai-draw-panel-header { + gap: 12px; + flex-wrap: wrap; +} + +.ai-draw-actions-bar { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.ai-draw-search-box { + min-width: 180px; +} + +.ai-draw-create-dropdown { + position: relative; +} + +.ai-draw-chevron { + margin-left: 4px; + font-size: 10px; +} + +.ai-draw-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + min-width: 190px; + z-index: 100; + overflow: hidden; +} + +.ai-draw-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + border: 0; + background: none; + color: var(--text-primary); + cursor: pointer; + font-size: 14px; + text-align: left; +} + +.ai-draw-menu-item:hover, +.ai-draw-menu-item:focus-visible { + background: var(--bg-secondary); +} + +.ai-draw-state { + padding: 60px; +} + +.ai-draw-loading-text { + margin-top: 12px; +} + +.ai-draw-empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.3; +} + +.ai-draw-empty-btn { + margin-top: 16px; +} + +.ai-draw-col-name { + width: 50%; +} + +.ai-draw-col-type { + width: 15%; +} + +.ai-draw-col-updated { + width: 20%; +} + +.ai-draw-col-actions { + width: 15%; +} + +.ai-draw-project-cell { + display: flex; + align-items: center; + gap: 10px; +} + +.ai-draw-project-title { + font-weight: 700; + color: var(--text-primary); + border: 0; + background: transparent; + cursor: pointer; + text-align: left; +} + +.ai-draw-project-title:hover, +.ai-draw-project-title:focus-visible { + color: var(--current-primary); +} + +.ai-draw-danger-btn { + color: var(--danger-color); } -/* 记录类型标签 */ .record-type.mermaid { - background: rgba(139, 92, 246, 0.15); - color: #a78bfa; + background: rgba(var(--current-rgb), 0.15); + color: #a5b4fc; } .record-type.drawio { - background: rgba(249, 115, 22, 0.15); - color: #fb923c; + background: rgba(249, 115, 22, 0.15); + color: #fb923c; } -/* ==================== 编辑器容器 ==================== */ -.ai-draw-editor-container { - display: flex; - height: calc(100vh - 220px); - min-height: 500px; - gap: 0; +.ai-draw-editor-toolbar { + margin-bottom: 16px; + gap: 12px; + flex-wrap: wrap; } -.ai-draw-editor-main { - flex: 1; - min-width: 0; - display: flex; - transition: margin-right 0.3s ease; +.ai-draw-editor-title { + gap: 8px; + display: flex; + align-items: center; + flex-wrap: nowrap; +} + +.ai-draw-back-btn { + margin-right: 6px; } -.ai-draw-editor-main.chat-visible { - margin-right: 0; +.ai-draw-title-input { + max-width: 320px; + font-weight: 600; +} + +.ai-draw-editor-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.ai-draw-editor-container { + display: flex; + min-height: 540px; + height: calc(100dvh - 220px); + max-height: 900px; + gap: 0; +} + +.ai-draw-editor-main { + flex: 1; + min-width: 0; + display: flex; } -/* ==================== Mermaid 分屏视图 ==================== */ .mermaid-split-view { - flex: 1; - display: flex; - border: 1px solid var(--border-color); - border-radius: 8px; - overflow: hidden; - background: var(--bg-secondary); + flex: 1; + display: flex; + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + background: var(--bg-secondary); } .mermaid-code-panel, .mermaid-preview-panel { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; } .mermaid-code-panel { - border-right: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); } .mermaid-panel-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 14px; - background: var(--bg-tertiary, var(--bg-secondary)); - border-bottom: 1px solid var(--border-color); - font-size: 13px; - font-weight: 500; - color: var(--text-secondary); + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + min-height: 45px; + box-sizing: border-box; + background: var(--bg-tertiary, var(--bg-secondary)); + border-bottom: 1px solid var(--border-color); + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.mermaid-panel-header>span { + display: inline-flex; + align-items: center; } .mermaid-panel-header i { - margin-right: 6px; - opacity: 0.7; + margin-right: 6px; + opacity: 0.7; +} + +.mermaid-panel-header .btn.btn-xs { + height: 24px; + padding: 0 10px; + display: inline-flex; + align-items: center; } .mermaid-textarea { - flex: 1; - width: 100%; - padding: 16px; - border: none; - background: var(--bg-primary); - color: var(--text-primary); - font-family: 'Monaco', 'Consolas', 'Courier New', monospace; - font-size: 13px; - line-height: 1.6; - resize: none; - outline: none; + flex: 1; + width: 100%; + padding: 16px; + border: none; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + resize: none; +} + +.mermaid-textarea:focus-visible { + outline: 2px solid rgba(var(--current-rgb), 0.5); + outline-offset: -2px; } .mermaid-textarea::placeholder { - color: var(--text-tertiary); + color: var(--text-tertiary); } .mermaid-preview-content { - flex: 1; - overflow: auto; - padding: 20px; - display: flex; - align-items: flex-start; - justify-content: center; - background: var(--bg-primary); + flex: 1; + overflow: auto; + padding: 20px; + display: flex; + align-items: flex-start; + justify-content: center; + background: var(--bg-primary); } .mermaid-svg { - max-width: 100%; + max-width: 100%; } .mermaid-svg svg { - max-width: 100%; - height: auto; + max-width: 100%; + height: auto; } .mermaid-error { - color: var(--danger-color); - text-align: center; - padding: 20px; + color: var(--danger-color); + text-align: center; + padding: 20px; } .mermaid-error i { - font-size: 24px; - margin-bottom: 12px; - display: block; + font-size: 24px; + margin-bottom: 12px; + display: block; } .mermaid-error pre { - margin-top: 10px; - font-size: 12px; - text-align: left; - background: var(--bg-secondary); - padding: 12px; - border-radius: 6px; - overflow-x: auto; - max-width: 400px; + margin-top: 10px; + font-size: 12px; + text-align: left; + background: var(--bg-secondary); + padding: 12px; + border-radius: 6px; + overflow-x: auto; + max-width: 460px; } -/* ==================== Draw.io 编辑器 ==================== */ .drawio-iframe { - width: 100%; - height: 100%; - border: 1px solid var(--border-color); - border-radius: 8px; + width: 100%; + height: 100%; + border: 1px solid var(--border-color); + border-radius: 8px; } -/* ==================== AI 聊天侧栏 ==================== */ .ai-draw-chat-sidebar { - width: 360px; - flex-shrink: 0; - display: flex; - flex-direction: column; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-left: none; - border-radius: 0 8px 8px 0; - margin-left: -1px; + width: 360px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-left: none; + border-radius: 0 8px 8px 0; + margin-left: -1px; } .ai-draw-chat-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - border-bottom: 1px solid var(--border-color); - font-weight: 500; + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + font-weight: 500; } .ai-draw-chat-header i { - margin-right: 8px; - color: var(--current-primary); + margin-right: 8px; + color: var(--current-primary); } .ai-draw-chat-messages { - flex: 1; - overflow-y: auto; - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; } .ai-draw-chat-empty { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - color: var(--text-secondary); - padding: 20px; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: var(--text-secondary); + padding: 20px; } .ai-draw-chat-empty i { - font-size: 40px; - color: var(--text-muted); - margin-bottom: 12px; - opacity: 0.4; + font-size: 40px; + color: var(--text-muted); + margin-bottom: 12px; + opacity: 0.4; } .ai-draw-chat-suggestions { - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - margin-top: 16px; + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + margin-top: 16px; } .ai-draw-chat-suggestions button { - padding: 10px 14px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 8px; - color: var(--text-primary); - cursor: pointer; - text-align: left; - font-size: 13px; - transition: all 0.2s; + padding: 10px 14px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + cursor: pointer; + text-align: left; + font-size: 13px; + transition: all 0.2s; } -.ai-draw-chat-suggestions button:hover { - border-color: var(--current-primary); - background: rgba(var(--current-rgb), 0.05); +.ai-draw-chat-suggestions button:hover, +.ai-draw-chat-suggestions button:focus-visible { + border-color: var(--current-primary); + background: rgba(var(--current-rgb), 0.05); } .ai-draw-chat-message { - max-width: 90%; - animation: fadeIn 0.2s ease; + max-width: 90%; + animation: ai-draw-fade-in 0.2s ease; } .ai-draw-chat-message.user { - align-self: flex-end; + align-self: flex-end; } .ai-draw-chat-message.assistant { - align-self: flex-start; + align-self: flex-start; } .ai-draw-message-content { - padding: 10px 14px; - border-radius: 12px; - font-size: 14px; - line-height: 1.5; + padding: 10px 14px; + border-radius: 12px; + font-size: 14px; + line-height: 1.5; } .ai-draw-chat-message.user .ai-draw-message-content { - background: var(--current-primary); - color: white; - border-bottom-right-radius: 4px; + background: var(--current-primary); + color: #fff; + border-bottom-right-radius: 4px; } .ai-draw-chat-message.assistant .ai-draw-message-content { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-bottom-left-radius: 4px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-bottom-left-radius: 4px; } .ai-draw-message-content pre { - margin: 8px 0; - padding: 10px; - background: var(--bg-secondary); - border-radius: 6px; - overflow-x: auto; - font-size: 12px; + margin: 8px 0; + padding: 10px; + background: var(--bg-secondary); + border-radius: 6px; + overflow-x: auto; + font-size: 12px; } .ai-draw-message-content code { - font-family: 'Monaco', 'Consolas', monospace; - font-size: 12px; - padding: 2px 5px; - background: var(--bg-secondary); - border-radius: 3px; + font-family: var(--font-mono); + font-size: 12px; + padding: 2px 5px; + background: var(--bg-secondary); + border-radius: 3px; +} + +.ai-draw-apply-btn { + margin-top: 8px; } .ai-draw-chat-input { - display: flex; - gap: 8px; - padding: 12px; - border-top: 1px solid var(--border-color); - background: var(--bg-primary); - border-radius: 0 0 8px 0; + display: flex; + gap: 8px; + padding: 12px; + border-top: 1px solid var(--border-color); + background: var(--bg-primary); + border-radius: 0 0 8px 0; } .ai-draw-chat-input textarea { - flex: 1; - padding: 10px 12px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-secondary); - color: var(--text-primary); - font-size: 14px; - resize: none; - outline: none; - transition: border-color 0.2s; + flex: 1; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 14px; + resize: none; + transition: border-color 0.2s, box-shadow 0.2s; } -.ai-draw-chat-input textarea:focus { - border-color: var(--current-primary); +.ai-draw-chat-input textarea:focus-visible { + border-color: var(--current-primary); + box-shadow: 0 0 0 3px rgba(var(--current-rgb), 0.12); + outline: none; } .ai-draw-chat-input button { - padding: 0 16px; - border-radius: 8px; + padding: 0 16px; + border-radius: 8px; } -/* ==================== 按钮激活态 ==================== */ -.btn.active { - background: var(--current-primary) !important; - color: white !important; - border-color: var(--current-primary) !important; +.ai-draw-tab-content .btn.active { + background: var(--current-primary) !important; + color: #fff !important; + border-color: var(--current-primary) !important; } -/* ==================== 动画 ==================== */ -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(8px); - } +.record-type.external { + background: rgba(59, 130, 246, 0.15); + color: #60a5fa; +} - to { - opacity: 1; - transform: translateY(0); - } +.record-type.internal { + background: rgba(34, 197, 94, 0.15); + color: #4ade80; } -/* ==================== 响应式 ==================== */ -@media (max-width: 1200px) { - .ai-draw-chat-sidebar { - width: 320px; - } +.ai-draw-provider-name-col { + width: 30%; } -@media (max-width: 900px) { - .ai-draw-editor-container { - flex-direction: column; - height: auto; - min-height: calc(100vh - 220px); - } - - .mermaid-split-view { - flex-direction: column; - min-height: 500px; - } - - .mermaid-code-panel { - border-right: none; - border-bottom: 1px solid var(--border-color); - max-height: 250px; - } - - .ai-draw-chat-sidebar { - width: 100%; - height: 50vh; - border-radius: 0 0 8px 8px; - border-left: 1px solid var(--border-color); - margin-left: 0; - margin-top: -1px; - } +.ai-draw-provider-source-col { + width: 15%; } -@media (max-width: 600px) { - .ai-draw-editor-container { - height: auto; - } +.ai-draw-provider-model-col { + width: 25%; +} - .mermaid-code-panel { - max-height: 200px; - } +.ai-draw-provider-status-col { + width: 10%; } -/* ==================== Provider 来源标签 ==================== */ -.record-type.external { - background: rgba(59, 130, 246, 0.15); - color: #60a5fa; +.ai-draw-provider-actions-col { + width: 20%; } -.record-type.internal { - background: rgba(34, 197, 94, 0.15); - color: #4ade80; +.ai-draw-provider-name-wrap { + display: flex; + align-items: center; + gap: 8px; +} + +.ai-draw-provider-default { + color: #f59e0b; +} + +.ai-draw-provider-code { + font-size: 12px; +} + +.ai-draw-stats-panel { + margin-top: 16px; +} + +.ai-draw-refresh-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.ai-draw-settings-empty { + padding: 40px; +} + +.ai-draw-settings-empty-icon { + font-size: 40px; + margin-bottom: 12px; + opacity: 0.3; +} + +.ai-draw-help-text { + font-size: 13px; + opacity: 0.7; + margin-top: 8px; +} + +.ai-draw-provider-modal { + max-width: 520px; +} + +.ai-draw-provider-body { + padding: 16px 20px; +} + +.ai-draw-required { + color: var(--danger-color); +} + +.ai-draw-warning { + color: var(--warning-color); + margin-top: 8px; } -/* ==================== 来源类型切换按钮 ==================== */ .ai-draw-source-toggle { - display: flex; - gap: 8px; + display: flex; + gap: 8px; } .ai-draw-source-toggle .source-btn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 12px 16px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-secondary); - color: var(--text-secondary); - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; -} - -.ai-draw-source-toggle .source-btn:hover { - border-color: var(--current-primary); - color: var(--text-primary); + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 16px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.ai-draw-source-toggle .source-btn:hover, +.ai-draw-source-toggle .source-btn:focus-visible { + border-color: var(--current-primary); + color: var(--text-primary); } .ai-draw-source-toggle .source-btn.active { - border-color: var(--current-primary); - background: rgba(var(--current-rgb), 0.1); - color: var(--current-primary); + border-color: var(--current-primary); + background: rgba(var(--current-rgb), 0.1); + color: var(--current-primary); } .ai-draw-source-toggle .source-btn i { - font-size: 16px; + font-size: 16px; +} + +.ai-draw-switch-row { + display: flex; + gap: 24px; + padding-top: 8px; +} + +.ai-draw-switch-text { + margin-left: 8px; +} + +.ai-draw-icon-mermaid { + color: var(--current-primary); +} + +.ai-draw-icon-drawio { + color: #f59e0b; +} + +@keyframes ai-draw-fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1200px) { + .ai-draw-chat-sidebar { + width: 320px; + } +} + +@media (max-width: 900px) { + + .ai-draw-panel-header, + .ai-draw-editor-toolbar { + align-items: flex-start; + } + + .ai-draw-editor-container { + flex-direction: column; + height: auto; + min-height: calc(100dvh - 220px); + max-height: none; + } + + .mermaid-split-view { + flex-direction: column; + min-height: 500px; + } + + .mermaid-code-panel { + border-right: none; + border-bottom: 1px solid var(--border-color); + max-height: 250px; + } + + .ai-draw-chat-sidebar { + width: 100%; + height: 50vh; + border-radius: 0 0 8px 8px; + border-left: 1px solid var(--border-color); + margin-left: 0; + margin-top: -1px; + } +} + +@media (max-width: 600px) { + .ai-draw-actions-bar { + width: 100%; + } + + .ai-draw-search-box { + min-width: 100%; + } + + .ai-draw-editor-actions { + width: 100%; + } + + .ai-draw-editor-actions .btn { + flex: 1; + } + + .mermaid-code-panel { + max-height: 200px; + } + + .ai-draw-state { + padding: 40px 16px; + } } \ No newline at end of file diff --git a/src/css/dashboard.css b/src/css/dashboard.css index b89cf79..6885eac 100644 --- a/src/css/dashboard.css +++ b/src/css/dashboard.css @@ -1047,7 +1047,7 @@ /* Media Queries */ @media (max-width: 1200px) { .stats-overview-grid { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(3, 1fr); } .dashboard-main-layout { diff --git a/src/css/filebox.css b/src/css/filebox.css index df2d27f..1cdc1ee 100644 --- a/src/css/filebox.css +++ b/src/css/filebox.css @@ -13,6 +13,16 @@ box-shadow: 0 2px 8px rgba(var(--current-rgb), 0.3); } +.filebox-page-title { + margin: 0; + font-size: 24px; + color: var(--text-primary); +} + +.filebox-title-icon { + color: var(--filebox-primary); +} + /* 顶部取件卡片 */ .filebox-header-card { background: var(--card-bg); @@ -24,6 +34,16 @@ background-image: radial-gradient(circle at top right, rgba(var(--primary-rgb), 0.05), transparent); } +.filebox-help-card { + margin-top: 20px; +} + +.filebox-help-text { + color: var(--text-secondary); + line-height: 1.6; + font-size: 14px; +} + .filebox-input-wrapper { display: flex; max-width: 500px; @@ -128,6 +148,71 @@ box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.2); } +.filebox-share-panel { + min-height: 500px; + display: flex; + flex-direction: column; + position: relative; +} + +.filebox-upload-progress-card { + margin-top: 14px; + padding: 12px 14px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 10px; +} + +.filebox-upload-progress-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.filebox-upload-name { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.filebox-upload-percent { + color: var(--primary-color); + font-weight: 700; + font-family: var(--font-mono); +} + +.filebox-upload-progress-track { + height: 8px; + border-radius: 6px; + background: var(--bg-tertiary); + overflow: hidden; +} + +.filebox-upload-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--filebox-primary), #34d399); + transition: width 0.2s ease; +} + +.filebox-upload-meta { + margin-top: 10px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + color: var(--text-secondary); + font-size: 12px; +} + +.filebox-upload-meta i { + margin-right: 4px; +} + /* 确保 Switch 样式在 filebox 中可用 */ .form-switch { display: inline-flex; @@ -183,6 +268,10 @@ border: 1px solid var(--border-color); } +.filebox-share-tabs-wrap { + margin-bottom: 20px; +} + .filebox-tab-btn { flex: 1; padding: 10px; @@ -263,6 +352,12 @@ margin-bottom: 15px; } +.filebox-text-area { + flex: 1; + min-height: 200px; + padding: 15px; +} + .filebox-upload-remove { font-size: 12px; color: var(--danger-color); @@ -319,6 +414,48 @@ text-shadow: 0 0 20px rgba(var(--primary-rgb), 0.3); } +.filebox-result-icon { + font-size: 56px; + color: #10b981; + margin-bottom: 20px; +} + +.filebox-result-title { + color: var(--text-primary); + margin-bottom: 5px; +} + +.filebox-result-desc { + color: var(--text-secondary); + font-size: 14px; + margin-bottom: 15px; +} + +.filebox-qr-wrap { + margin-bottom: 20px; +} + +.filebox-qr-image { + width: 120px; + height: 120px; + border-radius: 8px; + background: #fff; + padding: 8px; +} + +.filebox-qr-tip { + color: var(--text-tertiary); + font-size: 12px; + margin-top: 8px; +} + +.filebox-result-actions { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + /* 历史记录列表 */ .filebox-history-list-modern { display: flex; @@ -411,6 +548,62 @@ height: 24px; } +.filebox-history-toolbar { + display: flex; + align-items: center; + gap: 8px; +} + +.filebox-history-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.filebox-meta-badge { + font-size: 12px; +} + +.filebox-server-history-card { + margin-top: 14px; +} + +.filebox-empty-lg { + padding: 60px 0; +} + +.filebox-empty-md { + padding: 36px 0; +} + +.filebox-empty-icon-lg { + font-size: 48px; + opacity: 0.2; +} + +.filebox-empty-icon-md { + font-size: 34px; + opacity: 0.2; +} + +.filebox-empty-text { + margin-top: 12px; + color: var(--text-tertiary); +} + +.filebox-loading { + padding: 28px 0; +} + +.filebox-modal { + max-width: 550px; + overflow: hidden; +} + +.filebox-modal-body { + padding: 30px; +} + /* 提取结果弹窗增强 */ .retrieved-icon-wrapper { width: 100px; @@ -514,4 +707,49 @@ .bounce-in { animation: bounceIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} \ No newline at end of file +} + +@media (max-width: 768px) { + .filebox-page-title { + font-size: 20px; + } + + .filebox-input-wrapper { + flex-direction: column; + max-width: 100%; + } + + .filebox-retrieve-btn { + width: 100%; + } + + .filebox-share-panel { + min-height: 420px; + } + + .filebox-upload-zone { + min-height: 190px; + padding: 24px 20px; + } + + .filebox-options-row { + justify-content: flex-start; + gap: 12px; + } + + .filebox-options-row .btn-primary { + width: 100%; + } + + .filebox-history-header { + align-items: flex-start; + gap: 8px; + flex-direction: column; + } + + .filebox-history-toolbar { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } +} diff --git a/src/css/styles.css b/src/css/styles.css index 1e5ab98..54623bd 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -3279,7 +3279,7 @@ body.modal-open { } } -@media (max-width: 80px) { +@media (max-width: 480px) { .stats-grid { grid-template-columns: 1fr; } @@ -3429,4 +3429,4 @@ body.modal-open { min-width: auto; justify-content: space-between; } -} \ No newline at end of file +} diff --git a/src/index.html b/src/index.html index 4943110..cd156f6 100644 --- a/src/index.html +++ b/src/index.html @@ -11,8 +11,7 @@ })(); - + API Monitor @@ -734,4 +733,4 @@

{{ musicCurrentSong.name }}

- \ No newline at end of file + diff --git a/src/js/modules/ai-chat.js b/src/js/modules/ai-chat.js index 9b377d5..31b7636 100644 --- a/src/js/modules/ai-chat.js +++ b/src/js/modules/ai-chat.js @@ -2,14 +2,7 @@ * AI Chat 模块 - 前端业务逻辑 */ -// Markdown 渲染器导入 (使用 npm 包) -import { marked } from 'marked'; - -// 配置 marked -marked.setOptions({ - breaks: true, - gfm: true, -}); +import { renderMarkdown } from './utils.js'; /** @@ -318,7 +311,7 @@ export const aiChatMethods = { aiChatRenderMarkdown(content) { if (!content) return ''; try { - return marked.parse(content); + return renderMarkdown(content); } catch (e) { return content; } diff --git a/src/js/modules/ai-draw.js b/src/js/modules/ai-draw.js index 1a43c13..a486b4c 100644 --- a/src/js/modules/ai-draw.js +++ b/src/js/modules/ai-draw.js @@ -12,8 +12,8 @@ try { mermaid.initialize({ startOnLoad: false, theme: 'dark', - securityLevel: 'loose', - flowchart: { useMaxWidth: true, htmlLabels: true }, + securityLevel: 'strict', + flowchart: { useMaxWidth: true, htmlLabels: false }, sequence: { useMaxWidth: true }, fontFamily: 'inherit', }); @@ -30,6 +30,9 @@ export const aiDrawData = { aiDrawLoading: false, aiDrawSaving: false, aiDrawShowCreateMenu: false, + aiDrawCreateMenuGlobalBound: false, + aiDrawCreateMenuPointerHandler: null, + aiDrawCreateMenuKeyHandler: null, aiDrawShowChat: false, aiDrawCurrentTab: 'projects', aiDrawSearchQuery: '', @@ -95,12 +98,37 @@ export const aiDrawMethods = { */ async aiDrawInit() { console.log('[AI Draw] 初始化模块'); + this.aiDrawBindCreateMenuGlobalClose(); await Promise.all([ this.aiDrawLoadProjects(), this.aiDrawLoadProviders(), ]); }, + /** + * 绑定创建菜单的全局关闭事件(点击外部 / ESC) + */ + aiDrawBindCreateMenuGlobalClose() { + if (this.aiDrawCreateMenuGlobalBound) return; + + this.aiDrawCreateMenuPointerHandler = (event) => { + if (!this.aiDrawShowCreateMenu) return; + const dropdown = this.$refs.aiDrawCreateDropdown; + if (dropdown && dropdown.contains(event.target)) return; + this.aiDrawShowCreateMenu = false; + }; + + this.aiDrawCreateMenuKeyHandler = (event) => { + if (event.key === 'Escape' && this.aiDrawShowCreateMenu) { + this.aiDrawShowCreateMenu = false; + } + }; + + document.addEventListener('pointerdown', this.aiDrawCreateMenuPointerHandler, true); + document.addEventListener('keydown', this.aiDrawCreateMenuKeyHandler, true); + this.aiDrawCreateMenuGlobalBound = true; + }, + /** * 加载项目列表 */ diff --git a/src/js/modules/filebox.js b/src/js/modules/filebox.js index ea61ff2..1068e8e 100644 --- a/src/js/modules/filebox.js +++ b/src/js/modules/filebox.js @@ -1,28 +1,79 @@ import axios from 'axios'; -import { store } from '../store.js'; + +const FILEBOX_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB + +function formatSpeed(bytesPerSecond) { + if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return '-'; + const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']; + let value = bytesPerSecond; + let idx = 0; + while (value >= 1024 && idx < units.length - 1) { + value /= 1024; + idx += 1; + } + const fixed = value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(fixed)} ${units[idx]}`; +} + +function formatEta(seconds) { + if (!Number.isFinite(seconds) || seconds <= 0) return '-'; + if (seconds < 60) return `${Math.ceil(seconds)}秒`; + const mins = Math.floor(seconds / 60); + const secs = Math.ceil(seconds % 60); + return `${mins}分${secs}秒`; +} export const fileboxData = { fileboxRetrieveCode: '', fileboxShareType: 'file', // 'file' or 'text' - fileboxCurrentTab: 'share', // 'share' or 'history' + fileboxCurrentTab: 'share', // 'share' | 'retrieve' | 'history' fileboxShareText: '', fileboxSelectedFile: null, fileboxExpiry: '24', fileboxBurnAfterReading: false, fileboxLoading: false, - fileboxResult: null, // { code: '...' } - fileboxQrCode: '', // 二维码 Data URL - fileboxHistory: [], // Local history of uploads - fileboxRetrievedEntry: null, // Populated after retrieve + fileboxResult: null, + fileboxQrCode: '', + fileboxHistory: [], + fileboxServerHistory: [], + fileboxHistoryLoading: false, + fileboxRetrievedEntry: null, isDragging: false, + + // Upload telemetry + fileboxUploadProgress: 0, + fileboxUploadSpeedText: '-', + fileboxUploadEtaText: '-', + fileboxUploadingName: '', + fileboxAbortController: null, + + fileboxMaxFileSize: FILEBOX_MAX_FILE_SIZE, }; export const fileboxMethods = { - // Methods initFileBox() { this.loadFileBoxHistory(); }, + fileboxNotify(message, type = 'info') { + if (typeof this.showToast === 'function') { + this.showToast(message, type); + return; + } + if (this.$toast && typeof this.$toast[type] === 'function') { + this.$toast[type](message); + return; + } + console.log(`[FileBox][${type}] ${message}`); + }, + + switchFileboxTab(tab) { + this.fileboxCurrentTab = tab; + if (tab === 'history') { + this.loadFileBoxServerHistory(); + } + }, + loadFileBoxHistory() { try { const saved = localStorage.getItem('filebox_history'); @@ -35,109 +86,189 @@ export const fileboxMethods = { }, saveFileBoxHistory(entry) { - // Add to history this.fileboxHistory.unshift(entry); - // Limit to 20 - if (this.fileboxHistory.length > 20) this.fileboxHistory.length = 20; + if (this.fileboxHistory.length > 50) this.fileboxHistory.length = 50; localStorage.setItem('filebox_history', JSON.stringify(this.fileboxHistory)); }, + clearLocalFileBoxHistory() { + this.fileboxHistory = []; + localStorage.removeItem('filebox_history'); + this.fileboxNotify('本地历史已清空', 'success'); + }, + + async loadFileBoxServerHistory() { + this.fileboxHistoryLoading = true; + try { + const res = await axios.get('/api/filebox/history'); + if (res.data?.success) { + this.fileboxServerHistory = Array.isArray(res.data.data) ? res.data.data : []; + } + } catch (error) { + this.fileboxNotify(error.response?.data?.error || '加载服务端历史失败', 'error'); + } finally { + this.fileboxHistoryLoading = false; + } + }, + + validateFile(file) { + if (!file) return false; + if (file.size > this.fileboxMaxFileSize) { + this.fileboxNotify(`文件过大,最大支持 ${this.formatFileSize(this.fileboxMaxFileSize)}`, 'error'); + return false; + } + return true; + }, + + setSelectedFile(file) { + if (!this.validateFile(file)) return; + this.fileboxSelectedFile = file; + this.fileboxShareType = 'file'; + }, + handleFileDrop(e) { this.isDragging = false; - const files = e.dataTransfer.files; - if (files.length > 0) { - this.fileboxSelectedFile = files[0]; - this.fileboxShareType = 'file'; + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + this.setSelectedFile(files[0]); } }, handleFileSelect(e) { - const files = e.target.files; - if (files.length > 0) { - this.fileboxSelectedFile = files[0]; + const files = e.target?.files; + if (files && files.length > 0) { + this.setSelectedFile(files[0]); } }, + resetUploadTelemetry() { + this.fileboxUploadProgress = 0; + this.fileboxUploadSpeedText = '-'; + this.fileboxUploadEtaText = '-'; + this.fileboxUploadingName = ''; + this.fileboxAbortController = null; + }, + resetFileBoxForm() { this.fileboxShareText = ''; this.fileboxSelectedFile = null; this.fileboxExpiry = '24'; this.fileboxBurnAfterReading = false; - // Clear file input if (this.$refs.fileInput) this.$refs.fileInput.value = ''; + this.resetUploadTelemetry(); + }, + + cancelFileBoxUpload() { + if (this.fileboxAbortController) { + this.fileboxAbortController.abort(); + this.fileboxNotify('上传已取消', 'warning'); + } }, async shareFileBoxEntry() { - if (this.fileboxShareType === 'text' && !this.fileboxShareText) return; - if (this.fileboxShareType === 'file' && !this.fileboxSelectedFile) return; + const isTextMode = this.fileboxShareType === 'text'; + if (isTextMode && !this.fileboxShareText.trim()) return; + if (!isTextMode && !this.fileboxSelectedFile) return; + if (!isTextMode && !this.validateFile(this.fileboxSelectedFile)) return; this.fileboxLoading = true; + this.resetUploadTelemetry(); + + let lastTs = Date.now(); + let lastLoaded = 0; + try { const formData = new FormData(); formData.append('type', this.fileboxShareType); formData.append('expiry', this.fileboxExpiry); formData.append('burn_after_reading', this.fileboxBurnAfterReading); - if (this.fileboxShareType === 'text') { + if (isTextMode) { formData.append('text', this.fileboxShareText); } else { formData.append('file', this.fileboxSelectedFile); + this.fileboxUploadingName = this.fileboxSelectedFile.name; + this.fileboxAbortController = new AbortController(); } const res = await axios.post('/api/filebox/share', formData, { - headers: { 'Content-Type': 'multipart/form-data' } + headers: { 'Content-Type': 'multipart/form-data' }, + signal: this.fileboxAbortController?.signal, + onUploadProgress: (evt) => { + if (isTextMode) return; + if (!evt || !evt.total) return; + + const now = Date.now(); + const deltaMs = Math.max(1, now - lastTs); + const deltaBytes = Math.max(0, evt.loaded - lastLoaded); + const speed = (deltaBytes * 1000) / deltaMs; + const remain = Math.max(0, evt.total - evt.loaded); + const etaSec = speed > 0 ? remain / speed : Infinity; + + this.fileboxUploadProgress = Math.min(100, Math.round((evt.loaded / evt.total) * 100)); + this.fileboxUploadSpeedText = formatSpeed(speed); + this.fileboxUploadEtaText = formatEta(etaSec); + + lastTs = now; + lastLoaded = evt.loaded; + }, }); - if (res.data.success) { + if (res.data?.success) { + this.fileboxUploadProgress = 100; this.fileboxResult = { code: res.data.code }; + await this.generateFileBoxQrCode(res.data.code); - // 生成二维码 - this.generateFileBoxQrCode(res.data.code); - - // Save minimal info to history this.saveFileBoxHistory({ code: res.data.code, type: this.fileboxShareType, originalName: this.fileboxSelectedFile ? this.fileboxSelectedFile.name : null, content: this.fileboxShareText, size: this.fileboxSelectedFile ? this.fileboxSelectedFile.size : 0, - createdAt: Date.now() + createdAt: Date.now(), }); - this.showToast('分享成功!取件码已生成', 'success'); + this.fileboxNotify('分享成功,取件码已生成', 'success'); + if (this.fileboxCurrentTab === 'history') { + this.loadFileBoxServerHistory(); + } } else { - this.showToast('分享失败: ' + res.data.error, 'error'); + this.fileboxNotify('分享失败: ' + (res.data?.error || '未知错误'), 'error'); } } catch (error) { + if (error.name === 'CanceledError' || error.code === 'ERR_CANCELED') { + return; + } this.handleError(error); } finally { this.fileboxLoading = false; + this.fileboxAbortController = null; } }, async retrieveFileBoxEntry() { - if (!this.fileboxRetrieveCode || this.fileboxRetrieveCode.length < 5) { - this.showToast('请输入 5 位取件码', 'warning'); + const code = (this.fileboxRetrieveCode || '').trim().toUpperCase(); + if (!code || code.length < 5) { + this.fileboxNotify('请输入 5 位取件码', 'warning'); return; } + this.fileboxRetrieveCode = code; this.fileboxLoading = true; try { - // First get metadata - const res = await axios.get(`/api/filebox/retrieve/${this.fileboxRetrieveCode}`); - if (res.data.success) { + const res = await axios.get(`/api/filebox/retrieve/${code}`); + if (res.data?.success) { this.fileboxRetrievedEntry = res.data.data; if (this.fileboxRetrievedEntry.type === 'text') { - const contentRes = await axios.get(`/api/filebox/download/${this.fileboxRetrieveCode}`, { responseType: 'text' }); + const contentRes = await axios.get(`/api/filebox/download/${code}`, { responseType: 'text' }); this.fileboxRetrievedEntry.content = contentRes.data; } } else { - this.showToast(res.data.error || '取件失败', 'error'); + this.fileboxNotify(res.data?.error || '取件失败', 'error'); } } catch (error) { - // 404 handled here if (error.response && error.response.status === 404) { - this.showToast('取件码无效或已过期', 'error'); + this.fileboxNotify('取件码无效或已过期', 'error'); } else { this.handleError(error); } @@ -152,72 +283,67 @@ export const fileboxMethods = { async deleteFileBoxEntry(code) { try { - // 调用后端删除 API await axios.delete(`/api/filebox/${code}`); - this.showToast('已删除', 'success'); + this.fileboxNotify('已删除', 'success'); } catch (error) { - // 后端删除失败(可能已过期或不存在),仍继续清理本地记录 console.error('后端删除失败:', error); } - // 同时清理本地历史记录 + this.fileboxHistory = this.fileboxHistory.filter(h => h.code !== code); + this.fileboxServerHistory = this.fileboxServerHistory.filter(h => h.code !== code); localStorage.setItem('filebox_history', JSON.stringify(this.fileboxHistory)); }, handleError(error) { console.error(error); - const msg = error.response?.data?.error || error.message; - this.$toast.error(msg); + const msg = error.response?.data?.error || error.message || '操作失败'; + this.fileboxNotify(msg, 'error'); }, copyToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(() => { - this.showToast('已复制到剪贴板', 'success'); + this.fileboxNotify('已复制到剪贴板', 'success'); }, () => { - this.showToast('复制失败', 'error'); + this.fileboxNotify('复制失败', 'error'); }); } else { - // Fallback - const textArea = document.createElement("textarea"); + const textArea = document.createElement('textarea'); textArea.value = text; - textArea.style.position = "fixed"; - textArea.style.left = "-9999px"; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { document.execCommand('copy'); - this.showToast('已复制到剪贴板', 'success'); + this.fileboxNotify('已复制到剪贴板', 'success'); } catch (err) { - this.showToast('复制失败', 'error'); + this.fileboxNotify('复制失败', 'error'); } document.body.removeChild(textArea); } }, - // 复制分享链接(直接下载链接) copyFileBoxLink(code) { const url = `${window.location.origin}/api/filebox/download/${code}`; this.copyToClipboard(url); }, - // 生成二维码 async generateFileBoxQrCode(code) { const url = `${window.location.origin}/api/filebox/download/${code}`; try { - // 使用 QRCode CDN 库或 canvas 生成 const QRCode = window.QRCode || (await import('qrcode')).default; if (QRCode.toDataURL) { this.fileboxQrCode = await QRCode.toDataURL(url, { width: 150, margin: 1, - color: { dark: '#000', light: '#fff' } + color: { dark: '#000', light: '#fff' }, }); } } catch (e) { console.error('QRCode generation failed:', e); this.fileboxQrCode = ''; } - } + }, }; diff --git a/src/templates/ai-draw.html b/src/templates/ai-draw.html index 3c0d6a8..69779c7 100644 --- a/src/templates/ai-draw.html +++ b/src/templates/ai-draw.html @@ -1,80 +1,44 @@ - +
-
- - -
-
-
-
- 绘图项目 - ({{ aiDrawProjects.length }}) +
+
+ 绘图项目 + ({{ aiDrawProjects.length }})
-
- -