diff --git a/backend/apps/pisadmin/dashboard/views.py b/backend/apps/pisadmin/dashboard/views.py index 6f95d5e..d148498 100644 --- a/backend/apps/pisadmin/dashboard/views.py +++ b/backend/apps/pisadmin/dashboard/views.py @@ -26,13 +26,16 @@ class BuyerTaskSerializer(serializers.Serializer): inquiry_no = serializers.CharField() status = serializers.CharField() created_at = serializers.DateTimeField() + # 须声明,否则校验时会被丢弃,SerializerMethodField 无法读到库里的采购方式 + buying_method = serializers.IntegerField(required=False, allow_null=True) method = serializers.SerializerMethodField() quote_deadline = serializers.DateTimeField(allow_null=True, required=False) bid_start_time = serializers.DateTimeField(allow_null=True, required=False) bid_end_time = serializers.DateTimeField(allow_null=True, required=False) def get_method(self, obj): - if obj.get('buying_method') == 2: + bm = obj.get('buying_method') + if bm == 2: return '招标' return '询价' @@ -54,13 +57,15 @@ class SupplierQuoteSerializer(serializers.Serializer): unit = serializers.CharField() quantity = serializers.CharField(allow_blank=True, required=False) status = serializers.IntegerField() + buying_method = serializers.IntegerField(required=False, allow_null=True) method = serializers.SerializerMethodField() quote_deadline = serializers.DateTimeField(allow_null=True, required=False) bid_start_time = serializers.DateTimeField(allow_null=True, required=False) bid_end_time = serializers.DateTimeField(allow_null=True, required=False) def get_method(self, obj): - if obj.get('buying_method') == 2: + bm = obj.get('buying_method') + if bm == 2: return '招标' return '询价' @@ -139,10 +144,10 @@ class DashboardView(views.APIView): } def get_buyer_tasks(self, user): - """采购方待办任务""" + """采购方待办任务(仅询价状态为发布/报价中/报价结束/比议价中的单,见 Inquiry.BUYER_DASHBOARD_TASK_STATUSES)。""" inquiries = Inquiry.objects.filter( create_user=user.username, - status__in=[1, 2, 3] + status__in=Inquiry.BUYER_DASHBOARD_TASK_STATUSES, ).order_by('-create_time')[:10] return [ { @@ -164,12 +169,14 @@ class DashboardView(views.APIView): # 超级管理员看到所有供应商汇总数据 if user.is_superuser: total_quotes = QuotationMaster.objects.count() - pending_quotes = QuotationMaster.objects.filter(status__in=[1, 2]).count() + pending_quotes = QuotationMaster.objects.filter( + status__in=QuotationMaster.DASHBOARD_PENDING_STATUSES + ).count() won_quotes = QuotationMaster.objects.filter(is_awarded=1).count() else: total_quotes = QuotationMaster.objects.filter(supplier_code=user.username).count() pending_quotes = QuotationMaster.objects.filter( - supplier_code=user.username, status__in=[1, 2] + supplier_code=user.username, status__in=QuotationMaster.DASHBOARD_PENDING_STATUSES ).count() won_quotes = QuotationMaster.objects.filter( supplier_code=user.username, is_awarded=1 @@ -200,24 +207,53 @@ class DashboardView(views.APIView): 'overdue_quotes': overdue_quotes, } + def _supplier_quote_remaining_sort_key(self, q, inquiry, now): + """用于待报价清单排序:剩余时间由少到多(越早越靠前);无截止时间排最后。""" + bm = (inquiry.buying_method if inquiry is not None else None) or q.buying_method + if bm == 2: + start, end = q.bid_start_time, q.bid_end_time + if not start: + return None + if now < start: + return (start - now).total_seconds() + if end and now < end: + return (end - now).total_seconds() + return float('inf') + if q.quote_deadline: + return (q.quote_deadline - now).total_seconds() + return None + def get_supplier_pending_quotes(self, user): - """供应商待报价清单""" - # 超级管理员看到所有待报价,供应商只看自己的 - if user.is_superuser: - quotes = QuotationMaster.objects.filter(status__in=[1, 2]).order_by('-creattime')[:10] - else: - quotes = QuotationMaster.objects.filter( - supplier_code=user.username, - status__in=[1, 2] - ).order_by('-creattime')[:10] + """供应商待报价清单:仅未报价(待报价)/报价中;按剩余时间由少到多取前 10 条。""" + base = QuotationMaster.objects.filter(status__in=QuotationMaster.DASHBOARD_PENDING_STATUSES) + if not user.is_superuser: + base = base.filter(supplier_code=user.username) + quotes = list(base) + if not quotes: + return [] + + now = timezone.now() + inquiry_nos = {q.inquiry_no for q in quotes} + inquiries = {i.inquiry_no: i for i in Inquiry.objects.filter(inquiry_no__in=inquiry_nos)} + + def sort_key(q): + inq = inquiries.get(q.inquiry_no) + k = self._supplier_quote_remaining_sort_key(q, inq, now) + if k is None: + return float('inf') + return k + + quotes.sort(key=sort_key) + quotes = quotes[:10] + + from apps.pisadmin.miscprocurement.models import InquiryRfqItem + result = [] for q in quotes: - inquiry = Inquiry.objects.filter(inquiry_no=q.inquiry_no).first() - # 获取询价单中的数量 + inquiry = inquiries.get(q.inquiry_no) quantity = '' unit = '' if inquiry: - from apps.pisadmin.miscprocurement.models import InquiryRfqItem item = InquiryRfqItem.objects.filter(inquiry_no=q.inquiry_no).first() if item: quantity = item.qty @@ -229,7 +265,7 @@ class DashboardView(views.APIView): 'quantity': quantity, 'unit': unit, 'status': q.status, - 'buying_method': q.buying_method, + 'buying_method': (inquiry.buying_method if inquiry is not None else q.buying_method), 'quote_deadline': q.quote_deadline, 'bid_start_time': q.bid_start_time, 'bid_end_time': q.bid_end_time, @@ -473,4 +509,5 @@ class DashboardView(views.APIView): serializer = DashboardResponseSerializer(data=response_data) serializer.is_valid(raise_exception=True) - return SuccessResponse(data=serializer.validated_data, msg="获取成功") + # 须用 .data:validated_data 不含 SerializerMethodField(如 tasks[].method) + return SuccessResponse(data=serializer.data, msg="获取成功") diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index 47264bc..2c1a01c 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -195,6 +195,8 @@ class Inquiry(CoreModel): (9, "落标(结束)"), (0, "作废"), ) + # 采购端仪表盘「我的待办任务」:发布、报价中、报价结束(业务上常称「报价完成」)、比议价中 + BUYER_DASHBOARD_TASK_STATUSES = (3, 4, 5, 6) PAYMENT_METHOD_CHOICES = ( (1, "月结30天"), (2, "月结60天"), diff --git a/backend/apps/pissupplier/models.py b/backend/apps/pissupplier/models.py index 0795918..8b3ab02 100644 --- a/backend/apps/pissupplier/models.py +++ b/backend/apps/pissupplier/models.py @@ -19,6 +19,8 @@ class QuotationMaster(models.Model): (3, "已报价"), (4, "已过期"), ) + # 供应商仪表盘「待报价清单」仅包含:待报价(业务上常称「未报价」)、报价中 + DASHBOARD_PENDING_STATUSES = (1, 2) AWARD_STATUS_CHOICES = ( (0, "未中标"), diff --git a/web/src/stores/modules/dashboard.ts b/web/src/stores/modules/dashboard.ts index 8a597ed..bf0592c 100644 --- a/web/src/stores/modules/dashboard.ts +++ b/web/src/stores/modules/dashboard.ts @@ -15,6 +15,10 @@ interface BuyerDashboard { inquiry_no: string; status: string; created_at: string; + method?: string; + quote_deadline?: string | null; + bid_start_time?: string | null; + bid_end_time?: string | null; }>; messages: Array<{ id: number; @@ -23,7 +27,7 @@ interface BuyerDashboard { is_read: boolean; created_at: string; }>; - trend: Array<{ month: string; count: number }>; + trend: Array<{ month?: string; day?: number; count: number }>; } interface SupplierDashboard { @@ -37,10 +41,13 @@ interface SupplierDashboard { id: number; inquiry_no: string; item_name: string; - quantity: number; + quantity: string | number; unit: string; - deadline: string; status: number; + method?: string; + quote_deadline?: string | null; + bid_start_time?: string | null; + bid_end_time?: string | null; }>; messages: Array<{ id: number; @@ -49,7 +56,7 @@ interface SupplierDashboard { is_read: boolean; created_at: string; }>; - trend: Array<{ month: string; quotes: number; won: number }>; + trend: Array<{ month?: string; day?: number; quotes?: number; won?: number }>; } export const useDashboardStore = defineStore('dashboard', { diff --git a/web/src/views/pisadmin/dashboard/BuyerDashboard.vue b/web/src/views/pisadmin/dashboard/BuyerDashboard.vue index f742a8c..10f44a6 100644 --- a/web/src/views/pisadmin/dashboard/BuyerDashboard.vue +++ b/web/src/views/pisadmin/dashboard/BuyerDashboard.vue @@ -44,7 +44,7 @@ 询价单号 采购方式 - 物料名称 + 询价单名称 当前状态 截止时间 / 剩余时间 操作 @@ -61,10 +61,23 @@ {{ task.status }} -
- {{ getDeadlineText(task) }} - {{ getRemainingTimeText(task) }} -
+ + + + + + + + + + + +
{{ getDeadlinePrimaryLabel(task) }} + {{ getDeadlinePrimaryValue(task) }} +
+ 剩余: + {{ getDeadlineRemainingValue(task) }} +
@@ -196,12 +199,7 @@ const kpi = computed( conversion_rate: 0, } ); -const pendingQuotes = computed(() => { - const q = supplier.value?.pending_quotes || []; - console.log('[SupplierDashboard] supplier.value:', supplier.value); - console.log('[SupplierDashboard] pendingQuotes computed:', q); - return q; -}); +const pendingQuotes = computed(() => supplier.value?.pending_quotes || []); const trend = computed(() => supplier.value?.trend || []); // 获取消息列表 @@ -246,65 +244,122 @@ function getQuoteStatusClass(status: number) { } function getQuoteStatusText(status: number) { - if (status === 1) return '待报价'; + if (status === 1) return '未报价'; if (status === 2) return '报价中'; if (status === 3) return '已报价'; - return '待报价'; + return '未报价'; } -function getMethodClass(method: string) { +function getMethodClass(method: string | undefined) { if (method === '招标') return 'method-tender'; return 'method-inquiry'; } -function getMethodText(method: string) { +function getMethodText(method: string | undefined) { return method || '询价'; } -function getDeadlineClass(quote: any): string { - const deadline = quote.method === '招标' ? quote.bid_end_time : quote.quote_deadline; - console.log('[SupplierDashboard] getDeadlineClass quote:', quote.inquiry_no, 'method:', quote.method, 'deadline:', deadline); - if (!deadline) return ''; - const now = new Date(); - const deadlineDate = new Date(deadline); - const diffHours = (deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60); - console.log('[SupplierDashboard] diffHours:', diffHours); - if (diffHours < 0) return 'deadline-expired'; - if (diffHours < 24) return 'deadline-urgent'; - if (diffHours < 48) return 'deadline-warning'; - return ''; +function isTenderQuote(quote: any): boolean { + return quote?.method === '招标'; } -function getRemainingTimeText(quote: any): string { - const deadline = quote.method === '招标' ? quote.bid_end_time : quote.quote_deadline; - console.log('[SupplierDashboard] getRemainingTimeText quote:', quote.inquiry_no, 'deadline:', deadline); - if (!deadline) return ''; - const now = new Date(); - const deadlineDate = new Date(deadline); - const diffMs = deadlineDate.getTime() - now.getTime(); - console.log('[SupplierDashboard] diffMs:', diffMs); - if (diffMs <= 0) return '已到期'; - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - const days = Math.floor(diffHours / 24); - const hours = diffHours % 24; - if (days > 0) { - return `剩余 ${days}天${hours}小时`; - } - return `剩余 ${hours}小时`; +function formatDateTimeYMDHM(iso: string | Date | null | undefined): string { + if (!iso) return '-'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '-'; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const h = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${y}-${m}-${day} ${h}:${min}`; } -function formatDate(date: string) { - if (!date) return ''; - const d = new Date(date); - return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; +function formatTimeHM(iso: string | Date): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '-'; + const h = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${h}:${min}`; } -function formatDateRange(start: string, end: string) { - if (!start || !end) return '-'; +function formatTenderBidTimeRange( + start: string | Date | null | undefined, + end: string | Date | null | undefined +): string { + if (!start && !end) return '-'; + if (!start) return formatDateTimeYMDHM(end); + if (!end) return formatDateTimeYMDHM(start); const s = new Date(start); const e = new Date(end); - const formatShort = (d: Date) => `${d.getMonth() + 1}/${d.getDate()}`; - return `${formatShort(s)}-${formatShort(e)}`; + if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) return '-'; + const sameDay = + s.getFullYear() === e.getFullYear() && + s.getMonth() === e.getMonth() && + s.getDate() === e.getDate(); + if (sameDay) { + const y = s.getFullYear(); + const m = String(s.getMonth() + 1).padStart(2, '0'); + const day = String(s.getDate()).padStart(2, '0'); + return `${y}-${m}-${day} ${formatTimeHM(s)} 至 ${formatTimeHM(e)}`; + } + return `${formatDateTimeYMDHM(s)} 至 ${formatDateTimeYMDHM(e)}`; +} + +function formatDurationCn(diffMs: number): string { + if (diffMs <= 0) return '已到期'; + const totalHours = Math.floor(diffMs / (1000 * 60 * 60)); + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + if (days > 0) return `${days}天${hours}小时`; + return `${hours}小时`; +} + +function getInquiryHoursToQuoteDeadline(quote: any): number | null { + const q = quote?.quote_deadline; + if (!q) return null; + return (new Date(q).getTime() - Date.now()) / (1000 * 60 * 60); +} + +function getDeadlinePrimaryLabel(quote: any): string { + return isTenderQuote(quote) ? '投标时间:' : '报价截止时间:'; +} + +function getDeadlinePrimaryValue(quote: any): string { + if (isTenderQuote(quote)) { + return formatTenderBidTimeRange(quote?.bid_start_time, quote?.bid_end_time); + } + return formatDateTimeYMDHM(quote?.quote_deadline); +} + +function getDeadlineRemainingValue(quote: any): string { + if (isTenderQuote(quote)) { + const start = quote?.bid_start_time; + const end = quote?.bid_end_time; + if (!start) return '-'; + const now = Date.now(); + const startMs = new Date(start).getTime(); + const endMs = end ? new Date(end).getTime() : null; + if (now < startMs) return formatDurationCn(startMs - now); + if (endMs != null && now < endMs) return '投标进行中'; + if (endMs != null && now >= endMs) return '已结束'; + return '已开始'; + } + const q = quote?.quote_deadline; + if (!q) return '-'; + return formatDurationCn(new Date(q).getTime() - Date.now()); +} + +function getDeadlineValueClass(quote: any): string { + if (isTenderQuote(quote)) { + return 'deadline-tender-accent'; + } + const h = getInquiryHoursToQuoteDeadline(quote); + if (h == null || quote?.quote_deadline == null) return ''; + if (h < 0) return 'deadline-expired'; + if (h < 24) return 'deadline-urgent'; + if (h < 48) return 'deadline-warning'; + return 'deadline-accent-normal'; } function initChart() { @@ -395,8 +450,6 @@ function initChart() { } onMounted(() => { - console.log('[SupplierDashboard] onMounted - supplier store:', supplier.value); - console.log('[SupplierDashboard] onMounted - pendingQuotes:', pendingQuotes.value); initChart(); getMsg(); window.addEventListener('resize', () => chartRef.value && echarts.getInstanceByDom(chartRef.value)?.resize()); @@ -434,7 +487,7 @@ watch(trend, () => { --danger: #ef4444; --warning: #f59e0b; --success: #10b981; - --purple: #8b5cf6; + --gold: #d4af37; --border-color: #e5e7eb; font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif; background: var(--bg-body); @@ -467,12 +520,43 @@ watch(trend, () => { transform: translateY(-2px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); } + + &.green .kpi-icon { + color: var(--success); + } + &.orange .kpi-icon { + color: var(--warning); + } + &.gold .kpi-icon { + color: var(--gold); + } + &.blue .kpi-icon { + color: var(--primary-color); + } + + /* 底部说明与卡片主题一致(避免语义类与主题色错位) */ + &.green .kpi-trend.trend-up { + color: var(--success); + } + &.orange .kpi-trend { + color: var(--warning); + } + &.gold .kpi-trend { + color: var(--gold); + } + &.blue .kpi-trend.trend-flat { + color: var(--primary-color); + } } .kpi-icon { font-size: 28px; margin-bottom: 12px; - opacity: 0.8; + opacity: 0.9; + + i { + color: inherit; + } } .kpi-title { @@ -701,7 +785,49 @@ tr:last-child td { border: 1px solid #e9d5ff; } -/* 截止时间样式 */ +/* 截止时间 / 剩余时间(与采购端仪表盘一致) */ +.quote-deadline-cell { + margin-left: auto; + min-width: 220px; + max-width: 340px; + padding: 6px 10px; + background: #f9fafb; + border-radius: 6px; +} +.deadline-inner { + border-collapse: collapse; + width: 100%; + table-layout: auto; +} +.deadline-td-label { + vertical-align: top; + white-space: nowrap; + font-size: 13px; + color: var(--text-main); + padding: 0 8px 4px 0; + line-height: 1.45; +} +.deadline-td-value { + vertical-align: top; + font-size: 13px; + line-height: 1.45; + padding: 0 0 4px 0; +} +.deadline-td-gap { + padding: 0; + width: 0; +} +.deadline-td-remaining { + vertical-align: top; + font-size: 12px; + line-height: 1.45; + padding: 0; +} +.deadline-remaining-label { + color: var(--text-secondary); + font-weight: 400; + margin-right: 2px; +} .deadline-urgent { color: var(--danger) !important; font-weight: 700; @@ -712,42 +838,16 @@ tr:last-child td { } .deadline-expired { color: #9ca3af !important; + font-weight: 500; text-decoration: line-through; } - -.quote-countdown { - display: flex; - align-items: center; - gap: 8px; - margin-left: auto; - padding: 6px 12px; - background: #f9fafb; - border-radius: 6px; +.deadline-accent-normal { + color: #ea580c !important; + font-weight: 600; } - -.countdown-label { - font-size: 12px; - color: var(--text-secondary); -} - -.countdown-time { - font-size: 13px; - color: var(--text-main); - font-weight: 500; - - &.is-urgent { - color: var(--danger); - } -} - -.countdown-remaining { - font-size: 12px; - color: var(--text-secondary); - - &.is-urgent { - color: var(--danger); - font-weight: 500; - } +.deadline-tender-accent { + color: #ea580c !important; + font-weight: 600; } .quote-info-row {