feat(cnas,business): 客户满意度模板化表单与年度汇总导出、费用统计增强、路由加载自愈

- 客户满意度:新增/编辑表单按客户 15-1 模板重构(10 项评分、自动计算总分与满意度评定),列表补充地址、联系人、填表日期、总分、评定等字段
- 客户满意度:新增 15-2 年度汇总 Word 导出,每页固定 17 行明细,满页自动续页,统计只在文末
- 费用统计:新增送样时间列、月份区间筛选与委托单优惠字段维护
- 人员管理:精简人员能力标签页
- 路由:动态导入失败自动重试,chunk 加载失败自动刷新
已添加1个文件
已修改9个文件
766 ■■■■■ 文件已修改
src/api/business/insOrderRates.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/cnas/systemManagement/customerSatisfaction.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/permission.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/modules/permission.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/routeLoad.js 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/personnel/personnelInfo/index.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/personnel/personnelInfo/tabs/personnel-capacity.vue 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/systemManagement/customerSatisfaction/components/formDialog.vue 350 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/systemManagement/customerSatisfaction/index.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/costStatistics/index.vue 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/business/insOrderRates.js
@@ -17,3 +17,11 @@
    params: query
  })
}
// æ›´æ–°è´¹ç”¨ä¼˜æƒ ä¸Žå¤‡æ³¨
export function updateOrderFee(data) {
  return request({
    url: '/insOrderRates/updateFee',
    method: 'post',
    data: data
  })
}
src/api/cnas/systemManagement/customerSatisfaction.js
@@ -54,3 +54,13 @@
    data: data,
  });
}
// å¯¼å‡ºå®¢æˆ·æ»¡æ„åº¦å¹´åº¦æ±‡æ€»
export function exportAnnualSummary(query) {
  return request({
    url: "/clientSatisfaction/exportAnnualSummary",
    method: "get",
    params: query,
    responseType: "blob",
  });
}
src/permission.js
@@ -5,6 +5,7 @@
import { getToken } from '@/utils/auth'
import { isHttp, isPathMatch } from '@/utils/validate'
import { isRelogin } from '@/utils/request'
import { isChunkLoadError, autoReloadForChunkError } from '@/utils/routeLoad'
import useUserStore from '@/store/modules/user'
import useLockStore from '@/store/modules/lock'
import useSettingsStore from '@/store/modules/settings'
@@ -78,6 +79,11 @@
router.onError(error => {
  NProgress.done()
  // é¡µé¢ä»£ç æ¨¡å—加载失败(Vite é‡å¯/重编译、发版后旧分包)自动刷新恢复
  if (isChunkLoadError(error) && autoReloadForChunkError()) {
    console.warn('[router] é¡µé¢æ¨¡å—加载失败,自动刷新重试:', error)
    return
  }
  console.error('[router] é¡µé¢åŠ è½½å¤±è´¥ï¼š', error)
  ElMessage.error('页面加载失败,请刷新页面后重试')
})
src/store/modules/permission.js
@@ -4,6 +4,7 @@
import Layout from '@/layout/index'
import ParentView from '@/components/ParentView'
import InnerLink from '@/layout/components/InnerLink'
import { importWithRetry } from '@/utils/routeLoad'
// åŒ¹é…views里面所有的.vue文件
const modules = import.meta.glob('./../../views/**/*.vue')
@@ -166,7 +167,7 @@
  for (const path in modules) {
    const dir = path.split('views/')[1].split('.vue')[0]
    if (dir === view || dir === `${view}/index`) {
      res = () => modules[path]()
      res = () => importWithRetry(modules[path])
    }
  }
  return res
src/utils/routeLoad.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
// è·¯ç”±é¡µé¢ä»£ç æ¨¡å—加载失败的自愈处理。
// Vite é‡å¯/依赖预构建刷新、发布后旧分包失效时,页面首次 import ä¼šå¤±è´¥ï¼Œ
// ç¨åŽé‡è¯•或整页刷新即可恢复,不需要用户手动按 F5。
const CHUNK_LOAD_ERROR = /Failed to fetch dynamically imported module|Importing a module script failed|Loading chunk .* failed|Outdated Optimize Dep/i
export function isChunkLoadError(error) {
  if (!error) return false
  return CHUNK_LOAD_ERROR.test(error.message || String(error))
}
export async function importWithRetry(loader, retries = 2, delay = 500) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await loader()
    } catch (error) {
      if (attempt >= retries || !isChunkLoadError(error)) throw error
      await new Promise(resolve => setTimeout(resolve, delay * 2 ** attempt))
    }
  }
}
const RELOAD_STATE_KEY = 'lims-chunk-load-reload'
const RELOAD_WINDOW = 60 * 1000
const RELOAD_LIMIT = 3
// é‡è¯•后仍失败则自动整页刷新;60 ç§’内最多 3 æ¬¡ï¼Œé¿å…çœŸæ­£æ•…障时无限刷新。
export function autoReloadForChunkError() {
  const now = Date.now()
  let state = null
  try {
    state = JSON.parse(sessionStorage.getItem(RELOAD_STATE_KEY) || 'null')
  } catch (e) {
    state = null
  }
  if (!state || now - state.first > RELOAD_WINDOW) {
    state = { first: now, count: 0 }
  }
  if (state.count >= RELOAD_LIMIT) return false
  state.count += 1
  try {
    sessionStorage.setItem(RELOAD_STATE_KEY, JSON.stringify(state))
  } catch (e) {
    // éšç§æ¨¡å¼ç­‰å­˜å‚¨ä¸å¯ç”¨æ—¶å¿½ç•¥ï¼Œä¸å½±å“åˆ·æ–°
  }
  setTimeout(() => window.location.reload(), 500)
  return true
}
src/views/CNAS/personnel/personnelInfo/index.vue
@@ -64,10 +64,6 @@
                                :departId="departId"
                                :isDepartment="isDepartment"></job-responsibilities>
        </el-tab-pane>
        <el-tab-pane label="奖惩记录" name="奖惩记录">
          <rewardPunishmentRecord v-if="activeName === '奖惩记录'"
                                  :departId="departId" :isDepartment="isDepartment"></rewardPunishmentRecord>
        </el-tab-pane>
        <el-tab-pane label="培训记录" name="培训记录">
          <training-record v-if="activeName === '培训记录'" ref="trainingRecord"
                           :departId="departId"
