feat(web): project list and home views

This commit is contained in:
2026-05-01 10:44:29 +08:00
parent f51e29dd89
commit 5d66f2f1ce
2 changed files with 210 additions and 0 deletions
@@ -0,0 +1,56 @@
<template>
<AppShell>
<NSpin :show="store.loading">
<div v-if="store.current" class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="space-y-1">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-semibold">{{ store.current.name }}</h1>
<NTag :type="store.current.visibility === 'private' ? 'warning' : 'info'" size="small">
{{ store.current.visibility === 'private' ? '私有' : '内部' }}
</NTag>
<NTag v-if="store.current.archived_at" size="small">已归档</NTag>
</div>
<p class="text-gray-500 text-sm font-mono">{{ store.current.slug }}</p>
</div>
</div>
<!-- Description -->
<p v-if="store.current.description" class="text-gray-600">{{ store.current.description }}</p>
<!-- Nav buttons -->
<div class="flex gap-3">
<NButton type="primary" @click="goRequirements">需求列表</NButton>
<NButton @click="goIssues">Issue 列表</NButton>
</div>
</div>
<div v-else-if="!store.loading" class="text-gray-400 text-sm">项目不存在或无权访问</div>
</NSpin>
</AppShell>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NTag, NSpin } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useProjectsStore } from '@/stores/projects'
const store = useProjectsStore()
const route = useRoute()
const router = useRouter()
const slug = route.params.slug as string
onMounted(() => store.load(slug))
function goRequirements() {
router.push({ name: 'requirement-list', params: { slug } })
}
function goIssues() {
router.push({ name: 'issue-list', params: { slug } })
}
</script>
+154
View File
@@ -0,0 +1,154 @@
<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>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">显示已归档</span>
<NSwitch v-model:value="store.includeArchived" @update:value="store.refresh()" />
</div>
<NButton type="primary" @click="showCreate = true">新建项目</NButton>
</div>
</div>
<!-- Active projects grid -->
<div v-if="store.activeItems.length > 0" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<NCard
v-for="p in store.activeItems"
:key="p.id"
class="cursor-pointer hover:shadow-md transition-shadow"
@click="goProject(p.slug)"
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="font-medium">{{ p.name }}</span>
<NTag :type="p.visibility === 'private' ? 'warning' : 'info'" size="small">
{{ p.visibility === 'private' ? '私有' : '内部' }}
</NTag>
</div>
<p class="text-sm text-gray-500 line-clamp-2">{{ p.description || '暂无描述' }}</p>
<p class="text-xs text-gray-400 font-mono">{{ p.slug }}</p>
</div>
</NCard>
</div>
<div v-else-if="!store.loading" class="text-gray-400 text-sm">暂无活跃项目</div>
<!-- Archived projects -->
<template v-if="store.includeArchived && store.archivedItems.length > 0">
<h2 class="text-base font-medium text-gray-500">已归档</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<NCard
v-for="p in store.archivedItems"
:key="p.id"
class="opacity-60 cursor-pointer hover:opacity-80 transition-opacity"
@click="goProject(p.slug)"
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="font-medium">{{ p.name }}</span>
<NTag size="small">已归档</NTag>
</div>
<p class="text-sm text-gray-500 line-clamp-2">{{ p.description || '暂无描述' }}</p>
<p class="text-xs text-gray-400 font-mono">{{ p.slug }}</p>
</div>
</NCard>
</div>
</template>
</div>
</NSpin>
<!-- Create project modal -->
<NModal v-model:show="showCreate" preset="card" title="新建项目" class="w-full max-w-md">
<NForm @submit.prevent="onSubmit">
<NFormItem label="Slug(URL 标识)">
<NInput v-model:value="form.slug" placeholder="my-project" />
</NFormItem>
<NFormItem label="名称">
<NInput v-model:value="form.name" placeholder="My Project" />
</NFormItem>
<NFormItem label="描述">
<NInput v-model:value="form.description" type="textarea" :rows="3" placeholder="(可选)" />
</NFormItem>
<NFormItem label="可见性">
<NRadioGroup v-model:value="form.visibility">
<NRadio value="private">私有</NRadio>
<NRadio value="internal">内部</NRadio>
</NRadioGroup>
</NFormItem>
<div class="flex justify-end gap-2 mt-4">
<NButton @click="showCreate = false">取消</NButton>
<NButton type="primary" attr-type="submit" :loading="creating">创建</NButton>
</div>
</NForm>
<p v-if="createError" class="text-red-500 text-sm mt-2">{{ createError }}</p>
</NModal>
</AppShell>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
NCard,
NButton,
NTag,
NModal,
NForm,
NFormItem,
NInput,
NRadio,
NRadioGroup,
NSwitch,
NSpin,
} from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useProjectsStore } from '@/stores/projects'
import type { Visibility } from '@/api/projects'
const store = useProjectsStore()
const router = useRouter()
const showCreate = ref(false)
const creating = ref(false)
const createError = ref('')
const form = ref<{ slug: string; name: string; description: string; visibility: Visibility }>({
slug: '',
name: '',
description: '',
visibility: 'private',
})
onMounted(() => store.refresh())
function goProject(slug: string) {
router.push({ name: 'project-home', params: { slug } })
}
async function onSubmit() {
createError.value = ''
if (!form.value.slug || !form.value.name) {
createError.value = 'Slug 和名称不能为空'
return
}
creating.value = true
try {
const p = await store.create({
slug: form.value.slug,
name: form.value.name,
description: form.value.description || undefined,
visibility: form.value.visibility,
})
showCreate.value = false
form.value = { slug: '', name: '', description: '', visibility: 'private' }
router.push({ name: 'project-home', params: { slug: p.slug } })
} catch (e) {
createError.value = e instanceof Error ? e.message : '创建失败'
} finally {
creating.value = false
}
}
</script>