From cdb0e306d0b83902908f20da0903bd9df901c81b Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期六, 16 五月 2026 14:24:16 +0800
Subject: [PATCH] feat(travel-reimburse): 完善差旅报销模块界面和功能

---
 src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js             |  193 ++++++++++++
 src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue |   49 +++
 src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue                           |  625 +++++++++++++++++++++++++++++++++++++++++
 3 files changed, 860 insertions(+), 7 deletions(-)

diff --git a/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue
new file mode 100644
index 0000000..03a5fa3
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue
@@ -0,0 +1,49 @@
+<!-- 宸梾鎶ラ攢锛氬鎵规祦绋嬭繘搴﹀睍绀� -->
+<template>
+  <el-steps :active="activeStep" finish-status="success" align-center>
+    <el-step
+      v-for="(node, index) in sortedNodes"
+      :key="index"
+      :title="`鑺傜偣 ${index + 1}`"
+      :description="stepDescription(node)"
+      :status="stepStatus(node, index)"
+    />
+  </el-steps>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+const props = defineProps({
+  nodes: { type: Array, default: () => [] },
+  currentIndex: { type: Number, default: 0 },
+});
+
+const sortedNodes = computed(() => {
+  const list = props.nodes || [];
+  return [...list].sort((a, b) => (a.sortOrder ?? a.nodeOrder ?? 0) - (b.sortOrder ?? b.nodeOrder ?? 0));
+});
+
+const activeStep = computed(() => {
+  const list = sortedNodes.value;
+  if (!list.length) return 0;
+  const finished = list.filter((n) => n.nodeStatus === "finish").length;
+  const hasError = list.some((n) => n.nodeStatus === "error");
+  if (hasError) return Math.max(0, props.currentIndex);
+  return finished;
+});
+
+function stepDescription(node) {
+  const name = (node.approverName || "").trim() || "鏈寚瀹�";
+  const opinion = (node.approveOpinion || "").trim();
+  if (opinion) return `${name}锛�${opinion}`;
+  return name;
+}
+
+function stepStatus(node, index) {
+  if (node.nodeStatus === "error") return "error";
+  if (node.nodeStatus === "finish") return "success";
+  if (node.nodeStatus === "process" || index === props.currentIndex) return "process";
+  return "wait";
+}
+</script>
diff --git a/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
index b78eb7b..2e81e18 100644
--- a/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
+++ b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
@@ -1,12 +1,623 @@
-<!--
-  妯″潡涓枃鍚嶏細宸梾鎶ラ攢
-  鐩綍鏍囪瘑锛歊eimburseManage/travel-reimburse锛坱ravel-reimburse 鈫� 涓枃锛氬樊鏃呮姤閿�锛�
-  澶嶇敤椤甸潰锛欯/views/procurementManagement/procurementLedger/index.vue锛堥噰璐彴璐︼紱鏂囦欢鍚� index.vue 鈫� 鍏ュ彛椤碉級
--->
+<!--OA妯″潡锛氬樊鏃呮姤閿�-->
 <template>
