orbit-codex-bridge 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/rpc.mjs ADDED
@@ -0,0 +1,203 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { spawn } from 'node:child_process';
3
+ import { createInterface } from 'node:readline';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import WebSocket from 'ws';
7
+
8
+ export function findCodexBinary() {
9
+ if (process.env.ORBIT_CODEX_BIN) return process.env.ORBIT_CODEX_BIN;
10
+ if (process.platform !== 'win32') return 'codex';
11
+ const base = path.join(process.env.LOCALAPPDATA || '', 'OpenAI', 'Codex', 'bin');
12
+ try {
13
+ const candidates = fs.readdirSync(base).map(folder => path.join(base, folder, 'codex.exe')).filter(file => fs.existsSync(file));
14
+ candidates.sort((left, right) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs);
15
+ if (candidates.length) return candidates[0];
16
+ } catch {}
17
+ throw new Error('没有找到 Codex 原生可执行文件,请设置 ORBIT_CODEX_BIN。');
18
+ }
19
+
20
+ export class CodexRpc extends EventEmitter {
21
+ constructor({ home, endpoint = '', tokenFile = '' }) {
22
+ super();
23
+ this.home = home;
24
+ this.endpoint = endpoint;
25
+ this.tokenFile = tokenFile;
26
+ this.pending = new Map();
27
+ this.approvals = new Map();
28
+ this.threads = new Map();
29
+ this.starting = new Set();
30
+ this.sequence = 0;
31
+ this.connected = false;
32
+ this.connecting = null;
33
+ this.transport = null;
34
+ this.child = null;
35
+ }
36
+
37
+ async connect() {
38
+ if (this.connected) return;
39
+ if (this.connecting) return this.connecting;
40
+ this.connecting = this.open().catch(error => { this.disconnect(error); throw error; }).finally(() => { this.connecting = null; });
41
+ return this.connecting;
42
+ }
43
+
44
+ async open() {
45
+ if (this.endpoint) {
46
+ const url = new URL(this.endpoint);
47
+ if (url.username || url.password || url.search || url.hash) throw new Error('App Server 地址不能携带凭据或查询参数。');
48
+ if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && ['127.0.0.1','localhost','[::1]'].includes(url.hostname))) throw new Error('远程 App Server 必须使用 WSS。');
49
+ const headers = {};
50
+ if (this.tokenFile) headers.Authorization = `Bearer ${fs.readFileSync(this.tokenFile, 'utf8').trim()}`;
51
+ const socket = new WebSocket(url, { headers, maxPayload: 8 * 1024 * 1024, handshakeTimeout: 10000 });
52
+ this.transport = socket;
53
+ socket.on('close', () => { if (this.transport === socket) this.disconnect(new Error('App Server 连接中断;不会自动重放消息。')); });
54
+ socket.on('error', () => {});
55
+ await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
56
+ socket.on('message', raw => this.receive(raw.toString()));
57
+ } else {
58
+ const child = spawn(findCodexBinary(), ['app-server', '--listen', 'stdio://'], {
59
+ env: { ...process.env, CODEX_HOME: this.home }, windowsHide: true, stdio: ['pipe','pipe','pipe']
60
+ });
61
+ this.child = child;
62
+ this.transport = child.stdin;
63
+ createInterface({ input: child.stdout }).on('line', line => this.receive(line));
64
+ child.stderr.on('data', () => {});
65
+ child.stdin.on('error', error => { if (this.child === child) this.disconnect(error); });
66
+ child.on('error', error => { if (this.child === child) this.disconnect(error); });
67
+ child.on('exit', () => { if (this.child === child) this.disconnect(new Error('独立接续实例已停止。')); });
68
+ }
69
+ await this.request('initialize', { clientInfo: { name: 'orbit_local_bridge', title: 'Orbit', version: '0.1.0' }, capabilities: { experimentalApi: true } });
70
+ this.send({ method: 'initialized' });
71
+ this.connected = true;
72
+ this.emit('event', { method: 'orbit/engine', params: { connected: true, mode: this.endpoint ? 'endpoint' : 'cold-resume' } });
73
+ }
74
+
75
+ send(message) {
76
+ const encoded = JSON.stringify(message);
77
+ if (this.transport instanceof WebSocket) {
78
+ if (this.transport.readyState !== WebSocket.OPEN) throw new Error('App Server 尚未连接。');
79
+ this.transport.send(encoded);
80
+ } else {
81
+ if (!this.transport || this.transport.destroyed) throw new Error('App Server 尚未连接。');
82
+ this.transport.write(`${encoded}\n`);
83
+ }
84
+ }
85
+
86
+ request(method, params, timeout = 30000) {
87
+ const id = ++this.sequence;
88
+ return new Promise((resolve, reject) => {
89
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`${method} 超时;为避免重复执行,请先检查会话状态。`)); }, timeout);
90
+ this.pending.set(id, { resolve, reject, timer });
91
+ try { this.send({ id, method, params }); }
92
+ catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
93
+ });
94
+ }
95
+
96
+ receive(raw) {
97
+ let message;
98
+ try { message = JSON.parse(raw); } catch { return; }
99
+ if (message.id !== undefined && !message.method) {
100
+ const pending = this.pending.get(message.id);
101
+ if (!pending) return;
102
+ clearTimeout(pending.timer);
103
+ this.pending.delete(message.id);
104
+ if (message.error) pending.reject(new Error(message.error.message || 'App Server 请求失败。'));
105
+ else pending.resolve(message.result);
106
+ return;
107
+ }
108
+ if (message.id !== undefined && message.method) {
109
+ const supported = ['item/commandExecution/requestApproval','item/fileChange/requestApproval','item/tool/requestUserInput','item/permissions/requestApproval'];
110
+ if (!supported.includes(message.method) || !this.threads.has(message.params?.threadId)) {
111
+ this.send({ id: message.id, error: { code: -32601, message: 'This client does not implement the requested host tool.' } });
112
+ return;
113
+ }
114
+ this.approvals.set(String(message.id), message);
115
+ this.emit('event', { method: 'orbit/approval', params: { requestId: String(message.id), method: message.method, ...message.params } });
116
+ return;
117
+ }
118
+ if (message.method === 'serverRequest/resolved') this.approvals.delete(String(message.params?.requestId));
119
+ if (message.method === 'turn/started' && this.threads.has(message.params?.threadId)) this.threads.get(message.params.threadId).turnId = message.params.turn.id;
120
+ if (message.method === 'turn/completed' && this.threads.has(message.params?.threadId)) {
121
+ const thread = this.threads.get(message.params.threadId);
122
+ thread.lastCompletedTurnId = message.params.turn.id;
123
+ if (thread.turnId === message.params.turn.id) thread.turnId = null;
124
+ }
125
+ if (message.method) this.emit('event', message);
126
+ }
127
+
128
+ async resume(id, writable) {
129
+ await this.connect();
130
+ if (this.threads.has(id)) return;
131
+ const result = await this.request('thread/resume', { threadId: id, excludeTurns: true, sandbox: writable ? 'workspace-write' : 'read-only', approvalPolicy: 'on-request' });
132
+ if (result.thread.id !== id) throw new Error('返回的会话 ID 不一致,已拒绝发送消息。');
133
+ this.threads.set(id, { turnId: null, lastCompletedTurnId: null });
134
+ }
135
+
136
+ async startTurn(id, text, writable, settings = null) {
137
+ if (this.starting.has(id)) throw new Error('此会话正在发送,请勿并发提交。');
138
+ this.starting.add(id);
139
+ try {
140
+ await this.resume(id, writable);
141
+ const thread = this.threads.get(id);
142
+ if (thread.turnId) throw new Error('此会话仍在执行,请等待完成或停止后再发送。');
143
+ const result = await this.request('turn/start', {
144
+ threadId: id, input: [{ type: 'text', text }], approvalPolicy: 'on-request',
145
+ sandboxPolicy: writable ? { type: 'workspaceWrite', networkAccess: false, excludeTmpdirEnvVar: true, excludeSlashTmp: true } : { type: 'readOnly' },
146
+ ...(settings ? { model: settings.model, effort: settings.effort } : {})
147
+ });
148
+ const completed = thread.lastCompletedTurnId === result.turn.id;
149
+ thread.turnId = completed ? null : result.turn.id;
150
+ return { threadId: id, turnId: result.turn.id, completed };
151
+ } finally { this.starting.delete(id); }
152
+ }
153
+
154
+ async interrupt(id) {
155
+ const turnId = this.threads.get(id)?.turnId;
156
+ if (!turnId) throw new Error('没有由当前桥接实例管理的活动任务。');
157
+ return this.request('turn/interrupt', { threadId: id, turnId });
158
+ }
159
+
160
+ replyApproval(id, body) {
161
+ const request = this.approvals.get(String(id));
162
+ if (!request) throw new Error('审批已结束或不属于当前桥接实例。');
163
+ let result;
164
+ if (request.method === 'item/tool/requestUserInput') {
165
+ const answers = {};
166
+ for (const question of request.params.questions || []) {
167
+ const value = body.answers?.[question.id];
168
+ if (typeof value !== 'string' || !value.trim() || value.length > 4000) throw new Error('请回答所有问题。');
169
+ answers[question.id] = { answers: [value] };
170
+ }
171
+ result = { answers };
172
+ } else if (request.method === 'item/permissions/requestApproval') {
173
+ if (body.decision !== 'decline') throw new Error('首版暂不支持扩大权限,请在本机处理。');
174
+ result = { permissions: {}, scope: 'turn' };
175
+ } else {
176
+ if (!['accept','decline'].includes(body.decision)) throw new Error('无效审批决定。');
177
+ result = { decision: body.decision };
178
+ }
179
+ this.send({ id: request.id, result });
180
+ this.approvals.delete(String(id));
181
+ return { resolved: true };
182
+ }
183
+
184
+ disconnect(error) {
185
+ this.connected = false;
186
+ const transport = this.transport;
187
+ const child = this.child;
188
+ this.transport = null;
189
+ this.child = null;
190
+ if (transport instanceof WebSocket) transport.terminate();
191
+ else child?.kill();
192
+ for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
193
+ this.pending.clear();
194
+ this.threads.clear();
195
+ this.starting.clear();
196
+ this.approvals.clear();
197
+ this.emit('event', { method: 'orbit/engine', params: { connected: false } });
198
+ }
199
+
200
+ close() {
201
+ this.disconnect(new Error('服务已停止。'));
202
+ }
203
+ }
package/lib/store.mjs ADDED
@@ -0,0 +1,162 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+
6
+ export function cleanText(text) {
7
+ return String(text ?? '').replace(/<(external_browser_tabs|environment_context|system_reminder|INSTRUCTIONS)>[\s\S]*?<\/\1>/g, '').trim();
8
+ }
9
+
10
+ export function messageFromItem(item, timestamp = null) {
11
+ if (item.type === 'userMessage' || item.type === 'steeringUserMessage') {
12
+ const content = Array.isArray(item.content) ? item.content : Array.isArray(item.input) ? item.input : [];
13
+ const text = item.text || content.filter(part => ['text','inputText','input_text'].includes(part.type)).map(part => part.text || '').join('\n');
14
+ const attachments = content.filter(part => ['image','image_url','input_image','file','input_file'].includes(part.type)).map(part => ({
15
+ type: part.type,
16
+ name: part.name || part.filename || (part.path ? path.basename(part.path) : null),
17
+ mimeType: part.mimeType || part.mime_type || null
18
+ }));
19
+ return { id: item.id, role: 'user', text: cleanText(text || (attachments.length ? '已附加文件' : '')), attachments, timestamp };
20
+ }
21
+ if (item.type === 'agentMessage') return { id: item.id, role: 'assistant', text: cleanText(item.text), timestamp };
22
+ if (item.type === 'commandExecution') return {
23
+ id: item.id, role: 'activity', kind: 'command', text: cleanText(item.command || '执行命令'),
24
+ output: item.aggregatedOutput === null || item.aggregatedOutput === undefined ? '' : cleanText(typeof item.aggregatedOutput === 'string' ? item.aggregatedOutput : JSON.stringify(item.aggregatedOutput)).slice(-60000),
25
+ cwd: cleanText(item.cwd || ''), exitCode: Number.isSafeInteger(item.exitCode) ? item.exitCode : null,
26
+ durationMs: Number.isFinite(item.durationMs) ? item.durationMs : null,
27
+ status: item.status || 'unknown', timestamp
28
+ };
29
+ if (item.type === 'fileChange') {
30
+ const changes = (Array.isArray(item.changes) ? item.changes : []).filter(change => change && typeof change.path === 'string').map(change => ({
31
+ path: cleanText(change.path).slice(0, 2000),
32
+ kind: cleanText(change.kind?.type || change.kind || ''),
33
+ diff: typeof change.diff === 'string' ? cleanText(change.diff).slice(0, 60000) : ''
34
+ }));
35
+ return { id: item.id, role: 'activity', kind: 'fileChange', text: changes.map(change => change.path).join('\n'), changes, status: item.status || 'unknown', timestamp };
36
+ }
37
+ if (item.type === 'mcpToolCall') {
38
+ const content = Array.isArray(item.result?.content) ? item.result.content : [];
39
+ const resultText = content.filter(part => part?.type === 'text').map(part => part.text).join('\n');
40
+ const errorText = typeof item.error === 'string' ? item.error : item.error?.message || (item.error ? JSON.stringify(item.error) : '');
41
+ const output = errorText || resultText || (item.result?.structuredContent ? JSON.stringify(item.result.structuredContent) : '');
42
+ const argumentsText = item.arguments === null || item.arguments === undefined ? null : JSON.stringify(item.arguments).slice(0, 30000);
43
+ return {
44
+ id: item.id, role: 'activity', kind: 'tool',
45
+ text: `${cleanText(item.server || '工具')} / ${cleanText(item.tool || '调用')}`,
46
+ server: cleanText(item.server || ''), tool: cleanText(item.tool || ''),
47
+ arguments: argumentsText, output: cleanText(output).slice(0, 60000),
48
+ durationMs: Number.isFinite(item.durationMs) ? item.durationMs : null,
49
+ status: item.status || 'unknown', timestamp
50
+ };
51
+ }
52
+ if (item.type === 'webSearch') return {
53
+ id: item.id, role: 'activity', kind: 'webSearch',
54
+ text: cleanText(item.query || item.action?.query || '网页搜索'),
55
+ status: 'completed', timestamp
56
+ };
57
+ if (item.type === 'imageView') return {
58
+ id: item.id, role: 'activity', kind: 'imageView', text: cleanText(item.path || '图像'),
59
+ timestamp
60
+ };
61
+ return null;
62
+ }
63
+
64
+ export class NativeStore {
65
+ constructor(home) {
66
+ this.home = path.resolve(home);
67
+ this.statePath = path.join(this.home, 'state_5.sqlite');
68
+ this.historyPath = path.join(this.home, 'thread_history_1.sqlite');
69
+ if (!fs.existsSync(this.statePath)) throw new Error('没有找到原生会话数据库,请检查 ORBIT_CODEX_HOME。');
70
+ this.state = new DatabaseSync(this.statePath, { readOnly: true });
71
+ this.state.exec('PRAGMA query_only=ON; PRAGMA busy_timeout=1500;');
72
+ this.columns = new Set(this.state.prepare('PRAGMA table_info(threads)').all().map(column => column.name));
73
+ this.history = null;
74
+ if (fs.existsSync(this.historyPath)) {
75
+ this.history = new DatabaseSync(this.historyPath, { readOnly: true });
76
+ this.history.exec('PRAGMA query_only=ON; PRAGMA busy_timeout=1500;');
77
+ }
78
+ }
79
+ close() { this.history?.close(); this.state.close(); }
80
+ locked(id) {
81
+ try { fs.readFileSync(path.join(this.home, 'thread-writer-locks', `${id}.lock`)); return false; }
82
+ catch (error) { return error.code !== 'ENOENT'; }
83
+ }
84
+ row(id) {
85
+ if (!/^[a-zA-Z0-9_-]{8,120}$/.test(id)) return null;
86
+ return this.state.prepare('SELECT * FROM threads WHERE id=?').get(id) || null;
87
+ }
88
+ summary(row, projects) {
89
+ const cwd = row.cwd || '';
90
+ const leaf = cwd.split(/[\\/]/).filter(Boolean).at(-1) || '未分组';
91
+ return {
92
+ id: row.id, title: row.name || row.title || '未命名会话',
93
+ preview: cleanText(row.preview || row.first_user_message || '').slice(0, 220), cwd,
94
+ projectId: row.project_id || cwd || 'ungrouped', projectName: projects.get(row.project_id)?.name || leaf,
95
+ source: row.source, provider: row.model_provider, model: row.model || null,
96
+ archived: Boolean(row.archived), pinned: Boolean(row.is_pinned), locked: this.locked(row.id),
97
+ createdAt: row.created_at_ms || row.created_at * 1000, updatedAt: row.updated_at_ms || row.updated_at * 1000,
98
+ branch: row.git_branch || null, historyMode: row.history_mode || 'legacy'
99
+ };
100
+ }
101
+ list() {
102
+ let projects = new Map();
103
+ try { projects = new Map(this.state.prepare('SELECT id,name FROM projects').all().map(project => [project.id, project])); } catch {}
104
+ return this.state.prepare('SELECT * FROM threads ORDER BY updated_at DESC').all().map(row => this.summary(row, projects));
105
+ }
106
+ historyFor(id, before = null, limit = 60) {
107
+ const row = this.row(id);
108
+ if (!row) return null;
109
+ const pageSize = Math.max(1, Math.min(100, Number(limit) || 60));
110
+ if (this.history && row.history_mode === 'paginated') {
111
+ const marker = Number.isSafeInteger(Number(before)) && before !== null ? Number(before) : Number.MAX_SAFE_INTEGER;
112
+ const records = this.history.prepare('SELECT item_json,rollout_ordinal,created_at_ms FROM thread_items WHERE thread_id=? AND rollout_ordinal<? AND item_type IN (\'userMessage\',\'steeringUserMessage\',\'agentMessage\',\'commandExecution\',\'fileChange\',\'mcpToolCall\',\'webSearch\',\'imageView\') ORDER BY rollout_ordinal DESC LIMIT ?').all(id, marker, pageSize + 1);
113
+ const hasMore = records.length > pageSize;
114
+ const page = records.slice(0, pageSize).reverse();
115
+ const messages = page.map(record => {
116
+ try { return messageFromItem(JSON.parse(record.item_json), record.created_at_ms); } catch { return null; }
117
+ }).filter(message => message && (message.text || message.role === 'activity'));
118
+ const turn = this.history.prepare('SELECT turn_id,status FROM thread_turns WHERE thread_id=? ORDER BY rollout_ordinal DESC LIMIT 1').get(id);
119
+ return { id, messages, nextCursor: hasMore ? page[0].rollout_ordinal : null, truncated: false, latestTurn: turn ? { id: turn.turn_id, status: turn.status } : null };
120
+ }
121
+ return this.legacyHistory(row, pageSize, before);
122
+ }
123
+ legacyHistory(row, limit, before) {
124
+ const filePath = path.resolve(row.rollout_path);
125
+ const relative = path.relative(this.home, filePath);
126
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('原生会话日志位于 CODEX_HOME 以外,已拒绝读取。');
127
+ const descriptor = fs.openSync(filePath, 'r');
128
+ let raw;
129
+ let truncated = false;
130
+ try {
131
+ const size = fs.fstatSync(descriptor).size;
132
+ const length = Math.min(size, 4 * 1024 * 1024);
133
+ const buffer = Buffer.alloc(length);
134
+ fs.readSync(descriptor, buffer, 0, length, size - length);
135
+ raw = buffer.toString('utf8');
136
+ if (size > length) { raw = raw.slice(raw.indexOf('\n') + 1); truncated = true; }
137
+ } finally { fs.closeSync(descriptor); }
138
+ const messages = [];
139
+ let latestTurn = null;
140
+ for (const [index, line] of raw.split('\n').entries()) {
141
+ try {
142
+ const record = JSON.parse(line);
143
+ const payload = record.payload || {};
144
+ if (record.type === 'response_item' && payload.type === 'message' && ['user','assistant'].includes(payload.role)) {
145
+ const text = cleanText((payload.content || []).filter(part => ['input_text','output_text','text'].includes(part.type)).map(part => part.text).join('\n'));
146
+ if (text && !text.startsWith('# AGENTS.md instructions')) messages.push({ id: payload.id || `legacy-${index}`, role: payload.role, text, timestamp: Date.parse(record.timestamp) || null });
147
+ }
148
+ if (record.type === 'event_msg' && ['task_started','task_complete','turn_aborted'].includes(payload.type)) latestTurn = { id: payload.turn_id, status: payload.type === 'task_started' ? 'inProgress' : 'completed' };
149
+ } catch {}
150
+ }
151
+ const end = before === null ? messages.length : Math.max(0, Math.min(messages.length, Number(before) || 0));
152
+ const start = Math.max(0, end - limit);
153
+ return { id: row.id, messages: messages.slice(start, end), nextCursor: start ? start : null, truncated, latestTurn };
154
+ }
155
+ revision(id = null) {
156
+ const files = [this.statePath, `${this.statePath}-wal`, this.historyPath, `${this.historyPath}-wal`];
157
+ if (id) { const row = this.row(id); if (row) files.push(row.rollout_path); }
158
+ return crypto.createHash('sha1').update(files.map(file => {
159
+ try { const stat = fs.statSync(file); return `${stat.size}:${stat.mtimeMs}`; } catch { return 'missing'; }
160
+ }).join('|')).digest('hex');
161
+ }
162
+ }
package/orbit ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import {runCli} from './cli.mjs';
3
+
4
+ await runCli();
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "orbit-codex-bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Connect your original Codex desktop conversations to Orbit on the web.",
6
+ "engines": { "node": ">=24" },
7
+ "bin": {
8
+ "orbit": "orbit"
9
+ },
10
+ "files": [
11
+ "cli.mjs",
12
+ "orbit",
13
+ "server.mjs",
14
+ "relay-agent.mjs",
15
+ "relay-server.mjs",
16
+ "relay-store.mjs",
17
+ "lib/",
18
+ "public/"
19
+ ],
20
+ "scripts": {
21
+ "start": "node --env-file-if-exists=.env.local server.mjs",
22
+ "relay": "node --env-file-if-exists=.env.relay relay-server.mjs",
23
+ "relay:agent": "node --env-file-if-exists=.env.local relay-agent.mjs",
24
+ "prepublishOnly": "npm run check && npm test",
25
+ "test": "node --test test/*.test.mjs",
26
+ "test:native": "node test/native-smoke.mjs",
27
+ "check": "node --check cli.mjs && node --check server.mjs && node --check lib/store.mjs && node --check lib/rpc.mjs && node --check lib/desktop.mjs && node --check lib/device-auth.mjs && node --check public/app.js && node --check public/connect.js && node --check relay-server.mjs && node --check relay-store.mjs && node --check relay-agent.mjs && node --check public/admin.js"
28
+ },
29
+ "dependencies": {
30
+ "dompurify": "^3.3.0",
31
+ "marked": "^17.0.0",
32
+ "qrcode": "^1.5.4",
33
+ "ws": "^8.18.3"
34
+ }
35
+ }
@@ -0,0 +1,67 @@
1
+ :root{font-family:Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:#27392f;background:#f5f7f2;font-size:14px;--line:#e3e8de;--green:#183c34;--accent:#d8edb2}
2
+ *{box-sizing:border-box}
3
+ body{margin:0}
4
+ button,input,textarea{font:inherit}
5
+ button{cursor:pointer;border:1px solid var(--line);background:#fff;color:inherit;border-radius:5px;padding:8px 12px}
6
+ button:disabled{opacity:.55;cursor:not-allowed}
7
+ [hidden]{display:none!important}
8
+ .admin-shell{width:min(1240px,calc(100% - 40px));margin:0 auto}
9
+ .admin-top{height:68px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--line)}
10
+ .brand{display:flex;align-items:center;gap:4px;color:var(--green);font-size:21px;font-weight:700;text-decoration:none}
11
+ .brand-mark{display:grid;place-items:center;width:26px;height:26px;margin-right:4px;border-radius:7px;background:var(--green);color:var(--accent)}
12
+ .brand-dot{color:#99b878}
13
+ .brand small{margin-left:10px;font-size:9px;font-weight:600;letter-spacing:1px;color:#83907f}
14
+ .status{margin-left:auto;font-size:11px;color:#768270}
15
+ .login-section{min-height:calc(100dvh - 69px);display:grid;place-items:center}
16
+ .login-form{width:min(420px,100%);padding:32px;background:#fff;border:1px solid var(--line);border-radius:8px}
17
+ .eyebrow{font-size:9px;font-weight:700;letter-spacing:1.2px;color:#849379}
18
+ h1{margin:12px 0;font-size:25px;font-weight:600}
19
+ .login-form>p,.section-heading p{font-size:12px;line-height:1.8;color:#82907d}
20
+ label{display:grid;gap:6px;font-size:11px;color:#63745e}
21
+ input,textarea{width:100%;min-height:37px;padding:8px 10px;border:1px solid #dce4d6;border-radius:5px;background:#fff;color:#314332}
22
+ .login-form label{margin:20px 0 11px}
23
+ .primary{background:#204b36;color:#f2f6e8;border-color:#204b36}
24
+ .login-form>.primary{width:100%;margin-top:8px}
25
+ .error{min-height:18px;color:#b65e4a;font-size:11px}
26
+ .overview{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;padding:22px 0}
27
+ .overview>div{display:flex;align-items:center;justify-content:space-between;min-height:72px;padding:15px 17px;border:1px solid var(--line);border-radius:6px;background:#fff}
28
+ .overview span{font-size:11px;color:#82907d}
29
+ .overview strong{font-size:21px;font-weight:600}
30
+ .content-section{margin:0 0 24px;padding:20px;background:#fff;border:1px solid var(--line);border-radius:7px}
31
+ .section-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:17px}
32
+ .section-heading h2{margin:5px 0 0;font-size:18px;font-weight:600}
33
+ .user-filters{display:grid;grid-template-columns:auto minmax(180px,320px) auto minmax(130px,180px) 1fr;align-items:center;gap:9px;margin:0 0 14px}
34
+ .user-filters label{font-size:11px}
35
+ .user-filters input,.user-filters select{min-width:0;min-height:36px;padding:7px 9px;border:1px solid #dce4d6;border-radius:5px;background:#fff;color:#314332;font:inherit}
36
+ .user-filters span{text-align:right;color:#82907d;font-size:11px}
37
+ .create-user-form{display:grid;grid-template-columns:repeat(6,minmax(100px,1fr)) auto;align-items:end;gap:10px;margin-bottom:20px;padding:14px;background:#f7f9f4;border:1px solid #e9eee4;border-radius:5px}
38
+ .create-user-form label{min-width:0}
39
+ .create-user-form input{font-size:12px}
40
+ .users-list{display:grid;gap:9px}
41
+ .user-row{display:grid;grid-template-columns:minmax(180px,1.1fr) minmax(120px,.7fr) minmax(140px,.8fr) auto;gap:13px;align-items:center;padding:13px;border-bottom:1px solid #edf0e9}
42
+ .user-identity strong,.user-identity small{display:block;overflow-wrap:anywhere}
43
+ .user-identity small,.user-meta{margin-top:4px;color:#879280;font-size:10px}
44
+ .user-meta{line-height:1.8}
45
+ .badge{display:inline-flex;padding:4px 7px;border-radius:4px;background:#edf2e6;color:#65804f;font-size:9px}
46
+ .badge.suspended,.badge.revoked{background:#f5ece8;color:#a46250}
47
+ .badge.offline{background:#f2f0e7;color:#928257}
48
+ .user-actions{display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}
49
+ .user-settings{grid-column:1/-1;display:grid;grid-template-columns:minmax(140px,1.2fr) minmax(100px,.8fr) 100px 210px auto;align-items:end;gap:8px;padding:10px;background:#f7f9f5;border-radius:4px}
50
+ .user-settings label{min-width:0}
51
+ .user-settings input{font-size:11px}
52
+ .device-list{grid-column:1/-1;display:grid;gap:6px;padding-top:7px}
53
+ .device-row{display:flex;justify-content:space-between;gap:10px;padding:9px;background:#f7f9f5;border-radius:4px;font-size:10px}
54
+ .pagination{display:flex;align-items:center;justify-content:center;gap:14px;padding:16px 0 2px;color:#82907d;font-size:11px}
55
+ .pagination button{min-width:76px}
56
+ .audit-list{display:grid}
57
+ .audit-row{display:grid;grid-template-columns:180px 160px minmax(0,1fr);gap:12px;padding:10px 2px;border-bottom:1px solid #edf0e9;font-size:10px}
58
+ .audit-row time,.audit-row span{color:#85907d;overflow-wrap:anywhere}
59
+ dialog{width:min(520px,calc(100% - 28px));border:1px solid var(--line);border-radius:8px;padding:22px;background:#fff;color:#27392f}
60
+ dialog::backdrop{background:#10282077}
61
+ dialog h2{margin-top:0}
62
+ dialog label{margin:14px 0 7px}
63
+ dialog textarea{font-family:Consolas,monospace;overflow-wrap:anywhere;resize:vertical}
64
+ @media(max-width:1100px){.overview{grid-template-columns:repeat(3,minmax(0,1fr))}}
65
+ @media(max-width:900px){.create-user-form{grid-template-columns:repeat(3,minmax(0,1fr))}.user-row{grid-template-columns:minmax(140px,1fr) minmax(100px,.7fr) auto}.user-meta.secondary{display:none}.user-settings{grid-template-columns:repeat(3,minmax(0,1fr))}.user-settings button{grid-column:1/-1}}
66
+ @media(max-width:600px){.admin-shell{width:calc(100% - 24px)}.admin-top{height:58px}.brand{font-size:18px}.brand small{font-size:8px}.overview{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;padding:12px 0}.overview>div{min-height:58px;padding:11px}.overview strong{font-size:18px}.content-section{padding:14px 11px}.user-filters{grid-template-columns:auto minmax(0,1fr);gap:8px}.user-filters input{grid-column:2}.user-filters select{grid-column:2}.user-filters span{grid-column:1/-1;text-align:left}.create-user-form{grid-template-columns:1fr 1fr}.create-user-form label:first-child,.create-user-form label:nth-child(2){grid-column:span 1}.create-user-form button{grid-column:1/-1}.user-row{grid-template-columns:1fr auto;gap:7px}.user-row>.user-meta{grid-column:1}.user-actions{grid-column:2;grid-row:1/3}.user-settings{grid-template-columns:1fr 100px}.user-settings label:first-child,.user-settings label:nth-child(2){grid-column:auto}.user-settings button{grid-column:1/-1}.device-row{align-items:flex-start;flex-direction:column}.audit-row{grid-template-columns:1fr auto}.audit-row span{grid-column:1/-1}}
67
+ @media(max-width:600px){.create-user-form>*{min-width:0}.create-user-form input[type=datetime-local]{min-width:0;width:100%;font-size:10px;padding-left:5px;padding-right:5px}.user-settings input[type=datetime-local]{min-width:0;width:100%;font-size:10px;padding-left:5px;padding-right:5px}}
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#143a33"><title>Orbit Admin · 控制台</title><link rel="stylesheet" href="/admin.css"><script src="/admin.js" defer></script>
5
+ </head>
6
+ <body>
7
+ <main class="admin-shell">
8
+ <header class="admin-top"><a class="brand" href="/"><span class="brand-mark">o</span>orbit<span class="brand-dot">.</span><small>CONTROL PLANE</small></a><span id="admin-status" class="status">等待管理员登录</span><button id="admin-logout" type="button" hidden>退出</button></header>
9
+ <section id="admin-login" class="login-section"><form id="admin-login-form" class="login-form"><span class="eyebrow">OPERATOR ACCESS</span><h1>管理控制台</h1><p>使用服务器管理员密钥登录。该密钥只用于创建账号和设备,不会发给手机或电脑代理。</p><label for="admin-token">管理员密钥</label><input id="admin-token" type="password" autocomplete="current-password" required><button class="primary" type="submit">登录</button><p id="admin-login-error" class="error" role="alert"></p></form></section>
10
+ <section id="admin-app" hidden>
11
+ <section class="overview"><div><span>用户总数</span><strong id="overview-users">0</strong></div><div><span>有效用户</span><strong id="overview-active-users">0</strong></div><div><span>已停用</span><strong id="overview-suspended-users">0</strong></div><div><span>已到期</span><strong id="overview-expired-users">0</strong></div><div><span>有效电脑</span><strong id="overview-devices">0</strong></div><div><span>在线电脑</span><strong id="overview-online">0</strong></div></section>
12
+ <section class="content-section"><div class="section-heading"><div><span class="eyebrow">TENANTS</span><h2>用户与设备</h2></div><button id="refresh-admin" type="button">刷新</button></div>
13
+ <div class="user-filters"><label for="user-search">搜索用户</label><input id="user-search" type="search" maxlength="100" placeholder="用户名或显示名称"><label for="user-status">账户状态</label><select id="user-status"><option value="all">全部状态</option><option value="active">有效</option><option value="suspended">已停用</option><option value="expired">已到期</option></select><span id="user-count" role="status"></span></div>
14
+ <form id="create-user-form" class="create-user-form"><label>登录名<input name="username" minlength="3" maxlength="64" autocomplete="off" required></label><label>显示名称<input name="displayName" maxlength="120"></label><label>初始密码<input name="password" type="password" minlength="12" autocomplete="new-password" placeholder="留空则自动生成"></label><label>套餐<input name="plan" value="standard" maxlength="40" required></label><label>设备上限<input name="maxDevices" type="number" min="1" max="50" value="2" required></label><label>账号到期<input name="expiresAt" type="datetime-local"></label><button class="primary" type="submit">创建账号</button></form>
15
+ <div id="admin-message" role="status" hidden></div><div id="users-list" class="users-list"></div>
16
+ <nav class="pagination" aria-label="用户列表分页"><button id="users-previous" type="button">上一页</button><span id="users-page">第 1 页</span><button id="users-next" type="button">下一页</button></nav>
17
+ </section>
18
+ <section class="content-section"><div class="section-heading"><div><span class="eyebrow">AUDIT</span><h2>管理事件</h2></div></div><div id="audit-list" class="audit-list"></div></section>
19
+ </section>
20
+ </main>
21
+ <dialog id="secret-dialog"><form method="dialog"><h2 id="secret-title">凭证已签发</h2><p id="secret-description"></p><label for="secret-value">仅显示一次</label><textarea id="secret-value" readonly rows="5"></textarea><button id="copy-secret" type="button">复制</button><button class="primary" value="close">完成</button></form></dialog>
22
+ </body>
23
+ </html>