263 lines
8.2 KiB
Vue
263 lines
8.2 KiB
Vue
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, onMounted, computed } from 'vue';
|
|||
|
|
import { Send, Clock, CheckCircle, XCircle, ArrowRight, Search } from 'lucide-vue-next';
|
|||
|
|
import { useRoute, useRouter } from 'vue-router';
|
|||
|
|
import { getApplications } from '@/api/tasks';
|
|||
|
|
|
|||
|
|
const route = useRoute();
|
|||
|
|
const router = useRouter();
|
|||
|
|
|
|||
|
|
const allApps = ref<any[]>([]);
|
|||
|
|
const isLoading = ref(true);
|
|||
|
|
const searchQuery = ref('');
|
|||
|
|
|
|||
|
|
const tabs = [
|
|||
|
|
{ key: 'ALL', label: '全部' },
|
|||
|
|
{ key: 'PENDING', label: '待审核' },
|
|||
|
|
{ key: 'APPROVED', label: '已通过' },
|
|||
|
|
{ key: 'REJECTED', label: '被拒绝' },
|
|||
|
|
{ key: 'WITHDRAWN', label: '已撤回' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const validKeys = tabs.map(t => t.key);
|
|||
|
|
const initTab = typeof route.query.tab === 'string' && validKeys.includes(route.query.tab)
|
|||
|
|
? route.query.tab : 'ALL';
|
|||
|
|
const activeTab = ref(initTab);
|
|||
|
|
|
|||
|
|
const fetchData = async () => {
|
|||
|
|
try {
|
|||
|
|
isLoading.value = true;
|
|||
|
|
const res: any = await getApplications();
|
|||
|
|
allApps.value = res.results || [];
|
|||
|
|
} catch (e) { console.error(e); }
|
|||
|
|
finally { isLoading.value = false; }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
onMounted(fetchData);
|
|||
|
|
|
|||
|
|
import { watch } from 'vue';
|
|||
|
|
watch(() => route.query.tab, (newTab) => {
|
|||
|
|
if (typeof newTab === 'string' && validKeys.includes(newTab)) {
|
|||
|
|
activeTab.value = newTab;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const filteredApps = computed(() => {
|
|||
|
|
return allApps.value.filter(app => {
|
|||
|
|
// DELIVERED/COMPLETED belong to project management, not applications
|
|||
|
|
if (['DELIVERED', 'COMPLETED'].includes(app.status)) return false;
|
|||
|
|
const task = app.task_detail || {};
|
|||
|
|
const matchSearch = !searchQuery.value ||
|
|||
|
|
task.title?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
|||
|
|
task.description?.toLowerCase().includes(searchQuery.value.toLowerCase());
|
|||
|
|
if (!matchSearch) return false;
|
|||
|
|
if (activeTab.value === 'ALL') return true;
|
|||
|
|
return app.status === activeTab.value;
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const tabCounts = computed(() => {
|
|||
|
|
const appOnly = allApps.value.filter(a => !['DELIVERED', 'COMPLETED'].includes(a.status));
|
|||
|
|
const counts: Record<string, number> = { ALL: appOnly.length };
|
|||
|
|
for (const app of appOnly) {
|
|||
|
|
counts[app.status] = (counts[app.status] || 0) + 1;
|
|||
|
|
}
|
|||
|
|
return counts;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const getStatusType = (status: string) => {
|
|||
|
|
switch (status) {
|
|||
|
|
case 'APPROVED': return 'success';
|
|||
|
|
case 'DELIVERED': return 'primary';
|
|||
|
|
case 'REJECTED': return 'danger';
|
|||
|
|
case 'PENDING': return 'warning';
|
|||
|
|
case 'COMPLETED': return 'info';
|
|||
|
|
case 'WITHDRAWN': return 'info';
|
|||
|
|
default: return 'info';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const translateStatus = (status: string) => {
|
|||
|
|
const map: Record<string, string> = {
|
|||
|
|
'PENDING': '待审核',
|
|||
|
|
'APPROVED': '已通过',
|
|||
|
|
'DELIVERED': '已交付',
|
|||
|
|
'REJECTED': '被拒绝',
|
|||
|
|
'WITHDRAWN': '已撤回',
|
|||
|
|
'COMPLETED': '已完成',
|
|||
|
|
};
|
|||
|
|
return map[status] || status;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getTimeAgo = (dateStr: string) => {
|
|||
|
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|||
|
|
const mins = Math.floor(diff / 60000);
|
|||
|
|
if (mins < 60) return `${mins}分钟前`;
|
|||
|
|
const hours = Math.floor(mins / 60);
|
|||
|
|
if (hours < 24) return `${hours}小时前`;
|
|||
|
|
const days = Math.floor(hours / 24);
|
|||
|
|
if (days < 30) return `${days}天前`;
|
|||
|
|
return new Date(dateStr).toLocaleDateString();
|
|||
|
|
};
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="space-y-5">
|
|||
|
|
<!-- Header -->
|
|||
|
|
<div class="flex items-center justify-between">
|
|||
|
|
<div class="flex items-center gap-3">
|
|||
|
|
<div class="w-10 h-10 rounded-xl bg-blue-50 text-blue-500 flex items-center justify-center">
|
|||
|
|
<Send class="w-5 h-5" />
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<h1 class="text-xl font-bold text-gray-800">我的申请</h1>
|
|||
|
|
<p class="text-xs text-gray-400">管理所有投递的任务申请</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<el-button type="primary" @click="router.push('/user/tasks/market')">
|
|||
|
|
去接单大厅
|
|||
|
|
<ArrowRight class="w-4 h-4 ml-1" />
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Tabs + Search -->
|
|||
|
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
|||
|
|
<div class="flex items-center gap-1 bg-gray-50 rounded-lg p-1">
|
|||
|
|
<button
|
|||
|
|
v-for="tab in tabs"
|
|||
|
|
:key="tab.key"
|
|||
|
|
@click="activeTab = tab.key"
|
|||
|
|
class="tab-btn"
|
|||
|
|
:class="{ active: activeTab === tab.key }"
|
|||
|
|
>
|
|||
|
|
{{ tab.label }}
|
|||
|
|
<span class="tab-count">{{ tabCounts[tab.key] || 0 }}</span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<el-input v-model="searchQuery" placeholder="搜索任务名称..." clearable style="width: 240px" size="default">
|
|||
|
|
<template #prefix><Search class="w-4 h-4 text-gray-400" /></template>
|
|||
|
|
</el-input>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- List -->
|
|||
|
|
<div v-loading="isLoading">
|
|||
|
|
<div v-if="filteredApps.length > 0" class="space-y-3">
|
|||
|
|
<div
|
|||
|
|
v-for="app in filteredApps"
|
|||
|
|
:key="app.id"
|
|||
|
|
class="app-card"
|
|||
|
|
@click="router.push(`/user/tasks/${app.task}`)"
|
|||
|
|
>
|
|||
|
|
<!-- Status indicator bar -->
|
|||
|
|
<div class="status-bar"
|
|||
|
|
:class="{
|
|||
|
|
'bg-yellow-400': app.status === 'PENDING',
|
|||
|
|
'bg-green-400': app.status === 'APPROVED',
|
|||
|
|
'bg-red-400': app.status === 'REJECTED',
|
|||
|
|
'bg-blue-400': app.status === 'DELIVERED',
|
|||
|
|
'bg-gray-300': ['WITHDRAWN', 'COMPLETED'].includes(app.status),
|
|||
|
|
}"
|
|||
|
|
></div>
|
|||
|
|
|
|||
|
|
<div class="flex-1 min-w-0 p-4">
|
|||
|
|
<!-- Top row -->
|
|||
|
|
<div class="flex items-center gap-2 mb-2">
|
|||
|
|
<el-tag :type="(getStatusType(app.status) as any)" size="small" effect="light" round>
|
|||
|
|
{{ translateStatus(app.status) }}
|
|||
|
|
</el-tag>
|
|||
|
|
<span class="text-xs text-gray-400 font-mono">#{{ app.id?.toString().substring(0, 8) }}</span>
|
|||
|
|
<span class="text-xs text-gray-400 ml-auto">{{ getTimeAgo(app.created_at) }}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Task title -->
|
|||
|
|
<h3 class="font-bold text-gray-800 truncate group-hover:text-blue-600 transition-colors">
|
|||
|
|
{{ app.task_detail?.title || '任务加载中...' }}
|
|||
|
|
</h3>
|
|||
|
|
|
|||
|
|
<!-- Bottom row -->
|
|||
|
|
<div class="flex items-center gap-4 mt-2 text-sm">
|
|||
|
|
<span class="text-orange-500 font-bold">
|
|||
|
|
¥{{ app.expected_price?.toLocaleString() || 0 }}
|
|||
|
|
</span>
|
|||
|
|
<span class="text-gray-400 flex items-center gap-1">
|
|||
|
|
<Clock class="w-3.5 h-3.5" />{{ app.expected_days || 0 }} 天
|
|||
|
|
</span>
|
|||
|
|
<span v-if="app.task_detail?.enterprise_name" class="text-gray-400 text-xs">
|
|||
|
|
{{ app.task_detail.enterprise_name }}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Rejection reason -->
|
|||
|
|
<div v-if="app.status === 'REJECTED' && app.reject_reason" class="mt-2 text-xs text-red-500 bg-red-50 rounded-lg px-3 py-2">
|
|||
|
|
拒绝原因:{{ app.reject_reason }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<ArrowRight class="w-4 h-4 text-gray-300 mr-4 flex-shrink-0 self-center" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<el-empty v-else-if="!isLoading" :description="activeTab === 'ALL' ? '暂无申请记录' : `暂无${tabs.find(t => t.key === activeTab)?.label}的申请`" class="bg-white rounded-xl py-16">
|
|||
|
|
<el-button type="primary" @click="router.push('/user/tasks/market')">去接单大厅</el-button>
|
|||
|
|
</el-empty>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.tab-btn {
|
|||
|
|
padding: 6px 14px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
color: #6b7280;
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.15s;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
.tab-btn:hover { color: #374151; }
|
|||
|
|
.tab-btn.active {
|
|||
|
|
background: #fff;
|
|||
|
|
color: #3b82f6;
|
|||
|
|
font-weight: 600;
|
|||
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
|||
|
|
}
|
|||
|
|
.tab-count {
|
|||
|
|
font-size: 11px;
|
|||
|
|
padding: 1px 6px;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
background: #f3f4f6;
|
|||
|
|
color: #9ca3af;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
.tab-btn.active .tab-count {
|
|||
|
|
background: #eff6ff;
|
|||
|
|
color: #3b82f6;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.app-card {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: stretch;
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
border: 1px solid #f0f0f0;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.2s;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
.app-card:hover {
|
|||
|
|
border-color: #bfdbfe;
|
|||
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.04);
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
.status-bar {
|
|||
|
|
width: 4px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
</style>
|