Переглянути джерело

电子合同添加手动新增自定义费用

chenjun 1 місяць тому
батько
коміт
06e415af22

+ 62 - 0
.workbuddy/memory/2026-09-03.md

@@ -0,0 +1,62 @@
+# 2026-09-03 工作日志
+
+## 合同费用列表(monthlyExpenses)手动添加功能
+
+### 需求
+合同组件(广州版 ContractBody.vue + 湖北版 ContractBodyHubei.vue)的月度费用列表,增加「手动添加费用行」能力:点击添加一行,格式与现有行一致,数据随合同保存、重新打开能解析回显。
+
+### 实现
+- **addMonthlyExpense()**:向 `contractForm.monthlyExpenses` push 空行,标记 `isManual: true`,字段 `{ id: 'manual-'+Date.now(), isManual: true, itemName:'', itemCategoryName:'', actualAmount:'' }`
+- **removeMonthlyExpense(index)**:splice 删除(仅手动行提供删除入口)
+- **渲染区分**:
+  - 手动行(`isManual`)名称用 `FieldItem v-model="item.itemName"` 可编辑,后缀 `¥:`
+  - 系统行名称保持纯文本 `{{ item.itemCategoryName+'¥' || item.itemName+'¥' }}`
+  - 金额 `FieldItem v-model="item.actualAmount"` 不变,后缀 `元/月;`
+  - 删除按钮 `el-button link type=danger size=small`(`v-if="item.isManual && !isTextMode"`)
+  - 添加按钮 `el-button link type=primary`「+ 添加费用」(`v-if="!isTextMode"`,打印模式隐藏)
+- **v-for key** 改为 `item.id ?? 'manual-'+index`(手动行 id 为字符串 `manual-xxx`,避免与系统行冲突/空 key 重复)
+- 新增 `.expense-del` scoped 样式(margin-left:6px 对齐)
+
+### 保存/解析链路(无需额外改动)
+- 保存:`saveContract` → `JSON.stringify({...contractForm})`,手动行作为 monthlyExpenses 数组元素完整落库
+- 解析:`fetchContractData` → `JSON.parse(contractInfo)` → `Object.assign(contractForm, parsedData)` 整体替换 monthlyExpenses,`isManual` 标记保留,回显正确
+- totalFee 自动汇总 watch 遍历 `monthlyExpensesList`,手动行 actualAmount 一并计入总额
+
+### 涉及文件
+- `src/views/elderly/contracts/components/ContractBody.vue`(script 745-760 + template 1411-1449 + style)
+- `src/views/elderly/contracts/components/ContractBodyHubei.vue`(script 809-824 + template 1466-1504 + style)
+
+### 备注
+- 系统行名称表达式 `item.itemCategoryName+'¥' || item.itemName+'¥'` 存在既有逻辑瑕疵(空 category 时 `''+'¥'='¥'` 为 truthy,|| 永远短路返回前者),本次未改动、仅保留原行为
+
+## 费用名称显示严谨化(后续修复)
+
+### 背景
+上一轮遗留的 `displayExpenseName` 函数内 `return category + '¥' || name + '¥' || ''` 仍有短路 bug:`category` 为空时 `''+'¥'='¥'` 为真值,永远返回 `'¥'` 不回退到 `name`;且 category/name 都为空时返回孤零零 `¥`。
+
+### 修复
+- **两个文件 `displayExpenseName`** 统一改为:
+  ```js
+  const label = category || name
+  return label ? `${label}¥` : ''
+  ```
+  严谨优先级:类别+项目都存在且不同 → `类别(项目)¥`;否则取非空者;都空返回 `''`。
+- **广州版模板** 系统行名称旧表达式 `{{ item.itemCategoryName + '¥' || item.itemName + '¥' }}` → `{{ displayExpenseName(item) }}`,与湖北版及打印渲染处(oneTime/stage 费用)保持一致。
+- 已在函数上补注释说明禁用 `category + '¥' || name + '¥'` 的原因,防止后人重蹈。
+
+## 月度费用金额预览/打印模式去下划线
+
+### 需求
+合同月度费用列表(monthlyExpenses)的金额,在预览/打印(isTextMode)下 FieldItem 的 `.fill-text` 有 `border-bottom: 1px solid #333` 下划线,与上方费用名称(纯文本)不一致,希望去掉下划线。**仅限月度费用这一块,其他费用块(一次性/阶段性)金额横线保留。**
+
+### 实现
+- `FieldItem.vue` 新增 `noUnderline?: boolean` prop;isTextMode 下 `.fill-text`/`.fill-blank` 加 `:class="{ 'no-underline': noUnderline }"`;样式新增 `.no-underline { border-bottom: none; }`(放在 `.is-text` 内,仅 isTextMode 生效,编辑模式 input 无影响)。
+- 两个合同组件的月度费用金额 FieldItem(`width="120px"`)加 `no-underline`,一次性费用金额(`width="100px"`)不改。
+
+### 涉及文件
+- `src/views/elderly/contracts/components/FieldItem.vue`
+- `src/views/elderly/contracts/components/ContractBody.vue`(月度费用金额)
+- `src/views/elderly/contracts/components/ContractBodyHubei.vue`(月度费用金额)
+
+### 补充:手动行「费用名称」也去下划线
+用户纠正「金额和名称都要改」。手动添加行(isManual)的费用名称 FieldItem(`width="140px"` `placeholder="请输入费用名称"`)在预览/打印模式下同样带下划线,已给两个合同组件的名称 FieldItem 补 `no-underline`。系统行名称本就是纯文本 `displayExpenseName(item)`,无下划线,无需改。

