This commit is contained in:
zlwu committed 2026-06-10 22:59:34 +08:00
1 parent 725774ef0c
commit c3be9d1dce
176 files changed
+41925 -6342

No files matched your search

+2
View File
@@ -116,6 +116,8 @@
if (!preferPanelIcon) return
if (heading.classList.contains(panelTitleBarClass)) return
heading.classList.remove(panelTitleBarClass)
// 已手写 title-ic(例如 delivery-demo)则不干预
+28
View File
@@ -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')
}
}
+123
View File
@@ -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
})
}
}
+150
View File
@@ -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 }
})
}
}
+36
View File
@@ -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}`)
}
}
+123
View File
@@ -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}`)
}
}
+3
View File
@@ -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 }
)
}
+77 -4
View File
@@ -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',
+498
View File
@@ -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;
}
+2 -4
View File
@@ -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
})
+144
View File
@@ -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
+68
View File
@@ -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}`
}
}
+126
View File
@@ -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
+950
View File
@@ -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>
+289
View File
@@ -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