Files
prop-data-guard/frontend/src/views/project/Project.vue
T
hiderfong 86c487ae40 fix: levels and categories empty due to res vs res.data mismatch
- Fix Classification.vue: levels use res.data, auto-fetch on mount
- Fix Task.vue: levels and categories use res.data
- Fix Project.vue: templates use res.data
2026-04-23 10:51:06 +08:00

319 lines
8.5 KiB
Vue

<template>
<div class="page-container">
<div class="page-header">
<h2 class="page-title">项目管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新建项目
</el-button>
</div>
<div class="search-bar card-shadow">
<el-input
v-model="keyword"
placeholder="搜索项目名称"
clearable
style="width: 260px"
@keyup.enter="handleSearch"
>
<template #append>
<el-button @click="handleSearch">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<div class="project-list">
<el-row :gutter="16">
<el-col v-for="p in projectList" :key="p.id" :xs="24" :sm="12" :lg="8">
<div class="project-card card-shadow">
<div class="project-header">
<div class="project-name">{{ p.name }}</div>
<el-tag :type="statusType(p.status)" size="small">{{ statusText(p.status) }}</el-tag>
</div>
<div class="project-desc">{{ p.description || '暂无描述' }}</div>
<div class="project-stats">
<div class="stat-item">
<div class="stat-num">{{ p.stats?.total || 0 }}</div>
<div class="stat-label">总字段</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ p.stats?.auto || 0 }}</div>
<div class="stat-label">自动</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ p.stats?.manual || 0 }}</div>
<div class="stat-label">人工</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ p.stats?.reviewed || 0 }}</div>
<div class="stat-label">已审</div>
</div>
</div>
<div class="project-actions">
<el-button type="primary" size="small" @click="handleAutoClassify(p)">自动分类</el-button>
<el-button type="danger" link size="small" @click="handleDelete(p)">删除</el-button>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- Add Dialog -->
<el-dialog v-model="dialogVisible" title="新建项目" width="520px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="项目名称" prop="name">
<el-input v-model="form.name" placeholder="请输入项目名称" />
</el-form-item>
<el-form-item label="模板" prop="template_id">
<el-select v-model="form.template_id" placeholder="选择分类分级模板" style="width: 100%">
<el-option v-for="t in templates" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="数据源范围">
<el-select v-model="form.target_source_ids" multiple placeholder="选择数据源(不选则为全部)" style="width: 100%">
<el-option v-for="s in dataSources" :key="s.id" :label="s.name" :value="String(s.id)" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { getProjects, createProject, deleteProject, autoClassifyProject } from '@/api/project'
import { getTemplates } from '@/api/classification'
import { getDataSources } from '@/api/datasource'
import type { ProjectItem } from '@/api/project'
const loading = ref(false)
const projectList = ref<ProjectItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const keyword = ref('')
const dialogVisible = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const form = reactive({
name: '',
template_id: undefined as number | undefined,
target_source_ids: [] as string[],
description: '',
})
const rules = {
name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
template_id: [{ required: true, message: '请选择模板', trigger: 'change' }],
}
const templates = ref<any[]>([])
const dataSources = ref<any[]>([])
function statusType(status: string) {
const map: Record<string, any> = {
created: 'info',
scanning: 'warning',
assigning: '',
labeling: 'primary',
reviewing: 'success',
accepting: 'success',
published: 'success',
}
return map[status] || 'info'
}
function statusText(status: string) {
const map: Record<string, string> = {
created: '已创建',
scanning: '扫描中',
assigning: '分配中',
labeling: '打标中',
reviewing: '审核中',
accepting: '验收中',
published: '已发布',
}
return map[status] || status
}
async function fetchData() {
loading.value = true
try {
const res: any = await getProjects({ page: page.value, page_size: pageSize.value, keyword: keyword.value || undefined })
projectList.value = res.data || []
total.value = res.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
page.value = 1
fetchData()
}
function handleAdd() {
form.name = ''
form.template_id = templates.value[0]?.id
form.target_source_ids = []
form.description = ''
dialogVisible.value = true
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
await createProject({
name: form.name,
template_id: form.template_id!,
target_source_ids: form.target_source_ids.join(','),
description: form.description,
})
ElMessage.success('创建成功')
dialogVisible.value = false
fetchData()
} catch (e: any) {
ElMessage.error(e?.message || '创建失败')
} finally {
submitLoading.value = false
}
}
async function handleAutoClassify(p: ProjectItem) {
try {
const res: any = await autoClassifyProject(p.id)
ElMessage.success(res.message || '自动分类完成')
fetchData()
} catch (e: any) {
ElMessage.error(e?.message || '自动分类失败')
}
}
async function handleDelete(p: ProjectItem) {
try {
await ElMessageBox.confirm('确认删除该项目?', '提示', { type: 'warning' })
await deleteProject(p.id)
ElMessage.success('删除成功')
fetchData()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error(e?.message || '删除失败')
}
}
async function fetchMeta() {
try {
const [tRes, sRes] = await Promise.all([getTemplates(), getDataSources()])
templates.value = (tRes as any)?.data || []
dataSources.value = (sRes as any)?.data || []
} catch (e) {
// ignore
}
}
onMounted(() => {
fetchData()
fetchMeta()
})
</script>
<style scoped lang="scss">
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.page-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
}
.search-bar {
padding: 16px;
margin-bottom: 16px;
}
.project-list {
.el-col {
margin-bottom: 16px;
}
}
.project-card {
padding: 20px;
background: #fff;
border-radius: 8px;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.project-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.project-name {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.project-desc {
font-size: 13px;
color: #909399;
margin-bottom: 16px;
min-height: 20px;
}
.project-stats {
display: flex;
gap: 16px;
margin-bottom: 16px;
padding: 12px 0;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
.stat-item {
text-align: center;
flex: 1;
.stat-num {
font-size: 18px;
font-weight: 700;
color: #303133;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
}
}
.project-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
}
</style>