feat(snippets): add encrypted command library and draggable panel / 新增加密代码片段库与可拖拽浮窗

This commit is contained in:
gdz66601 committed 2026-09-24 06:53:36 +08:00
1 parent a2f119c47e
commit 33b161cfba
17 files changed
+852 -2

No files matched your search

+8
View File
@@ -243,6 +243,14 @@ EdgeSSH/
文件页继续使用原有完整 SSH 会话,不要求服务器提供独立的 SFTP-only 入口;不会执行主机配置中的初始命令。点击 **打开 SSH 终端** 可继续使用同一会话,文件传输不会因视图切换而中断。切换主机前先断开连接;离开到总览会关闭会话,正在传输时会要求确认。
### 代码片段
左侧 **代码片段** 页面可以搜索、新建、复制、编辑和删除常用命令,支持多行脚本。首次使用内置 10 条 Linux 查看类命令;内置片段与自建片段一样可以修改或删除,删空后不会自动补回。最多保存 200 条,名称不超过 80 个字符,命令不超过 8192 个字符。
终端右侧提供可折叠浮窗,拖动标题栏调整位置;键盘聚焦标题栏后,也可用方向键移动、Home 键复位。手机上默认收起。点击 **填入编辑器** 只写入命令草稿,不自动执行;检查后再用命令编辑器发送。片段管理页与终端共用同一个片段库,切换两者不会断开现有 SSH 会话。
片段通过管理员认证、同源 API 与 D1 加密存储同步,不把命令持久化到浏览器。升级自动应用增量 migration,保留现有主机、认证和加密密钥。
## 参与贡献
欢迎通过 Issue 反馈问题或通过 Pull Request 提交改进。提交前请运行 `npm run check`;涉及连接、文件传输或进程面板的改动,请使用真实且已获授权的 SSH 目标验证。
+25 -1
View File
@@ -1,12 +1,14 @@
import { api, saveHost, removeHost, refreshHostLocation, type CloudHost, type HostInput } from './cloud-api';
import type { HostGlobe } from './globe';
import type { FilePage } from './file-page';
import type { Snippets } from './snippets';
import { countryFlag } from './flags';
import { systemIcon } from './os-icons';
import './dashboard.css';
interface DashboardActions {
files: FilePage;
snippets: Snippets;
refresh(): Promise<CloudHost[]>;
connect(host: CloudHost): Promise<void>;
quickConnect(): void;
@@ -17,6 +19,7 @@ const icons = {
home: '<path d="m3 10 9-7 9 7v10H3Z"/><path d="M9 20v-7h6v7"/>',
server: '<rect x="4" y="3" width="16" height="7" rx="2"/><rect x="4" y="14" width="16" height="7" rx="2"/><path d="M8 6.5h.01M8 17.5h.01M15 6.5h2M15 17.5h2"/>',
terminal: '<path d="m5 6 6 6-6 6m8 0h6"/>',
snippets: '<path d="M9 3H7a2 2 0 0 0-2 2v4l-2 3 2 3v4a2 2 0 0 0 2 2h2m6-18h2a2 2 0 0 1 2 2v4l2 3-2 3v4a2 2 0 0 1-2 2h-2"/>',
folder: '<path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M3 9h18"/>',
shield: '<path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6Z"/><path d="m8 12 3 3 5-6"/>',
search: '<circle cx="10" cy="10" r="6"/><path d="m15 15 5 5"/>',
@@ -52,6 +55,7 @@ export class Dashboard {
<button class="rail-item selected" id="rail-overview" aria-current="page">${icon('home')}<span>总览</span></button>
<button class="rail-item" id="rail-hosts">${icon('server')}<span>主机</span></button>
<button class="rail-item" id="rail-files">${icon('folder')}<span>文件管理</span></button>
<button class="rail-item" id="rail-snippets">${icon('snippets')}<span>代码片段</span></button>
<button class="rail-item" id="quick-connect">${icon('terminal')}<span>快速连接</span></button>
<span class="rail-security" title="管理员身份认证">${icon('shield')}<span id="auth-provider-label">身份<br>保护</span></span>
</nav>
@@ -105,6 +109,7 @@ export class Dashboard {
</dialog>`;
document.body.prepend(this.root);
this.get('.home-layout').append(this.actions.files.root);
this.get('.home-layout').append(this.actions.snippets.page);
this.dialog = this.get<HTMLDialogElement>('.host-dialog');
this.form = this.get<HTMLFormElement>('#cloud-host-form');
this.get('#account-action').addEventListener('click', async (event) => {
@@ -129,6 +134,7 @@ export class Dashboard {
if (id === '#rail-hosts') this.get<HTMLInputElement>('#host-search').focus();
});
this.get('#rail-files').addEventListener('click', () => this.showFiles());
this.get('#rail-snippets').addEventListener('click', () => this.showSnippets());
for (const id of ['#quick-connect', '#bottom-quick']) this.get(id).addEventListener('click', () => {
if (this.busy || !this.actions.files.confirmLeave()) return;
this.actions.leaveWorkspace();
@@ -208,6 +214,7 @@ export class Dashboard {
show(): void {
this.actions.files.hide();
this.actions.snippets.hide();
this.isHome = true; this.root.hidden = false;
this.get('.home-content').hidden = false;
this.selectNavigation('rail-overview');
@@ -218,14 +225,17 @@ export class Dashboard {
openWorkspace(): void {
this.actions.files.hide();
this.actions.snippets.hide();
this.isHome = false; this.root.hidden = true;
document.getElementById('app')!.hidden = false;
document.body.dataset.view = 'workspace';
this.globe?.setActive(false);
window.dispatchEvent(new Event('resize'));
this.actions.snippets.load();
}
showFiles(): void {
this.actions.snippets.hide();
this.isHome = false; this.root.hidden = false;
this.get('.home-content').hidden = true;
document.getElementById('app')!.hidden = true;
@@ -235,6 +245,19 @@ export class Dashboard {
this.actions.files.show();
}
showSnippets(): void {
if (!this.actions.files.confirmLeave()) return;
// 仅切换视图,不结束 SSH;在片段页编辑后可回到同一会话。
this.actions.files.hide();
this.isHome = false; this.root.hidden = false;
this.get('.home-content').hidden = true;
document.getElementById('app')!.hidden = true;
document.body.dataset.view = 'snippets';
this.selectNavigation('rail-snippets');
this.globe?.setActive(false);
this.actions.snippets.show();
}
private selectNavigation(id: string): void {
this.root.querySelectorAll<HTMLElement>('.rail-item').forEach((button) => {
button.classList.toggle('selected', button.id === id);
@@ -249,13 +272,14 @@ export class Dashboard {
private signedOut(): void {
this.actions.leaveWorkspace();
this.actions.snippets.clear();
this.authenticated = false;
this.get('#account-label').textContent = '未登录';
const action = this.get<HTMLAnchorElement>('#account-action');
action.href = '/auth/login'; action.textContent = '登录'; action.title = '管理员登录';
this.setHosts([]);
this.get('#host-list').textContent = '请点击右上角「登录」验证管理员身份。';
this.root.querySelectorAll<HTMLButtonElement>('[data-add], #quick-connect, #bottom-quick').forEach((button) => { button.disabled = true; });
this.root.querySelectorAll<HTMLButtonElement>('[data-add], #quick-connect, #bottom-quick, #rail-snippets').forEach((button) => { button.disabled = true; });
this.show();
}
+12
View File
@@ -5,6 +5,7 @@ import { historyKey, historyLabel } from './history';
import { listHosts, hostCredentials, saveHost, removeHost, updateHostSystem, type CloudHost, type Credentials, type HostSystemInfo } from './cloud-api';
import { Dashboard } from './dashboard';
import { FilePage } from './file-page';
import { Snippets } from './snippets';
import { resolveConnectionControl, resolveConnectionPanel } from './ui-state';
import { classifyHostKey, SSH_FINGERPRINT_RE, type HostKeyPrompt } from './host-key';
import { FileManager, collectFileManagerElements } from './file-manager';
@@ -2135,6 +2136,17 @@ async function initialize(): Promise<void> {
}, fileManager);
dashboard = new Dashboard({
files: filePage,
snippets: new Snippets(ui.terminalCard, (snippet) => {
// 片段只进入草稿,尤其多行命令不能通过粘贴意外立即执行。
const input = document.getElementById('command-editor-input') as HTMLTextAreaElement;
if (input.value.trim() && input.value !== snippet.command
&& !confirm(bilingual('替换命令编辑器中的现有内容?', 'Replace the current command draft?'))) return;
input.value = snippet.command;
input.dispatchEvent(new Event('input'));
if (document.getElementById('command-editor')!.hidden) document.getElementById('command-editor-toggle')!.click();
input.focus();
toast(bilingual('已填入命令编辑器,确认后再发送。', 'Added to the command editor. Review before sending.'), 'info');
}, () => dashboard?.openWorkspace(), () => dashboard?.showSnippets()),
refresh: async () => {
profiles = await loadProfiles();
renderProfiles();
+71
View File
@@ -0,0 +1,71 @@
import { SnippetStore, type Snippet } from './snippet-store';
export class SnippetEditor {
private readonly dialog = document.createElement('dialog');
private readonly form: HTMLFormElement;
private editing?: Snippet;
private busy = false;
private returnFocus?: HTMLElement;
constructor(private readonly store: SnippetStore) {
this.dialog.className = 'snippet-dialog snippet-surface';
this.dialog.setAttribute('aria-labelledby', 'snippet-editor-title');
this.dialog.innerHTML = `<form autocomplete="off">
<header><span class="snippet-symbol" aria-hidden="true">{ }</span><h2 id="snippet-editor-title">新建代码片段</h2></header>
<label>名称<input name="name" maxlength="80" required placeholder="例如:查看磁盘空间"></label>
<label>命令<textarea name="command" rows="7" maxlength="8192" required spellcheck="false" placeholder="df -h"></textarea></label>
<p class="snippet-hint">支持多行命令。使用时先填入命令编辑器,确认后再发送。</p>
<p class="snippet-editor-error" role="alert" hidden></p>
<footer><button type="button" data-cancel>取消</button><button type="submit" class="snippet-primary">保存片段</button></footer>
</form>`;
document.body.append(this.dialog);
this.form = this.dialog.querySelector('form')!;
this.form.addEventListener('submit', (event) => { event.preventDefault(); void this.save(); });
this.dialog.querySelector('[data-cancel]')!.addEventListener('click', () => this.close());
this.dialog.addEventListener('cancel', (event) => { event.preventDefault(); this.close(); });
this.dialog.addEventListener('close', () => this.returnFocus?.focus());
window.addEventListener('auth-required', () => { this.dialog.close(); this.form.reset(); this.editing = undefined; });
}
private field(name: string): HTMLInputElement | HTMLTextAreaElement {
return this.form.elements.namedItem(name) as HTMLInputElement | HTMLTextAreaElement;
}
open(snippet?: Snippet): void {
this.editing = snippet;
this.returnFocus = document.activeElement as HTMLElement;
this.form.reset();
this.field('name').value = snippet?.name ?? '';
this.field('command').value = snippet?.command ?? '';
this.dialog.querySelector('h2')!.textContent = snippet ? '编辑代码片段' : '新建代码片段';
this.dialog.querySelector<HTMLElement>('.snippet-editor-error')!.hidden = true;
this.dialog.showModal();
this.field('name').focus();
}
private close(): void {
if (this.busy) return;
const changed = this.field('name').value !== (this.editing?.name ?? '') || this.field('command').value !== (this.editing?.command ?? '');
if (changed && !confirm('放弃尚未保存的片段修改?')) return;
this.dialog.close();
}
private async save(): Promise<void> {
if (this.busy || !this.form.reportValidity()) return;
this.busy = true;
const buttons = this.form.querySelectorAll<HTMLButtonElement>('button');
buttons.forEach((button) => { button.disabled = true; });
const error = this.dialog.querySelector<HTMLElement>('.snippet-editor-error')!;
error.hidden = true;
try {
await this.store.save({ name: this.field('name').value, command: this.field('command').value }, this.editing?.id);
this.dialog.close();
} catch (cause) {
error.textContent = cause instanceof Error ? cause.message : '保存失败,请重试。';
error.hidden = false;
} finally {
this.busy = false;
buttons.forEach((button) => { button.disabled = false; });
}
}
}
+78
View File
@@ -0,0 +1,78 @@
import { SnippetStore, type Snippet } from './snippet-store';
import { SnippetEditor } from './snippet-editor';
interface SnippetListOptions {
compact?: boolean;
use?(snippet: Snippet): void;
}
export class SnippetList {
readonly root = document.createElement('div');
private readonly search: HTMLInputElement;
private readonly list: HTMLElement;
private readonly status: HTMLElement;
constructor(private readonly store: SnippetStore, private readonly editor: SnippetEditor, private readonly options: SnippetListOptions = {}) {
this.root.className = `snippet-library${options.compact ? ' compact' : ''}`;
this.root.innerHTML = `<div class="snippet-toolbar">
<label class="snippet-search"><span aria-hidden="true">⌕</span><input type="search" aria-label="搜索代码片段" placeholder="搜索名称或命令"></label>
<button type="button" class="snippet-primary" data-new>+ 新建片段</button>
<button type="button" data-refresh aria-label="刷新代码片段" title="刷新代码片段">↻</button>
</div><p class="snippet-status" role="status"></p><div class="snippet-list"></div>`;
this.search = this.root.querySelector('input')!;
this.list = this.root.querySelector('.snippet-list')!;
this.status = this.root.querySelector('.snippet-status')!;
this.search.addEventListener('input', () => this.render());
this.root.querySelector('[data-new]')!.addEventListener('click', () => editor.open());
this.root.querySelector('[data-refresh]')!.addEventListener('click', () => void store.load(true));
store.addEventListener('change', () => this.render());
this.render();
}
private action(label: string, title: string, callback: (button: HTMLButtonElement) => void): HTMLButtonElement {
const button = document.createElement('button'); button.type = 'button';
button.textContent = label; button.title = title; button.setAttribute('aria-label', title);
button.addEventListener('click', () => callback(button));
return button;
}
private render(): void {
const query = this.search.value.trim().toLowerCase();
const items = this.store.items.filter((item) => `${item.name}\n${item.command}`.toLowerCase().includes(query));
this.status.textContent = this.store.loading ? '正在加载片段…' : this.store.error || `${items.length} 条片段 · 云端加密保存`;
this.root.querySelector<HTMLButtonElement>('[data-new]')!.disabled = !this.store.loaded;
this.root.querySelector<HTMLButtonElement>('[data-refresh]')!.disabled = this.store.loading;
this.list.replaceChildren();
if (!items.length && this.store.loaded) {
const empty = document.createElement('div'); empty.className = 'snippet-empty';
empty.innerHTML = '<span class="snippet-symbol" aria-hidden="true">{ }</span><strong></strong><p></p>';
empty.querySelector('strong')!.textContent = query ? '没有匹配的片段' : '把常用命令留在手边';
empty.querySelector('p')!.textContent = query ? '试试其他名称或命令。' : '新建一个片段,下次连接时直接使用。';
this.list.append(empty);
}
for (const snippet of items) {
const row = document.createElement('article'); row.className = 'snippet-card';
const name = document.createElement('h3'); name.textContent = snippet.name;
const command = document.createElement('pre'); command.textContent = snippet.command;
command.title = snippet.command;
const actions = document.createElement('div'); actions.className = 'snippet-actions';
if (this.options.use) {
const use = this.action('填入编辑器', `使用 ${snippet.name}`, () => this.options.use!(snippet));
use.className = 'snippet-use'; actions.append(use);
}
actions.append(this.action('复制', `复制 ${snippet.name}`, async () => {
try { await navigator.clipboard.writeText(snippet.command); this.status.textContent = '命令已复制。'; }
catch { this.status.textContent = '无法访问剪贴板,请选中命令手动复制。'; }
}));
actions.append(this.action('编辑', `编辑 ${snippet.name}`, () => this.editor.open(snippet)));
const remove = this.action('删除', `删除 ${snippet.name}`, async (button) => {
if (!confirm(`删除片段「${snippet.name}」?删除后不会自动恢复。`)) return;
button.disabled = true;
try { await this.store.remove(snippet.id); }
catch (error) { this.status.textContent = error instanceof Error ? error.message : '删除失败,请重试。'; button.disabled = false; }
});
remove.className = 'snippet-delete'; actions.append(remove);
row.append(name, command, actions); this.list.append(row);
}
}
}
+85
View File
@@ -0,0 +1,85 @@
import { SnippetList } from './snippet-list';
import { SnippetEditor } from './snippet-editor';
import { SnippetStore, type Snippet } from './snippet-store';
export class SnippetPanel {
readonly root = document.createElement('aside');
private readonly body: HTMLElement;
private readonly toggle: HTMLButtonElement;
private position?: { x: number; y: number };
constructor(private readonly container: HTMLElement, store: SnippetStore, editor: SnippetEditor, use: (snippet: Snippet) => void, openLibrary: () => void) {
this.root.id = 'snippet-panel';
this.root.className = 'snippet-panel snippet-surface';
this.root.setAttribute('aria-label', '代码片段浮窗');
this.root.innerHTML = `<header class="snippet-panel-heading">
<button class="snippet-drag" type="button" aria-label="移动代码片段窗口" title="拖动移动,也可用方向键移动、Home 键复位">
<span class="snippet-symbol" aria-hidden="true">{ }</span><strong>代码片段</strong><span class="snippet-grip" aria-hidden="true">⠿</span>
</button><button class="snippet-manage" type="button" aria-label="管理代码片段" title="管理代码片段">↗</button>
<button class="snippet-collapse" type="button" aria-controls="snippet-panel-body"></button>
</header><div id="snippet-panel-body"><p class="snippet-panel-hint">常用命令,一次保存,随时取用。</p></div>`;
this.body = this.root.querySelector('#snippet-panel-body')!;
this.toggle = this.root.querySelector('.snippet-collapse')!;
this.body.append(new SnippetList(store, editor, { compact: true, use: (snippet) => {
use(snippet);
if (matchMedia('(max-width: 700px)').matches) this.setCollapsed(true);
} }).root);
container.append(this.root);
this.toggle.addEventListener('click', () => this.setCollapsed(!this.body.hidden));
this.root.querySelector('.snippet-manage')!.addEventListener('click', openLibrary);
this.setCollapsed(matchMedia('(max-width: 700px)').matches);
this.bindDrag();
// 终端侧栏、全屏和横竖屏切换都会改变可用空间,保持拖动后的标题栏仍可触达。
const observer = new ResizeObserver(() => this.constrain());
observer.observe(container); observer.observe(this.root);
}
private setCollapsed(collapsed: boolean): void {
this.body.hidden = collapsed;
this.root.classList.toggle('is-collapsed', collapsed);
this.toggle.textContent = collapsed ? '+' : '−';
this.toggle.setAttribute('aria-expanded', String(!collapsed));
this.toggle.setAttribute('aria-label', collapsed ? '展开代码片段' : '收起代码片段');
this.toggle.title = collapsed ? '展开代码片段' : '收起代码片段';
this.constrain();
}
private move(x: number, y: number): void {
this.position = { x, y }; this.constrain();
}
private constrain(): void {
if (!this.position || !this.container.clientWidth) return;
this.position.x = Math.max(0, Math.min(this.position.x, this.container.clientWidth - this.root.offsetWidth));
this.position.y = Math.max(0, Math.min(this.position.y, this.container.clientHeight - this.root.offsetHeight));
this.root.style.left = `${this.position.x}px`; this.root.style.top = `${this.position.y}px`; this.root.style.right = 'auto';
}
private bindDrag(): void {
const handle = this.root.querySelector<HTMLButtonElement>('.snippet-drag')!;
let drag: { pointer: number; x: number; y: number; left: number; top: number } | undefined;
handle.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
drag = { pointer: event.pointerId, x: event.clientX, y: event.clientY, left: this.root.offsetLeft, top: this.root.offsetTop };
handle.setPointerCapture(event.pointerId);
});
handle.addEventListener('pointermove', (event) => {
if (!drag || drag.pointer !== event.pointerId) return;
const x = event.clientX - drag.x; const y = event.clientY - drag.y;
if (Math.abs(x) + Math.abs(y) < 4) return;
this.move(drag.left + x, drag.top + y);
});
handle.addEventListener('lostpointercapture', () => { drag = undefined; });
handle.addEventListener('keydown', (event) => {
const moves: Record<string, [number, number]> = { ArrowLeft: [-20, 0], ArrowRight: [20, 0], ArrowUp: [0, -20], ArrowDown: [0, 20] };
if (event.key === 'Home') {
event.preventDefault(); this.position = undefined;
this.root.style.left = ''; this.root.style.top = ''; this.root.style.right = '';
} else if (moves[event.key]) {
event.preventDefault();
const [x, y] = moves[event.key];
this.move(this.root.offsetLeft + x, this.root.offsetTop + y);
}
});
}
}
+66
View File
@@ -0,0 +1,66 @@
import { api } from './cloud-api';
import type { Snippet, SnippetInput } from '../../src/accounts/snippet-data';
export type { Snippet, SnippetInput };
export class SnippetStore extends EventTarget {
items: Snippet[] = [];
loaded = false;
loading = false;
error = '';
private pending?: Promise<void>;
private generation = 0;
constructor() {
super();
window.addEventListener('auth-required', () => this.clear());
}
clear(): void {
this.generation++;
this.items = []; this.loaded = false; this.loading = false; this.pending = undefined; this.error = '';
this.notify();
}
private notify(): void { this.dispatchEvent(new Event('change')); }
load(force = false): Promise<void> {
if (this.pending) return this.pending;
if (this.loaded && !force) return Promise.resolve();
const generation = this.generation;
this.loading = true; this.error = ''; this.notify();
this.pending = (async () => {
try {
const { snippets } = await api<{ snippets: Snippet[] }>('/api/snippets');
// 登出会清空内存,不能让先前尚未结束的请求重新显示私人片段。
if (generation !== this.generation) return;
this.items = snippets; this.loaded = true;
} catch (error) {
if (generation === this.generation) this.error = error instanceof Error ? error.message : '片段加载失败,请重试。';
} finally {
if (generation === this.generation) {
this.loading = false; this.pending = undefined; this.notify();
}
}
})();
return this.pending;
}
async save(input: SnippetInput, id?: string): Promise<void> {
await this.pending;
const generation = this.generation;
const { snippet } = await api<{ snippet: Snippet }>(id ? `/api/snippets/${id}` : '/api/snippets', id ? 'PUT' : 'POST', input);
if (generation !== this.generation) return;
this.items = [snippet, ...this.items.filter((item) => item.id !== snippet.id)];
this.notify();
}
async remove(id: string): Promise<void> {
await this.pending;
const generation = this.generation;
await api(`/api/snippets/${id}`, 'DELETE');
if (generation !== this.generation) return;
this.items = this.items.filter((item) => item.id !== id);
this.notify();
}
}
+95
View File
@@ -0,0 +1,95 @@
.snippet-surface {
--snip-bg: var(--panel); --snip-card: var(--surface); --snip-soft: var(--surface-2);
--snip-text: var(--text); --snip-muted: var(--muted); --snip-border: var(--border);
--snip-accent: var(--accent); --snip-accent-soft: var(--accent-dim); --snip-danger: var(--danger);
color: var(--snip-text); background: var(--snip-bg);
}
.snippet-page, body[data-view='snippets'] .snippet-dialog {
--snip-bg: var(--home-bg); --snip-card: var(--home-surface); --snip-soft: var(--home-bg);
--snip-text: var(--home-ink); --snip-muted: var(--home-muted); --snip-border: var(--home-line);
--snip-accent: var(--home-success); --snip-accent-soft: #e8f5ee; --snip-danger: var(--home-danger);
color-scheme: light;
}
body[data-view='snippets'] { overflow-y: auto; background: var(--home-bg); }
.snippet-surface button {
min-height: 36px; border: 1px solid var(--snip-border); border-radius: 7px;
padding: 7px 10px; color: var(--snip-text); background: var(--snip-card); cursor: pointer;
font-size: 12px; transition: background 150ms ease, border-color 150ms ease;
}
.snippet-surface button:hover:not(:disabled) { background: var(--snip-soft); border-color: var(--snip-accent); }
.snippet-surface button:disabled { opacity: .5; cursor: not-allowed; }
.snippet-surface :is(button, input, textarea):focus-visible { outline: 2px solid var(--snip-accent); outline-offset: 2px; }
.snippet-surface button.snippet-primary, .snippet-surface button.snippet-use {
color: var(--snip-accent); background: var(--snip-accent-soft); font-weight: 600;
}
.snippet-symbol { color: var(--snip-accent); font: 700 20px var(--mono); white-space: nowrap; }
.snippet-page { min-width: 0; padding: 36px 32px; width: 100%; max-width: 1450px; margin-inline: auto; }
.snippet-page-heading { display: flex; align-items: center; gap: 18px; margin-bottom: 32px; }
.snippet-open-terminal { margin-left: auto; white-space: nowrap; }
.snippet-page-heading > .snippet-symbol { display: grid; place-items: center; width: 64px; height: 64px; background: var(--snip-accent-soft); border-radius: 16px; font-size: 26px; flex-shrink: 0; }
.snippet-page-heading h1 { font-size: 28px; margin: 4px 0 8px; }
.snippet-page-heading p { margin: 0; color: var(--snip-muted); font-size: 13px; }
.snippet-page-heading p:first-child { font: 10px var(--mono); letter-spacing: 1.6px; }
.snippet-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.snippet-search { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 120px; padding: 0 12px; border: 1px solid var(--snip-border); border-radius: 8px; background: var(--snip-card); }
.snippet-search > span { color: var(--snip-muted); font-size: 24px; }
.snippet-surface .snippet-search input { width: 100%; min-width: 0; height: 42px; border: 0; box-shadow: none; background: transparent; color: var(--snip-text); padding: 0; }
.snippet-status { font-size: 12px; color: var(--snip-muted); margin: 14px 0; line-height: 1.5; }
.snippet-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 290px), 1fr)); gap: 14px; }
.snippet-card { min-width: 0; border: 1px solid var(--snip-border); border-radius: 12px; padding: 18px; background: var(--snip-card); }
.snippet-card h3 { font-size: 14px; font-weight: 600; margin: 0 0 12px; overflow-wrap: anywhere; }
.snippet-card pre { margin: 0 0 16px; padding: 12px; background: var(--snip-soft); border-radius: 6px; color: var(--snip-text); font: 12px/1.6 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow-y: auto; }
.snippet-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.snippet-actions .snippet-delete { margin-left: auto; color: var(--snip-muted); }
.snippet-actions .snippet-delete:hover { color: var(--snip-danger); }
.snippet-empty { grid-column: 1 / -1; text-align: center; padding: 64px 16px; color: var(--snip-muted); }
.snippet-empty .snippet-symbol { display: block; font-size: 32px; margin-bottom: 24px; }
.snippet-empty strong { display: block; color: var(--snip-text); }
.snippet-empty p { font-size: 13px; line-height: 1.6; }
/* 浮窗依附完整工作台而非 xterm 区域,避免手机横屏时被展开的命令编辑器挤到不可操作。 */
.terminal-card:has(> .snippet-panel) { position: relative; }
.snippet-panel { position: absolute; z-index: 5; right: 12px; top: 54px; width: 320px; max-width: calc(100% - 16px); max-height: min(480px, calc(100% - 68px)); border: 1px solid var(--snip-border); border-radius: 12px; box-shadow: var(--shadow); display: flex; flex-direction: column; overflow: hidden; }
.snippet-panel.is-collapsed { width: 186px; }
.snippet-panel-heading { display: flex; flex-shrink: 0; align-items: center; padding: 6px; border-bottom: 1px solid var(--snip-border); }
.is-collapsed .snippet-panel-heading { border-bottom: 0; }
.is-collapsed .snippet-manage { display: none; }
.snippet-panel button.snippet-drag { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; border: 0; background: transparent; cursor: grab; touch-action: none; user-select: none; text-align: left; }
.snippet-panel button.snippet-drag:active { cursor: grabbing; }
.snippet-drag strong { white-space: nowrap; font-size: 13px; }
.snippet-grip { color: var(--snip-muted); margin-left: auto; }
.snippet-panel :is(button.snippet-collapse, button.snippet-manage) { width: 36px; padding: 0; border: 0; background: transparent; font-size: 20px; flex-shrink: 0; }
#snippet-panel-body { min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 0 12px 12px; scrollbar-width: thin; }
.snippet-panel-hint { color: var(--snip-muted); font-size: 11px; margin: 12px 0; }
.compact .snippet-search { flex-basis: 100%; }
.compact .snippet-toolbar > button[data-new] { flex: 1; }
.compact .snippet-list { grid-template-columns: minmax(0, 1fr); gap: 8px; }
.compact .snippet-card { padding: 12px; border-radius: 8px; }
.compact .snippet-card pre { padding: 8px; margin-bottom: 10px; max-height: 90px; }
.compact .snippet-card h3 { font-size: 12px; margin-bottom: 8px; }
.compact .snippet-actions { gap: 4px; }
.compact .snippet-actions button { padding: 6px; font-size: 11px; }
.snippet-dialog { width: min(540px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); padding: 24px; border: 1px solid var(--snip-border); border-radius: 16px; box-shadow: var(--shadow); }
.snippet-dialog::backdrop { background: #080b10a6; }
.snippet-dialog header { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
.snippet-dialog h2 { margin: 0; font-size: 20px; }
.snippet-dialog label { display: grid; gap: 8px; margin: 18px 0; font-size: 13px; }
.snippet-dialog :is(input, textarea) { width: 100%; padding: 12px; color: var(--snip-text); background: var(--snip-card); border: 1px solid var(--snip-border); border-radius: 8px; }
.snippet-dialog textarea { font: 13px/1.6 var(--mono); resize: vertical; }
.snippet-hint { font-size: 12px; line-height: 1.6; color: var(--snip-muted); }
.snippet-editor-error { color: var(--snip-danger); font-size: 13px; }
.snippet-dialog footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; }
@media (max-width: 700px) {
.snippet-page { padding: 24px 16px; }
.snippet-page-heading { gap: 12px; margin-bottom: 24px; flex-wrap: wrap; }
.snippet-page-heading > .snippet-symbol { width: 48px; height: 48px; font-size: 22px; }
.snippet-page-heading h1 { font-size: 24px; }
.snippet-surface button { min-height: 44px; }
.snippet-panel :is(button.snippet-collapse, button.snippet-manage) { width: 44px; }
.snippet-surface :is(input, textarea), .snippet-surface .snippet-search input { font-size: 16px; }
.snippet-dialog { padding: 20px; }
}
@media (max-width: 600px) {
#dashboard .home-rail { overflow-x: auto; }
#dashboard .rail-item { flex-shrink: 0; }
}
@media (prefers-reduced-motion: reduce) { .snippet-surface button { transition: none; } }
+34
View File
@@ -0,0 +1,34 @@
import { SnippetStore, type Snippet } from './snippet-store';
import { SnippetEditor } from './snippet-editor';
import { SnippetList } from './snippet-list';
import { SnippetPanel } from './snippet-panel';
import './snippets.css';
export class Snippets {
readonly page = document.createElement('section');
private readonly store = new SnippetStore();
constructor(container: HTMLElement, use: (snippet: Snippet) => void, openTerminal: () => void, openLibrary: () => void) {
const editor = new SnippetEditor(this.store);
this.page.id = 'snippets-page';
this.page.className = 'snippet-page snippet-surface';
this.page.hidden = true;
this.page.setAttribute('aria-labelledby', 'snippets-heading');
this.page.innerHTML = `<header class="snippet-page-heading"><span class="snippet-symbol" aria-hidden="true">{ }</span>
<div><p>YOUR COMMAND LIBRARY</p><h1 id="snippets-heading" tabindex="-1">代码片段</h1>
<p>把常用命令留在手边,不必每次从头输入。</p></div>
<button type="button" class="snippet-open-terminal">打开 SSH 终端 ↗</button></header>`;
this.page.querySelector('.snippet-open-terminal')!.addEventListener('click', openTerminal);
this.page.append(new SnippetList(this.store, editor).root);
new SnippetPanel(container, this.store, editor, use, openLibrary);
}
show(): void {
this.page.hidden = false; void this.store.load(true);
this.page.querySelector<HTMLElement>('h1')!.focus();
}
hide(): void { this.page.hidden = true; }
load(): void { void this.store.load(true); }
clear(): void { this.store.clear(); }
}
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS snippet_libraries (
account_id TEXT PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS snippets (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
encrypted_payload TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS snippets_account_updated ON snippets(account_id, updated_at DESC);
+1 -1
View File
@@ -9,7 +9,7 @@
"dev:web": "vite --config vite.config.ts",
"build:web": "vite build --config vite.config.ts",
"typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit && tsc -p tsconfig.scripts.json --noEmit",
"test": "node --test --experimental-strip-types tests/*.test.ts",
"test": "node --test --experimental-strip-types --experimental-sqlite tests/*.test.ts",
"test:browser": "playwright test",
"check": "npm run typecheck && npm run test && wrangler deploy --dry-run --outdir .wrangler/build",
"deploy:validate": "node --experimental-strip-types scripts/deploy.ts --validate-only",
+23
View File
@@ -0,0 +1,23 @@
export interface SnippetInput {
name: string;
command: string;
}
export interface Snippet extends SnippetInput {
id: string;
updatedAt: number;
}
// 只提供少量查看类命令,不预置会修改系统或需要提权的操作。
export const DEFAULT_SNIPPETS: readonly SnippetInput[] = [
{ name: '查看当前目录', command: 'pwd' },
{ name: '列出文件(含隐藏文件)', command: 'ls -lah' },
{ name: '查看磁盘空间', command: 'df -h' },
{ name: '查看目录大小', command: 'du -sh ./*' },
{ name: '查看内存使用', command: 'free -h' },
{ name: '查看系统负载', command: 'uptime' },
{ name: '查看 CPU 占用最高的进程', command: 'ps aux --sort=-%cpu | head -n 11' },
{ name: '查看监听端口', command: 'ss -tuln' },
{ name: '查看 IP 地址', command: 'ip -brief address' },
{ name: '查看系统与内核', command: 'uname -a' },
];
+70
View File
@@ -0,0 +1,70 @@
import type { Env } from '../types';
import { decryptHost, encryptHost } from './crypto.ts';
import { APIError, json, readJSON } from './http.ts';
import { DEFAULT_SNIPPETS, type SnippetInput } from './snippet-data.ts';
interface SnippetRow { id: string; encrypted_payload: string; updated_at: number }
export function validateSnippet(body: Record<string, unknown>): SnippetInput {
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80) {
throw new APIError('片段名称不能为空,且不能超过 80 个字符。');
}
if (typeof body.command !== 'string' || !body.command.trim() || body.command.length > 8192
|| /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(body.command)) {
throw new APIError('命令不能为空、超过 8192 个字符或包含终端控制字符。');
}
return { name: body.name.trim(), command: body.command.replace(/\r\n?/g, '\n') };
}
async function initializeLibrary(env: Env, accountId: string): Promise<void> {
const initialized = await env.DB.prepare('SELECT account_id FROM snippet_libraries WHERE account_id = ?').bind(accountId).first();
if (initialized) return;
const now = Date.now();
const statements = await Promise.all(DEFAULT_SNIPPETS.map(async (snippet, index) => {
const id = crypto.randomUUID();
// 独立 AAD 前缀隔离主机与片段密文,同时复用现有加密格式,避免更换生产密钥。
const encrypted = await encryptHost(snippet, env.ENCRYPTION_KEY, accountId, `snippet:${id}`);
return env.DB.prepare(`INSERT INTO snippets(id, account_id, encrypted_payload, updated_at)
SELECT ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM snippet_libraries WHERE account_id = ?)`)
.bind(id, accountId, encrypted, now - index, accountId);
}));
// 初始化标记和默认记录在一个事务里写入;并发首次访问不重复,删空后也不重新补回。
await env.DB.batch([...statements,
env.DB.prepare('INSERT INTO snippet_libraries(account_id) VALUES (?) ON CONFLICT DO NOTHING').bind(accountId),
]);
}
export async function snippetsRoute(request: Request, env: Env, accountId: string, pathname: string): Promise<Response> {
const match = /^\/api\/snippets(?:\/([a-f0-9-]{36}))?$/.exec(pathname);
if (!match) throw new APIError('接口不存在。', 404);
const id = match[1];
if (!id && request.method === 'GET') {
await initializeLibrary(env, accountId);
const rows = await env.DB.prepare('SELECT id, encrypted_payload, updated_at FROM snippets WHERE account_id = ? ORDER BY updated_at DESC, id')
.bind(accountId).all<SnippetRow>();
const snippets = await Promise.all(rows.results.map(async (row) => ({
...await decryptHost<SnippetInput>(row.encrypted_payload, env.ENCRYPTION_KEY, accountId, `snippet:${row.id}`),
id: row.id, updatedAt: row.updated_at,
})));
return json({ snippets });
}
if (id && request.method === 'DELETE') {
const result = await env.DB.prepare('DELETE FROM snippets WHERE id = ? AND account_id = ?').bind(id, accountId).run();
if (!result.meta.changes) throw new APIError('片段不存在,请刷新列表。', 404);
return json({ ok: true });
}
if (id ? request.method !== 'PUT' : request.method !== 'POST') throw new APIError('不支持此请求方法。', 405);
const payload = validateSnippet(await readJSON(request));
if (!id) await initializeLibrary(env, accountId);
const snippetId = id ?? crypto.randomUUID();
const encrypted = await encryptHost(payload, env.ENCRYPTION_KEY, accountId, `snippet:${snippetId}`);
const now = Date.now();
const result = id
? await env.DB.prepare('UPDATE snippets SET encrypted_payload = ?, updated_at = ? WHERE id = ? AND account_id = ?')
.bind(encrypted, now, id, accountId).run()
: await env.DB.prepare(`INSERT INTO snippets(id, account_id, encrypted_payload, updated_at)
SELECT ?, ?, ?, ? WHERE (SELECT COUNT(*) FROM snippets WHERE account_id = ?) < 200`)
.bind(snippetId, accountId, encrypted, now, accountId).run();
if (!result.meta.changes) throw new APIError(id ? '片段不存在,请刷新列表。' : '最多保存 200 条片段,请删除不再使用的片段。', id ? 404 : 409);
return json({ snippet: { ...payload, id: snippetId, updatedAt: now } }, id ? 200 : 201);
}
+2
View File
@@ -5,6 +5,7 @@ import { currentAccount } from './accounts/auth';
import { authRoute } from './accounts/auth-routes';
import { authProvider } from './accounts/auth-provider';
import { hostsRoute } from './accounts/hosts';
import { snippetsRoute } from './accounts/snippets';
import { apiFailure, json } from './accounts/http';
import { locateHost } from './accounts/location';
@@ -153,6 +154,7 @@ export default {
if (isApiRequest && !account) return jsonError('请先登录。', 401);
if (url.pathname === '/api/auth/me' && request.method === 'GET') return json({ account, provider: authProvider(env) });
if (url.pathname.startsWith('/api/hosts')) return await hostsRoute(request, env, account!.id, url.pathname);
if (url.pathname.startsWith('/api/snippets')) return await snippetsRoute(request, env, account!.id, url.pathname);
if (url.pathname === '/api/session') {
if (request.method !== 'POST') return corsResponse(jsonError('Method not allowed', 405));
return corsResponse(await sessionTicket(request, env, account!.id));
+1
View File
@@ -34,6 +34,7 @@ export async function fileFixture(page: Page, options: { firstSeen?: boolean; cr
: { json: { password: 'test-only-password' } });
}
if (path === '/api/hosts') return route.fulfill({ json: { hosts } });
if (path === '/api/snippets') return route.fulfill({ json: { snippets: [] } });
return route.fulfill({ json: { host: hosts.find((item) => path.includes(item.id)) ?? hosts[0] } });
});
await page.routeWebSocket('**/api/ssh?*', (ws) => {
+168
View File
@@ -0,0 +1,168 @@
import { test, expect, type Page } from '@playwright/test';
import { DEFAULT_SNIPPETS, type Snippet } from '../../src/accounts/snippet-data';
import { connectFiles, fileFixture } from './file-fixture';
async function fixture(page: Page) {
const files = await fileFixture(page);
let items: Snippet[] = DEFAULT_SNIPPETS.map((item) => ({ ...item, id: crypto.randomUUID(), updatedAt: Date.now() }));
await page.route('**/api/snippets**', async (route) => {
const request = route.request();
const id = new URL(request.url()).pathname.split('/')[3];
if (request.method() === 'GET') return route.fulfill({ json: { snippets: items } });
if (request.method() === 'DELETE') {
items = items.filter((item) => item.id !== id);
return route.fulfill({ json: { ok: true } });
}
const snippet = { ...request.postDataJSON(), id: id ?? crypto.randomUUID(), updatedAt: Date.now() };
items = [snippet, ...items.filter((item) => item.id !== id)];
return route.fulfill({ json: { snippet } });
});
return files;
}
test('主页片段库支持十条默认命令、搜索、新建、多行编辑、删除及刷新持久化', async ({ page }, testInfo) => {
await fixture(page);
await page.locator('#rail-snippets').click();
const library = page.locator('#snippets-page');
await expect(library.locator('.snippet-card')).toHaveCount(10);
await expect(page.locator('#rail-snippets')).toHaveAttribute('aria-current', 'page');
await library.getByRole('searchbox').fill('磁盘');
await expect(library.locator('.snippet-card')).toHaveCount(1);
await library.getByRole('searchbox').fill('');
await library.getByRole('button', { name: '+ 新建片段' }).click();
const dialog = page.getByRole('dialog', { name: '新建代码片段' });
await dialog.getByLabel('名称').fill('部署检查 <script>');
await dialog.getByLabel('命令', { exact: true }).fill('echo first\npwd');
await dialog.getByRole('button', { name: '保存片段' }).click();
await expect(library.locator('.snippet-card')).toHaveCount(11);
await library.getByRole('button', { name: '编辑 部署检查 <script>', exact: true }).click();
await page.getByRole('dialog').getByLabel('命令', { exact: true }).fill('echo second\nls -lah');
await page.getByRole('button', { name: '保存片段', exact: true }).click();
await page.reload();
await page.locator('#rail-snippets').click();
await expect(library.locator('.snippet-card').filter({ hasText: '部署检查 <script>' }).locator('pre')).toHaveText('echo second\nls -lah');
page.once('dialog', (dialog) => dialog.accept());
await library.getByRole('button', { name: '删除 查看当前目录', exact: true }).click();
await expect(library.locator('.snippet-card')).toHaveCount(10);
await library.getByRole('button', { name: '刷新代码片段' }).click();
await expect(library.getByRole('heading', { name: '查看当前目录', exact: true })).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath('snippet-library.png'), fullPage: true });
});
test('终端浮窗折叠、拖动与键盘复位;填入草稿而不执行,编辑与管理页同步', async ({ page }, testInfo) => {
const files = await fixture(page);
await connectFiles(page);
await page.locator('#files-terminal').click();
const panel = page.locator('#snippet-panel');
const expand = panel.getByRole('button', { name: '展开代码片段' });
if (await expand.count()) await expand.click();
await expect(panel.locator('.snippet-card')).toHaveCount(10);
await panel.getByRole('button', { name: '使用 查看磁盘空间', exact: true }).click();
await expect(page.locator('#command-editor-input')).toHaveValue('df -h');
expect(files.calls.filter((call) => call.type === 'input')).toHaveLength(0);
await page.locator('#command-editor-send').click();
await expect.poll(() => files.calls.filter((call) => call.type === 'input').at(-1)?.data).toBe('df -h\r');
if (await panel.getByRole('button', { name: '收起代码片段' }).count()) await panel.getByRole('button', { name: '收起代码片段' }).click();
await expect(page.locator('#snippet-panel-body')).toBeHidden();
const handle = panel.getByRole('button', { name: '移动代码片段窗口' });
const before = (await panel.boundingBox())!;
await handle.focus();
await page.keyboard.press('ArrowLeft');
const after = (await panel.boundingBox())!;
expect(after.x).toBeLessThan(before.x);
await page.keyboard.press('Home');
if (testInfo.project.name === 'desktop') {
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + 30, box.y + 20);
await page.mouse.down();
await page.mouse.move(box.x - 100, box.y + 60, { steps: 8 });
await page.mouse.up();
expect((await panel.boundingBox())!.x).toBeLessThan(before.x);
}
await panel.getByRole('button', { name: '展开代码片段' }).click();
await panel.getByRole('button', { name: '编辑 查看磁盘空间', exact: true }).click();
await page.getByRole('dialog').getByLabel('名称').fill('磁盘概况');
await page.getByRole('button', { name: '保存片段', exact: true }).click();
await panel.getByRole('button', { name: '管理代码片段', exact: true }).click();
await expect(page.locator('#snippets-page').getByRole('heading', { name: '磁盘概况', exact: true })).toBeVisible();
await page.getByRole('button', { name: '打开 SSH 终端 ↗', exact: true }).click();
expect(files.calls.filter((call) => call.type === 'connect')).toHaveLength(1);
await expect(panel).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath('snippet-terminal.png'), fullPage: true });
});
test('加载与保存错误可重试,不丢失草稿;空列表不补回默认命令', async ({ page }) => {
await fixture(page);
await page.route('**/api/snippets', (route) => route.fulfill({ status: 500, json: { error: '片段服务暂时不可用' } }));
await page.locator('#rail-snippets').click();
const library = page.locator('#snippets-page');
await expect(library.getByRole('status')).toContainText('片段服务暂时不可用');
await page.unroute('**/api/snippets');
await library.getByRole('button', { name: '刷新代码片段' }).click();
await expect(library.locator('.snippet-card')).toHaveCount(10);
await library.getByRole('button', { name: '+ 新建片段' }).click();
await page.getByRole('dialog').getByLabel('名称').fill('保留草稿');
await page.getByRole('dialog').getByLabel('命令', { exact: true }).fill('echo safe');
await page.route('**/api/snippets', (route) => route.fulfill({ status: 500, json: { error: '保存失败,请重试。' } }));
await page.getByRole('button', { name: '保存片段', exact: true }).click();
await expect(page.getByRole('dialog').getByRole('alert')).toContainText('保存失败');
await expect(page.getByRole('dialog').getByLabel('命令', { exact: true })).toHaveValue('echo safe');
await page.unroute('**/api/snippets');
await page.getByRole('button', { name: '保存片段', exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
await page.route('**/api/snippets', (route) => route.fulfill({ json: { snippets: [] } }));
await library.getByRole('button', { name: '刷新代码片段' }).click();
await expect(library.locator('.snippet-empty')).toContainText('把常用命令留在手边');
await library.getByRole('button', { name: '刷新代码片段' }).click();
await expect(library.locator('.snippet-card')).toHaveCount(0);
});
test('多行片段保留换行且不直接发送;取消覆盖保留旧草稿,登出清空片段', async ({ page }) => {
const files = await fixture(page);
await page.locator('#rail-snippets').click();
await page.locator('#snippets-page').getByRole('button', { name: '+ 新建片段' }).click();
await page.getByRole('dialog').getByLabel('名称').fill('多行脚本');
await page.getByRole('dialog').getByLabel('命令', { exact: true }).fill('echo a\n# 注释\necho b');
await page.getByRole('button', { name: '保存片段', exact: true }).click();
await page.getByRole('button', { name: '打开 SSH 终端 ↗', exact: true }).click();
const panel = page.locator('#snippet-panel');
if (await panel.getByRole('button', { name: '展开代码片段' }).count()) await panel.getByRole('button', { name: '展开代码片段' }).click();
await panel.getByRole('button', { name: '使用 多行脚本', exact: true }).click();
await expect(page.locator('#command-editor-input')).toHaveValue('echo a\n# 注释\necho b');
if (await panel.getByRole('button', { name: '展开代码片段' }).count()) await panel.getByRole('button', { name: '展开代码片段' }).click();
page.once('dialog', (dialog) => dialog.dismiss());
await panel.getByRole('button', { name: '使用 查看当前目录', exact: true }).click();
await expect(page.locator('#command-editor-input')).toHaveValue('echo a\n# 注释\necho b');
expect(files.calls.filter((call) => call.type === 'input')).toHaveLength(0);
await page.evaluate(() => window.dispatchEvent(new Event('auth-required')));
await expect(page.locator('#snippet-panel .snippet-card')).toHaveCount(0);
await expect(page.locator('#snippets-page .snippet-card')).toHaveCount(0);
});
test('浮窗在调整尺寸后仍可触达,并适配浅色及减少动态效果', async ({ page }, testInfo) => {
await fixture(page);
await connectFiles(page);
await page.locator('#files-terminal').click();
const panel = page.locator('#snippet-panel');
const initiallyCollapsed = testInfo.project.name === 'mobile';
if (initiallyCollapsed) await expect(panel.locator('#snippet-panel-body')).toBeHidden();
else await expect(panel.locator('#snippet-panel-body')).toBeVisible();
if (initiallyCollapsed) await panel.getByRole('button', { name: '展开代码片段' }).click();
await panel.getByRole('button', { name: '移动代码片段窗口' }).focus();
for (let i = 0; i < 8; i++) await page.keyboard.press('ArrowDown');
await page.setViewportSize({ width: 667, height: 375 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
const stage = (await page.locator('#terminal-card').boundingBox())!;
const box = (await panel.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(stage.x);
expect(box.x + box.width).toBeLessThanOrEqual(stage.x + stage.width + 1);
expect(box.y).toBeGreaterThanOrEqual(stage.y);
expect(box.y + box.height).toBeLessThanOrEqual(stage.y + stage.height + 1);
await panel.getByRole('button', { name: '收起代码片段' }).click();
await expect(panel.locator('#snippet-panel-body')).toBeHidden();
expect((await panel.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await page.screenshot({ path: testInfo.outputPath('snippet-landscape-light.png'), fullPage: true });
});
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { test } from 'node:test';
import type { Env } from '../src/types.ts';
import { snippetsRoute, validateSnippet } from '../src/accounts/snippets.ts';
import { DEFAULT_SNIPPETS, type Snippet } from '../src/accounts/snippet-data.ts';
import { decryptHost } from '../src/accounts/crypto.ts';
// 使用真实 SQLite 执行迁移和参数化 SQL,而不是用字符串匹配模拟数据库行为。
function fixture() {
const sqlite = new DatabaseSync(':memory:');
sqlite.exec(readFileSync(new URL('../migrations/0003_snippets.sql', import.meta.url), 'utf8'));
const prepare = (sql: string) => {
let params: (string | number)[] = [];
const query = sqlite.prepare(sql);
const statement = {
bind(...values: (string | number)[]) { params = values; return statement; },
async first() { return query.get(...params) ?? null; },
async all() { return { results: query.all(...params) }; },
execute() { return { meta: { changes: Number(query.run(...params).changes) } }; },
async run() { return statement.execute(); },
};
return statement;
};
const env = {
ENCRYPTION_KEY: Buffer.alloc(32, 3).toString('base64'),
DB: {
prepare,
async batch(statements: ReturnType<typeof prepare>[]) {
sqlite.exec('BEGIN');
try { const results = statements.map((statement) => statement.execute()); sqlite.exec('COMMIT'); return results; }
catch (error) { sqlite.exec('ROLLBACK'); throw error; }
},
},
} as unknown as Env;
const request = async (method = 'GET', id = '', body?: unknown, owner = 'owner') => {
const path = `/api/snippets${id ? `/${id}` : ''}`;
return snippetsRoute(new Request(`https://example.com${path}`, {
method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body),
}), env, owner, path);
};
const list = async (owner = 'owner'): Promise<Snippet[]> => (await (await request('GET', '', undefined, owner)).json() as { snippets: Snippet[] }).snippets;
return { sqlite, env, request, list };
}
test('内置命令不超过十条,名称唯一且全部通过校验', () => {
assert.equal(DEFAULT_SNIPPETS.length, 10);
assert.equal(new Set(DEFAULT_SNIPPETS.map((item) => item.name)).size, 10);
for (const item of DEFAULT_SNIPPETS) assert.deepEqual(validateSnippet(item as unknown as Record<string, unknown>), item);
});
test('校验保留多行脚本语义,拒绝空白、超长与终端控制字符', () => {
assert.deepEqual(validateSnippet({ name: ' 多行 ', command: 'echo a\r\necho b\r' }), { name: '多行', command: 'echo a\necho b\n' });
for (const body of [
{ name: '', command: 'pwd' }, { name: ' ', command: 'pwd' }, { name: 'a'.repeat(81), command: 'pwd' },
{ name: '测试', command: '' }, { name: '测试', command: ' '.repeat(10) }, { name: '测试', command: 'a'.repeat(8193) },
{ name: '测试', command: '\x1b[201~pwd' }, { name: '测试', command: '\x03pwd' }, { name: 1, command: 'pwd' },
]) assert.throws(() => validateSnippet(body), /片段名称|命令/);
});
test('并发首次读取只生成十条;删空和重复读取不补回', async (context) => {
const f = fixture(); context.after(() => f.sqlite.close());
await Promise.all([f.list(), f.list(), f.list()]);
const items = await f.list();
assert.equal(items.length, 10);
for (const item of items) await f.request('DELETE', item.id);
assert.equal((await f.list()).length, 0);
assert.equal((await f.list()).length, 0);
assert.equal(f.sqlite.prepare('SELECT COUNT(*) AS count FROM snippet_libraries').get()!.count, 1);
});
test('片段增改删、持久化、加密与账户隔离', async (context) => {
const f = fixture(); context.after(() => f.sqlite.close());
await f.list();
const payload = { name: '<img src=x onerror=alert(1)>', command: 'echo private-token\npwd' };
const created = await f.request('POST', '', payload);
assert.equal(created.status, 201);
const { snippet } = await created.json() as { snippet: Snippet };
assert.deepEqual((await f.list()).find((item) => item.id === snippet.id), snippet);
const row = f.sqlite.prepare('SELECT encrypted_payload FROM snippets WHERE id = ?').get(snippet.id)!;
assert.equal(String(row.encrypted_payload).includes('private-token'), false);
await assert.rejects(decryptHost(String(row.encrypted_payload), f.env.ENCRYPTION_KEY, 'owner', snippet.id));
await assert.rejects(f.request('PUT', snippet.id, payload, 'other'), /不存在/);
await assert.rejects(f.request('DELETE', snippet.id, undefined, 'other'), /不存在/);
await f.request('PUT', snippet.id, { name: '新名称', command: 'ls -lah' });
assert.equal((await f.list()).find((item) => item.id === snippet.id)!.command, 'ls -lah');
await f.request('DELETE', snippet.id);
await assert.rejects(f.request('DELETE', snippet.id), /不存在/);
assert.equal((await f.list()).length, 10);
});
test('200 条总配额不限制用户添加到十条以上,错误路径与方法明确拒绝', async (context) => {
const f = fixture(); context.after(() => f.sqlite.close());
await f.list();
for (let i = 10; i < 200; i++) await f.request('POST', '', { name: `命令 ${i}`, command: 'pwd' });
await assert.rejects(f.request('POST', '', { name: '满额', command: 'pwd' }), /最多保存 200/);
assert.equal((await f.list()).length, 200);
await assert.rejects(f.request('PATCH'), /不支持/);
await assert.rejects(f.request('GET', 'invalid'), /不存在/);
});