feat: initial commit for HookPilot
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
Vendored
+7
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 <token></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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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' : '{"ip": "203.0.113.10"}'"
|
||||
/>
|
||||
</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>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user