You've already forked agentic-coding-workflow
启动项目逻辑
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { request } from './client'
|
||||
|
||||
export type RunState = 'running' | 'stopped' | 'crashed'
|
||||
|
||||
export interface RunProfile {
|
||||
id: string
|
||||
workspace_id: string
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
command: string
|
||||
args: string[]
|
||||
env_keys: string[]
|
||||
enabled: boolean
|
||||
status: RunState
|
||||
started_at: string | null
|
||||
last_started_at: string | null
|
||||
last_exit_code: number | null
|
||||
last_error: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface RunStatus {
|
||||
status: RunState
|
||||
started_at: string | null
|
||||
last_exit_code: number | null
|
||||
last_error: string
|
||||
}
|
||||
|
||||
export interface LogLine {
|
||||
id: number
|
||||
level: 'info' | 'error'
|
||||
text: string
|
||||
ts: string
|
||||
}
|
||||
|
||||
export interface CreateRunProfileBody {
|
||||
slug: string
|
||||
name: string
|
||||
description?: string
|
||||
command: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type UpdateRunProfileBody = Partial<CreateRunProfileBody>
|
||||
|
||||
const enc = (v: string) => encodeURIComponent(v)
|
||||
|
||||
export const runsApi = {
|
||||
list: (wsID: string) => request<RunProfile[]>(`/api/v1/workspaces/${enc(wsID)}/run-profiles/`),
|
||||
create: (wsID: string, body: CreateRunProfileBody) =>
|
||||
request<RunProfile>(`/api/v1/workspaces/${enc(wsID)}/run-profiles/`, { method: 'POST', body }),
|
||||
update: (id: string, body: UpdateRunProfileBody) =>
|
||||
request<RunProfile>(`/api/v1/run-profiles/${enc(id)}`, { method: 'PATCH', body }),
|
||||
remove: (id: string) => request<void>(`/api/v1/run-profiles/${enc(id)}`, { method: 'DELETE' }),
|
||||
start: (id: string) =>
|
||||
request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/start`, { method: 'POST' }),
|
||||
stop: (id: string) =>
|
||||
request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/stop`, { method: 'POST' }),
|
||||
restart: (id: string) =>
|
||||
request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/restart`, { method: 'POST' }),
|
||||
status: (id: string) => request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/status`),
|
||||
logs: (id: string, since = 0) =>
|
||||
request<LogLine[]>(`/api/v1/run-profiles/${enc(id)}/logs?since=${since}`),
|
||||
}
|
||||
|
||||
// openRunLogsWS 打开到日志流的原生 WebSocket。浏览器 WS 不支持自定义 header,
|
||||
// 故 token 经 ?token= 传递(同 acp WS 约定)。
|
||||
export function openRunLogsWS(profileID: string, since: number, token: string): WebSocket {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const host = window.location.host
|
||||
const url =
|
||||
`${proto}://${host}/api/v1/run-profiles/${enc(profileID)}` +
|
||||
`/logs/stream?since=${since}&token=${enc(token)}`
|
||||
return new WebSocket(url)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// useRunStream 封装 run profile 的日志 WebSocket,结构照搬 useAcpStream:
|
||||
// - connect + 自动重连(指数退避 1s/3s,最多 2 次)
|
||||
// - ?since=lastLogID 续传
|
||||
// - 把日志行累积进 ref 数组;state 帧更新 runStatus
|
||||
import { ref, onScopeDispose, type Ref } from 'vue'
|
||||
import { openRunLogsWS, type LogLine, type RunState } from '@/api/runs'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'error'
|
||||
|
||||
export interface UseRunStreamReturn {
|
||||
status: Ref<StreamStatus>
|
||||
runStatus: Ref<RunState>
|
||||
logs: Ref<LogLine[]>
|
||||
reconnect: () => void
|
||||
close: () => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
interface WireFrame {
|
||||
id?: number
|
||||
kind?: string
|
||||
level?: 'info' | 'error'
|
||||
text?: string
|
||||
ts?: string
|
||||
status?: RunState
|
||||
}
|
||||
|
||||
export function useRunStream(profileId: string): UseRunStreamReturn {
|
||||
const status = ref<StreamStatus>('idle')
|
||||
const runStatus = ref<RunState>('stopped')
|
||||
const logs = ref<LogLine[]>([])
|
||||
const lastLogID = ref(0)
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let retried = 0
|
||||
let manualClose = false
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
function connect() {
|
||||
if (ws || manualClose) return
|
||||
status.value = 'connecting'
|
||||
const token = auth.token ?? ''
|
||||
const sock = openRunLogsWS(profileId, lastLogID.value, token)
|
||||
ws = sock
|
||||
|
||||
sock.onopen = () => {
|
||||
status.value = 'streaming'
|
||||
retried = 0
|
||||
}
|
||||
|
||||
sock.onmessage = (ev: MessageEvent) => {
|
||||
let parsed: WireFrame
|
||||
try {
|
||||
parsed = JSON.parse(ev.data as string) as WireFrame
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return
|
||||
|
||||
if (parsed.kind === 'state') {
|
||||
if (parsed.status) runStatus.value = parsed.status
|
||||
return
|
||||
}
|
||||
if (parsed.kind === 'slow_consumer_disconnect') {
|
||||
status.value = 'error'
|
||||
return
|
||||
}
|
||||
// 普通日志行
|
||||
if (parsed.kind === 'log' && typeof parsed.id === 'number') {
|
||||
if (parsed.id > lastLogID.value) lastLogID.value = parsed.id
|
||||
logs.value.push({
|
||||
id: parsed.id,
|
||||
level: parsed.level ?? 'info',
|
||||
text: parsed.text ?? '',
|
||||
ts: parsed.ts ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sock.onerror = () => {
|
||||
status.value = 'error'
|
||||
}
|
||||
|
||||
sock.onclose = () => {
|
||||
ws = null
|
||||
if (manualClose) {
|
||||
status.value = 'closed'
|
||||
return
|
||||
}
|
||||
if (retried < 2) {
|
||||
const delay = retried === 0 ? 1000 : 3000
|
||||
retried += 1
|
||||
reconnectTimer = setTimeout(connect, delay)
|
||||
} else {
|
||||
status.value = 'closed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
if (ws) ws.close()
|
||||
retried = 0
|
||||
manualClose = false
|
||||
connect()
|
||||
}
|
||||
|
||||
function close() {
|
||||
manualClose = true
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
if (ws) ws.close()
|
||||
status.value = 'closed'
|
||||
}
|
||||
|
||||
function clear() {
|
||||
logs.value = []
|
||||
}
|
||||
|
||||
connect()
|
||||
onScopeDispose(close)
|
||||
|
||||
return { status, runStatus, logs, reconnect, close, clear }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
runsApi,
|
||||
type RunProfile,
|
||||
type RunStatus,
|
||||
type CreateRunProfileBody,
|
||||
type UpdateRunProfileBody,
|
||||
} from '@/api/runs'
|
||||
|
||||
export const useRunsStore = defineStore('runs', () => {
|
||||
const profiles = ref<RunProfile[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetch(wsID: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
profiles.value = await runsApi.list(wsID)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(wsID: string, body: CreateRunProfileBody) {
|
||||
const p = await runsApi.create(wsID, body)
|
||||
profiles.value.unshift(p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function update(id: string, body: UpdateRunProfileBody) {
|
||||
const p = await runsApi.update(id, body)
|
||||
replace(p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
await runsApi.remove(id)
|
||||
profiles.value = profiles.value.filter((x) => x.id !== id)
|
||||
}
|
||||
|
||||
async function start(id: string) {
|
||||
return applyStatus(id, await runsApi.start(id))
|
||||
}
|
||||
async function stop(id: string) {
|
||||
return applyStatus(id, await runsApi.stop(id))
|
||||
}
|
||||
async function restart(id: string) {
|
||||
return applyStatus(id, await runsApi.restart(id))
|
||||
}
|
||||
async function refreshStatus(id: string) {
|
||||
return applyStatus(id, await runsApi.status(id))
|
||||
}
|
||||
|
||||
function replace(p: RunProfile) {
|
||||
profiles.value = profiles.value.map((x) => (x.id === p.id ? p : x))
|
||||
}
|
||||
|
||||
// applyStatus 用 start/stop/restart/status 返回的轻量状态更新对应行的运行态字段。
|
||||
function applyStatus(id: string, s: RunStatus): RunStatus {
|
||||
profiles.value = profiles.value.map((x) =>
|
||||
x.id === id
|
||||
? {
|
||||
...x,
|
||||
status: s.status,
|
||||
started_at: s.started_at,
|
||||
last_exit_code: s.last_exit_code,
|
||||
last_error: s.last_error,
|
||||
}
|
||||
: x,
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
||||
return { profiles, loading, fetch, create, update, remove, start, stop, restart, refreshStatus }
|
||||
})
|
||||
@@ -34,6 +34,15 @@
|
||||
:ws="store.current"
|
||||
/>
|
||||
</NTabPane>
|
||||
<NTabPane
|
||||
name="runs"
|
||||
tab="Run"
|
||||
>
|
||||
<RunsTab
|
||||
v-if="store.current"
|
||||
:ws="store.current"
|
||||
/>
|
||||
</NTabPane>
|
||||
<NTabPane
|
||||
name="settings"
|
||||
tab="Settings"
|
||||
@@ -58,11 +67,12 @@ import SyncBar from './components/SyncBar.vue'
|
||||
import WorktreesTab from './components/WorktreesTab.vue'
|
||||
import MainTab from './components/MainTab.vue'
|
||||
import SettingsTab from './components/SettingsTab.vue'
|
||||
import RunsTab from './components/RunsTab.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useWorkspacesStore()
|
||||
const msg = useMessage()
|
||||
const tab = ref<'worktrees' | 'main' | 'settings'>('worktrees')
|
||||
const tab = ref<'worktrees' | 'main' | 'settings' | 'runs'>('worktrees')
|
||||
|
||||
const projectSlug = computed(() => route.params.slug as string)
|
||||
const wsSlug = computed(() => route.params.wsSlug as string)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="border rounded bg-gray-900 text-gray-100">
|
||||
<div class="flex items-center justify-between px-3 py-1.5 border-b border-gray-700 text-xs">
|
||||
<span>
|
||||
日志 ·
|
||||
<span :class="connClass">{{ connText }}</span>
|
||||
</span>
|
||||
<NButton
|
||||
text
|
||||
size="tiny"
|
||||
class="!text-gray-300"
|
||||
@click="clear"
|
||||
>
|
||||
清屏
|
||||
</NButton>
|
||||
</div>
|
||||
<NScrollbar
|
||||
ref="scrollRef"
|
||||
:style="{ maxHeight: '360px' }"
|
||||
>
|
||||
<div class="p-3 font-mono text-xs leading-relaxed">
|
||||
<div
|
||||
v-for="l in logs"
|
||||
:key="l.id"
|
||||
:class="l.level === 'error' ? 'text-red-400' : 'text-gray-200'"
|
||||
class="whitespace-pre-wrap break-all"
|
||||
>
|
||||
{{ l.text }}
|
||||
</div>
|
||||
<div
|
||||
v-if="logs.length === 0"
|
||||
class="text-gray-500"
|
||||
>
|
||||
暂无日志(进程未运行或尚无输出)
|
||||
</div>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { NScrollbar, NButton } from 'naive-ui'
|
||||
import { useRunStream } from '@/composables/useRunStream'
|
||||
|
||||
const props = defineProps<{ profileId: string }>()
|
||||
|
||||
const { status, logs, clear } = useRunStream(props.profileId)
|
||||
|
||||
const scrollRef = ref<InstanceType<typeof NScrollbar> | null>(null)
|
||||
|
||||
// 新日志到达后自动滚到底部。
|
||||
watch(
|
||||
() => logs.value.length,
|
||||
() => {
|
||||
nextTick(() => scrollRef.value?.scrollTo({ top: 999_999_999 }))
|
||||
},
|
||||
)
|
||||
|
||||
const connText = computed(() => {
|
||||
switch (status.value) {
|
||||
case 'streaming':
|
||||
return '已连接'
|
||||
case 'connecting':
|
||||
return '连接中…'
|
||||
case 'error':
|
||||
return '连接异常'
|
||||
default:
|
||||
return '已断开'
|
||||
}
|
||||
})
|
||||
const connClass = computed(() =>
|
||||
status.value === 'streaming'
|
||||
? 'text-green-400'
|
||||
: status.value === 'error'
|
||||
? 'text-red-400'
|
||||
: 'text-gray-400',
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<NModal
|
||||
:show="true"
|
||||
preset="card"
|
||||
:title="profile ? '编辑运行档' : '新建运行档'"
|
||||
:style="{ width: '640px' }"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<NForm label-placement="top">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<NFormItem
|
||||
label="Slug"
|
||||
required
|
||||
>
|
||||
<NInput
|
||||
v-model:value="form.slug"
|
||||
placeholder="dev"
|
||||
:disabled="!!profile"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem
|
||||
label="名称"
|
||||
required
|
||||
>
|
||||
<NInput
|
||||
v-model:value="form.name"
|
||||
placeholder="Dev server"
|
||||
/>
|
||||
</NFormItem>
|
||||
</div>
|
||||
|
||||
<NFormItem label="描述">
|
||||
<NInput
|
||||
v-model:value="form.description"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 3 }"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem
|
||||
label="启动命令"
|
||||
required
|
||||
>
|
||||
<NInput
|
||||
v-model:value="form.command"
|
||||
placeholder="pnpm"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="参数(每行一个)">
|
||||
<NInput
|
||||
v-model:value="argsText"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||
placeholder="dev"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="环境变量">
|
||||
<div class="w-full">
|
||||
<div
|
||||
v-if="profile && !editEnv"
|
||||
class="text-sm text-gray-500 mb-2"
|
||||
>
|
||||
<span v-if="profile.env_keys.length">已配置:{{ profile.env_keys.join(', ') }}</span>
|
||||
<span v-else>(无)</span>
|
||||
<NButton
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
class="ml-2"
|
||||
@click="enableEnvEdit"
|
||||
>
|
||||
修改
|
||||
</NButton>
|
||||
</div>
|
||||
<NDynamicInput
|
||||
v-else
|
||||
v-model:value="envPairs"
|
||||
preset="pair"
|
||||
key-placeholder="KEY"
|
||||
value-placeholder="value"
|
||||
/>
|
||||
</div>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem>
|
||||
<NCheckbox v-model:checked="form.enabled">
|
||||
启用(禁用后不可 start)
|
||||
</NCheckbox>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="emit('close')">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="submit"
|
||||
>
|
||||
保存
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import {
|
||||
NModal,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NDynamicInput,
|
||||
NCheckbox,
|
||||
NButton,
|
||||
NSpace,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { ApiException } from '@/api/types'
|
||||
import { useRunsStore } from '@/stores/runs'
|
||||
import type { RunProfile, CreateRunProfileBody, UpdateRunProfileBody } from '@/api/runs'
|
||||
|
||||
const props = defineProps<{ wsId: string; profile?: RunProfile | null }>()
|
||||
const emit = defineEmits<{ close: []; saved: [p: RunProfile] }>()
|
||||
const store = useRunsStore()
|
||||
const msg = useMessage()
|
||||
|
||||
const form = reactive({
|
||||
slug: props.profile?.slug ?? '',
|
||||
name: props.profile?.name ?? '',
|
||||
description: props.profile?.description ?? '',
|
||||
command: props.profile?.command ?? '',
|
||||
enabled: props.profile?.enabled ?? true,
|
||||
})
|
||||
const argsText = ref<string>((props.profile?.args ?? []).join('\n'))
|
||||
const envPairs = ref<{ key: string; value: string }[]>([])
|
||||
// 编辑模式下默认不改 env(server 不回传明文值);点"修改"后才整体替换。
|
||||
const editEnv = ref(!props.profile)
|
||||
const submitting = ref(false)
|
||||
|
||||
function enableEnvEdit() {
|
||||
editEnv.value = true
|
||||
envPairs.value = []
|
||||
}
|
||||
|
||||
function parsedArgs(): string[] {
|
||||
return argsText.value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
}
|
||||
|
||||
function parsedEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const p of envPairs.value) {
|
||||
if (p.key.trim()) env[p.key.trim()] = p.value
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.slug.trim() || !form.name.trim() || !form.command.trim()) {
|
||||
msg.warning('slug / 名称 / 启动命令为必填')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
let saved: RunProfile
|
||||
if (props.profile) {
|
||||
const body: UpdateRunProfileBody = {
|
||||
slug: form.slug,
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
command: form.command,
|
||||
args: parsedArgs(),
|
||||
enabled: form.enabled,
|
||||
}
|
||||
// 仅在用户显式修改时才整体替换 env,否则保留原值。
|
||||
if (editEnv.value) body.env = parsedEnv()
|
||||
saved = await store.update(props.profile.id, body)
|
||||
} else {
|
||||
const body: CreateRunProfileBody = {
|
||||
slug: form.slug,
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
command: form.command,
|
||||
args: parsedArgs(),
|
||||
env: parsedEnv(),
|
||||
enabled: form.enabled,
|
||||
}
|
||||
saved = await store.create(props.wsId, body)
|
||||
}
|
||||
emit('saved', saved)
|
||||
emit('close')
|
||||
} catch (e: unknown) {
|
||||
msg.error('保存失败:' + (e instanceof ApiException ? e.body.message : String(e)))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">在 main 工作区托管启动的运行档</span>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openCreate"
|
||||
>
|
||||
新建运行档
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="store.profiles"
|
||||
:loading="store.loading"
|
||||
:row-key="(row: RunProfile) => row.id"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<div v-if="selectedId">
|
||||
<RunLogViewer
|
||||
:key="selectedId"
|
||||
:profile-id="selectedId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RunProfileDialog
|
||||
v-if="dialogOpen"
|
||||
:ws-id="ws.id"
|
||||
:profile="editing"
|
||||
@close="dialogOpen = false"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, ref } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NSpace,
|
||||
NTag,
|
||||
useMessage,
|
||||
useDialog,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { ApiException } from '@/api/types'
|
||||
import type { Workspace } from '@/api/types'
|
||||
import { useRunsStore } from '@/stores/runs'
|
||||
import type { RunProfile, RunState } from '@/api/runs'
|
||||
import RunProfileDialog from './RunProfileDialog.vue'
|
||||
import RunLogViewer from './RunLogViewer.vue'
|
||||
|
||||
const props = defineProps<{ ws: Workspace }>()
|
||||
const store = useRunsStore()
|
||||
const msg = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const selectedId = ref<string | null>(null)
|
||||
const dialogOpen = ref(false)
|
||||
const editing = ref<RunProfile | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
store.fetch(props.ws.id).catch((e: unknown) => msg.error('加载运行档失败:' + errText(e)))
|
||||
})
|
||||
|
||||
function errText(e: unknown): string {
|
||||
return e instanceof ApiException ? e.body.message : e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
|
||||
const tagType: Record<RunState, 'success' | 'default' | 'error'> = {
|
||||
running: 'success',
|
||||
stopped: 'default',
|
||||
crashed: 'error',
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
dialogOpen.value = true
|
||||
}
|
||||
function openEdit(p: RunProfile) {
|
||||
editing.value = p
|
||||
dialogOpen.value = true
|
||||
}
|
||||
function onSaved() {
|
||||
dialogOpen.value = false
|
||||
}
|
||||
|
||||
async function act(fn: () => Promise<unknown>, ok: string) {
|
||||
try {
|
||||
await fn()
|
||||
msg.success(ok)
|
||||
} catch (e: unknown) {
|
||||
msg.error(errText(e))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(p: RunProfile) {
|
||||
dialog.warning({
|
||||
title: '删除运行档',
|
||||
content: `确定删除「${p.name}」?运行中的进程会被先停止。`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await act(() => store.remove(p.id), '已删除')
|
||||
if (selectedId.value === p.id) selectedId.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: DataTableColumns<RunProfile> = [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: 'Slug', key: 'slug' },
|
||||
{ title: '命令', key: 'command', ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (row) =>
|
||||
h(NTag, { type: tagType[row.status], size: 'small' }, { default: () => row.status }),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
render: (row) =>
|
||||
h(NSpace, { size: 'small' }, () => [
|
||||
row.status === 'running'
|
||||
? h(
|
||||
NButton,
|
||||
{ size: 'tiny', onClick: () => act(() => store.stop(row.id), '已停止') },
|
||||
() => '停止',
|
||||
)
|
||||
: h(
|
||||
NButton,
|
||||
{
|
||||
size: 'tiny',
|
||||
type: 'primary',
|
||||
disabled: !row.enabled,
|
||||
onClick: () => act(() => store.start(row.id), '已启动'),
|
||||
},
|
||||
() => '启动',
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', onClick: () => act(() => store.restart(row.id), '已重启') },
|
||||
() => '重启',
|
||||
),
|
||||
h(NButton, { size: 'tiny', onClick: () => (selectedId.value = row.id) }, () => '日志'),
|
||||
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, () => '编辑'),
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', type: 'error', onClick: () => confirmDelete(row) },
|
||||
() => '删除',
|
||||
),
|
||||
]),
|
||||
},
|
||||
]
|
||||
</script>
|
||||
Reference in New Issue
Block a user