diff --git a/docs/WebUI-MCP-使用说明.md b/docs/WebUI-MCP-使用说明.md index 5a8d52f..f6552ab 100644 --- a/docs/WebUI-MCP-使用说明.md +++ b/docs/WebUI-MCP-使用说明.md @@ -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 客户端使用: diff --git a/scripts/windows/Install-WxAgent.ps1 b/scripts/windows/Install-WxAgent.ps1 new file mode 100644 index 0000000..628edb9 --- /dev/null +++ b/scripts/windows/Install-WxAgent.ps1 @@ -0,0 +1,120 @@ +[CmdletBinding()] +param( + [string]$InstallRoot = (Join-Path $env:USERPROFILE 'wx-agent'), + [string]$TaskName = 'WxAgent', + [switch]$Start, + [switch]$OpenBrowser, + [switch]$PreventAutoLock, + [string]$Token +) + +$ErrorActionPreference = 'Stop' +$exe = Join-Path $InstallRoot 'WxAgent.Host.exe' +$config = Join-Path $InstallRoot 'service.json' +$credentials = Join-Path $InstallRoot 'credentials.json' +$data = Join-Path $InstallRoot 'data' +$url = 'http://127.0.0.1:5088/' + +if (-not (Test-Path $exe)) { throw "WxAgent.Host.exe was not found under $InstallRoot. Publish/copy the application first." } +if ($TaskName -notmatch '^[A-Za-z0-9 ._-]{1,64}$') { throw 'TaskName contains unsupported characters.' } +New-Item -ItemType Directory -Force -Path $data | Out-Null + +if ($PreventAutoLock) { + powercfg /change monitor-timeout-ac 0 + powercfg /change standby-timeout-ac 0 + powercfg /change hibernate-timeout-ac 0 + powercfg /change monitor-timeout-dc 0 + powercfg /change standby-timeout-dc 0 + powercfg /change hibernate-timeout-dc 0 + powercfg /setacvalueindex SCHEME_CURRENT SUB_NONE CONSOLELOCK 0 + powercfg /setdcvalueindex SCHEME_CURRENT SUB_NONE CONSOLELOCK 0 + powercfg /setactive SCHEME_CURRENT + New-Item -Path 'HKCU:\Control Panel\Desktop' -Force | Out-Null + Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value 0 + Set-ItemProperty -Path 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeout -Value 0 + New-Item -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' -Force | Out-Null + Set-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' -Name NoLockScreen -Type DWord -Value 1 +} + +function Write-Utf8Json([string]$Path, $Value) { + $utf8 = New-Object System.Text.UTF8Encoding($false) + [IO.File]::WriteAllText($Path, ($Value | ConvertTo-Json -Depth 8), $utf8) +} + +function To-Hex([byte[]]$Bytes) { [BitConverter]::ToString($Bytes).Replace('-', '') } + +$generatedToken = $null +if (-not (Test-Path $credentials)) { + if ([string]::IsNullOrWhiteSpace($Token)) { + $bytes = New-Object byte[] 32 + $rng = [Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } + $generatedToken = (To-Hex $bytes).ToLowerInvariant() + $Token = $generatedToken + } + $sha = [Security.Cryptography.SHA256]::Create() + try { $hash = To-Hex ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Token))).ToUpperInvariant() } + finally { $sha.Dispose() } + Write-Utf8Json $credentials @(@{ + PrincipalId = 'local-admin' + TokenSha256 = $hash + Permissions = @('read', 'content', 'manage') + AccountIds = @() + }) +} + +if (-not (Test-Path $config)) { + Write-Utf8Json $config @{ + ListenUrl = 'http://127.0.0.1:5088' + AllowExternal = $false + AllowedHosts = @('127.0.0.1:5088', 'localhost:5088', '[::1]:5088') + AllowedOrigins = @('http://127.0.0.1:5088', 'http://localhost:5088', 'http://[::1]:5088') + CredentialFile = $credentials + DataDirectory = $data + QueueCapacity = 100 + ListenerSession = '文件传输助手' + EnableListenerEvents = $false + } +} + +$user = $env:USERNAME +$action = New-ScheduledTaskAction -Execute $exe -Argument ('serve --config "{0}"' -f $config) -WorkingDirectory $InstallRoot +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user +$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null + +$desktop = [Environment]::GetFolderPath('Desktop') +$shell = New-Object -ComObject WScript.Shell +$startShortcut = Join-Path $desktop 'WxAgent-Start.lnk' +$link = $shell.CreateShortcut($startShortcut) +$link.TargetPath = Join-Path $env:SystemRoot 'System32\schtasks.exe' +$link.Arguments = "/run /tn `"$TaskName`"" +$link.WorkingDirectory = $InstallRoot +$link.Description = 'Start WxAgent Desktop Agent' +$link.Save() + +$openShortcut = Join-Path $desktop 'WxAgent.lnk' +$link = $shell.CreateShortcut($openShortcut) +$link.TargetPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +$command = "Start-ScheduledTask -TaskName '$TaskName'; Start-Sleep -Seconds 2; Start-Process '$url'" +$link.Arguments = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -Command `"$command`"" +$link.WorkingDirectory = $InstallRoot +$link.Description = 'Start WxAgent and open the web console' +$link.Save() + +$urlShortcut = Join-Path $desktop 'WxAgent-Web.url' +[IO.File]::WriteAllText($urlShortcut, "[InternetShortcut]`r`nURL=$url`r`n", [Text.Encoding]::ASCII) + +if ($Start) { Start-ScheduledTask -TaskName $TaskName } +if ($OpenBrowser) { Start-Process $url } + +[pscustomobject]@{ + TaskName = $TaskName + InstallRoot = $InstallRoot + Url = $url + Started = [bool]$Start + PreventAutoLock = [bool]$PreventAutoLock + TokenGenerated = [bool]$generatedToken + Token = $generatedToken +} | Format-List diff --git a/src/WxAgent.Host/WxAgent.Host.csproj b/src/WxAgent.Host/WxAgent.Host.csproj index 8cd4719..adc42e4 100644 --- a/src/WxAgent.Host/WxAgent.Host.csproj +++ b/src/WxAgent.Host/WxAgent.Host.csproj @@ -16,5 +16,6 @@ + diff --git a/src/WxAgent.Service/wwwroot/app.js b/src/WxAgent.Service/wwwroot/app.js index 7b33364..e50b6ad 100644 --- a/src/WxAgent.Service/wwwroot/app.js +++ b/src/WxAgent.Service/wwwroot/app.js @@ -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'; } +}; diff --git a/src/WxAgent.Service/wwwroot/index.html b/src/WxAgent.Service/wwwroot/index.html index 0b68c57..1bb38d4 100644 --- a/src/WxAgent.Service/wwwroot/index.html +++ b/src/WxAgent.Service/wwwroot/index.html @@ -1,6 +1,57 @@ -WxAgent -

WxAgent 控制台

HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。

-

登录

-
+ + + + + WxAgent 控制台 + + + +
+
+

DESKTOP AGENT

WxAgent 控制台

安全查看微信状态、账号绑定和只读数据。

+ 未连接 +
+

HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。

+ +
+

登录控制台

+

Token 只在当前浏览器会话使用,不会保存到页面或 URL。

+ +
+ + +
+ + + diff --git a/src/WxAgent.Service/wwwroot/styles.css b/src/WxAgent.Service/wwwroot/styles.css index 10cac92..4deee62 100644 --- a/src/WxAgent.Service/wwwroot/styles.css +++ b/src/WxAgent.Service/wwwroot/styles.css @@ -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}}