<!--
|
差旅/费用报销新增/编辑(与 Web 字段一致,移动端优化选人/布局)
|
-->
|
<template>
|
<view class="oa-detail-page reimburse-form-page">
|
<PageHeader :title="pageTitle"
|
@back="goBack" />
|
<scroll-view class="oa-detail-scroll reimburse-scroll"
|
scroll-y
|
:show-scrollbar="false">
|
<view v-if="loading"
|
class="rf-loading">加载中...</view>
|
|
<view v-else>
|
<!-- 申请人 -->
|
<view class="rf-section">
|
<view class="rf-section-hd">
|
<text class="rf-section-title">申请人</text>
|
</view>
|
<view class="rf-group">
|
<view class="rf-applicant-card"
|
:class="{ 'is-empty': !form.applicantId }"
|
@click="showApplicantPicker = true">
|
<view class="rf-applicant-avatar"
|
:style="{ backgroundColor: applicantAvatarColor }">
|
{{ (form.employeeName || '选').charAt(0) }}
|
</view>
|
<view class="rf-applicant-meta">
|
<text class="rf-applicant-name">{{ form.employeeName || '请选择员工' }}</text>
|
<text class="rf-applicant-sub">{{ applicantDisplaySub }}</text>
|
</view>
|
<text class="rf-applicant-action">{{ form.applicantId ? '更换' : '选择' }}</text>
|
</view>
|
</view>
|
</view>
|
|
<!-- 基本信息 -->
|
<view class="rf-section">
|
<view class="rf-section-hd">
|
<text class="rf-section-title">基本信息</text>
|
</view>
|
<view class="rf-group">
|
<view class="rf-cell rf-cell--col">
|
<text class="rf-label required">报销原因</text>
|
<view class="rf-textarea-wrap">
|
<up-textarea v-model="form.reimburseReason"
|
placeholder="请填写出差及报销原因"
|
maxlength="2000"
|
border="none"
|
height="80" />
|
</view>
|
</view>
|
|
<template v-if="isTravel">
|
<view class="rf-cell rf-cell--tap"
|
@click="openDatePicker('travelStartTime')">
|
<text class="rf-label required">出差开始</text>
|
<view class="rf-value-wrap">
|
<text class="rf-value"
|
:class="{ placeholder: !form.travelStartTime }">
|
{{ form.travelStartTime || '请选择' }}
|
</text>
|
<up-icon name="calendar"
|
size="18"
|
color="#c0c4cc" />
|
</view>
|
</view>
|
<view class="rf-cell rf-cell--tap"
|
@click="openDatePicker('travelEndTime')">
|
<text class="rf-label required">出差结束</text>
|
<view class="rf-value-wrap">
|
<text class="rf-value"
|
:class="{ placeholder: !form.travelEndTime }">
|
{{ form.travelEndTime || '请选择' }}
|
</text>
|
<up-icon name="calendar"
|
size="18"
|
color="#c0c4cc" />
|
</view>
|
</view>
|
<view class="rf-cell">
|
<text class="rf-label">出差天数</text>
|
<view class="rf-value-wrap">
|
<text class="rf-value">{{ travelDaysDisplay || '—' }}</text>
|
<text class="rf-value"
|
style="color:#909399;margin-left:4px">天</text>
|
</view>
|
</view>
|
<view class="rf-cell">
|
<text class="rf-label required">出差地</text>
|
<view class="rf-input-body">
|
<up-input v-model="form.departurePlace"
|
placeholder="出发城市"
|
border="none"
|
input-align="right"
|
@blur="recalcTravelStandards" />
|
</view>
|
</view>
|
<view class="rf-cell">
|
<text class="rf-label required">目的地</text>
|
<view class="rf-input-body">
|
<up-input v-model="form.destination"
|
placeholder="目的城市"
|
border="none"
|
input-align="right"
|
@blur="recalcTravelStandards" />
|
</view>
|
</view>
|
</template>
|
|
<template v-else>
|
<view class="rf-cell rf-cell--tap"
|
@click="showCategorySheet = true">
|
<text class="rf-label required">费用类型</text>
|
<view class="rf-value-wrap">
|
<text class="rf-value"
|
:class="{ placeholder: !form.expenseCategory }">{{ categoryLabel }}</text>
|
<up-icon name="arrow-right"
|
size="14"
|
color="#c0c4cc" />
|
</view>
|
</view>
|
<view class="rf-chips">
|
<text v-for="cat in quickCategories"
|
:key="cat.value"
|
class="rf-chip"
|
:class="{ active: form.expenseCategory === cat.value }"
|
@click="applyTemplate(cat.value)">{{ cat.label }}</text>
|
</view>
|
</template>
|
</view>
|
</view>
|
|
<!-- 差旅标准 -->
|
<view v-if="isTravel"
|
class="rf-section">
|
<view class="rf-section-hd">
|
<text class="rf-section-title">差旅标准</text>
|
<text class="rf-section-extra">{{ travelTierLabel }}</text>
|
</view>
|
<view v-if="overBudgetWarnings.length"
|
class="rf-warn-box">
|
<text v-for="(w, i) in overBudgetWarnings"
|
:key="i"
|
class="rf-warn-line">{{ w }}</text>
|
</view>
|
<view class="rf-group">
|
<view class="rf-cell">
|
<text class="rf-label">酒店标准</text>
|
<view class="rf-input-body">
|
<up-input v-model="form.hotelStandard"
|
type="digit"
|
placeholder="元/晚"
|
border="none"
|
input-align="right"
|
@blur="recalcTravelStandards" />
|
</view>
|
</view>
|
<view class="rf-cell">
|
<text class="rf-label">住宿天数</text>
|
<view class="rf-input-body">
|
<up-input v-model="form.hotelDays"
|
type="number"
|
border="none"
|
input-align="right"
|
@blur="recalcTravelStandards" />
|
</view>
|
</view>
|
<view class="rf-cell">
|
<text class="rf-label">生活补贴</text>
|
<view class="rf-input-body">
|
<up-input v-model="form.livingSubsidy"
|
type="digit"
|
border="none"
|
input-align="right" />
|
</view>
|
</view>
|
<view class="rf-cell">
|
<text class="rf-label">交通补贴</text>
|
<view class="rf-value-wrap">
|
<text class="rf-value">建议 {{ suggestedTransportSubsidy }} 元</text>
|
</view>
|
</view>
|
<view class="rf-cell">
|
<text class="rf-label">住宿限额</text>
|
<view class="rf-value-wrap">
|
<text class="rf-value">建议 {{ suggestedHotelLimit }} 元</text>
|
</view>
|
</view>
|
<view class="rf-cell">
|
<text class="rf-label">特批标记</text>
|
<text class="rf-tag"
|
:class="form.needSpecialApproval ? 'rf-tag--danger' : 'rf-tag--ok'">
|
{{ form.needSpecialApproval ? '超支需特批' : '在标准内' }}
|
</text>
|
</view>
|
</view>
|
</view>
|
|
<!-- 金额与收款 -->
|
<view class="rf-section">
|
<view class="rf-section-hd">
|
<text class="rf-section-title">金额与收款</text>
|
<text class="rf-section-extra"
|
@click="syncApplyAmountFromDetails">按明细 {{ detailTotalAmount }} 元</text>
|
</view>
|
<view class="rf-group">
|
<view class="rf-cell">
|
<text class="rf-label required">申请金额</text>
|
<view class="rf-input-body">
|
<up-input v-model="form.applyAmount"
|
type="digit"
|
placeholder="元"
|
border="none"
|
input-align="right" />
|
</view>
|
</view>
|
<view class="rf-cell">
|
<text class="rf-label required">收款人</text>
|
<view class="rf-input-body">
|
<up-input v-model="form.payee"
|
placeholder="收款人"
|
border="none"
|
input-align="right" />
|
</view>
|
</view>
|
<view class="rf-cell">
|
<text class="rf-label">收款账号</text>
|
<view class="rf-input-body">
|
<up-input v-model="form.payeeAccount"
|
placeholder="选填"
|
border="none"
|
input-align="right" />
|
</view>
|
</view>
|
<view class="rf-cell">
|
<text class="rf-label">开户支行</text>
|
<view class="rf-input-body">
|
<up-input v-if="isTravel"
|
v-model="form.payeeBank"
|
placeholder="选填"
|
border="none"
|
input-align="right" />
|
<up-input v-else
|
v-model="form.bankBranch"
|
placeholder="选填"
|
border="none"
|
input-align="right" />
|
</view>
|
</view>
|
</view>
|
</view>
|
|
<!-- 报销明细:列表摘要 + 详情按钮 -->
|
<view class="rf-section">
|
<view class="rf-section-hd">
|
<text class="rf-section-title">报销明细</text>
|
<text class="rf-section-extra"
|
@click="addAndOpenDetail">+ 新增</text>
|
</view>
|
<view class="rf-group"
|
v-if="form.expenseDetails.length">
|
<view v-for="(row, idx) in form.expenseDetails"
|
:key="row.id || idx"
|
class="rf-detail-row"
|
:class="{ 'rf-detail-row--warn': detailSummary(row).incomplete }"
|
@click="openDetailEditor(idx)">
|
<view class="rf-detail-index">{{ idx + 1 }}</view>
|
<view class="rf-detail-body">
|
<view class="rf-detail-line1">
|
<text class="rf-detail-subject">{{ detailSummary(row).subject }}</text>
|
<text class="rf-detail-amount">{{ detailSummary(row).amount }}</text>
|
</view>
|
<text class="rf-detail-line2">{{ detailSummary(row).sub }}</text>
|
</view>
|
<text class="rf-detail-action"
|
@click.stop="openDetailEditor(idx)">详情</text>
|
</view>
|
</view>
|
<view v-else
|
class="rf-group">
|
<view class="rf-empty"
|
@click="addAndOpenDetail">点击添加报销明细</view>
|
</view>
|
</view>
|
|
<!-- 附件 -->
|
<view class="rf-section">
|
<view class="rf-section-hd">
|
<text class="rf-section-title">附件(发票)</text>
|
</view>
|
<view class="rf-group">
|
<view v-for="(f, i) in form.attachmentList"
|
:key="i"
|
class="rf-attach-item">
|
<text>{{ f.name || '附件' }}</text>
|
<text class="rf-detail-del"
|
@click="removeAttachment(i)">删除</text>
|
</view>
|
<view class="rf-upload-zone"
|
@click="chooseAttachment">
|
<up-icon name="plus-circle"
|
size="22"
|
color="#2979ff" />
|
<text>上传发票/附件</text>
|
</view>
|
</view>
|
</view>
|
|
<!-- 审批流程 -->
|
<view class="rf-section">
|
<view class="rf-section-hd">
|
<text class="rf-section-title">审批流程</text>
|
</view>
|
<view class="rf-group"
|
style="padding:12px">
|
<ReimburseApprovalFlowEditor v-model="form.approvalFlowNodes"
|
:user-options="flowUserOptions" />
|
<text class="rf-hint-row">每级须指定审批人,支持搜索姓名或工号</text>
|
</view>
|
</view>
|
</view>
|
</scroll-view>
|
|
<view class="oa-page-footer">
|
<text class="oa-footer-btn btn-default"
|
@click="goBack">取消</text>
|
<text class="oa-footer-btn btn-primary"
|
:class="{ 'is-disabled': submitting }"
|
@click="onSubmit">提交</text>
|
</view>
|
|
<OaUserSearchPicker v-model:show="showApplicantPicker"
|
v-model="form.applicantId"
|
title="选择申请人"
|
:users="flowUserOptions"
|
@select="onApplicantPicked" />
|
|
<up-action-sheet :show="showCategorySheet"
|
title="费用类型"
|
:actions="categoryActions"
|
@select="onCategorySelect"
|
@close="showCategorySheet = false" />
|
|
<ReimburseExpenseDetailSheet v-model:show="showDetailSheet"
|
v-model="detailDraft"
|
:index="editingDetailIndex"
|
:is-travel="isTravel"
|
:subject-options="expenseSubjectOptions"
|
@confirm="onDetailSheetConfirm"
|
@delete="onDetailSheetDelete" />
|
|
<up-popup :show="showDatePicker"
|
mode="bottom"
|
round="16"
|
@close="showDatePicker = false">
|
<up-datetime-picker :show="true"
|
v-model="datePickerTs"
|
mode="datetime"
|
@confirm="onDateConfirm"
|
@cancel="showDatePicker = false" />
|
</up-popup>
|
</view>
|
</template>
|
|
<script setup>
|
import { computed, reactive, ref } from "vue";
|
import { onLoad } from "@dcloudio/uni-app";
|
import PageHeader from "@/components/PageHeader.vue";
|
import OaUserSearchPicker from "../../_components/OaUserSearchPicker.vue";
|
import ReimburseExpenseDetailSheet from "../_components/ReimburseExpenseDetailSheet.vue";
|
import config from "@/config.js";
|
import { getToken } from "@/utils/auth";
|
import { parseTime } from "@/utils/ruoyi";
|
import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js";
|
import { consumeReimburseEditFromApprove } from "../../_utils/reimburseApproveBridge.js";
|
import { EXPENSE_CATEGORY_OPTIONS } from "../_utils/costReimburseUtils.js";
|
import { buildExpenseDetailSummary } from "../_utils/expenseDetailDisplay.js";
|
import ReimburseApprovalFlowEditor from "../_components/ReimburseApprovalFlowEditor.vue";
|
import { useFinReimburseForm } from "./useFinReimburseForm.js";
|
|
const moduleKey = ref("");
|
const mode = ref("add");
|
const reimbursementId = ref("");
|
|
const {
|
form,
|
isTravel,
|
submitting,
|
loading,
|
flowUserOptions,
|
travelDaysDisplay,
|
travelTierLabel,
|
suggestedTransportSubsidy,
|
suggestedHotelLimit,
|
detailTotalAmount,
|
overBudgetWarnings,
|
expenseSubjectOptions,
|
categoryActions,
|
categoryLabel,
|
showApplicantPicker,
|
applicantDisplaySub,
|
applicantAvatarColor,
|
showCategorySheet,
|
loadUserPool,
|
onApplicantPicked,
|
recalcTravelStandards,
|
syncApplyAmountFromDetails,
|
addExpenseDetail,
|
removeExpenseDetail,
|
applyTemplate,
|
initForm,
|
loadEdit,
|
submitForm,
|
} = useFinReimburseForm(moduleKey, mode);
|
|
const showDatePicker = ref(false);
|
const datePickerField = ref("");
|
const datePickerTs = ref(Date.now());
|
|
const showDetailSheet = ref(false);
|
const editingDetailIndex = ref(0);
|
const detailDraft = reactive({
|
invoiceDate: "",
|
expenseSubject: "",
|
amount: "",
|
description: "",
|
});
|
|
const quickCategories = EXPENSE_CATEGORY_OPTIONS.slice(0, 4);
|
|
const pageTitle = computed(() => {
|
const label = getApprovalModuleConfig(moduleKey.value)?.label || "报销";
|
return mode.value === "edit" ? `编辑${label}` : `新增${label}`;
|
});
|
|
const goBack = () => uni.navigateBack();
|
|
function detailSummary(row) {
|
return buildExpenseDetailSummary(row, {
|
isTravel: isTravel.value,
|
subjectOptions: expenseSubjectOptions.value,
|
});
|
}
|
|
function openDetailEditor(idx) {
|
editingDetailIndex.value = idx;
|
const row = form.expenseDetails[idx];
|
if (!row) return;
|
Object.assign(detailDraft, JSON.parse(JSON.stringify(row)));
|
showDetailSheet.value = true;
|
}
|
|
function addAndOpenDetail() {
|
addExpenseDetail();
|
openDetailEditor(form.expenseDetails.length - 1);
|
}
|
|
function onDetailSheetConfirm(data) {
|
const idx = editingDetailIndex.value;
|
if (form.expenseDetails[idx]) {
|
Object.assign(form.expenseDetails[idx], data);
|
}
|
recalcTravelStandards();
|
}
|
|
function onDetailSheetDelete() {
|
const idx = editingDetailIndex.value;
|
removeExpenseDetail(idx);
|
showDetailSheet.value = false;
|
}
|
|
function onCategorySelect(action) {
|
form.expenseCategory = action.value;
|
applyTemplate(action.value);
|
showCategorySheet.value = false;
|
}
|
|
function openDatePicker(field) {
|
datePickerField.value = field;
|
detailDateIndex.value = -1;
|
datePickerTs.value = Date.now();
|
showDatePicker.value = true;
|
}
|
|
function onDateConfirm(e) {
|
const ts = e?.value ?? datePickerTs.value;
|
if (datePickerField.value) {
|
form[datePickerField.value] = parseTime(ts, "{y}-{m}-{d} {h}:{i}:{s}");
|
recalcTravelStandards();
|
}
|
showDatePicker.value = false;
|
}
|
|
function chooseAttachment() {
|
uni.chooseImage({
|
count: 9,
|
success: res => {
|
(res.tempFilePaths || []).forEach(path => uploadOne(path));
|
},
|
});
|
}
|
|
function uploadOne(filePath) {
|
uni.uploadFile({
|
url: `${config.baseUrl}/file/upload`,
|
filePath,
|
name: "file",
|
header: { Authorization: "Bearer " + getToken() },
|
success: res => {
|
try {
|
const data = JSON.parse(res.data || "{}");
|
const url = data.url || data.data?.url || "";
|
const name = data.originalFilename || data.fileName || "附件";
|
if (!form.attachmentList) form.attachmentList = [];
|
form.attachmentList.push({ name, url });
|
} catch {
|
uni.showToast({ title: "上传解析失败", icon: "none" });
|
}
|
},
|
fail: () => uni.showToast({ title: "上传失败", icon: "none" }),
|
});
|
}
|
|
function removeAttachment(i) {
|
form.attachmentList.splice(i, 1);
|
}
|
|
async function onSubmit() {
|
const ok = await submitForm();
|
if (ok) setTimeout(goBack, 400);
|
}
|
|
onLoad(async options => {
|
moduleKey.value = options?.moduleKey || "";
|
mode.value = options?.mode === "edit" ? "edit" : "add";
|
reimbursementId.value = options?.reimbursementId || "";
|
const fromApprove = consumeReimburseEditFromApprove();
|
if (fromApprove?.moduleKey) {
|
moduleKey.value = fromApprove.moduleKey;
|
mode.value = "edit";
|
reimbursementId.value = String(fromApprove.reimbursementId ?? "");
|
}
|
if (!moduleKey.value) {
|
uni.showToast({ title: "缺少模块类型", icon: "none" });
|
setTimeout(goBack, 500);
|
return;
|
}
|
await loadUserPool();
|
await initForm();
|
if (mode.value === "edit" && reimbursementId.value) {
|
try {
|
await loadEdit(reimbursementId.value);
|
} catch {
|
uni.showToast({ title: "加载失败", icon: "none" });
|
}
|
}
|
});
|
</script>
|
|
<style scoped lang="scss">
|
@import "../../_styles/oa-approval-list.scss";
|
@import "./reimburse-form.scss";
|
</style>
|