| | |
| | | ownerUserDept?: string; // 负责人的部门名称 |
| | | ownerUserDeptName?: string; // 负责人的部门名称 |
| | | lockStatus?: boolean; |
| | | dealStatus?: boolean; |
| | | autoPoolEnabled?: boolean; // 是否流入公海,关闭时取消公海可见度 |
| | | mobile: string; // 手机号 |
| | | telephone: string; // 电话 |
| | |
| | | createTime: Date; // 创建时间 |
| | | updateTime: Date; // 更新时间 |
| | | poolDay?: number; // 距离进入公海天数 |
| | | contractCount?: number; // 已签订合同数量(审批状态为审核通过),没有则为 0 |
| | | contractTotalPrice?: null | number; // 已签订合同金额合计(审核通过),没有则为 null |
| | | } |
| | | |
| | | /** 客户导入请求 */ |
| | |
| | | /** 客户放入公海 */ |
| | | export function putCustomerPool(id: number) { |
| | | return requestClient.put(`/crm/customer/put-pool?id=${id}`); |
| | | } |
| | | |
| | | /** 更新客户的成交状态 */ |
| | | export function updateCustomerDealStatus(id: number, dealStatus: boolean) { |
| | | return requestClient.put( |
| | | `/crm/customer/update-deal-status?id=${id}&dealStatus=${dealStatus}`, |
| | | ); |
| | | } |
| | | |
| | | /** 进入公海客户提醒的客户列表 */ |
| | |
| | | yesterdayOutput: number; // 昨日产量 |
| | | todayQualifiedQuantity: number; // 今日合格品数 |
| | | todayUnqualifiedQuantity: number; // 今日不良品数 |
| | | iqcQualifiedRate: null | number; // 来料检验合格率(%),无检验数据时为 null |
| | | ipqcQualifiedRate: null | number; // 过程检验合格率(%),无检验数据时为 null |
| | | oqcQualifiedRate: null | number; // 出货检验合格率(%),无检验数据时为 null |
| | | iqcCheckQuantity: number; // 来料检验的检验数量合计(合格率分母),无数据时为 0 |
| | | ipqcCheckQuantity: number; // 过程检验的检验数量合计(合格率分母),无数据时为 0 |
| | | oqcCheckQuantity: number; // 出货检验的检验数量合计(合格率分母),无数据时为 0 |
| | | machineryTotal: number; // 设备总数 |
| | | machineryProducing: number; // 生产中设备数 |
| | | machineryStop: number; // 停机设备数 |
| | |
| | | xData: string[], |
| | | series: Array<{ name: string; data: number[] }>, |
| | | colors: string[] = [C.cyan, C.blue, C.purple], |
| | | horizontal = false, |
| | | ): EChartsOption { |
| | | return { |
| | | grid: { bottom: 40, left: 48, right: 16, top: 16 }, |
| | | grid: { bottom: 40, left: horizontal ? 140 : 48, right: 16, top: 16 }, |
| | | legend: { ...darkLegend, bottom: 0 }, |
| | | tooltip: { ...darkTooltip, axisPointer: { type: 'shadow' }, trigger: 'axis' }, |
| | | xAxis: { ...darkAxis, data: xData, type: 'category' }, |
| | | yAxis: { ...darkAxis, type: 'value' }, |
| | | xAxis: horizontal ? { ...darkAxis, type: 'value' } : { ...darkAxis, data: xData, type: 'category' }, |
| | | yAxis: horizontal ? { |
| | | ...darkAxis, data: xData, type: 'category', inverse: true, |
| | | axisLabel: { ...darkAxis.axisLabel, width: 125, overflow: 'truncate', interval: 0 }, |
| | | } : { ...darkAxis, type: 'value' }, |
| | | series: series.map((s, i) => ({ |
| | | barGap: '30%', |
| | | barMaxWidth: 36, |
| | |
| | | 'purchase-sales': { |
| | | code: 'purchase_sales', |
| | | title: '采购 · 销售 · 售后', |
| | | subtitle: '供应链全链路经营决策看板', |
| | | subtitle: '客户经营 · 商机跟进 · 合同回款 · 采购售后', |
| | | gradient: 'from-indigo-600 via-blue-600 to-cyan-500', |
| | | icon: 'lucide:shopping-cart', |
| | | }, |
| | |
| | | const charts = ref<BiDashboardApi.ChartItem[]>([]); |
| | | const loading = ref(true); |
| | | const lastUpdateTime = ref(''); |
| | | let dataRefreshTimer: ReturnType<typeof setInterval> | undefined; |
| | | |
| | | async function loadData() { |
| | | async function loadData(background = false) { |
| | | if (!meta.value) return; |
| | | if (!background && dataRefreshTimer) clearInterval(dataRefreshTimer); |
| | | try { |
| | | loading.value = true; |
| | | if (!background) loading.value = true; |
| | | const data = await getDashboardData(meta.value.code); |
| | | charts.value = data.length > 0 ? data : getDefaultCharts(); |
| | | lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }); |
| | | } catch { |
| | | charts.value = getDefaultCharts(); |
| | | if (!background) charts.value = getDefaultCharts(); |
| | | } finally { |
| | | loading.value = false; |
| | | await nextTick(); |
| | | resizeAllCharts(); |
| | | if (!background) { |
| | | const intervals = charts.value.map((chart) => chart.refreshInterval).filter((interval) => interval > 0); |
| | | if (intervals.length) dataRefreshTimer = setInterval(() => { |
| | | if (!loading.value && document.visibilityState === 'visible') loadData(true); |
| | | }, Math.max(15, Math.min(...intervals)) * 1000); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // ======== 分类 ======== |
| | | const numberCards = computed(() => charts.value.filter((c) => c.chartType === 'number')); |
| | | const tableCharts = computed(() => charts.value.filter((c) => c.chartType === 'table')); |
| | | const echartsCharts = computed(() => charts.value.filter((c) => !['number', 'table'].includes(c.chartType))); |
| | | const displayCharts = computed(() => { |
| | | if (dashboardCode.value !== 'purchase-sales') return charts.value; |
| | | return [...charts.value].sort((a, b) => |
| | | Number(b.chartOptions?.dashboardSection === 'crm') - Number(a.chartOptions?.dashboardSection === 'crm'), |
| | | ); |
| | | }); |
| | | const numberCards = computed(() => displayCharts.value.filter((c) => c.chartType === 'number')); |
| | | const tableCharts = computed(() => displayCharts.value.filter((c) => c.chartType === 'table')); |
| | | const echartsCharts = computed(() => displayCharts.value.filter((c) => !['number', 'table'].includes(c.chartType))); |
| | | |
| | | // ======== 图表 Grid 列跨度 ======== |
| | | function isFullWidth(chart: BiDashboardApi.ChartItem): boolean { |
| | |
| | | const progress = Math.min((currentTime - startTime) / duration, 1); |
| | | const eased = 1 - (1 - progress) ** 3; |
| | | el.textContent = (end * eased).toFixed(decimals); |
| | | if (progress === 1) el.textContent = formatNumber(end); |
| | | if (progress < 1) requestAnimationFrame(update); |
| | | } |
| | | requestAnimationFrame(update); |
| | |
| | | function maybeAnimate(el: HTMLElement, cardId: number, value: number) { |
| | | if (animatedIds.has(cardId)) return; |
| | | animatedIds.add(cardId); |
| | | animateValue(el, value, 0); |
| | | animateValue(el, value, Number.isInteger(value) ? 0 : 2); |
| | | } |
| | | |
| | | watch(() => charts.value, () => { |
| | |
| | | return val; |
| | | } |
| | | |
| | | // 大屏表格「实时滚动」:数据复制一份,配合 CSS marquee 做无缝纵向滚动 |
| | | function duplicateRows(rows?: Record<string, unknown>[]): Record<string, unknown>[] { |
| | | return rows ? [...rows, ...rows] : []; |
| | | } |
| | | |
| | | // ======== 看板主题色 ======== |
| | | const dashboardAccentColors: Record<string, string> = { |
| | | 'purchase-sales': '#6366f1', |
| | |
| | | |
| | | // ======== KPI 图标/颜色 ======== |
| | | function getKpiIcon(name: string): string { |
| | | if (name.includes('客户')) return 'lucide:users'; |
| | | if (name.includes('商机')) return 'lucide:target'; |
| | | if (name.includes('回款')) return 'lucide:wallet'; |
| | | if (name.includes('报价')) return 'lucide:receipt-text'; |
| | | if (name.includes('采购')) return 'lucide:shopping-cart'; |
| | | if (name.includes('销售')) return 'lucide:trending-up'; |
| | | if (name.includes('售后')) return 'lucide:headphones'; |
| | |
| | | loadData(); |
| | | }); |
| | | onBeforeUnmount(() => { |
| | | if (dataRefreshTimer) clearInterval(dataRefreshTimer); |
| | | document.removeEventListener('fullscreenchange', onFullscreenChange); |
| | | resizeObserver?.disconnect(); |
| | | }); |
| | |
| | | <IconifyIcon :icon="getKpiIcon(card.name)" /> |
| | | </div> |
| | | <div class="kpi-body"> |
| | | <span class="kpi-label">{{ card.name }}</span> |
| | | <span class="kpi-label" :title="card.name">{{ card.name }}</span> |
| | | <span |
| | | class="kpi-value" |
| | | :ref="(el: unknown) => { |
| | |
| | | >{{ key }}</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | <tbody class="marquee-tbody"> |
| | | <tr |
| | | v-for="(row, ri) in chart.data" |
| | | v-for="(row, ri) in duplicateRows(chart.data)" |
| | | :key="ri" |
| | | :style="{ animationDelay: `${ri * 50}ms` }" |
| | | > |
| | |
| | | |
| | | .kpi-label { |
| | | font-size: 11px; color: var(--text-secondary); letter-spacing: 0.3px; |
| | | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| | | white-space: normal; overflow-wrap: anywhere; line-height: 1.4; |
| | | } |
| | | |
| | | .kpi-value { |
| | |
| | | } |
| | | |
| | | .table-wrap { |
| | | overflow-x: auto; border-radius: 8px; |
| | | overflow-x: auto; |
| | | overflow-y: hidden; |
| | | max-height: 320px; |
| | | border-radius: 8px; |
| | | background: rgba(4, 14, 36, 0.55); |
| | | backdrop-filter: blur(6px); |
| | | -webkit-backdrop-filter: blur(6px); |
| | |
| | | |
| | | .data-table { |
| | | width: 100%; border-collapse: collapse; font-size: 12px; |
| | | } |
| | | |
| | | .data-table thead { |
| | | position: sticky; |
| | | top: 0; |
| | | z-index: 2; |
| | | } |
| | | |
| | | .data-table thead th { |
| | |
| | | to { opacity: 1; transform: translateY(0); } |
| | | } |
| | | |
| | | /* 大屏表格「实时滚动」:tbody 复制一份后 translateY(-50%) 无缝循环 */ |
| | | .marquee-tbody { |
| | | animation: table-marquee 15s linear infinite; |
| | | } |
| | | .marquee-tbody:hover { |
| | | animation-play-state: paused; |
| | | } |
| | | @keyframes table-marquee { |
| | | from { transform: translateY(0); } |
| | | to { transform: translateY(-50%); } |
| | | } |
| | | |
| | | /* ======== 空状态 ======== */ |
| | | .empty-state { |
| | | position: relative; z-index: 1; |
| | |
| | | const valueKey = keys[1] || 'value'; |
| | | const labels = chart.data.map((d: Record<string, unknown>) => String(d[labelKey] || '')); |
| | | const values = chart.data.map((d: Record<string, unknown>) => Number(d[valueKey]) || 0); |
| | | const series = keys.slice(1).map((key) => ({ |
| | | name: keys.length > 2 ? key : chart.name, |
| | | data: chart.data!.map((row) => Number(row[key]) || 0), |
| | | })); |
| | | |
| | | switch (chart.chartType) { |
| | | case 'bar': |
| | | return getBarChartOptions(labels, [{ name: chart.name, data: values }]); |
| | | return getBarChartOptions(labels, series, undefined, chart.chartOptions?.horizontal === true); |
| | | case 'line': |
| | | return getAreaLineChartOptions(labels, [{ name: chart.name, data: values }]); |
| | | return getAreaLineChartOptions(labels, series); |
| | | case 'pie': |
| | | return getDonutPieChartOptions(labels.map((l, i) => ({ name: l, value: values[i] || 0 }))); |
| | | case 'gauge': { |
| | |
| | | <div v-if="chart.error" class="flex h-[240px] items-center justify-center"> |
| | | <Empty :description="chart.error" /> |
| | | </div> |
| | | <div v-else-if="!chart.data?.length" class="flex h-[240px] items-center justify-center"> |
| | | <Empty description="暂无业务数据" /> |
| | | </div> |
| | | <EchartsUI |
| | | v-else |
| | | ref="chartRef" |
| | |
| | | }, |
| | | }, |
| | | { |
| | | field: 'dealStatus', |
| | | title: '成交状态', |
| | | minWidth: 120, |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING }, |
| | | }, |
| | | field: 'contractCount', |
| | | title: '签订合同', |
| | | minWidth: 170, |
| | | slots: { default: 'contracts' }, |
| | | }, |
| | | { |
| | | field: 'contactLastTime', |
| | |
| | | render: (val) => (val === false ? '否' : '是'), |
| | | }, |
| | | { |
| | | field: 'dealStatus', |
| | | label: '成交状态', |
| | | render: (val) => (val ? '已成交' : '未成交'), |
| | | }, |
| | | { |
| | | field: 'ownerUserName', |
| | | label: '负责人', |
| | | }, |
| | |
| | | lockCustomer, |
| | | putCustomerPool, |
| | | receiveCustomer, |
| | | updateCustomerDealStatus, |
| | | } from '#/api/crm/customer'; |
| | | import { getOperateLogPage } from '#/api/crm/operateLog'; |
| | | import { BizTypeEnum } from '#/api/crm/permission'; |
| | |
| | | // return true; |
| | | // } |
| | | |
| | | /** 更新成交状态操作 */ |
| | | async function handleUpdateDealStatus(): Promise<boolean | undefined> { |
| | | const dealStatus = !customer.value.dealStatus; |
| | | try { |
| | | await confirm( |
| | | `确定更新成交状态为【${dealStatus ? '已成交' : '未成交'}】吗?`, |
| | | ); |
| | | } catch { |
| | | return false; |
| | | } |
| | | // 更新成交状态 |
| | | await updateCustomerDealStatus(customerId.value, dealStatus); |
| | | // 提示并返回成功 |
| | | message.success('更新成交状态成功'); |
| | | return true; |
| | | } |
| | | |
| | | /** 加载数据 */ |
| | | onMounted(() => { |
| | | customerId.value = Number(route.params.id); |
| | |
| | | type: 'primary', |
| | | ifShow: permissionListRef?.validateOwnerUser, |
| | | onClick: handleTransfer, |
| | | }, |
| | | { |
| | | label: '更改成交状态', |
| | | type: 'default', |
| | | ifShow: permissionListRef?.validateWrite, |
| | | onClick: handleUpdateDealStatus, |
| | | }, |
| | | { |
| | | label: '锁定', |
| | |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { Page, useVbenModal } from '@vben/common-ui'; |
| | | import { downloadFileFromBlobPart } from '@vben/utils'; |
| | | import { downloadFileFromBlobPart, erpNumberFormatter } from '@vben/utils'; |
| | | |
| | | import { Button, message, Tabs } from 'ant-design-vue'; |
| | | |
| | |
| | | {{ row.name }} |
| | | </Button> |
| | | </template> |
| | | <template #contracts="{ row }"> |
| | | <template v-if="row.contractCount"> |
| | | {{ row.contractCount }} 份 · ¥{{ erpNumberFormatter(row.contractTotalPrice, 2) }} |
| | | </template> |
| | | <template v-else>—</template> |
| | | </template> |
| | | <template #actions="{ row }"> |
| | | <TableAction |
| | | :actions="[ |
| | |
| | | minWidth: 200, |
| | | }, |
| | | { |
| | | title: '成交状态', |
| | | field: 'dealStatus', |
| | | minWidth: 80, |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING }, |
| | | }, |
| | | }, |
| | | { |
| | | title: '最后跟进时间', |
| | | field: 'contactLastTime', |
| | | minWidth: 180, |
| | |
| | | navigate: [name: string]; |
| | | }>(); |
| | | |
| | | const qualityRate = computed(() => { |
| | | const total = |
| | | props.summary.todayQualifiedQuantity + |
| | | props.summary.todayUnqualifiedQuantity; |
| | | return total === 0 ? 0 : (props.summary.todayQualifiedQuantity / total) * 100; |
| | | }); |
| | | /** |
| | | * 质量合格率:来料 / 过程 / 出货三个检验环节,各自一张卡片 |
| | | * |
| | | * 由后端按「合格数量 / 检验数量」计算(不过滤单据状态,无时间限制), |
| | | * 该环节没有检验数据时后端返回 null。 |
| | | */ |
| | | const qualityRates = computed(() => [ |
| | | { |
| | | key: 'quality-iqc', |
| | | label: '来料合格率', |
| | | rate: props.summary.iqcQualifiedRate, |
| | | checkQuantity: props.summary.iqcCheckQuantity, |
| | | routeName: 'MesQcIqc', |
| | | icon: 'lucide:package-check', |
| | | iconClass: 'bg-gradient-to-br from-orange-500 to-amber-400 shadow-lg shadow-orange-500/25', |
| | | textClass: 'text-orange-600 dark:text-orange-400', |
| | | washClass: 'bg-gradient-to-br from-orange-500/5 to-amber-500/5', |
| | | glowClass: 'bg-orange-400/10', |
| | | }, |
| | | { |
| | | key: 'quality-ipqc', |
| | | label: '过程合格率', |
| | | rate: props.summary.ipqcQualifiedRate, |
| | | checkQuantity: props.summary.ipqcCheckQuantity, |
| | | routeName: 'MesQcIpqc', |
| | | icon: 'lucide:workflow', |
| | | iconClass: 'bg-gradient-to-br from-cyan-500 to-teal-400 shadow-lg shadow-cyan-500/25', |
| | | textClass: 'text-cyan-600 dark:text-cyan-400', |
| | | washClass: 'bg-gradient-to-br from-cyan-500/5 to-teal-500/5', |
| | | glowClass: 'bg-cyan-400/10', |
| | | }, |
| | | { |
| | | key: 'quality-oqc', |
| | | label: '出货合格率', |
| | | rate: props.summary.oqcQualifiedRate, |
| | | checkQuantity: props.summary.oqcCheckQuantity, |
| | | routeName: 'MesQcOqc', |
| | | icon: 'lucide:truck', |
| | | iconClass: 'bg-gradient-to-br from-pink-500 to-rose-400 shadow-lg shadow-pink-500/25', |
| | | textClass: 'text-pink-600 dark:text-pink-400', |
| | | washClass: 'bg-gradient-to-br from-pink-500/5 to-rose-500/5', |
| | | glowClass: 'bg-pink-400/10', |
| | | }, |
| | | ]); |
| | | |
| | | /** 合格率展示文案(一位小数,与原「质量合格率」卡片一致),无数据时用「—」而不是 0% */ |
| | | function formatRate(rate: null | number | undefined) { |
| | | return rate === null || rate === undefined ? '—' : rate.toFixed(1); |
| | | } |
| | | |
| | | /** 无检验数据时不渲染百分号,避免出现「—%」 */ |
| | | function hasRate(rate: null | number | undefined) { |
| | | return rate !== null && rate !== undefined; |
| | | } |
| | | |
| | | const equipmentRate = computed(() => { |
| | | if (props.summary.machineryTotal === 0) return 0; |
| | |
| | | <template> |
| | | <Row :gutter="16"> |
| | | <!-- 生产订单 --> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4"> |
| | | <div |
| | | class="glass-tilt group overflow-hidden rounded-2xl border-0" |
| | | class="glass-tilt group h-full overflow-hidden rounded-2xl border-0" |
| | | :class="{ 'cursor-pointer': canNavigate('MesProWorkOrder') }" |
| | | :style="tiltStyle('order')" |
| | | @mousemove="(e: MouseEvent) => onMouseMove(e, 'order')" |
| | |
| | | </Col> |
| | | |
| | | <!-- 今日产量 --> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4"> |
| | | <div |
| | | class="glass-tilt group overflow-hidden rounded-2xl border-0" |
| | | class="glass-tilt group h-full overflow-hidden rounded-2xl border-0" |
| | | :class="{ 'cursor-pointer': canNavigate('MesProFeedback') }" |
| | | :style="tiltStyle('output')" |
| | | @mousemove="(e: MouseEvent) => onMouseMove(e, 'output')" |
| | |
| | | </div> |
| | | </Col> |
| | | |
| | | <!-- 质量合格率 --> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <!-- 质量合格率:来料 / 过程 / 出货,各一张卡片 --> |
| | | <Col |
| | | v-for="item in qualityRates" |
| | | :key="item.key" |
| | | :lg="6" |
| | | :md="12" |
| | | :sm="12" |
| | | :xl="4" |
| | | :xs="24" |
| | | class="mb-4" |
| | | > |
| | | <div |
| | | class="glass-tilt group overflow-hidden rounded-2xl border-0" |
| | | :class="{ 'cursor-pointer': canNavigate('MesQcRecord') }" |
| | | :style="tiltStyle('quality')" |
| | | @mousemove="(e: MouseEvent) => onMouseMove(e, 'quality')" |
| | | @mouseleave="onMouseLeave('quality')" |
| | | @click="canNavigate('MesQcRecord') && emit('navigate', 'MesQcRecord')" |
| | | class="glass-tilt group h-full overflow-hidden rounded-2xl border-0" |
| | | :class="{ 'cursor-pointer': canNavigate(item.routeName) }" |
| | | :style="tiltStyle(item.key)" |
| | | @mousemove="(e: MouseEvent) => onMouseMove(e, item.key)" |
| | | @mouseleave="onMouseLeave(item.key)" |
| | | @click="canNavigate(item.routeName) && emit('navigate', item.routeName)" |
| | | > |
| | | <div class="relative z-10 flex items-center gap-4 p-5"> |
| | | <div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-amber-400 shadow-lg shadow-orange-500/25 transition-transform duration-300 group-hover:scale-110"> |
| | | <IconifyIcon class="size-6 text-white" icon="lucide:shield-check" /> |
| | | <div |
| | | class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110" |
| | | :class="item.iconClass" |
| | | > |
| | | <IconifyIcon class="size-6 text-white" :icon="item.icon" /> |
| | | </div> |
| | | <div class="min-w-0 flex-1"> |
| | | <div class="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">质量合格率</div> |
| | | <div class="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">{{ item.label }}</div> |
| | | <div class="flex items-baseline gap-1"> |
| | | <CountTo |
| | | class="text-2xl font-bold leading-tight" |
| | | :class="qualityRate >= 95 ? 'text-emerald-600 dark:text-emerald-400' : qualityRate >= 80 ? 'text-orange-600 dark:text-orange-400' : 'text-red-600 dark:text-red-400'" |
| | | :decimals="1" :end-val="qualityRate" :duration="1200" |
| | | /> |
| | | <span class="text-xs text-gray-400">%</span> |
| | | <span class="text-2xl font-bold leading-tight" :class="item.textClass"> |
| | | {{ formatRate(item.rate) }} |
| | | </span> |
| | | <span v-if="hasRate(item.rate)" class="text-xs text-gray-400">%</span> |
| | | </div> |
| | | <div class="mt-1 text-xs text-gray-400"> |
| | | 合格 {{ summary.todayQualifiedQuantity }} | 不良 {{ summary.todayUnqualifiedQuantity }} |
| | | <div class="mt-1 flex items-center gap-1 text-xs text-gray-400"> |
| | | <span>检验 {{ item.checkQuantity }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="absolute inset-0 -z-0 bg-gradient-to-br from-orange-500/5 to-amber-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100" /> |
| | | <div class="absolute -right-6 -top-6 size-24 rounded-full bg-orange-400/10 blur-xl transition-all duration-300 group-hover:scale-150" /> |
| | | <div |
| | | class="absolute inset-0 -z-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" |
| | | :class="item.washClass" |
| | | /> |
| | | <div |
| | | class="absolute -right-6 -top-6 size-24 rounded-full blur-xl transition-all duration-300 group-hover:scale-150" |
| | | :class="item.glowClass" |
| | | /> |
| | | </div> |
| | | </Col> |
| | | |
| | | <!-- 设备运行率 --> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4"> |
| | | <div |
| | | class="glass-tilt group overflow-hidden rounded-2xl border-0" |
| | | class="glass-tilt group h-full overflow-hidden rounded-2xl border-0" |
| | | :class="{ 'cursor-pointer': canNavigate('MesDvMachinery') }" |
| | | :style="tiltStyle('equip')" |
| | | @mousemove="(e: MouseEvent) => onMouseMove(e, 'equip')" |
| | |
| | | navigate: [name: string]; |
| | | }>(); |
| | | |
| | | /** 是否有质量数据(合格品 + 不良品 > 0) */ |
| | | const hasQualityData = computed( |
| | | () => |
| | | props.summary.todayQualifiedQuantity + |
| | | props.summary.todayUnqualifiedQuantity > |
| | | 0, |
| | | ); |
| | | /** |
| | | * 质量合格率:来料 / 过程 / 出货三个检验环节,各自一张卡片 |
| | | * |
| | | * 由后端按「合格数量 / 检验数量」计算(不过滤单据状态,无时间限制), |
| | | * 该环节没有检验数据时后端返回 null。 |
| | | */ |
| | | const qualityRates = computed(() => [ |
| | | { |
| | | label: '来料合格率', |
| | | rate: props.summary.iqcQualifiedRate, |
| | | checkQuantity: props.summary.iqcCheckQuantity, |
| | | routeName: 'MesQcIqc', |
| | | icon: 'lucide:package-check', |
| | | color: '#e6a23c', |
| | | colorLight: '#ebb563', |
| | | }, |
| | | { |
| | | label: '过程合格率', |
| | | rate: props.summary.ipqcQualifiedRate, |
| | | checkQuantity: props.summary.ipqcCheckQuantity, |
| | | routeName: 'MesQcIpqc', |
| | | icon: 'lucide:workflow', |
| | | color: '#13c2c2', |
| | | colorLight: '#5cdbd3', |
| | | }, |
| | | { |
| | | label: '出货合格率', |
| | | rate: props.summary.oqcQualifiedRate, |
| | | checkQuantity: props.summary.oqcCheckQuantity, |
| | | routeName: 'MesQcOqc', |
| | | icon: 'lucide:truck', |
| | | color: '#eb2f96', |
| | | colorLight: '#f759ab', |
| | | }, |
| | | ]); |
| | | |
| | | /** 质量合格率 = 合格品 / (合格品 + 不良品) * 100,无数据时为 0 */ |
| | | const qualityRate = computed(() => { |
| | | const total = |
| | | props.summary.todayQualifiedQuantity + |
| | | props.summary.todayUnqualifiedQuantity; |
| | | if (total === 0) { |
| | | return 0; |
| | | } |
| | | return (props.summary.todayQualifiedQuantity / total) * 100; |
| | | }); |
| | | /** 合格率展示文案(一位小数,与原「质量合格率」卡片一致),无数据时用「—」而不是 0% */ |
| | | function formatRate(rate: null | number | undefined) { |
| | | return rate === null || rate === undefined ? '—' : rate.toFixed(1); |
| | | } |
| | | |
| | | /** 无检验数据时不渲染百分号,避免出现「—%」 */ |
| | | function hasRate(rate: null | number | undefined) { |
| | | return rate !== null && rate !== undefined; |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <Row :gutter="16"> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4"> |
| | | <Card |
| | | class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg" |
| | | class="kpi-card h-full cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg" |
| | | @click="emit('navigate', 'MesProWorkOrder')" |
| | | > |
| | | <div class="flex items-center gap-4"> |
| | |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4"> |
| | | <Card |
| | | class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg" |
| | | class="kpi-card h-full cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg" |
| | | @click="emit('navigate', 'MesProFeedback')" |
| | | > |
| | | <div class="flex items-center gap-4"> |
| | |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <Col |
| | | v-for="item in qualityRates" |
| | | :key="item.label" |
| | | :lg="6" |
| | | :md="12" |
| | | :sm="12" |
| | | :xl="4" |
| | | :xs="24" |
| | | class="mb-4" |
| | | > |
| | | <Card |
| | | class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg" |
| | | @click="emit('navigate', 'MesProFeedback')" |
| | | class="kpi-card h-full cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg" |
| | | @click="emit('navigate', item.routeName)" |
| | | > |
| | | <div class="flex items-center gap-4"> |
| | | <div |
| | | class="flex size-14 flex-shrink-0 items-center justify-center rounded-xl text-white" |
| | | style="background: linear-gradient(135deg, #e6a23c, #ebb563)" |
| | | :style="{ background: `linear-gradient(135deg, ${item.color}, ${item.colorLight})` }" |
| | | > |
| | | <IconifyIcon class="size-7" icon="lucide:circle-check" /> |
| | | <IconifyIcon class="size-7" :icon="item.icon" /> |
| | | </div> |
| | | <div> |
| | | <div class="text-muted-foreground mb-1 text-sm">质量合格率</div> |
| | | <div class="text-muted-foreground mb-1 text-sm">{{ item.label }}</div> |
| | | <div class="flex items-baseline gap-1"> |
| | | <CountTo |
| | | class="text-2xl font-bold leading-tight text-[#e6a23c]" |
| | | :decimals="1" |
| | | :end-val="qualityRate" |
| | | :duration="1500" |
| | | /> |
| | | <span class="text-muted-foreground text-xs">%</span> |
| | | <span |
| | | class="text-2xl font-bold leading-tight" |
| | | :style="{ color: item.color }" |
| | | > |
| | | {{ formatRate(item.rate) }} |
| | | </span> |
| | | <span |
| | | v-if="hasRate(item.rate)" |
| | | class="text-muted-foreground text-xs" |
| | | > |
| | | % |
| | | </span> |
| | | </div> |
| | | <div class="text-muted-foreground mt-1 text-xs"> |
| | | <template v-if="hasQualityData"> |
| | | <span>合格 {{ summary.todayQualifiedQuantity }}</span> |
| | | <Divider type="vertical" /> |
| | | <span>不良 {{ summary.todayUnqualifiedQuantity }}</span> |
| | | </template> |
| | | <span v-else>暂无数据</span> |
| | | <span>检验 {{ item.checkQuantity }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4"> |
| | | <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4"> |
| | | <Card |
| | | class="kpi-card cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg" |
| | | class="kpi-card h-full cursor-pointer transition-all hover:-translate-y-1 hover:shadow-lg" |
| | | @click="emit('navigate', 'MesDvMachinery')" |
| | | > |
| | | <div class="flex items-center gap-4"> |
| | |
| | | |
| | | import { markRaw } from 'vue'; |
| | | |
| | | import { DICT_TYPE, MesProWorkOrderStatusEnum } from '@vben/constants'; |
| | | import { DICT_TYPE } from '@vben/constants'; |
| | | import { getDictOptions } from '@vben/hooks'; |
| | | |
| | | import { getSimpleUserList } from '#/api/system/user'; |
| | |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | placeholder: '请选择订单(可选)', |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | }, |
| | | } |
| | | : { |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | } from '@vben/constants'; |
| | | |
| | | import { Button } from 'ant-design-vue'; |
| | |
| | | componentProps: { |
| | | disabled: headerReadonly, |
| | | placeholder: '请选择生产订单', |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | }, |
| | | rules: 'selectRequired', |
| | | }, |
| | |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProTaskStatusEnum, |
| | | MesProWorkOrderStatusEnum, |
| | | } from '@vben/constants'; |
| | | import { getDictOptions } from '@vben/hooks'; |
| | | import { formatDate } from '@vben/utils'; |
| | |
| | | componentProps: { |
| | | disabled: isHeaderReadonly, |
| | | placeholder: '请选择订单', |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | // 订单变更:清空任务及任务带出的产品信息、数量区域控制位 |
| | | onChange: async () => { |
| | | await formApi?.setValues({ |
| | |
| | | |
| | | import { useDetailSchema } from '../data'; |
| | | import ProcessTraceList from './process-trace-list.vue'; |
| | | import PurchaseSourceList from './purchase-source-list.vue'; |
| | | import SalesTargetList from './sales-target-list.vue'; |
| | | import TraceList from './trace-list.vue'; |
| | | |
| | | const detailData = ref<MesWmBatchApi.Batch>(); // 当前批次详情 |
| | | const subTabsName = ref< |
| | | | 'backward' |
| | | | 'forward' |
| | | | 'purchase' |
| | | | 'sales' |
| | | | 'process' |
| | | >('forward'); // 当前激活的追溯页签 |
| | | >('backward'); // 当前激活的追溯页签 |
| | | |
| | | const [Descriptions] = useDescription({ |
| | | bordered: true, |
| | |
| | | return; |
| | | } |
| | | detailData.value = modalApi.getData<MesWmBatchApi.Batch>(); |
| | | subTabsName.value = 'forward'; |
| | | subTabsName.value = 'backward'; |
| | | }, |
| | | }); |
| | | </script> |
| | |
| | | class="mx-4 mt-4" |
| | | type="card" |
| | | > |
| | | <Tabs.TabPane key="forward"> |
| | | <template #tab> |
| | | <Tooltip title="顺着生产流往下游查:该批次被哪些工单消耗后,产出成了哪些成品批次"> |
| | | <span>向前追溯</span> |
| | | </Tooltip> |
| | | </template> |
| | | <TraceList :batch-code="detailData.code" direction="forward" /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="backward"> |
| | | <template #tab> |
| | | <Tooltip title="逆着生产流往上游查:生产该批次时,消耗了哪些原料批次"> |
| | |
| | | </Tooltip> |
| | | </template> |
| | | <TraceList :batch-code="detailData.code" direction="backward" /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="purchase"> |
| | | <template #tab> |
| | | <Tooltip title="原料侧来源:该批次对应的采购入库单与 IQC 来料检验记录"> |
| | | <span>采购来源</span> |
| | | </Tooltip> |
| | | </template> |
| | | <PurchaseSourceList :batch-code="detailData.code" /> |
| | | </Tabs.TabPane> |
| | | <Tabs.TabPane key="sales"> |
| | | <template #tab> |
| | |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProTaskStatusEnum, |
| | | MesProWorkOrderStatusEnum, |
| | | } from '@vben/constants'; |
| | | import { getDictOptions } from '@vben/hooks'; |
| | | |
| | |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | placeholder: '请选择生产订单', |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | // 订单变更时清空生产任务 |
| | | onChange: () => formApi?.setFieldValue('taskId', undefined), |
| | | }, |
| | |
| | | componentProps: (values) => ({ |
| | | disabled: !!values.sourceDocId, |
| | | placeholder: '请选择生产订单', |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | onChange: () => formApi?.setFieldValue('taskId', undefined), |
| | | }), |
| | | }, |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | MesWmProductIssueStatusEnum, |
| | | } from '@vben/constants'; |
| | | |
| | |
| | | label: '生产订单', |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | }, |
| | | }, |
| | | { |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | MesWmProductIssueStatusEnum, |
| | | } from '@vben/constants'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | |
| | | label: '生产订单', |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | disabled: true, |
| | | }, |
| | | }, |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | MesWmReturnIssueStatusEnum, |
| | | } from '@vben/constants'; |
| | | import { getDictOptions } from '@vben/hooks'; |
| | |
| | | label: '生产订单', |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | }, |
| | | }, |
| | | { |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | MesWmReturnIssueStatusEnum, |
| | | } from '@vben/constants'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | |
| | | label: '生产订单', |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | disabled: true, |
| | | }, |
| | | }, |
| | |
| | | BarcodeBizTypeEnum, |
| | | CommonStatusEnum, |
| | | DICT_TYPE, |
| | | MesProWorkOrderStatusEnum, |
| | | } from '@vben/constants'; |
| | | import { getDictOptions } from '@vben/hooks'; |
| | | import { formatDate } from '@vben/utils'; |
| | |
| | | label: '订单', |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | onChange: (item: any) => syncBizDetail(formApi, item), |
| | | }, |
| | | dependencies: { |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | MesProWorkOrderTypeEnum, |
| | | } from '@vben/constants'; |
| | | |
| | |
| | | onChange: async (workOrder?: MesProWorkOrderApi.WorkOrder) => { |
| | | await formApi?.setFieldValue('vendorId', workOrder?.vendorId); |
| | | }, |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | type: MesProWorkOrderTypeEnum.OUTSOURCE, |
| | | }, |
| | | rules: 'selectRequired', |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | MesProWorkOrderTypeEnum, |
| | | } from '@vben/constants'; |
| | | import { getDictOptions } from '@vben/hooks'; |
| | |
| | | onChange: async (workOrder?: MesProWorkOrderApi.WorkOrder) => { |
| | | await formApi?.setFieldValue('vendorId', workOrder?.vendorId); |
| | | }, |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | type: MesProWorkOrderTypeEnum.OUTSOURCE, |
| | | }, |
| | | rules: 'selectRequired', |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | } from '@vben/constants'; |
| | | |
| | | import { Button } from 'ant-design-vue'; |
| | |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | placeholder: '请选择生产订单', |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | }, |
| | | rules: 'selectRequired', |
| | | }, |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | } from '@vben/constants'; |
| | | import { getDictOptions } from '@vben/hooks'; |
| | | |
| | |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | placeholder: '请选择生产订单', |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | // 订单变更:清空已选生产任务,避免任务与订单不一致 |
| | | onChange: async () => { |
| | | await formApi?.setFieldValue('taskId', undefined); |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | } from '@vben/constants'; |
| | | |
| | | import { Button } from 'ant-design-vue'; |
| | |
| | | label: '生产订单', |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | checkMethod: (row: any) => row.productionStatus !== 0, |
| | | }, |
| | | }, |
| | |
| | | import { |
| | | DICT_TYPE, |
| | | MesAutoCodeRuleCode, |
| | | MesProWorkOrderStatusEnum, |
| | | } from '@vben/constants'; |
| | | import { getDictOptions } from '@vben/hooks'; |
| | | |
| | |
| | | label: '生产订单', |
| | | component: markRaw(ProWorkOrderSelect), |
| | | componentProps: { |
| | | status: MesProWorkOrderStatusEnum.CONFIRMED, |
| | | }, |
| | | rules: 'selectRequired', |
| | | }, |
| | |
| | | proxy: { |
| | | "/admin-api": { |
| | | changeOrigin: true, |
| | | target: "http://localhost:48080", |
| | | target: "http://localhost:48081", |
| | | ws: true, |
| | | }, |
| | | }, |