client: Windows 客户端(WinUI3 壳 + agent-core;调试用 Electron 壳存档);localserver token 改为常数时间比较
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
const fs = require('fs');
|
||||
const p = require('path');
|
||||
const png = fs.readFileSync(p.join(__dirname, 'icons', 'app.png'));
|
||||
const hdr = Buffer.alloc(6);
|
||||
hdr.writeUInt16LE(1, 2);
|
||||
hdr.writeUInt16LE(1, 4);
|
||||
const ent = Buffer.alloc(16);
|
||||
ent.writeUInt16LE(1, 4);
|
||||
ent.writeUInt16LE(32, 6);
|
||||
ent.writeUInt32LE(png.length, 8);
|
||||
ent.writeUInt32LE(22, 12);
|
||||
fs.writeFileSync(p.join(__dirname, 'icons', 'app.ico'), Buffer.concat([hdr, ent, png]));
|
||||
console.log('app.ico bytes:', 22 + png.length);
|
||||
@@ -0,0 +1,50 @@
|
||||
// 生成 icons/app.png(256x256 纯蓝方块图标)
|
||||
const zlib = require("zlib");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const W = 256, H = 256;
|
||||
const row = Buffer.alloc(1 + W * 4);
|
||||
row[0] = 0;
|
||||
for (let x = 0; x < W; x++) {
|
||||
row[1 + x * 4] = 0;
|
||||
row[2 + x * 4] = 110;
|
||||
row[3 + x * 4] = 255;
|
||||
row[4 + x * 4] = 255;
|
||||
}
|
||||
const raw = Buffer.concat(Array(H).fill(row));
|
||||
|
||||
const crcTable = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
crcTable[n] = c >>> 0;
|
||||
}
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length);
|
||||
const t = Buffer.from(type, "ascii");
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([t, data])));
|
||||
return Buffer.concat([len, t, data, crc]);
|
||||
}
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(W, 0);
|
||||
ihdr.writeUInt32BE(H, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
const png = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", zlib.deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
fs.mkdirSync(path.join(__dirname, "icons"), { recursive: true });
|
||||
fs.writeFileSync(path.join(__dirname, "icons", "app.png"), png);
|
||||
console.log("icon generated:", png.length, "bytes");
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 938 B |
Binary file not shown.
|
After Width: | Height: | Size: 916 B |
@@ -0,0 +1,177 @@
|
||||
const { app, BrowserWindow, Tray, Menu, ipcMain, shell, nativeImage } = require('electron');
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
|
||||
const isWin = process.platform === 'win32';
|
||||
const isSmoke = process.argv.includes('--smoke');
|
||||
const minimized = process.argv.includes('--minimized');
|
||||
|
||||
let win = null;
|
||||
let tray = null;
|
||||
let coreProc = null;
|
||||
let corePort = 0;
|
||||
let coreToken = '';
|
||||
let localJsonTimer = null;
|
||||
let quitting = false;
|
||||
|
||||
const dataDir = path.join(app.getPath('userData'), 'data');
|
||||
const coreBin = isWin ? 'agent-core.exe' : 'agent-core';
|
||||
const coreExe = app.isPackaged
|
||||
? path.join(process.resourcesPath, coreBin)
|
||||
: path.join(__dirname, '..', 'dist', coreBin);
|
||||
|
||||
function startCore() {
|
||||
if (!fs.existsSync(coreExe)) {
|
||||
console.error('[core] binary not found: ' + coreExe);
|
||||
win?.webContents.send('core-error', '核心程序缺失:' + coreExe);
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
try { coreProc?.kill(); } catch (e) { /* ignore */ }
|
||||
coreProc = spawn(coreExe, ['-dir', dataDir], { windowsHide: true });
|
||||
coreProc.on('exit', (code) => {
|
||||
coreProc = null;
|
||||
win?.webContents.send('core-exit', code);
|
||||
});
|
||||
pollLocalJson();
|
||||
}
|
||||
|
||||
function pollLocalJson() {
|
||||
if (localJsonTimer) clearInterval(localJsonTimer);
|
||||
localJsonTimer = setInterval(() => {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(path.join(dataDir, 'local.json'), 'utf8'));
|
||||
if (info.port && info.token) {
|
||||
corePort = info.port;
|
||||
coreToken = info.token;
|
||||
clearInterval(localJsonTimer);
|
||||
win?.webContents.send('core-ready');
|
||||
}
|
||||
} catch (e) { /* 核心尚未就绪 */ }
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function coreRequest(method, p, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!corePort || !coreToken) { reject(new Error('核心未就绪')); return; }
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const req = http.request({
|
||||
host: '127.0.0.1',
|
||||
port: corePort,
|
||||
path: p,
|
||||
method: method,
|
||||
headers: {
|
||||
'X-Agent-Token': coreToken,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': data ? Buffer.byteLength(data) : 0,
|
||||
},
|
||||
}, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', (c) => { raw += c; });
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(raw)); } catch (e) { resolve({ raw }); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(8000, () => { req.destroy(new Error('核心请求超时')); });
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- IPC(渲染进程 → 核心/系统) ----
|
||||
ipcMain.handle('core:status', () => coreRequest('GET', '/status'));
|
||||
ipcMain.handle('core:pair', (e, code, name) => coreRequest('POST', '/pair', { code, name }));
|
||||
ipcMain.handle('core:challenges', () => coreRequest('GET', '/challenges'));
|
||||
ipcMain.handle('core:solve', (e, id, value) => coreRequest('POST', '/challenges/' + encodeURIComponent(id), { value }));
|
||||
ipcMain.handle('core:get-config', () => readConfig());
|
||||
ipcMain.handle('core:save-config', (e, cfg) => { writeConfig(cfg); startCore(); return true; });
|
||||
ipcMain.handle('core:restart', () => { startCore(); return true; });
|
||||
ipcMain.handle('app:open-data-dir', () => shell.openPath(dataDir));
|
||||
ipcMain.handle('app:open-web', (e, url) => shell.openExternal(url));
|
||||
ipcMain.handle('app:set-autostart', (e, on) => {
|
||||
app.setLoginItemSettings({ openAtLogin: !!on });
|
||||
return app.getLoginItemSettings().openAtLogin;
|
||||
});
|
||||
ipcMain.handle('app:get-autostart', () => app.getLoginItemSettings().openAtLogin);
|
||||
ipcMain.handle('app:quit', () => { quitting = true; app.quit(); });
|
||||
|
||||
function readConfig() {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(dataDir, 'config.json'), 'utf8')); }
|
||||
catch (e) { return { server: '', fakeAll: false }; }
|
||||
}
|
||||
function writeConfig(cfg) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify(cfg, null, 2));
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
let img = nativeImage.createFromPath(path.join(__dirname, 'icons', 'app.png'));
|
||||
if (img.isEmpty()) img = nativeImage.createEmpty();
|
||||
tray = new Tray(img);
|
||||
tray.setToolTip('EveryPublish 客户端');
|
||||
tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: '打开主窗口', click: showWin },
|
||||
{ type: 'separator' },
|
||||
{ label: '退出', click: () => { quitting = true; app.quit(); } },
|
||||
]));
|
||||
tray.on('click', showWin);
|
||||
}
|
||||
|
||||
function showWin() {
|
||||
if (win) { win.show(); win.focus(); }
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 980, height: 660,
|
||||
minWidth: 780, minHeight: 540,
|
||||
title: 'EveryPublish 客户端',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
||||
win.on('close', (e) => {
|
||||
if (!quitting) { e.preventDefault(); win.hide(); }
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.setAppUserModelId('com.everypublish.client');
|
||||
if (isSmoke) {
|
||||
// 冒烟模式:启动 core → 等就绪 → 查状态 → 退出(无窗口)
|
||||
runSmoke();
|
||||
return;
|
||||
}
|
||||
createTray();
|
||||
createWindow();
|
||||
if (minimized) win?.hide();
|
||||
startCore();
|
||||
});
|
||||
|
||||
async function runSmoke() {
|
||||
startCore();
|
||||
const deadline = Date.now() + 30000;
|
||||
while (!coreToken && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
if (!coreToken) { console.log('SMOKE_RESULT ' + JSON.stringify({ error: 'core 未就绪' })); app.exit(1); return; }
|
||||
try {
|
||||
const status = await coreRequest('GET', '/status');
|
||||
console.log('SMOKE_RESULT ' + JSON.stringify(status));
|
||||
app.exit(0);
|
||||
} catch (e) {
|
||||
console.log('SMOKE_RESULT ' + JSON.stringify({ error: String(e) }));
|
||||
app.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
app.on('before-quit', () => { quitting = true; try { coreProc?.kill(); } catch (e) { /* ignore */ } });
|
||||
app.on('window-all-closed', () => { /* 驻留托盘不退出 */ });
|
||||
app.on('activate', showWin);
|
||||
Generated
+5285
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "everypublish-client",
|
||||
"version": "0.2.0",
|
||||
"description": "EveryPublish Windows 客户端(Electron 壳 + Go agent-core)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"gen-icon": "node gen-icon.js",
|
||||
"dist:win": "electron-builder --win nsis --x64"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^31.7.7",
|
||||
"electron-builder": "^25.1.8"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.everypublish.client",
|
||||
"productName": "EveryPublish",
|
||||
"directories": { "output": "release" },
|
||||
"files": ["main.js", "preload.js", "renderer/**/*", "icons/**/*"],
|
||||
"extraResources": [
|
||||
{ "from": "../dist/agent-core.exe", "to": "agent-core.exe" }
|
||||
],
|
||||
"win": {
|
||||
"target": [{ "target": "nsis", "arch": ["x64"] }],
|
||||
"icon": "icons/app.ico",
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
status: () => ipcRenderer.invoke('core:status'),
|
||||
pair: (code, name) => ipcRenderer.invoke('core:pair', code, name),
|
||||
challenges: () => ipcRenderer.invoke('core:challenges'),
|
||||
solve: (id, value) => ipcRenderer.invoke('core:solve', id, value),
|
||||
getConfig: () => ipcRenderer.invoke('core:get-config'),
|
||||
saveConfig: (cfg) => ipcRenderer.invoke('core:save-config', cfg),
|
||||
restart: () => ipcRenderer.invoke('core:restart'),
|
||||
openDataDir: () => ipcRenderer.invoke('app:open-data-dir'),
|
||||
openWeb: (url) => ipcRenderer.invoke('app:open-web', url),
|
||||
setAutoStart: (on) => ipcRenderer.invoke('app:set-autostart', on),
|
||||
getAutoStart: () => ipcRenderer.invoke('app:get-autostart'),
|
||||
quit: () => ipcRenderer.invoke('app:quit'),
|
||||
onCoreReady: (cb) => ipcRenderer.on('core-ready', cb),
|
||||
onCoreError: (cb) => ipcRenderer.on('core-error', cb),
|
||||
onCoreExit: (cb) => ipcRenderer.on('core-exit', cb),
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
// EveryPublish 客户端渲染进程逻辑
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ---- 页签切换 ----
|
||||
document.querySelectorAll('.tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
||||
document.querySelectorAll('.page').forEach((p) => p.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
$('page-' + tab.dataset.tab).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 状态页 ----
|
||||
let currentServer = '';
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const s = await window.api.status();
|
||||
if (s && s.server) currentServer = s.server;
|
||||
$('st-server').textContent = (s && s.server) || '-';
|
||||
$('st-device').textContent = s && s.deviceId > 0 ? '#' + s.deviceId + '(' + (s.name || '') + ')' : '未配对';
|
||||
$('st-session').textContent = (s && s.session) || '-';
|
||||
const dot = $('st-dot');
|
||||
if (!s) { $('st-online').textContent = '核心未就绪'; dot.className = 'dot gray'; }
|
||||
else if (s.online) { $('st-online').textContent = '在线'; dot.className = 'dot green'; }
|
||||
else { $('st-online').textContent = s.deviceId > 0 ? '离线(自动重连中)' : '未配对'; dot.className = 'dot orange'; }
|
||||
} catch (e) {
|
||||
$('st-online').textContent = '核心未就绪';
|
||||
$('st-dot').className = 'dot gray';
|
||||
}
|
||||
}
|
||||
setInterval(refreshStatus, 3000);
|
||||
refreshStatus();
|
||||
|
||||
$('btn-open-web').addEventListener('click', () => {
|
||||
const url = currentServer || 'http://127.0.0.1:8090';
|
||||
window.api.openWeb(url);
|
||||
});
|
||||
$('btn-open-data').addEventListener('click', () => window.api.openDataDir());
|
||||
|
||||
// ---- 配对页 ----
|
||||
$('btn-pair').addEventListener('click', async () => {
|
||||
const code = $('pair-code').value.trim();
|
||||
const name = $('pair-name').value.trim();
|
||||
const res = $('pair-result');
|
||||
if (!code) { res.textContent = '请输入配对码'; res.className = 'msg err'; return; }
|
||||
$('btn-pair').disabled = true;
|
||||
res.textContent = '配对中...';
|
||||
res.className = 'msg';
|
||||
try {
|
||||
const r = await window.api.pair(code, name);
|
||||
if (r && r.code === 0) {
|
||||
res.textContent = '配对成功,正在连接服务器(可到「状态」页查看)';
|
||||
res.className = 'msg ok';
|
||||
} else {
|
||||
res.textContent = (r && r.message) || '配对失败';
|
||||
res.className = 'msg err';
|
||||
}
|
||||
} catch (e) {
|
||||
res.textContent = '配对失败:' + e;
|
||||
res.className = 'msg err';
|
||||
}
|
||||
$('btn-pair').disabled = false;
|
||||
});
|
||||
|
||||
// ---- 挑战页 ----
|
||||
let selectedChallenge = null;
|
||||
let pollTimer = null;
|
||||
const KIND_LABEL = { qr: '扫码', sms: '短信验证码', confirm: 'APP 确认', captcha: '图形验证码', pending: '等待设备' };
|
||||
|
||||
async function refreshChallenges() {
|
||||
try {
|
||||
const r = await window.api.challenges();
|
||||
const list = (r && r.list) || [];
|
||||
const badge = $('ch-badge');
|
||||
if (list.length > 0) { badge.textContent = list.length; badge.classList.remove('hidden'); }
|
||||
else badge.classList.add('hidden');
|
||||
const box = $('ch-list');
|
||||
box.innerHTML = '';
|
||||
list.forEach((ch) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'item' + (selectedChallenge && selectedChallenge.challengeId === ch.challengeId ? ' selected' : '');
|
||||
const inner = [
|
||||
'<span><b>',
|
||||
ch.platform || '?',
|
||||
'</b> · ',
|
||||
KIND_LABEL[ch.kind] || ch.kind,
|
||||
'</span><span class="p">',
|
||||
ch.prompt || '',
|
||||
'</span>',
|
||||
].join('');
|
||||
div.innerHTML = inner;
|
||||
div.addEventListener('click', () => selectChallenge(ch));
|
||||
box.appendChild(div);
|
||||
});
|
||||
if (!list.length) {
|
||||
box.innerHTML = '<p class="hint">暂无待处理挑战</p>';
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function selectChallenge(ch) {
|
||||
selectedChallenge = ch;
|
||||
refreshChallenges();
|
||||
const detail = $('ch-detail');
|
||||
detail.classList.remove('hidden');
|
||||
const qr = ch.qrUrl || ch.qrToken || '';
|
||||
const img = $('ch-qr');
|
||||
if (qr && (qr.startsWith('http') || qr.startsWith('file://'))) {
|
||||
img.src = qr;
|
||||
img.classList.remove('hidden');
|
||||
} else {
|
||||
img.classList.add('hidden');
|
||||
}
|
||||
$('ch-value').value = '';
|
||||
$('ch-result').textContent = '';
|
||||
}
|
||||
|
||||
$('btn-solve').addEventListener('click', async () => {
|
||||
if (!selectedChallenge) return;
|
||||
const value = $('ch-value').value.trim() || 'confirmed';
|
||||
const res = $('ch-result');
|
||||
try {
|
||||
const r = await window.api.solve(selectedChallenge.challengeId, value);
|
||||
if (r && r.code === 0) {
|
||||
res.textContent = '已提交,等待服务器确认';
|
||||
res.className = 'msg ok';
|
||||
selectedChallenge = null;
|
||||
$('ch-detail').classList.add('hidden');
|
||||
refreshChallenges();
|
||||
} else {
|
||||
res.textContent = (r && r.message) || '提交失败';
|
||||
res.className = 'msg err';
|
||||
}
|
||||
} catch (e) {
|
||||
res.textContent = '提交失败:' + e;
|
||||
res.className = 'msg err';
|
||||
}
|
||||
});
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return;
|
||||
pollTimer = setInterval(refreshChallenges, 2000);
|
||||
refreshChallenges();
|
||||
}
|
||||
startPolling();
|
||||
|
||||
// ---- 设置页 ----
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const cfg = await window.api.getConfig();
|
||||
$('set-server').value = (cfg && cfg.server) || '';
|
||||
$('set-fake').checked = !!(cfg && cfg.fakeAll);
|
||||
$('set-autostart').checked = await window.api.getAutoStart();
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
loadSettings();
|
||||
|
||||
$('btn-save').addEventListener('click', async () => {
|
||||
const server = $('set-server').value.trim();
|
||||
const res = $('set-result');
|
||||
if (!server) { res.textContent = '请填写服务器地址(内网部署填 http://服务器IP:8090)'; res.className = 'msg err'; return; }
|
||||
const cfg = { server: server, fakeAll: $('set-fake').checked };
|
||||
await window.api.saveConfig(cfg);
|
||||
await window.api.setAutoStart($('set-autostart').checked);
|
||||
currentServer = server;
|
||||
res.textContent = '已保存,核心进程已重启';
|
||||
res.className = 'msg ok';
|
||||
});
|
||||
|
||||
$('btn-quit').addEventListener('click', () => window.api.quit());
|
||||
|
||||
// 核心就绪提示
|
||||
if (window.api.onCoreReady) {
|
||||
window.api.onCoreReady(() => {
|
||||
$('st-online').textContent = '核心已就绪';
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>EveryPublish 客户端</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">EveryPublish <span class="sub">发布客户端</span></div>
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="status">状态</button>
|
||||
<button class="tab" data-tab="pair">配对</button>
|
||||
<button class="tab" data-tab="challenges">挑战<span id="ch-badge" class="badge hidden">0</span></button>
|
||||
<button class="tab" data-tab="settings">设置</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!-- 状态页 -->
|
||||
<section id="page-status" class="page active">
|
||||
<div class="card">
|
||||
<h2>运行状态</h2>
|
||||
<div class="kv"><span>连接状态</span><b id="st-online">检测中...</b><span id="st-dot" class="dot gray"></span></div>
|
||||
<div class="kv"><span>服务器</span><b id="st-server">-</b></div>
|
||||
<div class="kv"><span>设备</span><b id="st-device">-</b></div>
|
||||
<div class="kv"><span>会话</span><b id="st-session">-</b></div>
|
||||
</div>
|
||||
<div class="card actions">
|
||||
<button id="btn-open-web" class="btn primary">打开网页控制台</button>
|
||||
<button id="btn-open-data" class="btn">打开数据目录</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 配对页 -->
|
||||
<section id="page-pair" class="page">
|
||||
<div class="card">
|
||||
<h2>设备配对</h2>
|
||||
<ol class="steps">
|
||||
<li>打开网页控制台(需与服务器内网互通)</li>
|
||||
<li>登录后进入「设置 → Agent 设备」生成配对码</li>
|
||||
<li>在下方输入配对码并点击「配对」</li>
|
||||
</ol>
|
||||
<label>配对码(6 位,5 分钟有效)</label>
|
||||
<input id="pair-code" placeholder="例如:AB12CD" maxlength="6" />
|
||||
<label>设备名(选填)</label>
|
||||
<input id="pair-name" placeholder="例如:办公室电脑-01" />
|
||||
<button id="btn-pair" class="btn primary">配对</button>
|
||||
<p id="pair-result" class="msg"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 挑战页 -->
|
||||
<section id="page-challenges" class="page">
|
||||
<div class="card">
|
||||
<h2>二次验证挑战</h2>
|
||||
<p class="hint">平台要求扫码/验证码时在此处理;超时可在网页端一键重发。</p>
|
||||
<div id="ch-list" class="list"></div>
|
||||
<div id="ch-detail" class="detail hidden">
|
||||
<img id="ch-qr" alt="二维码" />
|
||||
<label>验证码 / 确认值</label>
|
||||
<input id="ch-value" placeholder="扫码后输入验证码;扫码挑战留空直接提交" />
|
||||
<button id="btn-solve" class="btn primary">提交</button>
|
||||
<p id="ch-result" class="msg"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 设置页 -->
|
||||
<section id="page-settings" class="page">
|
||||
<div class="card">
|
||||
<h2>设置</h2>
|
||||
<label>服务器地址(内网部署时填 http://服务器IP:8090)</label>
|
||||
<input id="set-server" placeholder="http://192.168.x.x:8090" />
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" id="set-fake" />
|
||||
<span>模拟执行(无真实账号的安装联调)</span>
|
||||
</label>
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" id="set-autostart" />
|
||||
<span>开机自启</span>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="btn-save" class="btn primary">保存并重启核心</button>
|
||||
<button id="btn-quit" class="btn danger">退出客户端</button>
|
||||
</div>
|
||||
<p id="set-result" class="msg"></p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f6f8; color: #1f2329; height: 100vh; display: flex; flex-direction: column; }
|
||||
.topbar { background: #fff; border-bottom: 1px solid #e5e7eb; padding: 0 24px; display: flex; align-items: center; height: 56px; gap: 32px; }
|
||||
.brand { font-size: 18px; font-weight: 700; color: #0a63f0; }
|
||||
.brand .sub { font-size: 13px; font-weight: 400; color: #86909c; margin-left: 6px; }
|
||||
.tabs { display: flex; gap: 8px; }
|
||||
.tab { border: none; background: none; padding: 8px 16px; font-size: 14px; cursor: pointer; border-radius: 6px; color: #4e5969; }
|
||||
.tab:hover { background: #f2f4f7; }
|
||||
.tab.active { background: #e8f1ff; color: #0a63f0; font-weight: 600; }
|
||||
.badge { display: inline-block; margin-left: 4px; background: #f53f3f; color: #fff; border-radius: 8px; font-size: 11px; padding: 0 6px; }
|
||||
.hidden { display: none !important; }
|
||||
#main { flex: 1; overflow-y: auto; padding: 24px; }
|
||||
.page { display: none; max-width: 680px; margin: 0 auto; }
|
||||
.page.active { display: block; }
|
||||
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin-bottom: 16px; }
|
||||
.card h2 { font-size: 16px; margin-bottom: 14px; }
|
||||
.kv { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px dashed #f0f1f3; font-size: 14px; }
|
||||
.kv span { color: #86909c; width: 80px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-left: 6px; }
|
||||
.dot.gray { background: #c9cdd4; } .dot.green { background: #00b42a; } .dot.orange { background: #ff7d00; } .dot.red { background: #f53f3f; }
|
||||
label { display: block; font-size: 13px; color: #4e5969; margin: 12px 0 6px; }
|
||||
input[type="text"], input:not([type]) { width: 100%; padding: 9px 12px; border: 1px solid #d9dde3; border-radius: 6px; font-size: 14px; outline: none; }
|
||||
input:focus { border-color: #0a63f0; }
|
||||
.btn { padding: 9px 20px; border: 1px solid #d9dde3; border-radius: 6px; background: #fff; cursor: pointer; font-size: 14px; }
|
||||
.btn:hover { background: #f7f8fa; }
|
||||
.btn.primary { background: #0a63f0; border-color: #0a63f0; color: #fff; }
|
||||
.btn.primary:hover { background: #0b57d0; }
|
||||
.btn.danger { color: #f53f3f; border-color: #f53f3f; }
|
||||
.actions { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.msg { margin-top: 10px; font-size: 13px; color: #4e5969; min-height: 18px; }
|
||||
.msg.ok { color: #00b42a; } .msg.err { color: #f53f3f; }
|
||||
.hint { font-size: 13px; color: #86909c; margin-bottom: 10px; }
|
||||
.steps { margin: 0 0 14px 18px; font-size: 13px; color: #4e5969; line-height: 1.9; }
|
||||
.list .item { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: 8px; cursor: pointer; font-size: 13px; }
|
||||
.list .item:hover { border-color: #0a63f0; }
|
||||
.list .item.selected { border-color: #0a63f0; background: #f0f7ff; }
|
||||
.list .item .p { color: #86909c; }
|
||||
.detail { margin-top: 14px; }
|
||||
.detail img { max-width: 220px; max-height: 220px; border: 1px solid #e5e7eb; border-radius: 6px; display: block; margin-bottom: 10px; }
|
||||
.switch-row { display: flex; align-items: center; gap: 8px; font-size: 14px; margin: 10px 0; cursor: pointer; }
|
||||
Reference in New Issue
Block a user