diff --git a/README.md b/README.md index fcb7e1d..05b3790 100644 --- a/README.md +++ b/README.md @@ -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 目标验证。 diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 0bf4ab3..b222755 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -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; connect(host: CloudHost): Promise; quickConnect(): void; @@ -17,6 +19,7 @@ const icons = { home: '', server: '', terminal: '', + snippets: '', folder: '', shield: '', search: '', @@ -52,6 +55,7 @@ export class Dashboard { + ${icon('shield')}身份
保护
@@ -105,6 +109,7 @@ export class Dashboard { `; 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('.host-dialog'); this.form = this.get('#cloud-host-form'); this.get('#account-action').addEventListener('click', async (event) => { @@ -129,6 +134,7 @@ export class Dashboard { if (id === '#rail-hosts') this.get('#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('.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('#account-action'); action.href = '/auth/login'; action.textContent = '登录'; action.title = '管理员登录'; this.setHosts([]); this.get('#host-list').textContent = '请点击右上角「登录」验证管理员身份。'; - this.root.querySelectorAll('[data-add], #quick-connect, #bottom-quick').forEach((button) => { button.disabled = true; }); + this.root.querySelectorAll('[data-add], #quick-connect, #bottom-quick, #rail-snippets').forEach((button) => { button.disabled = true; }); this.show(); } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b31eeb8..1863813 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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 { }, 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(); diff --git a/frontend/src/snippet-editor.ts b/frontend/src/snippet-editor.ts new file mode 100644 index 0000000..67503d9 --- /dev/null +++ b/frontend/src/snippet-editor.ts @@ -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 = `
+

新建代码片段

+ + +

支持多行命令。使用时先填入命令编辑器,确认后再发送。

+ +
+
`; + 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('.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 { + if (this.busy || !this.form.reportValidity()) return; + this.busy = true; + const buttons = this.form.querySelectorAll('button'); + buttons.forEach((button) => { button.disabled = true; }); + const error = this.dialog.querySelector('.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; }); + } + } +} diff --git a/frontend/src/snippet-list.ts b/frontend/src/snippet-list.ts new file mode 100644 index 0000000..e7c4c75 --- /dev/null +++ b/frontend/src/snippet-list.ts @@ -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 = `
+ + + +

`; + 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('[data-new]')!.disabled = !this.store.loaded; + this.root.querySelector('[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 = '

'; + 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); + } + } +} diff --git a/frontend/src/snippet-panel.ts b/frontend/src/snippet-panel.ts new file mode 100644 index 0000000..61d23cf --- /dev/null +++ b/frontend/src/snippet-panel.ts @@ -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 = `
+ + +

常用命令,一次保存,随时取用。

`; + 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('.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 = { 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); + } + }); + } +} diff --git a/frontend/src/snippet-store.ts b/frontend/src/snippet-store.ts new file mode 100644 index 0000000..5f15e38 --- /dev/null +++ b/frontend/src/snippet-store.ts @@ -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; + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/frontend/src/snippets.css b/frontend/src/snippets.css new file mode 100644 index 0000000..5ba02e7 --- /dev/null +++ b/frontend/src/snippets.css @@ -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; } } diff --git a/frontend/src/snippets.ts b/frontend/src/snippets.ts new file mode 100644 index 0000000..20bab84 --- /dev/null +++ b/frontend/src/snippets.ts @@ -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 = `
+

YOUR COMMAND LIBRARY

代码片段

+

把常用命令留在手边,不必每次从头输入。

+
`; + 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('h1')!.focus(); + } + + hide(): void { this.page.hidden = true; } + load(): void { void this.store.load(true); } + clear(): void { this.store.clear(); } +} diff --git a/migrations/0003_snippets.sql b/migrations/0003_snippets.sql new file mode 100644 index 0000000..82ad069 --- /dev/null +++ b/migrations/0003_snippets.sql @@ -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); diff --git a/package.json b/package.json index e5f0e20..ec13a10 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/accounts/snippet-data.ts b/src/accounts/snippet-data.ts new file mode 100644 index 0000000..981a02b --- /dev/null +++ b/src/accounts/snippet-data.ts @@ -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' }, +]; diff --git a/src/accounts/snippets.ts b/src/accounts/snippets.ts new file mode 100644 index 0000000..996426f --- /dev/null +++ b/src/accounts/snippets.ts @@ -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): 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 { + 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 { + 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(); + const snippets = await Promise.all(rows.results.map(async (row) => ({ + ...await decryptHost(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); +} diff --git a/src/worker.ts b/src/worker.ts index d01711f..58b88b6 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -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)); diff --git a/tests/browser/file-fixture.ts b/tests/browser/file-fixture.ts index 2e37a0a..acc4811 100644 --- a/tests/browser/file-fixture.ts +++ b/tests/browser/file-fixture.ts @@ -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) => { diff --git a/tests/browser/snippets.spec.ts b/tests/browser/snippets.spec.ts new file mode 100644 index 0000000..1b91b4e --- /dev/null +++ b/tests/browser/snippets.spec.ts @@ -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('部署检查