mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +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)
|
||||
}
|
||||
|
||||
// === 镜像拉取进度追踪 ===
|
||||
const _pullProgress = new Map()
|
||||
|
||||
// === 实例注册表 ===
|
||||
|
||||
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',
|
||||
'docker_info', 'docker_list_containers', 'docker_create_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_cluster_overview',
|
||||
'auth_check', 'auth_login', 'auth_logout',
|
||||
@@ -986,6 +989,128 @@ const handlers = {
|
||||
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 } = {}) {
|
||||
const nodes = readDockerNodes()
|
||||
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
|
||||
@@ -998,14 +1123,97 @@ const handlers = {
|
||||
return logs
|
||||
},
|
||||
|
||||
async docker_pull_image({ nodeId, image, tag = 'latest' } = {}) {
|
||||
async docker_pull_image({ nodeId, image, tag = 'latest', requestId } = {}) {
|
||||
const nodes = readDockerNodes()
|
||||
const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0]
|
||||
if (!node) throw new Error('节点不存在')
|
||||
const imgFull = `${image || OPENCLAW_IMAGE}:${tag}`
|
||||
const resp = await dockerRequest('POST', `/images/create?fromImage=${encodeURIComponent(image || OPENCLAW_IMAGE)}&tag=${tag}`, null, node.endpoint)
|
||||
if (resp.status !== 200) throw new Error(resp.data?.message || '拉取镜像失败')
|
||||
return `镜像 ${imgFull} 拉取完成`
|
||||
const rid = requestId || `pull-${Date.now()}`
|
||||
_pullProgress.set(rid, { status: 'connecting', image: imgFull, layers: {}, message: '连接 Docker...', percent: 0 })
|
||||
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 } = {}) {
|
||||
|
||||
@@ -412,7 +412,7 @@ mod platform {
|
||||
if !check_service_status(0, "").0 {
|
||||
// 关闭残留终端窗口
|
||||
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)
|
||||
.output()
|
||||
.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")
|
||||
.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)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// 兜底:按端口查杀(兼容旧版隐藏进程)
|
||||
if check_service_status(0, "").0 {
|
||||
let port = read_gateway_port();
|
||||
let _ = kill_by_port(port).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 通过 netstat 查找占用端口的 PID 并强制杀掉
|
||||
/// 通过 netstat 查找占用端口的 PID 并强制杀掉(在 Rust 侧解析,避免 cmd for/f 引号问题)
|
||||
async fn kill_by_port(port: u16) -> Result<(), String> {
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let netstat_cmd = format!(
|
||||
"for /f \"tokens=5\" %a in ('netstat -ano ^| findstr LISTENING ^| findstr :{port}') do taskkill /f /pid %a"
|
||||
);
|
||||
let _ = TokioCommand::new("cmd")
|
||||
.args(["/c", &netstat_cmd])
|
||||
let output = TokioCommand::new("cmd")
|
||||
.args(["/c", "netstat", "-ano"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.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(())
|
||||
}
|
||||
|
||||
|
||||
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: [
|
||||
{ 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">
|
||||
</div>
|
||||
<span class="sidebar-title">ClawPanel</span>
|
||||
<button class="sidebar-close-btn" id="btn-sidebar-close" title="关闭菜单">×</button>
|
||||
</div>
|
||||
${showSwitcher ? `<div class="instance-switcher" id="instance-switcher">
|
||||
<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]')
|
||||
if (navItem) {
|
||||
navigate(navItem.dataset.route)
|
||||
_closeMobileSidebar()
|
||||
return
|
||||
}
|
||||
// 移动端关闭按钮
|
||||
if (e.target.closest('#btn-sidebar-close')) {
|
||||
_closeMobileSidebar()
|
||||
return
|
||||
}
|
||||
// 主题切换
|
||||
@@ -235,6 +242,29 @@ export function renderSidebar(el) {
|
||||
|
||||
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() {
|
||||
const dd = document.getElementById('instance-dropdown')
|
||||
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"/>',
|
||||
'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"/>',
|
||||
|
||||
// 军事主题图标
|
||||
'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',
|
||||
'assistant_exec', 'assistant_write_file',
|
||||
'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',
|
||||
'instance_add', 'instance_remove', 'instance_set_active',
|
||||
])
|
||||
@@ -26,7 +26,7 @@ const WEB_ONLY_CMDS = new Set([
|
||||
'docker_test_endpoint',
|
||||
'docker_info', 'docker_list_containers', 'docker_create_container',
|
||||
'docker_start_container', 'docker_stop_container', 'docker_restart_container',
|
||||
'docker_remove_container', 'docker_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_cluster_overview',
|
||||
'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 }) },
|
||||
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 }),
|
||||
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 }),
|
||||
dockerListNodes: () => cachedInvoke('docker_list_nodes', {}, 30000),
|
||||
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 入口
|
||||
*/
|
||||
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 { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js'
|
||||
import { wsClient } from './lib/ws-client.js'
|
||||
import { api } from './lib/tauri-api.js'
|
||||
import { version as APP_VERSION } from '../package.json'
|
||||
import { statusIcon } from './lib/icons.js'
|
||||
import { tryShowEngagement } from './components/engagement.js'
|
||||
|
||||
// 样式
|
||||
import './style/variables.css'
|
||||
@@ -231,6 +232,20 @@ async function boot() {
|
||||
renderSidebar(sidebar)
|
||||
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')
|
||||
if (splash) {
|
||||
@@ -284,6 +299,8 @@ async function boot() {
|
||||
onGatewayChange((running) => {
|
||||
if (running) {
|
||||
autoConnectWebSocket()
|
||||
// 正向时机:Gateway 启动成功,延迟弹社区引导
|
||||
setTimeout(tryShowEngagement, 5000)
|
||||
} else {
|
||||
wsClient.disconnect()
|
||||
}
|
||||
@@ -310,7 +327,8 @@ async function autoConnectWebSocket() {
|
||||
console.log(`[main] 自动连接 WebSocket (实例: ${inst.name})...`)
|
||||
const config = await api.readOpenclawConfig()
|
||||
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 已写入,无需用户手动操作
|
||||
let needReload = false
|
||||
@@ -372,8 +390,9 @@ function setupGatewayBanner() {
|
||||
if (!banner) return
|
||||
|
||||
function update(running) {
|
||||
if (running) {
|
||||
if (running || sessionStorage.getItem('gw-banner-dismissed')) {
|
||||
banner.classList.add('gw-banner-hidden')
|
||||
return
|
||||
} else {
|
||||
banner.classList.remove('gw-banner-hidden')
|
||||
banner.innerHTML = `
|
||||
@@ -381,8 +400,13 @@ function setupGatewayBanner() {
|
||||
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
|
||||
<span>Gateway 未启动,部分功能不可用</span>
|
||||
<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>
|
||||
`
|
||||
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) => {
|
||||
const btn = e.target
|
||||
btn.disabled = true
|
||||
|
||||
@@ -1330,6 +1330,7 @@ function loadConfig() {
|
||||
if (!_config.tools) _config.tools = { terminal: false, fileOps: false, webSearch: false }
|
||||
if (!_config.mode) _config.mode = DEFAULT_MODE
|
||||
if (!_config.apiType) _config.apiType = 'openai'
|
||||
if (_config.autoRounds === undefined) _config.autoRounds = 8
|
||||
if (!Array.isArray(_config.knowledgeFiles)) _config.knowledgeFiles = []
|
||||
return _config
|
||||
}
|
||||
@@ -2038,22 +2039,28 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
|
||||
let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
|
||||
const toolHistory = []
|
||||
|
||||
const MAX_AUTO_ROUNDS = 8
|
||||
const autoRounds = _config.autoRounds ?? 8 // 0 = 无限制
|
||||
let nextPauseAt = autoRounds // 下一次暂停的轮次阈值
|
||||
for (let round = 0; ; round++) {
|
||||
// 检查是否已被用户中止
|
||||
if (!_isStreaming || _abortController?.signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError')
|
||||
}
|
||||
if (round >= MAX_AUTO_ROUNDS) {
|
||||
if (autoRounds > 0 && round >= nextPauseAt) {
|
||||
const answer = await showAskUserCard({
|
||||
question: `AI 已连续调用工具 ${round} 轮,可能陷入循环。你希望怎么做?`,
|
||||
type: 'single',
|
||||
options: ['继续执行', '让 AI 换个思路', '停止并总结'],
|
||||
options: [`继续执行 ${autoRounds} 轮`, '不再中断,一直执行', '让 AI 换个思路', '停止并总结'],
|
||||
})
|
||||
if (answer.includes('停止')) {
|
||||
return { content: '用户要求停止工具调用,以下是目前的执行情况摘要。', toolHistory }
|
||||
} else if (answer.includes('换个思路')) {
|
||||
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' : ''}>
|
||||
<span class="ast-switch-track"></span>
|
||||
</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>
|
||||
<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.fileOps = overlay.querySelector('#ast-tool-fileops').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')
|
||||
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 { toast } from '../components/toast.js'
|
||||
import { tryShowEngagement } from '../components/engagement.js'
|
||||
|
||||
// 兼容新版 SecretRef:token 可能是 string 或 { $env: "VAR" } / { $ref: "x/y" }
|
||||
function _tokenDisplayStr(token) {
|
||||
@@ -363,6 +364,7 @@ async function saveConfig(page, state) {
|
||||
try {
|
||||
await api.reloadGateway()
|
||||
toast('Gateway 已重载,新配置已生效', 'success')
|
||||
setTimeout(tryShowEngagement, 3000)
|
||||
} catch (e) {
|
||||
toast('配置已保存,但重载失败: ' + e, 'warning')
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ async function handleInfo(page, name) {
|
||||
const detail = page.querySelector('#skill-detail-area')
|
||||
if (!detail) return
|
||||
detail.innerHTML = '<div class="form-hint" style="margin-top:var(--space-md)">正在加载详情...</div>'
|
||||
detail.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
try {
|
||||
const skill = await api.skillsInfo(name)
|
||||
const s = skill || {}
|
||||
|
||||
@@ -1680,3 +1680,61 @@
|
||||
from { transform: rotate(0deg); }
|
||||
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;
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
margin-left: auto;
|
||||
background: rgba(0,0,0,0.15);
|
||||
@@ -400,3 +412,124 @@
|
||||
color: #000;
|
||||
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 {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.docker-node-pixel { line-height: 0; flex-shrink: 0; }
|
||||
.docker-node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -917,6 +918,177 @@
|
||||
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 {
|
||||
overflow-x: auto;
|
||||
@@ -987,6 +1159,52 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* 批量操作 */
|
||||
.batch-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.batch-count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #00c8ff);
|
||||
padding: 2px 10px;
|
||||
background: rgba(0,200,255,.08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.docker-ct-check {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
padding: 0 4px !important;
|
||||
}
|
||||
.docker-ct-check input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent, #00c8ff);
|
||||
}
|
||||
|
||||
.docker-ct-links {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.docker-ct-link {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0,240,255,.06);
|
||||
color: var(--accent, #00c8ff);
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(0,240,255,.12);
|
||||
white-space: nowrap;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.docker-ct-link:hover {
|
||||
background: rgba(0,240,255,.12);
|
||||
border-color: rgba(0,240,255,.25);
|
||||
}
|
||||
.docker-ct-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -1116,6 +1334,106 @@
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
/* 部署进度 */
|
||||
.deploy-progress {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.deploy-progress-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.deploy-progress-icon {
|
||||
font-size: 40px;
|
||||
animation: deployBounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes deployBounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
.deploy-progress-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.deploy-progress-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.deploy-progress-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.deploy-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
opacity: 0.4;
|
||||
transition: all 300ms;
|
||||
}
|
||||
.deploy-step.active {
|
||||
opacity: 1;
|
||||
background: rgba(0, 200, 255, 0.06);
|
||||
border-left: 3px solid var(--accent, #00c8ff);
|
||||
}
|
||||
.deploy-step.done {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.deploy-step.done .deploy-step-icon::after {
|
||||
content: ' ✓';
|
||||
color: #22c55e;
|
||||
}
|
||||
.deploy-step-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.deploy-step-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.deploy-step-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.deploy-step-detail {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deploy-progress-bar-wrap {
|
||||
height: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.deploy-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent, #00c8ff), #a78bfa);
|
||||
border-radius: 2px;
|
||||
transition: width 400ms ease;
|
||||
}
|
||||
.deploy-progress-log {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
text-align: center;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
/* 部署模式切换 */
|
||||
.deploy-mode-toggle {
|
||||
display: flex;
|
||||
@@ -1230,6 +1548,549 @@ details.docker-other-section[open] > .docker-other-toggle::before {
|
||||
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 {
|
||||
background: var(--bg-primary);
|
||||
|
||||
Reference in New Issue
Block a user