启动项目逻辑

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
+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>