feat: Docker 集群增强 — Gateway 通讯API、像素兵种系统、互动组件、UI 优化

This commit is contained in:
晴天
2026-03-09 05:35:30 +08:00
parent 5b8a7ab76f
commit 727903f94b
18 changed files with 3017 additions and 174 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -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 连接 GatewayNode 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 } = {}) {

View File

@@ -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(())
}

View 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="关闭">&times;</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()
}

View File

@@ -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="关闭菜单">&times;</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, '&lt;').replace(/>/g, '&gt;') }
// === 移动端侧边栏 ===
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')

View File

@@ -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
View 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)
}

View File

@@ -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 }) },

View File

@@ -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="关闭提示">&times;</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

View File

@@ -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') {

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { tryShowEngagement } from '../components/engagement.js'
// 兼容新版 SecretReftoken 可能是 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')
}

View File

@@ -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 || {}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);