feat: initial commit for HookPilot

This commit is contained in:
吴璨
2026-09-27 20:09:10 +08:00
commit e998d4defc
73 changed files with 10782 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%232563eb'/><path d='M9 17l5 5 9-12' stroke='white' stroke-width='3.2' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>" />
<title>HookPilot</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@hookpilot/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.4",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"codemirror": "^6.0.2",
"vue": "^3.5.43",
"vue-router": "^5.3.1"
},
"devDependencies": {
"@types/node": "^22.20.4",
"@vitejs/plugin-vue": "^6.0.9",
"@vue/tsconfig": "^0.9.1",
"typescript": "~5.9.3",
"vite": "^8.3.1",
"vue-tsc": "^3.3.11"
}
}
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import DialogHost from './components/DialogHost.vue';
import ToastHost from './components/ToastHost.vue';
</script>
<template>
<RouterView />
<DialogHost />
<ToastHost />
</template>
+130
View File
@@ -0,0 +1,130 @@
import type {
ExecutionDetail,
ExecutionStatus,
ExecutionSummary,
HookDetail,
HookForm,
HookSummary,
LogsResponse,
Overview,
Paged,
ParamDef,
PreviewResult,
ScriptVersion,
ScriptVersionSummary,
Settings,
TestRequest,
TriggerType,
} from './types';
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly details?: unknown,
) {
super(message);
}
}
let unauthorizedHandler: (() => void) | null = null;
export function onUnauthorized(handler: () => void): void {
unauthorizedHandler = handler;
}
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
const init: RequestInit = { method, credentials: 'same-origin', headers: {} };
if (method !== 'GET') {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify(body ?? {});
}
let response: Response;
try {
response = await fetch(url, init);
} catch {
throw new ApiError(0, '无法连接到服务器');
}
const text = await response.text();
let data: unknown = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = null;
}
}
if (!response.ok) {
const payload = (data ?? {}) as { error?: string; details?: unknown };
if (response.status === 401 && url !== '/api/auth/login') unauthorizedHandler?.();
throw new ApiError(response.status, payload.error ?? `请求失败(HTTP ${response.status})`, payload.details);
}
return data as T;
}
function query(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== '') search.set(key, String(value));
}
const text = search.toString();
return text ? `?${text}` : '';
}
export const api = {
login: (username: string, password: string) =>
request<{ username: string }>('POST', '/api/auth/login', { username, password }),
logout: () => request<{ ok: boolean }>('POST', '/api/auth/logout'),
me: () => request<{ username: string }>('GET', '/api/auth/me'),
updateAccount: (body: { currentPassword: string; username?: string; newPassword?: string }) =>
request<{ username: string }>('PUT', '/api/auth/account', body),
overview: () => request<Overview>('GET', '/api/overview'),
listHooks: () => request<{ items: HookSummary[]; groups: string[] }>('GET', '/api/hooks'),
getHook: (id: number) => request<HookDetail>('GET', `/api/hooks/${id}`),
createHook: (form: HookForm) => request<{ hook: HookDetail; token: string }>('POST', '/api/hooks', form),
updateHook: (id: number, form: HookForm) => request<HookDetail>('PUT', `/api/hooks/${id}`, form),
deleteHook: (id: number) => request<{ ok: boolean }>('DELETE', `/api/hooks/${id}`),
copyHook: (id: number, slug: string, name: string) =>
request<{ hook: HookDetail; token: string }>('POST', `/api/hooks/${id}/copy`, { slug, name }),
setHookEnabled: (id: number, enabled: boolean) =>
request<{ hook: HookDetail; cancelled: number }>('POST', `/api/hooks/${id}/enabled`, { enabled }),
regenerateToken: (id: number) => request<{ token: string }>('POST', `/api/hooks/${id}/token`),
listVersions: (id: number) =>
request<{ currentVersionId: number | null; items: ScriptVersionSummary[] }>('GET', `/api/hooks/${id}/versions`),
getVersion: (id: number, versionId: number) =>
request<ScriptVersion>('GET', `/api/hooks/${id}/versions/${versionId}`),
previewTest: (id: number, requestBody: TestRequest, params: ParamDef[]) =>
request<PreviewResult>('POST', `/api/hooks/${id}/test`, { dryRun: true, params, request: requestBody }),
runTest: (id: number, requestBody: TestRequest) =>
request<{ executionId: number }>('POST', `/api/hooks/${id}/test`, { dryRun: false, request: requestBody }),
listExecutions: (filter: {
hookId?: number;
status?: ExecutionStatus | '';
trigger?: TriggerType | '';
page?: number;
pageSize?: number;
}) => request<Paged<ExecutionSummary>>('GET', `/api/executions${query(filter)}`),
getExecution: (id: number) => request<ExecutionDetail>('GET', `/api/executions/${id}`),
getLogs: (id: number, stdout: number, stderr: number) =>
request<LogsResponse>('GET', `/api/executions/${id}/logs${query({ stdout, stderr })}`),
cancelExecution: (id: number) =>
request<{ result: 'cancelled' | 'killing' }>('POST', `/api/executions/${id}/cancel`),
rerunExecution: (id: number) => request<{ executionId: number }>('POST', `/api/executions/${id}/rerun`),
getSettings: () => request<Settings>('GET', '/api/settings'),
updateSettings: (settings: Settings) => request<Settings>('PUT', '/api/settings', settings),
};
export function errorMessage(err: unknown): string {
if (err instanceof ApiError) {
if (Array.isArray(err.details) && err.details.length) return `${err.message}:${err.details.join(';')}`;
return err.message;
}
return err instanceof Error ? err.message : String(err);
}
+172
View File
@@ -0,0 +1,172 @@
export type HttpMethod = 'GET' | 'POST';
export type ResponseMode = 'sync' | 'async';
export type ParamSource = 'json' | 'form' | 'query' | 'header';
export type ParamType = 'string' | 'number' | 'boolean' | 'ip';
export type BodyKind = 'json' | 'form' | 'text' | 'none';
export type ExecutionStatus =
| 'queued'
| 'running'
| 'success'
| 'failed'
| 'timeout'
| 'cancelled'
| 'killed'
| 'interrupted';
export type TriggerType = 'webhook' | 'test' | 'rerun';
export interface ParamDef {
env: string;
source: ParamSource;
key: string;
type: ParamType;
required: boolean;
default: string;
sensitive: boolean;
description: string;
}
export interface HookForm {
slug: string;
name: string;
description: string;
group: string;
method: HttpMethod;
params: ParamDef[];
script: string;
workingDir: string;
timeoutSec: number;
responseMode: ResponseMode;
concurrency: number;
}
export interface HookDetail extends HookForm {
id: number;
enabled: boolean;
tokenHint: string;
currentVersion: { id: number; version: number; createdAt: number } | null;
createdAt: number;
updatedAt: number;
}
export interface HookSummary {
id: number;
slug: string;
name: string;
description: string;
group: string;
method: HttpMethod;
enabled: boolean;
responseMode: ResponseMode;
concurrency: number;
currentVersion: number | null;
lastStatus: ExecutionStatus | null;
lastAt: number | null;
updatedAt: number;
}
export interface ScriptVersionSummary {
id: number;
version: number;
createdAt: number;
}
export interface ScriptVersion extends ScriptVersionSummary {
content: string;
}
export interface ParamValue {
env: string;
value: string;
origin: 'request' | 'default' | 'empty';
sensitive: boolean;
}
export interface RequestSnapshot {
method: string;
contentType: string;
query: Record<string, string | string[]>;
headers: Record<string, string>;
bodyKind: BodyKind;
body: unknown;
}
export interface ExecutionSummary {
id: number;
hookId: number;
hookSlug: string;
hookName: string;
scriptVersionId: number;
scriptVersion: number;
trigger: TriggerType;
sourceExecutionId: number | null;
status: ExecutionStatus;
requestIp: string;
params: { env: string; value: string; sensitive: boolean }[];
exitCode: number | null;
signal: string | null;
error: string | null;
stdoutBytes: number;
stderrBytes: number;
truncated: boolean;
createdAt: number;
startedAt: number | null;
finishedAt: number | null;
durationMs: number | null;
}
export interface ExecutionDetail extends ExecutionSummary {
paramDefs: ParamDef[];
request: RequestSnapshot;
config: { workingDir: string; timeoutSec: number; responseMode: ResponseMode };
script: string;
}
export interface LogChunk {
data: string;
nextOffset: number;
more: boolean;
}
export interface LogsResponse {
status: ExecutionStatus;
finished: boolean;
truncated: boolean;
stdout: LogChunk;
stderr: LogChunk;
}
export interface Overview {
hooks: { total: number; enabled: number };
running: number;
queued: number;
last24h: { success: number; failed: number; total: number };
recent: ExecutionSummary[];
recentFailures: ExecutionSummary[];
}
export interface Settings {
globalConcurrency: number;
retentionDays: number;
outputLimitBytes: number;
}
export interface Paged<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface TestRequest {
query: string;
headers: Record<string, string>;
bodyType: BodyKind;
body: string;
}
export interface PreviewResult {
ok: boolean;
values: ParamValue[];
errors: string[];
request: RequestSnapshot;
}
+158
View File
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { api } from '../api/client';
import { currentUser, setUser } from '../composables/auth';
const route = useRoute();
const router = useRouter();
const NAV = [
{ name: 'overview', label: '概览', icon: 'M3 12l9-8 9 8M5 10v10h14V10' },
{ name: 'hooks', label: '接口管理', icon: 'M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7' },
{ name: 'executions', label: '执行记录', icon: 'M4 6h16M4 12h16M4 18h10' },
{ name: 'settings', label: '系统设置', icon: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z' },
{ name: 'account', label: '账户设置', icon: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z' },
];
const activeNav = computed(() => (route.meta.nav as string | undefined) ?? (route.name as string));
async function logout() {
try {
await api.logout();
} finally {
setUser(null);
await router.push({ name: 'login' });
}
}
</script>
<template>
<div class="layout">
<aside class="sidebar">
<div class="brand">
<svg viewBox="0 0 32 32" width="26" height="26" aria-hidden="true">
<rect width="32" height="32" rx="7" fill="#2563eb" />
<path d="M9 17l5 5 9-12" stroke="#fff" stroke-width="3.2" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>HookPilot</span>
</div>
<nav>
<RouterLink
v-for="item in NAV"
:key="item.name"
:to="{ name: item.name }"
class="nav-item"
:class="{ active: activeNav === item.name }"
>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path :d="item.icon" />
</svg>
{{ item.label }}
</RouterLink>
</nav>
<div class="sidebar-footer">
<span class="user">{{ currentUser }}</span>
<button class="btn btn-ghost btn-sm logout" @click="logout">退出</button>
</div>
</aside>
<main class="content">
<RouterView :key="route.path" />
</main>
</div>
</template>
<style scoped>
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
width: 208px;
height: 100vh;
flex-shrink: 0;
background: var(--sidebar);
color: var(--sidebar-text);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 18px 22px;
color: #fff;
font-size: 17px;
font-weight: 600;
}
nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 10px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 6px;
color: var(--sidebar-text);
font-weight: 500;
}
.nav-item:hover {
background: var(--sidebar-active);
color: #fff;
text-decoration: none;
}
.nav-item.active {
background: var(--primary);
color: #fff;
}
.sidebar-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 18px;
border-top: 1px solid #1e293b;
}
.user {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.logout {
color: var(--sidebar-text);
}
.logout:hover:not(:disabled) {
background: var(--sidebar-active);
color: #fff;
}
.content {
flex: 1;
min-width: 0;
padding: 24px 28px 40px;
max-width: 1320px;
}
@media (max-width: 860px) {
.layout {
flex-direction: column;
}
.sidebar {
position: static;
width: 100%;
height: auto;
}
nav {
flex-direction: row;
flex-wrap: wrap;
padding-bottom: 10px;
}
.content {
padding: 18px 14px 32px;
}
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { closeDialog, dialogState } from '../composables/dialog';
import ModalDialog from './ModalDialog.vue';
const firstInput = ref<HTMLInputElement>();
watch(
() => dialogState.open,
async (open) => {
if (open && dialogState.options?.input) {
await nextTick();
firstInput.value?.focus();
firstInput.value?.select();
}
},
);
</script>
<template>
<ModalDialog :open="dialogState.open" :title="dialogState.options?.title ?? ''" @close="closeDialog(false)">
<template v-if="dialogState.options">
<div v-if="dialogState.options.warning" class="alert alert-warning">
<span class="alert-icon">!</span>
<span>{{ dialogState.options.warning }}</span>
</div>
<p style="margin: 0; white-space: pre-line">{{ dialogState.options.message }}</p>
<form v-if="dialogState.options.input" class="stack" @submit.prevent="closeDialog(true)">
<div class="field">
<label>{{ dialogState.options.input.label }}</label>
<input ref="firstInput" v-model="dialogState.value" :placeholder="dialogState.options.input.placeholder" />
</div>
<div v-if="dialogState.options.input2" class="field">
<label>{{ dialogState.options.input2.label }}</label>
<input v-model="dialogState.value2" :placeholder="dialogState.options.input2.placeholder" />
</div>
<button type="submit" hidden />
</form>
</template>
<template #footer>
<button class="btn" @click="closeDialog(false)">取消</button>
<button
class="btn"
:class="dialogState.options?.danger ? 'btn-danger' : 'btn-primary'"
@click="closeDialog(true)"
>
{{ dialogState.options?.confirmText ?? '确定' }}
</button>
</template>
</ModalDialog>
</template>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue';
const props = withDefaults(defineProps<{ open: boolean; title: string; wide?: boolean; closable?: boolean }>(), {
wide: false,
closable: true,
});
const emit = defineEmits<{ close: [] }>();
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && props.closable) emit('close');
}
watch(
() => props.open,
(open) => {
if (open) window.addEventListener('keydown', onKey);
else window.removeEventListener('keydown', onKey);
},
{ immediate: true },
);
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
</script>
<template>
<Teleport to="body">
<div v-if="open" class="modal-backdrop" @mousedown.self="closable && emit('close')">
<div class="modal" :class="{ wide }" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>{{ title }}</h2>
<span class="spacer" />
<button v-if="closable" class="btn btn-ghost btn-sm" aria-label="关闭" @click="emit('close')">✕</button>
</div>
<div class="modal-body">
<slot />
</div>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer" />
</div>
</div>
</div>
</Teleport>
</template>
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { api, errorMessage } from '../api/client';
import type { ExecutionStatus } from '../api/types';
import { formatBytes } from '../utils/format';
const props = defineProps<{ executionId: number }>();
const emit = defineEmits<{ status: [status: ExecutionStatus, finished: boolean] }>();
const POLL_MS = 1000;
const output = ref({ stdout: '', stderr: '' });
const offsets = { stdout: 0, stderr: 0 };
const active = ref<'stdout' | 'stderr'>('stdout');
const truncated = ref(false);
const finished = ref(false);
const error = ref('');
const follow = ref(true);
const pane = ref<HTMLPreElement>();
let timer: number | undefined;
let disposed = false;
let lastStatus: ExecutionStatus | null = null;
async function poll(): Promise<void> {
if (disposed) return;
try {
const res = await api.getLogs(props.executionId, offsets.stdout, offsets.stderr);
if (disposed) return;
error.value = '';
if (res.stdout.data) output.value.stdout += res.stdout.data;
if (res.stderr.data) output.value.stderr += res.stderr.data;
offsets.stdout = res.stdout.nextOffset;
offsets.stderr = res.stderr.nextOffset;
truncated.value = res.truncated;
if (res.status !== lastStatus) {
lastStatus = res.status;
emit('status', res.status, res.finished);
}
if (follow.value) void scrollToEnd();
const more = res.stdout.more || res.stderr.more;
// 结束后再读一轮,确保最后一批输出已入库
if (res.finished && !more) {
if (finished.value) return;
finished.value = true;
}
timer = window.setTimeout(poll, more ? 0 : POLL_MS);
} catch (err) {
error.value = errorMessage(err);
timer = window.setTimeout(poll, POLL_MS * 3);
}
}
async function scrollToEnd() {
await nextTick();
if (pane.value) pane.value.scrollTop = pane.value.scrollHeight;
}
function onScroll() {
const el = pane.value;
if (el) follow.value = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}
function select(stream: 'stdout' | 'stderr') {
active.value = stream;
follow.value = true;
void scrollToEnd();
}
onMounted(poll);
onBeforeUnmount(() => {
disposed = true;
window.clearTimeout(timer);
});
</script>
<template>
<div class="output-viewer">
<div class="output-toolbar">
<div class="tabs">
<button class="tab" :class="{ active: active === 'stdout' }" @click="select('stdout')">
stdout <span class="count">{{ formatBytes(offsets.stdout) }}</span>
</button>
<button class="tab" :class="{ active: active === 'stderr' }" @click="select('stderr')">
stderr <span class="count">{{ formatBytes(offsets.stderr) }}</span>
</button>
</div>
<span class="spacer" />
<span v-if="!finished" class="badge badge-info dot pulse">实时更新中</span>
<span v-if="truncated" class="badge badge-warning" title="输出超过上限,超出部分已丢弃">输出已截断</span>
</div>
<div v-if="error" class="alert alert-danger small">读取输出失败:{{ error }}</div>
<pre ref="pane" class="code-block output-pane" @scroll="onScroll">{{ output[active] || (finished ? '(无输出)' : '等待输出…') }}</pre>
</div>
</template>
<style scoped>
.output-viewer {
display: flex;
flex-direction: column;
gap: 10px;
}
.output-toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.output-toolbar .tabs {
border-bottom: none;
}
.output-pane {
min-height: 220px;
max-height: 560px;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { EditorView, basicSetup } from 'codemirror';
import { Compartment, EditorState } from '@codemirror/state';
import { keymap } from '@codemirror/view';
import { indentWithTab } from '@codemirror/commands';
import { StreamLanguage, indentUnit } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
const props = withDefaults(defineProps<{ modelValue: string; readonly?: boolean; height?: string }>(), {
readonly: false,
height: '460px',
});
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
const host = ref<HTMLDivElement>();
const editable = new Compartment();
let view: EditorView | null = null;
function editableExtensions(readonly: boolean) {
return [EditorState.readOnly.of(readonly), EditorView.editable.of(!readonly)];
}
onMounted(() => {
view = new EditorView({
parent: host.value!,
state: EditorState.create({
doc: props.modelValue,
extensions: [
basicSetup,
keymap.of([indentWithTab]),
StreamLanguage.define(shell),
indentUnit.of(' '),
EditorState.tabSize.of(2),
editable.of(editableExtensions(props.readonly)),
EditorView.updateListener.of((update) => {
if (update.docChanged) emit('update:modelValue', update.state.doc.toString());
}),
EditorView.theme({
'&': { height: props.height, fontSize: '13px' },
'.cm-scroller': { fontFamily: 'var(--mono)', lineHeight: '1.6' },
'.cm-gutters': { background: 'var(--surface-2)', borderRight: '1px solid var(--border)' },
}),
],
}),
});
});
watch(
() => props.modelValue,
(value) => {
if (view && value !== view.state.doc.toString()) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
}
},
);
watch(
() => props.readonly,
(readonly) => view?.dispatch({ effects: editable.reconfigure(editableExtensions(readonly)) }),
);
onBeforeUnmount(() => view?.destroy());
</script>
<template>
<div ref="host" class="script-editor" />
</template>
<style scoped>
.script-editor {
border: 1px solid var(--border-strong);
border-radius: var(--radius);
overflow: hidden;
background: var(--surface);
}
.script-editor :deep(.cm-focused) {
outline: none;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { ExecutionStatus } from '../api/types';
import { STATUS_LABELS } from '../utils/format';
const props = defineProps<{ status: ExecutionStatus }>();
const variant = computed(() => {
switch (props.status) {
case 'success':
return 'badge-success';
case 'failed':
case 'timeout':
case 'killed':
return 'badge-danger';
case 'interrupted':
return 'badge-warning';
case 'running':
case 'queued':
return 'badge-info';
default:
return '';
}
});
</script>
<template>
<span class="badge dot" :class="[variant, { pulse: status === 'running' }]">{{ STATUS_LABELS[status] }}</span>
</template>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { dismissToast, toasts } from '../composables/toast';
</script>
<template>
<div class="toast-host">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type" @click="dismissToast(t.id)">
{{ t.message }}
</div>
</div>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { HttpMethod } from '../api/types';
import { toast } from '../composables/toast';
import { copyText, webhookUrl } from '../utils/format';
import ModalDialog from './ModalDialog.vue';
const props = defineProps<{ token: string | null; slug: string; method: HttpMethod }>();
const emit = defineEmits<{ close: [] }>();
const url = computed(() => webhookUrl(props.slug));
const curl = computed(() => {
const lines = [`curl -X ${props.method} '${url.value}'`, ` -H 'Authorization: Bearer ${props.token}'`];
if (props.method === 'POST') {
lines.push(` -H 'Content-Type: application/json'`, ` -d '{}'`);
}
return lines.join(' \\\n');
});
async function copy(text: string, label: string) {
if (await copyText(text)) toast.success(`${label}已复制`);
else toast.error('复制失败,请手动选择复制');
}
</script>
<template>
<ModalDialog :open="!!token" title="接口 Token" :closable="false" @close="emit('close')">
<div class="alert alert-warning">
<span class="alert-icon">!</span>
<span>Token 只在此处完整显示一次,关闭后无法再次查看。请立即保存到调用方配置中;遗失后只能重新生成。</span>
</div>
<div class="field">
<label>Token</label>
<div class="row">
<input :value="token ?? ''" readonly class="mono" @focus="($event.target as HTMLInputElement).select()" />
<button class="btn" @click="copy(token ?? '', 'Token ')">复制</button>
</div>
</div>
<div class="field">
<label>调用示例</label>
<pre class="code-block">{{ curl }}</pre>
<div class="hint">
也可通过请求头 <code>X-Hook-Token</code> 或 Query 参数 <code>?token=</code> 传递(Query 方式可能被代理日志记录,不推荐)。
</div>
</div>
<template #footer>
<button class="btn" @click="copy(curl, '调用示例')">复制示例</button>
<button class="btn btn-primary" @click="emit('close')">我已保存</button>
</template>
</ModalDialog>
</template>
+22
View File
@@ -0,0 +1,22 @@
import { ref } from 'vue';
import { api } from '../api/client';
export const currentUser = ref<string | null>(null);
let checked = false;
/** 首次访问时向后端确认登录状态 */
export async function ensureSession(): Promise<boolean> {
if (checked) return currentUser.value !== null;
try {
currentUser.value = (await api.me()).username;
} catch {
currentUser.value = null;
}
checked = true;
return currentUser.value !== null;
}
export function setUser(username: string | null): void {
currentUser.value = username;
checked = true;
}
+48
View File
@@ -0,0 +1,48 @@
import { reactive } from 'vue';
export interface DialogOptions {
title: string;
message: string;
confirmText?: string;
danger?: boolean;
/** 醒目提示,用于标明会真实操作服务器的动作 */
warning?: string;
input?: { label: string; placeholder?: string; value?: string };
input2?: { label: string; placeholder?: string; value?: string };
}
interface DialogState {
open: boolean;
options: DialogOptions | null;
value: string;
value2: string;
resolve: ((result: { value: string; value2: string } | null) => void) | null;
}
export const dialogState = reactive<DialogState>({ open: false, options: null, value: '', value2: '', resolve: null });
function openDialog(options: DialogOptions): Promise<{ value: string; value2: string } | null> {
dialogState.resolve?.(null);
return new Promise((resolve) => {
dialogState.options = options;
dialogState.value = options.input?.value ?? '';
dialogState.value2 = options.input2?.value ?? '';
dialogState.resolve = resolve;
dialogState.open = true;
});
}
export function closeDialog(confirmed: boolean): void {
const resolve = dialogState.resolve;
dialogState.open = false;
dialogState.resolve = null;
resolve?.(confirmed ? { value: dialogState.value, value2: dialogState.value2 } : null);
}
export async function confirmDialog(options: DialogOptions): Promise<boolean> {
return (await openDialog(options)) !== null;
}
export function promptDialog(options: DialogOptions): Promise<{ value: string; value2: string } | null> {
return openDialog(options);
}
+27
View File
@@ -0,0 +1,27 @@
import { reactive } from 'vue';
export interface Toast {
id: number;
type: 'success' | 'error' | 'info';
message: string;
}
let nextId = 1;
export const toasts = reactive<Toast[]>([]);
function push(type: Toast['type'], message: string, timeout: number): void {
const id = nextId++;
toasts.push({ id, type, message });
setTimeout(() => dismissToast(id), timeout);
}
export function dismissToast(id: number): void {
const index = toasts.findIndex((t) => t.id === id);
if (index >= 0) toasts.splice(index, 1);
}
export const toast = {
success: (message: string) => push('success', message, 3000),
error: (message: string) => push('error', message, 6000),
info: (message: string) => push('info', message, 4000),
};
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<object, object, unknown>;
export default component;
}
+16
View File
@@ -0,0 +1,16 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { onUnauthorized } from './api/client';
import { setUser } from './composables/auth';
import './styles.css';
onUnauthorized(() => {
setUser(null);
const current = router.currentRoute.value;
if (current.name !== 'login') {
void router.push({ name: 'login', query: { redirect: current.fullPath } });
}
});
createApp(App).use(router).mount('#app');
+72
View File
@@ -0,0 +1,72 @@
import { createRouter, createWebHistory } from 'vue-router';
import { ensureSession } from './composables/auth';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('./views/LoginView.vue'), meta: { public: true } },
{
path: '/',
component: () => import('./components/AppLayout.vue'),
children: [
{ path: '', name: 'overview', component: () => import('./views/OverviewView.vue'), meta: { title: '概览' } },
{
path: 'webhooks',
name: 'hooks',
component: () => import('./views/HookListView.vue'),
meta: { title: '接口管理' },
},
{
path: 'webhooks/new',
name: 'hook-new',
component: () => import('./views/HookDetailView.vue'),
meta: { title: '新建接口', nav: 'hooks' },
},
{
path: 'webhooks/:id(\\d+)',
name: 'hook-detail',
component: () => import('./views/HookDetailView.vue'),
meta: { title: '接口详情', nav: 'hooks' },
},
{
path: 'executions',
name: 'executions',
component: () => import('./views/ExecutionListView.vue'),
meta: { title: '执行记录' },
},
{
path: 'executions/:id(\\d+)',
name: 'execution-detail',
component: () => import('./views/ExecutionDetailView.vue'),
meta: { title: '执行详情', nav: 'executions' },
},
{
path: 'settings',
name: 'settings',
component: () => import('./views/SettingsView.vue'),
meta: { title: '系统设置' },
},
{
path: 'account',
name: 'account',
component: () => import('./views/AccountView.vue'),
meta: { title: '账户设置' },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
});
router.beforeEach(async (to) => {
if (to.meta.public) return true;
if (await ensureSession()) return true;
return { name: 'login', query: to.fullPath !== '/' ? { redirect: to.fullPath } : {} };
});
router.afterEach((to) => {
const title = to.meta.title as string | undefined;
document.title = title ? `${title} · HookPilot` : 'HookPilot';
});
export default router;
+663
View File
@@ -0,0 +1,663 @@
:root {
--bg: #f5f7fb;
--surface: #ffffff;
--surface-2: #f8fafc;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--text: #0f172a;
--text-2: #475569;
--text-3: #94a3b8;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-soft: #eff6ff;
--success: #16a34a;
--success-soft: #f0fdf4;
--danger: #dc2626;
--danger-hover: #b91c1c;
--danger-soft: #fef2f2;
--warning: #b45309;
--warning-soft: #fffbeb;
--warning-border: #fcd34d;
--neutral-soft: #f1f5f9;
--sidebar: #0f172a;
--sidebar-text: #cbd5e1;
--sidebar-active: #1e293b;
--radius: 8px;
--shadow: 0 1px 2px rgba(15, 23, 42, 0.05), 0 1px 3px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 20px 45px rgba(15, 23, 42, 0.18);
--mono: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, 'Liberation Mono', monospace;
color-scheme: light;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 600;
line-height: 1.3;
}
h1 {
font-size: 20px;
}
h2 {
font-size: 16px;
}
h3 {
font-size: 14px;
}
code,
pre,
.mono {
font-family: var(--mono);
font-size: 12.5px;
}
code {
background: var(--neutral-soft);
border-radius: 4px;
padding: 1px 5px;
}
pre {
margin: 0;
}
.muted {
color: var(--text-2);
}
.faint {
color: var(--text-3);
}
.small {
font-size: 12px;
}
.nowrap {
white-space: nowrap;
}
.break {
word-break: break-all;
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.row.wrap {
flex-wrap: wrap;
}
.spacer {
flex: 1;
}
.stack {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 按钮 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 34px;
padding: 0 14px;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--surface);
color: var(--text);
font: inherit;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s;
}
.btn:hover:not(:disabled) {
background: var(--surface-2);
border-color: var(--text-3);
text-decoration: none;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.btn-danger {
background: var(--danger);
border-color: var(--danger);
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: var(--danger-hover);
border-color: var(--danger-hover);
}
.btn-danger-outline {
color: var(--danger);
border-color: #fca5a5;
}
.btn-danger-outline:hover:not(:disabled) {
background: var(--danger-soft);
border-color: var(--danger);
}
.btn-ghost {
border-color: transparent;
background: transparent;
}
.btn-ghost:hover:not(:disabled) {
background: var(--neutral-soft);
border-color: transparent;
}
.btn-sm {
height: 28px;
padding: 0 10px;
font-size: 13px;
}
/* 表单 */
input[type='text'],
input[type='password'],
input[type='number'],
input:not([type]),
select,
textarea {
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--surface);
color: var(--text);
font: inherit;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
textarea {
height: auto;
padding: 8px 10px;
resize: vertical;
line-height: 1.5;
}
textarea.mono {
font-family: var(--mono);
font-size: 12.5px;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
input:disabled,
select:disabled,
textarea:disabled {
background: var(--neutral-soft);
color: var(--text-2);
}
input[type='checkbox'],
input[type='radio'] {
accent-color: var(--primary);
width: 15px;
height: 15px;
margin: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field > label,
.field-label {
font-weight: 500;
color: var(--text);
}
.field .hint {
font-size: 12px;
color: var(--text-2);
}
.field .required::after {
content: ' *';
color: var(--danger);
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 20px;
}
.form-grid .full {
grid-column: 1 / -1;
}
.check {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.input-group {
display: flex;
align-items: stretch;
}
.input-group .addon {
display: flex;
align-items: center;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-right: none;
border-radius: 6px 0 0 6px;
background: var(--neutral-soft);
color: var(--text-2);
font-family: var(--mono);
font-size: 12.5px;
white-space: nowrap;
}
.input-group input {
border-radius: 0 6px 6px 0;
}
.radio-cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.radio-card {
display: flex;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
cursor: pointer;
}
.radio-card.active {
border-color: var(--primary);
background: var(--primary-soft);
}
.radio-card input {
margin-top: 3px;
}
/* 卡片 */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
.card-body {
padding: 18px;
}
.card-body.flush {
padding: 0;
}
/* 页面头部 */
.page-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.page-header .subtitle {
color: var(--text-2);
margin-top: 2px;
}
/* 表格 */
.table-wrap {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.table th {
background: var(--surface-2);
color: var(--text-2);
font-weight: 500;
font-size: 12.5px;
white-space: nowrap;
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table tbody tr.clickable {
cursor: pointer;
}
.table tbody tr.clickable:hover td {
background: var(--surface-2);
}
.table.compact th,
.table.compact td {
padding: 7px 10px;
}
.table .group-row td {
background: var(--neutral-soft);
color: var(--text-2);
font-weight: 500;
font-size: 12.5px;
padding-top: 6px;
padding-bottom: 6px;
}
/* 徽标 */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 1px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
background: var(--neutral-soft);
color: var(--text-2);
}
.badge.dot::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.badge-success {
background: var(--success-soft);
color: var(--success);
}
.badge-danger {
background: var(--danger-soft);
color: var(--danger);
}
.badge-warning {
background: var(--warning-soft);
color: var(--warning);
}
.badge-info {
background: var(--primary-soft);
color: var(--primary);
}
.badge-method {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.02em;
border-radius: 4px;
}
.badge.pulse::before {
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.25;
}
}
/* 提示条 */
.alert {
display: flex;
gap: 10px;
padding: 10px 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface-2);
line-height: 1.6;
}
.alert-warning {
background: var(--warning-soft);
border-color: var(--warning-border);
color: #78350f;
}
.alert-danger {
background: var(--danger-soft);
border-color: #fecaca;
color: #7f1d1d;
}
.alert-info {
background: var(--primary-soft);
border-color: #bfdbfe;
color: #1e3a8a;
}
.alert-success {
background: var(--success-soft);
border-color: #bbf7d0;
color: #14532d;
}
.alert .alert-icon {
font-weight: 700;
flex-shrink: 0;
}
/* 标签页 */
.tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.tab {
padding: 10px 16px;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--text-2);
font: inherit;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
margin-bottom: -1px;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab .count {
margin-left: 4px;
color: var(--text-3);
font-weight: 400;
}
/* 统计卡片 */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 14px;
}
.stat-card {
padding: 16px 18px;
}
.stat-card .label {
color: var(--text-2);
font-size: 13px;
}
.stat-card .value {
font-size: 26px;
font-weight: 600;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.stat-card .sub {
color: var(--text-3);
font-size: 12px;
}
/* 键值列表 */
.kv {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 10px 16px;
margin: 0;
}
.kv dt {
color: var(--text-2);
}
.kv dd {
margin: 0;
word-break: break-all;
}
/* 代码块 */
.code-block {
background: #0f172a;
color: #e2e8f0;
border-radius: var(--radius);
padding: 12px 14px;
overflow: auto;
max-height: 520px;
white-space: pre-wrap;
word-break: break-all;
font-family: var(--mono);
font-size: 12.5px;
line-height: 1.6;
}
.code-block.light {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
}
.empty {
padding: 40px 20px;
text-align: center;
color: var(--text-3);
}
.pagination {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
color: var(--text-2);
}
/* 弹窗 */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 10vh 16px 16px;
z-index: 100;
overflow-y: auto;
}
.modal {
width: 100%;
max-width: 480px;
background: var(--surface);
border-radius: 10px;
box-shadow: var(--shadow-lg);
}
.modal.wide {
max-width: 820px;
}
.modal-header {
display: flex;
align-items: center;
padding: 16px 20px 0;
}
.modal-body {
padding: 14px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 0 20px 18px;
}
/* 通知 */
.toast-host {
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 200;
max-width: min(420px, calc(100vw - 32px));
}
.toast {
padding: 10px 14px;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
box-shadow: var(--shadow-lg);
cursor: pointer;
word-break: break-word;
}
.toast.success {
border-left-color: var(--success);
}
.toast.error {
border-left-color: var(--danger);
}
@media (max-width: 860px) {
.form-grid,
.radio-cards {
grid-template-columns: minmax(0, 1fr);
}
.kv {
grid-template-columns: 90px minmax(0, 1fr);
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { ExecutionStatus, ParamSource, ParamType, TriggerType } from '../api/types';
export const STATUS_LABELS: Record<ExecutionStatus, string> = {
queued: '排队中',
running: '运行中',
success: '成功',
failed: '失败',
timeout: '超时',
cancelled: '已取消',
killed: '已终止',
interrupted: '中断',
};
export const TRIGGER_LABELS: Record<TriggerType, string> = {
webhook: '外部调用',
test: '在线测试',
rerun: '再次执行',
};
export const SOURCE_LABELS: Record<ParamSource, string> = {
json: 'JSON 请求体',
form: '表单请求体',
query: 'Query 参数',
header: '请求头',
};
export const TYPE_LABELS: Record<ParamType, string> = {
string: '字符串',
number: '数字',
boolean: '布尔值',
ip: 'IP 地址',
};
export function isActive(status: ExecutionStatus): boolean {
return status === 'queued' || status === 'running';
}
export function formatTime(ms: number | null | undefined): string {
if (!ms) return '—';
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function formatRelative(ms: number | null | undefined): string {
if (!ms) return '—';
const diff = Date.now() - ms;
if (diff < 60_000) return '刚刚';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`;
if (diff < 30 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`;
return formatTime(ms);
}
export function formatDuration(ms: number | null | undefined): string {
if (ms === null || ms === undefined) return '—';
if (ms < 1000) return `${ms} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.round((ms % 60_000) / 1000);
return `${minutes} 分 ${seconds} 秒`;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
}
export function webhookUrl(slug: string): string {
return `${window.location.origin}/hooks/${slug || '<标识>'}`;
}
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// 非 HTTPS 环境下 clipboard API 不可用,退回旧方法
const area = document.createElement('textarea');
area.value = text;
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
const ok = document.execCommand('copy');
area.remove();
return ok;
}
}
+94
View File
@@ -0,0 +1,94 @@
export interface ScriptTemplate {
name: string;
description: string;
content: string;
}
export const SCRIPT_TEMPLATES: ScriptTemplate[] = [
{
name: '基础模板',
description: '严格模式、打印平台注入的变量',
content: `#!/usr/bin/env bash
# 参数通过环境变量传入(HOOK_ 前缀),请始终用双引号引用,如 "$HOOK_NAME"
set -euo pipefail
echo "执行 ID:$HOOK_EXECUTION_ID"
echo "接口标识:$HOOK_SLUG"
echo "触发方式:$HOOK_TRIGGER"
echo "完整请求:$HOOK_REQUEST_FILE"
# 在这里编写你的操作
`,
},
{
name: '更新家庭 IP',
description: '接收 HOOK_IP 并写入文件(配合 Lucky DDNS Webhook)',
content: `#!/usr/bin/env bash
set -euo pipefail
# 需要在「参数」中配置:HOOK_IP ← JSON 字段 ip,类型 IP,必填
TARGET_FILE="/var/lib/hookpilot/workspace/home-ip.txt"
old_ip=""
if [[ -f "$TARGET_FILE" ]]; then
old_ip="$(cat "$TARGET_FILE")"
fi
if [[ "$old_ip" == "$HOOK_IP" ]]; then
echo "IP 未变化:$HOOK_IP"
exit 0
fi
# 先写临时文件再重命名,避免读到写了一半的内容
printf '%s\\n' "$HOOK_IP" > "$TARGET_FILE.tmp"
mv "$TARGET_FILE.tmp" "$TARGET_FILE"
echo "IP 已更新:\${old_ip:-无} -> $HOOK_IP"
`,
},
{
name: 'Git 拉取并部署',
description: '在工作目录中拉取代码后执行构建',
content: `#!/usr/bin/env bash
set -euo pipefail
# 请在「执行策略」中把工作目录设置为仓库路径,并适当调大超时时间
echo "当前目录:$(pwd)"
git fetch --prune
git reset --hard "origin/\${HOOK_BRANCH:-main}"
git log -1 --oneline
# npm ci && npm run build
`,
},
{
name: '重启系统服务',
description: '通过预先配置的 sudo 规则重启服务',
content: `#!/usr/bin/env bash
set -euo pipefail
# 需要部署者在 sudoers 中为服务账户单独授权该命令,例如:
# hookpilot ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx
SERVICE="nginx"
sudo -n /usr/bin/systemctl restart "$SERVICE"
systemctl is-active "$SERVICE"
`,
},
{
name: '读取完整请求',
description: '用 jq 从 HOOK_REQUEST_FILE 读取复杂字段',
content: `#!/usr/bin/env bash
set -euo pipefail
# HOOK_REQUEST_FILE 是本次任务专属的 JSON 文件,结构:
# { method, contentType, query, headers, bodyKind, body }
if ! command -v jq >/dev/null 2>&1; then
echo "需要安装 jq" >&2
exit 1
fi
jq '.body' "$HOOK_REQUEST_FILE"
jq -r '.headers["user-agent"] // "unknown"' "$HOOK_REQUEST_FILE"
`,
},
];
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { api, errorMessage } from '../api/client';
import { currentUser, setUser } from '../composables/auth';
import { toast } from '../composables/toast';
const form = reactive({
username: currentUser.value ?? '',
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const saving = ref(false);
async function save() {
if (!form.currentPassword) {
toast.error('请输入当前密码');
return;
}
if (form.newPassword && form.newPassword !== form.confirmPassword) {
toast.error('两次输入的新密码不一致');
return;
}
saving.value = true;
try {
const res = await api.updateAccount({
currentPassword: form.currentPassword,
username: form.username.trim(),
newPassword: form.newPassword || undefined,
});
setUser(res.username);
toast.success(form.newPassword ? '账户已更新,其他设备上的登录已失效' : '账户已更新');
form.currentPassword = '';
form.newPassword = '';
form.confirmPassword = '';
} catch (err) {
toast.error(errorMessage(err));
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="page-header">
<div>
<h1>账户设置</h1>
<div class="subtitle">平台只有一个管理员账户,不开放注册</div>
</div>
</div>
<div class="card" style="max-width: 560px">
<form class="card-body stack" autocomplete="off" @submit.prevent="save">
<div class="field">
<label class="required">用户名</label>
<input v-model="form.username" autocomplete="username" maxlength="32" />
</div>
<div class="field">
<label>新密码</label>
<input v-model="form.newPassword" type="password" autocomplete="new-password" placeholder="不修改请留空" />
<div class="hint">至少 8 个字符。修改后其他设备上的登录会话全部失效。</div>
</div>
<div v-if="form.newPassword" class="field">
<label class="required">确认新密码</label>
<input v-model="form.confirmPassword" type="password" autocomplete="new-password" />
</div>
<div class="field">
<label class="required">当前密码</label>
<input v-model="form.currentPassword" type="password" autocomplete="current-password" />
<div class="hint">修改任何账户信息都需要验证当前密码。</div>
</div>
<div>
<button class="btn btn-primary" type="submit" :disabled="saving">{{ saving ? '保存中…' : '保存' }}</button>
</div>
<div class="small faint">忘记密码时,可在服务器上执行 <code>npm run reset-password</code> 重置。</div>
</form>
</div>
</template>
+239
View File
@@ -0,0 +1,239 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { api, errorMessage } from '../api/client';
import type { ExecutionDetail, ExecutionStatus } from '../api/types';
import OutputViewer from '../components/OutputViewer.vue';
import ScriptEditor from '../components/ScriptEditor.vue';
import StatusBadge from '../components/StatusBadge.vue';
import { confirmDialog } from '../composables/dialog';
import { toast } from '../composables/toast';
import { formatBytes, formatDuration, formatTime, isActive, TRIGGER_LABELS } from '../utils/format';
const route = useRoute();
const router = useRouter();
const id = Number(route.params.id);
const detail = ref<ExecutionDetail | null>(null);
const loading = ref(true);
const section = ref<'output' | 'request' | 'script'>('output');
const acting = ref(false);
const bodyText = computed(() => {
const req = detail.value?.request;
if (!req || req.bodyKind === 'none') return '';
return typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2);
});
async function load() {
try {
detail.value = await api.getExecution(id);
} catch (err) {
toast.error(errorMessage(err));
} finally {
loading.value = false;
}
}
function onStatus(status: ExecutionStatus, finished: boolean) {
if (!detail.value) return;
// 状态变化时刷新详情,获取退出码、耗时等结果
if (status !== detail.value.status || finished) void load();
}
async function cancel() {
if (!detail.value) return;
const running = detail.value.status === 'running';
const ok = await confirmDialog({
title: running ? '终止任务' : '取消任务',
message: running
? '将终止该任务的整个进程组(先 SIGTERM,3 秒后 SIGKILL)。终止只能停止后续执行,无法撤销脚本已经完成的操作(如已写入的文件)。'
: '该任务尚未开始,取消后不会执行。',
confirmText: running ? '终止' : '取消任务',
danger: true,
});
if (!ok) return;
acting.value = true;
try {
const res = await api.cancelExecution(id);
toast.success(res.result === 'cancelled' ? '任务已取消' : '已发送终止信号');
await load();
} catch (err) {
toast.error(errorMessage(err));
} finally {
acting.value = false;
}
}
async function rerun() {
if (!detail.value) return;
const ok = await confirmDialog({
title: '再次执行',
message: `将使用本次记录的脚本 v${detail.value.scriptVersion} 与相同参数创建新任务,执行策略(工作目录、超时)采用接口当前配置。新任务会记录来源 #${detail.value.id}。`,
warning: '再次执行会在服务器上真实运行脚本,其修改会实际生效且无法撤销。',
confirmText: '确认执行',
danger: true,
});
if (!ok) return;
acting.value = true;
try {
const res = await api.rerunExecution(id);
toast.success(`已创建任务 #${res.executionId}`);
await router.push({ name: 'execution-detail', params: { id: res.executionId } });
} catch (err) {
toast.error(errorMessage(err));
} finally {
acting.value = false;
}
}
onMounted(load);
</script>
<template>
<div v-if="loading" class="empty">加载中…</div>
<div v-else-if="!detail" class="empty">
执行记录不存在或已被清理。<RouterLink :to="{ name: 'executions' }">返回列表</RouterLink>
</div>
<template v-else>
<div class="page-header">
<div>
<div class="small muted">
<RouterLink :to="{ name: 'executions' }">执行记录</RouterLink>
<span> / #{{ detail.id }}</span>
</div>
<div class="row" style="margin-top: 4px">
<h1>执行 #{{ detail.id }}</h1>
<StatusBadge :status="detail.status" />
</div>
<div class="subtitle">
<RouterLink :to="{ name: 'hook-detail', params: { id: detail.hookId } }">{{ detail.hookName }}</RouterLink>
<span class="mono faint"> /hooks/{{ detail.hookSlug }}</span>
</div>
</div>
<span class="spacer" />
<button v-if="isActive(detail.status)" class="btn btn-danger-outline" :disabled="acting" @click="cancel">
{{ detail.status === 'running' ? '终止任务' : '取消排队' }}
</button>
<button class="btn" :disabled="acting" @click="rerun">再次执行</button>
</div>
<div class="detail-grid">
<div class="card">
<div class="card-header"><h2>结果</h2></div>
<div class="card-body">
<dl class="kv">
<dt>状态</dt>
<dd><StatusBadge :status="detail.status" /></dd>
<dt>退出码</dt>
<dd class="mono">{{ detail.exitCode ?? '—' }}<span v-if="detail.signal" class="faint"> (信号 {{ detail.signal }})</span></dd>
<template v-if="detail.error">
<dt>说明</dt>
<dd style="color: var(--danger)">{{ detail.error }}</dd>
</template>
<dt>耗时</dt>
<dd>{{ formatDuration(detail.durationMs) }}</dd>
<dt>接收时间</dt>
<dd>{{ formatTime(detail.createdAt) }}</dd>
<dt>开始时间</dt>
<dd>{{ formatTime(detail.startedAt) }}</dd>
<dt>结束时间</dt>
<dd>{{ formatTime(detail.finishedAt) }}</dd>
<dt>输出大小</dt>
<dd>
stdout {{ formatBytes(detail.stdoutBytes) }} · stderr {{ formatBytes(detail.stderrBytes) }}
<span v-if="detail.truncated" class="badge badge-warning">已截断</span>
</dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-header"><h2>请求摘要</h2></div>
<div class="card-body">
<dl class="kv">
<dt>触发方式</dt>
<dd>
{{ TRIGGER_LABELS[detail.trigger] }}
<RouterLink
v-if="detail.sourceExecutionId"
:to="{ name: 'execution-detail', params: { id: detail.sourceExecutionId } }"
>
(来源 #{{ detail.sourceExecutionId }})
</RouterLink>
</dd>
<dt>请求来源</dt>
<dd class="mono">{{ detail.requestIp || '—' }}</dd>
<dt>脚本版本</dt>
<dd>v{{ detail.scriptVersion }}</dd>
<dt>执行配置</dt>
<dd class="small">
{{ detail.config.responseMode === 'sync' ? '同步' : '异步' }} · 超时 {{ detail.config.timeoutSec }} 秒 ·
工作目录 <span class="mono">{{ detail.config.workingDir || '默认 workspace' }}</span>
</dd>
<dt>参数</dt>
<dd>
<span v-if="!detail.params.length" class="faint">无</span>
<div v-for="p in detail.params" :key="p.env" class="mono small">
<span class="muted">{{ p.env }}=</span><span v-if="p.value === ''" class="faint">(空)</span>{{ p.value }}
<span v-if="p.sensitive" class="badge" style="margin-left: 4px">敏感</span>
</div>
</dd>
</dl>
</div>
</div>
</div>
<div class="card" style="margin-top: 16px">
<div class="tabs" style="padding: 0 10px">
<button class="tab" :class="{ active: section === 'output' }" @click="section = 'output'">输出</button>
<button class="tab" :class="{ active: section === 'request' }" @click="section = 'request'">请求详情</button>
<button class="tab" :class="{ active: section === 'script' }" @click="section = 'script'">脚本 v{{ detail.scriptVersion }}</button>
</div>
<div class="card-body">
<OutputViewer v-show="section === 'output'" :execution-id="detail.id" @status="onStatus" />
<div v-if="section === 'request'" class="stack">
<div class="small muted">
鉴权头、Cookie 与 Query 中的 token 不会被记录;敏感参数对应的字段以 ****** 显示。
</div>
<div class="field">
<label>方法与内容类型</label>
<div class="mono">{{ detail.request.method }} · {{ detail.request.contentType || '(无)' }}</div>
</div>
<div class="field">
<label>Query</label>
<pre class="code-block light">{{ Object.keys(detail.request.query).length ? JSON.stringify(detail.request.query, null, 2) : '(无)' }}</pre>
</div>
<div class="field">
<label>请求头</label>
<pre class="code-block light">{{ JSON.stringify(detail.request.headers, null, 2) }}</pre>
</div>
<div class="field">
<label>请求体</label>
<pre class="code-block light">{{ bodyText || '(无)' }}</pre>
</div>
</div>
<div v-if="section === 'script'" class="stack">
<div class="small muted">本次执行固定使用的脚本版本,后续修改脚本不会影响该记录。</div>
<ScriptEditor :model-value="detail.script" readonly height="440px" />
</div>
</div>
</div>
</template>
</template>
<style scoped>
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
@media (max-width: 1000px) {
.detail-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { api, errorMessage } from '../api/client';
import type { ExecutionStatus, ExecutionSummary, HookSummary, TriggerType } from '../api/types';
import StatusBadge from '../components/StatusBadge.vue';
import { toast } from '../composables/toast';
import { formatDuration, formatTime, isActive, STATUS_LABELS, TRIGGER_LABELS } from '../utils/format';
const route = useRoute();
const router = useRouter();
const PAGE_SIZE = 20;
const filter = reactive<{ hookId?: number; status: ExecutionStatus | ''; trigger: TriggerType | ''; page: number }>({
hookId: route.query.hookId ? Number(route.query.hookId) : undefined,
status: (route.query.status as ExecutionStatus | undefined) ?? '',
trigger: (route.query.trigger as TriggerType | undefined) ?? '',
page: route.query.page ? Number(route.query.page) : 1,
});
const items = ref<ExecutionSummary[]>([]);
const total = ref(0);
const hooks = ref<HookSummary[]>([]);
const loading = ref(true);
const autoRefresh = ref(true);
let timer: number | undefined;
const pages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
async function load() {
try {
const res = await api.listExecutions({ ...filter, pageSize: PAGE_SIZE });
items.value = res.items;
total.value = res.total;
} catch (err) {
toast.error(errorMessage(err));
} finally {
loading.value = false;
}
}
function schedule() {
window.clearTimeout(timer);
if (!autoRefresh.value) return;
timer = window.setTimeout(async () => {
await load();
schedule();
}, items.value.some((e) => isActive(e.status)) ? 2000 : 5000);
}
watch(
() => [filter.hookId, filter.status, filter.trigger],
() => {
filter.page = 1;
},
);
watch(
filter,
async () => {
const query: Record<string, string> = {};
if (filter.hookId) query.hookId = String(filter.hookId);
if (filter.status) query.status = filter.status;
if (filter.trigger) query.trigger = filter.trigger;
if (filter.page > 1) query.page = String(filter.page);
void router.replace({ query });
await load();
schedule();
},
{ deep: true },
);
watch(autoRefresh, schedule);
onMounted(async () => {
api
.listHooks()
.then((res) => (hooks.value = res.items))
.catch(() => undefined);
await load();
schedule();
});
onBeforeUnmount(() => window.clearTimeout(timer));
</script>
<template>
<div class="page-header">
<div>
<h1>执行记录</h1>
<div class="subtitle">每次调用、测试与再次执行都会生成一条记录,保留天数可在系统设置中调整</div>
</div>
</div>
<div class="card">
<div class="card-header" style="flex-wrap: wrap">
<select v-model="filter.hookId" style="max-width: 220px">
<option :value="undefined">全部接口</option>
<option v-for="h in hooks" :key="h.id" :value="h.id">{{ h.name }}</option>
</select>
<select v-model="filter.status" style="max-width: 140px">
<option value="">全部状态</option>
<option v-for="(label, key) in STATUS_LABELS" :key="key" :value="key">{{ label }}</option>
</select>
<select v-model="filter.trigger" style="max-width: 140px">
<option value="">全部来源</option>
<option v-for="(label, key) in TRIGGER_LABELS" :key="key" :value="key">{{ label }}</option>
</select>
<span class="spacer" />
<label class="check small muted"><input v-model="autoRefresh" type="checkbox" /> 自动刷新</label>
<button class="btn btn-sm" @click="load">刷新</button>
</div>
<div class="card-body flush table-wrap">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>接口</th>
<th>请求来源</th>
<th>参数摘要</th>
<th>版本</th>
<th>状态</th>
<th>耗时</th>
<th>退出码</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<tr
v-for="e in items"
:key="e.id"
class="clickable"
@click="router.push({ name: 'execution-detail', params: { id: e.id } })"
>
<td class="mono">#{{ e.id }}</td>
<td>
<div>{{ e.hookName }}</div>
<div class="small faint mono">/hooks/{{ e.hookSlug }}</div>
</td>
<td class="nowrap">
<div>{{ TRIGGER_LABELS[e.trigger] }}<span v-if="e.sourceExecutionId" class="faint"> ← #{{ e.sourceExecutionId }}</span></div>
<div class="small faint mono">{{ e.requestIp || '—' }}</div>
</td>
<td class="params-cell">
<span v-if="!e.params.length" class="faint">—</span>
<div v-for="p in e.params.slice(0, 3)" :key="p.env" class="small mono break">
<span class="muted">{{ p.env }}=</span>{{ p.value }}
</div>
<div v-if="e.params.length > 3" class="small faint">等 {{ e.params.length }} 个</div>
</td>
<td class="muted">v{{ e.scriptVersion }}</td>
<td><StatusBadge :status="e.status" /></td>
<td class="nowrap">{{ formatDuration(e.durationMs) }}</td>
<td class="mono">{{ e.exitCode ?? '—' }}</td>
<td class="muted nowrap small">{{ formatTime(e.createdAt) }}</td>
</tr>
<tr v-if="!loading && !items.length">
<td colspan="9" class="empty">暂无执行记录</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<span class="small">共 {{ total }} 条</span>
<span class="spacer" />
<button class="btn btn-sm" :disabled="filter.page <= 1" @click="filter.page--">上一页</button>
<span class="small">{{ filter.page }} / {{ pages }}</span>
<button class="btn btn-sm" :disabled="filter.page >= pages" @click="filter.page++">下一页</button>
</div>
</div>
</template>
<style scoped>
.params-cell {
max-width: 280px;
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
import { api, errorMessage } from '../api/client';
import type { HookDetail, HookForm } from '../api/types';
import TokenDialog from '../components/TokenDialog.vue';
import { confirmDialog, promptDialog } from '../composables/dialog';
import { toast } from '../composables/toast';
import { SCRIPT_TEMPLATES } from '../utils/templates';
import BasicTab from './hook/BasicTab.vue';
import ParamsTab from './hook/ParamsTab.vue';
import PolicyTab from './hook/PolicyTab.vue';
import ScriptTab from './hook/ScriptTab.vue';
import TestTab from './hook/TestTab.vue';
type TabKey = 'basic' | 'params' | 'script' | 'policy' | 'test';
const route = useRoute();
const router = useRouter();
const isNew = route.name === 'hook-new';
const hookId = isNew ? 0 : Number(route.params.id);
const TABS: { key: TabKey; label: string }[] = [
{ key: 'basic', label: '基本配置' },
{ key: 'params', label: '参数' },
{ key: 'script', label: '脚本' },
{ key: 'policy', label: '执行策略' },
{ key: 'test', label: '测试' },
];
function emptyForm(): HookForm {
return {
slug: '',
name: '',
description: '',
group: '',
method: 'POST',
params: [],
script: SCRIPT_TEMPLATES[0]!.content,
workingDir: '',
timeoutSec: 30,
responseMode: 'async',
concurrency: 1,
};
}
function toForm(hook: HookDetail): HookForm {
return {
slug: hook.slug,
name: hook.name,
description: hook.description,
group: hook.group,
method: hook.method,
params: hook.params.map((p) => ({ ...p })),
script: hook.script,
workingDir: hook.workingDir,
timeoutSec: hook.timeoutSec,
responseMode: hook.responseMode,
concurrency: hook.concurrency,
};
}
const initialTab = TABS.some((t) => t.key === route.query.tab) ? (route.query.tab as TabKey) : 'basic';
const tab = ref<TabKey>(initialTab);
const hook = ref<HookDetail | null>(null);
const form = reactive<HookForm>(emptyForm());
const original = ref(JSON.stringify(form));
const groups = ref<string[]>([]);
const loading = ref(!isNew);
const saving = ref(false);
const versionsKey = ref(0);
const tokenInfo = ref<{ token: string; slug: string; method: HookForm['method'] } | null>(null);
let pendingNavigation: number | null = null;
const dirty = computed(() => JSON.stringify(form) !== original.value);
function applyHook(detail: HookDetail) {
hook.value = detail;
Object.assign(form, toForm(detail));
original.value = JSON.stringify(form);
}
async function load() {
try {
const [list, detail] = await Promise.all([api.listHooks(), isNew ? Promise.resolve(null) : api.getHook(hookId)]);
groups.value = list.groups;
if (detail) applyHook(detail);
} catch (err) {
toast.error(errorMessage(err));
} finally {
loading.value = false;
}
}
async function save() {
saving.value = true;
try {
if (isNew) {
const res = await api.createHook({ ...form });
original.value = JSON.stringify(form);
pendingNavigation = res.hook.id;
tokenInfo.value = { token: res.token, slug: res.hook.slug, method: res.hook.method };
toast.success('接口已创建');
} else {
const previousVersion = hook.value?.currentVersion?.id;
const detail = await api.updateHook(hookId, { ...form });
applyHook(detail);
versionsKey.value++;
const newVersion = detail.currentVersion && detail.currentVersion.id !== previousVersion;
toast.success(newVersion ? `已保存,脚本生成新版本 v${detail.currentVersion!.version}` : '已保存');
}
} catch (err) {
toast.error(errorMessage(err));
} finally {
saving.value = false;
}
}
function closeToken() {
tokenInfo.value = null;
if (pendingNavigation) {
const id = pendingNavigation;
pendingNavigation = null;
void router.replace({ name: 'hook-detail', params: { id } });
}
}
async function regenerateToken() {
const ok = await confirmDialog({
title: '重新生成 Token',
message: '旧 Token 将立即失效,所有调用方需要更新为新 Token 才能继续调用。',
confirmText: '重新生成',
danger: true,
});
if (!ok || !hook.value) return;
try {
const { token } = await api.regenerateToken(hookId);
tokenInfo.value = { token, slug: hook.value.slug, method: hook.value.method };
hook.value.tokenHint = token.slice(-4);
} catch (err) {
toast.error(errorMessage(err));
}
}
async function toggleEnabled() {
if (!hook.value) return;
if (hook.value.enabled) {
const ok = await confirmDialog({
title: '停用接口',
message: '停用后将拒绝新的外部请求,并取消该接口所有尚未开始的任务。运行中的任务不受影响,可在执行记录中单独终止。',
confirmText: '停用',
danger: true,
});
if (!ok) return;
}
try {
const res = await api.setHookEnabled(hookId, !hook.value.enabled);
hook.value.enabled = res.hook.enabled;
toast.success(res.hook.enabled ? '接口已启用' : `接口已停用${res.cancelled ? `,取消了 ${res.cancelled} 个排队任务` : ''}`);
} catch (err) {
toast.error(errorMessage(err));
}
}
async function copyHook() {
if (!hook.value) return;
const result = await promptDialog({
title: '复制接口',
message: '将复制已保存的参数、脚本与执行策略,并生成新的 Token。副本默认处于停用状态。',
input: { label: '新接口标识', value: `${hook.value.slug}-copy` },
input2: { label: '新接口名称', value: `${hook.value.name}(副本)` },
confirmText: '复制',
});
if (!result) return;
try {
const res = await api.copyHook(hookId, result.value.trim(), result.value2.trim());
pendingNavigation = res.hook.id;
tokenInfo.value = { token: res.token, slug: res.hook.slug, method: res.hook.method };
} catch (err) {
toast.error(errorMessage(err));
}
}
async function removeHook() {
if (!hook.value) return;
const ok = await confirmDialog({
title: '删除接口',
message: `确定删除「${hook.value.name}」吗?该接口的全部脚本版本与执行记录都会被删除,且无法恢复。`,
confirmText: '删除',
danger: true,
});
if (!ok) return;
try {
await api.deleteHook(hookId);
original.value = JSON.stringify(form);
toast.success('接口已删除');
await router.replace({ name: 'hooks' });
} catch (err) {
toast.error(errorMessage(err));
}
}
function selectTab(key: TabKey) {
tab.value = key;
void router.replace({ query: { ...route.query, tab: key } });
}
onBeforeRouteLeave(async (to) => {
if (!dirty.value || to.name === 'login') return true;
return confirmDialog({
title: '放弃未保存的修改?',
message: '当前接口有尚未保存的修改,离开后这些修改将丢失。',
confirmText: '放弃修改',
danger: true,
});
});
onMounted(load);
</script>
<template>
<div v-if="loading" class="empty">加载中…</div>
<template v-else-if="isNew || hook">
<div class="page-header">
<div>
<div class="small muted">
<RouterLink :to="{ name: 'hooks' }">接口管理</RouterLink>
<span> / </span>
<span>{{ isNew ? '新建接口' : hook!.name }}</span>
</div>
<div class="row" style="margin-top: 4px">
<h1>{{ isNew ? '新建接口' : hook!.name }}</h1>
<span v-if="hook" class="badge dot" :class="hook.enabled ? 'badge-success' : ''">
{{ hook.enabled ? '已启用' : '已停用' }}
</span>
<span v-if="dirty" class="badge badge-warning">未保存</span>
</div>
</div>
<span class="spacer" />
<template v-if="hook">
<button class="btn" @click="toggleEnabled">{{ hook.enabled ? '停用' : '启用' }}</button>
<button class="btn" @click="copyHook">复制</button>
<button class="btn btn-danger-outline" @click="removeHook">删除</button>
</template>
<button class="btn btn-primary" :disabled="saving || (!isNew && !dirty)" @click="save">
{{ saving ? '保存中…' : isNew ? '创建接口' : '保存' }}
</button>
</div>
<div class="card">
<div class="tabs" style="padding: 0 10px">
<button
v-for="t in TABS"
:key="t.key"
class="tab"
:class="{ active: tab === t.key }"
:disabled="isNew && t.key === 'test'"
:title="isNew && t.key === 'test' ? '请先创建接口' : ''"
@click="selectTab(t.key)"
>
{{ t.label }}
<span v-if="t.key === 'params'" class="count">{{ form.params.length }}</span>
</button>
</div>
<div class="card-body">
<BasicTab
v-show="tab === 'basic'"
:form="form"
:hook="hook"
:groups="groups"
@regenerate-token="regenerateToken"
/>
<ParamsTab v-show="tab === 'params'" :form="form" />
<ScriptTab v-show="tab === 'script'" :form="form" :hook="hook" :versions-key="versionsKey" />
<PolicyTab v-show="tab === 'policy'" :form="form" />
<TestTab v-if="hook && tab === 'test'" :form="form" :hook="hook" :dirty="dirty" />
</div>
</div>
</template>
<div v-else class="empty">接口不存在或已被删除。<RouterLink :to="{ name: 'hooks' }">返回列表</RouterLink></div>
<TokenDialog :token="tokenInfo?.token ?? null" :slug="tokenInfo?.slug ?? ''" :method="tokenInfo?.method ?? 'POST'" @close="closeToken" />
</template>
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { api, errorMessage } from '../api/client';
import type { HookSummary } from '../api/types';
import StatusBadge from '../components/StatusBadge.vue';
import TokenDialog from '../components/TokenDialog.vue';
import { confirmDialog, promptDialog } from '../composables/dialog';
import { toast } from '../composables/toast';
import { formatRelative } from '../utils/format';
const router = useRouter();
const hooks = ref<HookSummary[]>([]);
const groups = ref<string[]>([]);
const loading = ref(true);
const search = ref('');
const groupFilter = ref('');
const copied = ref<{ token: string; slug: string; method: HookSummary['method']; id: number } | null>(null);
async function load() {
try {
const res = await api.listHooks();
hooks.value = res.items;
groups.value = res.groups;
} catch (err) {
toast.error(errorMessage(err));
} finally {
loading.value = false;
}
}
const grouped = computed(() => {
const keyword = search.value.trim().toLowerCase();
const filtered = hooks.value.filter((h) => {
if (groupFilter.value && h.group !== groupFilter.value) return false;
if (!keyword) return true;
return [h.name, h.slug, h.description].some((v) => v.toLowerCase().includes(keyword));
});
const map = new Map<string, HookSummary[]>();
for (const hook of filtered) {
const list = map.get(hook.group) ?? [];
list.push(hook);
map.set(hook.group, list);
}
return [...map.entries()].map(([group, items]) => ({ group, items }));
});
async function toggle(hook: HookSummary) {
if (hook.enabled) {
const ok = await confirmDialog({
title: '停用接口',
message: `停用「${hook.name}」后将拒绝新的外部请求,并取消该接口所有尚未开始的任务。运行中的任务不受影响,可在执行记录中单独终止。`,
confirmText: '停用',
danger: true,
});
if (!ok) return;
}
try {
const res = await api.setHookEnabled(hook.id, !hook.enabled);
hook.enabled = res.hook.enabled;
toast.success(hook.enabled ? '接口已启用' : `接口已停用${res.cancelled ? `,取消了 ${res.cancelled} 个排队任务` : ''}`);
} catch (err) {
toast.error(errorMessage(err));
}
}
async function copy(hook: HookSummary) {
const result = await promptDialog({
title: '复制接口',
message: '将复制参数、脚本与执行策略,并生成新的 Token。副本默认处于停用状态。',
input: { label: '新接口标识', value: `${hook.slug}-copy`, placeholder: '小写字母、数字、-、_' },
input2: { label: '新接口名称', value: `${hook.name}(副本)` },
confirmText: '复制',
});
if (!result) return;
try {
const res = await api.copyHook(hook.id, result.value.trim(), result.value2.trim());
copied.value = { token: res.token, slug: res.hook.slug, method: res.hook.method, id: res.hook.id };
await load();
} catch (err) {
toast.error(errorMessage(err));
}
}
async function remove(hook: HookSummary) {
const ok = await confirmDialog({
title: '删除接口',
message: `确定删除「${hook.name}」吗?该接口的全部脚本版本与执行记录都会被删除,且无法恢复。`,
confirmText: '删除',
danger: true,
});
if (!ok) return;
try {
await api.deleteHook(hook.id);
toast.success('接口已删除');
await load();
} catch (err) {
toast.error(errorMessage(err));
}
}
function closeCopied() {
const id = copied.value?.id;
copied.value = null;
if (id) void router.push({ name: 'hook-detail', params: { id } });
}
onMounted(load);
</script>
<template>
<div class="page-header">
<div>
<h1>接口管理</h1>
<div class="subtitle">每个接口 = 调用地址 + 鉴权凭证 + 参数映射 + Bash 脚本 + 执行策略</div>
</div>
<span class="spacer" />
<RouterLink :to="{ name: 'hook-new' }" class="btn btn-primary">+ 新建接口</RouterLink>
</div>
<div class="card">
<div class="card-header">
<input v-model="search" placeholder="搜索名称、标识或描述" style="max-width: 280px" />
<select v-model="groupFilter" style="max-width: 180px">
<option value="">全部分组</option>
<option v-for="g in groups" :key="g" :value="g">{{ g }}</option>
</select>
<span class="spacer" />
<span class="muted small">共 {{ hooks.length }} 个接口</span>
</div>
<div class="card-body flush table-wrap">
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>调用地址</th>
<th>响应</th>
<th>脚本</th>
<th>最近执行</th>
<th>状态</th>
<th style="text-align: right">操作</th>
</tr>
</thead>
<tbody>
<template v-for="block in grouped" :key="block.group">
<tr v-if="groups.length" class="group-row">
<td colspan="7">{{ block.group || '未分组' }}</td>
</tr>
<tr v-for="h in block.items" :key="h.id">
<td>
<RouterLink :to="{ name: 'hook-detail', params: { id: h.id } }">
<strong>{{ h.name }}</strong>
</RouterLink>
<div v-if="h.description" class="small muted">{{ h.description }}</div>
</td>
<td class="nowrap">
<span class="badge badge-method">{{ h.method }}</span>
<span class="mono">/hooks/{{ h.slug }}</span>
</td>
<td class="muted nowrap">
{{ h.responseMode === 'sync' ? '同步' : '异步' }}
<span class="faint">· 并发 {{ h.concurrency }}</span>
</td>
<td class="muted">v{{ h.currentVersion ?? '—' }}</td>
<td class="nowrap">
<template v-if="h.lastStatus">
<StatusBadge :status="h.lastStatus" />
<span class="small faint"> {{ formatRelative(h.lastAt) }}</span>
</template>
<span v-else class="faint">从未执行</span>
</td>
<td>
<span class="badge dot" :class="h.enabled ? 'badge-success' : ''">{{ h.enabled ? '已启用' : '已停用' }}</span>
</td>
<td>
<div class="row" style="justify-content: flex-end; gap: 4px">
<RouterLink :to="{ name: 'hook-detail', params: { id: h.id } }" class="btn btn-ghost btn-sm">编辑</RouterLink>
<button class="btn btn-ghost btn-sm" @click="toggle(h)">{{ h.enabled ? '停用' : '启用' }}</button>
<button class="btn btn-ghost btn-sm" @click="copy(h)">复制</button>
<button class="btn btn-ghost btn-sm" style="color: var(--danger)" @click="remove(h)">删除</button>
</div>
</td>
</tr>
</template>
<tr v-if="!loading && !grouped.length">
<td colspan="7" class="empty">
{{ hooks.length ? '没有匹配的接口' : '还没有接口,点击右上角「新建接口」开始' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<TokenDialog :token="copied?.token ?? null" :slug="copied?.slug ?? ''" :method="copied?.method ?? 'POST'" @close="closeCopied" />
</template>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { api, errorMessage } from '../api/client';
import { setUser } from '../composables/auth';
const route = useRoute();
const router = useRouter();
const username = ref('');
const password = ref('');
const loading = ref(false);
const error = ref('');
async function submit() {
if (!username.value || !password.value) {
error.value = '请输入用户名和密码';
return;
}
loading.value = true;
error.value = '';
try {
const res = await api.login(username.value, password.value);
setUser(res.username);
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') ? route.query.redirect : '/';
await router.replace(redirect);
} catch (err) {
error.value = errorMessage(err);
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-page">
<form class="card login-card" @submit.prevent="submit">
<div class="brand">
<svg viewBox="0 0 32 32" width="36" height="36" aria-hidden="true">
<rect width="32" height="32" rx="7" fill="#2563eb" />
<path d="M9 17l5 5 9-12" stroke="#fff" stroke-width="3.2" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div>
<h1>HookPilot</h1>
<div class="muted small">Webhook 脚本管理平台</div>
</div>
</div>
<div v-if="error" class="alert alert-danger">{{ error }}</div>
<div class="field">
<label for="username">用户名</label>
<input id="username" v-model="username" autocomplete="username" autofocus />
</div>
<div class="field">
<label for="password">密码</label>
<input id="password" v-model="password" type="password" autocomplete="current-password" />
</div>
<button class="btn btn-primary" type="submit" :disabled="loading">{{ loading ? '登录中…' : '登录' }}</button>
</form>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.login-card {
width: 100%;
max-width: 380px;
padding: 28px;
display: flex;
flex-direction: column;
gap: 16px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.btn {
height: 38px;
margin-top: 4px;
}
</style>
+158
View File
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { api, errorMessage } from '../api/client';
import type { Overview } from '../api/types';
import StatusBadge from '../components/StatusBadge.vue';
import { toast } from '../composables/toast';
import { formatDuration, formatRelative, TRIGGER_LABELS } from '../utils/format';
const router = useRouter();
const data = ref<Overview | null>(null);
let timer: number | undefined;
async function load() {
try {
data.value = await api.overview();
} catch (err) {
toast.error(errorMessage(err));
}
}
onMounted(() => {
void load();
timer = window.setInterval(load, 5000);
});
onBeforeUnmount(() => window.clearInterval(timer));
</script>
<template>
<div class="page-header">
<div>
<h1>概览</h1>
<div class="subtitle">接口与任务的整体运行状态</div>
</div>
</div>
<template v-if="data">
<div class="stat-grid">
<div class="card stat-card">
<div class="label">接口数量</div>
<div class="value">{{ data.hooks.total }}</div>
<div class="sub">已启用 {{ data.hooks.enabled }} 个</div>
</div>
<div class="card stat-card">
<div class="label">运行中任务</div>
<div class="value" :style="{ color: data.running ? 'var(--primary)' : undefined }">{{ data.running }}</div>
<div class="sub">排队中 {{ data.queued }} 个</div>
</div>
<div class="card stat-card">
<div class="label">近 24 小时成功</div>
<div class="value" style="color: var(--success)">{{ data.last24h.success }}</div>
<div class="sub">共 {{ data.last24h.total }} 次执行</div>
</div>
<div class="card stat-card">
<div class="label">近 24 小时失败</div>
<div class="value" :style="{ color: data.last24h.failed ? 'var(--danger)' : undefined }">{{ data.last24h.failed }}</div>
<div class="sub">含超时、终止与中断</div>
</div>
</div>
<div class="overview-grid">
<div class="card">
<div class="card-header">
<h2>近期执行</h2>
<span class="spacer" />
<RouterLink :to="{ name: 'executions' }" class="small">查看全部</RouterLink>
</div>
<div class="card-body flush table-wrap">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>接口</th>
<th>来源</th>
<th>状态</th>
<th>耗时</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<tr
v-for="e in data.recent"
:key="e.id"
class="clickable"
@click="router.push({ name: 'execution-detail', params: { id: e.id } })"
>
<td class="mono">#{{ e.id }}</td>
<td>{{ e.hookName }}</td>
<td class="muted">{{ TRIGGER_LABELS[e.trigger] }}</td>
<td><StatusBadge :status="e.status" /></td>
<td class="nowrap">{{ formatDuration(e.durationMs) }}</td>
<td class="muted nowrap">{{ formatRelative(e.createdAt) }}</td>
</tr>
<tr v-if="!data.recent.length">
<td colspan="6" class="empty">暂无执行记录</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header"><h2>近期失败</h2></div>
<div class="card-body stack">
<RouterLink
v-for="e in data.recentFailures"
:key="e.id"
:to="{ name: 'execution-detail', params: { id: e.id } }"
class="failure-item"
>
<div class="row">
<strong>{{ e.hookName }}</strong>
<span class="spacer" />
<StatusBadge :status="e.status" />
</div>
<div class="small muted">
#{{ e.id }} · {{ formatRelative(e.createdAt) }}
<template v-if="e.exitCode !== null"> · 退出码 {{ e.exitCode }}</template>
</div>
<div v-if="e.error" class="small failure-error">{{ e.error }}</div>
</RouterLink>
<div v-if="!data.recentFailures.length" class="empty" style="padding: 20px">暂无失败记录</div>
</div>
</div>
</div>
</template>
</template>
<style scoped>
.overview-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 16px;
margin-top: 16px;
align-items: start;
}
.failure-item {
display: block;
color: inherit;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
}
.failure-item:hover {
border-color: var(--border-strong);
background: var(--surface-2);
text-decoration: none;
}
.failure-error {
color: var(--danger);
margin-top: 2px;
}
@media (max-width: 1000px) {
.overview-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { api, errorMessage } from '../api/client';
import { toast } from '../composables/toast';
const MIB = 1024 * 1024;
const form = reactive({ globalConcurrency: 4, retentionDays: 30, outputLimitMiB: 5 });
const loading = ref(true);
const saving = ref(false);
async function load() {
try {
const s = await api.getSettings();
form.globalConcurrency = s.globalConcurrency;
form.retentionDays = s.retentionDays;
form.outputLimitMiB = Math.round((s.outputLimitBytes / MIB) * 100) / 100;
} catch (err) {
toast.error(errorMessage(err));
} finally {
loading.value = false;
}
}
async function save() {
saving.value = true;
try {
await api.updateSettings({
globalConcurrency: form.globalConcurrency,
retentionDays: form.retentionDays,
outputLimitBytes: Math.round(form.outputLimitMiB * MIB),
});
toast.success('设置已保存');
} catch (err) {
toast.error(errorMessage(err));
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="page-header">
<div>
<h1>系统设置</h1>
<div class="subtitle">影响所有接口的全局执行参数</div>
</div>
</div>
<div v-if="!loading" class="card" style="max-width: 720px">
<form class="card-body stack" @submit.prevent="save">
<div class="field">
<label class="required">全局并发上限</label>
<input v-model.number="form.globalConcurrency" type="number" min="1" max="64" style="max-width: 200px" />
<div class="hint">所有接口合计最多同时运行的任务数(1–64)。超出的任务按接收顺序排队。</div>
</div>
<div class="field">
<label class="required">日志保留天数</label>
<input v-model.number="form.retentionDays" type="number" min="1" max="3650" style="max-width: 200px" />
<div class="hint">超过天数的已结束执行记录及其输出每小时自动清理一次。</div>
</div>
<div class="field">
<label class="required">任务输出大小上限(MiB)</label>
<input v-model.number="form.outputLimitMiB" type="number" min="0.0625" max="100" step="0.5" style="max-width: 200px" />
<div class="hint">
单次执行 stdout 与 stderr 合计保留的上限。超出部分继续读取并丢弃(不会阻塞脚本),页面会标记「已截断」。修改只影响之后开始的任务。
</div>
</div>
<div>
<button class="btn btn-primary" type="submit" :disabled="saving">{{ saving ? '保存中…' : '保存设置' }}</button>
</div>
</form>
</div>
</template>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { HookDetail, HookForm } from '../../api/types';
import { toast } from '../../composables/toast';
import { copyText, formatTime, webhookUrl } from '../../utils/format';
const props = defineProps<{ form: HookForm; hook: HookDetail | null; groups: string[] }>();
const emit = defineEmits<{ regenerateToken: [] }>();
const url = computed(() => webhookUrl(props.form.slug));
const slugChanged = computed(() => props.hook !== null && props.hook.slug !== props.form.slug);
const hasBodyParams = computed(() => props.form.params.some((p) => p.source === 'json' || p.source === 'form'));
async function copyUrl() {
if (await copyText(url.value)) toast.success('调用地址已复制');
}
</script>
<template>
<div class="form-grid">
<div class="field">
<label class="required">接口名称</label>
<input v-model="form.name" maxlength="100" placeholder="如:更新家庭 IP" />
</div>
<div class="field">
<label>分组</label>
<input v-model="form.group" list="hook-groups" maxlength="50" placeholder="可选,用于在列表中归类" />
<datalist id="hook-groups">
<option v-for="g in groups" :key="g" :value="g" />
</datalist>
</div>
<div class="field">
<label class="required">接口标识</label>
<input v-model="form.slug" maxlength="64" placeholder="如:home-ip" class="mono" />
<div class="hint">小写字母、数字、下划线和连字符,以字母或数字开头。</div>
</div>
<div class="field">
<label class="required">请求方式</label>
<select v-model="form.method">
<option value="POST">POST</option>
<option value="GET">GET</option>
</select>
<div v-if="form.method === 'GET' && hasBodyParams" class="hint" style="color: var(--danger)">
GET 接口只能从 Query 或 Header 提取参数,请调整参数配置。
</div>
</div>
<div class="field full">
<label>调用地址</label>
<div class="row">
<div class="input-group" style="flex: 1">
<span class="addon">{{ form.method }}</span>
<input :value="url" readonly class="mono" />
</div>
<button class="btn" :disabled="!form.slug" @click="copyUrl">复制</button>
</div>
<div v-if="slugChanged" class="hint" style="color: var(--warning)">
修改标识后原地址 /hooks/{{ hook!.slug }} 将失效,调用方需同步更新。
</div>
</div>
<div class="field full">
<label>描述</label>
<textarea v-model="form.description" rows="3" maxlength="1000" placeholder="接口用途、调用方等说明" />
</div>
<div class="field full">
<label>接口 Token</label>
<template v-if="hook">
<div class="row">
<input :value="`hp_••••••••••••••••••••••••${hook.tokenHint}`" readonly class="mono" style="max-width: 360px" />
<button class="btn btn-danger-outline" @click="emit('regenerateToken')">重新生成</button>
</div>
<div class="hint">
Token 仅在创建或重新生成时完整显示一次。调用时通过 <code>Authorization: Bearer &lt;token&gt;</code> 或
<code>X-Hook-Token</code> 请求头传递。
</div>
</template>
<div v-else class="hint">创建接口后自动生成,届时只显示一次,请妥善保存。</div>
</div>
<div v-if="hook" class="field full">
<div class="small faint">创建于 {{ formatTime(hook.createdAt) }} · 最后修改 {{ formatTime(hook.updatedAt) }}</div>
</div>
</div>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import type { HookForm, ParamDef, ParamSource } from '../../api/types';
import { SOURCE_LABELS, TYPE_LABELS } from '../../utils/format';
const props = defineProps<{ form: HookForm }>();
const KEY_PLACEHOLDERS: Record<ParamSource, string> = {
json: '如 ip 或 data.ip',
form: '表单字段名',
query: 'Query 参数名',
header: '如 x-client-id',
};
function addParam() {
props.form.params.push({
env: 'HOOK_',
source: props.form.method === 'GET' ? 'query' : 'json',
key: '',
type: 'string',
required: false,
default: '',
sensitive: false,
description: '',
});
}
function removeParam(index: number) {
props.form.params.splice(index, 1);
}
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= props.form.params.length) return;
const [item] = props.form.params.splice(index, 1);
props.form.params.splice(target, 0, item!);
}
function envSuffix(param: ParamDef): string {
return param.env.replace(/^HOOK_/, '');
}
function setEnv(param: ParamDef, value: string) {
param.env = `HOOK_${value.toUpperCase().replace(/[^A-Z0-9_]/g, '_')}`;
}
function sourceDisabled(source: ParamSource): boolean {
return props.form.method === 'GET' && (source === 'json' || source === 'form');
}
</script>
<template>
<div class="stack">
<div class="alert alert-info">
<span class="alert-icon">i</span>
<div>
每个参数从<strong>指定来源</strong>读取(不会在 Body、Query、Header 之间自动回退),校验通过后作为环境变量传给脚本,
脚本中用 <code>"$HOOK_XXX"</code> 引用。请求数据不会拼接进脚本文本,含引号、换行等特殊字符也按原样作为数据传递。
缺失且非必填的参数会设置为空字符串。
</div>
</div>
<div class="table-wrap">
<table class="table compact params-table">
<thead>
<tr>
<th style="min-width: 190px">环境变量</th>
<th style="min-width: 130px">来源</th>
<th style="min-width: 150px">字段</th>
<th style="min-width: 100px">类型</th>
<th>必填</th>
<th style="min-width: 120px">默认值</th>
<th>敏感</th>
<th style="min-width: 160px">说明</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="(p, index) in form.params" :key="index">
<td>
<div class="input-group">
<span class="addon">HOOK_</span>
<input
:value="envSuffix(p)"
class="mono"
style="text-transform: uppercase"
placeholder="IP"
@input="setEnv(p, ($event.target as HTMLInputElement).value)"
/>
</div>
</td>
<td>
<select v-model="p.source">
<option v-for="(label, key) in SOURCE_LABELS" :key="key" :value="key" :disabled="sourceDisabled(key)">
{{ label }}
</option>
</select>
</td>
<td><input v-model="p.key" class="mono" :placeholder="KEY_PLACEHOLDERS[p.source]" /></td>
<td>
<select v-model="p.type">
<option v-for="(label, key) in TYPE_LABELS" :key="key" :value="key">{{ label }}</option>
</select>
</td>
<td style="text-align: center"><input v-model="p.required" type="checkbox" /></td>
<td><input v-model="p.default" placeholder="无" /></td>
<td style="text-align: center">
<input v-model="p.sensitive" type="checkbox" title="在执行记录中隐藏该参数的值" />
</td>
<td><input v-model="p.description" placeholder="可选" /></td>
<td class="nowrap">
<button class="btn btn-ghost btn-sm" title="上移" :disabled="index === 0" @click="move(index, -1)">↑</button>
<button class="btn btn-ghost btn-sm" title="下移" :disabled="index === form.params.length - 1" @click="move(index, 1)">↓</button>
<button class="btn btn-ghost btn-sm" style="color: var(--danger)" @click="removeParam(index)">删除</button>
</td>
</tr>
<tr v-if="!form.params.length">
<td colspan="9" class="empty">暂无参数。脚本仍可通过 HOOK_REQUEST_FILE 读取完整请求。</td>
</tr>
</tbody>
</table>
</div>
<div>
<button class="btn" @click="addParam">+ 添加参数</button>
</div>
<div class="small muted help">
<div><strong>字段说明:</strong>JSON 使用点路径访问嵌套字段,如 <code>data.ip</code>、<code>items.0.name</code>;请求头名称不区分大小写。</div>
<div><strong>类型校验:</strong>数字接受整数与小数;布尔值接受 true/false、1/0、yes/no、on/off,传给脚本时统一为 true 或 false;IP 接受 IPv4 与 IPv6。</div>
<div><strong>敏感参数:</strong>在执行记录与请求摘要中以掩码显示;但脚本主动打印的内容仍会出现在输出里。</div>
<div>
<strong>平台变量:</strong><code>HOOK_EXECUTION_ID</code>、<code>HOOK_SLUG</code>、<code>HOOK_TRIGGER</code>、
<code>HOOK_REQUEST_FILE</code>(完整请求的 JSON 文件路径)自动注入,不可占用。
</div>
</div>
</div>
</template>
<style scoped>
.params-table td {
vertical-align: middle;
}
.help {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import type { HookForm } from '../../api/types';
defineProps<{ form: HookForm }>();
</script>
<template>
<div class="form-grid">
<div class="field full">
<label class="field-label">响应模式</label>
<div class="radio-cards">
<label class="radio-card" :class="{ active: form.responseMode === 'sync' }">
<input v-model="form.responseMode" type="radio" value="sync" />
<div>
<strong>同步</strong>
<div class="small muted">
等待脚本执行结束再响应:成功返回 HTTP 200,脚本失败返回 500,超时返回 504。适合执行较快、调用方需要知道结果的场景。
</div>
</div>
</label>
<label class="radio-card" :class="{ active: form.responseMode === 'async' }">
<input v-model="form.responseMode" type="radio" value="async" />
<div>
<strong>异步</strong>
<div class="small muted">
任务持久化后立即返回 HTTP 202 与任务 ID,仅表示已接收。适合耗时较长的脚本,结果在执行记录中查看。
</div>
</div>
</label>
</div>
<div class="hint">两种模式都只返回任务 ID 与状态,脚本输出只在管理界面查看,不返回给调用方。</div>
</div>
<div class="field">
<label class="required">超时时间(秒)</label>
<input v-model.number="form.timeoutSec" type="number" min="1" max="86400" />
<div class="hint">超时后终止整个进程组(先 SIGTERM,3 秒后 SIGKILL)。脚本不会自动重试。</div>
</div>
<div class="field">
<label class="required">接口并发数</label>
<input v-model.number="form.concurrency" type="number" min="1" max="64" />
<div class="hint">同一接口最多同时运行的任务数。为 1 时按接收顺序串行执行,同时受全局并发上限约束。</div>
</div>
<div class="field full">
<label>工作目录</label>
<input v-model="form.workingDir" class="mono" placeholder="留空使用数据目录下的 workspace" />
<div class="hint">必须是绝对路径,且服务账户有权访问;目录不存在时任务直接失败,不会自动创建。</div>
</div>
<div class="field full">
<div class="alert">
<span class="alert-icon">i</span>
<div class="small">
脚本以服务账户身份直接运行在宿主机上,可访问该账户有权限的文件与命令。
执行环境只包含 <code>PATH</code>、<code>HOME</code>、<code>USER</code>、<code>LANG</code> 等基础变量和 <code>HOOK_</code> 前缀变量,
不继承服务自身的配置与凭证。需要提权的命令请由部署者在 sudoers 中按命令单独授权。
</div>
</div>
</div>
</div>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { api, errorMessage } from '../../api/client';
import type { HookDetail, HookForm, ScriptVersion, ScriptVersionSummary } from '../../api/types';
import ModalDialog from '../../components/ModalDialog.vue';
import ScriptEditor from '../../components/ScriptEditor.vue';
import { confirmDialog } from '../../composables/dialog';
import { toast } from '../../composables/toast';
import { formatTime } from '../../utils/format';
import { SCRIPT_TEMPLATES } from '../../utils/templates';
const props = defineProps<{ form: HookForm; hook: HookDetail | null; versionsKey: number }>();
const versions = ref<ScriptVersionSummary[]>([]);
const viewing = ref<ScriptVersion | null>(null);
const templateIndex = ref('');
async function loadVersions() {
if (!props.hook) return;
try {
versions.value = (await api.listVersions(props.hook.id)).items;
} catch (err) {
toast.error(errorMessage(err));
}
}
async function applyTemplate() {
const template = SCRIPT_TEMPLATES[Number(templateIndex.value)];
templateIndex.value = '';
if (!template) return;
if (props.form.script.trim()) {
const ok = await confirmDialog({
title: `使用模板「${template.name}」`,
message: '当前编辑器中的内容将被替换(已保存的版本不受影响)。',
confirmText: '替换',
});
if (!ok) return;
}
props.form.script = template.content;
}
async function view(version: ScriptVersionSummary) {
if (!props.hook) return;
try {
viewing.value = await api.getVersion(props.hook.id, version.id);
} catch (err) {
toast.error(errorMessage(err));
}
}
function loadIntoEditor() {
if (!viewing.value) return;
props.form.script = viewing.value.content;
toast.info(`已载入 v${viewing.value.version} 的内容,保存后将生成新版本`);
viewing.value = null;
}
watch(() => props.versionsKey, loadVersions);
onMounted(loadVersions);
</script>
<template>
<div class="script-layout">
<div class="stack" style="min-width: 0">
<div class="row wrap">
<span class="muted small">
Bash 脚本。Tab 键缩进,参数以环境变量传入,请用 <code>"$HOOK_XXX"</code> 引用。
</span>
<span class="spacer" />
<select v-model="templateIndex" style="width: auto" @change="applyTemplate">
<option value="" disabled>插入示例模板…</option>
<option v-for="(t, i) in SCRIPT_TEMPLATES" :key="t.name" :value="String(i)">{{ t.name }} — {{ t.description }}</option>
</select>
</div>
<ScriptEditor v-model="form.script" height="520px" />
<div class="small faint">
保存时若脚本内容有变化会生成新版本;已入队的任务仍使用入队时的版本执行。
</div>
</div>
<div class="versions card">
<div class="card-header"><h3>版本记录</h3></div>
<div v-if="!hook" class="empty small">创建后生成 v1</div>
<ul v-else class="version-list">
<li v-for="v in versions" :key="v.id">
<button class="version-item" @click="view(v)">
<span class="row">
<strong>v{{ v.version }}</strong>
<span v-if="hook.currentVersion?.id === v.id" class="badge badge-info">当前</span>
</span>
<span class="small faint">{{ formatTime(v.createdAt) }}</span>
</button>
</li>
</ul>
</div>
</div>
<ModalDialog :open="!!viewing" :title="`脚本版本 v${viewing?.version ?? ''}`" wide @close="viewing = null">
<div class="small muted">保存于 {{ formatTime(viewing?.createdAt) }}</div>
<ScriptEditor v-if="viewing" :model-value="viewing.content" readonly height="420px" />
<template #footer>
<button class="btn" @click="viewing = null">关闭</button>
<button class="btn btn-primary" @click="loadIntoEditor">载入到编辑器</button>
</template>
</ModalDialog>
</template>
<style scoped>
.script-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 16px;
align-items: start;
}
.versions {
box-shadow: none;
}
.version-list {
list-style: none;
margin: 0;
padding: 6px;
max-height: 520px;
overflow-y: auto;
}
.version-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: none;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
}
.version-item:hover {
background: var(--surface-2);
}
@media (max-width: 1000px) {
.script-layout {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+253
View File
@@ -0,0 +1,253 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { api, errorMessage } from '../../api/client';
import type { BodyKind, ExecutionStatus, HookDetail, HookForm, ParamDef, PreviewResult, TestRequest } from '../../api/types';
import OutputViewer from '../../components/OutputViewer.vue';
import StatusBadge from '../../components/StatusBadge.vue';
import { confirmDialog } from '../../composables/dialog';
import { toast } from '../../composables/toast';
const props = defineProps<{ form: HookForm; hook: HookDetail; dirty: boolean }>();
const SAMPLE_VALUES: Record<ParamDef['type'], unknown> = {
string: 'example',
number: 1,
boolean: true,
ip: '203.0.113.10',
};
const query = ref('');
const headersText = ref('');
const bodyType = ref<BodyKind>(props.hook.method === 'GET' ? 'none' : 'json');
const body = ref(props.hook.method === 'GET' ? '' : '{}');
const preview = ref<PreviewResult | null>(null);
const previewing = ref(false);
const running = ref(false);
const executionId = ref<number | null>(null);
const executionStatus = ref<ExecutionStatus | null>(null);
const isGet = computed(() => props.hook.method === 'GET');
function parseHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
for (const line of headersText.value.split('\n')) {
const index = line.indexOf(':');
if (index <= 0) continue;
headers[line.slice(0, index).trim()] = line.slice(index + 1).trim();
}
return headers;
}
function buildRequest(): TestRequest {
return { query: query.value, headers: parseHeaders(), bodyType: bodyType.value, body: body.value };
}
function setPath(target: Record<string, unknown>, path: string, value: unknown) {
const segments = path.split('.').filter(Boolean);
let current = target;
segments.forEach((segment, i) => {
if (i === segments.length - 1) current[segment] = value;
else {
if (typeof current[segment] !== 'object' || current[segment] === null) current[segment] = {};
current = current[segment] as Record<string, unknown>;
}
});
}
/** 根据当前参数配置生成示例请求 */
function generateSample() {
const json: Record<string, unknown> = {};
const form = new URLSearchParams();
const search = new URLSearchParams();
const headers: string[] = [];
for (const p of props.form.params) {
const value = SAMPLE_VALUES[p.type];
if (p.source === 'json') setPath(json, p.key, value);
else if (p.source === 'form') form.set(p.key, String(value));
else if (p.source === 'query') search.set(p.key, String(value));
else headers.push(`${p.key}: ${String(value)}`);
}
query.value = search.toString();
headersText.value = headers.join('\n');
if (!isGet.value) {
if ([...form.keys()].length && !Object.keys(json).length) {
bodyType.value = 'form';
body.value = form.toString();
} else {
bodyType.value = 'json';
body.value = JSON.stringify(json, null, 2);
}
}
preview.value = null;
}
async function runPreview() {
previewing.value = true;
try {
preview.value = await api.previewTest(props.hook.id, buildRequest(), props.form.params);
} catch (err) {
toast.error(errorMessage(err));
} finally {
previewing.value = false;
}
}
async function execute() {
const warning = props.dirty
? '当前有未保存的修改。测试将使用已保存的脚本 v' + (props.hook.currentVersion?.version ?? '?') + ' 和已保存的参数配置,而不是编辑器中的内容。'
: undefined;
const ok = await confirmDialog({
title: '真实执行脚本',
message: `将以服务账户身份在服务器上实际运行「${props.hook.name}」的脚本(v${props.hook.currentVersion?.version ?? '?'}),脚本对文件、服务等的修改会真实生效,且无法撤销。`,
warning,
confirmText: '确认执行',
danger: true,
});
if (!ok) return;
running.value = true;
try {
executionId.value = null;
executionStatus.value = null;
const res = await api.runTest(props.hook.id, buildRequest());
executionId.value = res.executionId;
executionStatus.value = 'queued';
} catch (err) {
toast.error(errorMessage(err));
} finally {
running.value = false;
}
}
function onStatus(status: ExecutionStatus) {
executionStatus.value = status;
}
</script>
<template>
<div class="stack">
<div class="alert alert-danger">
<span class="alert-icon">!</span>
<div>
<strong>注意:点击「执行」会在服务器上真实运行脚本。</strong>
「预览参数映射」只校验参数、不执行脚本;「执行」使用<strong>已保存</strong>的脚本版本与参数配置,跳过 Token 校验,其余流程与外部调用一致,并生成一条执行记录。
</div>
</div>
<div class="test-grid">
<div class="stack">
<div class="row">
<h3>模拟请求</h3>
<span class="badge badge-method">{{ hook.method }}</span>
<span class="mono small muted">/hooks/{{ hook.slug }}</span>
<span class="spacer" />
<button class="btn btn-sm" @click="generateSample">按参数生成示例</button>
</div>
<div class="field">
<label>Query 参数</label>
<input v-model="query" class="mono" placeholder="a=1&b=2" />
</div>
<div class="field">
<label>请求头</label>
<textarea v-model="headersText" class="mono" rows="3" placeholder="每行一个,如:X-Client-Id: abc" />
<div class="hint">鉴权相关请求头(Authorization、X-Hook-Token、Cookie)会被忽略。</div>
</div>
<div class="field">
<label>请求体</label>
<select v-model="bodyType" :disabled="isGet" style="max-width: 220px">
<option value="json">JSON</option>
<option value="form">表单(urlencoded)</option>
<option value="text">纯文本</option>
<option value="none">无</option>
</select>
<textarea
v-if="bodyType !== 'none'"
v-model="body"
class="mono"
rows="8"
:placeholder="bodyType === 'form' ? 'ip=203.0.113.10&name=home' : '{&quot;ip&quot;: &quot;203.0.113.10&quot;}'"
/>
</div>
<div class="row">
<button class="btn" :disabled="previewing" @click="runPreview">{{ previewing ? '预览中…' : '预览参数映射' }}</button>
<button class="btn btn-danger" :disabled="running || !hook.currentVersion" @click="execute">
{{ running ? '提交中…' : '执行(真实运行)' }}
</button>
</div>
<div v-if="dirty" class="small" style="color: var(--warning)">
有未保存的修改:预览使用编辑中的参数配置;执行只使用已保存的配置与脚本。
</div>
</div>
<div class="stack">
<h3>参数映射预览</h3>
<div v-if="!preview" class="empty small card" style="box-shadow: none">点击「预览参数映射」查看脚本将收到的环境变量</div>
<template v-else>
<div v-if="preview.ok" class="alert alert-success small">参数校验通过</div>
<div v-else class="alert alert-danger small">
<div>
<strong>参数校验未通过,脚本不会执行:</strong>
<ul style="margin: 4px 0 0; padding-left: 18px">
<li v-for="e in preview.errors" :key="e">{{ e }}</li>
</ul>
</div>
</div>
<div class="table-wrap card" style="box-shadow: none">
<table class="table compact">
<thead>
<tr>
<th>环境变量</th>
<th>值</th>
<th>来源</th>
</tr>
</thead>
<tbody>
<tr v-for="v in preview.values" :key="v.env">
<td class="mono nowrap">{{ v.env }}</td>
<td class="mono break">
<span v-if="v.value === ''" class="faint">(空)</span>
<template v-else>{{ v.value }}</template>
</td>
<td class="small muted nowrap">
{{ v.origin === 'request' ? '请求' : v.origin === 'default' ? '默认值' : '未提供' }}
</td>
</tr>
<tr v-if="!preview.values.length">
<td colspan="3" class="empty">无参数</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</div>
<div v-if="executionId" class="card" style="box-shadow: none">
<div class="card-header">
<h3>测试执行 #{{ executionId }}</h3>
<StatusBadge v-if="executionStatus" :status="executionStatus" />
<span class="spacer" />
<RouterLink :to="{ name: 'execution-detail', params: { id: executionId } }" class="btn btn-sm">查看执行详情</RouterLink>
</div>
<div class="card-body">
<OutputViewer :key="executionId" :execution-id="executionId" @status="onStatus" />
</div>
</div>
</div>
</template>
<style scoped>
.test-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
@media (max-width: 1000px) {
.test-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"strict": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
const backend = process.env.HOOKPILOT_DEV_BACKEND ?? 'http://127.0.0.1:8787';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': { target: backend, changeOrigin: false },
'^/hooks/': { target: backend, changeOrigin: false },
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
chunkSizeWarningLimit: 1024,
},
});