feat(web): WorkspaceHomeView with worktrees/main/settings tabs and CommitDialog

This commit is contained in:
2026-05-02 23:24:16 +08:00
parent d962ba2f16
commit 0423f0566c
6 changed files with 747 additions and 0 deletions
@@ -0,0 +1,86 @@
<template>
<AppShell>
<div class="space-y-4">
<NPageHeader :title="store.current?.name ?? wsSlug">
<template #subtitle>
{{ store.current?.slug }}
</template>
</NPageHeader>
<SyncBar
v-if="store.current"
:ws="store.current"
/>
<NTabs
v-model:value="tab"
type="line"
>
<NTabPane
name="worktrees"
tab="Worktrees"
>
<WorktreesTab
v-if="store.current"
:ws="store.current"
/>
</NTabPane>
<NTabPane
name="main"
tab="Main"
>
<MainTab
v-if="store.current"
:ws="store.current"
/>
</NTabPane>
<NTabPane
name="settings"
tab="Settings"
>
<SettingsTab
v-if="store.current"
:ws="store.current"
/>
</NTabPane>
</NTabs>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { NTabs, NTabPane, NPageHeader, useMessage } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useWorkspacesStore } from '@/stores/workspaces'
import SyncBar from './components/SyncBar.vue'
import WorktreesTab from './components/WorktreesTab.vue'
import MainTab from './components/MainTab.vue'
import SettingsTab from './components/SettingsTab.vue'
const route = useRoute()
const store = useWorkspacesStore()
const msg = useMessage()
const tab = ref<'worktrees' | 'main' | 'settings'>('worktrees')
const projectSlug = computed(() => route.params.slug as string)
const wsSlug = computed(() => route.params.wsSlug as string)
onMounted(async () => {
try {
// 通过 fetchByProject 拿列表后定位 wsID
await store.fetchByProject(projectSlug.value)
const ws = store.list.find((w) => w.slug === wsSlug.value)
if (!ws) {
msg.error('找不到工作区')
return
}
await store.fetchOne(ws.id)
await store.fetchWorktrees(ws.id)
await store.fetchCredential(ws.id)
} catch (e: unknown) {
msg.error('加载失败:' + (e instanceof Error ? e.message : String(e)))
}
})
</script>
@@ -0,0 +1,132 @@
<template>
<NModal
:show="true"
preset="card"
title="提交修改"
:style="{ width: '640px' }"
@close="emit('close')"
>
<p class="text-sm text-gray-600 mb-2">
{{ status.length }} 个改动
</p>
<NScrollbar
:style="{ maxHeight: '240px' }"
class="mb-4 border rounded p-2 bg-gray-50"
>
<div
v-for="f in status"
:key="f.path"
class="font-mono text-sm py-1"
>
<NTag
size="small"
type="info"
class="mr-2"
>
{{ f.index_x }}{{ f.worktree_y }}
</NTag>{{ f.path }}
</div>
<div
v-if="status.length === 0"
class="text-sm text-gray-400"
>
无改动
</div>
</NScrollbar>
<NForm label-placement="top">
<NFormItem
label="Commit message"
required
>
<NInput
v-model:value="message"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
/>
</NFormItem>
<NFormItem>
<NCheckbox v-model:checked="push">
提交后推送到远端
</NCheckbox>
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="emit('close')">
取消
</NButton>
<NButton
type="primary"
:loading="submitting"
:disabled="status.length === 0"
@click="submit"
>
提交
</NButton>
</NSpace>
</template>
</NModal>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
NModal,
NForm,
NFormItem,
NInput,
NCheckbox,
NButton,
NSpace,
NScrollbar,
NTag,
useMessage,
} from 'naive-ui'
import { useWorkspacesStore } from '@/stores/workspaces'
import type { FileStatus } from '@/api/types'
// target: 'main' 时用 wsID 调 commitOnMain;'worktree' 时用 wtID 调 commitOnWorktree。
const props = defineProps<{ target: 'main' | 'worktree'; targetId: string }>()
const emit = defineEmits<{ close: []; committed: [sha: string] }>()
const store = useWorkspacesStore()
const msg = useMessage()
const status = ref<FileStatus[]>([])
const message = ref<string>('')
const push = ref<boolean>(true)
const submitting = ref<boolean>(false)
onMounted(async () => {
try {
status.value =
props.target === 'main'
? await store.statusOnMain(props.targetId)
: await store.statusOnWorktree(props.targetId)
} catch (e: unknown) {
msg.error('读取 git status 失败:' + (e instanceof Error ? e.message : String(e)))
}
})
async function submit() {
if (!message.value.trim()) {
msg.warning('请填写 commit message')
return
}
submitting.value = true
try {
const body = { message: message.value, push: push.value, add_all: true }
const resp =
props.target === 'main'
? await store.commitOnMain(props.targetId, body)
: await store.commitOnWorktree(props.targetId, body)
msg.success('已提交:' + resp.sha.slice(0, 8))
emit('committed', resp.sha)
} catch (e: unknown) {
msg.error('提交失败:' + (e instanceof Error ? e.message : String(e)))
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,45 @@
<template>
<div>
<NSpace>
<NButton
type="primary"
@click="showCommit = true"
>
提交修改
</NButton>
<NButton @click="onPush">
Push 到远端
</NButton>
</NSpace>
<CommitDialog
v-if="showCommit"
target="main"
:target-id="ws.id"
@close="showCommit = false"
@committed="showCommit = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { NSpace, NButton, useMessage } from 'naive-ui'
import { useWorkspacesStore } from '@/stores/workspaces'
import CommitDialog from './CommitDialog.vue'
import type { Workspace } from '@/api/types'
const props = defineProps<{ ws: Workspace }>()
const store = useWorkspacesStore()
const msg = useMessage()
const showCommit = ref<boolean>(false)
async function onPush() {
try {
await store.pushOnMain(props.ws.id)
msg.success('已 push')
} catch (e: unknown) {
msg.error('push 失败:' + (e instanceof Error ? e.message : String(e)))
}
}
</script>
@@ -0,0 +1,206 @@
<template>
<NSpace
vertical
size="large"
>
<NCard title="基本信息">
<NForm
label-placement="left"
:label-width="100"
>
<NFormItem label="名称">
<NInput v-model:value="form.name" />
</NFormItem>
<NFormItem label="描述">
<NInput
v-model:value="form.description"
type="textarea"
/>
</NFormItem>
<NFormItem label="默认分支">
<NInput v-model:value="form.default_branch" />
</NFormItem>
</NForm>
<NButton
type="primary"
:loading="savingCore"
@click="saveCore"
>
保存
</NButton>
</NCard>
<NCard title="Git 凭据">
<p class="text-sm text-gray-500 mb-2">
当前指纹{{ store.credential?.fingerprint || '(未设置)' }}
</p>
<NForm
label-placement="left"
:label-width="100"
>
<NFormItem label="类型">
<NSelect
v-model:value="credForm.kind"
:options="kindOptions"
/>
</NFormItem>
<NFormItem
v-if="credForm.kind === 'pat'"
label="用户名"
>
<NInput v-model:value="credForm.username" />
</NFormItem>
<NFormItem
v-if="credForm.kind === 'pat'"
label="Token"
>
<NInput
v-model:value="credForm.secret"
type="password"
show-password-on="click"
/>
</NFormItem>
<NFormItem
v-if="credForm.kind === 'ssh_key'"
label="私钥(PEM)"
>
<NInput
v-model:value="credForm.secret"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
/>
</NFormItem>
</NForm>
<NButton
type="primary"
:loading="savingCred"
@click="saveCred"
>
替换凭据
</NButton>
</NCard>
<NCard title="危险区">
<NButton
type="error"
@click="onDelete"
>
删除工作区
</NButton>
</NCard>
</NSpace>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import {
NForm,
NFormItem,
NInput,
NSelect,
NButton,
NSpace,
NCard,
useMessage,
useDialog,
} from 'naive-ui'
import { useRouter } from 'vue-router'
import { useWorkspacesStore } from '@/stores/workspaces'
import type { CredentialKind, CredentialUpsertBody, Workspace } from '@/api/types'
interface CoreFormState {
name: string
description: string
default_branch: string
}
interface CredFormState {
kind: CredentialKind
username: string
secret: string
}
const props = defineProps<{ ws: Workspace }>()
const store = useWorkspacesStore()
const msg = useMessage()
const dialog = useDialog()
const router = useRouter()
const form = ref<CoreFormState>({
name: props.ws.name,
description: props.ws.description,
default_branch: props.ws.default_branch,
})
const credForm = ref<CredFormState>({
kind: store.credential?.kind ?? 'none',
username: store.credential?.username ?? '',
secret: '',
})
const savingCore = ref(false)
const savingCred = ref(false)
const kindOptions: { label: string; value: CredentialKind }[] = [
{ label: 'HTTPS PAT', value: 'pat' },
{ label: 'SSH Key', value: 'ssh_key' },
{ label: '公开仓库(无凭据)', value: 'none' },
]
async function saveCore() {
savingCore.value = true
try {
await store.update(props.ws.id, form.value)
msg.success('已保存')
} catch (e: unknown) {
msg.error('保存失败:' + (e instanceof Error ? e.message : String(e)))
} finally {
savingCore.value = false
}
}
async function saveCred() {
// kind!=='none' 时 secret 必填,避免向后端推空 token;与 WorkspaceCreateModal 校验一致。
if (credForm.value.kind !== 'none' && !credForm.value.secret) {
msg.error('请填写凭据 secret')
return
}
// kind==='none' 时不带 username/secret,避免之前残留的 PAT 用户名混入 'none' 请求体。
const body: CredentialUpsertBody =
credForm.value.kind === 'none'
? { kind: 'none' }
: {
kind: credForm.value.kind,
username: credForm.value.username,
secret: credForm.value.secret,
}
savingCred.value = true
try {
await store.upsertCredential(props.ws.id, body)
credForm.value.secret = ''
msg.success('凭据已替换')
} catch (e: unknown) {
msg.error('替换失败:' + (e instanceof Error ? e.message : String(e)))
} finally {
savingCred.value = false
}
}
function onDelete() {
dialog.error({
title: '确认删除工作区',
content: '将级联删除所有 worktree 并 rm -rf 磁盘文件,不可恢复。',
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await store.remove(props.ws.id)
msg.success('已删除')
router.push(`/p/${router.currentRoute.value.params.slug}/workspaces`)
} catch (e: unknown) {
msg.error('删除失败:' + (e instanceof Error ? e.message : String(e)))
}
},
})
}
</script>
@@ -0,0 +1,61 @@
<template>
<NCard size="small">
<NSpace
align="center"
justify="space-between"
>
<NSpace align="center">
<NTag :type="ws.sync_status === 'idle' ? 'success' : ws.sync_status === 'error' ? 'error' : 'info'">
{{ ws.sync_status }}
</NTag>
<span
v-if="ws.last_synced_at"
class="text-sm text-gray-500"
>
上次同步 {{ ws.last_synced_at }}
</span>
<span
v-else
class="text-sm text-gray-500"
>尚未同步</span>
<span
v-if="ws.last_sync_error"
class="text-sm text-red-600"
>{{ ws.last_sync_error }}</span>
</NSpace>
<NButton
:disabled="busy"
type="primary"
size="small"
@click="onSync"
>
立即同步
</NButton>
</NSpace>
</NCard>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { NCard, NTag, NButton, NSpace, useMessage } from 'naive-ui'
import { useWorkspacesStore } from '@/stores/workspaces'
import type { Workspace } from '@/api/types'
const props = defineProps<{ ws: Workspace }>()
const store = useWorkspacesStore()
const msg = useMessage()
const busy = computed(() => props.ws.sync_status === 'cloning' || props.ws.sync_status === 'syncing')
async function onSync() {
// store.sync() 仅触发异步 fetch;要等真正落到 idle/error,必须 pollUntilSettled。
try {
await store.sync(props.ws.id)
msg.info('已开始同步')
await store.pollUntilSettled(props.ws.id)
msg.success('已同步')
} catch (e: unknown) {
msg.error('同步失败:' + (e instanceof Error ? e.message : String(e)))
}
}
</script>
@@ -0,0 +1,217 @@
<template>
<div>
<NSpace
justify="end"
class="mb-2"
>
<NButton
type="primary"
@click="showCreate = true"
>
新建 worktree
</NButton>
</NSpace>
<NDataTable
:columns="columns"
:data="store.worktrees"
/>
<NModal
:show="showCreate"
preset="card"
title="新建 worktree"
:style="{ width: '420px' }"
@close="showCreate = false"
>
<NForm>
<NFormItem
label="分支"
required
>
<NInput v-model:value="newBranch" />
</NFormItem>
<NFormItem label="基线分支(默认 default_branch)">
<NInput v-model:value="newBase" />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="showCreate = false">
取消
</NButton>
<NButton
type="primary"
:loading="creating"
@click="onCreate"
>
创建
</NButton>
</NSpace>
</template>
</NModal>
<CommitDialog
v-if="commitFor"
target="worktree"
:target-id="commitFor.id"
@close="commitFor = null"
@committed="commitFor = null"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h } from 'vue'
import {
NDataTable,
NButton,
NSpace,
NTag,
NModal,
NForm,
NFormItem,
NInput,
useMessage,
useDialog,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { useWorkspacesStore } from '@/stores/workspaces'
import CommitDialog from './CommitDialog.vue'
import type { Workspace, Worktree, WorktreeStatus } from '@/api/types'
const props = defineProps<{ ws: Workspace }>()
const store = useWorkspacesStore()
const msg = useMessage()
const dialog = useDialog()
const showCreate = ref<boolean>(false)
const creating = ref<boolean>(false)
const newBranch = ref<string>('')
const newBase = ref<string>('')
const commitFor = ref<Worktree | null>(null)
const STATUS_TAG_TYPE: Record<WorktreeStatus, 'warning' | 'error' | 'default'> = {
active: 'warning',
pruning: 'error',
idle: 'default',
}
async function onCreate() {
if (!newBranch.value.trim()) {
msg.error('分支名为必填')
return
}
creating.value = true
try {
await store.createWorktree(props.ws.id, {
branch: newBranch.value,
base_branch: newBase.value || undefined,
})
showCreate.value = false
newBranch.value = ''
newBase.value = ''
} catch (e: unknown) {
msg.error('创建失败:' + (e instanceof Error ? e.message : String(e)))
} finally {
creating.value = false
}
}
async function onAcquire(wt: Worktree) {
try {
await store.acquire(wt.id)
} catch (e: unknown) {
msg.error('占用失败:' + (e instanceof Error ? e.message : String(e)))
}
}
async function onRelease(wt: Worktree) {
try {
await store.release(wt.id)
} catch (e: unknown) {
msg.error('释放失败:' + (e instanceof Error ? e.message : String(e)))
}
}
function onDelete(wt: Worktree) {
dialog.warning({
title: '确认删除',
content: `删除 worktree "${wt.branch}" 将移除磁盘文件夹,不可恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await store.deleteWorktree(wt.id)
} catch (e: unknown) {
msg.error('删除失败:' + (e instanceof Error ? e.message : String(e)))
}
},
})
}
async function onPush(wt: Worktree) {
try {
await store.pushOnWorktree(wt.id)
msg.success('已 push')
} catch (e: unknown) {
msg.error('push 失败:' + (e instanceof Error ? e.message : String(e)))
}
}
const columns = computed<DataTableColumns<Worktree>>(() => [
{ title: '分支', key: 'branch' },
{
title: '状态',
key: 'status',
render(row: Worktree) {
return h(
NTag,
{ type: STATUS_TAG_TYPE[row.status] ?? 'default', size: 'small' },
{ default: () => row.status },
)
},
},
{ title: '占用者', key: 'active_holder' },
{
title: '操作',
key: 'ops',
render(row: Worktree) {
return h(
NSpace,
{ size: 'small' },
{
default: () => [
h(
NButton,
{ size: 'tiny', onClick: () => (commitFor.value = row) },
{ default: () => '提交' },
),
h(
NButton,
{ size: 'tiny', onClick: () => onPush(row) },
{ default: () => 'Push' },
),
row.status === 'idle'
? h(
NButton,
{ size: 'tiny', type: 'primary', onClick: () => onAcquire(row) },
{ default: () => '占用' },
)
: h(
NButton,
{ size: 'tiny', onClick: () => onRelease(row) },
{ default: () => '释放' },
),
h(
NButton,
{ size: 'tiny', type: 'error', onClick: () => onDelete(row) },
{ default: () => '删除' },
),
],
},
)
},
},
])
</script>