+ 59 - 3
src/views/elderly/contracts/components/ContractBody.vue

@@ -742,6 +742,22 @@ const formatExpenseAmount = (val: any): string => {
   return String(val)
 }
 
+/** 手动添加一行月度费用(名称可编辑,随 contractForm.monthlyExpenses 一并保存/解析) */
+const addMonthlyExpense = () => {
+  contractForm.monthlyExpenses.push({
+    id: `manual-${Date.now()}`,
+    isManual: true,
+    itemName: '',
+    itemCategoryName: '',
+    actualAmount: ''
+  })
+}
+
+/** 删除手动添加的月度费用行(仅 isManual 行提供删除入口) */
+const removeMonthlyExpense = (index: number) => {
+  contractForm.monthlyExpenses.splice(index, 1)
+}
+
 const isDepositFee = (item: any): boolean => {
   return (
     item.isDeposit === 1 ||
@@ -756,13 +772,16 @@ const numberToCircle = (num: number): string => {
 }
 
 // 费用项显示名称:类别名(项目名)
+// 严谨优先级:类别与项目都存在且不同 → "类别(项目)¥";否则取二者非空者;都为空返回空串
+// (不能用 `category + '¥' || name + '¥'`,空类别时 `'' + '¥'` 为真值会吞掉项目名)
 const displayExpenseName = (item: any): string => {
   const category = item?.itemCategoryName || ''
   const name = item?.itemName || ''
   if (category && name && category !== name) {
     return `${category}(${name})¥`
   }
-  return category + '¥' || name + '¥' || ''
+  const label = category || name
+  return label ? `${label}¥` : ''
 }
 
 const bedList = ref<{ chargeName: string; id: string; price: number }[]>([])
@@ -1392,15 +1411,46 @@ watch(
           元/月。
         </p>
         <p class="content-text"> &nbsp;&nbsp;&nbsp;&nbsp;其中: </p>
-        <p v-for="item in monthlyExpensesList" :key="item.id" class="content-text">
-          {{ item.itemCategoryName+'¥' || item.itemName+'¥' }}:
+        <p
+          v-for="(item, index) in monthlyExpensesList"
+          :key="item.id ?? 'manual-' + index"
+          class="content-text"
+        >
+          <template v-if="item.isManual">
+            <FieldItem
+              v-model="item.itemName"
+              :is-text-mode="isTextMode"
+              width="140px"
+              placeholder="请输入费用名称"
+              no-underline
+            />¥:
+          </template>
+          <template v-else>
+            {{ displayExpenseName(item) }}:
+          </template>
           <FieldItem
             v-model="item.actualAmount"
             :is-text-mode="isTextMode"
             width="120px"
             placeholder="0"
+            no-underline
           />
           元/月;
+          <el-button
+            v-if="item.isManual && !isTextMode"
+            link
+            type="danger"
+            size="small"
+            class="expense-del"
+            @click="removeMonthlyExpense(index)"
+          >
+            删除
+          </el-button>
+        </p>
+        <p v-if="!isTextMode" class="content-text">
+          <el-button link type="primary" size="small" @click="addMonthlyExpense">
+            + 添加费用
+          </el-button>
         </p>
         <p class="content-text"> 其他费用: </p>
         <p class="content-text">
@@ -2636,6 +2686,12 @@ watch(
   color: #333;
 }
 
+/* 手动费用行的删除按钮:与正文保持对齐、留间距 */
+.expense-del {
+  margin-left: 6px;
+  vertical-align: middle;
+}
+
 /* 违约金百分比内联输入框:融入文档段落样式 */
 .percent-input {
   width: 90px;

+ 61 - 3
src/views/elderly/contracts/components/ContractBodyHubei.vue

@@ -806,6 +806,22 @@ const formatExpenseAmount = (val: any): string => {
   return String(val)
 }
 
+/** 手动添加一行月度费用(名称可编辑,随 contractForm.monthlyExpenses 一并保存/解析) */
+const addMonthlyExpense = () => {
+  contractForm.monthlyExpenses.push({
+    id: `manual-${Date.now()}`,
+    isManual: true,
+    itemName: '',
+    itemCategoryName: '',
+    actualAmount: ''
+  })
+}
+
+/** 删除手动添加的月度费用行(仅 isManual 行提供删除入口) */
+const removeMonthlyExpense = (index: number) => {
+  contractForm.monthlyExpenses.splice(index, 1)
+}
+
 const isDepositFee = (item: any): boolean => {
   return (
     item.isDeposit === 1 ||
@@ -820,13 +836,16 @@ const numberToCircle = (num: number): string => {
 }
 
 // 费用项显示名称:类别名(项目名)
+// 严谨优先级:类别与项目都存在且不同 → "类别(项目)¥";否则取二者非空者;都为空返回空串
+// (不能用 `category + '¥' || name + '¥'`,空类别时 `'' + '¥'` 为真值会吞掉项目名)
 const displayExpenseName = (item: any): string => {
   const category = item?.itemCategoryName || ''
   const name = item?.itemName || ''
   if (category && name && category !== name) {
     return `${category}(${name})¥`
   }
-  return category + '¥' || name + '¥' || ''
+  const label = category || name
+  return label ? `${label}¥` : ''
 }
 
 const bedList = ref<{ chargeName: string; id: string; price: number }[]>([])
@@ -1447,15 +1466,48 @@ watch(
           />
           元/月,包括:
         </p>
-        <p v-for="item in monthlyExpensesList" :key="item.id" class="content-text">
-          &nbsp;&nbsp;&nbsp;&nbsp;{{ displayExpenseName(item) }}:
+        <p
+          v-for="(item, index) in monthlyExpensesList"
+          :key="item.id ?? 'manual-' + index"
+          class="content-text"
+        >
+          &nbsp;&nbsp;&nbsp;&nbsp;
+          <template v-if="item.isManual">
+            <FieldItem
+              v-model="item.itemName"
+              :is-text-mode="isTextMode"
+              width="140px"
+              placeholder="请输入费用名称"
+              no-underline
+            />¥:
+          </template>
+          <template v-else>
+            {{ displayExpenseName(item) }}:
+          </template>
           <FieldItem
             v-model="item.actualAmount"
             :is-text-mode="isTextMode"
             width="120px"
             placeholder="0"
+            no-underline
           />
           元/月;
+          <el-button
+            v-if="item.isManual && !isTextMode"
+            link
+            type="danger"
+            size="small"
+            class="expense-del"
+            @click="removeMonthlyExpense(index)"
+          >
+            删除
+          </el-button>
+        </p>
+        <p v-if="!isTextMode" class="content-text">
+          &nbsp;&nbsp;&nbsp;&nbsp;
+          <el-button link type="primary" size="small" @click="addMonthlyExpense">
+            + 添加费用
+          </el-button>
         </p>
         <p class="content-text"> 其他费用: </p>
         <p class="content-text">
@@ -2590,6 +2642,12 @@ watch(
   color: #333;
 }
 
+/* 手动费用行的删除按钮:与正文保持对齐、留间距 */
+.expense-del {
+  margin-left: 6px;
+  vertical-align: middle;
+}
+
 .fill-text {
   display: inline-block;
   min-width: 60px;

+ 17 - 2
src/views/elderly/contracts/components/FieldItem.vue

@@ -9,6 +9,7 @@ const props = defineProps<{
   width?: string
   isTextMode?: boolean
   underlineWidth?: string
+  noUnderline?: boolean
   type?: 'text' | 'date' | 'datetime' | 'dict'
   dictType?: string
 }>()
@@ -98,8 +99,18 @@ const dateValue = computed({
   <span class="field-item" :class="{ 'is-text': isTextMode }">
     <!-- 打印模式:统一显示下划线文本 -->
     <template v-if="isTextMode">
-      <span v-if="isDictType ? dictLabel : localValue" class="fill-text">{{ isDictType ? dictLabel : localValue }}</span>
-      <span v-else class="fill-blank" :style="{ width: underlineWidth || width || '160px' }"></span>
+      <span
+        v-if="isDictType ? dictLabel : localValue"
+        class="fill-text"
+        :class="{ 'no-underline': noUnderline }"
+        >{{ isDictType ? dictLabel : localValue }}</span
+      >
+      <span
+        v-else
+        class="fill-blank"
+        :class="{ 'no-underline': noUnderline }"
+        :style="{ width: underlineWidth || width || '160px' }"
+      ></span>
     </template>
 
     <!-- 编辑模式:日期选择器 -->
@@ -231,5 +242,9 @@ const dateValue = computed({
     vertical-align: middle;
     margin: 0 2px;
   }
+
+  .no-underline {
+    border-bottom: none;
+  }
 }
 </style>