diff --git a/package.json b/package.json
index 4feb4d7..8360b8c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "clawpanel",
- "version": "0.8.3",
+ "version": "0.8.4",
"private": true,
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
"type": "module",
diff --git a/scripts/dev-api.js b/scripts/dev-api.js
index b727b38..8866708 100644
--- a/scripts/dev-api.js
+++ b/scripts/dev-api.js
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'url'
import net from 'net'
import http from 'http'
import crypto from 'crypto'
-import { DOCKER_TASK_TIMEOUT_MS } from '../src/lib/docker-tasking.js'
+const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000
const __dev_dirname = path.dirname(fileURLToPath(import.meta.url))
const OPENCLAW_DIR = path.join(homedir(), '.openclaw')
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 7bdcaf1..76f44ce 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -328,7 +328,7 @@ dependencies = [
[[package]]
name = "clawpanel"
-version = "0.8.3"
+version = "0.8.4"
dependencies = [
"base64 0.22.1",
"chrono",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 4aa19f9..05ea270 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "clawpanel"
-version = "0.8.3"
+version = "0.8.4"
edition = "2021"
description = "ClawPanel - OpenClaw 可视化管理面板"
authors = ["qingchencloud"]
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 7a05800..472eb6f 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "ClawPanel",
- "version": "0.8.3",
+ "version": "0.8.4",
"identifier": "ai.openclaw.clawpanel",
"build": {
"frontendDist": "../dist",
diff --git a/src/components/sidebar.js b/src/components/sidebar.js
index 9d91e69..7bfe488 100644
--- a/src/components/sidebar.js
+++ b/src/components/sidebar.js
@@ -42,12 +42,6 @@ const NAV_ITEMS_FULL = [
{ route: '/skills', label: 'Skills', icon: 'skills' },
]
},
- {
- section: '龙虾军团',
- items: [
- { route: '/docker', label: '龙虾军团', icon: 'docker' },
- ]
- },
{
section: '',
items: [
@@ -89,7 +83,6 @@ const ICONS = {
assistant: '
',
@@ -287,7 +280,7 @@ async function _toggleInstanceDropdown(sidebarEl) {
const h = healthMap[inst.id] || {}
const active = inst.id === activeId ? ' active' : ''
const dot = h.online !== false ? 'online' : 'offline'
- const badge = inst.type === 'docker' ? '
' : ''
const port = inst.endpoint ? inst.endpoint.match(/:(\d+)/)?.[1] : ''
const portTag = port ? `
diff --git a/src/lib/docker-tasking.js b/src/lib/docker-tasking.js
deleted file mode 100644
index 2fe683e..0000000
--- a/src/lib/docker-tasking.js
+++ /dev/null
@@ -1,44 +0,0 @@
-export const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000
-
-export function buildDockerDispatchTargets(targets = []) {
- if (!Array.isArray(targets)) return []
- return targets.map(target => ({
- containerId: target.id,
- containerName: target.name,
- nodeId: target.nodeId || null,
- }))
-}
-
-export function buildDockerInstanceSwitchContext({ containerId, name, port, gatewayPort, nodeId }) {
- if (!containerId) throw new Error('缺少容器 ID')
- if (!name) throw new Error('缺少容器名称')
-
- const panelPort = parseRequiredPort(port, '面板端口')
- const parsedGatewayPort = parseOptionalPort(gatewayPort, 18789)
-
- return {
- instanceId: `docker-${containerId.slice(0, 12)}`,
- reloadRoute: true,
- registration: {
- name,
- type: 'docker',
- endpoint: `http://127.0.0.1:${panelPort}`,
- gatewayPort: parsedGatewayPort,
- containerId,
- nodeId: nodeId || null,
- note: 'Added from Docker page',
- },
- }
-}
-
-function parseRequiredPort(value, label) {
- const port = Number.parseInt(value, 10)
- if (Number.isInteger(port) && port > 0) return port
- throw new Error(`${label}无效`)
-}
-
-function parseOptionalPort(value, fallback) {
- const port = Number.parseInt(value, 10)
- if (Number.isInteger(port) && port > 0) return port
- return fallback
-}
diff --git a/src/lib/pixel-roles.js b/src/lib/pixel-roles.js
deleted file mode 100644
index e5d8249..0000000
--- a/src/lib/pixel-roles.js
+++ /dev/null
@@ -1,204 +0,0 @@
-// 像素风格龙虾兵角色图
-// 每个角色是一个 16x16 像素画,用 SVG rects 渲染
-// 调色板:0=制服主色 1=制服亮色 2=制服暗色 3=虾红(头/钳) 4=深色(眼) 5=白 6=装备色 .=透明
-// 制服用兵种色 → 每个角色外观差异明显
-
-const PIXEL_CHARS = {
- // 步兵 — 灰蓝制服 + 钢盔
- general: {
- palette: ['#64748b', '#94a3b8', '#475569', '#e74c3c', '#1e293b', '#ffffff', '#cbd5e1'],
- pixels: [
- '................',
- '......0022......',
- '.....006622.....',
- '....00000000....',
- '..3..044440..3..',
- '..3..055550..3..',
- '.33..044440..33.',
- '.....000000.....',
- '....00011000....',
- '....00000000....',
- '.....000000.....',
- '.....000000.....',
- '......0000......',
- '.....00..00.....',
- '.....00..00.....',
- '.....22..22.....',
- ],
- },
- // 突击兵(coder) — 金黄制服 + 护目镜 + 闪电
- coder: {
- palette: ['#f59e0b', '#fbbf24', '#b45309', '#e74c3c', '#1e293b', '#ffffff', '#fef3c7'],
- pixels: [
- '.......66.......',
- '......6666......',
- '.....666666.....',
- '....00066000....',
- '..3..044440..3..',
- '..3..055550..3..',
- '.33..044440..33.',
- '.....000000.....',
- '....00011000....',
- '....00000000....',
- '.....000000.....',
- '.6...000000...6.',
- '.66...0000...66.',
- '.....00..00.....',
- '.....00..00.....',
- '.....22..22.....',
- ],
- },
- // 翻译官 — 青色制服 + 贝雷帽 + 卷轴
- translator: {
- palette: ['#06b6d4', '#22d3ee', '#0e7490', '#e74c3c', '#1e293b', '#ffffff', '#ecfeff'],
- pixels: [
- '....1100........',
- '...011000.......',
- '...0000000......',
- '....000000......',
- '..3..044440..3..',
- '..3..055550..3..',
- '.33..044440..33.',
- '.....000000.....',
- '....00011000....',
- '....00000000..66',
- '.....000000..646',
- '.....000000..646',
- '......0000...66.',
- '.....00..00.....',
- '.....00..00.....',
- '.....22..22.....',
- ],
- },
- // 文书官 — 紫色制服 + 文人帽 + 羽毛笔
- writer: {
- palette: ['#8b5cf6', '#a78bfa', '#6d28d9', '#e74c3c', '#1e293b', '#ffffff', '#ede9fe'],
- pixels: [
- '..............6.',
- '.....0011....6..',
- '....001100..6...',
- '....000000.6....',
- '..3..044440..3..',
- '..3..055550..3..',
- '.33..044440..33.',
- '.....000000.....',
- '....00011000....',
- '....00000000....',
- '.....000000.....',
- '.....000000.....',
- '......0000......',
- '.....00..00.....',
- '.....00..00.....',
- '.....22..22.....',
- ],
- },
- // 参谋(analyst) — 绿色制服 + 眼镜 + 图表板
- analyst: {
- palette: ['#22c55e', '#4ade80', '#15803d', '#e74c3c', '#1e293b', '#ffffff', '#dcfce7'],
- pixels: [
- '................',
- '......4444......',
- '.....444444.....',
- '....44444444....',
- '..3.066.660.3...',
- '..3..055550..3..',
- '.33..044440..33.',
- '.....000000.....',
- '....00011000....',
- '....00000000.66.',
- '.....000000.626.',
- '.....000000.626.',
- '......0000..66..',
- '.....00..00.....',
- '.....00..00.....',
- '.....22..22.....',
- ],
- },
- // 特种兵(custom) — 深红制服 + 战术面罩 + 扳手
- custom: {
- palette: ['#ef4444', '#f87171', '#b91c1c', '#dc2626', '#1e293b', '#ffffff', '#fee2e2'],
- pixels: [
- '......0022......',
- '.....020020.....',
- '....00000000....',
- '....04444400....',
- '..3..044440..3..',
- '..3..055550..3..',
- '.33..044440..33.',
- '.....000000.....',
- '....00011000....',
- '....00000000....',
- '.....000000.....',
- '.4...000000...4.',
- '.44...0000...44.',
- '.....00..00.....',
- '.....00..00.....',
- '.....22..22.....',
- ],
- },
-}
-
-// 军营(Docker 节点)像素图
-const PIXEL_BARRACKS = {
- palette: ['#92400e', '#b45309', '#fbbf24', '#d97706', '#78350f', '#ffffff', '#f59e0b'],
- pixels: [
- '......6666......',
- '.....666666.....',
- '....33333333....',
- '...3333333333...',
- '..333333333333..',
- '..300053005300..',
- '..300053005300..',
- '..300053005300..',
- '..333333333333..',
- '..300053005300..',
- '..300053005300..',
- '..300053005300..',
- '..333333333333..',
- '..333300003333..',
- '..333300003333..',
- '..444444444444..',
- ],
-}
-
-/**
- * 生成像素角色 SVG
- * @param {string} role - 兵种 key
- * @param {number} size - 显示尺寸 (px)
- * @returns {string} SVG HTML string
- */
-function _renderPixelSvg(data, size) {
- const { palette, pixels } = data
- const grid = 16
- let rects = ''
- for (let y = 0; y < pixels.length; y++) {
- const row = pixels[y]
- for (let x = 0; x < row.length; x++) {
- const ch = row[x]
- if (ch === '.') continue
- const colorIdx = parseInt(ch)
- if (isNaN(colorIdx) || !palette[colorIdx]) continue
- rects += `
`
- }
- }
- return `
`
-}
-
-/**
- * 生成像素角色 SVG
- * @param {string} role - 兵种 key
- * @param {number} size - 显示尺寸 (px)
- * @returns {string} SVG HTML string
- */
-export function pixelRole(role, size = 32) {
- return _renderPixelSvg(PIXEL_CHARS[role] || PIXEL_CHARS.general, size)
-}
-
-/**
- * 生成军营像素 SVG
- * @param {number} size - 显示尺寸 (px)
- * @returns {string} SVG HTML string
- */
-export function pixelBarracks(size = 32) {
- return _renderPixelSvg(PIXEL_BARRACKS, size)
-}
diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js
index 1908b89..39d1d0f 100644
--- a/src/lib/tauri-api.js
+++ b/src/lib/tauri-api.js
@@ -2,18 +2,11 @@
* Tauri API 封装层
* Tauri 环境用 invoke,Web 模式走 dev-api 后端
*/
-import { DOCKER_TASK_TIMEOUT_MS } from './docker-tasking.js'
const isTauri = !!window.__TAURI_INTERNALS__
// 仅在 Node.js 后端实现的命令(Tauri Rust 不处理),强制走 webInvoke
const WEB_ONLY_CMDS = new Set([
- 'docker_test_endpoint',
- 'docker_info', 'docker_list_containers', 'docker_create_container',
- 'docker_start_container', 'docker_stop_container', 'docker_restart_container',
- 'docker_remove_container', 'docker_rebuild_container', 'docker_container_logs', 'docker_container_exec', 'docker_init_worker', 'docker_gateway_chat', 'docker_agent', 'docker_agent_broadcast', 'docker_dispatch_task', 'docker_dispatch_broadcast', 'docker_task_status', 'docker_task_list', 'docker_pull_image', 'docker_pull_status',
- 'docker_list_images', 'docker_list_nodes', 'docker_add_node', 'docker_remove_node',
- 'docker_cluster_overview',
'instance_list', 'instance_add', 'instance_remove', 'instance_set_active',
'instance_health_check', 'instance_health_all',
'get_deploy_mode',
@@ -270,34 +263,6 @@ export const api = {
instanceHealthCheck: (id) => invoke('instance_health_check', { id }),
instanceHealthAll: () => invoke('instance_health_all'),
- // Docker 集群管理
- getDeployMode: () => cachedInvoke('get_deploy_mode', {}, 60000),
- dockerClusterOverview: () => invoke('docker_cluster_overview'),
- dockerTestEndpoint: (endpoint) => invoke('docker_test_endpoint', { endpoint }),
- dockerInfo: (nodeId) => invoke('docker_info', { nodeId }),
- dockerListContainers: (nodeId, all = true) => invoke('docker_list_containers', { nodeId, all }),
- dockerCreateContainer: (opts) => invoke('docker_create_container', opts),
- dockerStartContainer: (nodeId, containerId) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_start_container', { nodeId, containerId }) },
- dockerStopContainer: (nodeId, containerId) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_stop_container', { nodeId, containerId }) },
- dockerRestartContainer: (nodeId, containerId) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_restart_container', { nodeId, containerId }) },
- dockerRemoveContainer: (nodeId, containerId, force = false) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_remove_container', { nodeId, containerId, force }) },
- dockerContainerLogs: (nodeId, containerId, tail = 200) => invoke('docker_container_logs', { nodeId, containerId, tail }),
- dockerContainerExec: (nodeId, containerId, cmd) => invoke('docker_container_exec', { nodeId, containerId, cmd }),
- dockerInitWorker: (nodeId, containerId, role) => invoke('docker_init_worker', { nodeId, containerId, role }),
- dockerGatewayChat: (nodeId, containerId, message, timeout = DOCKER_TASK_TIMEOUT_MS) => invoke('docker_gateway_chat', { nodeId, containerId, message, timeout }),
- dockerAgent: (nodeId, containerId, cmd) => invoke('docker_agent', { nodeId, containerId, cmd }),
- dockerAgentBroadcast: (nodeId, containerIds, message, timeout = DOCKER_TASK_TIMEOUT_MS) => invoke('docker_agent_broadcast', { nodeId, containerIds, message, timeout }),
- dockerDispatchTask: (nodeId, containerId, containerName, message, timeout = DOCKER_TASK_TIMEOUT_MS) => invoke('docker_dispatch_task', { nodeId, containerId, containerName, message, timeout }),
- dockerDispatchBroadcast: (nodeId, targets, message, timeout = DOCKER_TASK_TIMEOUT_MS) => invoke('docker_dispatch_broadcast', { nodeId, targets, message, timeout }),
- dockerTaskStatus: (taskId) => invoke('docker_task_status', { taskId }),
- dockerTaskList: (containerId, status) => invoke('docker_task_list', { containerId, status }),
- dockerRebuildContainer: (nodeId, containerId, pullLatest = true) => invoke('docker_rebuild_container', { nodeId, containerId, pullLatest }),
- dockerPullImage: (nodeId, image, tag, requestId) => invoke('docker_pull_image', { nodeId, image, tag, requestId }),
- dockerPullStatus: (requestId) => invoke('docker_pull_status', { requestId }),
- dockerListImages: (nodeId) => invoke('docker_list_images', { nodeId }),
- dockerListNodes: () => cachedInvoke('docker_list_nodes', {}, 30000),
- dockerAddNode: (name, endpoint) => { invalidate('docker_list_nodes', 'docker_cluster_overview'); return invoke('docker_add_node', { name, endpoint }) },
- dockerRemoveNode: (nodeId) => { invalidate('docker_list_nodes', 'docker_cluster_overview'); return invoke('docker_remove_node', { nodeId }) },
// 前端热更新
checkFrontendUpdate: () => invoke('check_frontend_update'),
diff --git a/src/main.js b/src/main.js
index e6b0fcc..40f033e 100644
--- a/src/main.js
+++ b/src/main.js
@@ -302,7 +302,6 @@ async function boot() {
registerRoute('/about', () => import('./pages/about.js'))
registerRoute('/assistant', () => import('./pages/assistant.js'))
registerRoute('/setup', () => import('./pages/setup.js'))
- registerRoute('/docker', () => import('./pages/docker.js'))
registerRoute('/channels', () => import('./pages/channels.js'))
registerRoute('/cron', () => import('./pages/cron.js'))
diff --git a/src/pages/docker.js b/src/pages/docker.js
deleted file mode 100644
index ccadd72..0000000
--- a/src/pages/docker.js
+++ /dev/null
@@ -1,1733 +0,0 @@
-/**
- * Docker 集群管理页面
- * 管理 OpenClaw Docker 容器集群:节点管理、容器 CRUD、日志查看
- */
-import { api } from '../lib/tauri-api.js'
-import { toast } from '../components/toast.js'
-import { showConfirm } from '../components/modal.js'
-import { icon } from '../lib/icons.js'
-import { pixelRole, pixelBarracks } from '../lib/pixel-roles.js'
-import { getActiveInstance, switchInstance, isInDocker } from '../lib/app-state.js'
-import { renderSidebar } from '../components/sidebar.js'
-import { reloadCurrentRoute } from '../router.js'
-import { DOCKER_TASK_TIMEOUT_MS, buildDockerDispatchTargets, buildDockerInstanceSwitchContext } from '../lib/docker-tasking.js'
-
-function esc(str) {
- if (!str) return ''
- return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')
-}
-
-function fmtBytes(bytes) {
- if (!bytes) return '-'
- if (bytes < 1024) return bytes + ' B'
- if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
- if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'
- return (bytes / 1073741824).toFixed(1) + ' GB'
-}
-
-// OpenClaw 容器识别
-const OPENCLAW_PATTERNS = ['openclaw', 'qingchencloud']
-function isOpenClawContainer(c) {
- const img = (c.image || '').toLowerCase()
- return OPENCLAW_PATTERNS.some(p => img.includes(p))
-}
-
-// 用户手动纳入管理的容器 ID 持久化
-const ADOPTED_KEY = 'clawpanel_adopted_containers'
-function getAdoptedIds() {
- try { return new Set(JSON.parse(localStorage.getItem(ADOPTED_KEY) || '[]')) }
- catch { return new Set() }
-}
-function saveAdoptedIds(ids) {
- localStorage.setItem(ADOPTED_KEY, JSON.stringify([...ids]))
-}
-function isManagedContainer(c) {
- return isOpenClawContainer(c) || getAdoptedIds().has(c.id)
-}
-
-// 军事化术语 & 兵种系统
-const MILITARY = {
- roles: {
- general: { iconName: 'shield', title: '步兵', desc: '通用作战', color: '#64748b' },
- coder: { iconName: 'swords', title: '突击兵', desc: '编程突击', color: '#f59e0b' },
- translator: { iconName: 'globe', title: '翻译官', desc: '翻译作战', color: '#06b6d4' },
- writer: { iconName: 'pen-tool', title: '文书官', desc: '写作任务', color: '#8b5cf6' },
- analyst: { iconName: 'bar-chart', title: '参谋', desc: '数据分析', color: '#22c55e' },
- custom: { iconName: 'gear', title: '特种兵', desc: '特殊任务', color: '#ef4444' },
- },
- // 从容器名推断兵种
- inferRole(name) {
- if (!name) return 'general'
- const n = name.toLowerCase()
- for (const r of ['coder', 'translator', 'writer', 'analyst', 'custom']) {
- if (n.includes(r)) return r
- }
- return 'general'
- },
-}
-
-// 兵种徽章内嵌 SVG 路径(24x24 viewBox,嵌入盾形内)
-const BADGE_PATHS = {
- crown: '
',
- shield: '
',
- swords: '
',
- globe: '
',
- 'pen-tool':'
',
- 'bar-chart':'
',
- gear: '
',
-}
-
-function roleBadgeSvg(role, size = 32) {
- const r = MILITARY.roles[role] || MILITARY.roles.general
- const badgePath = BADGE_PATHS[r.iconName] || BADGE_PATHS.shield
- return `
`
-}
-
-function roleIcon(role, size = 14) {
- const r = MILITARY.roles[role] || MILITARY.roles.general
- return icon(r.iconName, size)
-}
-
-let _refreshTimer = null
-let _workspaceTimer = null
-let _lastContainers = []
-
-export async function render() {
- const page = document.createElement('div')
- page.className = 'page'
-
- page.innerHTML = `
-
-
-
-
-
-
-
-
-
-
-
-
- ${icon('alert-triangle', 12)}
- 测试功能,当前能力与稳定性仍在完善中。
-
-
-
-
-
-
-
-
-
-
-
${icon('swords', 14)} 兵力部署
-
-
-
- 0 已选
-
-
-
-
-
-
-
-
-
-
-
-
- ${icon('castle', 14)} 军营
-
-
-
- `
-
- bindEvents(page)
- initTaskHub(page)
- await loadClusterOverview(page)
-
- _refreshTimer = setInterval(() => loadClusterOverview(page), 30000)
- return page
-}
-
-export function cleanup() {
- if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null }
- if (_workspaceTimer) { clearInterval(_workspaceTimer); _workspaceTimer = null }
-}
-
-async function loadClusterOverview(page) {
- try {
- const nodes = await api.dockerClusterOverview()
- renderHeader(page, nodes)
- renderWorkers(page, nodes)
- renderNodes(page, nodes)
- renderOthers(page, nodes)
- updateTaskTargets(page, nodes)
- // 基础设施摘要
- const totalContainers = nodes.reduce((s, n) => s + (n.totalContainers || 0), 0)
- const runningContainers = nodes.reduce((s, n) => s + (n.runningContainers || 0), 0)
- const detail = page.querySelector('#infra-detail')
- if (detail) detail.textContent = `${nodes.length} 节点 · ${runningContainers} 运行 / ${totalContainers} 总计`
- } catch (e) {
- const errMsg = String(e.message || e)
- // 后端未运行(Tauri 桌面版不含 Docker 后端,或 Web 模式后端未启动)
- const isBackendMissing = errMsg.includes('后端服务未运行') || errMsg.includes('is not valid JSON') || errMsg.includes('${icon('x-circle', 12)} Docker 未连接: ${esc(displayMsg)}`
-
- // ClawPanel 自身运行在 Docker 容器中时,显示容器内专属指引
- if (isInDocker()) {
- page.querySelector('#workers-grid').innerHTML = `
-
-
-
Docker 宿主机未连接
-
ClawPanel 当前运行在 Docker 容器中,需要连接宿主机 Docker 守护进程才能管理龙虾军团。
-
-
${icon('gear', 14)} 连接宿主机 Docker
-
- - 确保宿主机 Docker 守护进程已开启 TCP 或 Unix Socket 访问
- - 挂载 Docker Socket:在 docker-compose.yml 中添加
- /var/run/docker.sock:/var/run/docker.sock
- - 或在「军营」区域添加远程 Docker 节点(TCP 方式:
http://宿主机IP:2375)
- - 重启容器后回到本页面点击「刷新」
-
-
龙虾军团功能用于在宿主机上部署和管理多个 OpenClaw 容器实例
-
-
- `
- page.querySelector('#docker-nodes').innerHTML = ''
- page.querySelector('#docker-containers').innerHTML = ''
- return
- }
-
- // 后端缺失时显示专属指引(桌面版需要 Web 部署模式)
- if (isBackendMissing) {
- page.querySelector('#workers-grid').innerHTML = `
-
-
-
龙虾军团需要 Web 部署模式
-
Docker 容器管理功能需要 ClawPanel Web 后端支持。桌面版暂不内置 Docker 管理后端。
-
-
${icon('info', 14)} 如何使用龙虾军团
-
- - 使用 Docker 部署 ClawPanel Web 版(推荐):
docker run -d -p 3000:3000 -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/qingchencloud/openclaw:latest
- - 或使用开发模式启动:
npm run dev,后端会自动启动 Docker 管理服务
- - 确保 Docker Desktop 已安装并运行
-
-
桌面版的 Docker 管理功能正在开发中,敬请期待后续版本更新。
-
-
- `
- page.querySelector('#docker-nodes').innerHTML = ''
- page.querySelector('#docker-containers').innerHTML = ''
- return
- }
-
- const isWin = navigator.userAgent.includes('Windows')
- const isMacOS = navigator.userAgent.includes('Mac')
- const installGuide = isWin
- ? `
-
${icon('download', 14)} Windows 安装 Docker Desktop
-
- - 下载 Docker Desktop for Windows
- - 双击安装包,按提示完成安装(需要 WSL2 支持)
- - 安装完成后启动 Docker Desktop,等待右下角鲸鱼图标变绿
- - 回到本页面点击「刷新」
-
-
如已安装,请确认 Docker Desktop 已启动(右下角托盘图标)
-
`
- : isMacOS
- ? `
-
${icon('download', 14)} macOS 安装 Docker Desktop
-
- - 下载 Docker Desktop for Mac (Apple Silicon) 或 Intel 版
- - 拖入 Applications 文件夹,启动 Docker Desktop
- - 等待菜单栏鲸鱼图标变为运行状态
- - 回到本页面点击「刷新」
-
-
也可通过 Homebrew 安装:brew install --cask docker
-
`
- : `
-
${icon('download', 14)} Linux 安装 Docker
-
- - 一键安装:
curl -fsSL https://get.docker.com | sh
- - 将当前用户加入 docker 组:
sudo usermod -aG docker $USER
- - 重新登录后执行
docker info 验证
- - 回到本页面点击「刷新」
-
-
如已安装,确认 Docker 守护进程已启动:sudo systemctl start docker
-
`
- page.querySelector('#workers-grid').innerHTML = `
-
-
-
Docker 未连接
-
${esc(e.message)}
- ${installGuide}
-
- `
- page.querySelector('#docker-nodes').innerHTML = ''
- page.querySelector('#docker-containers').innerHTML = ''
- }
-}
-
-function renderHeader(page, nodes) {
- const el = page.querySelector('#cluster-stats')
- let total = 0, running = 0
- for (const n of nodes) {
- if (!n.online || !n.containers) continue
- for (const c of n.containers) {
- if (isManagedContainer(c)) { total++; if (c.state === 'running') running++ }
- }
- }
- const stopped = total - running
- el.innerHTML = `
-
${running} 在线
-
·
-
${total} 兵力
- ${stopped > 0 ? `
·${stopped} 休整` : ''}
- `
-}
-
-function renderNodes(page, nodes) {
- const el = page.querySelector('#docker-nodes')
- let html = `
-
-
- `
- for (const node of nodes) {
- const statusClass = node.online ? 'online' : 'offline'
- const statusText = node.online ? '在线' : '离线'
- const mem = node.memory ? fmtBytes(node.memory) : '-'
- html += `
-
-
-
- ${esc(node.endpoint)}
- ${node.online ? `Docker ${esc(node.dockerVersion)}${node.cpus || '-'} CPU · ${mem} RAM` : `${esc(node.error || '连接失败')}`}
-
- ${node.online ? `
-
- ` : ''}
-
- `
- }
- html += '
'
- el.innerHTML = html
-}
-
-function _parseHostPorts(portsStr) {
- const result = { panel: null, gateway: null }
- if (!portsStr) return result
- for (const seg of portsStr.split(/,\s*/)) {
- // 支持多种格式: "1421→1420", "1421->1420", "1421:1420", "0.0.0.0:1421->1420/tcp"
- const m = seg.match(/(\d+)\s*(?:→|->|:)\s*(\d+)/)
- if (!m) continue
- const hostPort = m[1], containerPort = m[2]
- if (containerPort === '1420') result.panel = hostPort
- else if (containerPort === '18789') result.gateway = hostPort
- }
- return result
-}
-
-function _renderUnitCard(c, showAdopt) {
- const isRunning = c.state === 'running'
- const stateClass = isRunning ? 'running' : 'stopped'
- const isAdopted = !isOpenClawContainer(c) && getAdoptedIds().has(c.id)
- const ports = _parseHostPorts(c.ports)
- const host = location.hostname || 'localhost'
- const role = MILITARY.inferRole(c.name)
- const roleInfo = MILITARY.roles[role]
-
- if (showAdopt) {
- return `
-
-
-
`
- }
-
- // 检查是否为当前管理的活跃实例
- const activeInst = getActiveInstance()
- const isActive = activeInst.type === 'docker' && activeInst.id === `docker-${c.id.slice(0, 12)}`
-
- return `
-
-
-
-
- ${isRunning && (ports.panel || ports.gateway) ? `
-
- ` : ''}
-
-
`
-}
-
-function renderWorkers(page, nodes) {
- const el = page.querySelector('#workers-grid')
- const allContainers = []
- for (const node of nodes) {
- if (!node.online || !node.containers) continue
- for (const c of node.containers) {
- allContainers.push({ ...c, nodeId: node.id, nodeName: node.name })
- }
- }
- _lastContainers = allContainers
- const managed = allContainers.filter(c => isManagedContainer(c))
-
- if (managed.length === 0) {
- el.innerHTML = `
-
🦞
- 暂无兵力。前往基础设施,在军营中「征召龙虾」
-
`
- return
- }
-
- el.innerHTML = `
${managed.map(c => _renderUnitCard(c, false)).join('')}
`
-
- // 有军团成员时显示批量操作栏 + 重置全选状态
- const batchEl = page.querySelector('#batch-actions')
- if (batchEl) batchEl.style.display = managed.length > 0 ? 'flex' : 'none'
- _updateBatchUI(page)
-}
-
-function renderOthers(page, nodes) {
- const el = page.querySelector('#docker-containers')
- const others = []
- for (const node of nodes) {
- if (!node.online || !node.containers) continue
- for (const c of node.containers) {
- if (!isManagedContainer(c)) others.push({ ...c, nodeId: node.id, nodeName: node.name })
- }
- }
- if (others.length === 0) { el.innerHTML = ''; return }
- el.innerHTML = `
-
-
${others.map(c => _renderUnitCard(c, true)).join('')}
- `
-}
-
-// === 任务中心 ===
-
-let _runningWorkers = [] // 缓存在线工人列表
-
-function updateTaskTargets(page, nodes) {
- _runningWorkers = []
- for (const n of nodes) {
- if (!n.online || !n.containers) continue
- for (const c of n.containers) {
- if (isManagedContainer(c) && c.state === 'running') {
- const role = MILITARY.inferRole(c.name)
- const ports = _parseHostPorts(c.ports)
- _runningWorkers.push({ id: c.id, name: c.name, role, ports, nodeId: n.id })
- }
- }
- }
- // 更新任务中心可用状态
- const hub = page.querySelector('#task-hub')
- const sendBtn = page.querySelector('#task-send')
- if (hub) hub.style.display = _runningWorkers.length > 0 ? '' : 'none'
- if (sendBtn) sendBtn.disabled = _runningWorkers.length === 0
-
- // 更新指定模式的目标选择器
- _renderPickTargets(page)
-}
-
-function _renderPickTargets(page) {
- const el = page.querySelector('#task-pick')
- if (!el) return
- el.innerHTML = _runningWorkers.map(w => {
- const r = MILITARY.roles[w.role]
- return `
`
- }).join('')
-}
-
-function _smartRoute(command) {
- const cmd = command.toLowerCase()
- const keywords = {
- coder: ['代码', '编程', 'code', 'debug', '调试', '函数', 'bug', '重构', 'refactor'],
- translator: ['翻译', 'translate', '英译', '中译', '日译', '多语言'],
- writer: ['写', '文章', '文案', '作文', 'write', '创作', '文书', '邮件', 'email'],
- analyst: ['分析', '数据', 'data', 'analyze', '统计', '报表', '图表', '策略'],
- }
- for (const [role, words] of Object.entries(keywords)) {
- if (words.some(w => cmd.includes(w))) {
- const match = _runningWorkers.find(w => w.role === role)
- if (match) return [match]
- }
- }
- // 未匹配 → 第一个
- return _runningWorkers.length > 0 ? [_runningWorkers[0]] : []
-}
-
-function initTaskHub(page) {
- const input = page.querySelector('#task-input')
- const sendBtn = page.querySelector('#task-send')
- const modeBar = page.querySelector('#task-mode')
- const pickBar = page.querySelector('#task-pick')
- if (!input || !sendBtn) return
-
- let currentMode = 'broadcast'
-
- // 模式切换
- for (const btn of modeBar.querySelectorAll('.task-mode-btn')) {
- btn.onclick = () => {
- modeBar.querySelectorAll('.task-mode-btn').forEach(b => b.classList.remove('active'))
- btn.classList.add('active')
- currentMode = btn.dataset.mode
- pickBar.style.display = currentMode === 'pick' ? '' : 'none'
- }
- }
-
- // 自动调整高度
- input.addEventListener('input', () => {
- input.style.height = 'auto'
- input.style.height = Math.min(input.scrollHeight, 120) + 'px'
- sendBtn.disabled = !input.value.trim() || _runningWorkers.length === 0
- })
-
- // Enter 发送
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendBtn.click() }
- })
-
- sendBtn.onclick = async () => {
- const command = input.value.trim()
- if (!command || _runningWorkers.length === 0) return
-
- // 确定目标
- let targets = []
- if (currentMode === 'broadcast') {
- targets = [..._runningWorkers]
- } else if (currentMode === 'smart') {
- targets = _smartRoute(command)
- } else if (currentMode === 'pick') {
- const checked = page.querySelectorAll('#task-pick .pick-cb:checked')
- const ids = new Set([...checked].map(cb => cb.dataset.id))
- targets = _runningWorkers.filter(w => ids.has(w.id))
- }
-
- if (targets.length === 0) { toast('没有可用的目标', 'error'); return }
-
- // 异步派发 — 立即返回,不阻塞 UI
- sendBtn.disabled = true
- try {
- const dispatchTargets = buildDockerDispatchTargets(targets)
- await api.dockerDispatchBroadcast(null, dispatchTargets, command, DOCKER_TASK_TIMEOUT_MS)
- toast(`任务已派发给 ${targets.length} 名龙虾兵`, 'success')
- } catch (e) {
- toast(`派发失败: ${e.message}`, 'error')
- sendBtn.disabled = false
- return
- }
-
- // 清空输入,启动工作区轮询
- input.value = ''
- input.style.height = 'auto'
- sendBtn.disabled = !input.value.trim() || _runningWorkers.length === 0
- _startWorkspacePolling(page)
- _refreshWorkspace(page)
- }
-}
-
-// === 异步工作区 ===
-
-function _startWorkspacePolling(page) {
- if (_workspaceTimer) return
- _workspaceTimer = setInterval(() => _refreshWorkspace(page), 3000)
-}
-
-function _stopWorkspacePolling() {
- if (_workspaceTimer) { clearInterval(_workspaceTimer); _workspaceTimer = null }
-}
-
-async function _refreshWorkspace(page) {
- const wsEl = page.querySelector('#task-workspace')
- if (!wsEl) return
-
- try {
- const tasks = await api.dockerTaskList()
- if (!tasks || tasks.length === 0) {
- wsEl.style.display = 'none'
- _stopWorkspacePolling()
- return
- }
-
- wsEl.style.display = ''
- _renderWorkspaceWorkers(page, tasks)
- _renderWorkspaceHistory(page, tasks)
-
- // 没有正在运行的任务时停止轮询
- const hasRunning = tasks.some(t => t.status === 'running')
- if (!hasRunning) _stopWorkspacePolling()
- } catch (e) {
- console.warn('[workspace] 刷新失败:', e.message)
- }
-}
-
-function _renderWorkspaceWorkers(page, tasks) {
- const el = page.querySelector('#workspace-workers')
- if (!el) return
-
- // 用 containerId 去重,取每个容器最新的任务
- const latestByContainer = new Map()
- for (const t of tasks) {
- if (!latestByContainer.has(t.containerId) || t.startedAt > latestByContainer.get(t.containerId).startedAt) {
- latestByContainer.set(t.containerId, t)
- }
- }
-
- // 只展示有任务的工人
- const workers = [...latestByContainer.values()]
- if (workers.length === 0) { el.innerHTML = ''; return }
-
- el.innerHTML = `
${workers.map(t => {
- const role = MILITARY.inferRole(t.containerName)
- const r = MILITARY.roles[role] || MILITARY.roles.general
- const shortName = (t.containerName || '').replace(/^openclaw-/, '')
- const isRunning = t.status === 'running'
- const isError = t.status === 'error'
- const elapsed = t.elapsed ? (t.elapsed / 1000).toFixed(0) : '0'
- const msgPreview = (t.message || '').slice(0, 40) + ((t.message || '').length > 40 ? '...' : '')
-
- return `
-
- ${pixelRole(role, 28)}
-
-
${esc(shortName)}
-
${r.title} — ${r.desc}
-
-
- ${isRunning ? `${icon('zap', 10)} 工作中` : isError ? `${icon('x-circle', 10)} 失败` : `${icon('check-circle', 10)} 完成`}
-
-
-
-
${icon('message-square', 10)} ${esc(msgPreview)}
-
${isRunning ? `⏱ ${elapsed}s...` : `${elapsed}s`}
-
-
`
- }).join('')}
`
-}
-
-function _renderWorkspaceHistory(page, tasks) {
- const el = page.querySelector('#workspace-history')
- if (!el) return
-
- // 只显示已完成/失败的任务
- const finished = tasks.filter(t => t.status !== 'running')
- if (finished.length === 0) { el.innerHTML = ''; return }
-
- el.innerHTML = `
-
${icon('clock', 12)} 任务记录
-
- ${finished.map(t => {
- const shortName = (t.containerName || '').replace(/^openclaw-/, '')
- const elapsed = t.elapsed ? (t.elapsed / 1000).toFixed(1) : '0'
- const msgPreview = (t.message || '').slice(0, 50) + ((t.message || '').length > 50 ? '...' : '')
- const isError = t.status === 'error'
- const time = new Date(t.startedAt)
- const timeStr = `${time.getHours().toString().padStart(2,'0')}:${time.getMinutes().toString().padStart(2,'0')}`
- return `
- ${isError ? icon('x-circle', 12) : icon('check-circle', 12)}
- ${esc(shortName)}
- ${esc(msgPreview)}
- ${elapsed}s · ${timeStr}
- ${t.hasResult ? `` : ''}
-
`
- }).join('')}
-
- `
-}
-
-async function _showTaskDetail(page, taskId) {
- try {
- const task = await api.dockerTaskStatus(taskId)
- if (!task) { toast('任务不存在', 'error'); return }
-
- const shortName = (task.containerName || '').replace(/^openclaw-/, '')
- const elapsed = task.elapsed ? (task.elapsed / 1000).toFixed(1) : '0'
- const isError = task.status === 'error'
-
- // 提取结果文本
- let resultText = ''
- if (task.result?.result) {
- resultText = task.result.result
- } else if (task.error) {
- resultText = `错误: ${task.error}`
- } else if (task.events?.length) {
- const finals = task.events.filter(e => e.type === 'final' || e.type === 'result')
- resultText = finals.map(e => e.text || e.message || JSON.stringify(e)).join('\n')
- }
- if (!resultText) resultText = '(无回复)'
-
- // 提取工具调用日志
- const toolCalls = (task.events || []).filter(e => e.type === 'tool_call' || e.type === 'tool_result')
- const toolHtml = toolCalls.length > 0 ? `
-
-
${icon('gear', 12)} 工具调用 (${toolCalls.length})
-
-
- ` : ''
-
- // 展示详情弹窗
- const { showConfirm: _ } = await import('../components/modal.js')
- const overlay = document.createElement('div')
- overlay.className = 'task-detail-overlay'
- overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
- overlay.innerHTML = `
-
-
-
-
-
${icon('message-square', 12)} 指令
-
${esc(task.message)}
-
-
-
${isError ? icon('x-circle', 12) + ' 错误' : icon('check-circle', 12) + ' 结果'}
-
${esc(resultText)}
-
- ${toolHtml}
-
- 耗时 ${elapsed}s · ${new Date(task.startedAt).toLocaleTimeString()}
-
-
-
- `
- document.body.appendChild(overlay)
- } catch (e) {
- toast(`加载任务详情失败: ${e.message}`, 'error')
- }
-}
-
-function _updateBatchUI(page) {
- const checks = page.querySelectorAll('.ct-select:checked')
- const countEl = page.querySelector('#batch-count')
- if (countEl) countEl.textContent = `${checks.length} 名已选`
- const selectAll = page.querySelector('#ct-select-all')
- const allChecks = page.querySelectorAll('.ct-select')
- if (selectAll && allChecks.length) selectAll.checked = checks.length === allChecks.length
- // 批量按钮启用/禁用
- for (const btn of page.querySelectorAll('.batch-btn')) {
- btn.disabled = checks.length === 0
- }
-}
-
-function bindEvents(page) {
- // 全选 / 单选 复选框
- page.addEventListener('change', (e) => {
- if (e.target.id === 'ct-select-all') {
- const checked = e.target.checked
- page.querySelectorAll('.ct-select').forEach(cb => cb.checked = checked)
- }
- if (e.target.classList.contains('ct-select') || e.target.id === 'ct-select-all') {
- _updateBatchUI(page)
- }
- })
-
- page.addEventListener('click', async (e) => {
- // 工作区:点击工人卡片或历史条目查看详情
- const wsWorker = e.target.closest('.ws-worker[data-task-id]')
- const wsView = e.target.closest('.ws-history-view[data-task-id]')
- const wsItem = e.target.closest('.ws-history-item[data-task-id]')
- if (wsView) { _showTaskDetail(page, wsView.dataset.taskId); return }
- if (wsWorker && !wsWorker.querySelector('.ws-worker-badge.running')) { _showTaskDetail(page, wsWorker.dataset.taskId); return }
- if (wsItem && !wsView) { _showTaskDetail(page, wsItem.dataset.taskId); return }
-
- const btn = e.target.closest('[data-action]')
- if (!btn) return
- const action = btn.dataset.action
-
- // 工作区清空
- if (action === 'workspace-clear') {
- const wsEl = page.querySelector('#task-workspace')
- if (wsEl) wsEl.style.display = 'none'
- _stopWorkspacePolling()
- return
- }
-
- // 切换管理实例
- if (action === 'switch-instance') {
- const ct = btn.dataset.ct
- const name = btn.dataset.name
- const port = btn.dataset.port
- const gatewayPort = btn.dataset.gatewayPort
- const nodeId = btn.dataset.node || null
- if (!ct || !port) return
- const switchCtx = buildDockerInstanceSwitchContext({
- containerId: ct,
- name,
- port,
- gatewayPort,
- nodeId,
- })
- const originalHtml = btn.innerHTML
- btn.disabled = true
- btn.textContent = '切换中...'
- try {
- await switchInstance(switchCtx.instanceId)
- toast(`已切换管理 → ${name}(模型配置、Agent 等将管理该士兵)`, 'success')
- const sidebar = document.getElementById('sidebar')
- if (sidebar) renderSidebar(sidebar)
- if (switchCtx.reloadRoute) {
- reloadCurrentRoute()
- return
- }
- await loadClusterOverview(page)
- } catch (e) {
- try {
- const added = await api.instanceAdd(switchCtx.registration)
- await switchInstance(added.id)
- toast(`已注册并切换管理 → ${name}`, 'success')
- const sidebar = document.getElementById('sidebar')
- if (sidebar) renderSidebar(sidebar)
- if (switchCtx.reloadRoute) {
- reloadCurrentRoute()
- return
- }
- await loadClusterOverview(page)
- } catch (e2) {
- btn.disabled = false
- btn.innerHTML = originalHtml
- toast(`切换失败: ${e2.message}`, 'error')
- }
- }
- return
- }
-
- // 批量操作
- if (action.startsWith('batch-')) {
- const op = action.replace('batch-', '')
- const checks = page.querySelectorAll('.ct-select:checked')
- if (checks.length === 0) { toast('请先勾选士兵', 'error'); return }
-
- const OP_NAMES = { start: '出征', stop: '休整', restart: '整编', sync: '同步配置', rebuild: '重建', remove: '退役' }
- const opName = OP_NAMES[op] || op
-
- const confirmMsgs = {
- start: '将启动所有已勾选的士兵。',
- stop: '将停止所有已勾选的士兵。',
- restart: '将重启所有已勾选的士兵。',
- sync: '将向所有已勾选的士兵同步 API Key、兵种配置和 Agent。',
- rebuild: '将拉取最新镜像并重建所有已勾选的士兵(数据卷保留)。\n⚠ 重建过程中士兵将暂时离线。',
- remove: '⚠ 此操作不可撤销!将永久退役所有已勾选的士兵。',
- }
-
- const ok = await showConfirm(`军令:${opName} ${checks.length} 名士兵?`, confirmMsgs[op] || '将对所有已勾选的士兵执行命令。')
- if (!ok) return
-
- toast(`正在执行军令: ${opName}...`, 'info')
-
- // 禁用所有批量按钮
- page.querySelectorAll('.batch-btn').forEach(b => b.disabled = true)
-
- let success = 0, fail = 0
- const total = checks.length
- const errors = []
-
- for (const cb of checks) {
- const nId = cb.dataset.node, cId = cb.dataset.ct
- const cName = cb.closest('.unit-card')?.querySelector('.unit-name')?.textContent || cId
- try {
- if (op === 'start') await api.dockerStartContainer(nId, cId)
- else if (op === 'stop') await api.dockerStopContainer(nId, cId)
- else if (op === 'restart') await api.dockerRestartContainer(nId, cId)
- else if (op === 'sync') {
- const role = MILITARY.inferRole(cName)
- await api.dockerInitWorker(nId, cId, role)
- }
- else if (op === 'rebuild') await api.dockerRebuildContainer(nId, cId, true)
- else if (op === 'remove') await api.dockerRemoveContainer(nId, cId, true)
- success++
- toast(`${opName}进度: ${success + fail}/${total}`, 'info')
- } catch (e) {
- fail++
- errors.push(`${cName}: ${e.message}`)
- console.error(`[batch-${op}] ${cName} 失败:`, e.message)
- }
- }
-
- const resultType = fail === 0 ? 'success' : fail === total ? 'error' : 'info'
- let msg = `军令执行完毕: ${success} 名${opName}${fail ? `,${fail} 名失败` : ''}`
- if (errors.length > 0) msg += `\n${errors.slice(0, 3).join('\n')}${errors.length > 3 ? `\n...还有 ${errors.length - 3} 个错误` : ''}`
- toast(msg, resultType)
- await loadClusterOverview(page)
- return
- }
-
- if (action === 'refresh') {
- toast('侦察中...')
- await loadClusterOverview(page)
- return
- }
-
- if (action === 'add-node') {
- showAddNodeDialog(page)
- return
- }
-
- if (action === 'remove-node') {
- const nodeId = btn.dataset.nodeId
- const ok = await showConfirm('确定撤销此军营?', '撤销后该军营的士兵将不再接受指挥。')
- if (!ok) return
- try {
- await api.dockerRemoveNode(nodeId)
- toast('军营已撤销')
- await loadClusterOverview(page)
- } catch (e) { toast(e.message, 'error') }
- return
- }
-
- if (action === 'deploy') {
- showDeployDialog(page, btn.dataset.nodeId)
- return
- }
-
- if (action === 'adopt') {
- const ids = getAdoptedIds()
- ids.add(btn.dataset.ct)
- saveAdoptedIds(ids)
- toast(`${btn.dataset.name || btn.dataset.ct} 已编入军团`)
- await loadClusterOverview(page)
- return
- }
-
- if (action === 'unadopt') {
- const ids = getAdoptedIds()
- ids.delete(btn.dataset.ct)
- saveAdoptedIds(ids)
- toast('已脱编')
- await loadClusterOverview(page)
- return
- }
-
- const containerId = btn.dataset.ct
- const nodeId = btn.dataset.node
-
- if (action === 'start') {
- try {
- btn.disabled = true
- await api.dockerStartContainer(nodeId, containerId)
- toast('士兵已出征')
- await loadClusterOverview(page)
- } catch (e) { toast(e.message, 'error') }
- return
- }
-
- if (action === 'stop') {
- try {
- btn.disabled = true
- await api.dockerStopContainer(nodeId, containerId)
- toast('士兵已休整')
- await loadClusterOverview(page)
- } catch (e) { toast(e.message, 'error') }
- return
- }
-
- if (action === 'restart') {
- try {
- btn.disabled = true
- await api.dockerRestartContainer(nodeId, containerId)
- toast('士兵已整编')
- await loadClusterOverview(page)
- } catch (e) { toast(e.message, 'error') }
- return
- }
-
- if (action === 'remove') {
- const name = btn.dataset.name || containerId
- const ok = await showConfirm(`确定让 ${name} 退役?`, '军备库数据保留,但士兵本体将被遗散。')
- if (!ok) return
- try {
- await api.dockerRemoveContainer(nodeId, containerId, true)
- toast('士兵已退役')
- await loadClusterOverview(page)
- } catch (e) { toast(e.message, 'error') }
- return
- }
-
- if (action === 'rebuild') {
- const name = btn.dataset.name || containerId
- const ok = await showConfirm(`重建 ${name}?`, '将拉取最新镜像并重新创建容器,数据卷保留。\n重建期间士兵将暂时离线。')
- if (!ok) return
- btn.disabled = true
- toast(`正在重建 ${name}...`, 'info')
- try {
- const result = await api.dockerRebuildContainer(nodeId, containerId, true)
- toast(`${result.name || name} 已重建完成`, 'success')
- await loadClusterOverview(page)
- } catch (e) {
- toast(`${name} 重建失败: ${e.message}`, 'error')
- btn.disabled = false
- }
- return
- }
-
- if (action === 'sync-config') {
- const cid = btn.dataset.ct
- const nid = btn.dataset.node || null
- const name = btn.dataset.name || cid
- const role = btn.dataset.role || 'general'
- toast(`正在同步配置到 ${name}...`, 'info')
- try {
- const result = await api.dockerInitWorker(nid, cid, role)
- const count = result?.files?.length || 0
- // docker_init_worker 内部已重启 Gateway,不需要重启容器(重启会触发 entrypoint 覆盖配置)
- toast(`${name}: 已同步 ${count} 个文件,Gateway 已重启`, 'success')
- setTimeout(() => loadClusterOverview(page), 3000)
- } catch (e) {
- toast(`${name} 同步失败: ${e.message}`, 'error')
- }
- return
- }
-
- if (action === 'quick-chat') {
- const cid = btn.dataset.containerId
- const nid = btn.dataset.nodeId || null
- const name = btn.dataset.name || cid
- toast(`正在连接 ${name} 的 Gateway...`, 'info')
- try {
- const resp = await api.dockerAgent(nid, cid, { cmd: 'task.run', message: '你好,报告你的兵种和状态' })
- toast(`${name} 回复: ${(resp?.result || '(无回复)').slice(0, 100)}`, 'success')
- } catch (e) {
- toast(`${name} 通讯失败: ${e.message}`, 'error')
- }
- return
- }
-
- if (action === 'inspect') {
- showInspectDialog(page, nodeId, containerId)
- return
- }
-
- if (action === 'logs') {
- showLogsDialog(page, nodeId, containerId)
- return
- }
- })
-}
-
-function showAddNodeDialog(page) {
- const isWin = navigator.platform?.toLowerCase().includes('win')
- const presets = [
- { label: '本机 (TCP)', endpoint: 'tcp://127.0.0.1:2375', desc: '本机 Docker TCP 端口' },
- { label: '本机 (Socket)', endpoint: isWin ? '//./pipe/docker_engine' : 'unix:///var/run/docker.sock', desc: isWin ? 'Windows Named Pipe' : 'Unix Socket' },
- ]
-
- const overlay = document.createElement('div')
- overlay.className = 'docker-dialog-overlay'
- overlay.innerHTML = `
-
-
${icon('castle', 16)} 建立新军营
-
-
-
-
-
-
- 远程 Docker:需在目标机器开启 TCP 端口
- dockerd -H tcp://0.0.0.0:2375
-
-
-
-
-
-
- `
- document.body.appendChild(overlay)
- overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
- overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
-
- const epInput = overlay.querySelector('#dn-endpoint')
- const resultEl = overlay.querySelector('#dn-test-result')
-
- // 预设按钮点击
- for (const btn of overlay.querySelectorAll('.dn-preset-btn')) {
- btn.onclick = () => {
- overlay.querySelectorAll('.dn-preset-btn').forEach(b => b.classList.remove('active'))
- btn.classList.add('active')
- const idx = btn.dataset.idx
- if (idx === 'custom') {
- epInput.value = ''
- epInput.focus()
- } else {
- epInput.value = presets[parseInt(idx)].endpoint
- }
- resultEl.textContent = ''
- }
- }
-
- // 测试连接
- overlay.querySelector('#dn-test').onclick = async () => {
- const ep = epInput.value.trim()
- if (!ep) { resultEl.innerHTML = '
请先输入端点'; return }
- resultEl.innerHTML = '
连接中...'
- try {
- const info = await api.dockerTestEndpoint(ep)
- resultEl.innerHTML = `
${icon('check-circle', 14)} 连接成功 — Docker ${esc(info.ServerVersion || '?')},${info.Containers || 0} 个容器`
- } catch (e) {
- resultEl.innerHTML = `
${icon('x-circle', 14)} 连接失败:${esc(e.message)}`
- }
- }
-
- overlay.querySelector('#dn-submit').onclick = async () => {
- const name = overlay.querySelector('#dn-name').value.trim()
- const endpoint = epInput.value.trim()
- if (!name || !endpoint) { toast('请填写完整', 'error'); return }
- const btn = overlay.querySelector('#dn-submit')
- btn.disabled = true
- btn.textContent = '连接中...'
- try {
- await api.dockerAddNode(name, endpoint)
- toast('节点添加成功')
- overlay.remove()
- await loadClusterOverview(page)
- } catch (e) {
- toast(e.message, 'error')
- btn.disabled = false
- btn.textContent = '添加'
- }
- }
-}
-
-async function showDeployDialog(page, nodeId) {
- // 自动检测已用端口,分配下一组可用端口
- let usedPorts = new Set()
- try {
- const containers = await api.dockerListContainers(nodeId, true)
- for (const c of containers) {
- if (c.ports) {
- for (const p of c.ports.split(', ')) {
- const m = p.match(/^(\d+)/)
- if (m) usedPorts.add(parseInt(m[1]))
- }
- }
- }
- } catch {}
- let autoPanel = 1421
- while (usedPorts.has(autoPanel)) autoPanel++
- let autoGw = 18790
- while (usedPorts.has(autoGw)) autoGw++
-
- const defaultName = `openclaw-${Date.now().toString(36).slice(-4)}`
-
- const MIRRORS = {
- ghcr: { label: 'GitHub (ghcr.io)', image: 'ghcr.io/qingchencloud/openclaw' },
- tencent: { label: '国内源 (腾讯云)', image: 'ccr.ccs.tencentyun.com/qingchencloud/openclaw' },
- dockerhub: { label: 'Docker Hub', image: '1186258278/openclaw' },
- }
- const defaultMirror = 'ghcr'
-
- const overlay = document.createElement('div')
- overlay.className = 'docker-dialog-overlay'
- overlay.innerHTML = `
-
-
-
${icon('scroll', 16)} 征召令
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
入伍配置
-
装备包一体版 (latest)
-
指挥端口${autoPanel}
-
通讯端口${autoGw}
-
军备库自动分配
-
抗打能力战损自修 (unless-stopped)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `
- document.body.appendChild(overlay)
- overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
- overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
-
- // 镜像源切换 → 更新基础模式标签
- const mirrorSelect = overlay.querySelector('#dd-mirror')
- const mirrorLabel = overlay.querySelector('#dd-mirror-label')
- if (mirrorSelect && mirrorLabel) {
- mirrorSelect.onchange = () => {
- const m = MIRRORS[mirrorSelect.value]
- mirrorLabel.textContent = m ? `一体版 · ${m.label}` : '一体版 (latest)'
- }
- }
-
- // 模型提供商切换 → 显示/隐藏 API Key 输入
- const providerSelect = overlay.querySelector('#dd-provider')
- const providerFields = overlay.querySelector('#dd-provider-fields')
- if (providerSelect && providerFields) {
- providerSelect.onchange = () => {
- const v = providerSelect.value
- providerFields.style.display = (v === 'openai' || v === 'anthropic' || v === 'custom') ? '' : 'none'
- const keyInput = overlay.querySelector('#dd-api-key')
- const urlInput = overlay.querySelector('#dd-base-url')
- if (v === 'openai') { urlInput.value = 'https://api.openai.com/v1'; urlInput.placeholder = 'https://api.openai.com/v1' }
- else if (v === 'anthropic') { urlInput.value = 'https://api.anthropic.com'; urlInput.placeholder = 'https://api.anthropic.com' }
- else if (v === 'custom') { urlInput.value = ''; urlInput.placeholder = 'https://your-api.com/v1' }
- if (keyInput) keyInput.value = ''
- }
- }
-
- // 兵种卡片选择器
- const ROLE_DESCS = {
- general: '通用作战,什么都能做。适合不确定用途的新兵。',
- coder: '编程突击专精,擅长写代码、调试、Code Review。',
- translator: '多语言翻译作战,精通各国语言互译。',
- writer: '文案、文章、创意写作,笔下生花。',
- analyst: '数据分析与战略规划,运筹帷幄。',
- custom: '自定义特殊任务,按需配置。',
- }
- const roleHidden = overlay.querySelector('#dd-role')
- const roleInfo = overlay.querySelector('#dd-role-info')
- const nameInput = overlay.querySelector('#dd-name')
- for (const card of overlay.querySelectorAll('.role-card')) {
- card.onclick = () => {
- overlay.querySelectorAll('.role-card').forEach(c => c.classList.remove('selected'))
- card.classList.add('selected')
- const r = card.dataset.role
- const info = MILITARY.roles[r]
- if (roleHidden) roleHidden.value = r
- if (roleInfo) {
- roleInfo.style.setProperty('--role-color', info.color)
- roleInfo.innerHTML = `
-
${pixelRole(r, 32)}
-
${info.title} — ${ROLE_DESCS[r] || info.desc}
- `
- }
- if (nameInput && r !== 'custom') {
- nameInput.value = `openclaw-${r}-${Date.now().toString(36).slice(-4)}`
- }
- }
- }
-
- // 基础/高级模式切换
- let isAdvanced = false
- for (const btn of overlay.querySelectorAll('.deploy-mode-btn')) {
- btn.onclick = () => {
- isAdvanced = btn.dataset.mode === 'advanced'
- overlay.querySelectorAll('.deploy-mode-btn').forEach(b => b.classList.remove('active'))
- btn.classList.add('active')
- overlay.querySelector('#deploy-basic-info').style.display = isAdvanced ? 'none' : ''
- overlay.querySelector('#deploy-advanced-fields').style.display = isAdvanced ? '' : 'none'
- overlay.querySelector('#dd-submit').innerHTML = isAdvanced ? icon('swords', 14) + ' 部署' : icon('swords', 14) + ' 征召入伍'
- }
- }
-
- overlay.querySelector('#dd-submit').onclick = async () => {
- const name = overlay.querySelector('#dd-name').value.trim()
- if (!name) { toast('请输入士兵代号', 'error'); return }
- let image, tag, panelPort, gatewayPort, envVars = {}
- if (isAdvanced) {
- const imgFull = overlay.querySelector('#dd-image').value
- const parts = imgFull.split(':')
- tag = parts.pop()
- image = parts.join(':')
- panelPort = parseInt(overlay.querySelector('#dd-panel-port').value) || autoPanel
- gatewayPort = parseInt(overlay.querySelector('#dd-gw-port').value) || autoGw
- const envText = overlay.querySelector('#dd-env-key').value.trim()
- if (envText) {
- for (const line of envText.split('\n')) {
- const idx = line.indexOf('=')
- if (idx > 0) envVars[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
- }
- }
- } else {
- const mirrorKey = overlay.querySelector('#dd-mirror').value || defaultMirror
- const mirrorImg = MIRRORS[mirrorKey].image + ':latest'
- const parts = mirrorImg.split(':')
- tag = parts.pop()
- image = parts.join(':')
- panelPort = autoPanel
- gatewayPort = autoGw
- // 角色标签
- const role = overlay.querySelector('#dd-role')?.value || 'general'
- if (role !== 'custom') envVars['OPENCLAW_ROLE'] = role
- // AI 模型配置
- const provider = overlay.querySelector('#dd-provider')?.value || ''
- if (provider === 'free') {
- envVars['OPENCLAW_FREE_AI'] = 'true'
- } else if (provider === 'openai' || provider === 'anthropic' || provider === 'custom') {
- const apiKey = overlay.querySelector('#dd-api-key')?.value?.trim()
- const baseUrl = overlay.querySelector('#dd-base-url')?.value?.trim()
- if (apiKey) {
- if (provider === 'anthropic') {
- envVars['ANTHROPIC_API_KEY'] = apiKey
- if (baseUrl) envVars['ANTHROPIC_BASE_URL'] = baseUrl
- } else {
- envVars['OPENAI_API_KEY'] = apiKey
- if (baseUrl) envVars['OPENAI_BASE_URL'] = baseUrl
- }
- }
- }
- }
- const dialog = overlay.querySelector('.docker-dialog')
- const requestId = `pull-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
-
- // 切换到部署进度视图
- dialog.innerHTML = `
-
-
-
-
-
${icon('package', 18)}
-
-
-
-
${icon('gear', 18)}
-
-
-
-
${icon('rocket', 18)}
-
-
-
-
-
-
- `
-
- const pullDetail = dialog.querySelector('#pull-detail')
- const pullBar = dialog.querySelector('#pull-bar')
- const pullLog = dialog.querySelector('#pull-log')
- const stepPull = dialog.querySelector('#step-pull')
- const stepCreate = dialog.querySelector('#step-create')
- const stepStart = dialog.querySelector('#step-start')
- const createDetail = dialog.querySelector('#create-detail')
- const startDetail = dialog.querySelector('#start-detail')
-
- // 轮询拉取进度
- let pollTimer = setInterval(async () => {
- try {
- const s = await api.dockerPullStatus(requestId)
- if (!s || s.status === 'unknown') return
- pullDetail.textContent = s.message || '拉取中...'
- if (s.percent > 0) pullBar.style.width = s.percent + '%'
- if (s.layerCount) {
- const logText = `层进度: ${s.completedLayers || 0}/${s.layerCount} · ${s.percent || 0}%`
- pullLog.textContent = logText
- }
- if (s.status === 'done' || s.status === 'error') clearInterval(pollTimer)
- } catch {}
- }, 800)
-
- try {
- // Step 1: 拉取镜像
- try {
- await api.dockerPullImage(nodeId, image, tag, requestId)
- } catch (pullErr) {
- const images = await api.dockerListImages(nodeId).catch(() => [])
- const fullImage = `${image}:${tag}`
- const hasLocal = images.some(img => img.tags && img.tags.some(t => t === fullImage))
- if (!hasLocal) throw new Error(`镜像拉取失败: ${pullErr.message}`)
- }
- clearInterval(pollTimer)
- stepPull.classList.remove('active')
- stepPull.classList.add('done')
- pullDetail.textContent = '完成'
- pullBar.style.width = '100%'
-
- // Step 2: 创建容器
- stepCreate.classList.add('active')
- createDetail.textContent = '创建中...'
- const result = await api.dockerCreateContainer({ nodeId, name, image, tag, panelPort, gatewayPort, envVars })
- stepCreate.classList.remove('active')
- stepCreate.classList.add('done')
- createDetail.textContent = '完成'
-
- // Step 3: 启动 + 初始化
- stepStart.classList.add('active')
- startDetail.textContent = '启动中...'
- await new Promise(r => setTimeout(r, 1500))
-
- // 全套初始化:配置同步 + 性格注入 + 记忆同步 + MCP
- const selectedRoleForInject = overlay.querySelector('#dd-role')?.value || 'general'
- const cid = result.id || result.containerId || name
- try {
- startDetail.textContent = '同步配置 & 注入性格...'
- const initResult = await api.dockerInitWorker(nodeId, cid, selectedRoleForInject)
- const synced = initResult?.files?.length || 0
- startDetail.textContent = `已同步 ${synced} 个文件`
- console.log('[deploy] 初始化结果:', initResult)
- } catch (e) {
- console.warn('[deploy] 初始化警告:', e.message)
- startDetail.textContent = '初始化部分失败(不影响运行)'
- }
- await new Promise(r => setTimeout(r, 500))
-
- stepStart.classList.remove('active')
- stepStart.classList.add('done')
- startDetail.textContent = '运行中'
-
- // 成功页面
- const host = location.hostname || 'localhost'
- const panelUrl = `${location.protocol}//${host}:${panelPort}`
- const selectedRole = overlay.querySelector('#dd-role')?.value || 'general'
- const roleInfo = MILITARY.roles[selectedRole] || MILITARY.roles.general
-
- dialog.innerHTML = `
-
-
${pixelRole(selectedRole, 56)}
-
龙虾入列!
-
${esc(result.name || name)} 已加入军团 · ${roleInfo.title}
-
-
- Panel: ${panelUrl} · Gateway: ${location.protocol === 'https:' ? 'wss' : 'ws'}://${host}:${gatewayPort}
-
-
- `
- overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
- await loadClusterOverview(page)
- } catch (e) {
- clearInterval(pollTimer)
- dialog.innerHTML = `
-
-
${icon('x-circle', 48)}
-
部署失败
-
${esc(e.message)}
-
-
- `
- overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
- }
- }
-}
-
-async function showInspectDialog(page, nodeId, containerId) {
- const c = _lastContainers.find(x => x.id === containerId) || {}
- const isRunning = c.state === 'running'
- const ports = _parseHostPorts(c.ports)
- const host = location.hostname || 'localhost'
- const role = MILITARY.inferRole(c.name)
- const roleInfo = MILITARY.roles[role]
-
- const overlay = document.createElement('div')
- overlay.className = 'docker-dialog-overlay'
- overlay.innerHTML = `
-
-
-
${pixelRole(role, 36)}
-
-
${esc(c.name || containerId)}
-
${icon(roleInfo.iconName, 12)} ${roleInfo.title} · ${esc(c.id)}
-
-
${isRunning ? icon('swords', 12) + ' 出征中' : icon('tent', 12) + ' 休整中'}
-
-
-
-
-
军情概况
-
装备${esc(c.image)}
-
状态${esc(c.status || c.state)}
-
通讯${esc(c.ports) || '无'}
-
军营${esc(c.nodeName || nodeId)}
-
-
- ${isRunning && (ports.panel || ports.gateway) ? `
-
- ` : ''}
-
-
-
-
-
- ${isRunning
- ? `
- `
- : ``
- }
-
-
-
-
-
- `
- document.body.appendChild(overlay)
- overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
- overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
-
- // 内联操作按钮
- for (const btn of overlay.querySelectorAll('[data-action]')) {
- btn.addEventListener('click', async () => {
- const act = btn.dataset.action
- btn.disabled = true
- try {
- if (act === 'start') await api.dockerStartContainer(nodeId, containerId)
- else if (act === 'stop') await api.dockerStopContainer(nodeId, containerId)
- else if (act === 'restart') await api.dockerRestartContainer(nodeId, containerId)
- toast(`容器已${act === 'start' ? '启动' : act === 'stop' ? '停止' : '重启'}`)
- overlay.remove()
- await loadClusterOverview(page)
- } catch (e) { toast(e.message, 'error'); btn.disabled = false }
- })
- }
-
- // 加载日志
- async function loadLogs() {
- const pre = overlay.querySelector('.inspect-logs')
- try {
- const logs = await api.dockerContainerLogs(nodeId, containerId, 50)
- pre.textContent = logs || '(暂无日志)'
- pre.scrollTop = pre.scrollHeight
- } catch (e) {
- pre.textContent = '获取日志失败: ' + e.message
- }
- }
- await loadLogs()
- overlay.querySelector('#inspect-logs-refresh').onclick = loadLogs
-}
-
-async function showLogsDialog(page, nodeId, containerId) {
- const overlay = document.createElement('div')
- overlay.className = 'docker-dialog-overlay'
- overlay.innerHTML = `
-
-
${icon('scroll', 16)} 战报 ${esc(containerId)}
-
加载中...
-
-
-
-
-
- `
- document.body.appendChild(overlay)
- overlay.querySelector('[data-dismiss]').onclick = () => overlay.remove()
- overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
-
- async function loadLogs() {
- const pre = overlay.querySelector('.docker-logs-content')
- try {
- const logs = await api.dockerContainerLogs(nodeId, containerId, 200)
- pre.textContent = logs || '(暂无战报)'
- pre.scrollTop = pre.scrollHeight
- } catch (e) {
- pre.textContent = '战报获取失败: ' + e.message
- }
- }
- await loadLogs()
- overlay.querySelector('#dl-refresh').onclick = loadLogs
-}
diff --git a/src/style/pages.css b/src/style/pages.css
index 8fce840..009dfd0 100644
--- a/src/style/pages.css
+++ b/src/style/pages.css
@@ -731,417 +731,7 @@
color: var(--text-tertiary);
}
-/* === Docker 集群管理 === */
-
-.docker-section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: var(--space-md);
-}
-.docker-section-title {
- font-size: var(--font-size-lg);
- font-weight: 600;
- color: var(--text-primary);
-}
-
-/* 节点网格 */
-.docker-node-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
- gap: var(--space-md);
-}
-.docker-node-card {
- background: var(--bg-card);
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-lg);
- padding: var(--space-lg);
- transition: all var(--transition-fast);
-}
-.docker-node-card:hover {
- border-color: var(--border-focus);
-}
-.docker-node-card.offline {
- opacity: 0.7;
-}
-.docker-node-pixel { line-height: 0; flex-shrink: 0; }
-.docker-node-header {
- display: flex;
- align-items: center;
- gap: var(--space-sm);
- margin-bottom: var(--space-sm);
-}
-.docker-node-status {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-.docker-node-status.online { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,.4); }
-.docker-node-status.offline { background: #ef4444; }
-.docker-node-name {
- font-weight: 600;
- color: var(--text-primary);
- flex: 1;
-}
-.docker-node-badge {
- font-size: 11px;
- padding: 1px 8px;
- border-radius: 10px;
- background: var(--bg-secondary);
- color: var(--text-secondary);
-}
-.docker-node-card.online .docker-node-badge {
- background: rgba(34,197,94,.1);
- color: #22c55e;
-}
-.docker-node-remove {
- background: none;
- border: none;
- color: var(--text-tertiary);
- cursor: pointer;
- font-size: 18px;
- line-height: 1;
- padding: 0 4px;
-}
-.docker-node-remove:hover { color: var(--error, #ef4444); }
-.docker-node-info {
- display: flex;
- flex-direction: column;
- gap: 2px;
- font-size: var(--font-size-xs);
- color: var(--text-tertiary);
- margin-bottom: var(--space-sm);
-}
-.docker-node-error { color: var(--error, #ef4444); }
-.docker-node-footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding-top: var(--space-sm);
- border-top: 1px solid var(--border-secondary);
- font-size: var(--font-size-sm);
- color: var(--text-secondary);
-}
-
-/* 军团单位卡片网格 */
-.unit-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
- gap: 12px;
-}
-
-/* 单位卡片 */
-.unit-card {
- position: relative;
- background: var(--bg-card, var(--bg-primary));
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-lg);
- padding: 14px 16px;
- transition: all 200ms;
- border-left: 3px solid var(--unit-color, var(--border-primary));
-}
-.unit-card:hover {
- border-color: var(--unit-color, var(--accent));
- box-shadow: 0 2px 12px rgba(0,0,0,.08);
-}
-.unit-card.running {
- border-left-color: var(--unit-color, #22c55e);
-}
-.unit-card.stopped {
- opacity: 0.7;
- border-left-color: var(--text-tertiary);
-}
-.unit-card.enlist {
- border-left-color: var(--border-secondary);
- opacity: 0.6;
-}
-.unit-card.enlist:hover { opacity: 0.9; }
-
-.unit-card-select {
- position: absolute;
- top: 10px;
- right: 10px;
-}
-.unit-card-select input[type="checkbox"] {
- width: 16px;
- height: 16px;
- cursor: pointer;
- accent-color: var(--accent, #00c8ff);
-}
-
-.unit-card-header {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 8px;
-}
-.unit-badge {
- flex-shrink: 0;
- line-height: 0;
-}
-.unit-badge svg {
- filter: drop-shadow(0 1px 3px rgba(0,0,0,.15));
-}
-.unit-identity {
- flex: 1;
- min-width: 0;
-}
-.unit-name {
- font-size: 14px;
- font-weight: 700;
- color: var(--text-primary);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.unit-role {
- font-size: 11px;
- color: var(--text-tertiary);
- margin-top: 1px;
-}
-.unit-id {
- font-size: 10px;
- color: var(--text-tertiary);
- font-family: var(--font-mono);
-}
-.unit-state {
- flex-shrink: 0;
- font-size: 11px;
- font-weight: 600;
- padding: 2px 8px;
- border-radius: 10px;
-}
-.unit-state.running {
- background: rgba(34,197,94,.1);
- color: #22c55e;
-}
-.unit-state.stopped {
- background: var(--bg-secondary);
- color: var(--text-tertiary);
-}
-
-/* 活跃实例标记 */
-.unit-card.active-instance {
- border-color: var(--accent);
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
-}
-.unit-active-tag {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 10px;
- font-weight: 600;
- padding: 2px 8px;
- border-radius: 10px;
- background: rgba(99,102,241,.12);
- color: var(--accent);
- flex-shrink: 0;
-}
-.unit-switch-btn {
- flex-shrink: 0;
- font-size: 10px !important;
- padding: 1px 8px !important;
- opacity: 0;
- transition: opacity .15s;
-}
-.unit-card:hover .unit-switch-btn { opacity: 1; }
-
-.unit-links {
- display: flex;
- gap: 6px;
- margin-bottom: 8px;
- padding-left: 46px;
-}
-.unit-link {
- font-size: 11px;
- padding: 2px 10px;
- border-radius: 8px;
- text-decoration: none;
- transition: all 150ms;
-}
-.unit-link.panel {
- background: rgba(0,200,255,.06);
- color: var(--accent, #00c8ff);
- border: 1px solid rgba(0,200,255,.12);
-}
-.unit-link.panel:hover {
- background: rgba(0,200,255,.12);
-}
-.unit-link.gateway {
- background: rgba(168,85,247,.06);
- color: #a855f7;
- border: 1px solid rgba(168,85,247,.12);
- cursor: pointer;
-}
-.unit-link.gateway:hover {
- background: rgba(168,85,247,.12);
-}
-
-.unit-card-footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding-top: 8px;
- border-top: 1px solid var(--border-secondary);
-}
-.unit-image {
- font-size: 10px;
- color: var(--text-tertiary);
- font-family: var(--font-mono);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 160px;
-}
-.unit-actions {
- display: flex;
- gap: 2px;
-}
-
-/* 批量操作栏改进 */
-.batch-select-all {
- display: flex;
- align-items: center;
- gap: 4px;
- font-size: 12px;
- color: var(--text-secondary);
- cursor: pointer;
-}
-.batch-select-all input[type="checkbox"] {
- accent-color: var(--accent, #00c8ff);
-}
-
-@media (max-width: 768px) {
- .cluster-header { flex-direction: column; align-items: flex-start; gap: 8px; }
- .cluster-header-left { flex-direction: column; align-items: flex-start; gap: 4px; }
- .unit-grid { grid-template-columns: 1fr; }
- .unit-links { padding-left: 0; }
- .batch-actions { flex-wrap: wrap; }
- .task-mode-btn { padding: 5px 8px; font-size: 11px; }
-}
-
-/* 容器表格 */
-.docker-table-wrap {
- overflow-x: auto;
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-lg);
- scrollbar-width: none;
-}
-.docker-table-wrap::-webkit-scrollbar { display: none; }
-.docker-table {
- width: 100%;
- border-collapse: collapse;
- font-size: var(--font-size-sm);
-}
-.docker-table th {
- text-align: left;
- padding: var(--space-sm) var(--space-md);
- background: var(--bg-secondary);
- color: var(--text-secondary);
- font-weight: 500;
- font-size: var(--font-size-xs);
- text-transform: uppercase;
- letter-spacing: .5px;
- border-bottom: 1px solid var(--border-primary);
-}
-.docker-table td {
- padding: var(--space-sm) var(--space-md);
- border-bottom: 1px solid var(--border-secondary);
- color: var(--text-primary);
- vertical-align: middle;
-}
-.docker-table tr:last-child td { border-bottom: none; }
-.docker-table tr:hover td { background: var(--bg-card-hover); }
-.docker-ct-name {
- font-weight: 600;
- display: block;
-}
-.docker-ct-id {
- font-size: 11px;
- color: var(--text-tertiary);
- font-family: var(--font-mono);
-}
-.docker-ct-image {
- font-family: var(--font-mono);
- font-size: 12px;
- color: var(--text-secondary);
- max-width: 260px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.docker-ct-state {
- display: inline-block;
- padding: 2px 8px;
- border-radius: 10px;
- font-size: 11px;
- font-weight: 500;
-}
-.docker-ct-state.running {
- background: rgba(34,197,94,.1);
- color: #22c55e;
-}
-.docker-ct-state.stopped {
- background: var(--bg-secondary);
- color: var(--text-tertiary);
-}
-.docker-ct-ports {
- font-family: var(--font-mono);
- font-size: 12px;
- color: var(--text-secondary);
-}
-/* 批量操作 */
-.batch-actions {
- display: flex;
- align-items: center;
- gap: 6px;
-}
-.batch-count {
- font-size: 12px;
- font-weight: 600;
- color: var(--accent, #00c8ff);
- padding: 2px 10px;
- background: rgba(0,200,255,.08);
- border-radius: 10px;
-}
-.docker-ct-check {
- width: 32px;
- text-align: center;
- padding: 0 4px !important;
-}
-.docker-ct-check input[type="checkbox"] {
- width: 16px;
- height: 16px;
- cursor: pointer;
- accent-color: var(--accent, #00c8ff);
-}
-
-.docker-ct-links {
- display: flex;
- gap: 6px;
- margin-top: 4px;
-}
-.docker-ct-link {
- font-size: 11px;
- padding: 1px 8px;
- border-radius: 8px;
- background: rgba(0,240,255,.06);
- color: var(--accent, #00c8ff);
- text-decoration: none;
- border: 1px solid rgba(0,240,255,.12);
- white-space: nowrap;
- transition: background .15s, border-color .15s;
-}
-.docker-ct-link:hover {
- background: rgba(0,240,255,.12);
- border-color: rgba(0,240,255,.25);
-}
-.docker-ct-actions {
- display: flex;
- gap: 4px;
- white-space: nowrap;
-}
+/* === 通用按钮 === */
.btn-icon {
background: none;
border: 1px solid var(--border-primary);
@@ -1162,85 +752,6 @@
}
.btn-icon:disabled { opacity: .4; pointer-events: none; }
-/* 空状态 */
-.docker-empty {
- text-align: center;
- padding: var(--space-2xl) var(--space-xl);
-}
-.docker-empty-icon {
- color: var(--text-tertiary);
- margin-bottom: var(--space-md);
-}
-.docker-empty-title {
- font-size: var(--font-size-lg);
- font-weight: 600;
- color: var(--text-primary);
- margin-bottom: var(--space-xs);
-}
-.docker-empty-desc {
- color: var(--text-secondary);
- font-size: var(--font-size-sm);
- margin-bottom: var(--space-lg);
-}
-.docker-guide-section {
- text-align: left;
- display: inline-block;
- background: var(--bg-secondary);
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-md);
- padding: var(--space-md) var(--space-lg);
- max-width: 480px;
- margin: 0 auto;
-}
-.docker-guide-title {
- font-weight: 600;
- font-size: var(--font-size-sm);
- margin-bottom: var(--space-sm);
- display: flex;
- align-items: center;
- gap: 6px;
-}
-.docker-guide-section ol {
- margin: 0;
- padding-left: 20px;
- font-size: var(--font-size-sm);
- color: var(--text-secondary);
- line-height: 1.8;
-}
-.docker-guide-section code {
- background: var(--bg-tertiary);
- padding: 2px 6px;
- border-radius: var(--radius-sm);
- font-family: var(--font-mono);
-}
-.docker-empty-hint {
- text-align: left;
- display: inline-block;
- background: var(--bg-secondary);
- border-radius: var(--radius-lg);
- padding: var(--space-md) var(--space-lg);
- font-size: var(--font-size-sm);
- color: var(--text-secondary);
-}
-.docker-empty-hint code {
- display: block;
- margin: 4px 0;
- padding: 4px 8px;
- background: var(--bg-primary);
- border-radius: var(--radius-sm);
- font-family: var(--font-mono);
- font-size: 12px;
- color: var(--text-primary);
-}
-.docker-empty-inline {
- text-align: center;
- padding: var(--space-xl);
- color: var(--text-tertiary);
- font-size: var(--font-size-sm);
- border: 1px dashed var(--border-primary);
- border-radius: var(--radius-lg);
-}
-
/* 弹窗 */
.docker-dialog-overlay {
position: fixed;
@@ -1297,220 +808,6 @@
margin-top: var(--space-lg);
}
-/* 部署进度 */
-.deploy-progress {
- padding: 8px 0;
-}
-.deploy-progress-header {
- text-align: center;
- margin-bottom: 20px;
-}
-.deploy-progress-icon {
- font-size: 40px;
- animation: deployBounce 1.2s ease-in-out infinite;
-}
-@keyframes deployBounce {
- 0%, 100% { transform: translateY(0); }
- 50% { transform: translateY(-8px); }
-}
-.deploy-progress-title {
- font-size: 16px;
- font-weight: 700;
- margin-top: 8px;
- color: var(--text-primary);
-}
-.deploy-progress-subtitle {
- font-size: 12px;
- color: var(--text-tertiary);
- font-family: var(--font-mono);
- margin-top: 2px;
-}
-.deploy-progress-steps {
- display: flex;
- flex-direction: column;
- gap: 4px;
- margin-bottom: 16px;
-}
-.deploy-step {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 8px 12px;
- border-radius: var(--radius-md);
- background: var(--bg-secondary);
- opacity: 0.4;
- transition: all 300ms;
-}
-.deploy-step.active {
- opacity: 1;
- background: rgba(0, 200, 255, 0.06);
- border-left: 3px solid var(--accent, #00c8ff);
-}
-.deploy-step.done {
- opacity: 0.7;
-}
-.deploy-step.done .deploy-step-icon::after {
- content: ' ✓';
- color: #22c55e;
-}
-.deploy-step-icon {
- font-size: 18px;
- flex-shrink: 0;
- width: 28px;
- text-align: center;
-}
-.deploy-step-info {
- flex: 1;
- min-width: 0;
-}
-.deploy-step-label {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-primary);
-}
-.deploy-step-detail {
- font-size: 11px;
- color: var(--text-tertiary);
- font-family: var(--font-mono);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.deploy-progress-bar-wrap {
- height: 4px;
- background: var(--bg-secondary);
- border-radius: 2px;
- overflow: hidden;
- margin-bottom: 8px;
-}
-.deploy-progress-bar {
- height: 100%;
- background: linear-gradient(90deg, var(--accent, #00c8ff), #a78bfa);
- border-radius: 2px;
- transition: width 400ms ease;
-}
-.deploy-progress-log {
- font-size: 11px;
- color: var(--text-tertiary);
- font-family: var(--font-mono);
- text-align: center;
- min-height: 16px;
-}
-
-/* 部署模式切换 */
-.deploy-mode-toggle {
- display: flex;
- background: var(--bg-secondary);
- border-radius: var(--radius-md);
- padding: 2px;
- gap: 2px;
-}
-.deploy-mode-btn {
- padding: 3px 12px;
- border: none;
- background: transparent;
- color: var(--text-tertiary);
- font-size: 12px;
- font-weight: 500;
- border-radius: var(--radius-sm);
- cursor: pointer;
- transition: all 150ms;
-}
-.deploy-mode-btn.active {
- background: var(--bg-card);
- color: var(--text-primary);
- box-shadow: 0 1px 3px rgba(0,0,0,.1);
-}
-.deploy-auto-summary {
- background: var(--bg-secondary);
- border-radius: var(--radius-md);
- padding: var(--space-md);
- margin: var(--space-sm) 0;
-}
-.deploy-auto-title {
- font-size: 12px;
- font-weight: 600;
- color: var(--text-tertiary);
- margin-bottom: var(--space-sm);
- text-transform: uppercase;
- letter-spacing: .5px;
-}
-.deploy-auto-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 4px 0;
- font-size: 13px;
- color: var(--text-secondary);
-}
-.deploy-auto-item + .deploy-auto-item {
- border-top: 1px solid var(--border-primary);
-}
-.deploy-auto-item span:last-child {
- font-family: var(--font-mono);
- font-size: 12px;
- color: var(--text-primary);
- font-weight: 500;
-}
-
-/* 节点端点预设按钮 */
-.dn-presets {
- display: flex;
- gap: 6px;
- flex-wrap: wrap;
-}
-.dn-preset-btn {
- padding: 5px 12px;
- border: 1px solid var(--border-primary);
- background: var(--bg-secondary, #f5f5f5);
- color: var(--text-secondary);
- font-size: 12px;
- font-weight: 500;
- border-radius: var(--radius-md, 8px);
- cursor: pointer;
- transition: all 150ms;
-}
-.dn-preset-btn:hover {
- border-color: var(--primary, #6366f1);
- color: var(--primary, #6366f1);
-}
-.dn-preset-btn.active {
- background: var(--primary, #6366f1);
- color: #fff;
- border-color: var(--primary, #6366f1);
-}
-
-/* 其他容器折叠区 */
-.docker-other-toggle {
- display: flex;
- align-items: center;
- gap: var(--space-sm);
- cursor: pointer;
- font-size: var(--font-size-sm);
- font-weight: 500;
- color: var(--text-tertiary);
- padding: var(--space-sm) 0;
- list-style: none;
- user-select: none;
-}
-.docker-other-toggle::-webkit-details-marker { display: none; }
-.docker-other-toggle::before {
- content: '▶';
- font-size: 10px;
- transition: transform 150ms;
-}
-details.docker-other-section[open] > .docker-other-toggle::before {
- transform: rotate(90deg);
-}
-.docker-other-count {
- background: var(--bg-secondary);
- color: var(--text-tertiary);
- font-size: 11px;
- padding: 1px 8px;
- border-radius: 10px;
- font-weight: 600;
-}
-
/* === 移动端响应式 === */
@media (max-width: 768px) {
.overview-grid {
@@ -1528,13 +825,6 @@ details.docker-other-section[open] > .docker-other-toggle::before {
max-width: none;
padding: var(--space-lg);
}
- .docker-table {
- font-size: 12px;
- }
- .docker-table th,
- .docker-table td {
- padding: 6px 8px;
- }
.gw-option-cards {
grid-template-columns: 1fr;
}
@@ -1580,561 +870,6 @@ details.docker-other-section[open] > .docker-other-toggle::before {
}
}
-/* 容器详情面板 */
-.inspect-grid {
- display: flex;
- flex-direction: column;
- gap: var(--space-md);
-}
-.inspect-section {
- background: var(--bg-secondary);
- border-radius: var(--radius-md);
- padding: var(--space-md);
-}
-.inspect-section-title {
- font-size: 11px;
- font-weight: 600;
- color: var(--text-tertiary);
- text-transform: uppercase;
- letter-spacing: .5px;
- margin-bottom: var(--space-sm);
-}
-.inspect-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 4px 0;
- font-size: 13px;
-}
-.inspect-row + .inspect-row {
- border-top: 1px solid var(--border-primary);
-}
-.inspect-label {
- color: var(--text-tertiary);
- font-size: 12px;
-}
-.inspect-value {
- color: var(--text-primary);
- font-weight: 500;
- text-align: right;
- max-width: 65%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.inspect-value.mono {
- font-family: var(--font-mono);
- font-size: 12px;
-}
-.inspect-links {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: var(--space-sm);
-}
-.inspect-link-card {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 12px;
- background: var(--bg-primary);
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-md);
- text-decoration: none;
- transition: border-color .15s, box-shadow .15s;
-}
-.inspect-link-card:hover {
- border-color: var(--accent, #00c8ff);
- box-shadow: 0 0 0 2px rgba(0,200,255,.1);
-}
-.inspect-link-icon {
- font-size: 22px;
- flex-shrink: 0;
-}
-.inspect-link-text {
- display: flex;
- flex-direction: column;
- gap: 2px;
- min-width: 0;
-}
-.inspect-link-text strong {
- font-size: 13px;
- color: var(--text-primary);
-}
-.inspect-link-text span {
- font-size: 11px;
- color: var(--text-tertiary);
- font-family: var(--font-mono);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.inspect-logs {
- max-height: 200px;
-}
-@media (max-width: 768px) {
- .inspect-links {
- grid-template-columns: 1fr;
- }
-}
-
-/* === 集群页面 === */
-
-/* 顶部紧凑头 */
-.cluster-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: var(--space-lg);
-}
-.cluster-header-left {
- display: flex;
- align-items: center;
- gap: var(--space-md);
-}
-.cluster-title {
- font-size: 20px;
- font-weight: 700;
- margin: 0;
- white-space: nowrap;
-}
-.cluster-stats {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 13px;
- color: var(--text-secondary);
-}
-.cluster-stat { display: inline-flex; align-items: center; gap: 4px; }
-.cluster-stat .dot {
- width: 7px; height: 7px; border-radius: 50%;
- background: var(--text-tertiary);
- flex-shrink: 0;
-}
-.cluster-stat .dot.online { background: #22c55e; box-shadow: 0 0 4px #22c55e; }
-.cluster-stat-sep { color: var(--text-tertiary); }
-.cluster-stat.muted { color: var(--text-tertiary); }
-
-/* 任务中心 */
-.task-hub {
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-lg, 12px);
- overflow: hidden;
- background: var(--bg-primary);
- margin-bottom: var(--space-lg);
-}
-.task-hub-bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 8px 12px;
- border-bottom: 1px solid var(--border-primary);
- background: var(--bg-secondary);
-}
-.task-mode {
- display: flex;
- gap: 2px;
- background: var(--bg-primary);
- border-radius: var(--radius-md);
- padding: 2px;
- border: 1px solid var(--border-primary);
-}
-.task-mode-btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 5px 12px;
- border: none;
- border-radius: calc(var(--radius-md) - 2px);
- background: transparent;
- color: var(--text-tertiary);
- font-size: 12px;
- font-weight: 500;
- cursor: pointer;
- transition: all .15s;
- white-space: nowrap;
-}
-.task-mode-btn:hover { color: var(--text-primary); background: var(--bg-secondary); }
-.task-mode-btn.active {
- background: var(--accent, #d97706);
- color: white;
- font-weight: 600;
-}
-.task-pick-bar {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- border-bottom: 1px solid var(--border-primary);
- overflow-x: auto;
- flex-wrap: wrap;
-}
-.pick-target {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 3px 10px;
- border: 1.5px solid var(--border-primary);
- border-radius: 20px;
- font-size: 12px;
- color: var(--text-secondary);
- cursor: pointer;
- transition: all .15s;
- white-space: nowrap;
-}
-.pick-target:has(input:checked) {
- border-color: var(--pick-color, var(--accent));
- background: color-mix(in srgb, var(--pick-color, var(--accent)) 10%, var(--bg-primary));
- color: var(--pick-color, var(--accent));
- font-weight: 600;
-}
-.pick-target input { display: none; }
-.pick-dot {
- width: 6px; height: 6px; border-radius: 50%;
- flex-shrink: 0; opacity: .6;
-}
-.pick-target:has(input:checked) .pick-dot { opacity: 1; box-shadow: 0 0 4px var(--pick-color); }
-
-.task-beta-note {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 14px;
- border-top: 1px solid var(--border-primary);
- border-bottom: 1px solid var(--border-primary);
- background:
- linear-gradient(90deg, rgba(245, 158, 11, 0.12) 0%, rgba(245, 158, 11, 0.04) 100%);
- color: #b45309;
- font-size: 12px;
- font-weight: 500;
-}
-
-.task-beta-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- color: #d97706;
- flex-shrink: 0;
-}
-
-.task-input-row {
- display: flex;
- gap: 0;
- align-items: flex-end;
-}
-.task-input {
- flex: 1;
- resize: none;
- border: none;
- padding: 12px 16px;
- font-size: 14px;
- font-family: inherit;
- line-height: 1.5;
- color: var(--text-primary);
- background: transparent;
- min-height: 48px;
- max-height: 120px;
- box-sizing: border-box;
-}
-.task-input:focus { outline: none; }
-.task-input::placeholder { color: var(--text-tertiary); }
-.task-send-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 48px;
- height: 48px;
- border: none;
- background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
- color: white;
- cursor: pointer;
- transition: all .15s;
- flex-shrink: 0;
- border-radius: 0;
-}
-.task-send-btn:hover:not(:disabled) {
- background: linear-gradient(135deg, #b45309 0%, #d97706 100%);
-}
-.task-send-btn:disabled { opacity: .35; pointer-events: none; }
-
-/* 异步工作区 */
-.task-workspace {
- margin-bottom: var(--space-lg);
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-lg, 12px);
- overflow: hidden;
- background: var(--bg-primary);
-}
-.workspace-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 14px;
- background: var(--bg-secondary);
- border-bottom: 1px solid var(--border-primary);
-}
-.workspace-title {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 13px;
- font-weight: 600;
- color: var(--text-primary);
-}
-.ws-worker-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
- gap: var(--space-sm);
- padding: var(--space-md);
-}
-.ws-worker {
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-md, 8px);
- padding: 12px;
- background: var(--bg-secondary);
- cursor: pointer;
- transition: all .15s;
- position: relative;
- overflow: hidden;
-}
-.ws-worker::before {
- content: '';
- position: absolute;
- left: 0; top: 0; bottom: 0;
- width: 3px;
- background: var(--worker-color, var(--accent));
- opacity: 0;
- transition: opacity .15s;
-}
-.ws-worker:hover { background: var(--bg-card-hover); border-color: var(--border-focus); }
-.ws-worker:hover::before { opacity: 1; }
-.ws-worker.working {
- border-color: color-mix(in srgb, var(--worker-color, #f59e0b) 40%, transparent);
- animation: ws-pulse 2s ease-in-out infinite;
-}
-.ws-worker.working::before { opacity: 1; }
-@keyframes ws-pulse {
- 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--worker-color, #f59e0b) 15%, transparent); }
- 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--worker-color, #f59e0b) 8%, transparent); }
-}
-.ws-worker-top {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 8px;
-}
-.ws-worker-info { flex: 1; min-width: 0; }
-.ws-worker-name {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-primary);
- font-family: var(--font-mono);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.ws-worker-role {
- font-size: 11px;
- color: var(--text-tertiary);
-}
-.ws-worker-badge {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 11px;
- font-weight: 500;
- padding: 2px 8px;
- border-radius: 8px;
- white-space: nowrap;
-}
-.ws-worker-badge.running { color: #f59e0b; background: rgba(245,158,11,.1); }
-.ws-worker-badge.done { color: #22c55e; background: rgba(34,197,94,.1); }
-.ws-worker-badge.error { color: #ef4444; background: rgba(239,68,68,.1); }
-.ws-worker-task {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- padding-top: 8px;
- border-top: 1px solid var(--border-primary);
-}
-.ws-worker-msg {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 11px;
- color: var(--text-secondary);
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.ws-worker-time {
- font-size: 11px;
- color: var(--text-tertiary);
- font-family: var(--font-mono);
- white-space: nowrap;
-}
-
-/* 任务记录 */
-.ws-history-title {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-secondary);
- padding: 8px 14px 4px;
-}
-.ws-history-list {
- padding: 4px 14px 10px;
-}
-.ws-history-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 8px;
- border-radius: var(--radius-sm, 4px);
- font-size: 12px;
- cursor: pointer;
- transition: background .1s;
-}
-.ws-history-item:hover { background: var(--bg-secondary); }
-.ws-history-icon { flex-shrink: 0; }
-.ws-history-item.done .ws-history-icon { color: #22c55e; }
-.ws-history-item.error .ws-history-icon { color: #ef4444; }
-.ws-history-name {
- font-weight: 600;
- font-family: var(--font-mono);
- color: var(--text-primary);
- white-space: nowrap;
-}
-.ws-history-msg {
- flex: 1;
- color: var(--text-secondary);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- min-width: 0;
-}
-.ws-history-meta {
- font-size: 11px;
- color: var(--text-tertiary);
- font-family: var(--font-mono);
- white-space: nowrap;
-}
-.btn-xs {
- font-size: 11px;
- padding: 1px 8px;
- border-radius: 6px;
-}
-
-/* 任务详情弹窗 */
-.task-detail-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0,0,0,.5);
- z-index: 1000;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 24px;
-}
-.task-detail-modal {
- background: var(--bg-primary);
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-lg, 12px);
- width: 100%;
- max-width: 640px;
- max-height: 80vh;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- box-shadow: 0 16px 48px rgba(0,0,0,.2);
-}
-.task-detail-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 14px 18px;
- background: var(--bg-secondary);
- border-bottom: 1px solid var(--border-primary);
- font-size: 14px;
- font-weight: 600;
- color: var(--text-primary);
-}
-.task-detail-header span {
- display: inline-flex;
- align-items: center;
- gap: 8px;
-}
-.task-detail-body {
- padding: 18px;
- overflow-y: auto;
- flex: 1;
-}
-.task-detail-section {
- margin-bottom: 16px;
-}
-.task-detail-label {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-secondary);
- margin-bottom: 6px;
-}
-.task-detail-content {
- font-size: 13px;
- color: var(--text-primary);
- line-height: 1.6;
-}
-.task-detail-result {
- background: var(--bg-secondary);
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-md, 8px);
- padding: 12px;
- font-size: 13px;
- line-height: 1.6;
- color: var(--text-primary);
- white-space: pre-wrap;
- word-break: break-word;
- max-height: 300px;
- overflow-y: auto;
- font-family: var(--font-mono);
-}
-.task-detail-result.error { color: #ef4444; }
-.task-detail-tools {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-.task-detail-tool {
- background: var(--bg-secondary);
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-sm, 4px);
- padding: 8px 10px;
-}
-.task-detail-tool code {
- font-size: 11px;
- font-weight: 600;
- color: var(--accent);
-}
-.task-detail-tool pre {
- font-size: 11px;
- line-height: 1.5;
- margin: 4px 0 0;
- color: var(--text-secondary);
- white-space: pre-wrap;
- word-break: break-all;
-}
-.task-detail-tool pre.tool-output { color: var(--text-tertiary); }
-.task-detail-meta {
- font-size: 11px;
- color: var(--text-tertiary);
- text-align: right;
-}
-
/* 分栏标题 */
.section-bar {
display: flex;
@@ -2159,132 +894,6 @@ details.docker-other-section[open] > .docker-other-toggle::before {
margin-left: 4px;
}
-/* === 兵种选择器(游戏角色选择 UI) === */
-.role-selector {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 8px;
- margin-top: 6px;
-}
-.role-card {
- position: relative;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 6px;
- padding: 14px 8px 12px;
- border: 2px solid var(--border-primary);
- border-radius: var(--radius-lg, 12px);
- background: var(--bg-primary);
- cursor: pointer;
- transition: all .2s ease;
- text-align: center;
- overflow: hidden;
-}
-.role-card::before {
- content: '';
- position: absolute;
- inset: 0;
- background: radial-gradient(ellipse at 50% 0%, var(--role-color, #64748b) 0%, transparent 70%);
- opacity: 0;
- transition: opacity .3s ease;
- pointer-events: none;
-}
-.role-card:hover {
- border-color: color-mix(in srgb, var(--role-color, #64748b) 60%, transparent);
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0,0,0,.1);
-}
-.role-card:hover::before { opacity: .08; }
-.role-card.selected {
- border-color: var(--role-color, #64748b);
- background: color-mix(in srgb, var(--role-color, #64748b) 6%, var(--bg-primary));
- box-shadow: 0 0 0 1px var(--role-color, #64748b), 0 4px 16px color-mix(in srgb, var(--role-color) 20%, transparent);
-}
-.role-card.selected::before { opacity: .12; }
-.role-card.selected::after {
- content: '';
- position: absolute;
- top: 6px;
- right: 6px;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: var(--role-color, #64748b);
- box-shadow: 0 0 6px var(--role-color, #64748b);
- animation: role-pulse 1.5s ease-in-out infinite;
-}
-@keyframes role-pulse {
- 0%, 100% { box-shadow: 0 0 4px var(--role-color); opacity: 1; }
- 50% { box-shadow: 0 0 10px var(--role-color); opacity: .7; }
-}
-.role-card-badge {
- position: relative;
- z-index: 1;
- line-height: 0;
- filter: drop-shadow(0 2px 4px rgba(0,0,0,.15));
- transition: transform .2s ease;
-}
-.role-card:hover .role-card-badge { transform: scale(1.1); }
-.role-card.selected .role-card-badge { transform: scale(1.15); }
-.role-card-title {
- position: relative;
- z-index: 1;
- font-size: 13px;
- font-weight: 600;
- color: var(--text-primary);
- line-height: 1.2;
-}
-.role-card.selected .role-card-title { color: var(--role-color, #64748b); }
-.role-card-desc {
- position: relative;
- z-index: 1;
- font-size: 11px;
- color: var(--text-tertiary);
- line-height: 1.3;
-}
-.role-card.selected .role-card-desc { color: var(--text-secondary); }
-
-/* 选中角色信息展示 */
-.role-selected-info {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 12px;
- margin-top: 8px;
- border-radius: var(--radius-md);
- background: color-mix(in srgb, var(--role-color, #64748b) 8%, var(--bg-secondary));
- border: 1px solid color-mix(in srgb, var(--role-color, #64748b) 20%, transparent);
- animation: role-info-in .3s ease;
-}
-@keyframes role-info-in {
- from { opacity: 0; transform: translateY(-6px); }
- to { opacity: 1; transform: translateY(0); }
-}
-.role-selected-badge { line-height: 0; flex-shrink: 0; }
-.role-selected-text { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
-.role-selected-text strong { color: var(--role-color, #64748b); font-weight: 600; }
-
-@media (max-width: 480px) {
- .role-selector { grid-template-columns: repeat(2, 1fr); }
-}
-
-/* 日志 */
-.docker-logs-content {
- background: var(--bg-primary);
- border: 1px solid var(--border-primary);
- border-radius: var(--radius-md);
- padding: var(--space-md);
- font-family: var(--font-mono);
- font-size: 12px;
- line-height: 1.6;
- color: var(--text-secondary);
- max-height: 400px;
- overflow-y: auto;
- white-space: pre-wrap;
- word-break: break-all;
-}
-
/* ── 消息渠道管理 ── */
.platforms-grid {
@@ -2435,4 +1044,4 @@ details.docker-other-section[open] > .docker-other-toggle::before {
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
-}
+}
\ No newline at end of file