@@ -80,9 +76,6 @@
          <personnel-capacity v-if="activeName === '人员能力'" ref="personnelCapacity"
                              :departId="departId"
                              :isDepartment="isDepartment"></personnel-capacity>
        </el-tab-pane>
        <el-tab-pane label="沟通记录" name="沟通记录">
          <Communicate v-if="activeName === '沟通记录'" ref="communicateRef" :departId="departId" :isDepartment="isDepartment"></Communicate>
        </el-tab-pane>
      </el-tabs>
    </div>
@@ -110,16 +103,14 @@
import personnelInformation from './tabs/personnel-information.vue'
import PersonnelTraining from './tabs/personnelTraining';
import JobResponsibilities from './tabs/job-responsibilities.vue';
import rewardPunishmentRecord from "./tabs/reward-punishment-record.vue";
import TrainingRecord from './tabs/training-record.vue';
import Mandate from './tabs/mandate.vue';
import PersonnelCapacity from './tabs/personnel-capacity.vue';
import Communicate from './tabs/communicate.vue'
import {addDepartmentLims, delDepartmentLims, selectCNSAPersonTree} from "@/api/cnas/personal/personalList";
export default {
  name: 'PersonnelInfo',
  components: {
    PersonnelList, personnelInformation, PersonnelTraining, JobResponsibilities, rewardPunishmentRecord, TrainingRecord, Mandate, PersonnelCapacity, Communicate
    PersonnelList, personnelInformation, PersonnelTraining, JobResponsibilities, TrainingRecord, Mandate, PersonnelCapacity
  },
  data() {
    return {
src/views/CNAS/personnel/personnelInfo/tabs/personnel-capacity.vue
@@ -19,9 +19,6 @@
    <lims-table :tableData="tableData" :column="yearColumnData"
                @pagination="pagination" :height="'calc(100vh - 20em)'"
                :page="search" :tableLoading="yearLoading">
      <template #jobResponsibilities="scope"><div>
        <div v-html="changeLine(scope.row.responsibilities)"></div>
      </div></template>
      <template #placeWorkSlot="scope"><div>
        <div v-html="changeLine(scope.row.placeWork)"></div>
      </div></template>
@@ -146,34 +143,6 @@
            <el-input v-model="form.workLicenseRemarks" :disabled="operationType === 'view' || operationType === 'confirm'" size="small"></el-input>
          </el-form-item>
        </el-col>
        <el-col :span="8">
          <el-form-item label="岗位职责:" label-width="110px" prop="jobResponsibilitiesTem" style="height: 450px">
            <el-checkbox-group v-model="form.jobResponsibilitiesTem" :disabled="operationType === 'view' || operationType === 'confirm'"
                               @change="selectResponsibilities">
              <el-checkbox v-for="city in dict.type.responsibilities_list" :key="city.value" :label="city.label"
                           :value="city.value"></el-checkbox>
            </el-checkbox-group>
          </el-form-item>
        </el-col>
        <el-col :span="8">
          <el-form-item class="radio-group" prop="jobResponsibilitiesConformNot"
                        style="text-align: center;height: 420px">
            <el-radio-group v-model="form.jobResponsibilitiesConformNot" :disabled="operationType === 'view' || operationType === 'confirm'">
              <el-radio :value="1">符合</el-radio>
              <el-radio :value="2">不符合</el-radio>
              <el-radio :value="3">不适用</el-radio>
            </el-radio-group>
          </el-form-item>
        </el-col>
        <el-col :span="8">
          <el-form-item prop="jobResponsibilitiesRemarks" style="height: 420px">
            <el-input v-model="form.jobResponsibilitiesRemarks" :disabled="operationType === 'view' || operationType === 'confirm'"
                      size="small"></el-input>
          </el-form-item>
        </el-col>
        <el-col :span="24">
          <span style="padding-left: 110px;font-size: 14px">备注:岗位职责达到5项及5项以上为符合</span>
        </el-col>
        <el-col :span="16">
          <el-form-item label="综合评价:" label-width="110px" prop="comprehensiveAssessment">
            <el-radio-group v-model="form.comprehensiveAssessment" :disabled="operationType === 'view' || operationType === 'confirm'">
@@ -247,7 +216,6 @@
  computed: {
    ...mapGetters(["userId"]),
  },
  dicts: ['responsibilities_list'],
  data() {
    return {
      userName: '',
@@ -280,12 +248,6 @@
          label: '职称',
          prop: 'professionalTitle',
          width: '100'
        }, {
          dataType: 'slot',
          label: '岗位职责',
          prop: 'jobResponsibilities',
          width: '400',
          slot: 'jobResponsibilities'
        }, {
          dataType: 'tag',
          label: '综合评价',
@@ -384,9 +346,7 @@
      yearLoading: false,
      dialogVisible: false,
      verifyGetId: '',
      form: {
        jobResponsibilitiesTem: []
      },
      form: {},
      responsibleOptions: [],
      rules: {
        confirmOperatingPersonnelId: [{ required: true, message: '请选择确认人', trigger: 'change' }],
@@ -402,8 +362,6 @@
        relevantExperienceConformNot: [{ required: true, message: '请选择相关经验符合与否', trigger: 'change' }],
        workLicense: [{ required: true, message: '请输入上岗证查核结果', trigger: 'blur' }],
        workLicenseConformNot: [{ required: true, message: '请选择上岗证符合与否', trigger: 'change' }],
        jobResponsibilitiesTem: [{ required: true, message: '请选择岗位职责查核结果', trigger: 'change' }],
        jobResponsibilitiesConformNot: [{ required: true, message: '请选择岗位职责符合与否', trigger: 'change' }],
        comprehensiveAssessment: [{ required: true, message: '请选择综合评价', trigger: 'change' }]
      },
      responsibilities: []
@@ -454,16 +412,6 @@
      this.search.size = page.limit
      this.refreshTable()
    },
    selectResponsibilities(arr) {
      let arrTem = [];
      arr.map(val => {
        const index = this.dict.type.responsibilities_list.findIndex(item => item.label === val);
        if (index > -1) {
          arrTem.push(this.dict.type.responsibilities_list[index].value);
        }
      });
      this.form.jobResponsibilities = arrTem.join(',');
    },
    changeLine (val) {
      if(val) {
        return val.replace(/,/g, '<br>')
@@ -476,9 +424,7 @@
      this.operationType = type;
      this.title = '新增能力认定'
      this.dialogVisible = true;
      this.form = {
        jobResponsibilitiesTem: []
      }
      this.form = {}
      this.form.userId = this.departId
      this.getUserList();
@@ -490,11 +436,6 @@
      this.title = this.operationType === 'edit' ? '修改能力认定' : '查看能力认定'
      this.dialogVisible = true;
      this.form = {...row}
      if(this.form.responsibilities) {
        this.form['jobResponsibilitiesTem'] = this.form.responsibilities.split(",")
      } else {
        this.form['jobResponsibilitiesTem'] = []
      }
      this.getUserList();
    },
    // ä¿å­˜
src/views/CNAS/systemManagement/customerSatisfaction/components/formDialog.vue
@@ -2,29 +2,31 @@
  <div>
    <el-dialog :close-on-click-modal="false" :close-on-press-escape="false" v-model="formDia" title="客户满意度调查表"
      width="70%" @close="closeFormDia">
      <div class="survey-intro">
        å°Šæ•¬çš„客户:为了使本实验室的服务水平得以不断提高,特设此客户满意度调查表,烦请您拨冗填写。我们十分注重您的反馈,您的意见和建议,将为本实验室的持续改进提供重要参考,从而达到您期望的水平。请以传真或邮件方式将此表回复本实验室,多谢合作!在此非常感谢您对我们工作的支持!
      </div>
      <table border="1" cellspacing="10" class="tables">
        <colgroup>
          <col style="width: 12%" />
          <col style="width: 12%" />
          <col style="width: 12%" />
          <col style="width: 10.66%" />
          <col style="width: 10.66%" />
          <col style="width: 10.66%" />
          <col style="width: 10.66%" />
          <col style="width: 10.66%" />
          <col style="width: 10.66%" />
        </colgroup>
        <tr>
          <td class="td-title">
            <p>单位名称:</p>
          </td>
          <td class="td-info" colspan="2">
            <el-input v-model="form.unitName" placeholder="请输入内容" size="small">
            </el-input>
          </td>
          <td class="td-title">
            <p>日期:</p>
          </td>
          <td class="td-info" colspan="2">
            <el-date-picker v-model="form.fillDate" format="YYYY-MM-DD" placeholder="选择日期" size="small" type="date"
              value-format="YYYY-MM-DD">
            </el-date-picker>
          <td class="section-title" colspan="9">
            <p>客 æˆ· å¡« å†™</p>
          </td>
        </tr>
        <tr>
          <td class="td-title">
            <p>客户:</p>
          </td>
          <td class="td-info">
          <td class="td-info" colspan="8">
            <el-select v-model="form.orderId" filterable clearable size="small" style="width: 100%"
              popper-class="customer-select-popper" placeholder="请选择客户(已完成委托单)" @change="onCustomerChange">
              <el-option v-for="opt in customerOptions" :key="opt.id" :label="opt.company" :value="opt.id">
@@ -36,111 +38,114 @@
              </el-option>
            </el-select>
          </td>
        </tr>
        <tr>
          <td class="td-title">
            <p>部门:</p>
            <p>地址:</p>
          </td>
          <td class="td-info">
            <el-input v-model="form.department" placeholder="请输入内容" size="small">
            </el-input>
          </td>
          <td class="td-title">
            <p>联系电话:</p>
          </td>
          <td class="td-info">
            <el-input v-model="form.contactNumber" placeholder="请输入内容" size="small">
          <td class="td-info" colspan="8">
            <el-input v-model="form.address" placeholder="请输入内容" size="small">
            </el-input>
          </td>
        </tr>
        <tr>
          <td class="td-title">
            <p>服务态度:</p>
            <p>联系人:</p>
          </td>
          <td class="td-info" colspan="2">
            <el-radio-group v-model="form.serviceAttitude">
              <el-radio :value="0">满意</el-radio>
              <el-radio :value="1">一般</el-radio>
              <el-radio :value="2">不满意</el-radio>
            </el-radio-group>
          <td class="td-info" colspan="3">
            <el-input v-model="form.contactPerson" placeholder="请输入内容" size="small">
            </el-input>
          </td>
          <td class="td-title">
            <p>建议:</p>
            <p>填表日期:</p>
          </td>
          <td class="td-info" colspan="2">
            <el-input v-model="form.serviceAttitudeSuggestion" placeholder="请输入内容" size="small">
            </el-input>
          <td class="td-info" colspan="4">
            <el-date-picker v-model="form.fillDate" format="YYYY-MM-DD" placeholder="选择日期" size="small" type="date"
              value-format="YYYY-MM-DD" style="width: 100%">
            </el-date-picker>
          </td>
        </tr>
        <tr>
          <td class="td-title">
            <p>技术能力:</p>
          <td class="td-title" colspan="3">
            <p>评分项</p>
          </td>
          <td class="td-info" colspan="2">
            <el-radio-group v-model="form.technicalCompetence">
              <el-radio :value="0">满意</el-radio>
              <el-radio :value="1">一般</el-radio>
              <el-radio :value="2">不满意</el-radio>
            </el-radio-group>
          <td class="score-head" colspan="2">优秀</td>
          <td class="score-head" colspan="2">一般</td>
          <td class="score-head" colspan="2">å·®</td>
        </tr>
        <tr>
          <td class="td-title" colspan="3">
            <p>(分值:10—5分)</p>
          </td>
          <td class="td-title">
            <p>建议:</p>
          <td class="score-head" v-for="s in scoreOptions" :key="'head-' + s">{{ s }}</td>
        </tr>
        <tr v-for="(q, qi) in questions" :key="q.key">
          <td class="td-title question-title" colspan="3">
            <p>{{ q.text }}</p>
          </td>
          <td class="td-info" colspan="2">
            <el-input v-model="form.technicalCompetenceSuggestion" placeholder="请输入内容" size="small">
            </el-input>
          <td class="score-cell" v-for="s in scoreOptions" :key="s">
            <el-radio v-model="form[q.key]" :value="s" class="score-radio"></el-radio>
          </td>
        </tr>
        <tr>
          <td class="td-title">
            <p>检测工作:</p>
          <td class="td-title" colspan="3">
            <p>*您认为本实验室还有哪些不足,或对我们的工作还有哪些意见和建议?请说明:</p>
          </td>
          <td class="td-info" colspan="2">
            <el-radio-group v-model="form.inspectionWork">
              <el-radio :value="0">满意</el-radio>
              <el-radio :value="1">一般</el-radio>
              <el-radio :value="2">不满意</el-radio>
            </el-radio-group>
          </td>
          <td class="td-title">
            <p>建议:</p>
          </td>
          <td class="td-info" colspan="2">
            <el-input v-model="form.inspectionWorkSuggestion" placeholder="请输入内容" size="small">
            </el-input>
          </td>
        </tr>
        <tr>
          <td class="td-title">
            <p>收费合理性:</p>
          </td>
          <td class="td-info" colspan="2">
            <el-radio-group v-model="form.reasonableFees">
              <el-radio :value="0">满意</el-radio>
              <el-radio :value="1">一般</el-radio>
              <el-radio :value="2">不满意</el-radio>
            </el-radio-group>
          </td>
          <td class="td-title">
            <p>建议:</p>
          </td>
          <td class="td-info" colspan="2">
            <el-input v-model="form.reasonableFeesSuggestion" placeholder="请输入内容" size="small">
            </el-input>
          </td>
        </tr>
        <tr>
          <td class="td-title">
            <p>您对我们的希望:</p>
          </td>
          <td class="td-info" colspan="5">
          <td class="td-info" colspan="6">
            <el-input v-model="form.remark" :rows="4" placeholder="请输入内容" size="small" type="textarea">
            </el-input>
          </td>
        </tr>
        <tr>
          <td class="section-title" colspan="9">
            <p>实 éªŒ å®¤ å¡« å†™</p>
          </td>
        </tr>
        <tr>
          <td class="td-title">
            <p>总分:</p>
          </td>
          <td class="td-info">
            <span class="score-result">{{ scoreComplete ? totalScore : '--' }}</span> åˆ†
          </td>
          <td class="td-title" colspan="2">
            <p>满意度评定:</p>
          </td>
          <td class="td-info" colspan="5">
            {{ scoreComplete ? satisfactionText : '填写评分后自动计算' }}
          </td>
        </tr>
        <tr>
          <td class="td-title">
            <p>意见:</p>
          </td>
          <td class="td-info" colspan="8">
            <el-input v-model="form.labOpinion" :rows="3" placeholder="请输入内容" size="small" type="textarea">
            </el-input>
          </td>
        </tr>
        <tr>
          <td class="td-title">
            <p>质量负责人:</p>
          </td>
          <td class="td-info" colspan="3">
            <el-input v-model="form.qualityManager" placeholder="请输入内容" size="small">
            </el-input>
          </td>
          <td class="td-title">
            <p>日期:</p>
          </td>
          <td class="td-info" colspan="4">
            <el-date-picker v-model="form.labDate" format="YYYY-MM-DD" placeholder="选择日期" size="small" type="date"
              value-format="YYYY-MM-DD" style="width: 100%">
            </el-date-picker>
          </td>
        </tr>
        <tr>
          <td class="td-title">
            <p>照片:</p>
          </td>
          <td class="td-info" colspan="5">
          <td class="td-info" colspan="8">
            <lims-attachment ref="photoAttachment" inline defer-upload accept=".jpg,.jpeg,.png,.gif"
              :main-id="form.clientSatisfactionId || ''" upload-url="/clientSatisfaction/uploadAttachment"
              select-url="/clientSatisfaction/selectAttachments" delete-url="/clientSatisfaction/deleteAttachment"
@@ -148,7 +153,7 @@
          </td>
        </tr>
      </table>
      <template #footer><span  class="dialog-footer">
      <template #footer><span class="dialog-footer">
        <el-button @click="closeFormDia">取 æ¶ˆ</el-button>
        <el-button :loading="editLoad" type="primary" @click="handleEdit">提 äº¤</el-button>
      </span></template>
@@ -163,49 +168,80 @@
} from '@/api/cnas/systemManagement/customerSatisfaction.js'
import { selectCompletedOrders } from '@/api/business/productOrder'
import limsAttachment from '@/components/Attachment/lims-attachment.vue'
const QUESTIONS = [
  { key: 'score1', text: '1.业务/接待人员的服务态度' },
  { key: 'score2', text: '2.业务/接待人员的沟通能力' },
  { key: 'score3', text: '3.业务/接待人员的响应速度或回复及时性' },
  { key: 'score4', text: '4.出具检测报告的及时性' },
  { key: 'score5', text: '5.检测结果的准确性' },
  { key: 'score6', text: '6.检测报告内容表达清晰,编排合理并易于理解' },
  { key: 'score7', text: '7.处理投诉抱怨的及时性' },
  { key: 'score8', text: '8.对投诉抱怨处理的结果' },
  { key: 'score9', text: '9.对我司服务的专业性评价' },
  { key: 'score10', text: '10.对我司服务的总体满意程度评价' }
]
function createEmptyForm() {
  const form = {
    clientSatisfactionId: '',
    orderId: '',
    unitName: '',
    userName: '',
    address: '',
    contactPerson: '',
    fillDate: '',
    remark: '',
    labOpinion: '',
    qualityManager: '',
    labDate: null,
    entrustCode: '',
    sampleName: ''
  }
  QUESTIONS.forEach(q => {
    form[q.key] = ''
  })
  return form
}
export default {
  name: 'formDialog',
  // import å¼•入的组件需要注入到对象中才能使用
  components: { limsAttachment },
  data() {
    // è¿™é‡Œå­˜æ”¾æ•°æ®
    return {
      formDia: false,
      form: {
        unitName: '',
        fillDate: '',
        userName: '',
        department: '',
        contactNumber: '',
        serviceAttitude: '',
        serviceAttitudeSuggestion: '',
        technicalCompetence: '',
        technicalCompetenceSuggestion: '',
        inspectionWork: '',
        inspectionWorkSuggestion: '',
        reasonableFees: '',
        reasonableFeesSuggestion: '',
        remark: '',
        clientSatisfactionId: '',
        orderId: '',
        entrustCode: '',
        sampleName: ''
      },
      // å®¢æˆ·ä¸‹æ‹‰é€‰é¡¹ï¼šåªåŒ…含"已下过委托单且已完成"的数据
      form: createEmptyForm(),
      questions: QUESTIONS,
      scoreOptions: [10, 9, 8, 7, 6, 5],
      customerOptions: [],
      operationType: '',
      editLoad: false,
    };
  },
  // æ–¹æ³•集合
  computed: {
    scoreComplete() {
      return this.questions.every(q => this.form[q.key] !== '' && this.form[q.key] !== null && this.form[q.key] !== undefined)
    },
    totalScore() {
      return this.questions.reduce((sum, q) => sum + (Number(this.form[q.key]) || 0), 0)
    },
    satisfactionText() {
      if (!this.scoreComplete) {
        return ''
      }
      return this.totalScore >= 80 ? '满意(80分(含)以上)' : '不满意(80分以下)'
    }
  },
  methods: {
    async openDia(type, row) {
      this.formDia = true;
      this.operationType = type
      await this.loadCustomerOptions()
      if (this.operationType === 'edit') {
        this.form = { ...row }
        this.form = { ...createEmptyForm(), ...row }
        if (!this.form.unitName) {
          this.form.unitName = this.form.userName || ''
        }
        // è€æ•°æ®æ²¡æœ‰å…³è”委托单:合成一个选项把原来的值回显出来,不强制重选
        if (row.orderId == null && row.userName) {
          this.customerOptions.unshift({
@@ -229,26 +265,7 @@
      }
    },
    resetForm() {
      this.form = {
        unitName: '',
        fillDate: '',
        userName: '',
        department: '',
        contactNumber: '',
        serviceAttitude: '',
        serviceAttitudeSuggestion: '',
        technicalCompetence: '',
        technicalCompetenceSuggestion: '',
        inspectionWork: '',
        inspectionWorkSuggestion: '',
        reasonableFees: '',
        reasonableFeesSuggestion: '',
        remark: '',
        clientSatisfactionId: '',
        orderId: '',
        entrustCode: '',
        sampleName: ''
      }
      this.form = createEmptyForm()
    },
    // ä¸‹æ‹‰æ•°æ®ï¼šåŽç«¯åªè¿”回"已检验"的委托单(state=4)
    loadCustomerOptions() {
@@ -283,13 +300,16 @@
        this.$message.warning('请选择客户')
        return
      }
      if (!this.form.unitName) {
        this.$message.warning('请填写单位名称')
      if (!this.form.fillDate) {
        this.$message.warning('请选择填表日期')
        return
      }
      if (!this.form.department) {
        this.$message.warning('请填写部门')
        return
      for (let i = 0; i < this.questions.length; i++) {
        const score = this.form[this.questions[i].key]
        if (score === '' || score === null || score === undefined) {
          this.$message.warning('请完成第' + (i + 1) + '题评分')
          return
        }
      }
      this.editLoad = true
      const payload = { ...this.form }
@@ -351,15 +371,30 @@
  margin: 10vh auto 50px !important;
}
.survey-intro {
  margin-top: 10px;
  font-size: 13px;
  line-height: 22px;
  color: #303133;
}
.tables {
  table-layout: fixed;
  width: 100%;
  margin-top: 10px;
}
.section-title {
  height: 34px;
  text-align: center;
  font-size: 14px;
  font-weight: bold;
  background: #f5f7fa;
  padding: 6px;
}
.td-title {
  height: 40px;
  width: 170px;
  text-align: center;
  font-size: 14px;
  word-wrap: break-word;
@@ -367,17 +402,32 @@
  padding: 6px;
}
.question-title {
  text-align: left;
}
.td-info {
  padding: 6px;
}
.td-info1 {
  display: inline-block;
  width: 100%;
  text-align: left;
.score-head {
  text-align: center;
  font-size: 14px;
  word-wrap: break-word;
  white-space: normal;
  padding: 6px;
}
.score-cell {
  text-align: center;
  height: 38px;
}
.score-cell :deep(.el-radio) {
  margin-right: 0;
}
.score-result {
  font-weight: bold;
  color: #409eff;
}
.customer-card-sub {
src/views/CNAS/systemManagement/customerSatisfaction/index.vue
@@ -4,7 +4,7 @@
      <div class="search">
        <div>
          <el-form :model="searchForm" ref="searchForm" size="small" :inline="true">
            <el-form-item label="单位名称" prop="unitName" v-if="tabIndex === '0'">
            <el-form-item label="客户名称" prop="unitName" v-if="tabIndex === '0'">
              <el-input v-model="searchForm.unitName" clearable size="small"></el-input>
            </el-form-item>
            <el-form-item label="文件名称" prop="fileName" v-if="tabIndex === '1'">
@@ -18,6 +18,7 @@
        </div>
        <div>
          <el-button v-if="tabIndex === '0'" size="small" type="primary" @click="openFormDia('add')">新 å¢ž</el-button>
          <el-button v-if="tabIndex === '0'" size="small" type="primary" @click="openExportDialog">导 å‡º</el-button>
          <el-upload v-if="tabIndex === '1'" ref='upload' :action="action" :before-upload="beforeUpload"
                     :headers="uploadHeader" :on-error="onError" :on-success="handleSuccessUp" :show-file-list="false"
                     accept='.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.pdf,.zip,.rar'>
@@ -49,6 +50,18 @@
      <filePreview v-if="lookDialogVisible" :currentFile="{}" :fileUrl="javaApi + '/word/' + currentInfo.fileUrl"
        style="height: 90vh;overflow-y: auto;" />
    </el-dialog>
    <el-dialog v-model="exportDialogVisible" title="导出年度汇总" width="360px" :close-on-click-modal="false">
      <el-form label-width="80px">
        <el-form-item label="汇总年度">
          <el-date-picker v-model="exportYear" type="year" format="YYYYå¹´" value-format="YYYY" placeholder="选择年度"
            style="width: 100%"></el-date-picker>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="exportDialogVisible = false">取 æ¶ˆ</el-button>
        <el-button type="primary" :loading="exportLoading" @click="handleExport">导 å‡º</el-button>
      </template>
    </el-dialog>
    <FormDialog v-if="formDialog" ref="formDialog" @closeFormDia="closeFormDia"></FormDialog>
    <lims-attachment ref="attachmentDialog" title="满意度附件" name-field="unitName" id-param="clientSatisfactionId"
      upload-url="/clientSatisfaction/uploadAttachment" select-url="/clientSatisfaction/selectAttachments"
@@ -66,6 +79,7 @@
  pageAnalyseFile,
  delClientSatisfaction,
  delAnalyseFile,
  exportAnnualSummary,
} from '@/api/cnas/systemManagement/customerSatisfaction.js'
export default {
@@ -84,48 +98,66 @@
      tabIndex: '0',
      tableColumn: [
        {
          label: '单位名称',
          label: '客户名称',
          prop: 'unitName',
          minWidth: '140'
        },
        {
          label: '地址',
          prop: 'address',
          minWidth: '150'
        },
        {
          label: '联系人',
          prop: 'contactPerson',
          minWidth: '100'
        },
        {
          label: '日期',
          label: '填表日期',
          prop: 'fillDate',
          minWidth: '100'
        },
        {
          label: '客户',
          prop: 'userName',
          minWidth: '100'
          label: '总分',
          prop: 'totalScore',
          minWidth: '80',
          formatData: (val) => (val === null || val === undefined || val === '') ? '--' : val
        },
        {
          label: '满意度评定',
          prop: 'satisfactionResult',
          minWidth: '110',
          dataType: 'tag',
          formatData: (val) => {
            if (val === null || val === undefined || val === '') {
              return '--'
            }
            return Number(val) === 0 ? '满意' : '不满意'
          },
          formatType: (val) => {
            if (val === null || val === undefined || val === '') {
              return 'info'
            }
            return Number(val) === 0 ? 'success' : 'danger'
          }
        },
        {
          label: '委托单编号',
          prop: 'entrustCode',
          minWidth: '100'
          minWidth: '110'
        },
        {
          label: '样品名称',
          prop: 'sampleName',
          minWidth: '100'
        },
        {
          label: '部门',
          prop: 'department',
          minWidth: '100'
        },
        {
          label: '联系电话',
          prop: 'contactNumber',
          minWidth: '100'
          minWidth: '120'
        },
        {
          label: '创建日期',
          prop: 'createTime',
          minWidth: '100',
          minWidth: '160',
        },
        {
          dataType: 'action',
          minWidth: '80',
          label: '操作',
          operation: [
            {
@@ -178,7 +210,6 @@
        },
        {
          dataType: 'action',
          minWidth: '50',
          label: '操作',
          operation: [
            {
@@ -217,6 +248,9 @@
      upLoading: false,
      currentInfo: {},
      lookDialogVisible: false,
      exportDialogVisible: false,
      exportYear: String(new Date().getFullYear()),
      exportLoading: false,
    };
  },
  mounted() {
@@ -260,6 +294,42 @@
      this.formDialog = false
      this.searchList()
    },
    // æ‰“开年度汇总导出弹窗
    openExportDialog() {
      this.exportYear = String(new Date().getFullYear())
      this.exportDialogVisible = true
    },
    // å¯¼å‡ºå¹´åº¦æ±‡æ€» Word
    handleExport() {
      if (!this.exportYear) {
        this.$message.warning('请选择汇总年度')
        return
      }
      this.exportLoading = true
      exportAnnualSummary({ year: this.exportYear }).then(async data => {
        // åŽç«¯æ— æ•°æ®æ—¶è¿”回的是 JSON,需转成文本提示,避免存成坏文件
        if (data && data.type && data.type.indexOf('json') !== -1) {
          let msg = '导出失败'
          try {
            const resText = await data.text()
            const rspObj = JSON.parse(resText)
            msg = rspObj.msg || msg
          } catch (e) {
            console.log('err---', e)
          }
          this.exportLoading = false
          this.$message.error(msg)
          return
        }
        const blob = new Blob([data], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' })
        this.$download.saveAs(blob, '客户满意度年度汇总表_' + this.exportYear + 'å¹´.docx')
        this.exportLoading = false
        this.exportDialogVisible = false
      }).catch(err => {
        console.log('err---', err)
        this.exportLoading = false
      })
    },
    // é‡ç½®æŸ¥è¯¢æ¡ä»¶
    resetSearchForm() {
      this.searchForm.unitName = '';
src/views/business/costStatistics/index.vue
@@ -4,6 +4,12 @@
    <div class="search">
      <div>
        <el-form :model="entity" ref="entity" size="small" :inline="true">
          <el-form-item label="送样时间">
            <el-date-picker v-model="monthRange" type="monthrange" size="small"
                            value-format="YYYY-MM" range-separator="至"
                            start-placeholder="开始月份" end-placeholder="结束月份"
                            style="width: 240px" @change="refreshTable()"></el-date-picker>
          </el-form-item>
          <el-form-item label="委托编号" prop="entrustCode">
            <el-input size="small" placeholder="请输入" clearable v-model="entity.entrustCode"
                      @keyup.enter="refreshTable()"></el-input>
@@ -29,8 +35,31 @@
    </div>
    <div>
      <lims-table :tableData="tableData" :column="column"
                  :height="'calc(100vh - 250px)'" @pagination="pagination"
                  :page="page" :tableLoading="tableLoading"></lims-table>
                  :height="'calc(100vh - 300px)'" @pagination="pagination"
                  :isSelection="true" :handleSelectionChange="handleSelectionChange"
                  :page="page" :tableLoading="tableLoading">
        <template #isDiscount="{ row }">
          <el-select v-model="row.isDiscount" size="small" style="width: 80px"
                     @change="handleDiscountChange(row)">
            <el-option label="无" :value="0"></el-option>
            <el-option label="有" :value="1"></el-option>
          </el-select>
        </template>
        <template #discountAmount="{ row }">
          <el-input-number v-model="row.discountAmount" size="small" style="width: 100%"
                           :controls="false" :precision="2" :min="0"
                           :disabled="row.isDiscount !== 1"
                           placeholder="请输入" @change="handleAmountChange(row)"></el-input-number>
        </template>
        <template #feeRemark="{ row }">
          <el-input v-model="row.feeRemark" size="small" maxlength="500"
                    @change="handleFeeRemarkChange(row)"></el-input>
        </template>
      </lims-table>
    </div>
    <div class="sum-bar">
      <span>已勾选 <b class="sum-count">{{ selectedRows.length }}</b> æ¡</span>
      <span>总金额:<b class="sum-amount">Â¥{{ selectedTotal }}</b></span>
    </div>
    <viewInfoDia ref="viewInfoDia" v-if="viewInfoDia"></viewInfoDia>
  </div>
@@ -39,8 +68,9 @@
<script>
import limsTable from "@/components/Table/lims-table.vue";
import viewInfoDia from "./components/viewInfoDia.vue";
import {selectRatesPage} from "@/api/business/insOrderRates";
import {selectRatesPage, updateOrderFee} from "@/api/business/insOrderRates";
import {selectCustomPageList} from "@/api/system/customer";
import { ElMessage } from "element-plus";
export default {
  components: {limsTable,viewInfoDia},
@@ -50,10 +80,11 @@
        sample: null,
        company: null,
      },
      monthRange: [],
      tableData: [],
      tableLoading: false,
      column: [
        { label: '下单时间', prop: 'createTime', width: 150 },
        { label: '送样时间', prop: 'sampleDate', width: 120 },
        { label: '委托编号', prop: 'entrustCode', width: 160 },
        { label: '样品名称', prop: 'sample', width: 160 },
        { label: '委托单位', prop: 'company' },
@@ -84,8 +115,28 @@
          },
        },
        { label: '委托人', prop: 'prepareUser' },
        { label: '生产单位', prop: 'production' },
        { label: '总价', prop: 'totalPrice' },
        { label: '总价', prop: 'totalPrice', width: 110 },
        {
          label: '是否优惠',
          prop: 'isDiscount',
          width: 100,
          dataType: 'slot',
          slot: 'isDiscount'
        },
        {
          label: '优惠后金额',
          prop: 'discountAmount',
          width: 130,
          dataType: 'slot',
          slot: 'discountAmount'
        },
        {
          label: '备注',
          prop: 'feeRemark',
          minWidth: 160,
          dataType: 'slot',
          slot: 'feeRemark'
        },
        {
          dataType: "action",
          fixed: "right",
@@ -107,7 +158,19 @@
        current: 1
      },
      companyOptions: [],
      viewInfoDia: false
      viewInfoDia: false,
      selectedRows: []
    }
  },
  computed: {
    // å‹¾é€‰åˆè®¡ï¼šæœ‰ä¼˜æƒ ä¸”填了优惠后金额的按优惠后金额算,否则按总价算
    selectedTotal() {
      const total = this.selectedRows.reduce((sum, row) => {
        const useDiscount = row.isDiscount === 1 && row.discountAmount !== null && row.discountAmount !== undefined
        const amount = Number(useDiscount ? row.discountAmount : row.totalPrice)
        return sum + (isFinite(amount) ? amount : 0)
      }, 0)
      return total.toFixed(2)
    }
  },
  mounted() {
@@ -117,10 +180,15 @@
  methods :{
    refreshTable() {
      this.tableLoading = true
      selectRatesPage({ ...this.page, ...this.entity }).then(res => {
      const [startMonth, endMonth] = this.monthRange || []
      selectRatesPage({ ...this.page, ...this.entity, startMonth, endMonth }).then(res => {
        this.tableLoading = false
        this.tableData = res.data.records
        this.tableData = (res.data.records || []).map(item => {
          item.isDiscount = item.isDiscount === 1 ? 1 : 0
          return item
        })
        this.page.total = res.data.total
        this.selectedRows = []
      }).catch(err => {
        this.tableLoading = false
      })
@@ -128,6 +196,7 @@
    // é‡ç½®
    refresh() {
      this.resetForm('entity')
      this.monthRange = []
      this.refreshTable()
    },
    // æŸ¥è¯¢å§”托单位下拉框数据
@@ -152,6 +221,53 @@
      this.page.size = page.limit
      this.refreshTable()
    },
    // å‹¾é€‰è¡Œå˜åŒ–
    handleSelectionChange(selection) {
      this.selectedRows = selection
    },
    // åˆ‡æ¢æ˜¯å¦ä¼˜æƒ 
    handleDiscountChange(row) {
      if (row.isDiscount === 1) {
        // é€‰â€œæœ‰â€æ—¶å¸¦å‡ºæ€»ä»·ï¼Œç”¨æˆ·å†æ”¹æˆå®žé™…优惠后金额
        if (row.discountAmount === null || row.discountAmount === undefined || row.discountAmount === '') {
          row.discountAmount = this.amountOf(row.totalPrice)
        }
      } else {
        row.discountAmount = null
      }
      this.saveFee(row)
    },
    // ä¼˜æƒ åŽé‡‘额改完保存
    handleAmountChange(row) {
      if (row.isDiscount !== 1) {
        row.discountAmount = null
        return
      }
      if (row.discountAmount === null || row.discountAmount === undefined || row.discountAmount === '') {
        ElMessage.warning('有优惠时请填写优惠后金额')
        return
      }
      this.saveFee(row)
    },
    // å¤‡æ³¨æ”¹å®Œä¿å­˜
    handleFeeRemarkChange(row) {
      this.saveFee(row)
    },
    amountOf(value) {
      const num = Number(value)
      if (!isFinite(num)) return 0
      return Math.round(num * 100) / 100
    },
    saveFee(row) {
      updateOrderFee({
        id: row.id,
        isDiscount: row.isDiscount,
        discountAmount: row.isDiscount === 1 ? row.discountAmount : null,
        feeRemark: row.feeRemark || null
      }).catch(() => {
        this.refreshTable()
      })
    },
    // æŸ¥çœ‹è¯¦æƒ…
    openDia (row) {
      this.viewInfoDia = true
@@ -164,8 +280,28 @@
</script>
<style scoped>
.search {
  height: 46px;
  min-height: 46px;
  display: flex;
  justify-content: space-between;
}
.sum-bar {
  position: sticky;
  bottom: 0;
  z-index: 10;
  display: flex;
  justify-content: flex-end;
  gap: 24px;
  margin-top: 8px;
  padding: 10px 16px;
  background: #fff;
  border-top: 1px solid var(--el-border-color-light);
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
.sum-count {
  color: var(--el-color-primary);
}
.sum-amount {
  color: var(--el-color-danger);
  font-size: 16px;
}
</style>