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。
+
+
+
+
+
+
+
+
服务—
+
微信 UI—
+
当前绑定—
+
默认模式只读
+
+
+
+ 诊断详情
+
+
+
+
+
+
+
+
+
+
+
+
+
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}}