启动项目逻辑

This commit is contained in:
2026-06-09 22:43:29 +08:00
parent d5a28b11a3
commit f4a7c770af
43 changed files with 3797 additions and 42 deletions
+79
View File
@@ -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)
}
+128
View File
@@ -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 }
}
+75
View File
@@ -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 }
})
+11 -1
View File
@@ -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>