mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-12 16:01:56 +08:00
feat: Docker 集群增强 — Gateway 通讯API、像素兵种系统、互动组件、UI 优化
This commit is contained in:
BIN
public/images/OpenClaw-DY.png
Normal file
BIN
public/images/OpenClaw-DY.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
@@ -556,6 +556,9 @@ function isDockerAvailable() {
|
|||||||
return fs.existsSync(DOCKER_SOCKET)
|
return fs.existsSync(DOCKER_SOCKET)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 镜像拉取进度追踪 ===
|
||||||
|
const _pullProgress = new Map()
|
||||||
|
|
||||||
// === 实例注册表 ===
|
// === 实例注册表 ===
|
||||||
|
|
||||||
const DEFAULT_LOCAL_INSTANCE = { id: 'local', name: '本机', type: 'local', endpoint: null, gatewayPort: 18789, addedAt: 0, note: '' }
|
const DEFAULT_LOCAL_INSTANCE = { id: 'local', name: '本机', type: 'local', endpoint: null, gatewayPort: 18789, addedAt: 0, note: '' }
|
||||||
@@ -648,7 +651,7 @@ const ALWAYS_LOCAL = new Set([
|
|||||||
'instance_health_check', 'instance_health_all',
|
'instance_health_check', 'instance_health_all',
|
||||||
'docker_info', 'docker_list_containers', 'docker_create_container',
|
'docker_info', 'docker_list_containers', 'docker_create_container',
|
||||||
'docker_start_container', 'docker_stop_container', 'docker_restart_container',
|
'docker_start_container', 'docker_stop_container', 'docker_restart_container',
|
||||||
'docker_remove_container', 'docker_container_logs', 'docker_pull_image',
|
'docker_remove_container', 'docker_container_logs', 'docker_container_exec', 'docker_gateway_chat', 'docker_pull_image', 'docker_pull_status',
|
||||||
'docker_list_images', 'docker_list_nodes', 'docker_add_node', 'docker_remove_node',
|
'docker_list_images', 'docker_list_nodes', 'docker_add_node', 'docker_remove_node',
|
||||||
'docker_cluster_overview',
|
'docker_cluster_overview',
|
||||||
'auth_check', 'auth_login', 'auth_logout',
|
'auth_check', 'auth_login', 'auth_logout',
|
||||||
@@ -986,6 +989,128 @@ const handlers = {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async docker_gateway_chat({ nodeId, containerId, message, timeout = 120000 } = {}) {
|
||||||
|
if (!containerId || !message) throw new Error('缺少 containerId 或 message')
|
||||||
|
// 1. 查找容器的 Gateway 端口
|
||||||
|
const nodes = readDockerNodes()
|
||||||
|
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
|
||||||
|
if (!node) throw new Error('节点不存在')
|
||||||
|
const resp = await dockerRequest('GET', `/containers/${containerId}/json`, null, node.endpoint)
|
||||||
|
if (resp.status >= 400) throw new Error('容器不存在或无法访问')
|
||||||
|
const ports = resp.data?.NetworkSettings?.Ports || {}
|
||||||
|
const gwBinding = ports['18789/tcp']
|
||||||
|
if (!gwBinding || !gwBinding[0]?.HostPort) throw new Error('该容器没有暴露 Gateway 端口 (18789)')
|
||||||
|
const gwPort = gwBinding[0].HostPort
|
||||||
|
|
||||||
|
// 2. 通过 WebSocket 连接 Gateway(Node 22 内置 WebSocket)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const wsUrl = `ws://127.0.0.1:${gwPort}/ws`
|
||||||
|
let ws
|
||||||
|
try { ws = new WebSocket(wsUrl) } catch (e) { return reject(new Error(`无法连接 Gateway: ${e.message}`)) }
|
||||||
|
let result = '', handshakeOk = false, sessionKey = 'agent:main:cluster-task', done = false
|
||||||
|
const timer = setTimeout(() => { if (!done) { done = true; ws.close(); reject(new Error('Gateway 通信超时')) } }, timeout)
|
||||||
|
const challengeTimer = setTimeout(() => { if (!handshakeOk) doConnect('') }, 3000)
|
||||||
|
|
||||||
|
function doConnect(nonce) {
|
||||||
|
try {
|
||||||
|
const frame = handlers.create_connect_frame({ nonce, gatewayToken: '' })
|
||||||
|
ws.send(JSON.stringify(frame))
|
||||||
|
} catch {
|
||||||
|
ws.send(JSON.stringify({ type: 'req', id: 'connect-1', method: 'connect', params: {} }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendChat() {
|
||||||
|
const id = `chat-${Date.now().toString(36)}`
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'req', id, method: 'chat.send',
|
||||||
|
params: { sessionKey, message, deliver: false, idempotencyKey: id }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.addEventListener('message', (evt) => {
|
||||||
|
let msg
|
||||||
|
try { msg = JSON.parse(typeof evt.data === 'string' ? evt.data : evt.data.toString()) } catch { return }
|
||||||
|
|
||||||
|
// connect.challenge
|
||||||
|
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||||
|
clearTimeout(challengeTimer)
|
||||||
|
doConnect(msg.payload?.nonce || '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// connect 响应
|
||||||
|
if (msg.type === 'res' && msg.id?.startsWith('connect')) {
|
||||||
|
clearTimeout(challengeTimer)
|
||||||
|
if (msg.ok) {
|
||||||
|
handshakeOk = true
|
||||||
|
const defaults = msg.payload?.snapshot?.sessionDefaults
|
||||||
|
if (defaults?.mainSessionKey) sessionKey = defaults.mainSessionKey
|
||||||
|
else sessionKey = `agent:${defaults?.defaultAgentId || 'main'}:cluster-task`
|
||||||
|
sendChat()
|
||||||
|
} else {
|
||||||
|
done = true; clearTimeout(timer); ws.close()
|
||||||
|
reject(new Error(msg.error?.message || 'Gateway 握手失败'))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// chat 事件流
|
||||||
|
if (msg.type === 'event' && msg.event === 'chat') {
|
||||||
|
const p = msg.payload
|
||||||
|
if (p?.state === 'delta') {
|
||||||
|
const content = p.message?.content
|
||||||
|
if (typeof content === 'string' && content.length > result.length) result = content
|
||||||
|
}
|
||||||
|
if (p?.state === 'final') {
|
||||||
|
const content = p.message?.content
|
||||||
|
if (typeof content === 'string' && content) result = content
|
||||||
|
done = true; clearTimeout(timer); ws.close()
|
||||||
|
resolve({ ok: true, result })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// chat.send 确认
|
||||||
|
if (msg.type === 'res' && !msg.id?.startsWith('connect')) {
|
||||||
|
if (!msg.ok) {
|
||||||
|
done = true; clearTimeout(timer); ws.close()
|
||||||
|
reject(new Error(msg.error?.message || '任务发送失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
if (!done) { done = true; clearTimeout(timer); clearTimeout(challengeTimer); reject(new Error(`无法连接 ${wsUrl}`)) }
|
||||||
|
})
|
||||||
|
ws.addEventListener('close', (e) => {
|
||||||
|
clearTimeout(timer); clearTimeout(challengeTimer)
|
||||||
|
if (!done) {
|
||||||
|
done = true
|
||||||
|
if (result) resolve({ ok: true, result })
|
||||||
|
else if (e.code === 4001 || e.code === 4003) reject(new Error('Gateway 认证失败'))
|
||||||
|
else resolve({ ok: true, result: result || '(无回复)' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async docker_container_exec({ nodeId, containerId, cmd } = {}) {
|
||||||
|
const nodes = readDockerNodes()
|
||||||
|
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
|
||||||
|
if (!node) throw new Error('节点不存在')
|
||||||
|
if (!containerId) throw new Error('缺少 containerId')
|
||||||
|
if (!cmd || !Array.isArray(cmd)) throw new Error('cmd 必须是字符串数组')
|
||||||
|
// Step 1: 创建 exec 实例
|
||||||
|
const createResp = await dockerRequest('POST', `/containers/${containerId}/exec`, {
|
||||||
|
AttachStdout: true, AttachStderr: true, Cmd: cmd
|
||||||
|
}, node.endpoint)
|
||||||
|
if (createResp.status >= 400) throw new Error(`exec 创建失败: ${JSON.stringify(createResp.data)}`)
|
||||||
|
const execId = createResp.data?.Id
|
||||||
|
if (!execId) throw new Error('exec 创建失败: 无 ID')
|
||||||
|
// Step 2: 启动 exec
|
||||||
|
const startResp = await dockerRequest('POST', `/exec/${execId}/start`, { Detach: true }, node.endpoint)
|
||||||
|
if (startResp.status >= 400) throw new Error(`exec 启动失败: ${JSON.stringify(startResp.data)}`)
|
||||||
|
return { ok: true, execId }
|
||||||
|
},
|
||||||
|
|
||||||
async docker_container_logs({ nodeId, containerId, tail = 200 } = {}) {
|
async docker_container_logs({ nodeId, containerId, tail = 200 } = {}) {
|
||||||
const nodes = readDockerNodes()
|
const nodes = readDockerNodes()
|
||||||
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
|
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
|
||||||
@@ -998,14 +1123,97 @@ const handlers = {
|
|||||||
return logs
|
return logs
|
||||||
},
|
},
|
||||||
|
|
||||||
async docker_pull_image({ nodeId, image, tag = 'latest' } = {}) {
|
async docker_pull_image({ nodeId, image, tag = 'latest', requestId } = {}) {
|
||||||
const nodes = readDockerNodes()
|
const nodes = readDockerNodes()
|
||||||
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
|
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
|
||||||
if (!node) throw new Error('节点不存在')
|
if (!node) throw new Error('节点不存在')
|
||||||
const imgFull = `${image || OPENCLAW_IMAGE}:${tag}`
|
const imgFull = `${image || OPENCLAW_IMAGE}:${tag}`
|
||||||
const resp = await dockerRequest('POST', `/images/create?fromImage=${encodeURIComponent(image || OPENCLAW_IMAGE)}&tag=${tag}`, null, node.endpoint)
|
const rid = requestId || `pull-${Date.now()}`
|
||||||
if (resp.status !== 200) throw new Error(resp.data?.message || '拉取镜像失败')
|
_pullProgress.set(rid, { status: 'connecting', image: imgFull, layers: {}, message: '连接 Docker...', percent: 0 })
|
||||||
return `镜像 ${imgFull} 拉取完成`
|
const endpoint = node.endpoint
|
||||||
|
const apiPath = `/images/create?fromImage=${encodeURIComponent(image || OPENCLAW_IMAGE)}&tag=${tag}`
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const opts = { path: apiPath, method: 'POST', headers: { 'Content-Type': 'application/json' } }
|
||||||
|
if (endpoint && endpoint.startsWith('tcp://')) {
|
||||||
|
const url = new URL(endpoint.replace('tcp://', 'http://'))
|
||||||
|
opts.hostname = url.hostname
|
||||||
|
opts.port = parseInt(url.port) || 2375
|
||||||
|
} else {
|
||||||
|
opts.socketPath = endpoint || DOCKER_SOCKET
|
||||||
|
}
|
||||||
|
const req = http.request(opts, (res) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
let errData = ''
|
||||||
|
res.on('data', chunk => errData += chunk)
|
||||||
|
res.on('end', () => {
|
||||||
|
const err = (() => { try { return JSON.parse(errData).message } catch { return `HTTP ${res.statusCode}` } })()
|
||||||
|
_pullProgress.set(rid, { ..._pullProgress.get(rid), status: 'error', message: err })
|
||||||
|
reject(new Error(err))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_pullProgress.set(rid, { ..._pullProgress.get(rid), status: 'pulling', message: '正在拉取镜像层...' })
|
||||||
|
let lastError = null
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
const text = chunk.toString()
|
||||||
|
for (const line of text.split('\n').filter(Boolean)) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line)
|
||||||
|
if (obj.error) { lastError = obj.error; continue }
|
||||||
|
const p = _pullProgress.get(rid)
|
||||||
|
if (obj.id && obj.progressDetail) {
|
||||||
|
p.layers[obj.id] = {
|
||||||
|
status: obj.status || '',
|
||||||
|
current: obj.progressDetail.current || 0,
|
||||||
|
total: obj.progressDetail.total || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (obj.status) p.message = obj.id ? `${obj.id}: ${obj.status}` : obj.status
|
||||||
|
// 计算总体进度
|
||||||
|
const layers = Object.values(p.layers)
|
||||||
|
if (layers.length > 0) {
|
||||||
|
const totalBytes = layers.reduce((s, l) => s + (l.total || 0), 0)
|
||||||
|
const currentBytes = layers.reduce((s, l) => s + (l.current || 0), 0)
|
||||||
|
p.percent = totalBytes > 0 ? Math.round((currentBytes / totalBytes) * 100) : 0
|
||||||
|
p.layerCount = layers.length
|
||||||
|
p.completedLayers = layers.filter(l => l.status === 'Pull complete' || l.status === 'Already exists').length
|
||||||
|
}
|
||||||
|
_pullProgress.set(rid, p)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
res.on('end', () => {
|
||||||
|
if (lastError) {
|
||||||
|
_pullProgress.set(rid, { ..._pullProgress.get(rid), status: 'error', message: lastError })
|
||||||
|
reject(new Error(lastError))
|
||||||
|
} else {
|
||||||
|
_pullProgress.set(rid, { ..._pullProgress.get(rid), status: 'done', message: '拉取完成', percent: 100 })
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
req.on('error', (e) => {
|
||||||
|
_pullProgress.set(rid, { ..._pullProgress.get(rid), status: 'error', message: e.message })
|
||||||
|
reject(new Error('Docker 连接失败: ' + e.message))
|
||||||
|
})
|
||||||
|
req.setTimeout(600000, () => {
|
||||||
|
_pullProgress.set(rid, { ..._pullProgress.get(rid), status: 'error', message: '超时' })
|
||||||
|
req.destroy()
|
||||||
|
reject(new Error('镜像拉取超时(10分钟)'))
|
||||||
|
})
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
// 30秒后清理进度数据
|
||||||
|
setTimeout(() => _pullProgress.delete(rid), 30000)
|
||||||
|
}
|
||||||
|
return { message: `镜像 ${imgFull} 拉取完成`, requestId: rid }
|
||||||
|
},
|
||||||
|
|
||||||
|
docker_pull_status({ requestId } = {}) {
|
||||||
|
if (!requestId) return { status: 'unknown' }
|
||||||
|
return _pullProgress.get(requestId) || { status: 'unknown' }
|
||||||
},
|
},
|
||||||
|
|
||||||
async docker_list_images({ nodeId } = {}) {
|
async docker_list_images({ nodeId } = {}) {
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ mod platform {
|
|||||||
if !check_service_status(0, "").0 {
|
if !check_service_status(0, "").0 {
|
||||||
// 关闭残留终端窗口
|
// 关闭残留终端窗口
|
||||||
let _ = TokioCommand::new("cmd")
|
let _ = TokioCommand::new("cmd")
|
||||||
.args(["/c", "taskkill", "/fi", &format!("WINDOWTITLE eq {}", GATEWAY_WINDOW_TITLE)])
|
.args(["/c", "taskkill", "/f", "/t", "/fi", &format!("WINDOWTITLE eq {}", GATEWAY_WINDOW_TITLE)])
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
@@ -420,33 +420,69 @@ mod platform {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按窗口标题强杀(新版可见终端)
|
// 优雅停止失败,按端口查找进程并强杀(最可靠)
|
||||||
|
let port = read_gateway_port();
|
||||||
|
let _ = kill_by_port(port).await;
|
||||||
|
|
||||||
|
// 等端口释放
|
||||||
|
for _ in 0..5 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||||
|
if !check_service_status(0, "").0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭残留终端窗口(仅做清理,不影响进程停止)
|
||||||
let _ = TokioCommand::new("cmd")
|
let _ = TokioCommand::new("cmd")
|
||||||
.args(["/c", "taskkill", "/f", "/fi", &format!("WINDOWTITLE eq {}", GATEWAY_WINDOW_TITLE)])
|
.args(["/c", "taskkill", "/f", "/t", "/fi", &format!("WINDOWTITLE eq {}", GATEWAY_WINDOW_TITLE)])
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// 兜底:按端口查杀(兼容旧版隐藏进程)
|
|
||||||
if check_service_status(0, "").0 {
|
|
||||||
let port = read_gateway_port();
|
|
||||||
let _ = kill_by_port(port).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通过 netstat 查找占用端口的 PID 并强制杀掉
|
/// 通过 netstat 查找占用端口的 PID 并强制杀掉(在 Rust 侧解析,避免 cmd for/f 引号问题)
|
||||||
async fn kill_by_port(port: u16) -> Result<(), String> {
|
async fn kill_by_port(port: u16) -> Result<(), String> {
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
let netstat_cmd = format!(
|
let output = TokioCommand::new("cmd")
|
||||||
"for /f \"tokens=5\" %a in ('netstat -ano ^| findstr LISTENING ^| findstr :{port}') do taskkill /f /pid %a"
|
.args(["/c", "netstat", "-ano"])
|
||||||
);
|
|
||||||
let _ = TokioCommand::new("cmd")
|
|
||||||
.args(["/c", &netstat_cmd])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await
|
||||||
|
.map_err(|e| format!("netstat 失败: {e}"))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let port_pattern = format!(":{port}");
|
||||||
|
let mut pids = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for line in stdout.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.contains("LISTENING") || !trimmed.contains(&port_pattern) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 确认是本地地址端口精确匹配(避免 :1878 匹配 :18789)
|
||||||
|
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||||
|
if parts.len() >= 5 {
|
||||||
|
if let Some(addr) = parts.get(1) {
|
||||||
|
if addr.ends_with(&port_pattern) {
|
||||||
|
if let Ok(pid) = parts[4].parse::<u32>() {
|
||||||
|
if pid > 0 {
|
||||||
|
pids.insert(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pid in pids {
|
||||||
|
let _ = TokioCommand::new("cmd")
|
||||||
|
.args(["/c", "taskkill", "/f", "/t", "/pid", &pid.to_string()])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
151
src/components/engagement.js
Normal file
151
src/components/engagement.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* 社区引导浮窗 — 适时提醒用户加群 & Star
|
||||||
|
*
|
||||||
|
* 触发条件(全部满足才弹出):
|
||||||
|
* 1. 累计打开 ≥ 3 次
|
||||||
|
* 2. 首次打开距今 ≥ 3 天
|
||||||
|
* 3. 上次弹出距今 ≥ 14 天
|
||||||
|
* 4. 未被永久关闭
|
||||||
|
* 5. 由外部在"正向时机"主动调用 tryShow()
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEYS = {
|
||||||
|
firstOpen: 'clawpanel_first_open',
|
||||||
|
openCount: 'clawpanel_open_count',
|
||||||
|
lastShown: 'clawpanel_engage_shown',
|
||||||
|
never: 'clawpanel_engage_never',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY = 86400000
|
||||||
|
const MIN_OPENS = 3
|
||||||
|
const MIN_DAYS = 3
|
||||||
|
const COOLDOWN_DAYS = 14
|
||||||
|
const AUTO_DISMISS_MS = 25000
|
||||||
|
|
||||||
|
// 启动时记录打开次数
|
||||||
|
function _track() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (!localStorage.getItem(KEYS.firstOpen)) {
|
||||||
|
localStorage.setItem(KEYS.firstOpen, String(now))
|
||||||
|
}
|
||||||
|
const count = parseInt(localStorage.getItem(KEYS.openCount) || '0') + 1
|
||||||
|
localStorage.setItem(KEYS.openCount, String(count))
|
||||||
|
}
|
||||||
|
_track()
|
||||||
|
|
||||||
|
function _canShow() {
|
||||||
|
if (localStorage.getItem(KEYS.never) === '1') return false
|
||||||
|
const count = parseInt(localStorage.getItem(KEYS.openCount) || '0')
|
||||||
|
if (count < MIN_OPENS) return false
|
||||||
|
const first = parseInt(localStorage.getItem(KEYS.firstOpen) || '0')
|
||||||
|
if (Date.now() - first < MIN_DAYS * DAY) return false
|
||||||
|
const last = parseInt(localStorage.getItem(KEYS.lastShown) || '0')
|
||||||
|
if (Date.now() - last < COOLDOWN_DAYS * DAY) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let _showing = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在正向时机调用(如 Gateway 启动成功、配置保存成功)
|
||||||
|
* 满足条件才弹出,否则静默返回
|
||||||
|
*/
|
||||||
|
export function tryShowEngagement() {
|
||||||
|
if (_showing || !_canShow()) return
|
||||||
|
if (document.querySelector('.engage-overlay')) return
|
||||||
|
_showing = true
|
||||||
|
localStorage.setItem(KEYS.lastShown, String(Date.now()))
|
||||||
|
|
||||||
|
const shareText = '推荐一个开源的 OpenClaw 管理面板 — ClawPanel,一键搭建、便捷管理模型和 Agent,还内置 AI 助手帮你排查问题,小白也能轻松上手 👉 https://claw.qt.cool'
|
||||||
|
|
||||||
|
const overlay = document.createElement('div')
|
||||||
|
overlay.className = 'engage-overlay'
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="engage-modal">
|
||||||
|
<button class="engage-close" title="关闭">×</button>
|
||||||
|
|
||||||
|
<div class="engage-header">
|
||||||
|
<div class="engage-icon">
|
||||||
|
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="engage-title">感谢你使用 ClawPanel</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="engage-message">
|
||||||
|
ClawPanel 是一个<strong>完全开源、免费</strong>的项目,由晴辰云团队专职维护、持续更新。如果它帮到了你,对我们最大的鼓励就是:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="engage-actions-grid">
|
||||||
|
<a class="engage-action-card" href="https://github.com/qingchencloud/clawpanel" target="_blank" rel="noopener">
|
||||||
|
<div class="engage-action-icon engage-action-star">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="#f59e0b" stroke="#f59e0b" stroke-width="1"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="engage-action-text">
|
||||||
|
<div class="engage-action-title">GitHub Star</div>
|
||||||
|
<div class="engage-action-desc">点个 Star 是最直接的支持</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="engage-action-card engage-action-share" data-action="copy-share">
|
||||||
|
<div class="engage-action-icon engage-action-link">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="engage-action-text">
|
||||||
|
<div class="engage-action-title">分享给朋友</div>
|
||||||
|
<div class="engage-action-desc">复制推荐文案,让更多人知道</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="engage-section-label">扫码加入社区交流群,第一时间获取更新和帮助</div>
|
||||||
|
<div class="engage-qrcodes">
|
||||||
|
<a class="engage-qr-item" href="https://qt.cool/c/OpenClaw" target="_blank" rel="noopener">
|
||||||
|
<img src="/images/OpenClaw-QQ.png" alt="QQ 交流群" />
|
||||||
|
<div class="engage-qr-label">QQ 群</div>
|
||||||
|
</a>
|
||||||
|
<a class="engage-qr-item" href="https://qt.cool/c/OpenClawWx" target="_blank" rel="noopener">
|
||||||
|
<img src="/images/OpenClawWx.png" alt="微信交流群" />
|
||||||
|
<div class="engage-qr-label">微信群</div>
|
||||||
|
</a>
|
||||||
|
<a class="engage-qr-item" href="https://qt.cool/c/OpenClawDY" target="_blank" rel="noopener">
|
||||||
|
<img src="/images/OpenClaw-DY.png" alt="抖音交流群" />
|
||||||
|
<div class="engage-qr-label">抖音群</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="engage-footer">
|
||||||
|
<span class="engage-never">不再提醒</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
document.body.appendChild(overlay)
|
||||||
|
requestAnimationFrame(() => overlay.classList.add('engage-visible'))
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
overlay.classList.remove('engage-visible')
|
||||||
|
setTimeout(() => { overlay.remove(); _showing = false }, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) dismiss() })
|
||||||
|
overlay.querySelector('.engage-close').onclick = dismiss
|
||||||
|
overlay.querySelector('.engage-never').onclick = () => {
|
||||||
|
localStorage.setItem(KEYS.never, '1')
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
overlay.querySelector('[data-action="copy-share"]').onclick = () => {
|
||||||
|
navigator.clipboard.writeText(shareText).then(() => {
|
||||||
|
const desc = overlay.querySelector('[data-action="copy-share"] .engage-action-desc')
|
||||||
|
if (desc) { desc.textContent = '✅ 已复制,去分享吧!'; setTimeout(() => { desc.textContent = '复制推荐文案,让更多人知道' }, 2000) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试用:绕过条件直接弹出(浏览器控制台输入 __testEngagement())
|
||||||
|
window.__testEngagement = function() {
|
||||||
|
_showing = false
|
||||||
|
document.querySelector('.engage-overlay')?.remove()
|
||||||
|
localStorage.removeItem(KEYS.never)
|
||||||
|
localStorage.setItem(KEYS.openCount, '99')
|
||||||
|
localStorage.setItem(KEYS.firstOpen, '0')
|
||||||
|
localStorage.removeItem(KEYS.lastShown)
|
||||||
|
tryShowEngagement()
|
||||||
|
}
|
||||||
@@ -41,9 +41,9 @@ const NAV_ITEMS_FULL = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
section: 'Docker',
|
section: '龙虾军团',
|
||||||
items: [
|
items: [
|
||||||
{ route: '/docker', label: 'Docker 集群', icon: 'docker' },
|
{ route: '/docker', label: '🦞 龙虾军团', icon: 'docker' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -125,6 +125,7 @@ export function renderSidebar(el) {
|
|||||||
<img src="/images/logo.png" alt="ClawPanel">
|
<img src="/images/logo.png" alt="ClawPanel">
|
||||||
</div>
|
</div>
|
||||||
<span class="sidebar-title">ClawPanel</span>
|
<span class="sidebar-title">ClawPanel</span>
|
||||||
|
<button class="sidebar-close-btn" id="btn-sidebar-close" title="关闭菜单">×</button>
|
||||||
</div>
|
</div>
|
||||||
${showSwitcher ? `<div class="instance-switcher" id="instance-switcher">
|
${showSwitcher ? `<div class="instance-switcher" id="instance-switcher">
|
||||||
<button class="instance-current" id="btn-instance-toggle">
|
<button class="instance-current" id="btn-instance-toggle">
|
||||||
@@ -186,6 +187,12 @@ export function renderSidebar(el) {
|
|||||||
const navItem = e.target.closest('.nav-item[data-route]')
|
const navItem = e.target.closest('.nav-item[data-route]')
|
||||||
if (navItem) {
|
if (navItem) {
|
||||||
navigate(navItem.dataset.route)
|
navigate(navItem.dataset.route)
|
||||||
|
_closeMobileSidebar()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 移动端关闭按钮
|
||||||
|
if (e.target.closest('#btn-sidebar-close')) {
|
||||||
|
_closeMobileSidebar()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 主题切换
|
// 主题切换
|
||||||
@@ -235,6 +242,29 @@ export function renderSidebar(el) {
|
|||||||
|
|
||||||
function _escSidebar(s) { return String(s || '').replace(/</g, '<').replace(/>/g, '>') }
|
function _escSidebar(s) { return String(s || '').replace(/</g, '<').replace(/>/g, '>') }
|
||||||
|
|
||||||
|
// === 移动端侧边栏 ===
|
||||||
|
function _closeMobileSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar')
|
||||||
|
const overlay = document.getElementById('sidebar-overlay')
|
||||||
|
if (sidebar) sidebar.classList.remove('sidebar-open')
|
||||||
|
if (overlay) overlay.classList.remove('visible')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openMobileSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar')
|
||||||
|
if (!sidebar) return
|
||||||
|
sidebar.classList.add('sidebar-open')
|
||||||
|
let overlay = document.getElementById('sidebar-overlay')
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = document.createElement('div')
|
||||||
|
overlay.id = 'sidebar-overlay'
|
||||||
|
overlay.className = 'sidebar-overlay'
|
||||||
|
overlay.addEventListener('click', _closeMobileSidebar)
|
||||||
|
document.getElementById('app').appendChild(overlay)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => overlay.classList.add('visible'))
|
||||||
|
}
|
||||||
|
|
||||||
function _closeInstanceDropdown() {
|
function _closeInstanceDropdown() {
|
||||||
const dd = document.getElementById('instance-dropdown')
|
const dd = document.getElementById('instance-dropdown')
|
||||||
if (dd) dd.classList.remove('open')
|
if (dd) dd.classList.remove('open')
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ const PATHS = {
|
|||||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>',
|
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>',
|
||||||
'shield': '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
'shield': '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
||||||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||||
|
|
||||||
|
// 军事主题图标
|
||||||
|
'swords': '<path d="M14.5 17.5L3 6V3h3l11.5 11.5"/><path d="M13 19l6-6"/><path d="M16 16l4 4"/><path d="M14.5 6.5L18 3l3 3-3.5 3.5"/><path d="M20 4L8.5 15.5"/>',
|
||||||
|
'castle': '<path d="M3 21h18M5 21V10l2-2V4h2v4h2V4h2v4h2V4h2v4l2 2v11"/><path d="M10 21v-4h4v4"/>',
|
||||||
|
'tent': '<path d="M19 20L12 4 5 20"/><path d="M3 20h18"/><path d="M12 4v16"/>',
|
||||||
|
'scroll': '<path d="M8 21h12a2 2 0 002-2v-2H10v2a2 2 0 11-4 0V5a2 2 0 012-2h12v16"/>',
|
||||||
|
'play': '<polygon points="5 3 19 12 5 21 5 3"/>',
|
||||||
|
'stop': '<rect x="6" y="6" width="12" height="12" rx="1"/>',
|
||||||
|
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>',
|
||||||
|
'rocket': '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
|
||||||
|
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||||
|
'pen-tool': '<path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5zM2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/>',
|
||||||
|
'gear': '<circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>',
|
||||||
|
'plus-circle': '<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>',
|
||||||
|
'crown': '<path d="M2 20h20L19 9l-5 5-2-7-2 7-5-5-3 11z"/><path d="M2 20h20v2H2z"/>',
|
||||||
|
'send': '<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
204
src/lib/pixel-roles.js
Normal file
204
src/lib/pixel-roles.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// 像素风格龙虾兵角色图
|
||||||
|
// 每个角色是一个 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 += `<rect x="${x}" y="${y}" width="1" height="1" fill="${palette[colorIdx]}"/>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<svg viewBox="0 0 ${grid} ${grid}" width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg" style="image-rendering:pixelated">${rects}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成像素角色 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)
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ const NO_MOCK_CMDS = new Set([
|
|||||||
'auto_pair_device',
|
'auto_pair_device',
|
||||||
'assistant_exec', 'assistant_write_file',
|
'assistant_exec', 'assistant_write_file',
|
||||||
'docker_create_container', 'docker_start_container', 'docker_stop_container',
|
'docker_create_container', 'docker_start_container', 'docker_stop_container',
|
||||||
'docker_restart_container', 'docker_remove_container', 'docker_pull_image',
|
'docker_restart_container', 'docker_remove_container', 'docker_container_exec', 'docker_gateway_chat', 'docker_pull_image',
|
||||||
'docker_add_node', 'docker_remove_node',
|
'docker_add_node', 'docker_remove_node',
|
||||||
'instance_add', 'instance_remove', 'instance_set_active',
|
'instance_add', 'instance_remove', 'instance_set_active',
|
||||||
])
|
])
|
||||||
@@ -26,7 +26,7 @@ const WEB_ONLY_CMDS = new Set([
|
|||||||
'docker_test_endpoint',
|
'docker_test_endpoint',
|
||||||
'docker_info', 'docker_list_containers', 'docker_create_container',
|
'docker_info', 'docker_list_containers', 'docker_create_container',
|
||||||
'docker_start_container', 'docker_stop_container', 'docker_restart_container',
|
'docker_start_container', 'docker_stop_container', 'docker_restart_container',
|
||||||
'docker_remove_container', 'docker_container_logs', 'docker_pull_image',
|
'docker_remove_container', 'docker_container_logs', 'docker_container_exec', 'docker_gateway_chat', 'docker_pull_image', 'docker_pull_status',
|
||||||
'docker_list_images', 'docker_list_nodes', 'docker_add_node', 'docker_remove_node',
|
'docker_list_images', 'docker_list_nodes', 'docker_add_node', 'docker_remove_node',
|
||||||
'docker_cluster_overview',
|
'docker_cluster_overview',
|
||||||
'instance_list', 'instance_add', 'instance_remove', 'instance_set_active',
|
'instance_list', 'instance_add', 'instance_remove', 'instance_set_active',
|
||||||
@@ -421,7 +421,10 @@ export const api = {
|
|||||||
dockerRestartContainer: (nodeId, containerId) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_restart_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 }) },
|
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 }),
|
dockerContainerLogs: (nodeId, containerId, tail = 200) => invoke('docker_container_logs', { nodeId, containerId, tail }),
|
||||||
dockerPullImage: (nodeId, image, tag) => invoke('docker_pull_image', { nodeId, image, tag }),
|
dockerContainerExec: (nodeId, containerId, cmd) => invoke('docker_container_exec', { nodeId, containerId, cmd }),
|
||||||
|
dockerGatewayChat: (nodeId, containerId, message) => invoke('docker_gateway_chat', { nodeId, containerId, message }),
|
||||||
|
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 }),
|
dockerListImages: (nodeId) => invoke('docker_list_images', { nodeId }),
|
||||||
dockerListNodes: () => cachedInvoke('docker_list_nodes', {}, 30000),
|
dockerListNodes: () => cachedInvoke('docker_list_nodes', {}, 30000),
|
||||||
dockerAddNode: (name, endpoint) => { invalidate('docker_list_nodes', 'docker_cluster_overview'); return invoke('docker_add_node', { name, endpoint }) },
|
dockerAddNode: (name, endpoint) => { invalidate('docker_list_nodes', 'docker_cluster_overview'); return invoke('docker_add_node', { name, endpoint }) },
|
||||||
|
|||||||
30
src/main.js
30
src/main.js
@@ -2,13 +2,14 @@
|
|||||||
* ClawPanel 入口
|
* ClawPanel 入口
|
||||||
*/
|
*/
|
||||||
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
|
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
|
||||||
import { renderSidebar } from './components/sidebar.js'
|
import { renderSidebar, openMobileSidebar } from './components/sidebar.js'
|
||||||
import { initTheme } from './lib/theme.js'
|
import { initTheme } from './lib/theme.js'
|
||||||
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js'
|
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js'
|
||||||
import { wsClient } from './lib/ws-client.js'
|
import { wsClient } from './lib/ws-client.js'
|
||||||
import { api } from './lib/tauri-api.js'
|
import { api } from './lib/tauri-api.js'
|
||||||
import { version as APP_VERSION } from '../package.json'
|
import { version as APP_VERSION } from '../package.json'
|
||||||
import { statusIcon } from './lib/icons.js'
|
import { statusIcon } from './lib/icons.js'
|
||||||
|
import { tryShowEngagement } from './components/engagement.js'
|
||||||
|
|
||||||
// 样式
|
// 样式
|
||||||
import './style/variables.css'
|
import './style/variables.css'
|
||||||
@@ -231,6 +232,20 @@ async function boot() {
|
|||||||
renderSidebar(sidebar)
|
renderSidebar(sidebar)
|
||||||
initRouter(content)
|
initRouter(content)
|
||||||
|
|
||||||
|
// 移动端顶栏(汉堡菜单 + 标题)
|
||||||
|
const mainCol = document.getElementById('main-col')
|
||||||
|
const topbar = document.createElement('div')
|
||||||
|
topbar.className = 'mobile-topbar'
|
||||||
|
topbar.id = 'mobile-topbar'
|
||||||
|
topbar.innerHTML = `
|
||||||
|
<button class="mobile-hamburger" id="btn-mobile-menu">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="mobile-topbar-title">ClawPanel</span>
|
||||||
|
`
|
||||||
|
topbar.querySelector('.mobile-hamburger').addEventListener('click', openMobileSidebar)
|
||||||
|
mainCol.prepend(topbar)
|
||||||
|
|
||||||
// 隐藏启动加载屏
|
// 隐藏启动加载屏
|
||||||
const splash = document.getElementById('splash')
|
const splash = document.getElementById('splash')
|
||||||
if (splash) {
|
if (splash) {
|
||||||
@@ -284,6 +299,8 @@ async function boot() {
|
|||||||
onGatewayChange((running) => {
|
onGatewayChange((running) => {
|
||||||
if (running) {
|
if (running) {
|
||||||
autoConnectWebSocket()
|
autoConnectWebSocket()
|
||||||
|
// 正向时机:Gateway 启动成功,延迟弹社区引导
|
||||||
|
setTimeout(tryShowEngagement, 5000)
|
||||||
} else {
|
} else {
|
||||||
wsClient.disconnect()
|
wsClient.disconnect()
|
||||||
}
|
}
|
||||||
@@ -310,7 +327,8 @@ async function autoConnectWebSocket() {
|
|||||||
console.log(`[main] 自动连接 WebSocket (实例: ${inst.name})...`)
|
console.log(`[main] 自动连接 WebSocket (实例: ${inst.name})...`)
|
||||||
const config = await api.readOpenclawConfig()
|
const config = await api.readOpenclawConfig()
|
||||||
const port = config?.gateway?.port || 18789
|
const port = config?.gateway?.port || 18789
|
||||||
const token = config?.gateway?.auth?.token || ''
|
const rawToken = config?.gateway?.auth?.token
|
||||||
|
const token = (typeof rawToken === 'string') ? rawToken : ''
|
||||||
|
|
||||||
// 启动前先确保设备已配对 + allowedOrigins 已写入,无需用户手动操作
|
// 启动前先确保设备已配对 + allowedOrigins 已写入,无需用户手动操作
|
||||||
let needReload = false
|
let needReload = false
|
||||||
@@ -372,8 +390,9 @@ function setupGatewayBanner() {
|
|||||||
if (!banner) return
|
if (!banner) return
|
||||||
|
|
||||||
function update(running) {
|
function update(running) {
|
||||||
if (running) {
|
if (running || sessionStorage.getItem('gw-banner-dismissed')) {
|
||||||
banner.classList.add('gw-banner-hidden')
|
banner.classList.add('gw-banner-hidden')
|
||||||
|
return
|
||||||
} else {
|
} else {
|
||||||
banner.classList.remove('gw-banner-hidden')
|
banner.classList.remove('gw-banner-hidden')
|
||||||
banner.innerHTML = `
|
banner.innerHTML = `
|
||||||
@@ -381,8 +400,13 @@ function setupGatewayBanner() {
|
|||||||
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
|
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
|
||||||
<span>Gateway 未启动,部分功能不可用</span>
|
<span>Gateway 未启动,部分功能不可用</span>
|
||||||
<button class="btn btn-sm btn-primary" id="btn-gw-start">启动 Gateway</button>
|
<button class="btn btn-sm btn-primary" id="btn-gw-start">启动 Gateway</button>
|
||||||
|
<button class="gw-banner-close" id="btn-gw-dismiss" title="关闭提示">×</button>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
banner.querySelector('#btn-gw-dismiss')?.addEventListener('click', () => {
|
||||||
|
banner.classList.add('gw-banner-hidden')
|
||||||
|
sessionStorage.setItem('gw-banner-dismissed', '1')
|
||||||
|
})
|
||||||
banner.querySelector('#btn-gw-start')?.addEventListener('click', async (e) => {
|
banner.querySelector('#btn-gw-start')?.addEventListener('click', async (e) => {
|
||||||
const btn = e.target
|
const btn = e.target
|
||||||
btn.disabled = true
|
btn.disabled = true
|
||||||
|
|||||||
@@ -1330,6 +1330,7 @@ function loadConfig() {
|
|||||||
if (!_config.tools) _config.tools = { terminal: false, fileOps: false, webSearch: false }
|
if (!_config.tools) _config.tools = { terminal: false, fileOps: false, webSearch: false }
|
||||||
if (!_config.mode) _config.mode = DEFAULT_MODE
|
if (!_config.mode) _config.mode = DEFAULT_MODE
|
||||||
if (!_config.apiType) _config.apiType = 'openai'
|
if (!_config.apiType) _config.apiType = 'openai'
|
||||||
|
if (_config.autoRounds === undefined) _config.autoRounds = 8
|
||||||
if (!Array.isArray(_config.knowledgeFiles)) _config.knowledgeFiles = []
|
if (!Array.isArray(_config.knowledgeFiles)) _config.knowledgeFiles = []
|
||||||
return _config
|
return _config
|
||||||
}
|
}
|
||||||
@@ -2038,22 +2039,28 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
|
|||||||
let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
|
let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
|
||||||
const toolHistory = []
|
const toolHistory = []
|
||||||
|
|
||||||
const MAX_AUTO_ROUNDS = 8
|
const autoRounds = _config.autoRounds ?? 8 // 0 = 无限制
|
||||||
|
let nextPauseAt = autoRounds // 下一次暂停的轮次阈值
|
||||||
for (let round = 0; ; round++) {
|
for (let round = 0; ; round++) {
|
||||||
// 检查是否已被用户中止
|
// 检查是否已被用户中止
|
||||||
if (!_isStreaming || _abortController?.signal?.aborted) {
|
if (!_isStreaming || _abortController?.signal?.aborted) {
|
||||||
throw new DOMException('Aborted', 'AbortError')
|
throw new DOMException('Aborted', 'AbortError')
|
||||||
}
|
}
|
||||||
if (round >= MAX_AUTO_ROUNDS) {
|
if (autoRounds > 0 && round >= nextPauseAt) {
|
||||||
const answer = await showAskUserCard({
|
const answer = await showAskUserCard({
|
||||||
question: `AI 已连续调用工具 ${round} 轮,可能陷入循环。你希望怎么做?`,
|
question: `AI 已连续调用工具 ${round} 轮,可能陷入循环。你希望怎么做?`,
|
||||||
type: 'single',
|
type: 'single',
|
||||||
options: ['继续执行', '让 AI 换个思路', '停止并总结'],
|
options: [`继续执行 ${autoRounds} 轮`, '不再中断,一直执行', '让 AI 换个思路', '停止并总结'],
|
||||||
})
|
})
|
||||||
if (answer.includes('停止')) {
|
if (answer.includes('停止')) {
|
||||||
return { content: '用户要求停止工具调用,以下是目前的执行情况摘要。', toolHistory }
|
return { content: '用户要求停止工具调用,以下是目前的执行情况摘要。', toolHistory }
|
||||||
} else if (answer.includes('换个思路')) {
|
} else if (answer.includes('换个思路')) {
|
||||||
currentMessages.push({ role: 'user', content: '请换一种方法来解决这个问题,不要重复之前失败的操作。' })
|
currentMessages.push({ role: 'user', content: '请换一种方法来解决这个问题,不要重复之前失败的操作。' })
|
||||||
|
nextPauseAt = round + autoRounds
|
||||||
|
} else if (answer.includes('不再中断')) {
|
||||||
|
nextPauseAt = Infinity
|
||||||
|
} else {
|
||||||
|
nextPauseAt = round + autoRounds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2598,6 +2605,19 @@ function showSettings() {
|
|||||||
<input type="checkbox" id="ast-tool-websearch" ${c.tools?.webSearch !== false ? 'checked' : ''}>
|
<input type="checkbox" id="ast-tool-websearch" ${c.tools?.webSearch !== false ? 'checked' : ''}>
|
||||||
<span class="ast-switch-track"></span>
|
<span class="ast-switch-track"></span>
|
||||||
</label>
|
</label>
|
||||||
|
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border-color)">
|
||||||
|
<div class="form-group" style="margin-bottom:4px">
|
||||||
|
<label class="form-label">工具连续执行轮次 <span style="color:var(--text-tertiary);font-size:11px">— 超过该轮次后暂停并询问</span></label>
|
||||||
|
<select class="form-input" id="ast-auto-rounds" style="width:100%">
|
||||||
|
<option value="0" ${(c.autoRounds ?? 8) === 0 ? 'selected' : ''}>∞ 无限制(一直执行,不中断)</option>
|
||||||
|
<option value="8" ${(c.autoRounds ?? 8) === 8 ? 'selected' : ''}>8 轮(默认)</option>
|
||||||
|
<option value="15" ${(c.autoRounds ?? 8) === 15 ? 'selected' : ''}>15 轮</option>
|
||||||
|
<option value="30" ${(c.autoRounds ?? 8) === 30 ? 'selected' : ''}>30 轮</option>
|
||||||
|
<option value="50" ${(c.autoRounds ?? 8) === 50 ? 'selected' : ''}>50 轮</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-hint">设为「无限制」时 AI 将不会中断执行,适合复杂任务。随时可点停止按钮手动中止。</div>
|
||||||
|
</div>
|
||||||
<div class="form-hint" style="margin-top:10px">进程列表、端口检测、系统信息工具始终可用(非聊天模式下)。</div>
|
<div class="form-hint" style="margin-top:10px">进程列表、端口检测、系统信息工具始终可用(非聊天模式下)。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ast-tab-panel" data-panel="persona">
|
<div class="ast-tab-panel" data-panel="persona">
|
||||||
@@ -3322,6 +3342,7 @@ function showSettings() {
|
|||||||
_config.tools.terminal = overlay.querySelector('#ast-tool-terminal').checked
|
_config.tools.terminal = overlay.querySelector('#ast-tool-terminal').checked
|
||||||
_config.tools.fileOps = overlay.querySelector('#ast-tool-fileops').checked
|
_config.tools.fileOps = overlay.querySelector('#ast-tool-fileops').checked
|
||||||
_config.tools.webSearch = overlay.querySelector('#ast-tool-websearch').checked
|
_config.tools.webSearch = overlay.querySelector('#ast-tool-websearch').checked
|
||||||
|
_config.autoRounds = parseInt(overlay.querySelector('#ast-auto-rounds').value, 10) || 0
|
||||||
// 灵魂来源
|
// 灵魂来源
|
||||||
const soulRadio = overlay.querySelector('input[name="ast-soul-source"]:checked')
|
const soulRadio = overlay.querySelector('input[name="ast-soul-source"]:checked')
|
||||||
if (soulRadio?.value === 'openclaw') {
|
if (soulRadio?.value === 'openclaw') {
|
||||||
|
|||||||
1097
src/pages/docker.js
1097
src/pages/docker.js
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { api } from '../lib/tauri-api.js'
|
import { api } from '../lib/tauri-api.js'
|
||||||
import { toast } from '../components/toast.js'
|
import { toast } from '../components/toast.js'
|
||||||
|
import { tryShowEngagement } from '../components/engagement.js'
|
||||||
|
|
||||||
// 兼容新版 SecretRef:token 可能是 string 或 { $env: "VAR" } / { $ref: "x/y" }
|
// 兼容新版 SecretRef:token 可能是 string 或 { $env: "VAR" } / { $ref: "x/y" }
|
||||||
function _tokenDisplayStr(token) {
|
function _tokenDisplayStr(token) {
|
||||||
@@ -363,6 +364,7 @@ async function saveConfig(page, state) {
|
|||||||
try {
|
try {
|
||||||
await api.reloadGateway()
|
await api.reloadGateway()
|
||||||
toast('Gateway 已重载,新配置已生效', 'success')
|
toast('Gateway 已重载,新配置已生效', 'success')
|
||||||
|
setTimeout(tryShowEngagement, 3000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast('配置已保存,但重载失败: ' + e, 'warning')
|
toast('配置已保存,但重载失败: ' + e, 'warning')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ async function handleInfo(page, name) {
|
|||||||
const detail = page.querySelector('#skill-detail-area')
|
const detail = page.querySelector('#skill-detail-area')
|
||||||
if (!detail) return
|
if (!detail) return
|
||||||
detail.innerHTML = '<div class="form-hint" style="margin-top:var(--space-md)">正在加载详情...</div>'
|
detail.innerHTML = '<div class="form-hint" style="margin-top:var(--space-md)">正在加载详情...</div>'
|
||||||
|
detail.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
try {
|
try {
|
||||||
const skill = await api.skillsInfo(name)
|
const skill = await api.skillsInfo(name)
|
||||||
const s = skill || {}
|
const s = skill || {}
|
||||||
|
|||||||
@@ -1680,3 +1680,61 @@
|
|||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 移动端响应式 === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ast-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--space-sm) var(--space-sm);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.ast-header-left {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.ast-toggle-sidebar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ast-header-actions button,
|
||||||
|
.ast-header-actions a {
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.ast-mode-tabs {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.ast-mode-tab {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ast-skill-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: 0 var(--space-sm);
|
||||||
|
}
|
||||||
|
.ast-sidebar.open {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 850;
|
||||||
|
height: 100vh;
|
||||||
|
width: 240px;
|
||||||
|
min-width: 240px;
|
||||||
|
box-shadow: 4px 0 24px rgba(0,0,0,.15);
|
||||||
|
}
|
||||||
|
.ast-input-area {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.ast-skill-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -839,3 +839,39 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 移动端响应式 === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chat-sidebar.open {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 850;
|
||||||
|
height: 100vh;
|
||||||
|
width: 220px;
|
||||||
|
min-width: 220px;
|
||||||
|
box-shadow: 4px 0 24px rgba(0,0,0,.15);
|
||||||
|
}
|
||||||
|
.chat-input-area {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
.chat-header {
|
||||||
|
padding: var(--space-sm) var(--space-sm);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.chat-toggle-sidebar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.chat-header-actions button {
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -427,3 +427,249 @@ mark {
|
|||||||
.toggle-switch input:checked + .toggle-slider::before {
|
.toggle-switch input:checked + .toggle-slider::before {
|
||||||
transform: translateX(20px);
|
transform: translateX(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 社区引导弹窗 === */
|
||||||
|
.engage-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1100;
|
||||||
|
background: rgba(0,0,0,.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 250ms ease;
|
||||||
|
}
|
||||||
|
.engage-overlay.engage-visible { opacity: 1; }
|
||||||
|
.engage-modal {
|
||||||
|
position: relative;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 28px 28px 20px;
|
||||||
|
width: 460px;
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 24px 80px rgba(0,0,0,.2);
|
||||||
|
transform: scale(0.92) translateY(12px);
|
||||||
|
transition: transform 250ms cubic-bezier(.22,1,.36,1);
|
||||||
|
}
|
||||||
|
.engage-visible .engage-modal {
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .engage-modal {
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid rgba(255,255,255,.1);
|
||||||
|
}
|
||||||
|
.engage-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 16px;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 150ms;
|
||||||
|
}
|
||||||
|
.engage-close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.engage-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.engage-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.engage-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.engage-message {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
.engage-message strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.engage-actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.engage-action-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-primary, #e5e7eb);
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 180ms;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.engage-action-card:hover {
|
||||||
|
border-color: var(--primary, #6366f1);
|
||||||
|
box-shadow: 0 2px 12px rgba(99,102,241,.12);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.engage-action-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.engage-action-star {
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .engage-action-star {
|
||||||
|
background: rgba(245,158,11,.15);
|
||||||
|
}
|
||||||
|
.engage-action-link {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: var(--primary, #6366f1);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .engage-action-link {
|
||||||
|
background: rgba(99,102,241,.15);
|
||||||
|
}
|
||||||
|
.engage-action-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.engage-action-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.engage-section-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.engage-qrcodes {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.engage-qr-item {
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
transition: all 150ms;
|
||||||
|
}
|
||||||
|
.engage-qr-item:hover {
|
||||||
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.engage-qr-item img {
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
background: #fff;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.engage-qr-label {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.engage-footer {
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid var(--border-primary, #e5e7eb);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.engage-never {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 150ms;
|
||||||
|
}
|
||||||
|
.engage-never:hover {
|
||||||
|
opacity: 1;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 移动端响应式 === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stat-cards {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
.stat-card-value {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
.engage-modal {
|
||||||
|
width: 92vw;
|
||||||
|
padding: 24px 18px 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
.engage-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.engage-actions-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.engage-qrcodes {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.engage-qr-item {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.engage-qr-item img {
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.engage-qr-label {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.stat-cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.engage-qr-item img {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -393,6 +393,18 @@
|
|||||||
.gw-banner-icon {
|
.gw-banner-icon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
.gw-banner-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(0,0,0,.5);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
.gw-banner-close:hover { color: rgba(0,0,0,.8); }
|
||||||
.gw-banner .btn {
|
.gw-banner .btn {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
background: rgba(0,0,0,0.15);
|
background: rgba(0,0,0,0.15);
|
||||||
@@ -400,3 +412,124 @@
|
|||||||
color: #000;
|
color: #000;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 移动端顶栏 + 侧边栏 === */
|
||||||
|
.mobile-topbar {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: 8px var(--space-md);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mobile-hamburger {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mobile-hamburger:active {
|
||||||
|
background: var(--bg-glass-hover);
|
||||||
|
}
|
||||||
|
.mobile-topbar-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.sidebar-close-btn {
|
||||||
|
display: none;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.sidebar-close-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-glass-hover);
|
||||||
|
}
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 899;
|
||||||
|
background: rgba(0,0,0,.4);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
.sidebar-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 移动端响应式 (≤768px) === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mobile-topbar {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.sidebar-close-btn {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 900;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 250ms cubic-bezier(.22,1,.36,1);
|
||||||
|
box-shadow: none;
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
#sidebar.sidebar-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
box-shadow: 4px 0 24px rgba(0,0,0,.15);
|
||||||
|
}
|
||||||
|
#main-col {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
padding: var(--space-lg) var(--space-lg);
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
}
|
||||||
|
.page-header:has(.page-actions) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
.gw-banner-content {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.update-banner-content {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.update-banner-text {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 小屏手机 (≤480px) === */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.page {
|
||||||
|
padding: var(--space-md) var(--space-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -858,6 +858,7 @@
|
|||||||
.docker-node-card.offline {
|
.docker-node-card.offline {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
.docker-node-pixel { line-height: 0; flex-shrink: 0; }
|
||||||
.docker-node-header {
|
.docker-node-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -917,6 +918,177 @@
|
|||||||
color: var(--text-secondary);
|
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-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 {
|
.docker-table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -987,6 +1159,52 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
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 {
|
.docker-ct-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -1116,6 +1334,106 @@
|
|||||||
margin-top: var(--space-lg);
|
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 {
|
.deploy-mode-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1230,6 +1548,549 @@ details.docker-other-section[open] > .docker-other-toggle::before {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 移动端响应式 === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-overview {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
.overview-item {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.overview-value {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.docker-dialog {
|
||||||
|
width: 92vw;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.model-provider-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.quick-actions {
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
.quick-actions .btn {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.memory-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.memory-sidebar {
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
.memory-editor {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
.clawhub-item {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
.clawhub-item-actions {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.clawhub-item-actions .btn {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.overview-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 容器详情面板 */
|
||||||
|
.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-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-results {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg, 12px);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
.task-results:empty { display: none; }
|
||||||
|
.task-results-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
.task-results-mode {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
.task-results-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.task-result-card {
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
.task-result-card:last-child { border-bottom: none; }
|
||||||
|
.task-result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.task-result-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.task-result-status {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.task-result-status.pending { color: var(--text-tertiary); }
|
||||||
|
.task-result-status.running { color: #f59e0b; }
|
||||||
|
.task-result-status.done { color: #22c55e; }
|
||||||
|
.task-result-status.error { color: #ef4444; }
|
||||||
|
.task-result-body {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-primary);
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.task-result-body .typing-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px; height: 14px;
|
||||||
|
background: var(--primary);
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
animation: cursor-blink .8s step-end infinite;
|
||||||
|
}
|
||||||
|
@keyframes cursor-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分栏标题 */
|
||||||
|
.section-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 基础设施摘要 */
|
||||||
|
.infra-detail {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
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 {
|
.docker-logs-content {
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
|
|||||||
Reference in New Issue
Block a user