Files
codePortAdmin/src/views/project/signed/index.vue
2025-12-28 22:09:44 +08:00

572 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>