This commit is contained in:
zlwu committed 2026-06-21 12:41:39 +08:00
1 parent 299609706c
commit e46c0480d4
87 files changed
+11975 -15543

No files matched your search

-36
View File
@@ -1,36 +0,0 @@
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}`)
}
}
+4 -4
View File
@@ -1081,12 +1081,12 @@ body {
flex-direction: column !important;
gap: 4px;
min-width: 0;
font-size: 13px;
font-size: 14px;
align-items: stretch;
dt {
margin: 0;
font-size: 11px;
font-size: 13px;
font-weight: 400 !important;
color: #000 !important;
line-height: 1.4;
@@ -1102,7 +1102,7 @@ body {
}
.view-field-label {
font-size: 11px;
font-size: 13px;
font-weight: 400 !important;
color: #000 !important;
line-height: 1.4;
@@ -1217,7 +1217,7 @@ body {
th.el-table__cell {
color: var(--mint-text);
font-size: 12px;
font-size: 13px;
font-weight: 600;
}
@@ -1,159 +1,25 @@
<template>
<el-dialog
v-model="innerVisible"
<SystemViewDialog
v-model:visible="innerVisible"
title="查看关联数据"
:icon="Link"
:sections="sections"
:loading="loading"
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">
/**
* UI 模式 P09 · 标准查看弹窗(3 列 KV 表格)
* 参考:DbPlanRelatedViewDialog · src/views/db/plans/index.vue
*/
import { computed } from 'vue'
import { Close } from '@element-plus/icons-vue'
import { Link } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { InventoryRelatedData } from '@/api/db/inventory'
import {
businessTypeTagType,
formatKgToTon,
planExecutionTagType
} from '@/components/business/db/db-related-view'
import { buildInventoryRelatedSections } from '@/components/business/db/db-related-view'
const props = defineProps<{
visible: boolean
@@ -170,9 +36,5 @@
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] : []))
const sections = computed(() => buildInventoryRelatedSections(props.data))
</script>
@@ -1,131 +1,25 @@
<template>
<el-dialog
v-model="innerVisible"
<SystemViewDialog
v-model:visible="innerVisible"
title="查看关联数据"
:icon="Link"
:sections="sections"
:loading="loading"
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">
/**
* UI 模式 P09 · 标准查看弹窗(3 列 KV 表格)
* 参考:DbRelatedViewDialog · src/views/db/weighing/index.vue
*/
import { computed } from 'vue'
import { Close } from '@element-plus/icons-vue'
import { Link } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { PlanRelatedData } from '@/api/db/plans'
import { businessTypeTagType, inventoryStatusTagType } from '@/components/business/db/db-related-view'
import { buildPlanRelatedSections } from '@/components/business/db/db-related-view'
const props = defineProps<{
visible: boolean
@@ -142,7 +36,5 @@
set: (val) => emit('update:visible', val)
})
const hasData = computed(
() => props.data.inventories.length > 0 || props.data.weighings.length > 0
)
const sections = computed(() => buildPlanRelatedSections(props.data))
</script>
@@ -1,95 +0,0 @@
<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>
@@ -1,5 +1,5 @@
<template>
<div class="db-chart-card">
<div class="db-chart-card db-dual-bar">
<h3 class="db-chart-title">{{ title }}</h3>
<div class="db-dual-bar__body">
<el-empty v-if="!data.length" description="今日无数据" />
@@ -14,8 +14,9 @@
实际量
</span>
</div>
<div class="chart-container">
<div v-for="item in data" :key="item.label" class="chart-item">
<span class="chart-label">{{ item.label }}</span>
<span class="chart-label" :title="item.label">{{ item.label }}</span>
<div class="chart-bar-group">
<div class="chart-bar-wrapper">
<div
@@ -41,6 +42,7 @@
<span class="value actual">{{ item.actual.toFixed(2) }}</span>
</div>
</div>
</div>
</template>
</div>
</div>
@@ -30,6 +30,8 @@ function sectionTitle(base: string, index: number, total: number): string {
export function buildPlanViewSection(plan: Plan): ViewSection {
return {
title: '计划',
fieldLayout: 'table',
tableMode: 'triple',
fields: [
{ label: '计划号', value: plan.planNo },
{ label: '业务类型', value: plan.businessType, tagType: businessTypeTagType(plan.businessType) },
@@ -57,6 +59,8 @@ export function buildInventoryViewSection(
): ViewSection {
return {
title,
fieldLayout: 'table',
tableMode: 'triple',
fields: [
{ label: '出入库单号', value: record.batchNo },
{ label: '日期', value: record.date },
@@ -120,7 +124,7 @@ export function buildWeighingViewSection(
fields.push({ label: '备注', value: record.remarks, span: true })
}
return { title, fields }
return { title, fieldLayout: 'table', tableMode: 'triple', fields }
}
type MergedPlanRelatedItem = (PlanInventoryRecord | PlanWeighingRecord) & {
@@ -160,7 +164,14 @@ function mergePlanRelatedItems(data: PlanRelatedData): MergedPlanRelatedItem[] {
export function buildPlanRelatedSections(data: PlanRelatedData): ViewSection[] {
const items = mergePlanRelatedItems(data)
if (!items.length) {
return [{ title: '关联数据', fields: [{ label: '说明', value: '暂无关联数据', span: true }] }]
return [
{
title: '关联数据',
fieldLayout: 'table',
tableMode: 'triple',
fields: [{ label: '说明', value: '暂无关联数据', span: 'full' }]
}
]
}
const inventoryCount = items.filter((item) => item.type === 'inventory').length
@@ -185,7 +196,14 @@ export function buildInventoryRelatedSections(data: InventoryRelatedData): ViewS
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 [
{
title: '关联数据',
fieldLayout: 'table',
tableMode: 'triple',
fields: [{ label: '说明', value: '暂无关联数据', span: 'full' }]
}
]
}
return sections
}
@@ -195,7 +213,14 @@ export function buildWeighingRelatedSections(data: WeighingRelatedData): ViewSec
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 [
{
title: '关联数据',
fieldLayout: 'table',
tableMode: 'triple',
fields: [{ label: '说明', value: '暂无关联数据', span: 'full' }]
}
]
}
return sections
}
@@ -670,13 +670,13 @@
dt {
margin: 0 0 4px;
font-size: 11px;
font-size: 13px;
color: #64748b;
}
dd {
margin: 0;
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
word-break: break-word;
@@ -0,0 +1,79 @@
<template>
<el-select
v-model="model"
:placeholder="placeholder"
filterable
remote
:remote-method="loadOptions"
:loading="loading"
clearable
style="width: 100%"
@focus="handleFocus"
>
<el-option
v-for="item in displayOptions"
:key="item.id"
:label="item.licensePlate"
:value="item.licensePlate"
/>
</el-select>
</template>
<script setup lang="ts">
import { vehicleApi, type Vehicle } from '@/api/pl'
import { pickBxList } from '@/utils/http/bx'
defineOptions({ name: 'PlVehicleSelect' })
const model = defineModel<string>({ default: '' })
withDefaults(
defineProps<{
placeholder?: string
}>(),
{
placeholder: '请选择车牌号'
}
)
const loading = ref(false)
const options = ref<Vehicle[]>([])
const loadOptions = async (keyword: string) => {
loading.value = true
try {
const res = await vehicleApi.list(keyword.trim() ? { q: keyword.trim() } : undefined)
options.value = pickBxList<Vehicle>(res)
} catch (error) {
console.error('加载运输车辆列表失败', error)
options.value = []
} finally {
loading.value = false
}
}
const handleFocus = () => {
if (options.value.length === 0) {
loadOptions('')
}
}
/** 编辑态回显:历史出入库单中的车牌若不在运输车辆库,仍展示只读选项 */
const displayOptions = computed(() => {
const current = model.value?.trim()
if (!current) return options.value
if (options.value.some((item) => item.licensePlate === current)) {
return options.value
}
return [
{
id: `legacy-${current}`,
licensePlate: current,
vehicleType: '',
vehicleStatus: '',
createTime: ''
},
...options.value
]
})
</script>
@@ -63,12 +63,12 @@
flex-direction: column;
gap: 4px;
min-width: 0;
font-size: 13px;
font-size: 14px;
align-items: stretch;
dt {
margin: 0;
font-size: 11px;
font-size: 13px;
font-weight: 400;
color: #000;
line-height: 1.4;
@@ -44,7 +44,7 @@
</template>
</tr>
</template>
<template v-else>
<template v-else-if="mode === 'triple'">
<tr
v-for="(row, idx) in tripleRows"
:key="tripleRowKey(row, idx)"
@@ -72,22 +72,52 @@
</template>
</tr>
</template>
<template v-else>
<tr
v-for="(row, idx) in quadRows"
:key="quadRowKey(row, idx)"
:class="{ 'row-long': row.type === 'long' }"
>
<template v-if="row.type === 'long'">
<th>{{ row.field.label }}</th>
<td colspan="7">
<ViewKvTableCell :field="row.field" />
</td>
</template>
<template v-else>
<template v-for="slot in 4" :key="slot">
<template v-if="row.fields[slot - 1]">
<th>{{ row.fields[slot - 1].label }}</th>
<td>
<ViewKvTableCell :field="row.fields[slot - 1]" />
</td>
</template>
<template v-else>
<th></th>
<td></td>
</template>
</template>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, provide } from 'vue'
import type { ViewField } from './types'
import { formatViewValue } from './types'
import ViewKvTableCell from './ViewKvTableCell.vue'
import {
buildViewKvTableDualRows,
buildViewKvTableQuadRows,
buildViewKvTableTripleRows,
getViewFieldDisplayText,
isViewFieldFullSpan,
isViewFieldLong,
type ViewKvTableQuadRow,
type ViewKvTableRow,
type ViewKvTableTripleRow
} from '@/utils/view-field-layout'
@@ -95,8 +125,8 @@
const props = withDefaults(
defineProps<{
fields: ViewField[]
/** pair:一行一组;dual:一行两组(四列);triple:一行三组(六列) */
mode?: 'pair' | 'dual' | 'triple'
/** pair:一行一组;dual:一行两组(四列);triple:一行三组(六列);quad:一行四组(八列) */
mode?: 'pair' | 'dual' | 'triple' | 'quad'
}>(),
{
mode: 'pair'
@@ -105,6 +135,13 @@
const dualRows = computed(() => buildViewKvTableDualRows(props.fields, formatViewValue))
const tripleRows = computed(() => buildViewKvTableTripleRows(props.fields, formatViewValue))
const quadRows = computed(() => buildViewKvTableQuadRows(props.fields, formatViewValue))
const emit = defineEmits<{
(e: 'field-view', field: ViewField): void
}>()
provide('viewKvFieldView', (field: ViewField) => emit('field-view', field))
function isLong(field: ViewField) {
const text = getViewFieldDisplayText(field, formatViewValue)
@@ -120,6 +157,11 @@
if (row.type === 'long') return row.field.label
return row.fields.map((f) => f.label).join('-') || String(idx)
}
function quadRowKey(row: ViewKvTableQuadRow, idx: number) {
if (row.type === 'long') return row.field.label
return row.fields.map((f) => f.label).join('-') || String(idx)
}
</script>
<style scoped lang="scss">
@@ -133,14 +175,14 @@
.view-kv-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
font-size: 14px;
table-layout: fixed;
th {
padding: 10px 12px;
background: var(--table-head, #f8fafc);
color: var(--text-muted, #64748b);
font-size: 11px;
font-size: 13px;
font-weight: 500;
text-align: left;
vertical-align: top;
@@ -203,4 +245,20 @@
.view-kv-table--triple tr.row-long td {
width: auto;
}
.view-kv-table--quad th {
width: 8%;
}
.view-kv-table--quad td {
width: 17%;
}
.view-kv-table--quad tr.row-long th {
width: 8%;
}
.view-kv-table--quad tr.row-long td {
width: auto;
}
</style>
@@ -1,30 +1,73 @@
<template>
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
formatViewValue(field.value)
}}</el-tag>
<ul v-else-if="field.items?.length" class="view-plain-list">
<li v-for="(item, idx) in field.items" :key="idx">{{ item }}</li>
</ul>
<span v-else :class="{ 'money-display': field.money, 'muted-display': isMuted }">{{
formatViewValue(field.value)
}}</span>
<div class="view-kv-table-cell">
<el-tag v-if="field.tagType" :type="field.tagType" size="small">{{
formatViewValue(field.value)
}}</el-tag>
<ul v-else-if="field.items?.length" class="view-plain-list">
<li v-for="(item, idx) in field.items" :key="idx">{{ item }}</li>
</ul>
<span v-else :class="{ 'money-display': field.money, 'muted-display': isMuted }">{{
formatViewValue(field.value)
}}</span>
<el-button
v-if="showViewBtn"
link
type="primary"
size="small"
class="view-kv-field-link"
@click="handleView"
>
<el-icon><View /></el-icon>查看
</el-button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, inject } from 'vue'
import { View } from '@element-plus/icons-vue'
import type { ViewField } from './types'
import { formatViewValue } from './types'
const VIEW_KV_FIELD_VIEW_KEY = 'viewKvFieldView'
const props = defineProps<{
field: ViewField
}>()
const onFieldView = inject<((field: ViewField) => void) | undefined>(VIEW_KV_FIELD_VIEW_KEY)
const isMuted = computed(
() => !props.field.money && formatViewValue(props.field.value) === '—'
)
const showViewBtn = computed(() => {
if (!props.field.viewTarget || !props.field.viewId || !onFieldView) return false
if (props.field.viewTarget !== 'customer' && formatViewValue(props.field.value) === '—') {
return false
}
return true
})
function handleView() {
onFieldView?.(props.field)
}
</script>
<style scoped lang="scss">
.view-kv-table-cell {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.view-kv-field-link {
margin: 0;
padding: 0 4px;
height: auto;
font-weight: 500;
}
.view-plain-list {
margin: 0;
padding-left: 1.15em;
@@ -52,14 +52,24 @@
>
{{ group.title }}
</div>
<ViewKvTable
v-if="group.fields.length && group.fieldLayout === 'table'"
:fields="group.fields"
:mode="group.tableMode || 'triple'"
/>
<ViewKvGrid
v-if="group.fields.length"
v-else-if="group.fields.length"
:fields="group.fields"
:columns="splitGridColumns"
/>
<p v-else-if="group.title" class="view-empty-hint">{{ group.emptyHint || '—' }}</p>
</div>
</template>
<ViewKvTable
v-else-if="section.fields.length && section.fieldLayout === 'table'"
:fields="section.fields"
:mode="section.tableMode || 'triple'"
/>
<ViewKvGrid
v-else-if="section.fields.length"
:fields="section.fields"
@@ -118,14 +128,24 @@
>
{{ group.title }}
</div>
<ViewKvTable
v-if="group.fields.length && group.fieldLayout === 'table'"
:fields="group.fields"
:mode="group.tableMode || 'triple'"
/>
<ViewKvGrid
v-if="group.fields.length"
v-else-if="group.fields.length"
:fields="group.fields"
:columns="effectiveGridColumns"
/>
<p v-else-if="group.title" class="view-empty-hint">{{ group.emptyHint || '—' }}</p>
</div>
</template>
<ViewKvTable
v-else-if="section.fields.length && section.fieldLayout === 'table'"
:fields="section.fields"
:mode="section.tableMode || 'triple'"
/>
<ViewKvGrid
v-else-if="section.fields.length"
:fields="section.fields"
@@ -158,16 +178,19 @@
import { computed, useSlots, type Component } from 'vue'
import { Close, Document } from '@element-plus/icons-vue'
import ViewKvGrid from './ViewKvGrid.vue'
import ViewKvTable from './ViewKvTable.vue'
import {
isIconComponent,
type TagType,
type ViewField,
type ViewFieldGroup,
type ViewFieldLayout,
type ViewGridColumns,
type ViewSection
type ViewSection,
type ViewTableMode
} from './types'
export type { TagType, ViewField, ViewFieldGroup, ViewSection }
export type { TagType, ViewField, ViewFieldGroup, ViewFieldLayout, ViewSection, ViewTableMode }
const props = withDefaults(
defineProps<{
@@ -405,7 +428,7 @@
.view-field-label {
margin: 0 0 8px;
font-size: 11px;
font-size: 13px;
font-weight: 400;
color: #000;
line-height: 1.4;
@@ -483,7 +506,7 @@
}
:deep(.view-kv) {
font-size: 12px;
font-size: 13px;
}
.view-field-label {
@@ -18,13 +18,22 @@ export interface ViewField {
fullSpan?: boolean
tagType?: TagType
money?: boolean
/** 值后展示「查看」按钮,点击时 ViewKvTable 抛出 field-view */
viewTarget?: 'customer' | 'opportunity' | 'quote'
viewId?: string | number | null
}
export type ViewFieldLayout = 'grid' | 'table'
export type ViewTableMode = 'pair' | 'dual' | 'triple' | 'quad'
export interface ViewFieldGroup {
title?: string
titleStyle?: 'section' | 'field'
fields: ViewField[]
emptyHint?: string
/** 默认 grid;table 时使用 ViewKvTable */
fieldLayout?: ViewFieldLayout
tableMode?: ViewTableMode
}
export interface ViewSection {
@@ -32,6 +41,9 @@ export interface ViewSection {
fields: ViewField[]
fieldGroups?: ViewFieldGroup[]
titleStyle?: 'section' | 'field'
/** 默认 grid;table 时使用 ViewKvTable(如 triple = 一行三组 / 2 行 3 列) */
fieldLayout?: ViewFieldLayout
tableMode?: ViewTableMode
slotTitle?: string
slotTitleStyle?: 'section' | 'field'
slot?: string
-11
View File
@@ -485,17 +485,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'db:weighing:list'
}
},
{
id: 'DB00000000000000000000000000006',
name: 'DbVehicles',
path: 'vehicles',
component: '/db/vehicles/index',
meta: {
title: '车牌管理',
keepAlive: true,
menuCode: 'db:vehicle:list'
}
}
]
},
+1 -1
View File
@@ -1559,7 +1559,7 @@ body {
display: grid;
grid-template-columns: 96px minmax(0, 1fr) 96px minmax(0, 1fr);
gap: 14px 28px;
font-size: 13px;
font-size: 14px;
align-items: start;
}
+8 -2
View File
@@ -286,17 +286,23 @@
background: var(--primary);
}
.db-dual-bar .chart-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.db-dual-bar .chart-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
margin-bottom: 8px;
}
.db-dual-bar .chart-label {
flex-shrink: 0;
min-width: 80px;
max-width: 140px;
max-width: 160px;
font-size: 13px;
color: var(--text);
overflow: hidden;
+1 -1
View File
@@ -14,7 +14,7 @@ type RetryableConfig = InternalAxiosRequestConfig & {
}
/** 地磅集合根路径(后端 @router.get("/") 注册,须带尾斜杠,否则 307 重定向会丢 Authorization) */
const DB_COLLECTION_ROOT = /^\/(plans|inventory|weighing|master-data\/vehicles)$/
const DB_COLLECTION_ROOT = /^\/(plans|inventory|weighing)$/
function normalizeDbUrl(url?: string): string | undefined {
if (!url || url.endsWith('/')) return url
-2
View File
@@ -43,7 +43,6 @@ export const SIDEBAR_MENU_ICON_MAP: Record<string, string> = {
DbPlans: 'ri:calendar-schedule-line',
DbInventory: 'ri:archive-line',
DbWeighing: 'ri:scales-line',
DbVehicles: 'ri:car-line',
// ── 工作流 ──
Workflow: 'workflow',
@@ -101,7 +100,6 @@ export const SIDEBAR_MENU_ICON_BY_CODE: Record<string, string> = {
'db:plan:list': 'ri:calendar-schedule-line',
'db:inventory:list': 'ri:archive-line',
'db:weighing:list': 'ri:scales-line',
'db:vehicle:list': 'ri:car-line',
'workflow:process': 'workflow',
'workflow:pending': 'ri:inbox-line',
'workflow:completed': 'ri:checkbox-circle-line',
+33
View File
@@ -123,3 +123,36 @@ export function buildViewKvTableTripleRows(
}
return rows
}
export type ViewKvTableQuadRow =
| { type: 'long'; field: ViewField }
| { type: 'quad'; fields: ViewField[] }
/** KV 表格模式(四组八列):长字段独占行,短字段最多四个一组 */
export function buildViewKvTableQuadRows(
fields: ViewField[],
formatValue: (val: unknown) => string
): ViewKvTableQuadRow[] {
const rows: ViewKvTableQuadRow[] = []
let i = 0
while (i < fields.length) {
const field = fields[i]
const text = getViewFieldDisplayText(field, formatValue)
if (isViewFieldLong(field, text) || isViewFieldFullSpan(field, text)) {
rows.push({ type: 'long', field })
i += 1
continue
}
const group: ViewField[] = [field]
i += 1
while (i < fields.length && group.length < 4) {
const next = fields[i]
const nextText = getViewFieldDisplayText(next, formatValue)
if (isViewFieldLong(next, nextText) || isViewFieldFullSpan(next, nextText)) break
group.push(next)
i += 1
}
rows.push({ type: 'quad', fields: group })
}
return rows
}
+31 -22
View File
@@ -3,7 +3,7 @@
v-model:visible="visible"
:title="clueInfo.clueName || '线索详情'"
:icon="User"
width="1200px"
width="1400px"
layout="split"
:meta="viewHeroMeta"
:sections="viewSections"
@@ -212,6 +212,15 @@
}
}
function viewTableSection(title: string, fields: ViewField[]): ViewSection {
return {
title,
fields,
fieldLayout: 'table',
tableMode: 'dual'
}
}
function parseClueRemark(remark?: string | null) {
if (!remark?.trim()) return { remarkText: '', invalidReason: '' }
const marker = '无效原因:'
@@ -258,38 +267,31 @@
}
const sections: ViewSection[] = [
{ title: '基本信息', fields: basicFields },
{ title: '线索信息', fields: clueFields }
viewTableSection('基本信息', basicFields),
viewTableSection('线索信息', clueFields)
]
if (c.intentProducts || c.requirement) {
sections.push({
title: '需求信息',
fields: [
{ label: '意向产品', value: c.intentProducts, span: true },
{ label: '需求描述', value: c.requirement, span: true }
]
})
sections.push(
viewTableSection('需求信息', [
{ label: '意向产品', value: c.intentProducts },
{ label: '需求描述', value: c.requirement }
])
)
}
const { remarkText, invalidReason } = parseClueRemark(c.remark)
if (remarkText || invalidReason) {
const remarkFields: ViewField[] = []
if (remarkText) {
remarkFields.push({ label: '备注', value: remarkText, span: true })
remarkFields.push({ label: '备注', value: remarkText })
}
if (invalidReason) {
remarkFields.push({ label: '无效原因', value: invalidReason, span: true })
remarkFields.push({ label: '无效原因', value: invalidReason })
}
sections.push({
title: '备注信息',
fields: remarkFields
})
sections.push(viewTableSection('备注信息', remarkFields))
} else if (c.remark) {
sections.push({
title: '备注信息',
fields: [{ label: '备注', value: c.remark, span: true }]
})
sections.push(viewTableSection('备注信息', [{ label: '备注', value: c.remark }]))
}
return sections
@@ -483,7 +485,13 @@
<style lang="scss">
.clue-view-dialog.system-view-dialog {
&.el-dialog {
max-width: calc(100vw - 32px);
max-width: calc(100vw - 48px);
}
.view-split-left {
flex: 0 0 580px;
width: 580px;
max-width: 50%;
}
.view-split-left .view-hero {
@@ -497,7 +505,7 @@
flex-basis: 100%;
padding-top: 12px;
margin-top: 4px;
border-top: 1px solid rgba(217, 232, 226, 0.85);
justify-content: flex-end;
}
.clue-view-hero-actions {
@@ -505,6 +513,7 @@
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
width: 100%;
+9 -9
View File
@@ -2,7 +2,7 @@
<el-dialog
v-model="dialog.visible"
:title="dialog.title"
width="960px"
width="1160px"
class="dialog-form-layout"
align-center
destroy-on-close
@@ -18,7 +18,7 @@
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<el-divider content-position="left">基本信息</el-divider>
<div class="form-grid-3">
<div class="form-grid-4">
<el-form-item label="线索姓名" prop="clueName">
<el-input v-model="form.clueName" placeholder="请输入线索姓名" maxlength="100" />
</el-form-item>
@@ -34,7 +34,7 @@
<el-form-item label="公司名称" prop="company">
<el-input v-model="form.company" placeholder="请输入公司名称" maxlength="200" />
</el-form-item>
<el-form-item label="客户类型" prop="customerType">
<el-form-item label="客户类型" prop="customerType" class="form-field-span-2">
<el-select v-model="form.customerType" placeholder="请选择客户类型" clearable>
<el-option
v-for="opt in dictStore.getOptions('crm_customer_type')"
@@ -44,13 +44,13 @@
/>
</el-select>
</el-form-item>
<el-form-item label="客户网站" prop="website" class="form-col-span-2">
<el-form-item label="客户网站" prop="website" class="form-field-span-2">
<el-input v-model="form.website" placeholder="请输入客户网站" maxlength="500" />
</el-form-item>
</div>
<el-divider content-position="left">详细信息</el-divider>
<div class="form-grid-3">
<div class="form-grid-4">
<el-form-item label="线索来源" prop="source">
<el-select v-model="form.source" placeholder="请选择线索来源" clearable>
<el-option
@@ -113,8 +113,8 @@
</div>
<el-divider content-position="left">需求信息</el-divider>
<div class="form-grid-3">
<el-form-item label="意向产品">
<div class="form-grid-4">
<el-form-item label="意向产品" class="form-field-span-2">
<el-input
v-model="form.intentProducts"
placeholder="请输入意向产品,多个用逗号分隔"
@@ -133,7 +133,7 @@
</div>
<el-divider content-position="left">分配信息</el-divider>
<div class="form-grid-3">
<div class="form-grid-4">
<el-form-item label="负责人" prop="ownerUserId">
<el-select v-model="form.ownerUserId" placeholder="请选择负责人" clearable filterable>
<el-option
@@ -391,7 +391,7 @@
</script>
<style scoped lang="scss">
.form-grid-3 .form-field-span-2 {
.form-grid-4 .form-field-span-2 {
grid-column: span 2;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -114,8 +114,8 @@
@change="handleDateRangeChange"
/>
</el-form-item>
<el-form-item label="供方主体" prop="supplierId" required>
<el-select v-model="form.supplierId" placeholder="请选择供方主体" clearable style="width: 100%" @change="handleSupplierChange">
<el-form-item label="交易主体" prop="supplierId" required>
<el-select v-model="form.supplierId" placeholder="请选择交易主体" clearable style="width: 100%" @change="handleSupplierChange">
<el-option v-for="supplier in supplierList" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" />
</el-select>
</el-form-item>
@@ -180,7 +180,23 @@
<div class="party-flow-bridge" aria-hidden="true">
<span class="party-flow-bridge__label">供货</span>
<el-icon class="party-flow-bridge__arrow"><Right /></el-icon>
<span class="party-flow-bridge__arrow">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path
d="M5 12h11.5"
stroke="currentColor"
stroke-width="2.75"
stroke-linecap="round"
/>
<path
d="M13.5 8.5 18.5 12l-5 3.5"
stroke="currentColor"
stroke-width="2.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</div>
<div class="party-panel">
@@ -527,7 +543,6 @@ import {
Document,
Delete,
Sort,
Right,
Plus,
Search,
Close,
@@ -684,7 +699,7 @@ const rules = ref({
contactId: [{ required: true, message: '联系人不能为空', trigger: 'change' }],
signDate: [{ required: true, message: '签订日期不能为空', trigger: 'change' }],
ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'change' }],
supplierId: [{ required: true, message: '供方主体不能为空', trigger: 'change' }],
supplierId: [{ required: true, message: '交易主体不能为空', trigger: 'change' }],
startDate: [
{
validator: (_rule: any, _value: any, callback: any) => {
@@ -1759,7 +1774,7 @@ const handleSaveDraft = async () => {
return
}
if (!form.value.supplierId) {
$modal.msgWarning('供方主体不能为空')
$modal.msgWarning('交易主体不能为空')
return
}
@@ -2136,20 +2151,39 @@ const loadSupplierList = async () => {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
gap: 10px;
padding: 0 8px;
}
.party-flow-bridge__label {
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: var(--primary-dark, #059669);
letter-spacing: 0.04em;
letter-spacing: 0.06em;
padding: 4px 10px;
border-radius: 999px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
line-height: 1.2;
}
.party-flow-bridge__arrow {
font-size: 28px;
color: var(--primary, #10b981);
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
color: var(--primary-dark, #059669);
background: linear-gradient(145deg, #ecfdf5 0%, #d1fae5 100%);
border: 1px solid #a7f3d0;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.14);
svg {
width: 22px;
height: 22px;
display: block;
}
}
@media (max-width: 1100px) {
@@ -2161,6 +2195,7 @@ const loadSupplierList = async () => {
.party-flow-bridge {
flex: 0 0 auto;
flex-direction: row;
gap: 12px;
padding: 12px 0;
}
File diff suppressed because it is too large. Load diff
@@ -457,12 +457,12 @@
<CustomerForm ref="customerFormRef" @success="getCustomerInfo" />
<FollowForm ref="followFormRef" @success="handleFollowSuccess" />
<ContactForm ref="contactFormRef" @success="handleContactSuccess" />
<OpportunityDetailDrawer ref="opportunityDetailRef" />
<ContractDetailDialog ref="contractDetailRef" />
<OpportunityDetailDrawer v-if="opportunityDialogActive" ref="opportunityDetailRef" />
<ContractDetailDialog v-if="contractDialogActive" ref="contractDetailRef" />
</template>
<script setup lang="ts" name="CustomerDetail">
import { computed, ref } from 'vue'
import { computed, ref, defineAsyncComponent, nextTick } from 'vue'
import { User, Edit, Delete, Plus, View, Star } from '@element-plus/icons-vue'
import SystemViewDialog, {
type ViewField,
@@ -491,8 +491,13 @@ import CustomerForm from './CustomerFormDialog.vue'
import FollowForm from './FollowFormDialog.vue'
import ContactForm from './components/ContactFormDialog.vue'
import InvoiceInfo from './components/InvoiceInfo.vue'
import OpportunityDetailDrawer from '@/views/crm/opportunity/components/OpportunityDetailDrawer.vue'
import ContractDetailDialog from '@/views/crm/contract/components/ContractDetailDialog.vue'
const OpportunityDetailDrawer = defineAsyncComponent(
() => import('@/views/crm/opportunity/components/OpportunityDetailDrawer.vue')
)
const ContractDetailDialog = defineAsyncComponent(
() => import('@/views/crm/contract/components/ContractDetailDialog.vue')
)
const dictStore = useDictStore()
const userStore = useUserStore()
@@ -528,8 +533,10 @@ const customerFormRef = ref<InstanceType<typeof CustomerForm>>()
const followFormRef = ref<InstanceType<typeof FollowForm>>()
const contactFormRef = ref<InstanceType<typeof ContactForm>>()
const invoiceInfoRef = ref()
const opportunityDetailRef = ref<InstanceType<typeof OpportunityDetailDrawer>>()
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
const opportunityDetailRef = ref<{ open: (id: string | number) => Promise<void> }>()
const contractDetailRef = ref<{ open: (id: string | number) => Promise<void> }>()
const opportunityDialogActive = ref(false)
const contractDialogActive = ref(false)
const isOverdue = computed(() => {
if (!customerInfo.value.nextTime) return false
@@ -565,6 +572,15 @@ function tagField(
}
}
function viewTableSection(title: string, fields: ViewField[]): ViewSection {
return {
title,
fields,
fieldLayout: 'table',
tableMode: 'pair'
}
}
const viewSections = computed<ViewSection[]>(() => {
const c = customerInfo.value
if (!c?.id) return []
@@ -604,8 +620,8 @@ const viewSections = computed<ViewSection[]>(() => {
}
const sections: ViewSection[] = [
{ title: '基本信息', fields: basicFields },
{ title: '业务信息', fields: bizFields }
viewTableSection('基本信息', basicFields),
viewTableSection('业务信息', bizFields)
]
if (c.businessLicenseImages || c.businessPermitImages || c.otherCertImages) {
@@ -617,10 +633,7 @@ const viewSections = computed<ViewSection[]>(() => {
}
if (c.remark) {
sections.push({
title: '备注',
fields: [{ label: '备注', value: c.remark, span: true }]
})
sections.push(viewTableSection('备注', [{ label: '备注', value: c.remark, span: true }]))
}
return sections
@@ -798,11 +811,15 @@ function handleViewLogDetail(row: OperLogVO) {
})
}
function handleViewOpportunity(id: number | string) {
async function handleViewOpportunity(id: number | string) {
opportunityDialogActive.value = true
await nextTick()
opportunityDetailRef.value?.open(id)
}
function handleViewContract(id: string | number) {
async function handleViewContract(id: string | number) {
contractDialogActive.value = true
await nextTick()
contractDetailRef.value?.open(id)
}
@@ -1024,6 +1041,12 @@ defineExpose({ open })
max-width: calc(100vw - 100px);
}
.view-split-left {
flex: 0 0 580px;
width: 580px;
max-width: 50%;
}
.view-split-left .view-hero {
flex-wrap: wrap;
align-items: flex-start;
@@ -1033,7 +1056,7 @@ defineExpose({ open })
flex-basis: 100%;
margin-left: 0;
margin-top: 12px;
justify-content: flex-start;
justify-content: flex-end;
}
.customer-view-hero-actions {
@@ -1041,6 +1064,7 @@ defineExpose({ open })
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
width: 100%;
@@ -18,7 +18,7 @@
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<el-divider content-position="left">基本信息</el-divider>
<div class="form-grid-3">
<div class="form-grid-4">
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="form.customerName" placeholder="请输入客户名称" maxlength="200" />
</el-form-item>
@@ -60,7 +60,7 @@
</div>
<el-divider content-position="left">详细信息</el-divider>
<div class="form-grid-3">
<div class="form-grid-4">
<el-form-item label="客户来源" prop="source">
<el-select v-model="form.source" placeholder="请选择客户来源" clearable>
<el-option
@@ -123,7 +123,7 @@
</div>
<el-divider content-position="left">分配信息</el-divider>
<div class="form-grid-3">
<div class="form-grid-4">
<el-form-item label="负责人" prop="ownerUserId">
<el-select v-model="form.ownerUserId" placeholder="请选择负责人" clearable filterable>
<el-option
@@ -149,7 +149,7 @@
</div>
<el-divider content-position="left">证件信息</el-divider>
<div class="form-grid-3">
<div class="form-grid-4">
<el-form-item label="营业执照" prop="businessLicenseImages">
<AttachmentUploadField
ref="businessLicenseUploadRef"
@@ -186,7 +186,7 @@
<template v-if="!form.id">
<el-divider content-position="left">开票信息</el-divider>
<div class="form-grid-3">
<div class="form-grid-4">
<el-form-item label="单位名称(章)">
<el-input
v-model="invoiceForm.companyName"
@@ -617,7 +617,7 @@ defineExpose({ open })
</script>
<style scoped lang="scss">
.form-grid-3 .form-field-span-2 {
.form-grid-4 .form-field-span-2 {
grid-column: span 2;
}
</style>
@@ -42,110 +42,110 @@
</template>
<template #split-right>
<div class="opportunity-view-side">
<div class="opportunity-view-side-block">
<div class="opportunity-view-side-head">
<h3>跟进记录</h3>
<el-button
v-if="hasOpportunityPerm('follow')"
class="btn-main"
type="primary"
size="small"
:disabled="!isFollowing"
@click="handleFollow"
>
<el-icon><Plus /></el-icon>添加跟进
</el-button>
</div>
<div class="opportunity-view-tabs-section">
<el-tabs v-model="activeSideTab">
<el-tab-pane label="跟进记录" name="follow">
<div class="tab-toolbar">
<el-button
v-if="hasOpportunityPerm('follow')"
class="btn-main"
type="primary"
size="small"
:disabled="!isFollowing"
@click="handleFollow"
>
<el-icon><Plus /></el-icon>添加跟进
</el-button>
</div>
<el-timeline v-if="followList.length > 0">
<el-timeline-item
v-for="item in followList"
:key="item.id"
:timestamp="formatDateTime(item.followTime || item.createTime)"
placement="top"
>
<div class="follow-card">
<div class="follow-header">
<div class="follow-header-left">
<el-tag
:type="resolveOpportunityTagType(dictStore, 'crm_follow_type', item.followType)"
size="small"
>
{{
resolveOpportunityDictLabel(dictStore, 'crm_follow_type', item.followType) ||
item.followType
}}
</el-tag>
<span class="follow-creator">{{ item.followByName || item.createByName || '—' }}</span>
<el-timeline v-if="followList.length > 0">
<el-timeline-item
v-for="item in followList"
:key="item.id"
:timestamp="formatDateTime(item.followTime || item.createTime)"
placement="top"
>
<div class="follow-card">
<div class="follow-header">
<div class="follow-header-left">
<el-tag
:type="resolveOpportunityTagType(dictStore, 'crm_follow_type', item.followType)"
size="small"
>
{{
resolveOpportunityDictLabel(dictStore, 'crm_follow_type', item.followType) ||
item.followType
}}
</el-tag>
<span class="follow-creator">{{ item.followByName || item.createByName || '—' }}</span>
</div>
<div v-if="String(item.followBy) === String(currentUserId)" class="follow-actions">
<el-button link type="primary" size="small" @click="handleEditFollow(item)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button link type="danger" size="small" @click="handleDeleteFollow(item.id!)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<div v-if="String(item.followBy) === String(currentUserId)" class="follow-actions">
<el-button link type="primary" size="small" @click="handleEditFollow(item)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button link type="danger" size="small" @click="handleDeleteFollow(item.id!)">
<el-icon><Delete /></el-icon>
</el-button>
<div class="follow-content">{{ item.followContent }}</div>
<div v-if="item.nextTime" class="follow-meta follow-meta--warn">
下次联系:{{ formatDateTime(item.nextTime) }}
</div>
<div v-if="item.nextPlan || item.nextContent" class="follow-meta">
下次计划:{{ item.nextPlan || item.nextContent }}
</div>
<AttachmentUploadField
v-if="item.attachments"
:model-value="item.attachments"
disabled
/>
<div v-if="item.remark" class="follow-meta">备注:{{ item.remark }}</div>
</div>
<div class="follow-content">{{ item.followContent }}</div>
<div v-if="item.nextTime" class="follow-meta follow-meta--warn">
下次联系:{{ formatDateTime(item.nextTime) }}
</div>
<div v-if="item.nextPlan || item.nextContent" class="follow-meta">
下次计划:{{ item.nextPlan || item.nextContent }}
</div>
<AttachmentUploadField
v-if="item.attachments"
:model-value="item.attachments"
disabled
/>
<div v-if="item.remark" class="follow-meta">备注:{{ item.remark }}</div>
</div>
</el-timeline-item>
</el-timeline>
<p v-else class="view-empty-hint">暂无跟进记录</p>
</div>
</el-timeline-item>
</el-timeline>
<p v-else class="view-empty-hint">暂无跟进记录</p>
</el-tab-pane>
<div class="opportunity-view-side-block">
<h3>阶段记录</h3>
<el-timeline v-if="stageLogList.length > 0">
<el-timeline-item
v-for="item in stageLogList"
:key="item.id"
:timestamp="formatDateTime(item.createTime)"
placement="top"
>
<div class="stage-card">
<div class="stage-change-row">
<el-tag
v-if="item.fromStage || item.fromStageName"
:type="resolveOpportunityTagType(dictStore, 'crm_opportunity_stage', item.fromStage)"
size="small"
>
{{ resolveOpportunityStageLabel(dictStore, item.fromStage, item.fromStageName) }}
</el-tag>
<span v-else class="stage-dash">—</span>
<el-icon class="stage-arrow"><Right /></el-icon>
<el-tag
:type="resolveOpportunityTagType(dictStore, 'crm_opportunity_stage', item.toStage)"
size="small"
>
{{ resolveOpportunityStageLabel(dictStore, item.toStage, item.toStageName) }}
</el-tag>
<el-tab-pane label="阶段记录" name="stage">
<el-timeline v-if="stageLogList.length > 0">
<el-timeline-item
v-for="item in stageLogList"
:key="item.id"
:timestamp="formatDateTime(item.createTime)"
placement="top"
>
<div class="stage-card">
<div class="stage-change-row">
<el-tag
v-if="item.fromStage || item.fromStageName"
:type="resolveOpportunityTagType(dictStore, 'crm_opportunity_stage', item.fromStage)"
size="small"
>
{{ resolveOpportunityStageLabel(dictStore, item.fromStage, item.fromStageName) }}
</el-tag>
<span v-else class="stage-dash">—</span>
<el-icon class="stage-arrow"><Right /></el-icon>
<el-tag
:type="resolveOpportunityTagType(dictStore, 'crm_opportunity_stage', item.toStage)"
size="small"
>
{{ resolveOpportunityStageLabel(dictStore, item.toStage, item.toStageName) }}
</el-tag>
</div>
<p v-if="item.toPercent != null" class="stage-meta">
阶段进度:<strong>{{ item.toPercent }}%</strong>
</p>
<p v-if="item.changeReason" class="stage-meta">变更原因:{{ item.changeReason }}</p>
<p v-if="item.lostDetail" class="stage-meta stage-meta--danger">输单说明:{{ item.lostDetail }}</p>
<p v-if="item.wonRemark" class="stage-meta stage-meta--success">赢单说明:{{ item.wonRemark }}</p>
<p class="stage-meta">变更人:{{ item.createByName || '—' }}</p>
</div>
<p v-if="item.toPercent != null" class="stage-meta">
阶段进度:<strong>{{ item.toPercent }}%</strong>
</p>
<p v-if="item.changeReason" class="stage-meta">变更原因:{{ item.changeReason }}</p>
<p v-if="item.lostDetail" class="stage-meta stage-meta--danger">输单说明:{{ item.lostDetail }}</p>
<p v-if="item.wonRemark" class="stage-meta stage-meta--success">赢单说明:{{ item.wonRemark }}</p>
<p class="stage-meta">变更人:{{ item.createByName || '—' }}</p>
</div>
</el-timeline-item>
</el-timeline>
<p v-else class="view-empty-hint">暂无阶段变更记录</p>
</div>
</el-timeline-item>
</el-timeline>
<p v-else class="view-empty-hint">暂无阶段变更记录</p>
</el-tab-pane>
</el-tabs>
</div>
</template>
</SystemViewDialog>
@@ -215,10 +215,15 @@ const userStore = useUserStore()
const { loadPerms, hasOpportunityPerm } = useOpportunityPerm()
const currentUserId = computed(() => userStore.info?.userId || '')
const props = defineProps<{
modelValue: boolean
opportunityId?: number | string
}>()
const props = withDefaults(
defineProps<{
modelValue?: boolean
opportunityId?: number | string
}>(),
{
modelValue: false
}
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
@@ -235,6 +240,7 @@ const formVisible = ref(false)
const changeStageVisible = ref(false)
const followVisible = ref(false)
const currentFollowData = ref<OpportunityFollowVO | null>(null)
const activeSideTab = ref<'follow' | 'stage'>('follow')
const currentOpportunityId = computed(() => props.opportunityId ?? opportunityInfo.value?.id)
@@ -278,6 +284,15 @@ function tagField(
}
}
function viewTableSection(title: string, fields: ViewField[]): ViewSection {
return {
title,
fields,
fieldLayout: 'table',
tableMode: 'dual'
}
}
const viewHeroMeta = computed(() => {
void dictStore.loaded
const o = opportunityInfo.value
@@ -330,19 +345,18 @@ const viewSections = computed<ViewSection[]>(() => {
]
const sections: ViewSection[] = [
{ title: '基本信息', fields: basicFields },
{ title: '跟进统计', fields: trackFields }
viewTableSection('基本信息', basicFields),
viewTableSection('跟进统计', trackFields)
]
if (o.competitors || o.keyFactors || o.description) {
sections.push({
title: '业务信息',
fields: [
sections.push(
viewTableSection('业务信息', [
{ label: '竞争对手', value: o.competitors, span: true },
{ label: '关键决策因素', value: o.keyFactors, span: true },
{ label: '商机描述', value: o.description, span: true }
]
})
])
)
}
if (o.status === 'lost' && o.lostReason) {
@@ -353,10 +367,7 @@ const viewSections = computed<ViewSection[]>(() => {
}
if (o.remark) {
sections.push({
title: '备注',
fields: [{ label: '备注', value: o.remark, span: true }]
})
sections.push(viewTableSection('备注', [{ label: '备注', value: o.remark, span: true }]))
}
return sections
@@ -367,6 +378,7 @@ watch(
async (val) => {
visible.value = val
if (val && props.opportunityId) {
activeSideTab.value = 'follow'
await loadPerms()
await loadData()
}
@@ -378,6 +390,7 @@ watch(visible, (val) => {
})
const open = async (id: string | number) => {
activeSideTab.value = 'follow'
await loadPerms()
visible.value = true
await loadDetail(id)
@@ -484,30 +497,38 @@ defineExpose({ open })
</script>
<style scoped lang="scss">
.opportunity-view-side {
display: flex;
flex-direction: column;
gap: 24px;
}
.opportunity-view-tabs-section {
min-height: 100%;
.opportunity-view-side-block {
h3 {
margin: 0;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: #1e293b;
border-left: 3px solid #10b981;
line-height: 1;
:deep(.el-tabs) {
display: flex;
flex-direction: column;
min-height: calc(100vh - 200px);
}
:deep(.el-tabs__header) {
margin-bottom: 12px;
padding: 0 4px;
border: none;
background: transparent;
}
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
}
:deep(.el-tab-pane) {
min-height: 320px;
}
}
.opportunity-view-side-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
.tab-toolbar {
margin-bottom: 10px;
}
.follow-card,
@@ -593,7 +614,18 @@ defineExpose({ open })
<style lang="scss">
.opportunity-view-dialog.system-view-dialog {
&.el-dialog {
max-width: calc(100vw - 200px);
max-width: calc(100vw - 48px);
}
.view-split-left {
flex: 0 0 580px;
width: 580px;
max-width: 50%;
}
.view-split-right {
flex: 1;
min-width: 0;
}
.view-split-left .view-hero {
@@ -605,7 +637,7 @@ defineExpose({ open })
flex-basis: 100%;
margin-left: 0;
margin-top: 12px;
justify-content: flex-start;
justify-content: flex-end;
}
.opportunity-view-hero-actions {
@@ -613,6 +645,7 @@ defineExpose({ open })
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
width: 100%;
@@ -622,5 +655,9 @@ defineExpose({ open })
justify-content: center;
}
}
.view-split-right .opportunity-view-tabs-section {
padding: 0;
}
}
</style>
@@ -2,163 +2,165 @@
<el-dialog
:title="title"
v-model="visible"
width="800px"
width="1160px"
class="dialog-form-layout"
align-center
append-to-body
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
:destroy-on-close="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商机名称" prop="opportunityName">
<el-input v-model="form.opportunityName" placeholder="请输入商机名称" maxlength="200" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="关联客户" prop="customerId">
<el-select
v-model="form.customerId"
placeholder="请输入客户名称搜索"
filterable
remote
:remote-method="searchCustomer"
:loading="customerLoading"
clearable
style="width: 100%"
@change="handleCustomerChange"
@focus="handleCustomerFocus"
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-4">
<el-form-item label="商机名称" prop="opportunityName">
<el-input v-model="form.opportunityName" placeholder="请输入商机名称" maxlength="200" />
</el-form-item>
<el-form-item label="关联客户" prop="customerId">
<el-select
v-model="form.customerId"
placeholder="请输入客户名称搜索"
filterable
remote
:remote-method="searchCustomer"
:loading="customerLoading"
clearable
style="width: 100%"
@change="handleCustomerChange"
@focus="handleCustomerFocus"
>
<el-option
v-for="item in customerList"
:key="item.id"
:label="`${item.customerNo || ''} - ${item.customerName}`"
:value="item.id"
>
<el-option v-for="item in customerList" :key="item.id" :label="`${item.customerNo || ''} - ${item.customerName}`" :value="item.id">
<div style="display: flex; justify-content: space-between; align-items: center">
<span>{{ item.customerName }}</span>
<span style="color: #8492a6; font-size: 13px; margin-left: 10px">{{ item.customerNo }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商机阶段" prop="stage">
<el-select v-model="form.stage" placeholder="请选择商机阶段" style="width: 100%" @change="handleStageChange">
<el-option
v-for="opt in dictStore.getOptions('crm_opportunity_stage')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="阶段进度" prop="stagePercent">
<el-slider v-model="form.stagePercent" :min="0" :max="100" :step="5" show-input />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="预估赢单率(%)" prop="winRate">
<el-input-number v-model="form.winRate" :min="0" :max="100" :precision="2" placeholder="请输入赢单率" style="width: 100%" />
</el-form-item>
</el-col>
<!-- <el-col :span="12">
<el-form-item label="预估金额" prop="amount">
<el-input v-model="form.amount" placeholder="请输入预估金额" type="number" :min="0" style="width: 100%">
<template #prepend>¥</template>
</el-input>
</el-form-item>
</el-col> -->
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="预计成交日期" prop="expectDealDate">
<el-date-picker v-model="form.expectDealDate" type="date" placeholder="选择预计成交日期" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人" prop="ownerUserId">
<el-select v-model="form.ownerUserId" placeholder="请选择负责人" filterable clearable style="width: 100%">
<el-option
v-for="user in userList"
:key="user.id"
:label="user.nickName"
:value="user.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商机类型" prop="type">
<el-select v-model="form.type" placeholder="请选择商机类型" clearable style="width: 100%">
<el-option
v-for="opt in typeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商机来源" prop="source">
<el-select v-model="form.source" placeholder="请选择商机来源" clearable style="width: 100%">
<el-option
v-for="opt in sourceOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="商机描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入商机描述" maxlength="500" show-word-limit />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="竞争对手" prop="competitors">
<el-input v-model="form.competitors" type="textarea" :rows="2" placeholder="请输入竞争对手信息" maxlength="500" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="关键决策因素" prop="keyFactors">
<el-input v-model="form.keyFactors" type="textarea" :rows="2" placeholder="请输入客户的关键决策因素" maxlength="500" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注信息" maxlength="500" />
</el-form-item>
</el-col>
</el-row>
<div style="display: flex; justify-content: space-between; align-items: center">
<span>{{ item.customerName }}</span>
<span style="color: #8492a6; font-size: 13px; margin-left: 10px">{{ item.customerNo }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="商机阶段" prop="stage">
<el-select
v-model="form.stage"
placeholder="请选择商机阶段"
style="width: 100%"
@change="handleStageChange"
>
<el-option
v-for="opt in dictStore.getOptions('crm_opportunity_stage')"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="预估赢单率(%)" prop="winRate">
<el-input-number
v-model="form.winRate"
:min="0"
:max="100"
:precision="2"
placeholder="请输入赢单率"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="预计成交日期" prop="expectDealDate">
<el-date-picker
v-model="form.expectDealDate"
type="date"
placeholder="选择预计成交日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="负责人" prop="ownerUserId">
<el-select
v-model="form.ownerUserId"
placeholder="请选择负责人"
filterable
clearable
style="width: 100%"
>
<el-option v-for="user in userList" :key="user.id" :label="user.nickName" :value="user.id" />
</el-select>
</el-form-item>
<el-form-item label="商机类型" prop="type">
<el-select v-model="form.type" placeholder="请选择商机类型" clearable style="width: 100%">
<el-option v-for="opt in typeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="商机来源" prop="source">
<el-select v-model="form.source" placeholder="请选择商机来源" clearable style="width: 100%">
<el-option v-for="opt in sourceOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="阶段进度(%)" prop="stagePercent">
<el-input-number
v-model="form.stagePercent"
:min="0"
:max="100"
:step="5"
placeholder="请输入阶段进度"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="商机描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="请输入商机描述"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="竞争对手" prop="competitors">
<el-input
v-model="form.competitors"
type="textarea"
:rows="2"
placeholder="请输入竞争对手信息"
maxlength="500"
/>
</el-form-item>
<el-form-item label="关键决策因素" prop="keyFactors">
<el-input
v-model="form.keyFactors"
type="textarea"
:rows="2"
placeholder="请输入客户的关键决策因素"
maxlength="500"
/>
</el-form-item>
<el-form-item label="备注" prop="remark" class="form-col-span-2">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
placeholder="请输入备注信息"
maxlength="500"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取 消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading">确 定</el-button>
<el-button class="btn-ghost" @click="handleClose">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="submitForm">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
@@ -434,6 +436,6 @@ const resetForm = () => {
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
gap: 8px;
}
</style>
+41 -28
View File
@@ -113,13 +113,30 @@
<el-table-column
prop="productName"
label="产品名称"
min-width="160"
min-width="200"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link type="primary" @click="handleView(row)">{{ row.productName }}</el-link>
<div class="product-name-cell">
<el-link type="primary" @click="handleView(row)">{{ row.productName }}</el-link>
<el-tag
v-if="Number(row.isToxic) === 1"
type="danger"
size="small"
class="product-hazard-tag"
>
毒
</el-tag>
<el-tag
v-if="Number(row.isExplosive) === 1"
type="danger"
size="small"
class="product-hazard-tag"
>
爆
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column
@@ -187,30 +204,6 @@
</el-tag>
</template>
</el-table-column>
<el-table-column
label="易制毒"
min-width="90"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="getHazardFlagTagType(row.isToxic)" size="small">
{{ formatHazardFlag(row.isToxic) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="易制爆"
min-width="90"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="getHazardFlagTagType(row.isExplosive)" size="small">
{{ formatHazardFlag(row.isExplosive) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
@@ -622,7 +615,7 @@ const productViewSections = computed<ViewSection[]>(() => {
]
const sections: ViewSection[] = [
{ title: '基本信息', fields: basicFields },
{ title: '基本信息', fields: basicFields, fieldLayout: 'table', tableMode: 'triple' },
{ title: '价格信息', fields: priceFields },
{ title: '产品图片', fields: [], slot: 'images' }
]
@@ -929,6 +922,26 @@ onMounted(() => {
font-weight: 600;
}
.product-name-cell {
display: inline-flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
max-width: 100%;
}
.product-name-cell .el-link {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-hazard-tag {
flex-shrink: 0;
}
.product-images {
display: flex;
flex-wrap: wrap;
@@ -288,11 +288,11 @@
</template>
</SystemViewDialog>
<CustomerDetailDialog ref="customerDetailRef" />
<CustomerDetailDialog v-if="customerDialogActive" ref="customerDetailRef" />
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, defineAsyncComponent, nextTick } from 'vue'
import {
CircleClose,
Delete,
@@ -306,7 +306,10 @@ import SystemViewDialog, {
type ViewSection
} from '@/components/business/system-view-dialog/index.vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import CustomerDetailDialog from '@/views/crm/customer/CustomerDetailDialog.vue'
const CustomerDetailDialog = defineAsyncComponent(
() => import('@/views/crm/customer/CustomerDetailDialog.vue')
)
import { useDictStore } from '@/store/modules/dict'
import { useQuotePerm } from '@/hooks/business/useQuotePerm'
import { $modal } from '@/utils/modal'
@@ -339,7 +342,8 @@ const visible = ref(false)
const loading = ref(false)
const quoteInfo = ref<QuoteVO>({} as QuoteVO)
const approvalList = ref<QuoteApprovalVO[]>([])
const customerDetailRef = ref<InstanceType<typeof CustomerDetailDialog> | null>(null)
const customerDetailRef = ref<{ open: (id: string | number) => Promise<void> } | null>(null)
const customerDialogActive = ref(false)
const currentQuoteId = ref<string | number>()
const isPurchase = computed(() => quoteInfo.value?.quoteType === 'purchase')
@@ -386,6 +390,16 @@ function getApprovalTimelineColor(result?: string) {
return '#94a3b8'
}
/** 8 列表格区块:禁用长字段独占行,保证每行尽量排满 4 组 */
function viewQuadTableSection(title: string, fields: ViewField[]): ViewSection {
return {
title,
fields: fields.map((field) => ({ ...field, long: false, fullSpan: false })),
fieldLayout: 'table',
tableMode: 'quad'
}
}
const viewHeroMeta = computed(() => {
void dictStore.loaded
const q = quoteInfo.value
@@ -444,7 +458,7 @@ const viewSections = computed<ViewSection[]>(() => {
]
const sections: ViewSection[] = [
{ title: '基本信息', fields: basicFields },
viewQuadTableSection('基本信息', basicFields),
{ title: '金额汇总', fields: amountFields },
{
title: '产品明细',
@@ -519,10 +533,11 @@ async function refresh() {
await loadDetail(currentQuoteId.value)
}
function handleViewCustomer() {
if (quoteInfo.value?.customerId) {
customerDetailRef.value?.open(quoteInfo.value.customerId as number)
}
async function handleViewCustomer() {
if (!quoteInfo.value?.customerId) return
customerDialogActive.value = true
await nextTick()
customerDetailRef.value?.open(quoteInfo.value.customerId as number)
}
function handleEdit() {
+3 -8
View File
@@ -455,7 +455,7 @@
/>
</el-form-item>
<el-form-item label="车牌号" prop="vehicleNo">
<DbVehicleSelect ref="vehicleSelectRef" v-model="formData.vehicleNo" />
<PlVehicleSelect v-model="formData.vehicleNo" />
</el-form-item>
<el-form-item label="计划号" prop="planNo" class="form-col-span-2">
<el-input v-model="formData.planNo" placeholder="请输入计划号" :readonly="isPlanNoReadonly" />
@@ -511,7 +511,7 @@
import DbCrmProductSelect from '@/components/business/db/DbCrmProductSelect.vue'
import DbInventoryRelatedViewDialog from '@/components/business/db/DbInventoryRelatedViewDialog.vue'
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
import DbVehicleSelect from '@/components/business/db/DbVehicleSelect.vue'
import PlVehicleSelect from '@/components/business/pl/PlVehicleSelect.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { $modal } from '@/utils/modal'
import {
@@ -549,7 +549,6 @@
const modalOpen = ref(false)
const editingRecord = ref<InventoryRecord | null>(null)
const vehicleSelectRef = ref<InstanceType<typeof DbVehicleSelect> | null>(null)
const relatedModalOpen = ref(false)
const loadingRelated = ref(false)
const relatedData = ref<InventoryRelatedData>({ plan: null, weighing: null })
@@ -597,7 +596,7 @@
partnerCompany: [{ required: true, message: '请输入往来单位', trigger: 'blur' }],
productId: [{ required: true, message: '请选择 CRM 产品', trigger: 'change' }],
quantity: [{ required: true, message: '请输入数量', trigger: 'blur' }],
vehicleNo: [{ required: true, message: '请输入车牌号', trigger: 'blur' }]
vehicleNo: [{ required: true, message: '请选择车牌号', trigger: 'change' }]
}
const formatKgToTon = (kg: number | undefined | null) => {
@@ -926,10 +925,6 @@
if (!valid) return
saving.value = true
try {
if (formData.vehicleNo?.trim()) {
await vehicleSelectRef.value?.ensureVehicleExists()
}
const cleanData = (data: Record<string, unknown>) => {
const cleaned: Record<string, unknown> = {}
for (const key in data) {
-289
View File
@@ -1,289 +0,0 @@
<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>
+3 -7
View File
@@ -357,7 +357,7 @@
scroll-to-error
>
<el-form-item label="车牌号" prop="vehicleNo">
<DbVehicleSelect ref="vehicleSelectRef" v-model="formData.vehicleNo" />
<PlVehicleSelect v-model="formData.vehicleNo" />
</el-form-item>
<el-form-item label="产品" prop="productId">
<DbCrmProductSelect
@@ -504,7 +504,7 @@
import DbCrmProductSelect from '@/components/business/db/DbCrmProductSelect.vue'
import DbRelatedViewDialog from '@/components/business/db/DbRelatedViewDialog.vue'
import { buildWeighingRelatedSections } from '@/components/business/db/db-related-view'
import DbVehicleSelect from '@/components/business/db/DbVehicleSelect.vue'
import PlVehicleSelect from '@/components/business/pl/PlVehicleSelect.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { $modal } from '@/utils/modal'
import { inventoryApi } from '@/api/db/inventory'
@@ -552,7 +552,6 @@
const timeRange = ref<[string, string] | null>([formatYmd(new Date()), formatYmd(new Date())])
const modalOpen = ref(false)
const vehicleSelectRef = ref<InstanceType<typeof DbVehicleSelect> | null>(null)
const editingRecord = ref<WeighingRecord | null>(null)
const relatedModalOpen = ref(false)
const loadingRelated = ref(false)
@@ -592,7 +591,7 @@
})
const formRules = {
vehicleNo: [{ required: true, message: '请输入车牌号', trigger: 'blur' }],
vehicleNo: [{ required: true, message: '请选择车牌号', trigger: 'change' }],
productId: [{ required: true, message: '请选择 CRM 产品', trigger: 'change' }],
supplier: [{ required: true, message: '请输入供货单位', trigger: 'blur' }],
consignee: [{ required: true, message: '请输入收货单位', trigger: 'blur' }]
@@ -903,9 +902,6 @@
if (!valid) return
saving.value = true
try {
if (formData.vehicleNo?.trim()) {
await vehicleSelectRef.value?.ensureVehicleExists()
}
const hasGrossWeight = formData.grossWeight && formData.grossWeight > 0
const hasTareWeight = formData.tareWeight && formData.tareWeight > 0
if (hasGrossWeight && hasTareWeight) {
+14 -7
View File
@@ -496,18 +496,25 @@ const dictStore = useDictStore()
const viewSections = computed(() => [
{
title: '基本信息',
fieldLayout: 'table' as const,
tableMode: 'triple' as const,
fields: [
{ label: '组织名称', value: viewRow.value?.deptName },
{ label: '上级组织', value: getParentDeptDisplay(viewRow.value?.parentId) },
{ label: '组织类别', value: getDeptCategoryLabel(viewRow.value?.deptCategoryCode || '') },
{ label: '组织名称', value: viewRow.value?.deptName, long: false },
{ label: '上级组织', value: getParentDeptDisplay(viewRow.value?.parentId), long: false },
{
label: '组织类别',
value: getDeptCategoryLabel(viewRow.value?.deptCategoryCode || ''),
long: false
},
{
label: '状态',
value: viewRow.value?.status === '1' ? '正常' : '停用',
tagType: viewRow.value?.status === '1' ? 'success' : 'info'
tagType: viewRow.value?.status === '1' ? 'success' : 'info',
long: false
},
{ label: '负责人', value: viewRow.value?.leader },
{ label: '联系电话', value: viewRow.value?.leaderPhone },
{ label: '备注', value: viewRow.value?.remark, span: true }
{ label: '负责人', value: viewRow.value?.leader, long: false },
{ label: '联系电话', value: viewRow.value?.leaderPhone, long: false },
{ label: '备注', value: viewRow.value?.remark, long: false }
]
}
])
+6 -4
View File
@@ -547,11 +547,13 @@
const viewSections = computed(() => [
{
title: '基本信息',
fieldLayout: 'table' as const,
tableMode: 'triple' as const,
fields: [
{ label: '字典名称', value: viewRow.value?.dictName },
{ label: '字典类型编码', value: viewRow.value?.dictType },
{ label: '备注', value: viewRow.value?.remark, span: true },
{ label: '创建时间', value: viewRow.value?.createTime }
{ label: '字典名称', value: viewRow.value?.dictName, long: false },
{ label: '字典类型编码', value: viewRow.value?.dictType, long: false },
{ label: '创建时间', value: viewRow.value?.createTime, long: false },
{ label: '备注', value: viewRow.value?.remark, long: false }
]
}
])
+26 -9
View File
@@ -62,6 +62,8 @@
min-width="180"
align="left"
header-align="left"
class-name="col-menu-name"
label-class-name="col-menu-name"
/>
<el-table-column
prop="menuType"
@@ -340,28 +342,33 @@
const viewSections = computed(() => [
{
title: '基本信息',
fieldLayout: 'table' as const,
tableMode: 'triple' as const,
fields: [
{ label: '菜单名称', value: viewRow.value?.menuName },
{ label: '菜单名称', value: viewRow.value?.menuName, long: false },
{
label: '菜单类型',
value: dictStore.getLabel('menu_type', viewRow.value?.menuType || ''),
tagType: dictStore.getTagType('menu_type', viewRow.value?.menuType || '')
tagType: dictStore.getTagType('menu_type', viewRow.value?.menuType || ''),
long: false
},
{ label: '路由路径', value: viewRow.value?.path },
{ label: '组件路径', value: viewRow.value?.component },
{ label: '权限标识', value: viewRow.value?.perms },
{ label: '是否外链', value: viewRow.value?.isFrame === 1 ? '否' : '是' },
{ label: '路由路径', value: viewRow.value?.path, long: false },
{ label: '组件路径', value: viewRow.value?.component, long: false },
{ label: '权限标识', value: viewRow.value?.perms, long: false },
{ label: '是否外链', value: viewRow.value?.isFrame === 1 ? '否' : '是', long: false },
{
label: '显示状态',
value: dictStore.getLabel('sys_visible', viewRow.value?.visible || ''),
tagType: dictStore.getTagType('sys_visible', viewRow.value?.visible || '')
tagType: dictStore.getTagType('sys_visible', viewRow.value?.visible || ''),
long: false
},
{
label: '状态',
value: dictStore.getLabel('sys_status', viewRow.value?.status || ''),
tagType: dictStore.getTagType('sys_status', viewRow.value?.status || '')
tagType: dictStore.getTagType('sys_status', viewRow.value?.status || ''),
long: false
},
{ label: '备注', value: viewRow.value?.remark, span: true }
{ label: '备注', value: viewRow.value?.remark, long: false }
]
}
])
@@ -570,4 +577,14 @@
margin-bottom: 0;
}
}
:deep(.col-menu-name.el-table__cell) {
text-align: left !important;
> .cell {
display: flex;
justify-content: flex-start !important;
text-align: left !important;
}
}
</style>
+8 -5
View File
@@ -389,16 +389,19 @@ const dictStore = useDictStore()
const sections: any[] = [
{
title: '基本信息',
fieldLayout: 'table' as const,
tableMode: 'triple' as const,
fields: [
{ label: '职位名称', value: row.postName },
{ label: '职位编码', value: row.postCode },
{ label: '职位名称', value: row.postName, long: false },
{ label: '职位编码', value: row.postCode, long: false },
{
label: '状态',
value: dictStore.getLabel('sys_status', row.status),
tagType: dictStore.getTagType('sys_status', row.status)
tagType: dictStore.getTagType('sys_status', row.status),
long: false
},
{ label: '创建时间', value: row.createTime },
{ label: '备注', value: row.remark, span: true }
{ label: '创建时间', value: row.createTime, long: false },
{ label: '备注', value: row.remark, long: false }
]
}
]
+7 -4
View File
@@ -949,15 +949,18 @@ const dictStore = useDictStore()
const sections: ViewSection[] = [
{
title: '基本信息',
fieldLayout: 'table',
tableMode: 'triple',
fields: [
{ label: '角色名称', value: row.roleName },
{ label: '角色名称', value: row.roleName, long: false },
{
label: '状态',
value: dictStore.getLabel('sys_status', row.status),
tagType: dictStore.getTagType('sys_status', row.status) as 'success' | 'info'
tagType: dictStore.getTagType('sys_status', row.status) as 'success' | 'info',
long: false
},
{ label: '创建时间', value: row.createTime },
{ label: '备注', value: row.remark, span: true }
{ label: '创建时间', value: row.createTime, long: false },
{ label: '备注', value: row.remark, long: false }
]
},
{
+7 -5
View File
@@ -294,12 +294,14 @@
return [
{
title: '基本信息',
fieldLayout: 'table' as const,
tableMode: 'triple' as const,
fields: [
{ label: '租户编号', value: row.tenantNo },
{ label: '租户名称', value: row.tenantName },
{ label: '联系人', value: row.contactName },
{ label: '联系电话', value: row.contactPhone },
{ label: '备注', value: row.remark, span: true }
{ label: '租户编号', value: row.tenantNo, long: false },
{ label: '租户名称', value: row.tenantName, long: false },
{ label: '联系人', value: row.contactName, long: false },
{ label: '联系电话', value: row.contactPhone, long: false },
{ label: '备注', value: row.remark, long: false }
]
}
]
+14 -10
View File
@@ -615,25 +615,29 @@ const dictStore = useDictStore()
return [
{
title: '基本信息',
fieldLayout: 'table' as const,
tableMode: 'triple' as const,
fields: [
{ label: '用户账号', value: row.userNo },
{ label: '用户名', value: row.nickName },
{ label: '所属组织', value: formatUserDeptDisplay(row) },
{ label: '手机号', value: row.phoneNo },
{ label: '邮箱', value: row.email },
{ label: '用户账号', value: row.userNo, long: false },
{ label: '用户名', value: row.nickName, long: false },
{ label: '所属组织', value: formatUserDeptDisplay(row), long: false },
{ label: '手机号', value: row.phoneNo, long: false },
{ label: '邮箱', value: row.email, long: false },
{
label: '性别',
value:
({ '0': '男', '1': '女', '2': '未知' } as Record<string, string>)[row.sex] || '未知'
({ '0': '男', '1': '女', '2': '未知' } as Record<string, string>)[row.sex] || '未知',
long: false
},
{
label: '状态',
value: dictStore.getLabel('sys_status', row.status),
tagType: dictStore.getTagType('sys_status', row.status)
tagType: dictStore.getTagType('sys_status', row.status),
long: false
},
{ label: '角色', value: formatUserRoleDisplay(row), span: true },
{ label: '创建时间', value: row.createTime },
{ label: '备注', value: row.remark, span: true }
{ label: '创建时间', value: row.createTime, long: false },
{ label: '角色', value: formatUserRoleDisplay(row), long: false },
{ label: '备注', value: row.remark, long: false }
]
}
]