first commit
This commit is contained in:
571
src/views/project/signed/index.vue
Normal file
571
src/views/project/signed/index.vue
Normal file
@@ -0,0 +1,571 @@
|
||||
<template>
|
||||
<div class="signed-project-page">
|
||||
<a-page-header title="已成交项目" sub-title="管理平台已签约和执行中的项目">
|
||||
<template #tags>
|
||||
<a-tag color="blue">进行中 {{ stats.inProgress }}</a-tag>
|
||||
<a-tag color="green">已完成 {{ stats.completed }}</a-tag>
|
||||
<a-tag color="red">争议处理 {{ stats.disputed }}</a-tag>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" class="stats-row">
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-total">
|
||||
<div class="stat-icon"><ProjectOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
<div class="stat-title">总项目数</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-progress">
|
||||
<div class="stat-icon"><SyncOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.inProgress }}</div>
|
||||
<div class="stat-title">进行中</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-completed">
|
||||
<div class="stat-icon"><CheckCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.completed }}</div>
|
||||
<div class="stat-title">已完成</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-disputed">
|
||||
<div class="stat-icon"><ExclamationCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.disputed }}</div>
|
||||
<div class="stat-title">争议处理</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-amount">
|
||||
<div class="stat-icon"><PayCircleOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">¥{{ formatAmount(stats.totalAmount) }}</div>
|
||||
<div class="stat-title">总金额</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="stat-card stat-paid">
|
||||
<div class="stat-icon"><WalletOutlined /></div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">¥{{ formatAmount(stats.paidAmount) }}</div>
|
||||
<div class="stat-title">已支付</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<a-card class="filter-card">
|
||||
<div class="search-bar">
|
||||
<a-form layout="inline" class="search-form">
|
||||
<a-form-item>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索项目名 / 合同号 / 人员"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-select
|
||||
v-model:value="filterStatus"
|
||||
placeholder="项目状态"
|
||||
allow-clear
|
||||
style="width: 130px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option v-for="(label, key) in ProjectProgressStatusMap" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
style="width: 240px"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="loading" @click="handleSearch">
|
||||
<SearchOutlined /> 查询
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button @click="handleReset">
|
||||
<ReloadOutlined /> 重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<a-card class="main-card" :loading="loading">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="projectList"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'project'">
|
||||
<div class="project-cell">
|
||||
<div class="project-name">{{ record.projectName }}</div>
|
||||
<div class="contract-no">{{ record.contract.contractNo }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'parties'">
|
||||
<div class="parties-cell">
|
||||
<div class="party">
|
||||
<a-avatar :src="record.publisher.avatar" :size="24" />
|
||||
<span>{{ record.publisher.nickname }}</span>
|
||||
<a-tag size="small" color="blue">甲方</a-tag>
|
||||
</div>
|
||||
<div class="party">
|
||||
<a-avatar :src="record.contractor.avatar" :size="24" />
|
||||
<span>{{ record.contractor.nickname }}</span>
|
||||
<a-tag size="small" color="green">乙方</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'amount'">
|
||||
<div class="amount-cell">
|
||||
<div class="total-amount">¥{{ record.contractAmount.toLocaleString() }}</div>
|
||||
<div class="paid-info">
|
||||
已付 ¥{{ record.paidAmount.toLocaleString() }}
|
||||
<span v-if="record.pendingAmount > 0" class="pending">
|
||||
· 待付 ¥{{ record.pendingAmount.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
<div class="progress-cell">
|
||||
<a-badge
|
||||
:status="ProjectProgressStatusBadgeMap[record.progressStatus as ProjectProgressStatus]"
|
||||
:text="ProjectProgressStatusMap[record.progressStatus as ProjectProgressStatus]"
|
||||
/>
|
||||
<a-progress :percent="record.progressPercent" :size="60" type="circle" :width="45" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'signedAt'">
|
||||
{{ formatDateTime(record.signedAt) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="showDetail(record as SignedProjectRecord)">
|
||||
<EyeOutlined /> 详情
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="detailVisible"
|
||||
:title="currentRecord ? currentRecord.projectName : ''"
|
||||
width="720"
|
||||
destroy-on-close
|
||||
>
|
||||
<template v-if="currentRecord">
|
||||
<!-- 项目基本信息 -->
|
||||
<a-descriptions :column="2" bordered size="small" title="项目信息">
|
||||
<a-descriptions-item label="项目名称" :span="2">
|
||||
{{ currentRecord.projectName }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="合同编号">
|
||||
{{ currentRecord.contract.contractNo }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="项目状态">
|
||||
<a-badge
|
||||
:status="ProjectProgressStatusBadgeMap[currentRecord.progressStatus]"
|
||||
:text="ProjectProgressStatusMap[currentRecord.progressStatus]"
|
||||
/>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="工作类型">
|
||||
{{ getWorkTypeLabel(currentRecord.workType) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="工作地点">
|
||||
{{ currentRecord.location }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 双方信息 -->
|
||||
<a-divider>签约双方</a-divider>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="12">
|
||||
<div class="party-card">
|
||||
<div class="party-header">
|
||||
<a-avatar :src="currentRecord.publisher.avatar" :size="48" />
|
||||
<div class="party-info">
|
||||
<div class="party-name">
|
||||
{{ currentRecord.publisher.nickname }}
|
||||
<CheckCircleFilled v-if="currentRecord.publisher.verified" class="verified" />
|
||||
</div>
|
||||
<div class="party-role">甲方(发布人)</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-descriptions :column="1" size="small">
|
||||
<a-descriptions-item label="公司">{{ currentRecord.publisher.company || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ currentRecord.publisher.email }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="party-card">
|
||||
<div class="party-header">
|
||||
<a-avatar :src="currentRecord.contractor.avatar" :size="48" />
|
||||
<div class="party-info">
|
||||
<div class="party-name">
|
||||
{{ currentRecord.contractor.nickname }}
|
||||
<CheckCircleFilled v-if="currentRecord.contractor.verified" class="verified" />
|
||||
</div>
|
||||
<div class="party-role">乙方(签约人)</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-descriptions :column="1" size="small">
|
||||
<a-descriptions-item label="职位">{{ currentRecord.contractor.position || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">{{ currentRecord.contractor.email }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 合同信息 -->
|
||||
<a-divider>合同信息</a-divider>
|
||||
<a-descriptions :column="2" bordered size="small">
|
||||
<a-descriptions-item label="合同金额">
|
||||
<span class="amount-highlight">¥{{ currentRecord.contractAmount.toLocaleString() }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="已支付">
|
||||
¥{{ currentRecord.paidAmount.toLocaleString() }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="签约时间">
|
||||
{{ formatDateTime(currentRecord.signedAt) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="合同周期">
|
||||
{{ formatDate(currentRecord.contract.startDate) }} - {{ formatDate(currentRecord.contract.endDate) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="合同附件" :span="2">
|
||||
<a v-if="currentRecord.contract.attachmentUrl" :href="currentRecord.contract.attachmentUrl" target="_blank">
|
||||
<FileOutlined /> {{ currentRecord.contract.attachmentName }}
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 项目进度 -->
|
||||
<a-divider>项目进度 ({{ currentRecord.progressPercent }}%)</a-divider>
|
||||
<a-progress :percent="currentRecord.progressPercent" :stroke-color="getProgressColor(currentRecord.progressPercent)" />
|
||||
|
||||
<a-timeline class="milestone-timeline">
|
||||
<a-timeline-item
|
||||
v-for="milestone in currentRecord.milestones"
|
||||
:key="milestone.id"
|
||||
:color="getMilestoneColor(milestone.status)"
|
||||
>
|
||||
<div class="milestone-item">
|
||||
<div class="milestone-header">
|
||||
<span class="milestone-title">{{ milestone.title }}</span>
|
||||
<a-tag :color="getMilestoneTagColor(milestone.status)" size="small">
|
||||
{{ getMilestoneStatusText(milestone.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="milestone-desc">{{ milestone.description }}</div>
|
||||
<div class="milestone-date">
|
||||
计划: {{ formatDate(milestone.plannedDate) }}
|
||||
<span v-if="milestone.actualDate"> · 实际: {{ formatDate(milestone.actualDate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import {
|
||||
ProjectOutlined,
|
||||
SyncOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
PayCircleOutlined,
|
||||
WalletOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
CheckCircleFilled,
|
||||
FileOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { SignedProjectRecord, ProjectProgressStatus } from '@/types'
|
||||
import { ProjectProgressStatusMap, ProjectProgressStatusBadgeMap } from '@/types'
|
||||
import { mockGetSignedProjectStats, mockGetSignedProjectList } from '@/mock'
|
||||
import { formatDateTime } from '@/utils/common'
|
||||
|
||||
const loading = ref(false)
|
||||
const projectList = ref<SignedProjectRecord[]>([])
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
inProgress: 0,
|
||||
completed: 0,
|
||||
disputed: 0,
|
||||
totalAmount: 0,
|
||||
paidAmount: 0
|
||||
})
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const filterStatus = ref<ProjectProgressStatus | undefined>()
|
||||
const dateRange = ref<[Dayjs, Dayjs] | undefined>()
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<SignedProjectRecord | null>(null)
|
||||
|
||||
const columns = [
|
||||
{ title: '项目', key: 'project', width: 200 },
|
||||
{ title: '签约双方', key: 'parties', width: 200 },
|
||||
{ title: '合同金额', key: 'amount', width: 180 },
|
||||
{ title: '项目进度', key: 'progress', width: 150 },
|
||||
{ title: '签约时间', key: 'signedAt', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 100 }
|
||||
]
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
if (amount >= 10000) {
|
||||
return (amount / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return amount.toLocaleString()
|
||||
}
|
||||
|
||||
function formatDate(str: string): string {
|
||||
return new Date(str).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function getWorkTypeLabel(workType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
remote: '远程',
|
||||
fulltime: '全职',
|
||||
parttime: '兼职',
|
||||
freelance: '自由职业'
|
||||
}
|
||||
return labels[workType] || workType
|
||||
}
|
||||
|
||||
function getProgressColor(percent: number): string {
|
||||
if (percent >= 80) return '#52c41a'
|
||||
if (percent >= 50) return '#1890ff'
|
||||
return '#faad14'
|
||||
}
|
||||
|
||||
function getMilestoneColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
completed: 'green',
|
||||
in_progress: 'blue',
|
||||
pending: 'gray',
|
||||
delayed: 'red'
|
||||
}
|
||||
return colors[status] || 'gray'
|
||||
}
|
||||
|
||||
function getMilestoneTagColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
completed: 'success',
|
||||
in_progress: 'processing',
|
||||
pending: 'default',
|
||||
delayed: 'error'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
function getMilestoneStatusText(status: string): string {
|
||||
const texts: Record<string, string> = {
|
||||
completed: '已完成',
|
||||
in_progress: '进行中',
|
||||
pending: '待开始',
|
||||
delayed: '已延期'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await mockGetSignedProjectStats()
|
||||
Object.assign(stats, data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mockGetSignedProjectList({
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchKeyword.value || undefined,
|
||||
progressStatus: filterStatus.value,
|
||||
startDate: dateRange.value?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: dateRange.value?.[1]?.format('YYYY-MM-DD')
|
||||
})
|
||||
projectList.value = res.list
|
||||
pagination.total = res.total
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchKeyword.value = ''
|
||||
filterStatus.value = undefined
|
||||
dateRange.value = undefined
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: { current?: number; pageSize?: number }) {
|
||||
pagination.current = pag.current || 1
|
||||
pagination.pageSize = pag.pageSize || 10
|
||||
loadData()
|
||||
}
|
||||
|
||||
function showDetail(record: SignedProjectRecord) {
|
||||
currentRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signed-project-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-total { background: linear-gradient(135deg, #667eea, #764ba2); }
|
||||
.stat-progress { background: linear-gradient(135deg, #4facfe, #00f2fe); }
|
||||
.stat-completed { background: linear-gradient(135deg, #11998e, #38ef7d); }
|
||||
.stat-disputed { background: linear-gradient(135deg, #ff416c, #ff4b2b); }
|
||||
.stat-amount { background: linear-gradient(135deg, #f093fb, #f5576c); }
|
||||
.stat-paid { background: linear-gradient(135deg, #fa709a, #fee140); }
|
||||
|
||||
.stat-icon { font-size: 28px; opacity: 0.9; }
|
||||
.stat-content { flex: 1; }
|
||||
.stat-value { font-size: 22px; font-weight: 700; line-height: 1.2; }
|
||||
.stat-title { font-size: 13px; opacity: 0.85; margin-top: 2px; }
|
||||
|
||||
.filter-card, .main-card { border-radius: 12px; }
|
||||
|
||||
.search-bar { padding: 8px; }
|
||||
.search-form { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.search-form :deep(.ant-form-item) { margin-bottom: 0; margin-right: 0; }
|
||||
|
||||
.project-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||
.project-name { font-weight: 600; color: #1a1a2e; }
|
||||
.contract-no { font-size: 12px; color: #8c8c8c; font-family: monospace; }
|
||||
|
||||
.parties-cell { display: flex; flex-direction: column; gap: 8px; }
|
||||
.party { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
|
||||
.amount-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||
.total-amount { font-size: 15px; font-weight: 600; color: #f5222d; }
|
||||
.paid-info { font-size: 12px; color: #52c41a; }
|
||||
.pending { color: #faad14; }
|
||||
|
||||
.progress-cell { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* 详情样式 */
|
||||
.party-card {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.party-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.party-info { flex: 1; }
|
||||
.party-name { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
||||
.party-role { font-size: 12px; color: #8c8c8c; margin-top: 2px; }
|
||||
.verified { color: #1890ff; font-size: 14px; }
|
||||
|
||||
.amount-highlight { font-size: 18px; font-weight: 700; color: #f5222d; }
|
||||
|
||||
.milestone-timeline { margin-top: 16px; }
|
||||
.milestone-item { display: flex; flex-direction: column; gap: 4px; }
|
||||
.milestone-header { display: flex; align-items: center; gap: 8px; }
|
||||
.milestone-title { font-weight: 500; }
|
||||
.milestone-desc { font-size: 13px; color: #666; }
|
||||
.milestone-date { font-size: 12px; color: #8c8c8c; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user