zlwu пре 2 месеци
родитељ
комит
90ae136f38

+ 1 - 1
backend/app.pid

@@ -1 +1 @@
-19962
+99535

+ 14 - 3
backend/app/crud/crm/quote.py

@@ -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
graphify-out/manifest.json

@@ -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": {

+ 0 - 25
web-admin/graphify-out/.graphify_labels.json

@@ -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",

+ 0 - 13
web-admin/graphify-out/2026-08-08/.graphify_labels.json

@@ -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",

Разлика између датотеке није приказан због своје велике величине
+ 209 - 258
web-admin/graphify-out/2026-08-08/GRAPH_REPORT.md


Разлика између датотеке није приказан због своје велике величине
+ 112 - 112
web-admin/graphify-out/2026-08-08/graph.json


Разлика између датотеке није приказан због своје велике величине
+ 200 - 297
web-admin/graphify-out/GRAPH_REPORT.md


Разлика између датотеке није приказан због своје велике величине
+ 112 - 112
web-admin/graphify-out/graph.json


+ 174 - 80
web-admin/src/components/QuoteSelector.vue

@@ -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>

+ 2 - 2
web-admin/src/views/crm/contract/ContractListPage.vue

@@ -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"

Неке датотеке нису приказане због велике количине промена