feat: improve web console and Windows startup

This commit is contained in:
2026-09-08 03:22:00 +08:00
parent da9f0a4549
commit 7e3c41d1e5
6 changed files with 318 additions and 22 deletions
+8 -1
View File
@@ -17,7 +17,14 @@
WxAgent.Host.exe serve --config C:\Users\USERNAME\wx-agent\service.json
```
默认只监听 `127.0.0.1:5088`。外部监听必须同时设置 `AllowExternal=true`、明确 IP/端口和精确 `AllowedHosts`/`AllowedOrigins`;自行配置防火墙,服务不会自动开放端口。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。
需要安装为当前用户登录后自动启动、创建桌面快捷方式并打开控制台时,使用发布目录中的脚本:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\scripts\windows\Install-WxAgent.ps1 -InstallRoot C:\Users\USERNAME\wx-agent -Start -OpenBrowser -PreventAutoLock
```
脚本不会覆盖已有 `credentials.json` 或 `service.json`;首次安装且没有凭据时生成一次本地管理 Token,仅在终端显示、不写入明文文件。`-PreventAutoLock` 会关闭当前电源计划的自动息屏/睡眠/休眠和屏保,并设置 Windows 锁屏策略;不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`。外部监听必须同时设置 `AllowExternal=true`、明确 IP/端口和精确 `AllowedHosts`/`AllowedOrigins`;自行配置防火墙,服务不会自动开放端口。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。
浏览器访问 `/`,输入 Token 登录。HTTP/MCP 客户端使用:
Binary file not shown.
+1
View File
@@ -16,5 +16,6 @@
<ItemGroup>
<ProjectReference Include="..\WxAgent.Windows\WxAgent.Windows.csproj" />
<ProjectReference Include="..\WxAgent.Service\WxAgent.Service.csproj" />
<None Include="..\..\scripts\windows\Install-WxAgent.ps1" Link="Install-WxAgent.ps1" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" />
</ItemGroup>
</Project>
+252 -15
View File
@@ -1,16 +1,253 @@
const $=id=>document.getElementById(id);let csrf='';
function showError(e){$('error').textContent=e.message||'请求失败'}
async function api(path,init={}){const r=await fetch(path,{...init,headers:{'Accept':'application/json',...(init.headers||{})}});if(!r.ok){let x={};try{x=await r.json()}catch{}if(r.status===401){$('app').hidden=true;$('login').hidden=false}throw new Error(x.error?.message||`HTTP ${r.status}`)}return r.status===204?null:r.json()}
async function mutate(path,body){return api(path,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf,'Origin':location.origin},body:JSON.stringify(body)})}
function rows(id,items,format){$(id).replaceChildren(...items.map(x=>{const d=document.createElement('div');d.className='row';d.textContent=format(x);return d}))}
function renderBindings(accounts,targets){
const accountById=new Map(accounts.map(x=>[x.accountId,x]));
const accountRows=accounts.map(account=>{const d=document.createElement('div');d.className='row';d.textContent=`${account.accountId} ${account.bindingStatus|| (account.binding?'Bound':'Unbound')} ${account.binding?`进程 ${account.binding.processId} 窗口 ${account.binding.windowHandle}`:''}`;if(account.binding){const b=document.createElement('button');b.textContent='解绑';b.onclick=async()=>{try{await mutate('/api/v1/accounts/unbind',{accountId:account.accountId});await refresh()}catch(e){showError(e)}};d.append(' ',b)}return d});
$('accounts').replaceChildren(...accountRows);
const targetRows=targets.map(target=>{const d=document.createElement('div');d.className='row';d.textContent=`${target.targetId} ${target.wechatId||target.nickname||'[身份未读取]'}(PID ${target.processId} / HWND ${target.windowHandle})`;if(target.isBound){d.append(' 已绑定')}else{const select=document.createElement('select');select.append(new Option('选择账号',''),...accounts.filter(x=>!x.binding).map(x=>new Option(x.accountId,x.accountId)));const b=document.createElement('button');b.textContent='绑定';b.onclick=async()=>{if(!select.value)return;try{await mutate('/api/v1/accounts/bind',{accountId:select.value,targetId:target.targetId});await refresh()}catch(e){showError(e)}};d.append(' ',select,' ',b)}return d});
$('uiTargets').replaceChildren(...targetRows);
if(!accountById.size&&!targets.length){$('uiTargets').textContent='未发现当前交互式会话中的微信主窗口。'}
const $ = id => document.getElementById(id);
let csrf = '';
let refreshTimer = 0;
let busy = false;
function showError(error) {
$('error').textContent = error?.message || '请求失败';
}
async function refresh(){try{const [s,c,a,t]=await Promise.all([api('/api/v1/status'),api('/api/v1/capabilities'),api('/api/v1/accounts?limit=50'),api('/api/v1/ui-targets')]);$('status').textContent=JSON.stringify(s,null,2);rows('capabilities',c,x=>`${x.operation} — ${x.enabled?'可用':'禁用'}${x.disabledReason?`:${x.disabledReason}`:''}`);const select=$('accountSelect');const old=select.value;select.replaceChildren(new Option('选择已绑定账号',''),...a.items.filter(x=>x.binding).map(x=>new Option(x.accountId,x.accountId)));select.value=a.items.some(x=>x.accountId===old&&x.binding)?old:'';renderBindings(a.items,t);if(!select.value){rows('sessions',[] ,()=> '');rows('contacts',[] ,()=> '');rows('messages',[] ,()=> '');return}const id=`&accountId=${encodeURIComponent(select.value)}`;const [se,co,m]=await Promise.all([api(`/api/v1/sessions?limit=50${id}`),api(`/api/v1/contacts?limit=50${id}`),api(`/api/v1/messages?limit=50&includeContent=${$('content').checked}${id}`)]);rows('sessions',se.items,x=>`${x.name}${x.isCurrent?'(当前)':''}`);rows('contacts',co.items,x=>`${x.displayName||'[未知]'} — ${x.id}`);rows('messages',m.items,x=>`${x.type} ${x.sender||''}: ${x.content??x.summary??'[正文未授权]'}`)}catch(e){showError(e)}}
$('loginForm').addEventListener('submit',async e=>{e.preventDefault();try{const x=await api('/api/v1/login',{method:'POST',headers:{'Content-Type':'application/json','Origin':location.origin},body:JSON.stringify({token:$('token').value})});csrf=x.csrfToken;$('token').value='';$('login').hidden=true;$('app').hidden=false;await refresh()}catch(e){showError(e)}});
$('refresh').onclick=refresh;$('accountSelect').onchange=refresh;$('content').onchange=refresh;$('lookup').onclick=async()=>{try{$('operation').textContent=JSON.stringify(await api('/api/v1/operations/'+encodeURIComponent($('operationId').value)),null,2)}catch(e){showError(e)}};$('logout').onclick=async()=>{try{await api('/api/v1/logout',{method:'POST',headers:{'X-CSRF-Token':csrf,'Origin':location.origin}})}finally{$('app').hidden=true;$('login').hidden=false}};
function clearError() {
$('error').textContent = '';
}
async function api(path, init = {}) {
const response = await fetch(path, {
...init,
headers: { Accept: 'application/json', ...(init.headers || {}) }
});
if (!response.ok) {
let body = {};
try { body = await response.json(); } catch { /* response may not be JSON */ }
if (response.status === 401) {
$('app').hidden = true;
$('login').hidden = false;
$('connectionBadge').textContent = '未登录';
$('connectionBadge').className = 'badge neutral';
}
throw new Error(body.error?.message || `HTTP ${response.status}`);
}
return response.status === 204 ? null : response.json();
}
async function get(path) {
try { return { ok: true, value: await api(path) }; }
catch (error) { return { ok: false, error }; }
}
async function mutate(path, body) {
return api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, Origin: location.origin },
body: JSON.stringify(body)
});
}
function setList(id, items, format) {
const target = $(id);
target.replaceChildren(...items.map(item => {
const row = document.createElement('div');
row.className = 'row';
row.textContent = format(item);
return row;
}));
}
function setListMessage(id, message, error = false) {
const target = $(id);
const row = document.createElement('div');
row.className = error ? 'row error-row' : 'row';
row.textContent = message;
target.replaceChildren(row);
}
function value(result, fallback) {
return result.ok ? result.value : fallback;
}
function friendlyAccount(account) {
const binding = account.binding;
const identity = binding?.nickname || binding?.wechatId || account.displayName;
return identity ? `${identity} · ${account.bindingStatus || 'Unknown'}` : `数据库账号 ${account.accountId.slice(0, 10)}… · ${account.bindingStatus || 'Unknown'}`;
}
function renderStatus(result) {
if (!result.ok) {
$('connectionBadge').textContent = '状态读取失败';
$('connectionBadge').className = 'badge error';
return;
}
const status = result.value;
$('status').textContent = JSON.stringify(status, null, 2);
$('serviceState').textContent = status.serviceOnline ? '在线' : '离线';
$('wechatState').textContent = status.wechatAvailable && status.sessionAvailable ? '可用' : '不可用';
$('bindingState').textContent = status.activeAccountBound ? '已绑定' : '未绑定';
$('modeState').textContent = status.defaultReadOnly ? '只读' : '可写';
const healthy = status.serviceOnline && status.wechatAvailable && status.sessionAvailable;
$('connectionBadge').textContent = healthy ? '已连接' : '需检查';
$('connectionBadge').className = `badge ${healthy ? 'ok' : 'warn'}`;
}
function renderCapabilities(result) {
if (!result.ok) return setListMessage('capabilities', result.error.message, true);
setList('capabilities', result.value, item => `${item.operation} — ${item.enabled ? '可用' : '禁用'}${item.disabledReason ? `:${item.disabledReason}` : ''}`);
}
function renderBindings(accounts, targets) {
const accountRows = accounts.map(account => {
const row = document.createElement('div');
row.className = 'row';
const main = document.createElement('div');
main.className = 'row-main';
const title = document.createElement('div');
title.className = 'row-title';
title.textContent = friendlyAccount(account);
const meta = document.createElement('div');
meta.className = 'row-meta';
meta.textContent = account.binding
? `${account.binding.wechatId || '微信号未读取'} · PID ${account.binding.processId} · HWND ${account.binding.windowHandle}`
: `Fingerprint: ${account.accountId}`;
main.append(title, meta);
row.append(main);
if (account.binding) {
const unbind = document.createElement('button');
unbind.className = 'secondary';
unbind.textContent = '解绑';
unbind.onclick = async () => {
unbind.disabled = true;
try { await mutate('/api/v1/accounts/unbind', { accountId: account.accountId }); await refresh(); }
catch (error) { showError(error); unbind.disabled = false; }
};
row.append(unbind);
}
return row;
});
$('accounts').replaceChildren(...accountRows);
if (!accountRows.length) setListMessage('accounts', '暂无数据库账号。');
const targetRows = targets.map(target => {
const row = document.createElement('div');
row.className = 'row';
const main = document.createElement('div');
main.className = 'row-main';
const title = document.createElement('div');
title.className = 'row-title';
title.textContent = target.wechatId || target.nickname || '身份未读取';
const meta = document.createElement('div');
meta.className = 'row-meta';
meta.textContent = `${target.targetId} · PID ${target.processId} · HWND ${target.windowHandle}${target.isBound ? ' · 已绑定' : ''}`;
main.append(title, meta);
row.append(main);
if (!target.isBound) {
const select = document.createElement('select');
select.setAttribute('aria-label', `为 ${target.targetId} 选择数据库账号`);
select.append(new Option('选择账号', ''), ...accounts.filter(account => !account.binding).map(account => new Option(friendlyAccount(account), account.accountId)));
const bind = document.createElement('button');
bind.textContent = '绑定';
bind.onclick = async () => {
if (!select.value) { showError(new Error('请先选择数据库账号')); return; }
bind.disabled = true;
try { await mutate('/api/v1/accounts/bind', { accountId: select.value, targetId: target.targetId }); await refresh(); }
catch (error) { showError(error); bind.disabled = false; }
};
row.append(select, bind);
}
return row;
});
$('uiTargets').replaceChildren(...targetRows);
if (!targetRows.length) setListMessage('uiTargets', '未发现当前交互式会话中的微信主窗口。');
}
function populateAccounts(result) {
if (!result.ok) return setListMessage('accounts', result.error.message, true);
const accounts = result.value.items || [];
const select = $('accountSelect');
const previous = select.value;
select.replaceChildren(new Option('选择已绑定账号', ''), ...accounts.filter(account => account.binding).map(account => new Option(friendlyAccount(account), account.accountId)));
select.value = accounts.some(account => account.accountId === previous && account.binding) ? previous : '';
return accounts;
}
function renderScoped(result, id, format, emptyMessage) {
if (!result.ok) return setListMessage(id, result.error.message, true);
const items = result.value.items || [];
setList(id, items, format);
if (!items.length) setListMessage(id, emptyMessage);
}
async function refresh() {
if (busy) return;
busy = true;
clearError();
$('refresh').disabled = true;
$('refresh').textContent = '刷新中…';
try {
const [status, capabilities, accounts, targets] = await Promise.all([
get('/api/v1/status'), get('/api/v1/capabilities'), get('/api/v1/accounts?limit=50'), get('/api/v1/ui-targets')
]);
renderStatus(status);
renderCapabilities(capabilities);
const accountItems = populateAccounts(accounts) || [];
renderBindings(accountItems, value(targets, []));
const accountId = $('accountSelect').value;
if (!accountId) {
const message = '选择已绑定账号后加载数据。';
setListMessage('sessions', message); setListMessage('contacts', message); setListMessage('messages', message);
return;
}
const query = `&accountId=${encodeURIComponent(accountId)}`;
const [sessions, contacts, messages] = await Promise.all([
get(`/api/v1/sessions?limit=50${query}`),
get(`/api/v1/contacts?limit=50${query}`),
get(`/api/v1/messages?limit=50&includeContent=${$('content').checked}${query}`)
]);
renderScoped(sessions, 'sessions', item => `${item.name}${item.isCurrent ? '(当前)' : ''}`, '暂无可见会话。');
renderScoped(contacts, 'contacts', item => `${item.displayName || '[未知]'} — ${item.id}`, '暂无联系人数据或当前凭据未授权。');
renderScoped(messages, 'messages', item => `${item.type} ${item.sender || ''}: ${item.content ?? item.summary ?? '[正文未授权]'}`, '暂无可见消息。');
} finally {
$('refresh').disabled = false;
$('refresh').textContent = '刷新';
$('lastUpdated').textContent = `更新于 ${new Date().toLocaleTimeString()}`;
busy = false;
}
}
function setAutoRefresh(enabled) {
if (refreshTimer) window.clearInterval(refreshTimer);
refreshTimer = enabled ? window.setInterval(() => { if (!document.hidden) refresh(); }, 15000) : 0;
localStorage.setItem('wxagent-auto-refresh', enabled ? '1' : '0');
}
$('loginForm').addEventListener('submit', async event => {
event.preventDefault();
const button = $('loginButton');
button.disabled = true;
button.textContent = '登录中…';
clearError();
try {
const response = await api('/api/v1/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: location.origin }, body: JSON.stringify({ token: $('token').value }) });
csrf = response.csrfToken;
$('token').value = '';
$('login').hidden = true;
$('app').hidden = false;
await refresh();
} catch (error) { showError(error); }
finally { button.disabled = false; button.textContent = '登录'; }
});
$('refresh').onclick = refresh;
$('accountSelect').onchange = refresh;
$('content').onchange = refresh;
$('autoRefresh').checked = localStorage.getItem('wxagent-auto-refresh') === '1';
$('autoRefresh').onchange = event => setAutoRefresh(event.target.checked);
if ($('autoRefresh').checked) setAutoRefresh(true);
$('lookup').onclick = async () => {
if (!$('operationId').value.trim()) { showError(new Error('请输入 Operation ID')); return; }
try { $('operation').textContent = JSON.stringify(await api(`/api/v1/operations/${encodeURIComponent($('operationId').value.trim())}`), null, 2); }
catch (error) { showError(error); }
};
$('logout').onclick = async () => {
try { await api('/api/v1/logout', { method: 'POST', headers: { 'X-CSRF-Token': csrf, Origin: location.origin } }); }
finally { if (refreshTimer) window.clearInterval(refreshTimer); $('app').hidden = true; $('login').hidden = false; $('connectionBadge').textContent = '未连接'; $('connectionBadge').className = 'badge neutral'; }
};
+56 -5
View File
@@ -1,6 +1,57 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>WxAgent</title><link rel="stylesheet" href="styles.css"></head>
<body><main><h1>WxAgent 控制台</h1><p class="warning">HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。</p>
<section id="login"><h2>登录</h2><form id="loginForm"><label>Token <input id="token" type="password" autocomplete="off" required></label><button>登录</button></form></section>
<section id="app" hidden><div class="toolbar"><button id="refresh">刷新状态</button><button id="logout">退出</button><label>当前账号 <select id="accountSelect"><option value="">选择账号</option></select></label></div><pre id="status" role="status"></pre>
<h2>能力</h2><div id="capabilities"></div><h2>账号</h2><div id="accounts"></div><h2>微信窗口 / 显式绑定</h2><div id="uiTargets"></div><h2>会话</h2><div id="sessions"></div><h2>联系人/群</h2><div id="contacts"></div><h2>任务</h2><label>Operation ID <input id="operationId" autocomplete="off"><button id="lookup">查询</button></label><pre id="operation"></pre><h2>可见消息</h2><label><input id="content" type="checkbox"> 显示正文(需要 content 权限)</label><div id="messages"></div><p id="error" class="error" role="alert"></p></section></main><script type="module" src="app.js"></script></body></html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WxAgent 控制台</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main>
<header class="hero">
<div><p class="eyebrow">DESKTOP AGENT</p><h1>WxAgent 控制台</h1><p class="subtitle">安全查看微信状态、账号绑定和只读数据。</p></div>
<span id="connectionBadge" class="badge neutral">未连接</span>
</header>
<p class="warning">HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。</p>
<section id="login" class="panel login-panel">
<h2>登录控制台</h2>
<p class="muted">Token 只在当前浏览器会话使用,不会保存到页面或 URL。</p>
<form id="loginForm" class="login-form">
<label for="token">访问 Token</label>
<div class="input-row"><input id="token" type="password" autocomplete="off" required placeholder="粘贴 Token"><button id="loginButton">登录</button></div>
</form>
</section>
<section id="app" class="app-shell" hidden>
<div class="toolbar panel">
<div class="toolbar-group"><button id="refresh">刷新</button><label class="toggle"><input id="autoRefresh" type="checkbox"> 自动刷新 <span class="muted">15 秒</span></label><span id="lastUpdated" class="muted">尚未刷新</span></div>
<div class="toolbar-group account-picker"><label for="accountSelect">当前账号</label><select id="accountSelect"><option value="">选择已绑定账号</option></select></div>
<button id="logout" class="secondary">退出</button>
</div>
<div class="status-grid" aria-live="polite">
<div class="metric panel"><span>服务</span><strong id="serviceState">—</strong></div>
<div class="metric panel"><span>微信 UI</span><strong id="wechatState">—</strong></div>
<div class="metric panel"><span>当前绑定</span><strong id="bindingState">—</strong></div>
<div class="metric panel"><span>默认模式</span><strong id="modeState">只读</strong></div>
</div>
<p id="error" class="error" role="alert" aria-live="assertive"></p>
<details class="panel details"><summary>诊断详情</summary><pre id="status" role="status"></pre></details>
<section class="panel"><div class="section-heading"><h2>能力</h2><span class="muted">按当前凭据显示</span></div><div id="capabilities" class="list"></div></section>
<div class="dashboard-grid">
<section class="panel"><div class="section-heading"><h2>账号绑定</h2><span class="muted">数据库账号 ↔ 微信窗口</span></div><div id="accounts" class="list"></div></section>
<section class="panel"><div class="section-heading"><h2>微信窗口</h2><span class="muted">显式选择后绑定</span></div><div id="uiTargets" class="list"></div></section>
</div>
<section class="panel"><div class="section-heading"><h2>会话</h2><span class="muted">需要选择已绑定账号</span></div><div id="sessions" class="list"></div></section>
<section class="panel"><div class="section-heading"><h2>联系人 / 群</h2><span class="muted">只读数据库数据</span></div><div id="contacts" class="list"></div></section>
<section class="panel"><div class="section-heading"><h2>可见消息</h2><label class="toggle"><input id="content" type="checkbox"> 显示正文 <span class="muted">需要 content 权限</span></label></div><div id="messages" class="list"></div></section>
<section class="panel"><div class="section-heading"><h2>任务查询</h2><span class="muted">粘贴 Operation ID</span></div><div class="input-row"><input id="operationId" autocomplete="off" placeholder="Operation ID"><button id="lookup">查询</button></div><pre id="operation"></pre></section>
</section>
</main>
<script type="module" src="app.js"></script>
</body>
</html>
+1 -1
View File
@@ -1 +1 @@
*{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#18202a;font:16px system-ui,sans-serif}main{max-width:1100px;margin:auto;padding:24px}section{background:white;border:1px solid #d9dee7;border-radius:8px;padding:18px;margin:16px 0}label{display:block;margin:10px 0}input{max-width:100%;padding:9px;border:1px solid #9aa6b2;border-radius:4px}button{padding:9px 14px;margin:5px;border:0;border-radius:4px;background:#1459c7;color:white;cursor:pointer}button:focus,input:focus{outline:3px solid #ffbf47;outline-offset:2px}.warning{padding:12px;background:#fff3cd;border:1px solid #e0b400}.error{color:#a00;min-height:1.4em}pre{white-space:pre-wrap;overflow:auto;background:#111827;color:#e5e7eb;padding:12px;border-radius:4px}.row{padding:8px;border-bottom:1px solid #eee}.disabled{color:#777}h1{margin-top:0}@media(max-width:600px){main{padding:12px}}
:root{color-scheme:light;--bg:#f4f7fb;--panel:#fff;--ink:#182230;--muted:#64748b;--line:#dbe3ee;--blue:#155eef;--blue-dark:#0d47b5;--danger:#b42318;--success:#067647;--warning:#8a5b00}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}main{max-width:1240px;margin:auto;padding:28px 20px 60px}.hero{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.eyebrow{margin:0;color:var(--blue);font-size:12px;font-weight:800;letter-spacing:.12em}.hero h1{margin:2px 0 4px;font-size:30px;letter-spacing:-.02em}.subtitle{margin:0;color:var(--muted)}.badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:13px;font-weight:700;white-space:nowrap}.badge:before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}.badge.ok{color:var(--success);background:#ecfdf3}.badge.warn{color:var(--warning);background:#fffaeb}.badge.error{color:var(--danger);background:#fef3f2}.badge.neutral{color:var(--muted);background:#eef2f6}.warning{margin:0 0 18px;padding:11px 14px;color:var(--warning);background:#fffaeb;border:1px solid #f5d78e;border-radius:10px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:0 3px 12px #172b4d0b}.login-panel{max-width:620px;padding:24px}.app-shell{display:grid;gap:16px}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 14px;position:sticky;top:10px;z-index:2}.toolbar-group{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.account-picker{min-width:310px}.account-picker label{margin:0;white-space:nowrap;font-weight:700}.input-row{display:flex;align-items:center;gap:10px}.input-row input{flex:1;min-width:0}.login-form label{display:block;margin:0 0 6px;font-weight:700}input,select{width:100%;min-height:40px;padding:9px 11px;border:1px solid #a8b5c5;border-radius:8px;background:#fff;color:var(--ink);font:inherit}button{min-height:40px;padding:8px 15px;border:1px solid var(--blue);border-radius:8px;background:var(--blue);color:#fff;font:inherit;font-weight:700;cursor:pointer;transition:background .15s,transform .15s}button:hover{background:var(--blue-dark)}button:active{transform:translateY(1px)}button:disabled{opacity:.55;cursor:wait}.secondary{border-color:var(--line);background:#fff;color:var(--ink)}.secondary:hover{background:#f8fafc}.toggle{display:inline-flex;align-items:center;gap:7px;margin:0;color:var(--ink);white-space:nowrap}.toggle input{width:16px;min-height:16px;margin:0}.muted{color:var(--muted);font-size:13px}.status-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.metric{padding:16px 18px}.metric span{display:block;color:var(--muted);font-size:13px}.metric strong{display:block;margin-top:3px;font-size:21px}.dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.panel:not(.toolbar){padding:18px}.section-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px}.section-heading h2{margin:0;font-size:18px}.list{min-height:40px}.row{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid #edf1f5;overflow-wrap:anywhere}.row:last-child{border-bottom:0}.row-main{flex:1;min-width:0}.row-title{font-weight:700}.row-meta{color:var(--muted);font-size:12px}.row button{min-height:34px;padding:5px 10px;font-size:13px}.row select{width:auto;min-width:170px;min-height:34px;padding:5px 8px;font-size:13px}.error{min-height:0;margin:0;color:var(--danger);font-weight:600}.error:not(:empty){padding:10px 12px;background:#fef3f2;border:1px solid #fecdca;border-radius:8px}.error-row{color:var(--danger);background:#fff8f7;border-radius:7px;padding:10px}.details summary{cursor:pointer;font-weight:700;color:#334155}.details pre{margin:12px 0 0}pre{white-space:pre-wrap;overflow:auto;background:#111827;color:#e5e7eb;padding:12px;border-radius:8px;font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace}#operation:empty{display:none}@media(max-width:850px){main{padding:18px 12px 40px}.toolbar{position:static;align-items:flex-start;flex-direction:column}.account-picker{width:100%;min-width:0}.status-grid{grid-template-columns:repeat(2,1fr)}.dashboard-grid{grid-template-columns:1fr}}@media(max-width:520px){.hero{display:block}.hero .badge{margin-top:12px}.status-grid{grid-template-columns:1fr 1fr}.input-row{align-items:stretch}.input-row button{flex:none}.section-heading{align-items:flex-start;flex-direction:column}.row{align-items:flex-start;flex-wrap:wrap}.row select{flex:1;min-width:150px}}