This commit is contained in:
zlwu committed 2026-08-08 23:14:40 +08:00
1 parent fdf7d391a6
commit 90ae136f38
11 files changed
+4318 -4087

No files matched your search

+1 -1
View File
@@ -1 +1 @@
19962
99535
+14 -3
View File
@@ -31,7 +31,7 @@ from app.crud.crm.quote_product import (
)
from app.crud.crm.quote_share_log import record_visit
from app.crud.crm.supplier_info import get_supplier_info
from app.models.crm_models import CrmContract, CrmQuote, CrmQuoteApproval
from app.models.crm_models import CrmContract, CrmCustomer, CrmQuote, CrmQuoteApproval
from app.models.models import SysUser
from app.services.crm_quote_pdf import generate_quote_pdf
from app.crud.crm.data_scope_helpers import apply_crm_owner_scope, ensure_crm_owner_row_in_scope
@@ -197,10 +197,21 @@ def _build_quote_filters(
customer_name = (_get_field(filters, "customer_name", "customerName") or "").strip()
if keyword:
# 报价编号 / 报价主题 / 客户名称 / 客户名称拼音首字母
like = f"%{keyword}%"
pinyin_like = f"%{keyword.lower()}%"
customer_pinyin_match = exists().where(
CrmCustomer.id == CrmQuote.customer_id,
CrmCustomer.del_flag == 0,
CrmCustomer.tenant_no == tenant_no,
func.lower(func.coalesce(CrmCustomer.name_pinyin_abbr, "")).like(pinyin_like),
)
q = q.filter(
or_(
CrmQuote.quote_title.like(f"%{keyword}%"),
CrmQuote.customer_name.like(f"%{keyword}%"),
CrmQuote.quote_no.like(like),
CrmQuote.quote_title.like(like),
CrmQuote.customer_name.like(like),
customer_pinyin_match,
)
)
elif quote_title and customer_name and quote_title == customer_name:
+6 -6
View File
@@ -1155,8 +1155,8 @@
"semantic_hash": ""
},
"src/components/QuoteSelector.vue": {
"mtime": 1782858534.0,
"ast_hash": "4f8d3070872bab4dbe4d542c0199f7a6",
"mtime": 1786201461.8957162,
"ast_hash": "721a1efdc38ad56c57ed1e2d1f4ddf11",
"semantic_hash": ""
},
"src/components/TinyMceEditor/index.vue": {
@@ -2020,8 +2020,8 @@
"semantic_hash": ""
},
"src/components/core/layouts/art-work-tab/index.vue": {
"mtime": 1786162921.2307498,
"ast_hash": "6cae657e9302e91bc8a41db46cdfedac",
"mtime": 1786198065.0342228,
"ast_hash": "fe74285d72054ecc1afb663507948e3c",
"semantic_hash": ""
},
"src/components/core/list-toolbar-date-shortcuts/index.vue": {
@@ -2530,8 +2530,8 @@
"semantic_hash": ""
},
"src/store/modules/worktab.ts": {
"mtime": 1786161800.500426,
"ast_hash": "b5617de1c64132ff855d03a5e4d6ae9c",
"mtime": 1786198064.0964086,
"ast_hash": "b41ff4d9b24197e61a2bba0bb4ae1636",
"semantic_hash": ""
},
"src/types/api.d.ts": {
@@ -144,7 +144,6 @@
"142": "Community 142",
"143": "index.js",
"144": "Community 144",
"145": "ContactList.vue",
"146": "price-letter-template.ts",
"147": "loading.ts",
"148": "Community 148",
@@ -152,55 +151,35 @@
"150": "Community 150",
"151": "Community 151",
"152": "SettingDrawer.vue",
"153": "main.ts",
"154": "detail.vue",
"155": "ArtException.vue",
"156": "v2.5.7 - 2025-09-14",
"157": "form.vue",
"158": "removeProductsNotAllowedByHazard",
"159": "detail.vue",
"160": "zh_CN.js",
"161": "Community 161",
"162": "Community 162",
"163": "saveNodes",
"164": "Community 164",
"165": "handleDisburse",
"166": "Community 166",
"167": "Community 167",
"168": "loadDefaultCustomerOptions",
"169": "Community 169",
"170": "Community 170",
"171": "fetchCustomerDetail",
"172": "index.js",
"173": "price-letter.ts",
"174": "Community 174",
"175": "index.vue",
"176": "index.vue",
"177": "emit",
"178": "Community 178",
"179": "Community 179",
"180": "handleSearch",
"181": "Community 181",
"182": "Community 182",
"183": "index.vue",
"184": "v2.4.2.1 - 2025-06-16",
"185": "zh_CN.js",
"186": "highlight.ts",
"187": "Community 187",
"188": "Community 188",
"189": "Community 189",
"190": "index.vue",
"191": "canDeleteByCreator",
"192": "loadUserOptions",
"193": "loadUserOptions",
"194": "detail.vue",
"195": "Community 195",
"196": "Community 196",
"197": "handleSearch",
"198": "loadUserOptions",
"199": "v2.4.2.3 - 2025-06-18",
"200": "index.js",
"201": "loading.ts",
"202": "Community 202",
"207": "Community 207",
"208": "Community 208",
@@ -208,7 +187,6 @@
"210": "Community 210",
"213": "Community 213",
"214": "Community 214",
"219": "initNew",
"223": "Community 223",
"224": "Community 224",
"227": "Community 227",
@@ -238,7 +216,6 @@
"285": "Community 285",
"286": "Community 286",
"292": "Community 292",
"293": "colors.ts",
"295": "Community 295",
"296": "Community 296",
"302": "Community 302",
@@ -554,7 +531,6 @@
"1270": "index.js",
"1271": "plugin.js",
"1272": "plugin.min.js",
"1273": "index.js",
"1275": "index.js",
"1277": "plugin.js",
"1278": "plugin.min.js",
@@ -574,7 +550,6 @@
"1293": "plugin.js",
"1294": "plugin.min.js",
"1295": "index.js",
"1296": "index.js",
"1297": "plugin.js",
"1298": "plugin.min.js",
"1299": "content.js",
@@ -167,15 +167,12 @@
"165": "handleDisburse",
"166": "Community 166",
"167": "Community 167",
"168": "loadDefaultCustomerOptions",
"169": "Community 169",
"170": "Community 170",
"171": "fetchCustomerDetail",
"172": "index.js",
"173": "price-letter.ts",
"174": "Community 174",
"175": "index.vue",
"176": "index.vue",
"177": "emit",
"178": "Community 178",
"179": "Community 179",
@@ -185,24 +182,15 @@
"183": "index.vue",
"184": "v2.4.2.1 - 2025-06-16",
"185": "zh_CN.js",
"186": "highlight.ts",
"187": "Community 187",
"188": "Community 188",
"189": "Community 189",
"190": "index.vue",
"191": "canDeleteByCreator",
"192": "loadUserOptions",
"193": "loadUserOptions",
"194": "detail.vue",
"195": "Community 195",
"196": "Community 196",
"197": "handleSearch",
"198": "loadUserOptions",
"199": "v2.4.2.3 - 2025-06-18",
"200": "index.js",
"201": "loading.ts",
"202": "Community 202",
"203": "tabs.ts",
"207": "Community 207",
"208": "Community 208",
"209": "Community 209",
@@ -555,7 +543,6 @@
"1270": "index.js",
"1271": "plugin.js",
"1272": "plugin.min.js",
"1273": "index.js",
"1275": "index.js",
"1277": "plugin.js",
"1278": "plugin.min.js",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+174 -80
View File
@@ -2,9 +2,9 @@
<el-dialog
:title="dialogTitle"
v-model="dialogVisible"
width="1200px"
width="calc(100vw - 48px)"
align-center
class="dialog-form-layout"
class="dialog-form-layout quote-selector-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
append-to-body
@@ -12,53 +12,54 @@
<!-- 提示信息 -->
<el-alert v-if="props.customerId" :title="`已筛选客户的报价单`" type="info" :closable="false" class="mb-3" show-icon />
<!-- 查询表单 -->
<el-form :model="queryParams" ref="queryFormRef" :inline="true" class="mb-3 search-form-inline">
<el-form-item label="报价编号" prop="quoteNo">
<el-input v-model="queryParams.quoteNo" placeholder="请输入报价编号" clearable @keyup.enter="handleQuery" style="width: 150px" />
</el-form-item>
<el-form-item label="报价标题" prop="quoteTitle">
<el-input v-model="queryParams.quoteTitle" placeholder="请输入报价标题" clearable @keyup.enter="handleQuery" style="width: 180px" />
</el-form-item>
<el-form-item label="客户名称" prop="customerName">
<el-input v-model="queryParams.customerName" placeholder="请输入客户名称" clearable @keyup.enter="handleQuery" style="width: 150px" />
</el-form-item>
<el-form-item label="报价状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 120px">
<el-option v-for="dict in quoteStatusFilterOptions" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item class="form-buttons-right">
<el-button type="primary" :loading="loading" @click="handleQuery">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button :loading="loading" @click="resetQuery">
<el-icon><Refresh /></el-icon>重置
</el-button>
</el-form-item>
</el-form>
<!-- 报价单列表 -->
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row quote-selector-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="报价编号/客户名称/拼音首字母"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
</div>
</div>
<div class="list-body list-wrap dialog-list-body-only">
<el-table
ref="tableRef"
v-loading="loading"
:data="quoteList"
class="list-table"
class="list-table quote-selector-table"
stripe
highlight-current-row
row-key="id"
@current-change="handleCurrentChange"
:max-height="400"
max-height="calc(100vh - 280px)"
>
<el-table-column label="" min-width="48" align="center" header-align="center">
<template #default="{ row }">
<el-radio v-model="selectedId" :label="String(row.id)" @click.stop @change="handleRadioChange(row)">
<span class="sr-only">选择</span>
</el-radio>
</template>
</el-table-column>
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column label="报价单编号" prop="quoteNo" min-width="140" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="报价单编号" prop="quoteNo" min-width="200" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="状态" prop="status" min-width="100" align="center" header-align="center">
<template #default="scope">
<DictTag dict-type="crm_quote_status" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="报价主题" prop="quoteTitle" min-width="180" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="客户名称" prop="customerName" min-width="150" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="客户名称" prop="customerName" min-width="220" align="center" header-align="center" :show-overflow-tooltip="true" />
<el-table-column label="产品信息" min-width="280" align="left" header-align="center">
<template #default="{ row }">
<div class="product-info-cell">
<div v-for="(line, idx) in formatQuoteProductLines(row)" :key="idx" class="product-info-line">
{{ line }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="合同关联情况" prop="contractLinkedCount" min-width="120" align="center" header-align="center">
<template #default="scope">
<span>{{ scope.row.contractLinkedCount != null ? `已关联${scope.row.contractLinkedCount}个合同` : '未关联' }}</span>
@@ -126,11 +127,12 @@
import { useDictStore } from '@/store/modules/dict'
import DictTag from '@/components/DictTag/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { Check, Close, Refresh, Search } from '@element-plus/icons-vue'
import { ref, watch, computed } from 'vue'
import type { ElFormInstance } from 'element-plus'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { Check, Close } from '@element-plus/icons-vue'
import { ref, watch, computed, nextTick } from 'vue'
import type { TableInstance } from 'element-plus'
import { listQuote, getQuote } from '@/api/crm/quote'
import type { QuoteQuery, QuoteVO } from '@/api/crm/quote/types'
import type { QuoteProductForm, QuoteQuery, QuoteVO } from '@/api/crm/quote/types'
interface Props {
visible: boolean
@@ -144,13 +146,6 @@ const dialogTitle = computed(() => {
return props.customerId ? '选择报价单(已筛选当前客户)' : '选择报价单'
})
/** 筛选下拉里不提供「已作废」 */
const quoteStatusFilterOptions = computed(() => {
const list = dictStore.getOptions('crm_quote_status')
if (!Array.isArray(list)) return []
return list.filter((d: { value: string }) => d.value !== 'invalid')
})
const emit = defineEmits(['update:visible', 'confirm'])
const dialogVisible = ref(false)
@@ -160,6 +155,11 @@ watch(
dialogVisible.value = val
if (val) {
await dictStore.loadAllDict()
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
queryParams.value.keyword = undefined
selectedId.value = ''
currentQuote.value = null
getList()
}
}
@@ -182,19 +182,43 @@ watch(dialogVisible, (val) => {
const queryParams = ref<QuoteQuery>({
pageNum: 1,
pageSize: 20,
quoteNo: undefined,
quoteTitle: undefined,
customerName: undefined,
keyword: undefined,
customerId: props.customerId,
status: undefined,
excludeStatus: 'invalid'
})
const quickSearchKeyword = ref('')
const quoteList = ref<QuoteVO[]>([])
const loading = ref(false)
const total = ref(0)
const currentQuote = ref<QuoteVO | null>(null)
const queryFormRef = ref<ElFormInstance>()
const selectedId = ref('')
const tableRef = ref<TableInstance>()
/** 单位字典 code → 展示文案 */
const resolveUnitLabel = (unit?: string) => {
if (unit == null || String(unit).trim() === '') return ''
return dictStore.getLabel('crm_product_unit', unit) || String(unit).trim()
}
/** 单行产品:产品名称 规格型号 数量 单位;空值跳过,不展示占位符 */
const formatProductLine = (product: QuoteProductForm): string => {
const parts = [
product.productName,
product.model,
product.quantity != null ? String(product.quantity) : '',
resolveUnitLabel(product.unit)
]
.map((v) => (v == null ? '' : String(v).trim()))
.filter(Boolean)
return parts.join(' ')
}
/** 报价单产品信息:一行一个产品 */
const formatQuoteProductLines = (row: QuoteVO): string[] => {
const list = row.productList || []
return list.map(formatProductLine).filter(Boolean)
}
const getList = async () => {
loading.value = true
@@ -202,10 +226,13 @@ const getList = async () => {
queryParams.value.customerId = props.customerId
const res = await listQuote({
...queryParams.value,
keyword: queryParams.value.keyword || undefined,
customerId: props.customerId != null ? String(props.customerId) : undefined
})
quoteList.value = res.rows || []
total.value = res.total ?? 0
await nextTick()
syncTableSelection()
} catch (error) {
console.error('获取报价单列表失败:', error)
} finally {
@@ -213,39 +240,49 @@ const getList = async () => {
}
}
const handleQuery = () => {
const syncTableSelection = () => {
const table = tableRef.value
if (!table) return
if (!selectedId.value) {
table.setCurrentRow(undefined)
return
}
const row = quoteList.value.find((item) => String(item.id) === selectedId.value)
if (row) {
table.setCurrentRow(row)
} else {
table.setCurrentRow(undefined)
}
}
/** ListQuickSearchInput 内部已 debounce 300ms,输入即检索 */
const handleQuickSearch = () => {
queryParams.value.pageNum = 1
queryParams.value.keyword = quickSearchKeyword.value?.trim() || undefined
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.value.excludeStatus = 'invalid'
if (props.customerId) {
queryParams.value.customerId = props.customerId
}
handleQuery()
const handleQuickSearchClear = () => {
quickSearchKeyword.value = ''
queryParams.value.pageNum = 1
queryParams.value.keyword = undefined
getList()
}
watchDebounced(
() => ({
quoteNo: queryParams.value.quoteNo,
quoteTitle: queryParams.value.quoteTitle,
customerName: queryParams.value.customerName,
status: queryParams.value.status
}),
() => {
queryParams.value.pageNum = 1
getList()
},
{ debounce: 300, deep: true }
)
const handleCurrentChange = async (row: QuoteVO) => {
if (!row) {
const selectQuoteRow = async (row?: QuoteVO | null) => {
if (!row?.id) {
selectedId.value = ''
currentQuote.value = null
syncTableSelection()
return
}
const rowId = String(row.id)
if (rowId === selectedId.value && currentQuote.value && String(currentQuote.value.id) === rowId) {
syncTableSelection()
return
}
selectedId.value = rowId
syncTableSelection()
try {
// 加载完整的报价单信息(包括产品明细),确保 productList 可供合同表单使用
const res = await getQuote(row.id)
@@ -257,6 +294,14 @@ const handleCurrentChange = async (row: QuoteVO) => {
}
}
const handleCurrentChange = async (row?: QuoteVO) => {
await selectQuoteRow(row)
}
const handleRadioChange = async (row: QuoteVO) => {
await selectQuoteRow(row)
}
const handleConfirm = () => {
emit('confirm', currentQuote.value)
handleClose()
@@ -264,6 +309,7 @@ const handleConfirm = () => {
const handleClose = () => {
currentQuote.value = null
selectedId.value = ''
dialogVisible.value = false
}
</script>
@@ -279,16 +325,64 @@ const handleClose = () => {
margin-bottom: 16px;
}
.mt-3 {
margin-top: 16px;
/* 检索框约占工具栏 1/3 宽,避免独占整行 */
.quote-selector-toolbar-row :deep(.quick-search-input) {
width: 33.333%;
max-width: 33.333%;
flex: 0 0 33.333%;
}
.search-form-inline {
display: flex;
flex-wrap: wrap;
.product-info-cell {
padding: 4px 0;
line-height: 1.5;
text-align: left;
white-space: normal;
}
.form-buttons-right {
margin-left: auto !important;
.product-info-line + .product-info-line {
margin-top: 2px;
}
.quote-selector-table {
:deep(.el-radio) {
height: auto;
margin-right: 0;
}
:deep(.el-radio__label) {
display: none;
}
/* 选中行:比默认 current-row 更深的薄荷绿底 */
:deep(.el-table__body tr.current-row > td.el-table__cell) {
background-color: #a7f3d0 !important;
}
:deep(.el-table__body tr.current-row:hover > td.el-table__cell) {
background-color: #6ee7b7 !important;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
<style>
/* 选择报价单弹窗:宽高最大化(贴合 dialog-form-layout 视口留白) */
.quote-selector-dialog.el-dialog {
width: calc(100vw - 48px) !important;
height: calc(100vh - var(--dialog-viewport-inset, 20px) * 2) !important;
max-width: calc(100vw - 48px) !important;
max-height: calc(100vh - var(--dialog-viewport-inset, 20px) * 2) !important;
margin: 0 !important;
}
</style>
@@ -223,11 +223,11 @@
</template>
</el-table-column>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="合同编号" align="center" prop="contractNo" min-width="170" show-overflow-tooltip />
<el-table-column label="合同编号" align="center" prop="contractNo" min-width="240" show-overflow-tooltip />
<el-table-column
label="合同名称"
prop="contractName"
min-width="280"
min-width="380"
align="left"
header-align="left"
class-name="col-name-wrap"