This commit is contained in:
2026-07-30 10:30:50 +08:00
parent cf6d708566
commit 853c478657
85 changed files with 4711 additions and 1052 deletions
@@ -1,48 +1,45 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { addNotification } from '@/shared/notification'
import { useInventory } from '@/modules/inventory/composables/useInventory'
import { useInventoryStore } from '@/stores/inventory'
import InventorySidebar from './components/InventorySidebar.vue'
import DashboardTab from './components/DashboardTab.vue'
import ProductsTab from './components/ProductsTab.vue'
import MaterialsTab from './components/MaterialsTab.vue'
import InventoryTab from './components/InventoryTab.vue'
import PurchaseOrdersTab from './components/PurchaseOrdersTab.vue'
import SalesOrdersTab from './components/SalesOrdersTab.vue'
import SuppliersTab from './components/SuppliersTab.vue'
import CustomersTab from './components/CustomersTab.vue'
import FinanceTab from './components/FinanceTab.vue'
import MovementsTab from './components/MovementsTab.vue'
const store = useAppStore()
const store = useInventoryStore()
const appStore = useAppStore()
const router = useRouter()
const {
state,
activeMenu,
checkBackendHealth,
loadDashboard,
destroyPickers
} = useInventory()
const route = useRoute()
// Derive activeTab from route path (last segment)
const tabFromRoute = () => {
const segments = route.path.split('/')
return segments[segments.length - 1] || 'dashboard'
}
// Sync store.activeTab when route changes
watch(() => route.path, () => {
store.activeTab = tabFromRoute()
}, { immediate: true })
onMounted(() => {
if (!store.user) {
if (!appStore.user) {
router.push('/login')
return
}
checkBackendHealth().then(() => {
if (!state.backendDbReady) {
addNotification(state.backendDbMessage || '业务服务不可用', 'warning')
store.activeTab = tabFromRoute()
store.checkBackendHealth().then(() => {
if (!store.backendDbReady) {
addNotification(store.backendDbMessage || '业务服务不可用', 'warning')
return
}
loadDashboard()
store.loadDashboard()
})
})
onUnmounted(() => {
destroyPickers()
store.destroyPickers()
})
</script>
@@ -61,32 +58,23 @@ onUnmounted(() => {
<section class="inventory-content">
<div class="inventory-content-header">
<div class="inventory-breadcrumb">
<span>{{ activeMenu?.group?.title || '进销存' }}</span>
<span>{{ store.activeMenu?.group?.title || '进销存' }}</span>
<span class="sep">/</span>
<span class="current">{{ activeMenu?.item?.label || '' }}</span>
<span class="current">{{ store.activeMenu?.item?.label || '' }}</span>
</div>
</div>
<t-alert
v-if="!state.backendDbReady"
:title="state.backendDbMessage"
v-if="!store.backendDbReady"
:title="store.backendDbMessage"
theme="warning"
show-icon
:closable="false"
style="margin-bottom: 16px;"
/>
<t-loading :loading="state.loading">
<DashboardTab v-if="state.activeTab === 'dashboard'" />
<ProductsTab v-else-if="state.activeTab === 'products'" />
<MaterialsTab v-else-if="state.activeTab === 'materials'" />
<InventoryTab v-else-if="state.activeTab === 'inventory'" />
<PurchaseOrdersTab v-else-if="state.activeTab === 'purchases'" />
<SalesOrdersTab v-else-if="state.activeTab === 'sales_orders'" />
<SuppliersTab v-else-if="state.activeTab === 'suppliers'" />
<CustomersTab v-else-if="state.activeTab === 'customers'" />
<FinanceTab v-else-if="state.activeTab === 'finance'" />
<MovementsTab v-else-if="state.activeTab === 'movements'" />
<t-loading :loading="store.loading">
<router-view />
</t-loading>
</section>
</div>
@@ -1,7 +1,15 @@
<script setup lang="ts">
import { useInventory } from '@/modules/inventory/composables/useInventory'
import { useRouter } from 'vue-router'
import { useInventoryStore } from '@/stores/inventory'
const { state, menuGroups, openGroups, toggleGroup, handleMenuClick } = useInventory()
const router = useRouter()
const store = useInventoryStore()
const { menuGroups, openGroups, toggleGroup } = store
const navigateTo = (itemKey: string) => {
store.activeTab = itemKey
router.push(`/inventory/${itemKey}`)
}
</script>
<template>
@@ -15,8 +23,8 @@ const { state, menuGroups, openGroups, toggleGroup, handleMenuClick } = useInven
<div
v-for="item in group.items"
:key="item.key"
:class="['sidebar-item', { active: state.activeTab === item.key }]"
@click="handleMenuClick(item.key)"
:class="['sidebar-item', { active: store.activeTab === item.key }]"
@click="navigateTo(item.key)"
>
{{ item.label }}
</div>
@@ -3,7 +3,9 @@ import { ref, computed, onMounted } from 'vue'
import { DialogPlugin } from 'tdesign-vue-next'
import { useInventory } from '../composables/useInventory'
import { apiRequest } from '@/shared/api'
import { inventoryApi } from '@/shared/api-client'
import { addNotification, handleApiError } from '@/shared/notification'
import type { Schema } from '@/types/schemas'
function confirmDialog(header: string, body: string, theme: string, confirmText: string, cancelText: string): Promise<boolean> {
return new Promise((resolve) => {
@@ -47,6 +49,46 @@ const receiveWarehouseId = ref<number | null>(null)
const receiveRemark = ref('')
const receiving = ref(false)
// ── 采购需求推导 ──
const showDemandModal = ref(false)
const demandLoading = ref(false)
const salesOrdersForDemand = ref<Schema<'SalesOrderResponse'>[]>([])
const selectedSalesOrderIds = ref<number[]>([])
const demandResult = ref<Schema<'PurchaseDemandResponse'> | null>(null)
async function openDemandDialog() {
showDemandModal.value = true
demandResult.value = null
selectedSalesOrderIds.value = []
try {
const data = await inventoryApi.listSalesOrders({ limit: 100 })
salesOrdersForDemand.value = (data as any)?.items || data || []
} catch (e) {
handleApiError(e, '加载销售订单')
}
}
async function calculateDemands() {
if (!selectedSalesOrderIds.value.length) {
addNotification('请选择至少一个销售订单', 'warning')
return
}
demandLoading.value = true
try {
demandResult.value = await inventoryApi.calculatePurchaseDemands(selectedSalesOrderIds.value)
} catch (e) {
handleApiError(e, '计算采购需求')
} finally {
demandLoading.value = false
}
}
function closeDemandModal() {
showDemandModal.value = false
demandResult.value = null
selectedSalesOrderIds.value = []
}
function openCreateOrder() {
editingItem.value = null
form.value = {
@@ -299,6 +341,7 @@ onMounted(() => {
</t-select>
<t-button @click="loadPurchaseOrders">刷新</t-button>
<t-button type="primary" @click="openCreateOrder">新增采购订单</t-button>
<t-button theme="success" variant="outline" @click="openDemandDialog">采购建议</t-button>
</div>
<t-table :data="state.purchaseOrders" :loading="state.loading" stripe>
@@ -515,6 +558,87 @@ onMounted(() => {
<t-button type="primary" :loading="receiving" :disabled="receiving" @click="receivePurchaseOrder">确认入库</t-button>
</template>
</t-dialog>
<!-- 采购需求推导对话框 -->
<t-dialog
v-model:visible="showDemandModal"
title="采购需求推导"
width="1000px"
@closed="closeDemandModal"
>
<div style="margin-bottom: 16px;">
<p style="margin-bottom: 8px; color: var(--td-text-color-secondary);">
选择销售订单,系统将自动按 BOM 展开物料需求、对比库存、推荐供应商。
</p>
<div style="display: flex; gap: 12px; align-items: center;">
<t-select
v-model="selectedSalesOrderIds"
multiple
placeholder="请选择销售订单"
style="flex: 1;"
:loading="salesOrdersForDemand.length === 0"
>
<t-option
v-for="order in salesOrdersForDemand"
:key="order.id"
:label="`${order.order_no} - ${order.customer_name || ''}`"
:value="order.id"
/>
</t-select>
<t-button
type="primary"
:loading="demandLoading"
:disabled="!selectedSalesOrderIds.length"
@click="calculateDemands"
>
计算
</t-button>
</div>
</div>
<template v-if="demandResult">
<div style="display: flex; gap: 24px; margin-bottom: 12px; font-weight: 600;">
<span>来源订单:{{ demandResult.source_order_nos?.join(', ') || '-' }}</span>
<span>缺货物料:<t-tag :type="demandResult.shortage_count > 0 ? 'danger' : 'success'" size="small">{{ demandResult.shortage_count }}</t-tag></span>
<span>预计采购总额:{{ formatCurrency(Number(demandResult.total_estimated_cost || 0)) }}</span>
</div>
<t-table :data="demandResult.items || []" stripe border size="small" max-height="400">
<t-table-column prop="material_sku" label="物料SKU" width="120" />
<t-table-column prop="material_name" label="物料名称" min-width="140" />
<t-table-column label="需求量" width="90">
<template #default="{ row }">{{ row.required_quantity }}</template>
</t-table-column>
<t-table-column label="库存量" width="90">
<template #default="{ row }">{{ row.available_quantity }}</template>
</t-table-column>
<t-table-column label="缺口" width="90">
<template #default="{ row }">
<t-tag :type="row.shortage_quantity > 0 ? 'danger' : 'success'" size="small">
{{ row.shortage_quantity }}
</t-tag>
</template>
</t-table-column>
<t-table-column label="单价" width="90">
<template #default="{ row }">{{ formatCurrency(Number(row.unit_cost || 0)) }}</template>
</t-table-column>
<t-table-column label="预计金额" width="110">
<template #default="{ row }">{{ formatCurrency(Number(row.estimated_cost || 0)) }}</template>
</t-table-column>
<t-table-column prop="suggested_supplier_name" label="建议供应商" min-width="120">
<template #default="{ row }">{{ row.suggested_supplier_name || '-' }}</template>
</t-table-column>
<t-table-column label="交期(天)" width="80">
<template #default="{ row }">{{ row.supplier_lead_time ?? '-' }}</template>
</t-table-column>
</t-table>
<t-empty v-if="(demandResult.items || []).length === 0" description="无物料需求(BOM 为空或订单无明细)" />
</template>
<template #footer>
<t-button @click="showDemandModal = false">关闭</t-button>
</template>
</t-dialog>
</div>
</template>
@@ -1,518 +1,10 @@
import { reactive, ref, computed } from 'vue'
import { apiRequest } from '@/shared/api'
import { addNotification, handleApiError } from '@/shared/notification'
import { formatCurrency, formatNumber, formatDateTime, formatDate } from '@/shared/utils'
import type { Schema } from '@/types/schemas'
declare const AirDatepicker: any
const state = reactive({
activeTab: 'dashboard' as string,
backendDbReady: true,
backendDbMessage: '' as string,
productCategory: 'finished' as string,
dashboard: null as any, // 后端 /api/dashboard 无 response_model,暂未生成类型
financeSummary: null as Schema<'FinanceSummaryResponse'> | null,
financePeriod: {
year: new Date().getFullYear(),
quarter: '' as string
},
financeTransactions: [] as Schema<'FinanceTransactionResponse'>[],
receivables: [] as Schema<'ReceivableItemResponse'>[],
payables: [] as Schema<'PayableItemResponse'>[],
customerFinanceStatement: [] as Schema<'PartnerStatementItemResponse'>[],
supplierFinanceStatement: [] as Schema<'PartnerStatementItemResponse'>[],
customerProductStatement: [] as Schema<'PartnerProductStatementItemResponse'>[],
supplierProductStatement: [] as Schema<'PartnerProductStatementItemResponse'>[],
products: [] as any[],
materials: [] as any[],
finishedProducts: [] as any[],
purchaseOrders: [] as any[],
purchaseWarehouseId: null as number | null,
purchaseReceiveItems: [] as any[],
productionOrders: [] as any[],
productionPlan: null as any,
productionWarehouseId: null as number | null,
suppliers: [] as any[],
customers: [] as any[],
warehouses: [] as any[],
inventory: [] as any[],
movements: [] as any[],
loading: false,
showModal: false,
modalType: '' as string,
editingItem: null as any,
productBomItems: [] as any[],
materialConsumptionItems: [] as any[],
showMaterialConsumptionModal: false,
consumedMaterials: [] as any[],
restockItems: [] as any[],
showRestockModal: false,
form: {} as any
})
let deliveryPicker: any = null
let expectedPicker: any = null
const deliveryDateInput = ref<HTMLElement | null>(null)
const expectedDateInput = ref<HTMLElement | null>(null)
const deliveryDateNativeInput = ref<HTMLElement | null>(null)
const expectedDateNativeInput = ref<HTMLElement | null>(null)
/**
* Backward-compatible wrapper — delegates to Pinia store.
* All existing components calling `useInventory()` keep working unchanged.
* New code should import `useInventoryStore` directly from `@/stores/inventory`.
*/
import { useInventoryStore } from '@/stores/inventory'
export function useInventory() {
const parseDateTimeLocal = (text: string | null | undefined): Date | null => {
if (!text) return null
const raw = String(text).trim()
const normalized = raw.replace('T', ' ').slice(0, 16)
const m = normalized.match(/^(\d{4})-(\d{2})-(\d{2})\s(\d{2}):(\d{2})$/)
if (!m) return null
const year = Number(m[1])
const month = Number(m[2])
const day = Number(m[3])
const hour = Number(m[4])
const minute = Number(m[5])
if (!Number.isFinite(year + month + day + hour + minute)) return null
return new Date(year, month - 1, day, hour, minute, 0)
}
const toPickerValue = (value: any): string => {
if (!value) return ''
const raw = String(value).trim()
if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}/.test(raw)) return raw.slice(0, 16)
if (raw.includes('T')) return raw.replace('T', ' ').slice(0, 16)
const dt = new Date(raw)
if (!Number.isFinite(dt.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
}
const toApiDateTime = (value: any): string | null => {
if (!value) return null
const text = String(value).trim()
if (text.includes('T')) return text.split('T')[0]
if (text.includes(' ')) return text.split(' ')[0]
if (text.length === 10) return text
if (value instanceof Date) {
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, '0')
const day = String(value.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
return text
}
const toNativeValue = (value: any): string => {
if (!value) return ''
const text = String(value).trim()
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
const isoText = text.replace(' ', 'T')
return isoText.length >= 16 ? isoText.slice(0, 16) : isoText
}
const fromNativeValue = (value: any): string => {
if (!value) return ''
const text = String(value).trim()
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
return text.replace('T', ' ').slice(0, 16)
}
const destroyPickers = () => {
if (deliveryPicker) {
deliveryPicker.destroy()
deliveryPicker = null
}
if (expectedPicker) {
expectedPicker.destroy()
expectedPicker = null
}
}
const initPickers = () => {
destroyPickers()
if (typeof AirDatepicker !== 'function') return
if (state.modalType === 'salesOrder' && deliveryDateInput.value) {
deliveryPicker = new AirDatepicker(deliveryDateInput.value, {
timepicker: false,
autoClose: true,
zIndex: 2005,
dateFormat: 'yyyy-MM-dd',
onSelect: ({ formattedDate }: any) => {
state.form.delivery_date = formattedDate || ''
state.form.delivery_date_native = toNativeValue(formattedDate || '')
}
})
const initial = parseDateTimeLocal(state.form.delivery_date)
if (initial) deliveryPicker.selectDate(initial, { silent: true })
}
if (state.modalType === 'purchaseOrder' && expectedDateInput.value) {
expectedPicker = new AirDatepicker(expectedDateInput.value, {
timepicker: false,
autoClose: true,
zIndex: 2005,
dateFormat: 'yyyy-MM-dd',
onSelect: ({ formattedDate }: any) => {
state.form.expected_date = formattedDate || ''
state.form.expected_date_native = toNativeValue(formattedDate || '')
}
})
const initial = parseDateTimeLocal(state.form.expected_date)
if (initial) expectedPicker.selectDate(initial, { silent: true })
}
}
const openDateTimePicker = (pickerKind: string) => {
if (pickerKind === 'delivery' && deliveryPicker) {
deliveryPicker.show()
return
}
if (pickerKind === 'expected' && expectedPicker) {
expectedPicker.show()
return
}
const nativeInput = pickerKind === 'delivery' ? deliveryDateNativeInput.value : expectedDateNativeInput.value
if (!nativeInput) return
if (typeof (nativeInput as any).showPicker === 'function') {
(nativeInput as any).showPicker()
return
}
nativeInput.focus()
nativeInput.click()
}
const getMovementTypeLabel = (movementType: string): string => {
const movementLabelMap: Record<string, string> = {
in: '其他入库',
out: '其他出库',
adjust: '库存调整',
purchase_in: '采购入库',
return_from_production: '生产退料入库',
outsource_return: '外协回库',
finish_in: '完工入库',
issue_to_production: '生产领料出库',
outsource_send: '外协发料出库',
shipment_out: '销售出库',
scrap_out: '报废出库'
}
return movementLabelMap[movementType] || movementType
}
const getMovementBadgeClass = (movementType: string): string => {
if (['purchase_in', 'return_from_production', 'outsource_return', 'finish_in', 'in'].includes(movementType)) {
return 'badge-success'
}
if (['issue_to_production', 'outsource_send', 'shipment_out', 'scrap_out', 'out'].includes(movementType)) {
return 'badge-error'
}
return 'badge-warning'
}
const getPurchaseOrderStatusLabel = (status: string): string => {
const statusMap: Record<string, string> = {
draft: '已下单',
pending: '已下单',
partial_received: '部分收货',
received: '已收货',
paid: '已付款',
cancelled: '已作废'
}
return statusMap[status] || status
}
const isPurchaseOrderLocked = (status: string): boolean => {
return ['received', 'paid', 'cancelled'].includes(status)
}
const getSalesOrderStatusLabel = (status: string): string => {
const statusMap: Record<string, string> = {
manufacturing: '制造中',
delivered: '已交付',
paid: '已收款',
cancelled: '已作废'
}
return statusMap[status] || status
}
const getDeliveryStatusLabel = (ds: string): string => {
const map: Record<string, string> = { manufacturing: '制造中', delivered: '已交付', cancelled: '已作废' }
return map[ds] || ds
}
const getPaymentStatusLabel = (ps: string): string => {
const map: Record<string, string> = { unpaid: '未收款', paid: '已收款' }
return map[ps] || ps
}
const getReceiptStatusLabel = (rs: string): string => {
const map: Record<string, string> = { pending: '已下单', partial_received: '部分收货', received: '已收货', cancelled: '已作废' }
return map[rs] || rs
}
const checkBackendHealth = async () => {
try {
const resp = await fetch('/health', { method: 'GET' })
if (!resp.ok) {
state.backendDbReady = false
state.backendDbMessage = '后端服务异常,暂无法加载业务数据'
return
}
const health = await resp.json().catch(() => null)
if (health && health.database_connected === false) {
state.backendDbReady = false
state.backendDbMessage = '数据库未连接,当前仅可浏览界面,业务数据暂不可用'
return
}
state.backendDbReady = true
state.backendDbMessage = ''
} catch {
state.backendDbReady = false
state.backendDbMessage = '无法连接后端服务'
}
}
const loadDashboard = async () => {
state.loading = true
try { state.dashboard = await apiRequest('/api/dashboard') }
catch (e) { handleApiError(e, '加载仪表盘') }
finally { state.loading = false }
}
const loadFinishedProducts = async () => {
state.loading = true
try { state.finishedProducts = await apiRequest('/api/products?item_type=finished&limit=100') }
catch (e) { handleApiError(e, '加载成品') }
finally { state.loading = false }
}
const loadProducts = async () => { await loadFinishedProducts() }
const loadMaterials = async () => {
state.loading = true
try { state.materials = await apiRequest('/api/products?item_type=material&limit=100') }
catch (e) { handleApiError(e, '加载物料') }
finally { state.loading = false }
}
const loadWarehouses = async () => {
state.loading = true
try { state.warehouses = await apiRequest('/api/warehouses') }
catch (e) { handleApiError(e, '加载仓库') }
finally { state.loading = false }
}
const ensureStockBaseData = async () => {
if (!state.materials.length) await loadMaterials()
if (!state.warehouses.length) await loadWarehouses()
if (!state.warehouses.length) {
try {
await apiRequest('/api/warehouses', { method: 'POST', body: JSON.stringify({ name: '默认仓库' }) })
await loadWarehouses()
addNotification('已自动创建默认仓库', 'success')
} catch (e) { handleApiError(e, '自动创建默认仓库') }
}
}
const loadSuppliers = async () => {
state.loading = true
try { state.suppliers = await apiRequest('/api/suppliers') }
catch (e) { handleApiError(e, '加载供应商') }
finally { state.loading = false }
}
const loadProductionOrders = async () => {
state.loading = true
try {
const [orders, warehouses] = await Promise.all([
apiRequest('/api/sales-orders?limit=100'),
apiRequest('/api/warehouses')
])
state.productionOrders = orders?.items || []
state.warehouses = warehouses || []
if (!state.productionWarehouseId) {
state.productionWarehouseId = state.warehouses.find((w: any) => w.is_default)?.id || state.warehouses[0]?.id || null
}
} catch (e) { handleApiError(e, '加载按单生产数据') }
finally { state.loading = false }
}
const loadPurchaseOrders = async () => {
state.loading = true
try {
const [orders, warehouses] = await Promise.all([
apiRequest('/api/purchase-orders?limit=100'),
apiRequest('/api/warehouses')
])
state.purchaseOrders = orders?.items || []
state.warehouses = warehouses || []
if (!state.purchaseWarehouseId) {
state.purchaseWarehouseId = state.warehouses.find((w: any) => w.is_default)?.id || state.warehouses[0]?.id || null
}
} catch (e) { handleApiError(e, '加载采购订单') }
finally { state.loading = false }
}
const loadCustomers = async () => {
state.loading = true
try { state.customers = await apiRequest('/api/customers') }
catch (e) { handleApiError(e, '加载客户') }
finally { state.loading = false }
}
const loadInventory = async () => {
state.loading = true
try { state.inventory = (await apiRequest('/api/inventory'))?.items || [] }
catch (e) { handleApiError(e, '加载库存') }
finally { state.loading = false }
}
const loadMovements = async () => {
state.loading = true
try { state.movements = (await apiRequest('/api/stock-movements'))?.items || [] }
catch (e) { handleApiError(e, '加载变动记录') }
finally { state.loading = false }
}
const loadFinance = async () => {
state.loading = true
try {
const selectedYear = Number(state.financePeriod.year) || new Date().getFullYear()
const selectedQuarter = state.financePeriod.quarter ? Number(state.financePeriod.quarter) : null
const periodQuery = selectedQuarter
? `year=${selectedYear}&quarter=${selectedQuarter}`
: `year=${selectedYear}`
const [summary, transactions, receivables, payables, customerStatement, supplierStatement, customerProductStatement, supplierProductStatement] = await Promise.all([
apiRequest(`/api/finance/summary?${periodQuery}`),
apiRequest(`/api/finance/transactions?status=confirmed&limit=20&${periodQuery}`),
apiRequest(`/api/finance/receivables?limit=20&${periodQuery}`),
apiRequest(`/api/finance/payables?limit=20&${periodQuery}`),
apiRequest(`/api/finance/partner-statement/customer?${periodQuery}`),
apiRequest(`/api/finance/partner-statement/supplier?${periodQuery}`),
apiRequest(`/api/finance/partner-product-statement/customer?${periodQuery}`),
apiRequest(`/api/finance/partner-product-statement/supplier?${periodQuery}`)
])
state.financeSummary = summary
state.financeTransactions = transactions?.items || []
state.receivables = receivables
state.payables = payables
state.customerFinanceStatement = customerStatement.items || []
state.supplierFinanceStatement = supplierStatement.items || []
state.customerProductStatement = customerProductStatement.items || []
state.supplierProductStatement = supplierProductStatement.items || []
} catch (e) { handleApiError(e, '加载财务数据') }
finally { state.loading = false }
}
const refreshFinanceByPeriod = () => {
if (state.activeTab === 'finance') loadFinance()
}
const switchTab = (tab: string) => {
state.activeTab = tab
}
const closeModal = () => {
state.showModal = false
state.modalType = ''
state.editingItem = null
state.productBomItems = []
state.purchaseReceiveItems = []
state.form = {}
destroyPickers()
}
const modalTitle = computed(() => {
const prefix = state.editingItem ? '编辑' : '新增'
const typeMap: Record<string, string> = {
product: state.form.item_type === 'finished' ? '成品' : '物料',
inventoryItem: '物料库存',
salesOrder: '销售订单',
purchaseOrder: '采购订单',
purchaseReceive: '采购到货入库',
supplier: '供应商',
customer: '客户'
}
return prefix + (typeMap[state.modalType] || '')
})
const menuGroups = [
{ key: 'overview', title: '概览', items: [{ key: 'dashboard', label: '仪表盘' }] },
{ key: 'sales', title: '销售', items: [{ key: 'sales_orders', label: '销售订单管理' }] },
{ key: 'purchase', title: '采购', items: [{ key: 'purchases', label: '采购订单管理' }] },
{ key: 'product', title: '产品', items: [{ key: 'products', label: '成品管理' }, { key: 'materials', label: '物料管理' }] },
{ key: 'partner', title: '往来单位', items: [{ key: 'customers', label: '客户管理' }, { key: 'suppliers', label: '供应商管理' }] },
{ key: 'warehouse', title: '仓库', items: [{ key: 'inventory', label: '库存管理' }, { key: 'movements', label: '库存变动记录' }] },
{ key: 'finance', title: '财务', items: [{ key: 'finance', label: '财务概览' }] }
]
const openGroups = reactive<Record<string, boolean>>(
Object.fromEntries(menuGroups.map(g => [g.key, true]))
)
const toggleGroup = (groupKey: string) => { openGroups[groupKey] = !openGroups[groupKey] }
const activeMenu = computed(() => {
for (const group of menuGroups) {
const item = group.items.find(i => i.key === state.activeTab)
if (item) return { group, item }
}
return null
})
const handleMenuClick = (itemKey: string) => { switchTab(itemKey) }
const switchProductCategory = (category: string) => { state.productCategory = category }
return {
state,
deliveryDateInput,
expectedDateInput,
deliveryDateNativeInput,
expectedDateNativeInput,
menuGroups,
openGroups,
activeMenu,
modalTitle,
parseDateTimeLocal,
toPickerValue,
toApiDateTime,
toNativeValue,
fromNativeValue,
destroyPickers,
initPickers,
openDateTimePicker,
getMovementTypeLabel,
getMovementBadgeClass,
getPurchaseOrderStatusLabel,
isPurchaseOrderLocked,
getSalesOrderStatusLabel,
getDeliveryStatusLabel,
getPaymentStatusLabel,
getReceiptStatusLabel,
checkBackendHealth,
loadDashboard,
loadFinishedProducts,
loadProducts,
loadMaterials,
loadWarehouses,
ensureStockBaseData,
loadSuppliers,
loadProductionOrders,
loadPurchaseOrders,
loadCustomers,
loadInventory,
loadMovements,
loadFinance,
refreshFinanceByPeriod,
switchTab,
closeModal,
toggleGroup,
handleMenuClick,
switchProductCategory,
formatCurrency,
formatNumber,
formatDateTime,
formatDate
}
return useInventoryStore()
}
@@ -0,0 +1,372 @@
<template>
<div class="page-container">
<div class="page-header">
<t-button variant="text" @click="router.back()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回
</t-button>
<h1>批量分析</h1>
<p>同时上传多个 STP 文件,并行分析、统一查看进度</p>
</div>
<!-- 上传区 -->
<div class="upload-layout" v-if="!state.batchId">
<div class="upload-main-card">
<h2 class="section-title">1. 选择多个 STP 文件</h2>
<div
:class="['upload-zone', { 'drag-over': state.dragOver }]"
@dragover.prevent="state.dragOver = true"
@dragleave.prevent="state.dragOver = false"
@drop="handleDrop"
@click="fileInput?.click()"
>
<input
ref="fileInput"
type="file"
accept=".stp,.step"
multiple
@change="handleFileChange"
hidden
/>
<div class="upload-icon">📁</div>
<div class="upload-text">
<span class="upload-title">点击选择或拖拽多个 STP/STEP 文件</span>
<span class="upload-hint">支持批量上传,单次最多 20 个文件</span>
</div>
</div>
<div v-if="state.selectedFiles.length" class="batch-file-list">
<div class="batch-file-header">
<span>已选择 {{ state.selectedFiles.length }} 个文件</span>
<t-button variant="text" size="small" @click="state.selectedFiles = []">清空</t-button>
</div>
<div v-for="(file, idx) in state.selectedFiles" :key="idx" class="batch-file-item">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<t-button variant="text" size="small" @click="removeFile(idx)">×</t-button>
</div>
</div>
<div v-if="state.error" class="error-message">{{ state.error }}</div>
</div>
<div class="upload-side-card">
<h2 class="section-title">2. 注塑模参数</h2>
<div class="material-panel compact-panel">
<t-form-item label="产品材料">
<t-select v-model="state.selectedMaterial">
<t-option value="ABS" label="ABS (1.05 g/cm³)" />
<t-option value="PP" label="PP (0.90 g/cm³)" />
<t-option value="PE" label="PE (0.95 g/cm³)" />
<t-option value="PC" label="PC (1.20 g/cm³)" />
<t-option value="PA" label="PA (1.14 g/cm³)" />
<t-option value="POM" label="POM (1.41 g/cm³)" />
<t-option value="PMMA" label="PMMA (1.18 g/cm³)" />
<t-option value="PBT" label="PBT (1.31 g/cm³)" />
</t-select>
</t-form-item>
</div>
<t-button
v-if="state.selectedFiles.length"
theme="primary"
class="upload-submit-btn"
@click="batchUpload"
:disabled="state.uploading"
>
{{ state.uploading ? '上传中...' : '3. 开始批量分析' }}
</t-button>
<div v-else class="inline-note">先选择 STP 文件,再填写材料并开始批量分析。</div>
</div>
</div>
<!-- 进度看板 -->
<div v-else class="batch-dashboard">
<div class="batch-progress-header">
<div class="batch-progress-info">
<h2>批量任务:{{ state.batchId.slice(0, 8) }}...</h2>
<p>
共 {{ state.batchData?.total }} 个任务,
<t-tag type="success" variant="light">完成 {{ state.batchData?.completed }}</t-tag>
<t-tag type="danger" variant="light" v-if="state.batchData?.failed">失败 {{ state.batchData?.failed }}</t-tag>
<t-tag type="warning" variant="light" v-if="state.batchData?.processing">进行中 {{ state.batchData?.processing }}</t-tag>
</p>
</div>
<t-button
variant="outline"
size="small"
@click="state.batchId = ''; state.batchData = null"
>
新建批量
</t-button>
</div>
<!-- 整体进度条 -->
<div class="progress-bar" style="margin-bottom: var(--space-4);">
<div
class="progress-fill"
:style="{ width: (state.batchData?.progress_percent || 0) + '%' }"
></div>
</div>
<!-- 任务列表 -->
<div class="table-container">
<t-table :data="state.batchData?.tasks || []" row-key="task_id" stripe>
<t-table-column title="文件名" colKey="filename" />
<t-table-column title="状态">
<template #default="{ row }">
<t-tag
:type="row.status === 'completed' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'"
variant="light"
>
{{ statusLabel(row.status) }}
</t-tag>
</template>
</t-table-column>
<t-table-column title="进度">
<template #default="{ row }">
<div v-if="row.status === 'processing'" class="progress-bar" style="width: 100px; display: inline-block;">
<div class="progress-fill" :style="{ width: (row.progress || 0) + '%' }"></div>
</div>
<span v-else>{{ row.progress || 0 }}%</span>
</template>
</t-table-column>
<t-table-column title="错误信息" colKey="error" />
<t-table-column title="操作">
<template #default="{ row }">
<t-button
v-if="row.status === 'completed'"
theme="primary"
size="small"
@click="router.push(`/moldinsight/result/${row.task_id}`)"
>
查看结果
</t-button>
<span v-else-if="row.status === 'failed'" style="color: var(--danger-color);">
{{ row.error || '分析失败' }}
</span>
<span v-else style="color: var(--text-secondary);">处理中...</span>
</template>
</t-table-column>
</t-table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { apiRequest } from '@/shared/api'
import { handleApiError, addNotification } from '@/shared/notification'
import { clearAuth } from '@/shared/auth'
import { formatFileSize } from '@/shared/utils'
interface BatchTask {
task_id: string
filename: string
status: string
progress?: number
error?: string
html_file?: string
}
interface BatchData {
batch_id: string
total: number
completed: number
failed: number
processing: number
progress_percent: number
tasks: BatchTask[]
}
const router = useRouter()
const appStore = useAppStore()
const fileInput = ref<HTMLInputElement | null>(null)
let pollTimer: ReturnType<typeof setTimeout> | null = null
const state = reactive({
selectedFiles: [] as File[],
selectedMaterial: 'ABS',
dragOver: false,
uploading: false,
error: '',
batchId: null as string | null,
batchData: null as BatchData | null,
})
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const files = Array.from(target.files || [])
addFiles(files)
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
state.dragOver = false
const files = Array.from(event.dataTransfer?.files || [])
addFiles(files)
}
const addFiles = (files: File[]) => {
const valid = files.filter(f => {
const lower = f.name.toLowerCase()
return lower.endsWith('.stp') || lower.endsWith('.step')
})
if (valid.length < files.length) {
state.error = '部分文件格式不支持,已自动过滤非 STP/STEP 文件'
}
const combined = [...state.selectedFiles, ...valid]
if (combined.length > 20) {
state.error = '单次批量上传最多 20 个文件'
state.selectedFiles = combined.slice(0, 20)
} else {
state.selectedFiles = combined
}
}
const removeFile = (idx: number) => {
state.selectedFiles.splice(idx, 1)
}
const statusLabel = (status: string) => {
const map: Record<string, string> = {
processing: '处理中',
completed: '已完成',
failed: '失败',
unknown: '未知',
rejected: '已拒绝',
}
return map[status] || status
}
const batchUpload = async () => {
if (!appStore.token) {
state.error = '请先登录后再上传文件'
router.push('/login')
return
}
if (!state.selectedFiles.length) return
state.uploading = true
state.error = ''
const formData = new FormData()
for (const file of state.selectedFiles) {
formData.append('files', file)
}
formData.append('material', state.selectedMaterial)
formData.append('draft_angle', '2.0')
formData.append('shrinkage_rate', '0.5')
formData.append('parting_precision', '0.1')
formData.append('cavity_match', '95')
try {
const res = await fetch('/api/batch-upload', {
method: 'POST',
headers: { Authorization: `Bearer ${appStore.token}` },
body: formData,
})
if (res.status === 401) {
clearAuth()
router.push('/login')
return
}
if (!res.ok) throw new Error(`上传失败: ${res.status}`)
const data = await res.json()
state.batchId = data.batch_id
addNotification(`已创建批量任务:${data.accepted} 个文件已接受`, 'success')
startBatchPolling(data.batch_id)
} catch (e) {
state.error = handleApiError(e, '批量上传')
} finally {
state.uploading = false
}
}
const startBatchPolling = (batchId: string) => {
const poll = async () => {
try {
const data = await apiRequest<BatchData>(`/api/batch/${batchId}`)
state.batchData = data
// 全部完成或全部失败则停止轮询
if (data.processing === 0) {
addNotification(
`批量任务完成:${data.completed} 个成功,${data.failed} 个失败`,
data.failed > 0 ? 'warning' : 'success'
)
return
}
pollTimer = setTimeout(poll, 3000)
} catch (e) {
handleApiError(e, '查询批量状态')
}
}
poll()
}
onMounted(() => {
if (!appStore.user) {
router.push('/login')
return
}
})
onUnmounted(() => {
if (pollTimer) clearTimeout(pollTimer)
})
</script>
<style scoped>
.batch-file-list {
margin-top: var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: var(--space-2);
}
.batch-file-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
font-weight: 600;
}
.batch-file-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
border-bottom: 1px solid var(--border-light);
}
.batch-file-item .file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.batch-file-item .file-size {
color: var(--text-secondary);
font-size: 0.85rem;
white-space: nowrap;
}
.batch-dashboard {
margin-top: var(--space-4);
}
.batch-progress-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-2);
}
.batch-progress-info h2 {
margin: 0 0 var(--space-1);
}
.batch-progress-info p {
margin: 0;
display: flex;
align-items: center;
gap: var(--space-2);
}
</style>
@@ -3,6 +3,9 @@
<div class="page-header">
<h1>注塑模 STP 分析</h1>
<p>上传 STEP/STP 产品件,完成自动分模、工程建议与导出</p>
<t-button variant="outline" size="small" @click="router.push('/moldinsight/batch')" style="margin-top: var(--space-2);">
📦 批量分析
</t-button>
</div>
<div class="moldinsight-intro-grid">
+142 -1
View File
@@ -123,6 +123,9 @@
<t-button type="default" size="small" @click="createProductFromAnalysis" :loading="state.creatingProduct" title="将本次模具分析创建为进销存成品,可在进销存模块继续配置 BOM / 销售">
📋 创建为成品
</t-button>
<t-button type="default" size="small" @click="estimateCost" :loading="state.costLoading" title="估算模具造价与单件成本">
💰 成本估算
</t-button>
</div>
<div id="preview-3d" v-if="selectedHtmlFile" class="viewer-section viewer-section-hero">
@@ -388,6 +391,97 @@
</template>
</div>
<div id="cost-estimate" class="viewer-section">
<div class="summary-header">
<h3>成本估算</h3>
<t-tag v-if="state.costResult" :type="state.costResult.source === 'ai' ? 'success' : 'warning'">
{{ state.costResult.source === 'ai' ? 'AI 估算' : '规则估算' }}
</t-tag>
<t-tag v-else theme="primary">待生成</t-tag>
</div>
<div class="result-card" style="margin-bottom: var(--space-3);">
<t-button
type="primary"
size="small"
:disabled="state.costLoading"
@click="estimateCost()"
title="基于当前方案估算模具造价与单件成本"
>
{{ state.costLoading ? '⏳ 估算中...' : '💰 生成成本估算' }}
</t-button>
</div>
<t-alert v-if="state.costError" theme="warning" title="成本估算失败" :message="state.costError" />
<template v-if="state.costResult">
<div class="result-grid">
<div class="result-card result-card-highlight">
<h4>模具造价</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">总造价</span>
<span class="info-value" style="font-size: 1.2rem; font-weight: 700; color: var(--primary-color);">
{{ state.costResult.total_mold_cost }}
</span>
</div>
<div class="info-item">
<span class="info-label">材料费</span>
<span class="info-value">{{ state.costResult.mold_cost.material }}</span>
</div>
<div class="info-item">
<span class="info-label">加工费</span>
<span class="info-value">{{ state.costResult.mold_cost.machining }}</span>
</div>
<div class="info-item">
<span class="info-label">复杂度系数</span>
<span class="info-value">{{ state.costResult.mold_cost.complexity_factor }}</span>
</div>
</div>
</div>
<div class="result-card">
<h4>单件成本</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">单件费用</span>
<span class="info-value" style="font-size: 1.1rem; font-weight: 600; color: var(--warning-color);">
{{ state.costResult.cost_per_part }}
</span>
</div>
<div class="info-item">
<span class="info-label">材料用量</span>
<span class="info-value">{{ state.costResult.part_cost.material }}</span>
</div>
<div class="info-item">
<span class="info-label">成型周期</span>
<span class="info-value">{{ state.costResult.part_cost.cycle_time }}</span>
</div>
<div class="info-item">
<span class="info-label">置信度</span>
<span class="info-value">{{ Math.round((state.costResult.confidence || 0) * 100) }}%</span>
</div>
</div>
</div>
</div>
<div v-if="state.costResult.assumptions?.length" class="result-card full-width" style="margin-top: var(--space-3);">
<h4>估算假设</h4>
<div class="recommendations-list">
<div
v-for="(assumption, idx) in state.costResult.assumptions"
:key="'cost-assumption-' + idx"
class="recommendation-item low"
>
<div class="recommendation-text">ℹ️ {{ assumption }}</div>
</div>
</div>
</div>
</template>
<t-alert v-else-if="!state.costError" theme="info" title="尚未生成" message="点击“生成成本估算”后,将基于当前方案估算模具造价与单件成本。" />
</div>
<div id="llm-report" class="viewer-section" v-if="hasVisibleDesignReport">
<div class="summary-header">
<h3>LLM 设计报告</h3>
@@ -564,6 +658,25 @@ interface CamOperation {
estimated_time_min?: number
}
interface CostEstimate {
mold_cost: {
material: string
machining: string
complexity_factor: string
subtotal: string
}
part_cost: {
material: string
cycle_time: string
cost_per_part: string
}
total_mold_cost: string
cost_per_part: string
confidence: number
assumptions: string[]
source?: string
}
interface SideActionAiAdvice {
source: string
status: string
@@ -607,7 +720,10 @@ const state = reactive({
controller: 'fanuc',
include_gcode: false
},
creatingProduct: false
creatingProduct: false,
costLoading: false,
costError: '',
costResult: null as CostEstimate | null,
})
const camSteelOptions = [
@@ -889,6 +1005,7 @@ const resultAnchorLinks = [
{ id: 'preview-3d', label: '预览' },
{ id: 'ai-side-action', label: 'AI倒扣分析' },
{ id: 'export-cam', label: 'CAM/CNC' },
{ id: 'cost-estimate', label: '成本估算' },
{ id: 'llm-report', label: '设计报告' }
]
@@ -1141,4 +1258,28 @@ const generateCamPlan = async () => {
state.camLoading = false
}
}
const estimateCost = async () => {
try {
state.costLoading = true
state.costError = ''
const taskId = route.params.taskId as string
const result = await apiRequest<any>('/api/cost-estimate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId })
})
state.costResult = result?.data || null
addNotification(
result?.data?.source === 'ai' ? 'AI 成本估算完成' : '规则成本估算完成',
'success'
)
} catch (e: any) {
state.costResult = null
state.costError = e.message || '成本估算失败'
addNotification(`成本估算失败: ${state.costError}`, 'error')
} finally {
state.costLoading = false
}
}
</script>