-  <ProcurementLedger />
+  <div class="app-container">
+    <div class="search_form mb20">
+      <div>
+        <span class="search_title">鐢宠浜猴細</span>
+        <el-input
+          v-model="searchForm.applicantKeyword"
+          style="width: 220px"
+          placeholder="濮撳悕鎴栫紪鍙�"
+          clearable
+          :prefix-icon="Search"
+          @keyup.enter="handleQuery"
+        />
+        <span class="search_title" style="margin-left: 12px">鍑哄樊寮�濮嬶細</span>
+        <el-date-picker
+          v-model="searchForm.travelStartFrom"
+          type="date"
+          placeholder="寮�濮嬫棩鏈�"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          style="width: 150px"
+          clearable
+        />
+        <span class="search_title" style="margin-left: 8px">缁撴潫锛�</span>
+        <el-date-picker
+          v-model="searchForm.travelEndTo"
+          type="date"
+          placeholder="缁撴潫鏃ユ湡"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          style="width: 150px"
+          clearable
+        />
+        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">鎼滅储</el-button>
+        <el-button @click="resetSearch">閲嶇疆</el-button>
+      </div>
+      <div class="search_actions">
+        <el-button type="success" plain @click="handleImportClick">瀵煎叆</el-button>
+        <el-button type="warning" plain @click="handleExport">瀵煎嚭</el-button>
+        <el-button type="primary" @click="openFormDialog('add')">鏂板宸梾鎶ラ攢</el-button>
+      </div>
+    </div>
+
+    <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
+
+    <div class="table_list">
+      <PIMTable
+        rowKey="id"
+        :column="tableColumn"
+        :tableData="tableData"
+        :page="page"
+        :isSelection="false"
+        :tableLoading="tableLoading"
+        :total="page.total"
+        @pagination="pagination"
+      />
+    </div>
+
+    <!-- 鏂板 / 缂栬緫 -->
+    <el-dialog
+      v-model="formDialog.visible"
+      :title="formDialog.title"
+      width="1120px"
+      append-to-body
+      destroy-on-close
+      class="travel-reimburse-form-dialog"
+      @closed="onFormClosed"
+    >
+      <el-alert
+        v-if="budgetHint.visible"
+        :title="budgetHint.title"
+        :type="budgetHint.type"
+        :description="budgetHint.description"
+        show-icon
+        :closable="false"
+        class="mb16"
+      />
+      <el-alert v-if="overBudgetWarnings.length" type="warning" show-icon :closable="false" class="mb16">
+        <template #title>宸梾鏍囧噯瓒呮敮鎻愰啋锛堥渶鐗规壒锛�</template>
+        <ul class="warn-list">
+          <li v-for="(w, i) in overBudgetWarnings" :key="i">{{ w }}</li>
+        </ul>
+      </el-alert>
+
+      <el-form
+        ref="formRef"
+        :model="form"
+        :rules="formRules"
+        label-width="120px"
+        class="travel-reimburse-form"
+        :disabled="formDialog.readonly"
+      >
+        <el-card class="form-section" shadow="never">
+          <template #header><span class="card-header-title">鍩烘湰淇℃伅</span></template>
+          <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="鍛樺伐缂栧彿">
+              <el-input v-model="form.employeeNo" readonly placeholder="閫夋嫨鍛樺伐鍚庤嚜鍔ㄥ甫鍑�" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="鍛樺伐濮撳悕" prop="applicantId">
+              <el-select
+                v-model="form.applicantId"
+                filterable
+                remote
+                clearable
+                reserve-keyword
+                placeholder="璇烽�夋嫨鎴栨悳绱㈠憳宸�"
+                style="width: 100%"
+                :remote-method="remoteSearchApplicantForm"
+                :loading="applicantFormSearchLoading"
+                @change="onApplicantChange"
+              >
+                <el-option
+                  v-for="u in applicantFormOptions"
+                  :key="u.userId"
+                  :label="userSelectLabel(u)"
+                  :value="u.userId"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="24">
+            <el-form-item label="鎶ラ攢鍘熷洜" prop="reimburseReason">
+              <el-input
+                v-model="form.reimburseReason"
+                type="textarea"
+                :rows="3"
+                placeholder="璇峰~鍐欏嚭宸強鎶ラ攢鍘熷洜"
+                maxlength="2000"
+                show-word-limit
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="鍑哄樊寮�濮�" prop="travelStartTime">
+              <el-date-picker
+                v-model="form.travelStartTime"
+                type="datetime"
+                placeholder="寮�濮嬫椂闂�"
+                format="YYYY-MM-DD HH:mm:ss"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                style="width: 100%"
+                @change="onTravelRangeChange"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="鍑哄樊缁撴潫" prop="travelEndTime">
+              <el-date-picker
+                v-model="form.travelEndTime"
+                type="datetime"
+                placeholder="缁撴潫鏃堕棿"
+                format="YYYY-MM-DD HH:mm:ss"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                style="width: 100%"
+                @change="onTravelRangeChange"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="鍑哄樊澶╂暟">
+              <el-input :model-value="travelDaysDisplay" readonly>
+                <template #append>澶�</template>
+              </el-input>
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="鍑哄樊鍦�" prop="departurePlace">
+              <el-input v-model="form.departurePlace" placeholder="鍑哄彂鍩庡競" @blur="recalcTravelStandards" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="鐩殑鍦�" prop="destination">
+              <el-input v-model="form.destination" placeholder="鐩殑鍩庡競" @blur="recalcTravelStandards" />
+            </el-form-item>
+          </el-col>
+          </el-row>
+        </el-card>
+
+        <el-card class="form-section" shadow="never">
+          <template #header>
+            <div class="card-header-row">
+              <span class="card-header-title">宸梾鏍囧噯</span>
+              <el-text type="info" size="small">{{ travelTierLabel }} 路 鐢熸椿琛ヨ创寤鸿 {{ suggestedLivingSubsidy }} 鍏�</el-text>
+            </div>
+          </template>
+          <el-row :gutter="20">
+            <el-col :span="8">
+              <el-form-item label="閰掑簵鏍囧噯">
+              <el-input-number
+                v-model="form.hotelStandard"
+                :min="0"
+                :precision="2"
+                controls-position="right"
+                style="width: 100%"
+                @change="recalcTravelStandards"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="浣忓澶╂暟">
+              <el-input-number
+                v-model="form.hotelDays"
+                :min="0"
+                :max="365"
+                :precision="0"
+                controls-position="right"
+                style="width: 100%"
+                @change="recalcTravelStandards"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="鐢熸椿琛ヨ创">
+              <el-input-number
+                v-model="form.livingSubsidy"
+                :min="0"
+                :precision="2"
+                controls-position="right"
+                style="width: 100%"
+                @change="recalcTravelStandards"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+          <el-row :gutter="20">
+            <el-col :span="8">
+              <el-form-item label="浜ら�氳ˉ璐�">
+                <el-input :model-value="String(suggestedTransportSubsidy)" readonly><template #append>鍏�</template></el-input>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="浣忓闄愰">
+                <el-input :model-value="String(suggestedHotelLimit)" readonly><template #append>鍏�</template></el-input>
+              </el-form-item>
+            </el-col>
+            <el-col :span="8">
+              <el-form-item label="鐗规壒鏍囪">
+                <el-tag :type="form.needSpecialApproval ? 'danger' : 'success'" effect="plain">
+                  {{ form.needSpecialApproval ? "瓒呮敮闇�鐗规壒" : "鍦ㄦ爣鍑嗚寖鍥村唴" }}
+                </el-tag>
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-card>
+
+        <el-card class="form-section" shadow="never">
+          <template #header><span class="card-header-title">閲戦涓庢敹娆�</span></template>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="鐢宠閲戦" prop="applyAmount">
+                <div class="amount-row">
+                  <el-input-number v-model="form.applyAmount" :min="0" :precision="2" controls-position="right" class="amount-input" />
+                  <el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails">
+                    鎸夋槑缁嗘眹鎬� {{ detailTotalAmount }} 鍏�
+                  </el-button>
+                </div>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="鏀舵浜�" prop="payee">
+                <el-input v-model="form.payee" placeholder="璇疯緭鍏ユ敹娆句汉" maxlength="50" />
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-card>
+
+        <el-card class="form-section" shadow="never">
+          <template #header>
+            <div class="card-header-row">
+              <span class="card-header-title">鎶ラ攢鏄庣粏</span>
+              <el-button v-if="!formDialog.readonly" type="primary" plain size="small" @click="addExpenseDetail">鏂板鏄庣粏</el-button>
+            </div>
+          </template>
+
+        <el-table :data="form.expenseDetails" border size="small" class="detail-table">
+          <el-table-column type="index" label="搴忓彿" width="55" align="center" />
+          <el-table-column label="鍙戠エ鏃ユ湡" width="150">
+            <template #default="{ row }">
+              <el-date-picker
+                v-if="!formDialog.readonly"
+                v-model="row.invoiceDate"
+                type="date"
+                value-format="YYYY-MM-DD"
+                size="small"
+                style="width: 100%"
+              />
+              <span v-else>{{ row.invoiceDate || "鈥�" }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="璐圭敤绉戠洰" width="130">
+            <template #default="{ row }">
+              <el-select
+                v-if="!formDialog.readonly"
+                v-model="row.expenseSubject"
+                size="small"
+                style="width: 100%"
+                @change="recalcTravelStandards"
+              >
+                <el-option
+                  v-for="opt in EXPENSE_SUBJECT_OPTIONS"
+                  :key="opt.value"
+                  :label="opt.label"
+                  :value="opt.value"
+                />
+              </el-select>
+              <span v-else>{{ expenseSubjectLabel(row.expenseSubject) }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="閲戦" width="120">
+            <template #default="{ row }">
+              <el-input-number
+                v-if="!formDialog.readonly"
+                v-model="row.amount"
+                :min="0"
+                :precision="2"
+                size="small"
+                controls-position="right"
+                style="width: 100%"
+                @change="onDetailAmountChange"
+              />
+              <span v-else>{{ row.amount ?? "鈥�" }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="鎻忚堪" min-width="140">
+            <template #default="{ row }">
+              <el-input v-if="!formDialog.readonly" v-model="row.description" size="small" placeholder="璇存槑" />
+              <span v-else>{{ row.description || "鈥�" }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column v-if="!formDialog.readonly" label="鎿嶄綔" width="70" align="center">
+            <template #default="{ $index }">
+              <el-button type="danger" link size="small" @click="removeExpenseDetail($index)">鍒犻櫎</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        </el-card>
+
+        <el-card class="form-section" shadow="never">
+          <template #header><span class="card-header-title">闄勪欢锛堝彂绁級</span></template>
+          <el-form-item label-width="0" class="attachment-form-item">
+            <div class="upload-block">
+              <FileUpload v-model:file-list="form.attachmentList" :limit="20" button-text="鐐瑰嚮閫夋嫨鏂囦欢" />
+            </div>
+          </el-form-item>
+        </el-card>
+
+        <el-card class="form-section" shadow="never">
+          <template #header><span class="card-header-title">瀹℃壒娴佺▼</span></template>
+          <el-form-item prop="approvalFlowNodes" label-width="0">
+          <ApprovalFlowEditor
+            v-if="!formDialog.readonly"
+            v-model="form.approvalFlowNodes"
+            :user-options="flowUserOptions"
+            @update:model-value="onApprovalFlowChange"
+          />
+          <ApprovalFlowProgress v-else :nodes="form.approvalFlowNodes" :current-index="form.currentNodeIndex" />
+          <p v-if="!formDialog.readonly" class="flow-tip">鑷冲皯淇濈暀涓�涓妭鐐癸紱瀹℃牳涓�佸凡閫氳繃鐨勫崟鎹笉鍙紪杈戙��</p>
+        </el-form-item>
+        </el-card>
+      </el-form>
+      <template #footer>
+        <el-button v-if="!formDialog.readonly" type="primary" @click="submitForm">鎻� 浜�</el-button>
+        <el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "鍏� 闂�" : "鍙� 娑�" }}</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 璇︽儏 -->
+    <el-dialog v-model="detailDialog.visible" title="宸梾鎶ラ攢璇︽儏" width="900px" append-to-body destroy-on-close>
+      <DetailPanel :row="detailRow" />
+      <ApprovalFlowProgress
+        class="mt16"
+        :nodes="detailRow.approvalFlowNodes"
+        :current-index="detailRow.currentNodeIndex ?? 0"
+      />
+      <el-divider content-position="left">瀹℃壒璁板綍锛堝叏娴佺▼鐣欑棔锛�</el-divider>
+      <el-timeline v-if="detailRow.approvalRecords?.length">
+        <el-timeline-item
+          v-for="(rec, i) in detailRow.approvalRecords"
+          :key="i"
+          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
+          :timestamp="rec.time"
+        >
+          {{ rec.operatorName }} 鈥� {{ approvalActionLabel(rec.result) }}锛歿{ rec.opinion || "鏃犳剰瑙�" }}
+        </el-timeline-item>
+      </el-timeline>
+      <el-empty v-else description="鏆傛棤瀹℃壒璁板綍" :image-size="60" />
+      <template #footer>
+        <el-button type="primary" @click="detailDialog.visible = false">鍏� 闂�</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 瀹℃壒 -->
+    <el-dialog
+      v-model="approveDialog.visible"
+      title="宸梾鎶ラ攢瀹℃壒"
+      width="1000px"
+      append-to-body
+      destroy-on-close
+      @closed="approveOpinion = ''"
+    >
+      <DetailPanel :row="approveDialog.row" />
+      <el-divider content-position="left">娴佺▼杩涘害</el-divider>
+      <ApprovalFlowProgress
+        :nodes="approveDialog.row?.approvalFlowNodes"
+        :current-index="approveDialog.row?.currentNodeIndex ?? 0"
+      />
+      <el-form label-width="100px" class="mt16">
+        <el-form-item label="瀹℃壒鎰忚">
+          <el-input
+            v-model="approveOpinion"
+            type="textarea"
+            :rows="3"
+            maxlength="500"
+            show-word-limit
+            placeholder="閫氳繃鍙暀绌猴紱椹冲洖璇峰~鍐欏師鍥�"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button type="success" @click="submitApprove('approved')">閫� 杩�</el-button>
+        <el-button type="danger" @click="submitApprove('rejected')">椹� 鍥�</el-button>
+        <el-button @click="approveDialog.visible = false">鍙� 娑�</el-button>
+      </template>
+    </el-dialog>
+  </div>
 </template>
 
 <script setup>
-import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue'
+import FileUpload from "@/components/AttachmentUpload/file/index.vue";
+import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
+import ApprovalFlowProgress from "./components/ApprovalFlowProgress.vue";
+import DetailPanel from "./components/DetailPanel.vue";
+import { useTravelReimburse } from "./useTravelReimburse.js";
+
+const tr = useTravelReimburse();
+const {
+  Search,
+  EXPENSE_SUBJECT_OPTIONS,
+  expenseSubjectLabel,
+  searchForm,
+  tableLoading,
+  page,
+  tableData,
+  tableColumn,
+  importInputRef,
+  formRef,
+  form,
+  formDialog,
+  formRules,
+  detailDialog,
+  detailRow,
+  approveDialog,
+  approveOpinion,
+  applicantFormSearchLoading,
+  applicantFormOptions,
+  flowUserOptions,
+  travelDaysDisplay,
+  travelTierLabel,
+  suggestedLivingSubsidy,
+  suggestedTransportSubsidy,
+  suggestedHotelLimit,
+  detailTotalAmount,
+  overBudgetWarnings,
+  budgetHint,
+  handleQuery,
+  resetSearch,
+  pagination,
+  remoteSearchApplicantForm,
+  userSelectLabel,
+  onApplicantChange,
+  recalcTravelStandards,
+  onTravelRangeChange,
+  onDetailAmountChange,
+  onApprovalFlowChange,
+  addExpenseDetail,
+  removeExpenseDetail,
+  syncApplyAmountFromDetails,
+  openFormDialog,
+  onFormClosed,
+  submitForm,
+  openDetail,
+  approvalActionLabel,
+  submitApprove,
+  handleExport,
+  handleImportClick,
+  onImportFile,
+} = tr;
 </script>
+
+<style scoped>
+.mb20 {
+  margin-bottom: 20px;
+}
+.mb16 {
+  margin-bottom: 16px;
+}
+.mb8 {
+  margin-bottom: 8px;
+}
+.mt16 {
+  margin-top: 16px;
+}
+.search_form {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+.search_actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.search_title {
+  font-size: 14px;
+  color: var(--el-text-color-regular);
+}
+.sr-only-input {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border: 0;
+}
+.form-section {
+  margin-bottom: 16px;
+  border: 1px solid var(--el-border-color-lighter);
+}
+.form-section :deep(.el-card__header) {
+  padding: 12px 16px;
+  background: var(--el-fill-color-lighter);
+}
+.form-section :deep(.el-card__body) {
+  padding: 16px 16px 4px;
+}
+.card-header-title {
+  font-size: 15px;
+  font-weight: 600;
+}
+.card-header-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+.amount-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  width: 100%;
+}
+.amount-input {
+  flex: 1;
+  min-width: 160px;
+}
+.w-full {
+  width: 100%;
+}
+.attachment-form-item {
+  margin-bottom: 0;
+}
+.detail-table {
+  margin-bottom: 0;
+}
+.section-title {
+  font-size: 15px;
+  font-weight: 600;
+  margin: 8px 0 12px;
+  color: var(--el-text-color-primary);
+  border-left: 3px solid var(--el-color-primary);
+  padding-left: 8px;
+}
+.field-tip {
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  margin-top: 4px;
+}
+.warn-list {
+  margin: 0;
+  padding-left: 18px;
+}
+.detail-toolbar {
+  margin-bottom: 8px;
+}
+.upload-block {
+  width: 100%;
+}
+.flow-tip {
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  margin-top: 8px;
+}
+.sync-btn {
+  margin-top: 4px;
+}
+.travel-reimburse-form-dialog :deep(.el-dialog__body) {
+  padding-top: 12px;
+}
+.travel-reimburse-form :deep(.el-form-item) {
+  margin-bottom: 18px;
+}
+.travel-reimburse-form :deep(.el-input-number) {
+  width: 100%;
+}
+.travel-reimburse-form :deep(.el-row) {
+  margin-bottom: 0;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js
new file mode 100644
index 0000000..d898614
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js
@@ -0,0 +1,193 @@
+import dayjs from "dayjs";
+
+/** 璐圭敤绉戠洰 */
+export const EXPENSE_SUBJECT_OPTIONS = [
+  { label: "浜ら�氳垂", value: "transport" },
+  { label: "浣忓璐�", value: "hotel" },
+  { label: "椁愰ギ璐�", value: "meal" },
+  { label: "鍏朵粬", value: "other" },
+];
+
+const TIER1_CITIES = ["鍖椾含", "涓婃捣", "骞垮窞", "娣卞湷"];
+
+export function expenseSubjectLabel(v) {
+  return EXPENSE_SUBJECT_OPTIONS.find((x) => x.value === v)?.label || "鈥�";
+}
+
+export function statusLabel(v) {
+  if (v === "approved") return "閫氳繃";
+  if (v === "rejected") return "椹冲洖";
+  return "瀹℃牳涓�";
+}
+
+export function statusTagType(v) {
+  if (v === "approved") return "success";
+  if (v === "rejected") return "danger";
+  return "warning";
+}
+
+export function detectTravelTier(destination) {
+  const city = (destination || "").trim();
+  if (!city) return "tier3";
+  if (TIER1_CITIES.some((c) => city.includes(c))) return "tier1";
+  const tier2Keywords = ["鏉窞", "鍗椾含", "姝︽眽", "鎴愰兘", "閲嶅簡", "瑗垮畨", "澶╂触", "鑻忓窞", "闀挎矙", "閮戝窞"];
+  if (tier2Keywords.some((c) => city.includes(c))) return "tier2";
+  return "tier3";
+}
+
+export function getTravelStandardByTier(tier) {
+  const map = {
+    tier1: { hotelPerNight: 600, transportPerDay: 80, mealPerDay: 100, label: "涓�绾垮煄甯�" },
+    tier2: { hotelPerNight: 450, transportPerDay: 60, mealPerDay: 80, label: "浜岀嚎鍩庡競" },
+    tier3: { hotelPerNight: 350, transportPerDay: 40, mealPerDay: 60, label: "鍏朵粬鍩庡競" },
+  };
+  return map[tier] || map.tier3;
+}
+
+export function computeTravelDays(startStr, endStr) {
+  if (!startStr || !endStr) return null;
+  const t0 = dayjs(startStr);
+  const t1 = dayjs(endStr);
+  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
+  const days = Math.ceil(t1.diff(t0, "day", true));
+  return Math.max(1, days);
+}
+
+export function createEmptyExpenseDetail() {
+  return {
+    id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
+    invoiceDate: "",
+    expenseSubject: "",
+    amount: undefined,
+    description: "",
+  };
+}
+
+export function createEmptyForm() {
+  return {
+    id: undefined,
+    reimburseNo: "",
+    applicantId: "",
+    employeeNo: "",
+    employeeName: "",
+    reimburseReason: "",
+    travelStartTime: "",
+    travelEndTime: "",
+    travelDays: undefined,
+    departurePlace: "",
+    destination: "",
+    hotelStandard: undefined,
+    hotelDays: undefined,
+    livingSubsidy: undefined,
+    applyAmount: undefined,
+    payee: "",
+    expenseDetails: [],
+    attachmentList: [],
+    approvalFlowNodes: [],
+    currentNodeIndex: 0,
+    needSpecialApproval: false,
+    deptId: "",
+    deptName: "",
+    travelTier: "tier3",
+  };
+}
+
+export function initApprovalFlowNodes(nodes) {
+  return (nodes || []).map((n, i) => ({
+    ...n,
+    sortOrder: i + 1,
+    nodeOrder: i + 1,
+    nodeStatus: i === 0 ? "process" : "wait",
+    approveOpinion: n.approveOpinion || "",
+    approveTime: n.approveTime || "",
+  }));
+}
+
+export function advanceApprovalFlow(row, opinion) {
+  const nodes = [...(row.approvalFlowNodes || [])];
+  const idx = row.currentNodeIndex ?? 0;
+  if (!nodes.length) return { nodes, currentNodeIndex: idx, approvalResult: row.approvalResult };
+  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+  nodes[idx] = {
+    ...nodes[idx],
+    nodeStatus: "finish",
+    approveOpinion: opinion || "鍚屾剰",
+    approveTime: now,
+  };
+  const next = idx + 1;
+  if (next >= nodes.length) {
+    return { nodes, currentNodeIndex: idx, approvalResult: "approved" };
+  }
+  nodes[next] = { ...nodes[next], nodeStatus: "process" };
+  return { nodes, currentNodeIndex: next, approvalResult: "pending" };
+}
+
+export function rejectApprovalFlow(row, opinion) {
+  const nodes = [...(row.approvalFlowNodes || [])];
+  const idx = row.currentNodeIndex ?? 0;
+  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+  if (nodes[idx]) {
+    nodes[idx] = {
+      ...nodes[idx],
+      nodeStatus: "error",
+      approveOpinion: opinion || "椹冲洖",
+      approveTime: now,
+    };
+  }
+  return { nodes, currentNodeIndex: idx, approvalResult: "rejected", rejectReason: opinion || "椹冲洖" };
+}
+
+/** 妯℃嫙閮ㄩ棬棰勭畻锛堜笌棰勭畻绯荤粺鑱斿姩鍗犱綅锛� */
+export function mockDeptBudget(deptId) {
+  const id = String(deptId || "default");
+  let s = 0;
+  for (let i = 0; i < id.length; i++) s += id.charCodeAt(i);
+  const total = 500000 + (s % 200) * 1000;
+  const used = (s % 80) * 3500;
+  return {
+    deptId: id,
+    totalBudget: total,
+    usedAmount: used,
+    remainingAmount: Math.max(0, total - used),
+  };
+}
+
+export function normalizeImportedRow(raw, idx) {
+  const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`;
+  const travelDays =
+    raw.travelDays != null
+      ? Number(raw.travelDays)
+      : computeTravelDays(raw.travelStartTime, raw.travelEndTime);
+  return {
+    id,
+    reimburseNo: raw.reimburseNo || `TR${dayjs().format("YYYYMMDD")}${String(idx).padStart(4, "0")}`,
+    applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`,
+    employeeNo: raw.employeeNo ?? raw.applicantNo ?? "",
+    employeeName: raw.employeeName ?? raw.applicantName ?? "鏈煡",
+    applicantNo: raw.employeeNo ?? raw.applicantNo ?? "",
+    applicantName: raw.employeeName ?? raw.applicantName ?? "鏈煡",
+    reimburseReason: raw.reimburseReason ?? "",
+    travelStartTime: raw.travelStartTime ?? "",
+    travelEndTime: raw.travelEndTime ?? "",
+    travelDays: travelDays == null || Number.isNaN(travelDays) ? 1 : travelDays,
+    departurePlace: raw.departurePlace ?? "",
+    destination: raw.destination ?? "",
+    hotelStandard: raw.hotelStandard,
+    hotelDays: raw.hotelDays,
+    livingSubsidy: raw.livingSubsidy,
+    applyAmount: raw.applyAmount ?? 0,
+    payee: raw.payee ?? "",
+    expenseDetails: Array.isArray(raw.expenseDetails) ? raw.expenseDetails : [],
+    invoiceAttachments: Array.isArray(raw.invoiceAttachments) ? raw.invoiceAttachments : [],
+    approvalFlowNodes: Array.isArray(raw.approvalFlowNodes) ? raw.approvalFlowNodes : [],
+    currentNodeIndex: raw.currentNodeIndex ?? 0,
+    approvalResult: ["pending", "approved", "rejected"].includes(raw.approvalResult) ? raw.approvalResult : "pending",
+    rejectReason: raw.rejectReason ?? "",
+    approvalRecords: Array.isArray(raw.approvalRecords) ? raw.approvalRecords : [],
+    needSpecialApproval: !!raw.needSpecialApproval,
+    deptId: raw.deptId ?? "",
+    deptName: raw.deptName ?? "",
+    travelTier: raw.travelTier || detectTravelTier(raw.destination),
+    createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
+  };
+}

--
Gitblit v1.9.3