提交
This commit is contained in:
1 parent
725774ef0c
commit
c3be9d1dce
176 files changed
+41925
-6342
No files matched your search
@@ -116,6 +116,8 @@
|
||||
|
||||
if (!preferPanelIcon) return
|
||||
|
||||
if (heading.classList.contains(panelTitleBarClass)) return
|
||||
|
||||
heading.classList.remove(panelTitleBarClass)
|
||||
|
||||
// 已手写 title-ic(例如 delivery-demo)则不干预
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import dbRequest from '@/utils/http/db'
|
||||
|
||||
export interface DashboardCargoStat {
|
||||
label: string
|
||||
planned: number
|
||||
actual: number
|
||||
}
|
||||
|
||||
export interface DashboardPartnerCargoStat {
|
||||
label: string
|
||||
planned: number
|
||||
actual: number
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
getInboundByCargo() {
|
||||
return dbRequest.get<DashboardCargoStat[]>('/dashboard/inbound/by-cargo')
|
||||
},
|
||||
getInboundByPartnerCargo() {
|
||||
return dbRequest.get<DashboardPartnerCargoStat[]>('/dashboard/inbound/by-partner-cargo')
|
||||
},
|
||||
getOutboundByCargo() {
|
||||
return dbRequest.get<DashboardCargoStat[]>('/dashboard/outbound/by-cargo')
|
||||
},
|
||||
getOutboundByPartnerCargo() {
|
||||
return dbRequest.get<DashboardPartnerCargoStat[]>('/dashboard/outbound/by-partner-cargo')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import dbRequest from '@/utils/http/db'
|
||||
import type { Plan } from '@/api/db/plans'
|
||||
|
||||
export interface InventoryRecord {
|
||||
id: string
|
||||
date: string
|
||||
partnerCompany: string
|
||||
businessType: '入库' | '出库'
|
||||
productId?: string
|
||||
product: string
|
||||
specification?: string
|
||||
quantity: number
|
||||
planNo?: string
|
||||
batchNo?: string
|
||||
vehicleNo: string
|
||||
status?: string
|
||||
remarks?: string
|
||||
purchasePlanId?: string
|
||||
isDeleted: number
|
||||
createdAt: string
|
||||
grossWeight?: number
|
||||
tareWeight?: number
|
||||
deduction?: number
|
||||
netWeight?: number
|
||||
grossWeightTime?: string
|
||||
tareWeightTime?: string
|
||||
hasWeighingSlip?: boolean
|
||||
}
|
||||
|
||||
export interface InventoryCreate {
|
||||
date: string
|
||||
partnerCompany: string
|
||||
businessType: '入库' | '出库'
|
||||
productId: string
|
||||
quantity: number
|
||||
planNo?: string
|
||||
batchNo?: string
|
||||
vehicleNo: string
|
||||
status?: string
|
||||
remarks?: string
|
||||
purchasePlanId?: string
|
||||
}
|
||||
|
||||
export interface InventoryUpdate {
|
||||
date?: string
|
||||
partnerCompany?: string
|
||||
businessType?: '入库' | '出库'
|
||||
productId?: string
|
||||
product?: string
|
||||
specification?: string
|
||||
quantity?: number
|
||||
planNo?: string
|
||||
batchNo?: string
|
||||
vehicleNo?: string
|
||||
status?: string
|
||||
remarks?: string | null
|
||||
purchasePlanId?: string
|
||||
}
|
||||
|
||||
export interface InventoryWeighingRecord {
|
||||
id: string
|
||||
vehicleNo: string
|
||||
planNo?: string
|
||||
productId?: string
|
||||
cargoName: string
|
||||
specification?: string
|
||||
businessType: string
|
||||
batchNo?: string
|
||||
supplier: string
|
||||
consignee: string
|
||||
grossWeight: number
|
||||
grossWeightTime: string
|
||||
tareWeight: number
|
||||
tareWeightTime: string
|
||||
deduction?: number
|
||||
netWeight: number
|
||||
remarks?: string
|
||||
}
|
||||
|
||||
export interface InventoryRelatedData {
|
||||
plan: Plan | null
|
||||
weighing: InventoryWeighingRecord | null
|
||||
}
|
||||
|
||||
export interface InventoryListParams {
|
||||
q?: string
|
||||
planNo?: string
|
||||
batchNo?: string
|
||||
partnerCompany?: string
|
||||
product?: string
|
||||
vehicleNo?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
status?: string
|
||||
businessType?: '入库' | '出库'
|
||||
}
|
||||
|
||||
export const inventoryApi = {
|
||||
list(params?: InventoryListParams) {
|
||||
return dbRequest.get<InventoryRecord[]>('/inventory', { params, timeout: 60000 })
|
||||
},
|
||||
get(id: string) {
|
||||
return dbRequest.get<InventoryRecord>(`/inventory/${id}`)
|
||||
},
|
||||
getRelated(id: string) {
|
||||
return dbRequest.get<InventoryRelatedData>(`/inventory/${id}/related`)
|
||||
},
|
||||
create(payload: InventoryCreate) {
|
||||
return dbRequest.post<InventoryRecord>('/inventory', payload)
|
||||
},
|
||||
update(id: string, payload: InventoryUpdate) {
|
||||
return dbRequest.patch<InventoryRecord>(`/inventory/${id}`, payload)
|
||||
},
|
||||
delete(id: string) {
|
||||
return dbRequest.delete<void>(`/inventory/${id}`)
|
||||
},
|
||||
createLinkForPlan(purchasePlanId: string, remarks?: string) {
|
||||
return dbRequest.post<InventoryRecord>('/inventory/create-link-for-plan', {
|
||||
purchasePlanId,
|
||||
remarks
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import dbRequest from '@/utils/http/db'
|
||||
|
||||
export interface Plan {
|
||||
id: string
|
||||
planNo?: string
|
||||
planDate: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
businessType: '入库' | '出库'
|
||||
productId?: string
|
||||
product: string
|
||||
specification?: string
|
||||
plannedQuantity: number
|
||||
vehicleNo?: string
|
||||
customerName?: string
|
||||
remarks?: string
|
||||
executionStatus?: string
|
||||
executedAt?: string
|
||||
lastWeighingSlipId?: string
|
||||
executedQuantity?: number
|
||||
isDeleted: number
|
||||
createdAt: string
|
||||
fillLinkInventoryId?: string
|
||||
autoGenerateEnabled: number
|
||||
autoGenerateEndMonth?: string
|
||||
autoGenerateLastMonth?: string
|
||||
}
|
||||
|
||||
export interface PlanCreate {
|
||||
planNo?: string
|
||||
planDate: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
businessType: '入库' | '出库'
|
||||
productId: string
|
||||
plannedQuantity: number
|
||||
vehicleNo?: string
|
||||
customerName?: string
|
||||
remarks?: string
|
||||
executionStatus?: string
|
||||
autoGenerateEnabled?: number
|
||||
autoGenerateEndMonth?: string
|
||||
}
|
||||
|
||||
export interface PlanUpdate {
|
||||
planNo?: string
|
||||
planDate?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
businessType?: '入库' | '出库'
|
||||
productId?: string
|
||||
product?: string
|
||||
specification?: string
|
||||
plannedQuantity?: number
|
||||
vehicleNo?: string
|
||||
customerName?: string
|
||||
remarks?: string
|
||||
executionStatus?: string
|
||||
executedAt?: string
|
||||
lastWeighingSlipId?: string
|
||||
executedQuantity?: number
|
||||
autoGenerateEnabled?: number
|
||||
autoGenerateEndMonth?: string
|
||||
autoGenerateLastMonth?: string
|
||||
}
|
||||
|
||||
export interface PlanInventoryRecord {
|
||||
id: string
|
||||
date: string
|
||||
partnerCompany: string
|
||||
businessType: string
|
||||
productId?: string
|
||||
product: string
|
||||
specification?: string
|
||||
quantity: number
|
||||
planNo?: string
|
||||
batchNo?: string
|
||||
vehicleNo: string
|
||||
status?: string
|
||||
remarks?: string
|
||||
purchasePlanId?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PlanWeighingRecord {
|
||||
id: string
|
||||
vehicleNo: string
|
||||
planNo?: string
|
||||
productId?: string
|
||||
cargoName: string
|
||||
specification?: string
|
||||
businessType: string
|
||||
batchNo?: string
|
||||
grossWeight: number
|
||||
tareWeight: number
|
||||
netWeight: number
|
||||
grossWeightTime: string
|
||||
tareWeightTime: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PlanRelatedData {
|
||||
inventories: PlanInventoryRecord[]
|
||||
weighings: PlanWeighingRecord[]
|
||||
}
|
||||
|
||||
export interface PlanListParams {
|
||||
q?: string
|
||||
planNo?: string
|
||||
customerName?: string
|
||||
product?: string
|
||||
businessType?: '入库' | '出库'
|
||||
executionStatus?: string
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
startDateFrom?: string
|
||||
startDateTo?: string
|
||||
}
|
||||
|
||||
export interface AutoGenerateMonthlyResult {
|
||||
createdCount: number
|
||||
skippedReason?: string | null
|
||||
currentMonth: string
|
||||
}
|
||||
|
||||
export const planApi = {
|
||||
list(params?: PlanListParams) {
|
||||
return dbRequest.get<Plan[]>('/plans', { params })
|
||||
},
|
||||
get(id: string) {
|
||||
return dbRequest.get<Plan>(`/plans/${id}`)
|
||||
},
|
||||
getRelated(id: string) {
|
||||
return dbRequest.get<PlanRelatedData>(`/plans/${id}/related`)
|
||||
},
|
||||
create(payload: PlanCreate) {
|
||||
return dbRequest.post<Plan>('/plans', payload)
|
||||
},
|
||||
update(id: string, payload: PlanUpdate) {
|
||||
return dbRequest.patch<Plan>(`/plans/${id}`, payload)
|
||||
},
|
||||
delete(id: string) {
|
||||
return dbRequest.delete<void>(`/plans/${id}`)
|
||||
},
|
||||
runAutoGenerate(force = false) {
|
||||
return dbRequest.post<AutoGenerateMonthlyResult>('/plans/run-auto-generate', undefined, {
|
||||
params: { force }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import dbRequest from '@/utils/http/db'
|
||||
|
||||
export interface Vehicle {
|
||||
id: string
|
||||
vehicleNo: string
|
||||
remarks?: string | null
|
||||
}
|
||||
|
||||
export interface VehicleCreate {
|
||||
vehicleNo: string
|
||||
remarks?: string | null
|
||||
}
|
||||
|
||||
export interface VehicleUpdate {
|
||||
vehicleNo?: string
|
||||
remarks?: string | null
|
||||
}
|
||||
|
||||
export const vehicleApi = {
|
||||
list(q?: string) {
|
||||
return dbRequest.get<Vehicle[]>('/master-data/vehicles', { params: q ? { q } : undefined })
|
||||
},
|
||||
get(id: string) {
|
||||
return dbRequest.get<Vehicle>(`/master-data/vehicles/${id}`)
|
||||
},
|
||||
create(payload: VehicleCreate, config?: AxiosRequestConfig) {
|
||||
return dbRequest.post<Vehicle>('/master-data/vehicles', payload, config)
|
||||
},
|
||||
update(id: string, payload: VehicleUpdate) {
|
||||
return dbRequest.patch<Vehicle>(`/master-data/vehicles/${id}`, payload)
|
||||
},
|
||||
delete(id: string) {
|
||||
return dbRequest.delete<void>(`/master-data/vehicles/${id}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import dbRequest from '@/utils/http/db'
|
||||
import type { InventoryRecord } from '@/api/db/inventory'
|
||||
import type { Plan } from '@/api/db/plans'
|
||||
|
||||
export interface WeighingRecord {
|
||||
id: string
|
||||
vehicleNo: string
|
||||
planNo?: string
|
||||
productId?: string
|
||||
cargoName: string
|
||||
specification?: string
|
||||
businessType: '入库' | '出库'
|
||||
batchNo?: string
|
||||
supplier: string
|
||||
consignee: string
|
||||
grossWeight: number
|
||||
grossWeightTime: string
|
||||
tareWeight: number
|
||||
tareWeightTime: string
|
||||
deduction?: number
|
||||
netWeight: number
|
||||
plannedQuantity?: number
|
||||
executedQuantity?: number
|
||||
purchasePlanId?: string
|
||||
inboundOutboundId?: string
|
||||
weighingPoint?: string
|
||||
remarks?: string
|
||||
currentQuantity?: number
|
||||
isVoided: number
|
||||
createdAt: string
|
||||
inventoryDate?: string
|
||||
}
|
||||
|
||||
export interface WeighingCreate {
|
||||
vehicleNo: string
|
||||
planNo?: string
|
||||
productId: string
|
||||
businessType: '入库' | '出库'
|
||||
batchNo?: string
|
||||
supplier: string
|
||||
consignee: string
|
||||
grossWeight: number
|
||||
grossWeightTime: string
|
||||
tareWeight: number
|
||||
tareWeightTime: string
|
||||
deduction?: number
|
||||
netWeight: number
|
||||
plannedQuantity?: number
|
||||
executedQuantity?: number
|
||||
purchasePlanId?: string
|
||||
inboundOutboundId?: string
|
||||
weighingPoint?: string
|
||||
remarks?: string
|
||||
currentQuantity?: number
|
||||
}
|
||||
|
||||
export interface WeighingUpdate {
|
||||
vehicleNo?: string
|
||||
planNo?: string
|
||||
productId?: string
|
||||
cargoName?: string
|
||||
specification?: string
|
||||
businessType?: '入库' | '出库'
|
||||
batchNo?: string
|
||||
supplier?: string
|
||||
consignee?: string
|
||||
grossWeight?: number
|
||||
grossWeightTime?: string
|
||||
tareWeight?: number
|
||||
tareWeightTime?: string
|
||||
deduction?: number
|
||||
netWeight?: number
|
||||
plannedQuantity?: number
|
||||
executedQuantity?: number
|
||||
purchasePlanId?: string
|
||||
inboundOutboundId?: string
|
||||
weighingPoint?: string
|
||||
remarks?: string
|
||||
currentQuantity?: number
|
||||
isVoided?: number
|
||||
}
|
||||
|
||||
export interface WeighingRelatedData {
|
||||
plan: Plan | null
|
||||
inventory: InventoryRecord | null
|
||||
}
|
||||
|
||||
export interface WeighingListParams {
|
||||
q?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
planNo?: string
|
||||
batchNo?: string
|
||||
vehicleNo?: string
|
||||
cargoName?: string
|
||||
supplier?: string
|
||||
consignee?: string
|
||||
businessType?: '入库' | '出库'
|
||||
}
|
||||
|
||||
export const weighingApi = {
|
||||
list(params?: WeighingListParams) {
|
||||
return dbRequest.get<WeighingRecord[]>('/weighing', { params })
|
||||
},
|
||||
get(id: string) {
|
||||
return dbRequest.get<WeighingRecord>(`/weighing/${id}`)
|
||||
},
|
||||
getRelated(id: string) {
|
||||
return dbRequest.get<WeighingRelatedData>(`/weighing/${id}/related`)
|
||||
},
|
||||
create(payload: WeighingCreate) {
|
||||
return dbRequest.post<WeighingRecord>('/weighing', payload)
|
||||
},
|
||||
update(id: string, payload: WeighingUpdate) {
|
||||
return dbRequest.patch<WeighingRecord>(`/weighing/${id}`, payload)
|
||||
},
|
||||
void(id: string) {
|
||||
return dbRequest.post<void>(`/weighing/${id}/void`)
|
||||
},
|
||||
delete(id: string) {
|
||||
return dbRequest.delete<void>(`/weighing/${id}`)
|
||||
}
|
||||
}
|
||||
@@ -30,3 +30,6 @@
|
||||
|
||||
// 报销模块(bx)页面样式(从 bx-web 迁移,与 Fresh Mint Token 对齐)
|
||||
@use '../../styles/bx/index.scss';
|
||||
|
||||
// 地磅(db)模块 Fresh Mint 公共样式
|
||||
@use '../../styles/db-module.scss';
|
||||
@@ -121,16 +121,14 @@ const props = withDefaults(
|
||||
accept?: string
|
||||
hint?: string
|
||||
disabled?: boolean
|
||||
maxSizeMb?: number
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
folder: 'crm/customer',
|
||||
limit: 15,
|
||||
accept: 'image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar',
|
||||
hint: '支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB',
|
||||
disabled: false,
|
||||
maxSizeMb: 20
|
||||
hint: '支持图片、视频、PDF、Word、Excel等文件',
|
||||
disabled: false
|
||||
}
|
||||
)
|
||||
|
||||
@@ -215,13 +213,6 @@ async function handleAttachmentChange(uploadFile: UploadFile) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxBytes = props.maxSizeMb * 1024 * 1024
|
||||
if (rawFile.size > maxBytes) {
|
||||
ElMessage.error(`单文件不能超过 ${props.maxSizeMb}MB`)
|
||||
uploadRef.value?.clearFiles()
|
||||
return
|
||||
}
|
||||
|
||||
const item: AttachmentItem = {
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
fileName: rawFile.name,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<el-select
|
||||
v-model="model"
|
||||
:placeholder="placeholder"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchRemote"
|
||||
:loading="loading"
|
||||
:allow-create="allowCreate"
|
||||
default-first-option
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@focus="handleFocus"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in options"
|
||||
:key="item.id"
|
||||
:label="item.customerName"
|
||||
:value="item.customerName"
|
||||
>
|
||||
<div class="db-crm-option">
|
||||
<span>{{ item.customerName }}</span>
|
||||
<span v-if="item.customerNo" class="db-crm-option__meta">{{ item.customerNo }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fetchCustomerList } from '@/api/crm/customer'
|
||||
|
||||
defineOptions({ name: 'DbCrmCustomerSelect' })
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string
|
||||
allowCreate?: boolean
|
||||
}>(),
|
||||
{
|
||||
placeholder: '请输入客户名称搜索',
|
||||
allowCreate: true
|
||||
}
|
||||
)
|
||||
|
||||
interface CustomerOption {
|
||||
id: string
|
||||
customerName: string
|
||||
customerNo?: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const options = ref<CustomerOption[]>([])
|
||||
|
||||
const extractList = (res: unknown): CustomerOption[] => {
|
||||
const data = (res as { data?: { list?: CustomerOption[]; rows?: CustomerOption[] } })?.data ?? res ?? {}
|
||||
const payload = data as { list?: CustomerOption[]; rows?: CustomerOption[] }
|
||||
return payload.list || payload.rows || []
|
||||
}
|
||||
|
||||
const loadDefault = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchCustomerList({ page: 1, pageSize: 20 })
|
||||
options.value = extractList(res)
|
||||
} catch (error) {
|
||||
console.error('加载 CRM 客户列表失败', error)
|
||||
options.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchRemote = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
await loadDefault()
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchCustomerList({
|
||||
customerName: query.trim(),
|
||||
page: 1,
|
||||
pageSize: 50
|
||||
})
|
||||
options.value = extractList(res)
|
||||
} catch (error) {
|
||||
console.error('搜索 CRM 客户失败', error)
|
||||
options.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (options.value.length === 0) {
|
||||
loadDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.db-crm-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.db-crm-option__meta {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="db-crm-product-select">
|
||||
<el-select
|
||||
v-model="productId"
|
||||
:placeholder="placeholder"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchRemote"
|
||||
:loading="loading"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@focus="handleFocus"
|
||||
@change="handleProductChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in options"
|
||||
:key="item.id"
|
||||
:label="item.productName"
|
||||
:value="item.id"
|
||||
>
|
||||
<div class="db-crm-option">
|
||||
<span>{{ item.productName }}</span>
|
||||
<span v-if="formatProductMeta(item)" class="db-crm-option__meta">{{
|
||||
formatProductMeta(item)
|
||||
}}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fetchProductDetail, fetchProductList } from '@/api/crm/product'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
|
||||
defineOptions({ name: 'DbCrmProductSelect' })
|
||||
|
||||
const productId = defineModel<string>('productId', { default: '' })
|
||||
const productName = defineModel<string>('productName', { default: '' })
|
||||
const specification = defineModel<string>('specification', { default: '' })
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string
|
||||
}>(),
|
||||
{
|
||||
placeholder: '请搜索并选择 CRM 产品'
|
||||
}
|
||||
)
|
||||
|
||||
interface ProductOption {
|
||||
id: string
|
||||
productName: string
|
||||
model?: string
|
||||
unit?: string
|
||||
}
|
||||
|
||||
const dictStore = useDictStore()
|
||||
const loading = ref(false)
|
||||
const options = ref<ProductOption[]>([])
|
||||
|
||||
const formatProductMeta = (item: ProductOption): string => {
|
||||
const model = item.model?.trim() || ''
|
||||
const unitRaw = item.unit?.trim() || ''
|
||||
const unit = unitRaw ? dictStore.getLabel('crm_product_unit', unitRaw) : ''
|
||||
if (model && unit) return `${model} / ${unit}`
|
||||
return model || unit || ''
|
||||
}
|
||||
|
||||
const extractList = (res: unknown): ProductOption[] => {
|
||||
const data =
|
||||
(res as { data?: { list?: ProductOption[]; rows?: ProductOption[] } })?.data ?? res ?? {}
|
||||
const payload = data as { list?: ProductOption[]; rows?: ProductOption[] }
|
||||
return payload.list || payload.rows || []
|
||||
}
|
||||
|
||||
const syncFromOption = (item: ProductOption | undefined) => {
|
||||
if (!item) {
|
||||
productName.value = ''
|
||||
specification.value = ''
|
||||
return
|
||||
}
|
||||
productName.value = item.productName
|
||||
specification.value = item.model?.trim() || ''
|
||||
}
|
||||
|
||||
const ensureCurrentOption = async () => {
|
||||
const id = productId.value?.trim()
|
||||
if (!id) return
|
||||
if (options.value.some((item) => item.id === id)) return
|
||||
try {
|
||||
const res = await fetchProductDetail({ id })
|
||||
const detail = (res as { data?: ProductOption })?.data ?? (res as ProductOption)
|
||||
if (detail?.id) {
|
||||
options.value = [detail, ...options.value]
|
||||
syncFromOption(detail)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载 CRM 产品详情失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadDefault = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchProductList({ page: 1, pageSize: 20 })
|
||||
options.value = extractList(res)
|
||||
await ensureCurrentOption()
|
||||
} catch (error) {
|
||||
console.error('加载 CRM 产品列表失败', error)
|
||||
options.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchRemote = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
await loadDefault()
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchProductList({
|
||||
keyword: query.trim(),
|
||||
page: 1,
|
||||
pageSize: 50
|
||||
})
|
||||
options.value = extractList(res)
|
||||
await ensureCurrentOption()
|
||||
} catch (error) {
|
||||
console.error('搜索 CRM 产品失败', error)
|
||||
options.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (options.value.length === 0) {
|
||||
loadDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const handleProductChange = (value: string) => {
|
||||
const item = options.value.find((option) => option.id === value)
|
||||
syncFromOption(item)
|
||||
}
|
||||
|
||||
watch(productId, async (value) => {
|
||||
if (!value) {
|
||||
syncFromOption(undefined)
|
||||
return
|
||||
}
|
||||
await ensureCurrentOption()
|
||||
handleProductChange(value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (productId.value) {
|
||||
ensureCurrentOption()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.db-crm-product-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.db-crm-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.db-crm-option__meta {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="db-quick-dates" role="group" aria-label="日期快捷筛选">
|
||||
<el-button
|
||||
v-for="item in options"
|
||||
:key="item.value"
|
||||
:class="{ 'is-active': modelValue === item.value }"
|
||||
@click="emit('update:modelValue', item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export type DbDateFilterType = 'all' | 'today' | 'tomorrow' | 'yesterday' | 'week'
|
||||
|
||||
defineProps<{
|
||||
modelValue: DbDateFilterType | ''
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: DbDateFilterType]
|
||||
}>()
|
||||
|
||||
const options: { label: string; value: DbDateFilterType }[] = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '明日', value: 'tomorrow' },
|
||||
{ label: '昨日', value: 'yesterday' },
|
||||
{ label: '本周', value: 'week' }
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="innerVisible"
|
||||
title="查看关联数据"
|
||||
width="1200px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
class="dialog-form-layout db-related-table-dialog"
|
||||
modal-class="order-module-dialog-mask"
|
||||
>
|
||||
<div v-loading="loading" class="db-related-body">
|
||||
<el-empty v-if="!hasData" description="暂无关联数据" />
|
||||
<template v-else>
|
||||
<section v-if="data.plan" class="db-related-table-section">
|
||||
<h3 class="db-related-table-title">计划</h3>
|
||||
<div class="view-subtable-wrap">
|
||||
<el-table :data="planRows" class="view-subtable list-table" stripe>
|
||||
<el-table-column prop="planNo" label="计划号" min-width="120" align="center" />
|
||||
<el-table-column prop="businessType" label="业务类型" min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="businessTypeTagType(row.businessType)" size="small">
|
||||
{{ row.businessType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="customerName"
|
||||
label="往来单位"
|
||||
min-width="150"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="product"
|
||||
label="产品"
|
||||
min-width="120"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="planDate" label="计划日期" min-width="120" align="center" />
|
||||
<el-table-column prop="startDate" label="起始日期" min-width="120" align="center" />
|
||||
<el-table-column prop="endDate" label="截止日期" min-width="120" align="center" />
|
||||
<el-table-column prop="executionStatus" label="执行状态" min-width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="planExecutionTagType(row.executionStatus)" size="small">
|
||||
{{ row.executionStatus || '—' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="plannedQuantity" label="计划量(吨)" min-width="110" align="right" />
|
||||
<el-table-column label="已执行量(吨)" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ formatKgToTon(row.executedQuantity) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="remarks"
|
||||
label="备注"
|
||||
min-width="150"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="data.weighing" class="db-related-table-section">
|
||||
<h3 class="db-related-table-title">地磅单</h3>
|
||||
<div class="view-subtable-wrap">
|
||||
<el-table :data="weighingRows" class="view-subtable list-table" stripe>
|
||||
<el-table-column
|
||||
prop="batchNo"
|
||||
label="出入库单号"
|
||||
min-width="130"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="vehicleNo" label="车牌号" min-width="110" align="center" />
|
||||
<el-table-column
|
||||
prop="cargoName"
|
||||
label="货物名称"
|
||||
min-width="120"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="businessType" label="业务类型" min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="businessTypeTagType(row.businessType)" size="small">
|
||||
{{ row.businessType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="supplier"
|
||||
label="供货单位"
|
||||
min-width="150"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="consignee"
|
||||
label="收货单位"
|
||||
min-width="150"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="grossWeight" label="毛重(kg)" min-width="110" align="right" />
|
||||
<el-table-column
|
||||
prop="grossWeightTime"
|
||||
label="毛重时间"
|
||||
min-width="160"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="tareWeight" label="皮重(kg)" min-width="110" align="right" />
|
||||
<el-table-column
|
||||
prop="tareWeightTime"
|
||||
label="皮重时间"
|
||||
min-width="160"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="deduction" label="扣杂(kg)" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.deduction ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="netWeight" label="净重(kg)" min-width="110" align="right" />
|
||||
<el-table-column
|
||||
prop="remarks"
|
||||
label="备注"
|
||||
min-width="150"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="innerVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
关闭
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import type { InventoryRelatedData } from '@/api/db/inventory'
|
||||
import {
|
||||
businessTypeTagType,
|
||||
formatKgToTon,
|
||||
planExecutionTagType
|
||||
} from '@/components/business/db/db-related-view'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
loading?: boolean
|
||||
data: InventoryRelatedData
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
}>()
|
||||
|
||||
const innerVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const hasData = computed(() => Boolean(props.data.plan || props.data.weighing))
|
||||
|
||||
const planRows = computed(() => (props.data.plan ? [props.data.plan] : []))
|
||||
|
||||
const weighingRows = computed(() => (props.data.weighing ? [props.data.weighing] : []))
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="innerVisible"
|
||||
title="查看关联数据"
|
||||
width="1200px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
class="dialog-form-layout db-related-table-dialog"
|
||||
modal-class="order-module-dialog-mask"
|
||||
>
|
||||
<div v-loading="loading" class="db-related-body">
|
||||
<el-empty v-if="!hasData" description="暂无关联数据" />
|
||||
<template v-else>
|
||||
<section v-if="data.inventories.length" class="db-related-table-section">
|
||||
<h3 class="db-related-table-title">出入库单</h3>
|
||||
<div class="view-subtable-wrap">
|
||||
<el-table :data="data.inventories" class="view-subtable list-table" stripe>
|
||||
<el-table-column type="index" label="序号" min-width="60" align="center" />
|
||||
<el-table-column
|
||||
prop="batchNo"
|
||||
label="出入库单号"
|
||||
min-width="130"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="date" label="日期" min-width="120" align="center" />
|
||||
<el-table-column
|
||||
prop="partnerCompany"
|
||||
label="往来单位"
|
||||
min-width="150"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="businessType" label="业务类型" min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="businessTypeTagType(row.businessType)" size="small">
|
||||
{{ row.businessType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="product"
|
||||
label="产品"
|
||||
min-width="120"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="vehicleNo" label="车牌号" min-width="110" align="center" />
|
||||
<el-table-column prop="quantity" label="数量(吨)" min-width="110" align="right" />
|
||||
<el-table-column prop="status" label="状态" min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="inventoryStatusTagType(row.status)" size="small">
|
||||
{{ row.status || '待处理' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="remarks"
|
||||
label="备注"
|
||||
min-width="150"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="data.weighings.length" class="db-related-table-section">
|
||||
<h3 class="db-related-table-title">地磅单</h3>
|
||||
<div class="view-subtable-wrap">
|
||||
<el-table :data="data.weighings" class="view-subtable list-table" stripe>
|
||||
<el-table-column type="index" label="序号" min-width="60" align="center" />
|
||||
<el-table-column
|
||||
prop="batchNo"
|
||||
label="出入库单号"
|
||||
min-width="130"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="vehicleNo" label="车牌号" min-width="110" align="center" />
|
||||
<el-table-column
|
||||
prop="cargoName"
|
||||
label="货物名称"
|
||||
min-width="120"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="businessType" label="业务类型" min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="businessTypeTagType(row.businessType)" size="small">
|
||||
{{ row.businessType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="grossWeight" label="毛重(kg)" min-width="110" align="right" />
|
||||
<el-table-column
|
||||
prop="grossWeightTime"
|
||||
label="毛重时间"
|
||||
min-width="160"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="tareWeight" label="皮重(kg)" min-width="110" align="right" />
|
||||
<el-table-column
|
||||
prop="tareWeightTime"
|
||||
label="皮重时间"
|
||||
min-width="160"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="netWeight" label="净重(kg)" min-width="110" align="right" />
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="innerVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
关闭
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import type { PlanRelatedData } from '@/api/db/plans'
|
||||
import { businessTypeTagType, inventoryStatusTagType } from '@/components/business/db/db-related-view'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
loading?: boolean
|
||||
data: PlanRelatedData
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
}>()
|
||||
|
||||
const innerVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const hasData = computed(
|
||||
() => props.data.inventories.length > 0 || props.data.weighings.length > 0
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<SystemViewDialog
|
||||
v-model:visible="innerVisible"
|
||||
title="查看关联数据"
|
||||
:icon="Link"
|
||||
:sections="sections"
|
||||
:loading="loading"
|
||||
width="1200px"
|
||||
modal-class="order-module-dialog-mask"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* UI 模式 P09 · 标准查看弹窗
|
||||
* 参考:SystemViewDialog · src/views/system/express/index.vue
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { Link } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
loading?: boolean
|
||||
sections: ViewSection[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void
|
||||
}>()
|
||||
|
||||
const innerVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<el-select
|
||||
v-model="model"
|
||||
:placeholder="placeholder"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="loadOptions"
|
||||
:loading="loading"
|
||||
:allow-create="allowCreate"
|
||||
default-first-option
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@focus="handleFocus"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in options"
|
||||
:key="item.id"
|
||||
:label="item.vehicleNo"
|
||||
:value="item.vehicleNo"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { vehicleApi, type Vehicle } from '@/api/db/vehicles'
|
||||
|
||||
defineOptions({ name: 'DbVehicleSelect' })
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string
|
||||
allowCreate?: boolean
|
||||
}>(),
|
||||
{
|
||||
placeholder: '请选择或输入车牌号',
|
||||
allowCreate: true
|
||||
}
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
const options = ref<Vehicle[]>([])
|
||||
|
||||
const loadOptions = async (keyword: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
options.value = await vehicleApi.list(keyword || undefined)
|
||||
} catch (error) {
|
||||
console.error('加载车牌号列表失败', error)
|
||||
options.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (options.value.length === 0) {
|
||||
loadOptions('')
|
||||
}
|
||||
}
|
||||
|
||||
const isSameVehicleNo = (left: string, right: string) =>
|
||||
left.trim().toLowerCase() === right.trim().toLowerCase()
|
||||
|
||||
const vehicleExists = async (vehicleNo: string) => {
|
||||
const trimmed = vehicleNo.trim()
|
||||
if (options.value.some((item) => isSameVehicleNo(item.vehicleNo, trimmed))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const list = await vehicleApi.list(trimmed)
|
||||
const matched = list.find((item) => isSameVehicleNo(item.vehicleNo, trimmed))
|
||||
if (matched && !options.value.some((item) => item.id === matched.id)) {
|
||||
options.value = [...options.value, matched]
|
||||
}
|
||||
return Boolean(matched)
|
||||
}
|
||||
|
||||
/** 保存前确保车牌号已写入基础资料(已存在则跳过,与原地磅系统一致) */
|
||||
const ensureVehicleExists = async () => {
|
||||
const vehicleNo = model.value?.trim()
|
||||
if (!vehicleNo) return
|
||||
if (await vehicleExists(vehicleNo)) return
|
||||
|
||||
try {
|
||||
await vehicleApi.create({ vehicleNo, remarks: null }, { showErrorMessage: false })
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status !== 409) throw err
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ ensureVehicleExists })
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="db-chart-card">
|
||||
<h3 class="db-chart-title">{{ title }}</h3>
|
||||
<div class="db-dual-bar__body">
|
||||
<el-empty v-if="!data.length" description="今日无数据" />
|
||||
<template v-else>
|
||||
<div class="legend">
|
||||
<span class="legend-item">
|
||||
<span class="legend-color planned" />
|
||||
计划量
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="legend-color actual" />
|
||||
实际量
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="item in data" :key="item.label" class="chart-item">
|
||||
<span class="chart-label">{{ item.label }}</span>
|
||||
<div class="chart-bar-group">
|
||||
<div class="chart-bar-wrapper">
|
||||
<div
|
||||
class="chart-bar planned"
|
||||
:style="{
|
||||
width: maxValue ? (item.planned / maxValue) * 100 + '%' : '0%',
|
||||
minWidth: item.planned > 0 ? '4px' : '0'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="chart-bar-wrapper">
|
||||
<div
|
||||
class="chart-bar actual"
|
||||
:style="{
|
||||
width: maxValue ? (item.actual / maxValue) * 100 + '%' : '0%',
|
||||
minWidth: item.actual > 0 ? '4px' : '0'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-values">
|
||||
<span class="value planned">{{ item.planned.toFixed(2) }}</span>
|
||||
<span class="value actual">{{ item.actual.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface DualBarItem {
|
||||
label: string
|
||||
planned: number
|
||||
actual: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
data: DualBarItem[]
|
||||
}>()
|
||||
|
||||
const maxValue = computed(() => {
|
||||
if (!props.data.length) return 0
|
||||
const maxPlanned = Math.max(...props.data.map((item) => item.planned))
|
||||
const maxActual = Math.max(...props.data.map((item) => item.actual))
|
||||
return Math.max(maxPlanned, maxActual) || 0
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Plan, PlanInventoryRecord, PlanRelatedData, PlanWeighingRecord } from '@/api/db/plans'
|
||||
import type { InventoryRecord, InventoryRelatedData, InventoryWeighingRecord } from '@/api/db/inventory'
|
||||
import type { WeighingRelatedData } from '@/api/db/weighing'
|
||||
import type { TagType, ViewField, ViewSection } from '@/components/business/system-view-dialog/index.vue'
|
||||
|
||||
export function businessTypeTagType(businessType: string): TagType {
|
||||
return businessType === '入库' ? 'success' : 'warning'
|
||||
}
|
||||
|
||||
export function inventoryStatusTagType(status?: string): TagType {
|
||||
if (status === '已完成') return 'success'
|
||||
if (status === '待入库' || status === '待出库') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function planExecutionTagType(status?: string): TagType {
|
||||
if (status === '已入库' || status === '已出库') return 'success'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
export function formatKgToTon(kg: number | undefined | null): string {
|
||||
if (kg === undefined || kg === null) return '—'
|
||||
return (kg / 1000).toFixed(2)
|
||||
}
|
||||
|
||||
function sectionTitle(base: string, index: number, total: number): string {
|
||||
return total > 1 ? `${base}(${index + 1}/${total})` : base
|
||||
}
|
||||
|
||||
export function buildPlanViewSection(plan: Plan): ViewSection {
|
||||
return {
|
||||
title: '计划',
|
||||
fields: [
|
||||
{ label: '计划号', value: plan.planNo },
|
||||
{ label: '业务类型', value: plan.businessType, tagType: businessTypeTagType(plan.businessType) },
|
||||
{ label: '往来单位', value: plan.customerName },
|
||||
{ label: '产品', value: plan.product },
|
||||
{ label: '规格', value: plan.specification },
|
||||
{ label: '计划日期', value: plan.planDate },
|
||||
{ label: '起始日期', value: plan.startDate },
|
||||
{ label: '截止日期', value: plan.endDate },
|
||||
{
|
||||
label: '执行状态',
|
||||
value: plan.executionStatus,
|
||||
tagType: planExecutionTagType(plan.executionStatus)
|
||||
},
|
||||
{ label: '计划量(吨)', value: plan.plannedQuantity },
|
||||
{ label: '已执行量(吨)', value: formatKgToTon(plan.executedQuantity) },
|
||||
{ label: '备注', value: plan.remarks, span: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInventoryViewSection(
|
||||
record: PlanInventoryRecord | InventoryRecord,
|
||||
title = '出入库单'
|
||||
): ViewSection {
|
||||
return {
|
||||
title,
|
||||
fields: [
|
||||
{ label: '出入库单号', value: record.batchNo },
|
||||
{ label: '日期', value: record.date },
|
||||
{ label: '往来单位', value: record.partnerCompany },
|
||||
{
|
||||
label: '业务类型',
|
||||
value: record.businessType,
|
||||
tagType: businessTypeTagType(record.businessType)
|
||||
},
|
||||
{ label: '产品', value: record.product },
|
||||
{ label: '规格', value: record.specification },
|
||||
{ label: '车牌号', value: record.vehicleNo },
|
||||
{ label: '数量(吨)', value: record.quantity },
|
||||
{
|
||||
label: '状态',
|
||||
value: record.status || '待处理',
|
||||
tagType: inventoryStatusTagType(record.status)
|
||||
},
|
||||
{ label: '备注', value: record.remarks, span: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWeighingViewSection(
|
||||
record: PlanWeighingRecord | InventoryWeighingRecord,
|
||||
title = '地磅单'
|
||||
): ViewSection {
|
||||
const fields: ViewField[] = [
|
||||
{ label: '出入库单号', value: record.batchNo },
|
||||
{ label: '车牌号', value: record.vehicleNo },
|
||||
{ label: '货物名称', value: record.cargoName },
|
||||
{ label: '规格', value: 'specification' in record ? record.specification : undefined },
|
||||
{
|
||||
label: '业务类型',
|
||||
value: record.businessType,
|
||||
tagType: businessTypeTagType(record.businessType)
|
||||
}
|
||||
]
|
||||
|
||||
if ('supplier' in record) {
|
||||
fields.push(
|
||||
{ label: '供货单位', value: record.supplier },
|
||||
{ label: '收货单位', value: record.consignee }
|
||||
)
|
||||
}
|
||||
|
||||
fields.push(
|
||||
{ label: '毛重(kg)', value: record.grossWeight },
|
||||
{ label: '毛重时间', value: record.grossWeightTime },
|
||||
{ label: '皮重(kg)', value: record.tareWeight },
|
||||
{ label: '皮重时间', value: record.tareWeightTime }
|
||||
)
|
||||
|
||||
if ('deduction' in record) {
|
||||
fields.push({ label: '扣杂(kg)', value: record.deduction || 0 })
|
||||
}
|
||||
|
||||
fields.push({ label: '净重(kg)', value: record.netWeight })
|
||||
|
||||
if ('remarks' in record) {
|
||||
fields.push({ label: '备注', value: record.remarks, span: true })
|
||||
}
|
||||
|
||||
return { title, fields }
|
||||
}
|
||||
|
||||
type MergedPlanRelatedItem = (PlanInventoryRecord | PlanWeighingRecord) & {
|
||||
type: 'inventory' | 'weighing'
|
||||
sortTime: string
|
||||
}
|
||||
|
||||
function mergePlanRelatedItems(data: PlanRelatedData): MergedPlanRelatedItem[] {
|
||||
const items: MergedPlanRelatedItem[] = []
|
||||
|
||||
data.inventories.forEach((inv) => {
|
||||
items.push({
|
||||
...inv,
|
||||
type: 'inventory',
|
||||
sortTime: inv.createdAt || inv.date || ''
|
||||
})
|
||||
})
|
||||
|
||||
data.weighings.forEach((w) => {
|
||||
items.push({
|
||||
...w,
|
||||
type: 'weighing',
|
||||
sortTime: w.grossWeightTime || w.createdAt || ''
|
||||
})
|
||||
})
|
||||
|
||||
return items.sort((a, b) => {
|
||||
const timeA = a.sortTime ? new Date(a.sortTime).getTime() : 0
|
||||
const timeB = b.sortTime ? new Date(b.sortTime).getTime() : 0
|
||||
if (timeA === 0 && timeB === 0) return 0
|
||||
if (timeA === 0) return 1
|
||||
if (timeB === 0) return -1
|
||||
return timeB - timeA
|
||||
})
|
||||
}
|
||||
|
||||
export function buildPlanRelatedSections(data: PlanRelatedData): ViewSection[] {
|
||||
const items = mergePlanRelatedItems(data)
|
||||
if (!items.length) {
|
||||
return [{ title: '关联数据', fields: [{ label: '说明', value: '暂无关联数据', span: true }] }]
|
||||
}
|
||||
|
||||
const inventoryCount = items.filter((item) => item.type === 'inventory').length
|
||||
const weighingCount = items.filter((item) => item.type === 'weighing').length
|
||||
let inventoryIndex = 0
|
||||
let weighingIndex = 0
|
||||
|
||||
return items.map((item) => {
|
||||
if (item.type === 'inventory') {
|
||||
const title = sectionTitle('出入库单', inventoryIndex, inventoryCount)
|
||||
inventoryIndex += 1
|
||||
return buildInventoryViewSection(item, title)
|
||||
}
|
||||
const title = sectionTitle('地磅单', weighingIndex, weighingCount)
|
||||
weighingIndex += 1
|
||||
return buildWeighingViewSection(item, title)
|
||||
})
|
||||
}
|
||||
|
||||
export function buildInventoryRelatedSections(data: InventoryRelatedData): ViewSection[] {
|
||||
const sections: ViewSection[] = []
|
||||
if (data.plan) sections.push(buildPlanViewSection(data.plan))
|
||||
if (data.weighing) sections.push(buildWeighingViewSection(data.weighing))
|
||||
if (!sections.length) {
|
||||
return [{ title: '关联数据', fields: [{ label: '说明', value: '暂无关联数据', span: true }] }]
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
export function buildWeighingRelatedSections(data: WeighingRelatedData): ViewSection[] {
|
||||
const sections: ViewSection[] = []
|
||||
if (data.plan) sections.push(buildPlanViewSection(data.plan))
|
||||
if (data.inventory) sections.push(buildInventoryViewSection(data.inventory))
|
||||
if (!sections.length) {
|
||||
return [{ title: '关联数据', fields: [{ label: '说明', value: '暂无关联数据', span: true }] }]
|
||||
}
|
||||
return sections
|
||||
}
|
||||
@@ -116,14 +116,12 @@ const props = withDefaults(
|
||||
hint?: string
|
||||
showHint?: boolean
|
||||
disabled?: boolean
|
||||
maxSizeMb?: number
|
||||
}>(),
|
||||
{
|
||||
bizId: 0,
|
||||
hint: '支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB',
|
||||
hint: '支持图片、视频、PDF、Word、Excel等文件',
|
||||
showHint: true,
|
||||
disabled: false,
|
||||
maxSizeMb: 20
|
||||
disabled: false
|
||||
}
|
||||
)
|
||||
|
||||
@@ -151,13 +149,6 @@ async function handleAttachmentChange(uploadFile: UploadFile) {
|
||||
const rawFile = uploadFile.raw
|
||||
if (!rawFile) return
|
||||
|
||||
const maxBytes = props.maxSizeMb * 1024 * 1024
|
||||
if (rawFile.size > maxBytes) {
|
||||
ElMessage.error(`单文件不能超过 ${props.maxSizeMb}MB`)
|
||||
uploadRef.value?.clearFiles()
|
||||
return
|
||||
}
|
||||
|
||||
const item: BxAttachmentItem = {
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
fileName: rawFile.name,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* 地磅列表筛选项变更后防抖触发检索(即时检索,默认 300ms)
|
||||
*/
|
||||
export function useDbInstantSearch(getFilterSnapshot: () => unknown, onSearch: () => void, debounce = 300) {
|
||||
watchDebounced(
|
||||
getFilterSnapshot,
|
||||
() => {
|
||||
onSearch()
|
||||
},
|
||||
{ debounce, deep: true }
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
|
||||
/**
|
||||
* 前端路由(控制台、报销管理、CRM 管理、工作流管理、系统管理)
|
||||
* 与后端菜单对齐时请同步 `backend/app/api/menu.py` 与 `sys_menu`(bx 见 docs/sql/20260606_bx_menu.sql)。
|
||||
* 前端路由(控制台、报销管理、CRM 管理、地磅管理、工作流管理、系统管理)
|
||||
* 与后端菜单对齐时请同步 `sys_menu`(bx 见 docs/sql/20260606_bx_menu.sql;db 见 docs/sql/20260607_db_menu.sql)。
|
||||
*/
|
||||
export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
// ──────────────────────────────────────────────
|
||||
@@ -426,7 +426,80 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 4. 工作流管理
|
||||
// 4. 地磅管理(db)
|
||||
// ──────────────────────────────────────────────
|
||||
{
|
||||
id: 'DB00000000000000000000000000001',
|
||||
name: 'DbManagement',
|
||||
path: '/db',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '地磅管理',
|
||||
icon: 'ri:scales-3-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'DB00000000000000000000000000002',
|
||||
name: 'DbDashboard',
|
||||
path: 'dashboard',
|
||||
component: '/db/dashboard/index',
|
||||
meta: {
|
||||
title: '仪表盘',
|
||||
keepAlive: true,
|
||||
menuCode: 'db:dashboard:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'DB00000000000000000000000000003',
|
||||
name: 'DbPlans',
|
||||
path: 'plans',
|
||||
component: '/db/plans/index',
|
||||
meta: {
|
||||
title: '计划管理',
|
||||
keepAlive: true,
|
||||
menuCode: 'db:plan:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'DB00000000000000000000000000004',
|
||||
name: 'DbInventory',
|
||||
path: 'inventory',
|
||||
component: '/db/inventory/index',
|
||||
meta: {
|
||||
title: '出入库管理',
|
||||
keepAlive: true,
|
||||
menuCode: 'db:inventory:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'DB00000000000000000000000000005',
|
||||
name: 'DbWeighing',
|
||||
path: 'weighing',
|
||||
component: '/db/weighing/index',
|
||||
meta: {
|
||||
title: '地磅单管理',
|
||||
keepAlive: true,
|
||||
menuCode: 'db:weighing:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'DB00000000000000000000000000006',
|
||||
name: 'DbVehicles',
|
||||
path: 'vehicles',
|
||||
component: '/db/vehicles/index',
|
||||
meta: {
|
||||
title: '车牌管理',
|
||||
keepAlive: true,
|
||||
menuCode: 'db:vehicle:list'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 5. 工作流管理
|
||||
// ──────────────────────────────────────────────
|
||||
{
|
||||
id: 'WKF00000000000000000000000000001',
|
||||
@@ -500,7 +573,7 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
},
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 5. 系统管理
|
||||
// 6. 系统管理
|
||||
// ──────────────────────────────────────────────
|
||||
{
|
||||
id: 'SYSM000000000000000000000000000001',
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
/* 地磅(db)模块 Fresh Mint 公共样式 */
|
||||
|
||||
/* 列表工具栏(对齐计划管理:单行不换行) */
|
||||
.page-container .list-toolbar-row.db-list-toolbar-row {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
> .list-toolbar-selected,
|
||||
> .list-toolbar-date-shortcuts,
|
||||
> .el-input,
|
||||
> .el-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-toolbar-date-shortcuts {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.db-toolbar-quick-search-input.el-input {
|
||||
width: 360px;
|
||||
max-width: 360px;
|
||||
flex: 0 0 360px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-toolbar-date-shortcuts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding-right: 8px;
|
||||
margin-right: 4px;
|
||||
border-right: 1px solid var(--mint-border, #d9e8e2);
|
||||
}
|
||||
|
||||
.list-toolbar-date-shortcuts .date-shortcut-btn.el-button {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-toolbar-date-shortcuts .date-shortcut-btn.is-active.el-button {
|
||||
color: var(--mint-primary-dark, #059669) !important;
|
||||
background: var(--mint-primary-bg, #ecfdf5) !important;
|
||||
border-color: rgba(16, 185, 129, 0.35) !important;
|
||||
--el-button-text-color: var(--mint-primary-dark, #059669);
|
||||
--el-button-bg-color: var(--mint-primary-bg, #ecfdf5);
|
||||
--el-button-border-color: rgba(16, 185, 129, 0.35);
|
||||
--el-button-hover-text-color: var(--mint-primary-dark, #059669);
|
||||
--el-button-hover-bg-color: var(--mint-primary-bg, #ecfdf5);
|
||||
--el-button-hover-border-color: rgba(16, 185, 129, 0.45);
|
||||
}
|
||||
|
||||
.db-quick-search-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.db-op-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.db-quick-dates {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
|
||||
.el-button {
|
||||
margin: 0;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
box-shadow: none;
|
||||
|
||||
& + .el-button {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--row-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.db-auto-end-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.db-related-body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.db-related-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.db-related-desc {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.db-related-table-dialog {
|
||||
.el-dialog__body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.db-related-table-section {
|
||||
& + & {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.db-related-table-title {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #1e293b);
|
||||
border-left: 3px solid var(--primary, #10b981);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.db-related-table-dialog .view-subtable-wrap {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.db-related-table-dialog .view-subtable-wrap .el-table.view-subtable {
|
||||
--el-table-header-bg-color: #f8fafc;
|
||||
--el-table-header-text-color: #64748b;
|
||||
--el-table-border-color: #e2e8f0;
|
||||
width: 100%;
|
||||
|
||||
.el-table__header th.el-table__cell,
|
||||
.el-table__body td.el-table__cell {
|
||||
font-size: 14px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.el-table__header th.el-table__cell .cell {
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.el-table__body td.el-table__cell .cell {
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
/* 仪表盘图表卡片(避免嵌套 panel-top 与页面主标题重复) */
|
||||
.db-chart-card {
|
||||
height: 100%;
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.db-chart-title {
|
||||
margin: 0;
|
||||
padding: 14px 20px 14px 17px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.db-form-quantity-red.el-form-item .el-form-item__label {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
/* 仪表盘统计卡 */
|
||||
.db-dashboard-body {
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.db-dashboard-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.db-stat-card {
|
||||
text-align: center;
|
||||
padding: 20px 12px;
|
||||
background: var(--primary-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.db-stat-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.db-stat-value {
|
||||
margin: 0;
|
||||
font-size: clamp(18px, 2.4vw, 28px);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.db-stat-suffix {
|
||||
font-size: 14px;
|
||||
margin-left: 4px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.db-dashboard-charts {
|
||||
.el-col {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 双柱图 */
|
||||
.db-dual-bar__body {
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.db-dual-bar .legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.db-dual-bar .legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.db-dual-bar .legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.db-dual-bar .legend-color.planned {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.db-dual-bar .legend-color.actual {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.db-dual-bar .chart-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.db-dual-bar .chart-label {
|
||||
min-width: 80px;
|
||||
max-width: 140px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.db-dual-bar .chart-bar-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.db-dual-bar .chart-bar-wrapper {
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.db-dual-bar .chart-bar {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.db-dual-bar .chart-bar.planned {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.db-dual-bar .chart-bar.actual {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.db-dual-bar .chart-values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 90px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.db-dual-bar .chart-values .value.planned {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.db-dual-bar .chart-values .value.actual {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* 磅单二维码弹窗 — 地磅业务 UI 例外(见 openspec dibang-frontend §磅单二维码弹窗业务例外) */
|
||||
.db-qrcode-dialog {
|
||||
.el-dialog__header {
|
||||
padding: 2px 10px 0;
|
||||
|
||||
.el-dialog__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 6px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.db-qrcode-overlay {
|
||||
background: rgba(0, 0, 0, 0.92) !important;
|
||||
}
|
||||
|
||||
.db-qrcode-body {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline {
|
||||
background: #f5f7fa;
|
||||
padding: 4px 8px;
|
||||
margin: 6px 10px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .capture-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
margin: 10px;
|
||||
padding: 6px 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .capture-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .capture-row-large {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .capture-row .label {
|
||||
color: #6b7280;
|
||||
min-width: 82px;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .capture-row .value {
|
||||
color: #111827;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .capture-row-plate {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .vehicle-plate {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 10px 32px;
|
||||
background: #f5d000;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .vehicle-plate-text {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .vehicle-plate-dot {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .vehicle-plate-dot-lt {
|
||||
left: 6px;
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .vehicle-plate-dot-lb {
|
||||
left: 6px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .vehicle-plate-dot-rt {
|
||||
right: 6px;
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .vehicle-plate-dot-rb {
|
||||
right: 6px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .capture-qrcode {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .capture-id {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .capture-qr-wrap {
|
||||
padding: 8px 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .capture-qr-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pound-slip-capture-inline .capture-desc {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { applyPortalAuthHeader } from '@/utils/http/portal-auth'
|
||||
|
||||
export interface BxApiResponse<T = unknown> {
|
||||
code: number
|
||||
@@ -19,10 +20,7 @@ const bxAxios = axios.create({
|
||||
})
|
||||
|
||||
bxAxios.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = useUserStore().accessToken
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
applyPortalAuthHeader(config)
|
||||
return config
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 地磅(db)模块 HTTP 客户端
|
||||
* 协议:直接 JSON 响应(与原 simple-dibang-xiaoshou 一致,非门户 code/data 信封)
|
||||
*/
|
||||
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { fetchRefreshToken } from '@/api/auth'
|
||||
import { applyPortalAuthHeader } from '@/utils/http/portal-auth'
|
||||
|
||||
type RetryableConfig = InternalAxiosRequestConfig & {
|
||||
_retry?: boolean
|
||||
/** 为 false 时不弹出全局错误提示(由调用方自行处理) */
|
||||
showErrorMessage?: boolean
|
||||
}
|
||||
|
||||
/** 地磅集合根路径(后端 @router.get("/") 注册,须带尾斜杠,否则 307 重定向会丢 Authorization) */
|
||||
const DB_COLLECTION_ROOT = /^\/(plans|inventory|weighing|master-data\/vehicles)$/
|
||||
|
||||
function normalizeDbUrl(url?: string): string | undefined {
|
||||
if (!url || url.endsWith('/')) return url
|
||||
return DB_COLLECTION_ROOT.test(url) ? `${url}/` : url
|
||||
}
|
||||
|
||||
const dbAxios = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 60000,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
let isRefreshing = false
|
||||
let pendingRequests: Array<{
|
||||
resolve: (token: string) => void
|
||||
reject: (error: unknown) => void
|
||||
}> = []
|
||||
|
||||
function subscribeTokenRefresh(
|
||||
resolve: (token: string) => void,
|
||||
reject: (error: unknown) => void
|
||||
) {
|
||||
pendingRequests.push({ resolve, reject })
|
||||
}
|
||||
|
||||
function onRefreshed(newToken: string) {
|
||||
pendingRequests.forEach(({ resolve }) => resolve(newToken))
|
||||
pendingRequests = []
|
||||
}
|
||||
|
||||
function onRefreshFailed(error: unknown) {
|
||||
pendingRequests.forEach(({ reject }) => reject(error))
|
||||
pendingRequests = []
|
||||
}
|
||||
|
||||
dbAxios.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
if (config.url) {
|
||||
config.url = normalizeDbUrl(config.url)
|
||||
}
|
||||
applyPortalAuthHeader(config)
|
||||
return config
|
||||
})
|
||||
|
||||
dbAxios.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
async (err) => {
|
||||
const status = err.response?.status
|
||||
const config = err.config as RetryableConfig | undefined
|
||||
|
||||
if (status === 401 && config && !config._retry) {
|
||||
const userStore = useUserStore()
|
||||
const storedRefreshToken = userStore.refreshToken
|
||||
|
||||
if (storedRefreshToken) {
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
subscribeTokenRefresh(
|
||||
(newToken: string) => {
|
||||
applyPortalAuthHeader(config)
|
||||
config._retry = true
|
||||
resolve(dbAxios.request(config))
|
||||
},
|
||||
(refreshError: unknown) => reject(refreshError)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
isRefreshing = true
|
||||
config._retry = true
|
||||
try {
|
||||
const result = await fetchRefreshToken(storedRefreshToken)
|
||||
userStore.setToken(result.token, result.refreshToken)
|
||||
onRefreshed(result.token)
|
||||
applyPortalAuthHeader(config)
|
||||
return dbAxios.request(config)
|
||||
} catch (refreshError) {
|
||||
onRefreshFailed(refreshError)
|
||||
await userStore.logOut()
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
await userStore.logOut()
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
const showErrorMessage = config?.showErrorMessage !== false
|
||||
if (showErrorMessage) {
|
||||
const detail = err.response?.data?.detail
|
||||
const message =
|
||||
typeof detail === 'string'
|
||||
? detail
|
||||
: Array.isArray(detail)
|
||||
? detail.map((x: { msg?: string }) => x.msg).filter(Boolean).join(';')
|
||||
: err.response?.data?.msg
|
||||
ElMessage.error(message || err.message || '网络异常')
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
function request<T = unknown>(config: AxiosRequestConfig) {
|
||||
return dbAxios.request<any, T>(config)
|
||||
}
|
||||
|
||||
const dbRequest = {
|
||||
get<T = unknown>(url: string, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, method: 'GET' })
|
||||
},
|
||||
post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, method: 'POST', data })
|
||||
},
|
||||
put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, method: 'PUT', data })
|
||||
},
|
||||
patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, method: 'PATCH', data })
|
||||
},
|
||||
delete<T = unknown>(url: string, config?: AxiosRequestConfig) {
|
||||
return request<T>({ ...config, url, method: 'DELETE' })
|
||||
}
|
||||
}
|
||||
|
||||
export default dbRequest
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 门户 JWT 读取与请求头注入(供 db / bx 等非信封协议客户端复用)
|
||||
*/
|
||||
import { unref } from 'vue'
|
||||
import type { InternalAxiosRequestConfig } from 'axios'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { StorageConfig } from '@/utils/storage/storage-config'
|
||||
|
||||
function readTokenFromStorageKey(key: string): string {
|
||||
if (typeof localStorage === 'undefined') return ''
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return ''
|
||||
const parsed = JSON.parse(raw) as { accessToken?: unknown }
|
||||
const token = parsed?.accessToken
|
||||
return typeof token === 'string' ? token.trim() : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 Pinia 或持久化缓存解析 accessToken(setup store / 持久化时序兜底) */
|
||||
export function resolvePortalAccessToken(): string {
|
||||
try {
|
||||
const store = useUserStore()
|
||||
const fromStore = unref(store.accessToken as unknown as string | { value?: string })
|
||||
if (typeof fromStore === 'string' && fromStore.trim()) {
|
||||
return fromStore.trim()
|
||||
}
|
||||
const fromState = store.$state?.accessToken
|
||||
if (typeof fromState === 'string' && fromState.trim()) {
|
||||
return fromState.trim()
|
||||
}
|
||||
} catch {
|
||||
// Pinia 未就绪时走 localStorage 兜底
|
||||
}
|
||||
|
||||
const direct = readTokenFromStorageKey('user')
|
||||
if (direct) return direct
|
||||
|
||||
const versioned = readTokenFromStorageKey(StorageConfig.generateStorageKey('userStore'))
|
||||
if (versioned) return versioned
|
||||
|
||||
if (typeof localStorage === 'undefined') return ''
|
||||
try {
|
||||
const pattern = StorageConfig.createKeyPattern('userStore')
|
||||
for (const key of Object.keys(localStorage)) {
|
||||
if (!pattern.test(key)) continue
|
||||
const token = readTokenFromStorageKey(key)
|
||||
if (token) return token
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 为 Axios 请求附加 Bearer Token */
|
||||
export function applyPortalAuthHeader(config: InternalAxiosRequestConfig): void {
|
||||
const token = resolvePortalAccessToken()
|
||||
if (!token) return
|
||||
if (typeof config.headers.set === 'function') {
|
||||
config.headers.set('Authorization', `Bearer ${token}`)
|
||||
} else {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="page-container db-page">
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">仪表盘</h2>
|
||||
</div>
|
||||
|
||||
<div class="db-dashboard-body" v-loading="loading">
|
||||
<div class="db-dashboard-stats">
|
||||
<div v-for="stat in statsList" :key="stat.title" class="db-stat-card">
|
||||
<p class="db-stat-title">{{ stat.title }}</p>
|
||||
<p class="db-stat-value">
|
||||
{{ stat.value }}<span class="db-stat-suffix">{{ stat.suffix }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" class="db-dashboard-charts">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<DualBarChart title="今日入库量(按货物,t)" :data="inboundByCargo" />
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="12">
|
||||
<DualBarChart title="今日出库量(按货物,t)" :data="outboundByCargo" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="db-dashboard-charts">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<DualBarChart title="今日入库量(按往来单位+货物,t)" :data="inboundByPartnerCargo" />
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="12">
|
||||
<DualBarChart title="今日出库量(按往来单位+货物,t)" :data="outboundByPartnerCargo" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* UI 模式:统计仪表盘(Fresh Mint 面板 + 图表区,无独立 P 编号)
|
||||
* 参考:/#/demo/console · src/views/dashboard/console/index.vue
|
||||
*/
|
||||
import DualBarChart from '@/components/business/db/DualBarChart.vue'
|
||||
import {
|
||||
dashboardApi,
|
||||
type DashboardCargoStat,
|
||||
type DashboardPartnerCargoStat
|
||||
} from '@/api/db/dashboard'
|
||||
|
||||
defineOptions({ name: 'DbDashboard' })
|
||||
|
||||
const loading = ref(false)
|
||||
const inboundByCargo = ref<DashboardCargoStat[]>([])
|
||||
const outboundByCargo = ref<DashboardCargoStat[]>([])
|
||||
const inboundByPartnerCargo = ref<DashboardPartnerCargoStat[]>([])
|
||||
const outboundByPartnerCargo = ref<DashboardPartnerCargoStat[]>([])
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [inCargo, outCargo, inPartner, outPartner] = await Promise.all([
|
||||
dashboardApi.getInboundByCargo(),
|
||||
dashboardApi.getOutboundByCargo(),
|
||||
dashboardApi.getInboundByPartnerCargo(),
|
||||
dashboardApi.getOutboundByPartnerCargo()
|
||||
])
|
||||
inboundByCargo.value = inCargo
|
||||
outboundByCargo.value = outCargo
|
||||
inboundByPartnerCargo.value = inPartner
|
||||
outboundByPartnerCargo.value = outPartner
|
||||
} catch (error) {
|
||||
console.error('获取仪表盘数据失败', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
const todayInboundVehicles = computed(() => {
|
||||
const cargoSet = new Set<string>()
|
||||
inboundByCargo.value.forEach((item) => {
|
||||
if (item.planned > 0) cargoSet.add(item.label)
|
||||
})
|
||||
return cargoSet.size
|
||||
})
|
||||
|
||||
const todayOutboundVehicles = computed(() => {
|
||||
const cargoSet = new Set<string>()
|
||||
outboundByCargo.value.forEach((item) => {
|
||||
if (item.planned > 0) cargoSet.add(item.label)
|
||||
})
|
||||
return cargoSet.size
|
||||
})
|
||||
|
||||
const todayInboundTotal = computed(() => {
|
||||
const planned = inboundByCargo.value.reduce((sum, item) => sum + item.planned, 0)
|
||||
const actual = inboundByCargo.value.reduce((sum, item) => sum + item.actual, 0)
|
||||
return { actual, planned }
|
||||
})
|
||||
|
||||
const todayOutboundTotal = computed(() => {
|
||||
const planned = outboundByCargo.value.reduce((sum, item) => sum + item.planned, 0)
|
||||
const actual = outboundByCargo.value.reduce((sum, item) => sum + item.actual, 0)
|
||||
return { actual, planned }
|
||||
})
|
||||
|
||||
const statsList = computed(() => [
|
||||
{ title: '今日入库', value: todayInboundVehicles.value, suffix: '车' },
|
||||
{
|
||||
title: '今日入库总量',
|
||||
value: `${todayInboundTotal.value.actual.toFixed(2)} / ${todayInboundTotal.value.planned.toFixed(2)}`,
|
||||
suffix: '吨'
|
||||
},
|
||||
{ title: '今日出库', value: todayOutboundVehicles.value, suffix: '车' },
|
||||
{
|
||||
title: '今日出库总量',
|
||||
value: `${todayOutboundTotal.value.actual.toFixed(2)} / ${todayOutboundTotal.value.planned.toFixed(2)}`,
|
||||
suffix: '吨'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,950 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">计划管理</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="filters" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="日期范围">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="-"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
@change="handleDateRangeChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务类型">
|
||||
<el-select
|
||||
v-model="filters.businessType"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in businessTypeOptions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行状态">
|
||||
<el-select
|
||||
v-model="filters.executionStatus"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in executionStatusOptions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<div class="filter-actions-start" />
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" :loading="loading" @click="handleResetFilters">
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row db-list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<div
|
||||
class="filter-date-shortcuts list-toolbar-date-shortcuts"
|
||||
role="group"
|
||||
aria-label="日期快捷筛选"
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
class="btn-ghost date-shortcut-btn"
|
||||
:class="{ 'is-active': activeDateShortcut === 'all' }"
|
||||
@click="handleDateShortcut('all')"
|
||||
>
|
||||
全部
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
class="btn-ghost date-shortcut-btn"
|
||||
:class="{ 'is-active': activeDateShortcut === 'yesterday' }"
|
||||
@click="handleDateShortcut('yesterday')"
|
||||
>
|
||||
昨日
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
class="btn-ghost date-shortcut-btn"
|
||||
:class="{ 'is-active': activeDateShortcut === 'today' }"
|
||||
@click="handleDateShortcut('today')"
|
||||
>
|
||||
今日
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
class="btn-ghost date-shortcut-btn"
|
||||
:class="{ 'is-active': activeDateShortcut === 'tomorrow' }"
|
||||
@click="handleDateShortcut('tomorrow')"
|
||||
>
|
||||
明日
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
class="btn-ghost date-shortcut-btn"
|
||||
:class="{ 'is-active': activeDateShortcut === 'week' }"
|
||||
@click="handleDateShortcut('week')"
|
||||
>
|
||||
本周
|
||||
</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
class="db-toolbar-quick-search-input"
|
||||
placeholder="往来单位 / 计划号 / 产品"
|
||||
clearable
|
||||
@keyup.enter="handleQuickSearch"
|
||||
@clear="handleQuickSearchClear"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon class="db-quick-search-icon" @click="handleQuickSearch"><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增计划
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :loading="exportLoading" @click="handleExport">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 Excel
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :loading="loading" @click="loadData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
show-summary
|
||||
:summary-method="getSummary"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" min-width="55" align="center" />
|
||||
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
prop="planNo"
|
||||
label="计划号"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="businessType"
|
||||
label="业务类型"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.businessType === '入库' ? 'success' : 'warning'" size="small">
|
||||
{{ row.businessType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="customerName"
|
||||
label="往来单位"
|
||||
min-width="150"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="startDate"
|
||||
label="起始日期"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="endDate"
|
||||
label="截止日期"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="product"
|
||||
label="产品"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="specification"
|
||||
label="规格"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
label="计划量(吨)"
|
||||
prop="plannedQuantity"
|
||||
min-width="120"
|
||||
align="right"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column label="已执行量(吨)" min-width="130" align="right" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ ((row.executedQuantity || 0) / 1000).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="executionStatus"
|
||||
label="执行状态"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getExecutionStatusType(getCorrectExecutionStatus(row))" size="small">
|
||||
{{ getCorrectExecutionStatus(row) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="remarks"
|
||||
label="备注"
|
||||
min-width="150"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="自动生成" min-width="140" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.autoGenerateEnabled"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
@change="(val) => handleToggleAutoGenerate(row, Number(val))"
|
||||
/>
|
||||
<div v-if="row.autoGenerateEndMonth" class="db-auto-end-hint">
|
||||
结束:{{ row.autoGenerateEndMonth }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="360"
|
||||
align="center"
|
||||
header-align="center"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="db-op-actions">
|
||||
<el-button class="btn-op-item" @click="handleView(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
查看
|
||||
</el-button>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button class="btn-op-item" @click="handleGenerateInventory(row)">
|
||||
<el-icon><DocumentAdd /></el-icon>
|
||||
生成出入库单
|
||||
</el-button>
|
||||
<el-button class="btn-op-item danger" @click="handleDelete(row.id)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
v-show="total > 0"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadData"
|
||||
@size-change="loadData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="modalOpen"
|
||||
:title="editingPlan ? '修改计划' : '新增计划'"
|
||||
width="760px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
class="dialog-form-layout"
|
||||
modal-class="order-module-dialog-mask"
|
||||
@close="handleModalClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="form-grid-2"
|
||||
scroll-to-error
|
||||
>
|
||||
<el-form-item label="计划号" prop="planNo">
|
||||
<el-input v-model="formData.planNo" placeholder="系统自动生成" />
|
||||
</el-form-item>
|
||||
<el-form-item label="业务类型" prop="businessType">
|
||||
<el-radio-group v-model="formData.businessType">
|
||||
<el-radio value="入库">入库</el-radio>
|
||||
<el-radio value="出库">出库</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="自动按月生成">
|
||||
<el-switch
|
||||
v-model="formData.autoGenerateEnabled"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结束月份">
|
||||
<el-date-picker
|
||||
v-model="formData.autoGenerateEndMonth"
|
||||
type="month"
|
||||
placeholder="不选表示一直生成"
|
||||
style="width: 100%"
|
||||
format="YYYY-MM"
|
||||
value-format="YYYY-MM"
|
||||
:disabled="!formData.autoGenerateEnabled"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="往来单位" prop="customerName">
|
||||
<DbCrmCustomerSelect v-model="formData.customerName" placeholder="请输入往来单位搜索" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品" prop="productId">
|
||||
<DbCrmProductSelect
|
||||
v-model:product-id="formData.productId"
|
||||
v-model:product-name="formData.product"
|
||||
v-model:specification="formData.specification"
|
||||
placeholder="请搜索并选择 CRM 产品"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="规格">
|
||||
<el-input
|
||||
v-model="formData.specification"
|
||||
readonly
|
||||
placeholder="选择产品后自动带出"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划开始时间" prop="startDate">
|
||||
<el-date-picker
|
||||
v-model="formData.startDate"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划结束时间" prop="endDate">
|
||||
<el-date-picker
|
||||
v-model="formData.endDate"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划量(吨)" prop="plannedQuantity">
|
||||
<el-input-number
|
||||
v-model="formData.plannedQuantity"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
placeholder="请输入计划吨位"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remarks" class="form-col-span-2">
|
||||
<el-input
|
||||
v-model="formData.remarks"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="填写备注信息"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleModalClose">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<DbPlanRelatedViewDialog
|
||||
v-model:visible="relatedModalOpen"
|
||||
:loading="loadingRelated"
|
||||
:data="relatedData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* UI 模式 P02 · 复杂业务列表
|
||||
* 参考:/#/crm/quote · src/views/crm/quote/index.vue
|
||||
*/
|
||||
import type { TableColumnCtx } from 'element-plus'
|
||||
import {
|
||||
Check,
|
||||
Close,
|
||||
Delete,
|
||||
DocumentAdd,
|
||||
Download,
|
||||
Edit,
|
||||
Plus,
|
||||
Refresh,
|
||||
RefreshLeft,
|
||||
Search,
|
||||
View
|
||||
} from '@element-plus/icons-vue'
|
||||
import DbCrmCustomerSelect from '@/components/business/db/DbCrmCustomerSelect.vue'
|
||||
import DbCrmProductSelect from '@/components/business/db/DbCrmProductSelect.vue'
|
||||
import DbPlanRelatedViewDialog from '@/components/business/db/DbPlanRelatedViewDialog.vue'
|
||||
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
|
||||
import { $modal } from '@/utils/modal'
|
||||
import {
|
||||
planApi,
|
||||
type Plan,
|
||||
type PlanCreate,
|
||||
type PlanListParams,
|
||||
type PlanRelatedData,
|
||||
type PlanUpdate
|
||||
} from '@/api/db/plans'
|
||||
|
||||
defineOptions({ name: 'DbPlans' })
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const tableData = ref<Plan[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const searchKeyword = ref('')
|
||||
const selectedRows = ref<Plan[]>([])
|
||||
const activeDateShortcut = ref<'all' | 'today' | 'tomorrow' | 'yesterday' | 'week' | ''>('')
|
||||
|
||||
const filters = reactive({
|
||||
businessType: '' as '' | '入库' | '出库',
|
||||
executionStatus: ''
|
||||
})
|
||||
|
||||
const businessTypeOptions: Array<'入库' | '出库'> = ['入库', '出库']
|
||||
const executionStatusOptions = ['待入库', '入库中', '已入库', '待出库', '出库中', '已出库']
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||||
const formatYmd = (d: Date) =>
|
||||
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||||
|
||||
/** 默认筛选:当前月 1 日 ~ 当前月最后一天(对齐原地磅 art-design-pro) */
|
||||
const getCurrentMonthDateRange = (): [string, string] => {
|
||||
const now = new Date()
|
||||
return [
|
||||
formatYmd(new Date(now.getFullYear(), now.getMonth(), 1)),
|
||||
formatYmd(new Date(now.getFullYear(), now.getMonth() + 1, 0))
|
||||
]
|
||||
}
|
||||
|
||||
const dateRange = ref<[string, string] | []>(getCurrentMonthDateRange())
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const editingPlan = ref<Plan | null>(null)
|
||||
const formRef = ref()
|
||||
const formData = reactive<
|
||||
PlanCreate & {
|
||||
product: string
|
||||
specification: string
|
||||
}
|
||||
>({
|
||||
planNo: '',
|
||||
planDate: formatYmd(new Date()),
|
||||
startDate: formatYmd(new Date()),
|
||||
endDate: '',
|
||||
businessType: '入库',
|
||||
productId: '',
|
||||
product: '',
|
||||
specification: '',
|
||||
plannedQuantity: 0,
|
||||
vehicleNo: '',
|
||||
customerName: '',
|
||||
remarks: '',
|
||||
autoGenerateEnabled: 0,
|
||||
autoGenerateEndMonth: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
businessType: [{ required: true, message: '请选择业务类型', trigger: 'change' }],
|
||||
productId: [{ required: true, message: '请选择 CRM 产品', trigger: 'change' }],
|
||||
plannedQuantity: [{ required: true, message: '请输入计划量', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const buildListQueryParams = (): PlanListParams => {
|
||||
const params: PlanListParams = {}
|
||||
const keyword = searchKeyword.value.trim()
|
||||
|
||||
if (keyword) params.q = keyword
|
||||
if (dateRange.value?.[0] && dateRange.value?.[1]) {
|
||||
params.startDateFrom = dateRange.value[0]
|
||||
params.startDateTo = dateRange.value[1]
|
||||
}
|
||||
if (filters.businessType) params.businessType = filters.businessType
|
||||
if (filters.executionStatus) params.executionStatus = filters.executionStatus
|
||||
return params
|
||||
}
|
||||
|
||||
const fetchFilteredPlans = async () => {
|
||||
return planApi.list(buildListQueryParams())
|
||||
}
|
||||
|
||||
const getCorrectExecutionStatus = (row: Plan): string => {
|
||||
if (!row.executionStatus) {
|
||||
return row.businessType === '入库' ? '待入库' : '待出库'
|
||||
}
|
||||
if (row.businessType === '入库') {
|
||||
if (
|
||||
row.executionStatus === '待出库' ||
|
||||
row.executionStatus === '出库中' ||
|
||||
row.executionStatus === '已出库'
|
||||
) {
|
||||
return (row.executedQuantity || 0) > 0 ? '入库中' : '待入库'
|
||||
}
|
||||
} else if (row.businessType === '出库') {
|
||||
if (
|
||||
row.executionStatus === '待入库' ||
|
||||
row.executionStatus === '入库中' ||
|
||||
row.executionStatus === '已入库'
|
||||
) {
|
||||
return (row.executedQuantity || 0) > 0 ? '出库中' : '待出库'
|
||||
}
|
||||
}
|
||||
return row.executionStatus
|
||||
}
|
||||
|
||||
const getExecutionStatusType = (
|
||||
status: string | undefined
|
||||
): 'success' | 'warning' | 'info' | 'primary' | 'danger' => {
|
||||
if (!status) return 'info'
|
||||
if (status === '已入库' || status === '已出库') return 'success'
|
||||
if (status === '入库中' || status === '出库中') return 'info'
|
||||
if (status === '待入库' || status === '待出库') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const getSummary = (param: { columns: TableColumnCtx<Plan>[]; data: Plan[] }) => {
|
||||
const { columns } = param
|
||||
const sums: string[] = []
|
||||
|
||||
columns.forEach((column, index) => {
|
||||
if (column.type === 'selection') {
|
||||
sums[index] = ''
|
||||
return
|
||||
}
|
||||
if (column.type === 'index') {
|
||||
sums[index] = '合计'
|
||||
return
|
||||
}
|
||||
if (column.property === 'plannedQuantity') {
|
||||
const totalPlanned = tableData.value.reduce(
|
||||
(sum, row) => sum + (Number(row.plannedQuantity) || 0),
|
||||
0
|
||||
)
|
||||
sums[index] = totalPlanned.toString()
|
||||
return
|
||||
}
|
||||
if (column.label === '已执行量(吨)') {
|
||||
const totalExecuted = tableData.value.reduce(
|
||||
(sum, row) => sum + (row.executedQuantity || 0) / 1000,
|
||||
0
|
||||
)
|
||||
sums[index] = totalExecuted.toFixed(2)
|
||||
return
|
||||
}
|
||||
sums[index] = ''
|
||||
})
|
||||
|
||||
return sums
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const records = await fetchFilteredPlans()
|
||||
total.value = records.length
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
tableData.value = records.slice(start, end)
|
||||
} catch (error) {
|
||||
console.error('加载计划列表失败', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleQuickSearch = () => {
|
||||
currentPage.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleQuickSearchClear = () => {
|
||||
searchKeyword.value = ''
|
||||
handleQuickSearch()
|
||||
}
|
||||
|
||||
const handleSelectionChange = (selection: Plan[]) => {
|
||||
selectedRows.value = selection
|
||||
}
|
||||
|
||||
const handleResetFilters = () => {
|
||||
searchKeyword.value = ''
|
||||
filters.businessType = ''
|
||||
filters.executionStatus = ''
|
||||
dateRange.value = getCurrentMonthDateRange()
|
||||
activeDateShortcut.value = ''
|
||||
currentPage.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleDateShortcut = (type: 'all' | 'today' | 'tomorrow' | 'yesterday' | 'week') => {
|
||||
activeDateShortcut.value = type
|
||||
const today = new Date()
|
||||
|
||||
if (type === 'all') {
|
||||
dateRange.value = []
|
||||
} else if (type === 'today') {
|
||||
const d = formatYmd(today)
|
||||
dateRange.value = [d, d]
|
||||
} else if (type === 'tomorrow') {
|
||||
const t = new Date(today)
|
||||
t.setDate(t.getDate() + 1)
|
||||
const d = formatYmd(t)
|
||||
dateRange.value = [d, d]
|
||||
} else if (type === 'yesterday') {
|
||||
const y = new Date(today)
|
||||
y.setDate(y.getDate() - 1)
|
||||
const d = formatYmd(y)
|
||||
dateRange.value = [d, d]
|
||||
} else if (type === 'week') {
|
||||
const weekday = today.getDay() === 0 ? 7 : today.getDay()
|
||||
const monday = new Date(today)
|
||||
monday.setDate(today.getDate() - (weekday - 1))
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
dateRange.value = [formatYmd(monday), formatYmd(sunday)]
|
||||
}
|
||||
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handleDateRangeChange = () => {
|
||||
activeDateShortcut.value = ''
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const resetFormData = () => {
|
||||
Object.assign(formData, {
|
||||
planNo: '',
|
||||
planDate: formatYmd(new Date()),
|
||||
startDate: formatYmd(new Date()),
|
||||
endDate: '',
|
||||
businessType: '入库',
|
||||
productId: '',
|
||||
product: '',
|
||||
specification: '',
|
||||
plannedQuantity: 0,
|
||||
vehicleNo: '',
|
||||
customerName: '',
|
||||
remarks: '',
|
||||
autoGenerateEnabled: 0,
|
||||
autoGenerateEndMonth: ''
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
editingPlan.value = null
|
||||
resetFormData()
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record: Plan) => {
|
||||
editingPlan.value = record
|
||||
Object.assign(formData, {
|
||||
planNo: record.planNo || '',
|
||||
planDate: record.planDate,
|
||||
startDate: record.startDate || '',
|
||||
endDate: record.endDate || '',
|
||||
businessType: record.businessType,
|
||||
productId: record.productId || '',
|
||||
product: record.product,
|
||||
specification: record.specification || '',
|
||||
plannedQuantity: record.plannedQuantity,
|
||||
vehicleNo: record.vehicleNo || '',
|
||||
customerName: record.customerName || '',
|
||||
remarks: record.remarks || '',
|
||||
autoGenerateEnabled: record.autoGenerateEnabled ?? 0,
|
||||
autoGenerateEndMonth: record.autoGenerateEndMonth || ''
|
||||
})
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
const relatedData = ref<PlanRelatedData>({
|
||||
inventories: [],
|
||||
weighings: []
|
||||
})
|
||||
const relatedModalOpen = ref(false)
|
||||
const loadingRelated = ref(false)
|
||||
|
||||
const handleView = async (record: Plan) => {
|
||||
loadingRelated.value = true
|
||||
try {
|
||||
relatedData.value = await planApi.getRelated(record.id)
|
||||
relatedModalOpen.value = true
|
||||
} catch (error) {
|
||||
console.error('加载关联数据失败', error)
|
||||
} finally {
|
||||
loadingRelated.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await $modal.confirm('确定要删除这个计划吗?')
|
||||
await planApi.delete(id)
|
||||
$modal.msgSuccess('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
// 用户取消或请求失败
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateInventory = (record: Plan) => {
|
||||
router.push({
|
||||
path: '/db/inventory',
|
||||
query: {
|
||||
planNo: record.planNo || '',
|
||||
product: record.product || '',
|
||||
productId: record.productId || '',
|
||||
specification: record.specification || '',
|
||||
businessType: record.businessType || '',
|
||||
customerName: record.customerName || '',
|
||||
purchasePlanId: record.id,
|
||||
from: 'plan'
|
||||
}
|
||||
})
|
||||
$modal.msgSuccess('已跳转到出入库页面,请填写详细信息')
|
||||
}
|
||||
|
||||
const handleToggleAutoGenerate = async (record: Plan, value: number) => {
|
||||
const normalizedValue = value === 1 ? 1 : 0
|
||||
try {
|
||||
await planApi.update(record.id, {
|
||||
autoGenerateEnabled: normalizedValue,
|
||||
autoGenerateEndMonth: record.autoGenerateEndMonth
|
||||
})
|
||||
$modal.msgSuccess(normalizedValue === 1 ? '已开启自动生成' : '已关闭自动生成')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('更新自动生成失败', error)
|
||||
record.autoGenerateEnabled = normalizedValue === 1 ? 0 : 1
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: PlanCreate | PlanUpdate = editingPlan.value
|
||||
? {
|
||||
planNo: formData.planNo || undefined,
|
||||
planDate: formData.planDate,
|
||||
startDate: formData.startDate || undefined,
|
||||
endDate: formData.endDate || undefined,
|
||||
businessType: formData.businessType,
|
||||
productId: formData.productId,
|
||||
plannedQuantity: formData.plannedQuantity,
|
||||
vehicleNo: formData.vehicleNo || undefined,
|
||||
customerName: formData.customerName || undefined,
|
||||
remarks: formData.remarks || undefined,
|
||||
autoGenerateEnabled: formData.autoGenerateEnabled,
|
||||
autoGenerateEndMonth: formData.autoGenerateEndMonth || undefined
|
||||
}
|
||||
: {
|
||||
planNo: formData.planNo || undefined,
|
||||
planDate: formData.planDate,
|
||||
startDate: formData.startDate || undefined,
|
||||
endDate: formData.endDate || undefined,
|
||||
businessType: formData.businessType,
|
||||
productId: formData.productId,
|
||||
plannedQuantity: formData.plannedQuantity,
|
||||
vehicleNo: formData.vehicleNo || undefined,
|
||||
customerName: formData.customerName || undefined,
|
||||
remarks: formData.remarks || undefined,
|
||||
autoGenerateEnabled: formData.autoGenerateEnabled,
|
||||
autoGenerateEndMonth: formData.autoGenerateEndMonth || undefined
|
||||
}
|
||||
|
||||
if (editingPlan.value) {
|
||||
await planApi.update(editingPlan.value.id, payload as PlanUpdate)
|
||||
$modal.msgSuccess('更新成功')
|
||||
} else {
|
||||
await planApi.create(payload as PlanCreate)
|
||||
$modal.msgSuccess('创建成功')
|
||||
}
|
||||
modalOpen.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('保存计划失败', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleModalClose = () => {
|
||||
modalOpen.value = false
|
||||
editingPlan.value = null
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
exportLoading.value = true
|
||||
try {
|
||||
const records = await fetchFilteredPlans()
|
||||
if (records.length === 0) {
|
||||
$modal.msgWarning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'计划号',
|
||||
'往来单位',
|
||||
'计划日期',
|
||||
'起始日期',
|
||||
'截止日期',
|
||||
'产品',
|
||||
'规格',
|
||||
'车牌号',
|
||||
'计划量',
|
||||
'已执行量',
|
||||
'执行状态',
|
||||
'备注'
|
||||
]
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...records.map((r) =>
|
||||
[
|
||||
r.planNo,
|
||||
r.customerName,
|
||||
r.planDate,
|
||||
r.startDate,
|
||||
r.endDate,
|
||||
r.product,
|
||||
r.specification,
|
||||
r.vehicleNo,
|
||||
r.plannedQuantity,
|
||||
r.executedQuantity || 0,
|
||||
r.executionStatus,
|
||||
r.remarks || ''
|
||||
].join(',')
|
||||
)
|
||||
].join('\n')
|
||||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const now = new Date()
|
||||
const stamp = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`
|
||||
a.download = `计划列表_${stamp}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
$modal.msgSuccess('导出成功')
|
||||
} catch (error) {
|
||||
console.error('导出失败', error)
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useDbInstantSearch(
|
||||
() => ({
|
||||
q: searchKeyword.value,
|
||||
dateRange: dateRange.value,
|
||||
businessType: filters.businessType,
|
||||
executionStatus: filters.executionStatus
|
||||
}),
|
||||
handleSearch
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (!dateRange.value[0] || !dateRange.value[1]) {
|
||||
dateRange.value = getCurrentMonthDateRange()
|
||||
}
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">车牌管理</h2>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row db-list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
class="db-toolbar-quick-search-input"
|
||||
placeholder="车牌号 / 备注"
|
||||
clearable
|
||||
@keyup.enter="handleQuickSearch"
|
||||
@clear="handleQuickSearchClear"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon class="db-quick-search-icon" @click="handleQuickSearch"><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增车牌号
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :loading="loading" @click="loadData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" min-width="55" align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
min-width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="vehicleNo"
|
||||
label="车牌号"
|
||||
min-width="160"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="remarks"
|
||||
label="备注"
|
||||
min-width="240"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="200"
|
||||
align="center"
|
||||
header-align="center"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="db-op-actions">
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button class="btn-op-item danger" @click="handleDelete(row.id)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
v-show="total > 0"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="applyPagination"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="modalOpen"
|
||||
:title="editingVehicle ? '修改车牌号' : '新增车牌号'"
|
||||
width="520px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
class="dialog-form-layout"
|
||||
modal-class="order-module-dialog-mask"
|
||||
@close="handleModalClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="form-grid-2"
|
||||
scroll-to-error
|
||||
>
|
||||
<el-form-item label="车牌号" prop="vehicleNo">
|
||||
<el-input v-model="formData.vehicleNo" placeholder="请输入车牌号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remarks" class="form-col-span-2">
|
||||
<el-input v-model="formData.remarks" type="textarea" :rows="3" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleModalClose">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* UI 模式 P01 · 标准列表 CRUD
|
||||
* 参考:/#/crm/quote · src/views/crm/quote/index.vue
|
||||
*/
|
||||
import { Check, Close, Delete, Edit, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { vehicleApi, type Vehicle, type VehicleCreate } from '@/api/db/vehicles'
|
||||
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
|
||||
import { $modal } from '@/utils/modal'
|
||||
|
||||
defineOptions({ name: 'DbVehicles' })
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const allRecords = ref<Vehicle[]>([])
|
||||
const tableData = ref<Vehicle[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const searchKeyword = ref('')
|
||||
const selectedRows = ref<Vehicle[]>([])
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const editingVehicle = ref<Vehicle | null>(null)
|
||||
const formRef = ref()
|
||||
const formData = reactive<VehicleCreate>({
|
||||
vehicleNo: '',
|
||||
remarks: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
vehicleNo: [{ required: true, message: '请输入车牌号', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const applyPagination = () => {
|
||||
total.value = allRecords.value.length
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
tableData.value = allRecords.value.slice(start, end)
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const keyword = searchKeyword.value.trim()
|
||||
allRecords.value = await vehicleApi.list(keyword || undefined)
|
||||
const maxPage = Math.ceil(allRecords.value.length / pageSize.value) || 1
|
||||
if (currentPage.value > maxPage) {
|
||||
currentPage.value = 1
|
||||
}
|
||||
applyPagination()
|
||||
} catch (error) {
|
||||
console.error('加载车牌列表失败', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickSearch = () => {
|
||||
currentPage.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleQuickSearchClear = () => {
|
||||
searchKeyword.value = ''
|
||||
handleQuickSearch()
|
||||
}
|
||||
|
||||
const handleSelectionChange = (selection: Vehicle[]) => {
|
||||
selectedRows.value = selection
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
applyPagination()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
editingVehicle.value = null
|
||||
Object.assign(formData, { vehicleNo: '', remarks: '' })
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record: Vehicle) => {
|
||||
editingVehicle.value = record
|
||||
Object.assign(formData, {
|
||||
vehicleNo: record.vehicleNo,
|
||||
remarks: record.remarks || ''
|
||||
})
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await $modal.confirm('确定要删除该车牌号吗?')
|
||||
await vehicleApi.delete(id)
|
||||
$modal.msgSuccess('删除成功')
|
||||
loadData()
|
||||
} catch {
|
||||
// 用户取消或请求失败
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: VehicleCreate = {
|
||||
vehicleNo: formData.vehicleNo.trim(),
|
||||
remarks: formData.remarks?.trim() || null
|
||||
}
|
||||
if (editingVehicle.value) {
|
||||
await vehicleApi.update(editingVehicle.value.id, payload)
|
||||
$modal.msgSuccess('修改成功')
|
||||
} else {
|
||||
await vehicleApi.create(payload)
|
||||
$modal.msgSuccess('新增成功')
|
||||
}
|
||||
modalOpen.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('保存车牌失败', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleModalClose = () => {
|
||||
modalOpen.value = false
|
||||
editingVehicle.value = null
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
useDbInstantSearch(() => searchKeyword.value, handleQuickSearch)
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user