You've already forked agentic-coding-workflow
feat(web): WorkspaceListView and create modal
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<AppShell>
|
||||||
|
<NSpin :show="store.loading">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-semibold">
|
||||||
|
工作区
|
||||||
|
</h1>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
@click="showCreate = true"
|
||||||
|
>
|
||||||
|
新建工作区
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Workspace list -->
|
||||||
|
<NDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:data="store.list"
|
||||||
|
:loading="store.loading"
|
||||||
|
:row-props="rowProps"
|
||||||
|
:bordered="false"
|
||||||
|
striped
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!store.loading && store.list.length === 0"
|
||||||
|
class="text-gray-400 text-sm py-8 text-center"
|
||||||
|
>
|
||||||
|
暂无工作区,点击「新建工作区」创建第一个。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NSpin>
|
||||||
|
|
||||||
|
<!-- Create workspace modal -->
|
||||||
|
<WorkspaceCreateModal
|
||||||
|
v-if="showCreate"
|
||||||
|
:project-slug="projectSlug"
|
||||||
|
@close="showCreate = false"
|
||||||
|
@created="onCreated"
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { h, ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { NButton, NDataTable, NTag, NSpin, useMessage } from 'naive-ui'
|
||||||
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
|
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||||
|
import type { SyncStatus, Workspace } from '@/api/types'
|
||||||
|
import WorkspaceCreateModal from './components/WorkspaceCreateModal.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useWorkspacesStore()
|
||||||
|
const msg = useMessage()
|
||||||
|
|
||||||
|
const projectSlug = computed(() => route.params.slug as string)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
|
||||||
|
const SYNC_TAG_TYPE: Record<SyncStatus, 'success' | 'info' | 'warning' | 'error'> = {
|
||||||
|
idle: 'success',
|
||||||
|
cloning: 'info',
|
||||||
|
syncing: 'info',
|
||||||
|
error: 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataTableColumns<Workspace> = [
|
||||||
|
{ title: '名称', key: 'name' },
|
||||||
|
{
|
||||||
|
title: 'Slug',
|
||||||
|
key: 'slug',
|
||||||
|
render: (row) => h('span', { class: 'font-mono text-xs text-gray-500' }, row.slug),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '同步状态',
|
||||||
|
key: 'sync_status',
|
||||||
|
width: 120,
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NTag,
|
||||||
|
{ type: SYNC_TAG_TYPE[row.sync_status] ?? 'default', size: 'small' },
|
||||||
|
{ default: () => row.sync_status },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '默认分支', key: 'default_branch' },
|
||||||
|
{
|
||||||
|
title: '上次同步',
|
||||||
|
key: 'last_synced_at',
|
||||||
|
render: (row) => row.last_synced_at ?? '—',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function rowProps(row: Workspace) {
|
||||||
|
return {
|
||||||
|
style: 'cursor: pointer',
|
||||||
|
onClick: () => router.push(`/p/${projectSlug.value}/w/${row.slug}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreated() {
|
||||||
|
showCreate.value = false
|
||||||
|
store.fetchByProject(projectSlug.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await store.fetchByProject(projectSlug.value)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
msg.error('加载工作区失败:' + (e instanceof Error ? e.message : String(e)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<NModal
|
||||||
|
:show="true"
|
||||||
|
preset="card"
|
||||||
|
title="新建工作区"
|
||||||
|
:style="{ width: '560px' }"
|
||||||
|
@close="emit('close')"
|
||||||
|
>
|
||||||
|
<NForm
|
||||||
|
label-placement="left"
|
||||||
|
:label-width="100"
|
||||||
|
>
|
||||||
|
<NFormItem
|
||||||
|
label="Slug"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.slug"
|
||||||
|
placeholder="小写字母/数字/连字符,1–32 位"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="名称">
|
||||||
|
<NInput v-model:value="form.name" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="描述">
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="(可选)"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem
|
||||||
|
label="Git 远端"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.git_remote_url"
|
||||||
|
placeholder="https://... / git@... / ssh://..."
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="默认分支">
|
||||||
|
<NInput v-model:value="form.default_branch" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="凭据类型">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="form.credential.kind"
|
||||||
|
:options="kindOptions"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem
|
||||||
|
v-if="form.credential.kind === 'pat'"
|
||||||
|
label="用户名"
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.credential.username"
|
||||||
|
placeholder="GitHub 通常填 git 或用户名"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem
|
||||||
|
v-if="form.credential.kind === 'pat'"
|
||||||
|
label="Token"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.credential.secret"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem
|
||||||
|
v-if="form.credential.kind === 'ssh_key'"
|
||||||
|
label="私钥(PEM)"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.credential.secret"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||||
|
/>
|
||||||
|
</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 } from 'vue'
|
||||||
|
import {
|
||||||
|
NModal,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NSelect,
|
||||||
|
NButton,
|
||||||
|
NSpace,
|
||||||
|
useMessage,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||||
|
import type { CredentialKind, CredentialUpsertBody } from '@/api/types'
|
||||||
|
|
||||||
|
interface CredentialFormState {
|
||||||
|
kind: CredentialKind
|
||||||
|
username: string
|
||||||
|
secret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateFormState {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
git_remote_url: string
|
||||||
|
default_branch: string
|
||||||
|
credential: CredentialFormState
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ projectSlug: string }>()
|
||||||
|
const emit = defineEmits<{ close: []; created: [] }>()
|
||||||
|
|
||||||
|
const store = useWorkspacesStore()
|
||||||
|
const msg = useMessage()
|
||||||
|
|
||||||
|
const form = ref<CreateFormState>({
|
||||||
|
slug: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
git_remote_url: '',
|
||||||
|
default_branch: 'main',
|
||||||
|
credential: {
|
||||||
|
kind: 'pat',
|
||||||
|
username: 'git',
|
||||||
|
secret: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const kindOptions: { label: string; value: CredentialKind }[] = [
|
||||||
|
{ label: 'HTTPS PAT', value: 'pat' },
|
||||||
|
{ label: 'SSH Key', value: 'ssh_key' },
|
||||||
|
{ label: '公开仓库(无凭据)', value: 'none' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function buildCredential(): CredentialUpsertBody {
|
||||||
|
if (form.value.credential.kind === 'none') {
|
||||||
|
return { kind: 'none' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: form.value.credential.kind,
|
||||||
|
username: form.value.credential.username,
|
||||||
|
secret: form.value.credential.secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
// 必填字段前置校验:与 ProjectListView/RequirementListView 保持一致,避免空请求穿透到后端。
|
||||||
|
if (!form.value.slug.trim() || !form.value.git_remote_url.trim()) {
|
||||||
|
msg.error('Slug 与 Git 远端为必填')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.value.credential.kind !== 'none' && !form.value.credential.secret) {
|
||||||
|
msg.error('请填写凭据 secret')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await store.create(props.projectSlug, {
|
||||||
|
slug: form.value.slug,
|
||||||
|
name: form.value.name || form.value.slug,
|
||||||
|
description: form.value.description,
|
||||||
|
git_remote_url: form.value.git_remote_url,
|
||||||
|
default_branch: form.value.default_branch || 'main',
|
||||||
|
credential: buildCredential(),
|
||||||
|
})
|
||||||
|
msg.success('工作区创建成功,正在异步克隆')
|
||||||
|
emit('created')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
msg.error('创建失败:' + (e instanceof Error ? e.message : String(e)))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user