ykt-wxapp/pages/message/benefit-entry.vue
2026-09-10 17:12:26 +08:00

2808 lines
91 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<page-meta
:page-style="projectPickerVisible || todoExecutorPickerVisible || billDeptPickerVisible ? 'overflow: hidden;' : ''"></page-meta>
<full-page ref="pageRef" pageClass="benefit-page-shell">
<view class="benefit-page">
<view class="patient-card">
<view class="patient-card-accent"></view>
<view class="patient-card-main">
<view class="patient-card-top">
<view class="patient-name-wrap">
<text class="patient-name">{{ patientName || "当前患者" }}</text>
<text class="patient-badge">患者档案</text>
</view>
<text class="patient-status">会话已关联</text>
</view>
<view class="patient-meta">
<view class="patient-meta-item">
<text class="patient-meta-label">团队</text>
<text class="patient-meta-value">{{ teamName || "未关联团队" }}</text>
</view>
</view>
</view>
</view>
<view class="field-group">
<view class="field-label">开单科室</view>
<view class="project-input" @tap="openBillDeptPicker">
<text :class="billDeptId ? 'value-text' : 'placeholder-text'">{{ billDeptName || "请选择开单科室" }}</text>
<uni-icons type="right" size="18" color="#999" />
</view>
</view>
<view class="field-group">
<view class="field-label">开单医生</view>
<picker :range="getBillDoctors()" range-key="anotherName" :disabled="!billDeptId || !getBillDoctors().length"
@change="changeBillDoctor">
<view class="project-input">
<text :class="billDoctorUserId ? 'value-text' : 'placeholder-text'">
{{ billDoctorName || "请选择医生" }}
</text>
<uni-icons type="right" size="18" color="#999" />
</view>
</picker>
</view>
<view class="section-title-row">
<view class="section-title">权益项目</view>
<view class="batch-operation-button" @tap="openBatchTodoPicker">
<text>批量操作</text>
</view>
</view>
<view v-for="(entry, index) in entries" :id="`entry-${entry.key}`" :key="entry.key" class="entry-card">
<template v-if="entry.isPackage">
<view class="package-card-head">
<view>
<view class="field-label">套餐名称</view>
<view class="package-name">{{ entry.packageName }}</view>
</view>
<view class="package-price">¥{{ Number(entry.price || 0).toFixed(2) }}</view>
</view>
<view class="package-summary">
<text>组合形式{{ entry.packageType === "free" ? `${entry.projects.length}${entry.projectNumber}` : "固定组合"
}}</text>
<text>必选项{{ entry.requiredCount }}</text>
<text v-if="entry.packageType === 'free'">可选项{{ entry.optionalSelectedCount }} / {{ entry.optionalCount
}}</text>
</view>
<view v-if="showBenefitAmount" class="field-row">
<view class="field-block">
<view class="field-label">数量</view>
<input class="number-input" type="number" v-model="entry.usageCount"
@input="calculatePackageTotal(entry)" />
</view>
<view class="field-block">
<view class="field-label">折扣</view>
<picker class="discount-picker" :class="{ 'discount-disabled': !entry.enableDiscount }"
mode="multiSelector" :range="getDiscountPickerRange(entry)" :value="getDiscountPickerValue(entry)"
:disabled="!entry.enableDiscount" @change="changeDiscount($event, entry)">
<view class="number-input picker-value value-text">{{ Number(entry.discount).toFixed(1) }}</view>
</picker>
</view>
<view class="field-block">
<view class="field-label">总价</view>
<view class="readonly-input">¥{{ Number(entry.totalPrice || 0).toFixed(2) }}</view>
</view>
</view>
<view v-else class="field-row">
<view class="field-block">
<view class="field-label">数量</view>
<input class="number-input" type="number" v-model="entry.usageCount"
@input="calculatePackageTotal(entry)" />
</view>
</view>
<view v-for="projectEntry in entry.projects" :key="projectEntry.key" class="package-project-card"
:class="{ 'required-project-card': projectEntry.required }">
<view class="package-project-title">
<view v-if="entry.packageType === 'free' && projectEntry.required" class="required-checkbox">
<checkbox :checked="projectEntry.selected" disabled color="#0877f1" />
</view>
<checkbox v-else-if="entry.packageType === 'free'" :checked="projectEntry.selected"
:disabled="!projectEntry.selected && isPackageSelectionFull(entry)" color="#0877f1"
@click="togglePackageProject(entry, projectEntry)" />
<text v-if="projectEntry.required" class="required-badge">必选</text>
<text>{{ projectEntry.project.projectName }}</text>
</view>
<view class="package-project-detail">¥{{ Number(projectEntry.price || 0).toFixed(2) }} × {{
projectEntry.usageCount }}折扣 {{ Number(projectEntry.discount || 0).toFixed(1) }}</view>
<view v-if="projectEntry.selected" class="field-group">
<view class="field-label">治疗科室</view>
<picker :range="getProjectDepts(projectEntry)" range-key="deptName"
:disabled="!getProjectDepts(projectEntry).length" @change="changeDept($event, projectEntry)">
<view class="project-input" :class="{ 'required-select': !projectEntry.dept }"><text
:class="projectEntry.dept ? 'value-text' : 'placeholder-text'">{{ projectEntry.dept?.deptName ||
"请选择治疗科室" }}</text><uni-icons type="right" size="18" color="#999" /></view>
</picker>
</view>
<view v-if="projectEntry.selected" class="field-group">
<view class="field-label">计划治疗医生</view>
<picker :range="getTreatmentDoctors(projectEntry)" range-key="anotherName"
:disabled="!projectEntry.dept?._id || !getTreatmentDoctors(projectEntry).length"
@change="changeTreatmentDoctor($event, projectEntry)">
<view class="project-input"><text
:class="projectEntry.treatmentDoctorUserId ? 'value-text' : 'placeholder-text'">{{
projectEntry.treatmentDoctorName || "选择治疗医生" }}</text><uni-icons type="right" size="18"
color="#999" /></view>
</picker>
</view>
<view v-if="projectEntry.selected && Number(projectEntry.validDays || 0) > 0" class="field-group">
<view class="field-label">有效期</view>
<view class="valid-time-wrap">
<view class="valid-time-picker disabled-picker">
<picker mode="multiSelector" :range="validTimeRange" :value="validTimeIndex" disabled
@columnchange="onValidTimeColumnChange" @change="changeValidTime($event, projectEntry)">
<view class="picker-value" :class="projectEntry.validTime ? 'value-text' : 'placeholder-text'">
{{ projectEntry.validTime || "选择日期/长期" }}
</view>
</picker>
</view>
<view class="valid-time-locked-tip">套餐有效期已锁定</view>
</view>
</view>
<view v-if="projectEntry.selected" class="todo-switch-row">
<text>生成待办</text>
<switch :checked="projectEntry.todoEnabled" color="#0877f1" @change="toggleTodo($event, projectEntry)" />
</view>
<view v-if="projectEntry.selected && projectEntry.todoEnabled" class="todo-config-card">
<input v-model="projectEntry.todoConfig.taskContent" class="todo-input" placeholder="任务内容" />
<picker :range="todoTypeOptions" range-key="label" @change="changeTodoType($event, projectEntry)">
<view class="project-input"><text>类型{{ getTodoTypeLabel(projectEntry.todoConfig.eventType)
}}</text><uni-icons type="right" size="16" color="#999" /></view>
</picker>
<view class="field-row">
<view class="field-block">
<view class="field-label">回访日期</view>
<view class="valid-time-picker" @tap="prepareTodoDatePicker(projectEntry, 'planDate')">
<picker mode="multiSelector" :range="validTimeRange" :value="validTimeIndex"
@columnchange="onValidTimeColumnChange" @change="changeTodoDate($event)">
<view class="picker-value value-text">{{ projectEntry.todoConfig.planDate }}</view>
</picker>
</view>
</view>
<view class="field-block">
<view class="field-label">截止日期</view>
<view class="valid-time-picker" @tap="prepareTodoDatePicker(projectEntry, 'expireDate')">
<picker mode="multiSelector" :range="validTimeRange" :value="validTimeIndex"
@columnchange="onValidTimeColumnChange" @change="changeTodoDate($event)">
<view class="picker-value value-text">{{ projectEntry.todoConfig.expireDate }}</view>
</picker>
</view>
</view>
</view>
<view class="project-input" :class="{ 'required-select': !projectEntry.todoConfig.executorUserId }"
@tap="openTodoExecutorPicker(projectEntry)"><text
:class="projectEntry.todoConfig.executorUserId ? 'value-text' : 'placeholder-text'">{{
projectEntry.todoConfig.executorName || '请选择团队和处理人' }}</text><uni-icons type="right" size="16"
color="#999" /></view>
<view class="todo-deduct-row"><text>完成关联核销权益</text>
<switch :checked="projectEntry.todoConfig.deductBenefit" color="#0877f1"
@change="projectEntry.todoConfig.deductBenefit = $event.detail.value" />
</view>
</view>
</view>
<view class="remove-entry" @click="removeEntry(index)">删除套餐</view>
</template>
<template v-else>
<view class="field-label">项目名称</view>
<view class="project-input required-select" @click="openProjectPicker(index)">
<text :class="entry.project ? 'value-text' : 'placeholder-text'">
{{ entry.project?.projectName || "请选择权益项目" }}
</text>
<uni-icons type="right" size="18" color="#999" />
</view>
<view v-if="showBenefitAmount" class="field-row">
<view class="field-block">
<view class="field-label">单价</view>
<input class="number-input" type="digit" v-model="entry.price" @input="calculateDiscount(entry)"
:disabled=true />
</view>
<view class="field-block">
<view class="field-label">数量</view>
<input class="number-input" type="number" v-model="entry.usageCount"
@input="calculateTotalPrice(entry)" />
</view>
</view>
<view v-if="showBenefitAmount" class="field-row">
<view class="field-block">
<view class="field-label">折扣</view>
<view class="discount-input-wrap">
<picker class="discount-picker" :class="{ 'discount-disabled': entry.isFree || !entry.isDiscount }"
mode="multiSelector" :range="getDiscountPickerRange(entry)" :value="getDiscountPickerValue(entry)"
:disabled="entry.isFree || !entry.isDiscount" @change="changeDiscount($event, entry)">
<view class="number-input picker-value value-text">{{ Number(entry.discount).toFixed(1) }}</view>
</picker>
<checkbox-group v-if="entry.isGift" class="free-check" @change="handleFreeChange($event, entry)">
<label class="free-check-label">
<checkbox value="free" :checked="entry.isFree" color="#0877f1" />
<text>赠送</text>
</label>
</checkbox-group>
</view>
</view>
<view class="field-block">
<view class="field-label">总价</view>
<input class="number-input total-input" type="digit" v-model="entry.totalPrice"
:disabled="entry.isFree || !entry.isDiscount" @input="calculateDiscount(entry)" />
</view>
</view>
<view v-else class="field-row">
<view class="field-block">
<view class="field-label">数量</view>
<input class="number-input" type="number" v-model="entry.usageCount" />
</view>
<view class="field-block">
<view class="field-label">有效期</view>
<view class="valid-time-wrap">
<view class="valid-time-picker" @tap="prepareValidTimePicker(entry)">
<picker mode="multiSelector" :range="validTimeRange" :value="validTimeIndex"
@columnchange="onValidTimeColumnChange" @change="changeValidTime($event, entry)">
<view class="picker-value" :class="entry.validTime ? 'value-text' : 'placeholder-text'">
{{ entry.validTime || "选择日期/长期" }}
</view>
</picker>
</view>
<view class="unlimited-button" @click="clearValidTime(entry)">长期</view>
</view>
</view>
</view>
<view v-if="showBenefitAmount" class="field-row">
<view class="field-block field-block-full">
<view class="field-label">有效期</view>
<view class="valid-time-wrap">
<view class="valid-time-picker" @tap="prepareValidTimePicker(entry)">
<picker mode="multiSelector" :range="validTimeRange" :value="validTimeIndex"
@columnchange="onValidTimeColumnChange" @change="changeValidTime($event, entry)">
<view class="picker-value" :class="entry.validTime ? 'value-text' : 'placeholder-text'">
{{ entry.validTime || "选择日期/长期" }}
</view>
</picker>
</view>
<view class="unlimited-button" @click="clearValidTime(entry)">长期</view>
</view>
</view>
</view>
<view class="field-group">
<view class="field-label">治疗科室</view>
<picker :range="getProjectDepts(entry)" range-key="deptName"
:disabled="!entry.project?._id || !getProjectDepts(entry).length" @change="changeDept($event, entry)">
<view class="project-input" :class="{ 'required-select': !entry.dept }">
<text :class="entry.dept ? 'value-text' : 'placeholder-text'">
{{ entry.dept?.deptName || (entry.project ? "该项目未配置治疗科室" : "请先选择权益项目") }}
</text>
<uni-icons type="right" size="18" color="#999" />
</view>
</picker>
</view>
<view class="field-group">
<view class="field-label">计划治疗医生</view>
<picker :range="getTreatmentDoctors(entry)" range-key="anotherName"
:disabled="!entry.dept?._id || !getTreatmentDoctors(entry).length"
@change="changeTreatmentDoctor($event, entry)">
<view class="project-input">
<text :class="entry.treatmentDoctorUserId ? 'value-text' : 'placeholder-text'">
{{ entry.treatmentDoctorName || (entry.dept ? "选择治疗医生" : "请先选择治疗科室") }}
</text>
<uni-icons type="right" size="18" color="#999" />
</view>
</picker>
</view>
<view v-if="entry.project?._id" class="todo-switch-row">
<text>生成待办</text>
<switch :checked="entry.todoEnabled" color="#0877f1" @change="toggleTodo($event, entry)" />
</view>
<view v-if="entry.project?._id && entry.todoEnabled" class="todo-config-card">
<input v-model="entry.todoConfig.taskContent" class="todo-input" placeholder="任务内容" />
<picker :range="todoTypeOptions" range-key="label" @change="changeTodoType($event, entry)">
<view class="project-input"><text>类型{{ getTodoTypeLabel(entry.todoConfig.eventType) }}</text><uni-icons
type="right" size="16" color="#999" /></view>
</picker>
<view class="field-row">
<view class="field-block">
<view class="field-label">回访日期</view>
<view class="valid-time-picker" @tap="prepareTodoDatePicker(entry, 'planDate')">
<picker mode="multiSelector" :range="validTimeRange" :value="validTimeIndex"
@columnchange="onValidTimeColumnChange" @change="changeTodoDate($event)">
<view class="picker-value value-text">{{ entry.todoConfig.planDate }}</view>
</picker>
</view>
</view>
<view class="field-block">
<view class="field-label">截止日期</view>
<view class="valid-time-picker" @tap="prepareTodoDatePicker(entry, 'expireDate')">
<picker mode="multiSelector" :range="validTimeRange" :value="validTimeIndex"
@columnchange="onValidTimeColumnChange" @change="changeTodoDate($event)">
<view class="picker-value value-text">{{ entry.todoConfig.expireDate }}</view>
</picker>
</view>
</view>
</view>
<view class="project-input" :class="{ 'required-select': !entry.todoConfig.executorUserId }"
@tap="openTodoExecutorPicker(entry)"><text
:class="entry.todoConfig.executorUserId ? 'value-text' : 'placeholder-text'">{{
entry.todoConfig.executorName || '请选择团队和处理人' }}</text><uni-icons type="right" size="16" color="#999" />
</view>
<view class="todo-deduct-row"><text>完成关联核销权益</text>
<switch :checked="entry.todoConfig.deductBenefit" color="#0877f1"
@change="entry.todoConfig.deductBenefit = $event.detail.value" />
</view>
</view>
<view v-if="entries.length > 1" class="remove-entry" @click="removeEntry(index)">删除此项目</view>
</template>
</view>
<view class="add-entry" @click="addEntry">
<uni-icons type="plusempty" size="18" color="#0877f1" />
<text>添加权益项目</text>
</view>
<view v-if="batchTodoVisible" class="picker-mask" @tap="closeBatchTodoPicker">
<view class="batch-todo-picker" @tap.stop>
<view class="picker-header">
<text class="picker-title">生成待办批量操作</text>
<uni-icons type="closeempty" size="22" color="#666" @tap="closeBatchTodoPicker" />
</view>
<view class="batch-todo-row" @tap.stop>
<text>批量生成待办</text>
<switch :checked="batchTodoConfig.todoEnabled" color="#0877f1"
@change="batchTodoConfig.todoEnabled = $event.detail.value" />
</view>
<template v-if="batchTodoConfig.todoEnabled">
<view class="batch-todo-row">
<text>待办完成后关联核销权益</text>
<switch :checked="batchTodoConfig.deductBenefit" color="#0877f1"
@change="batchTodoConfig.deductBenefit = $event.detail.value" />
</view>
<view class="batch-todo-field" @tap="openBatchExecutorPicker">
<text>处理人</text>
<text :class="batchTodoConfig.executorUserId ? 'value-text' : 'placeholder-text'">
{{ batchTodoConfig.executorName || "请选择待办处理人" }}
</text>
<uni-icons type="right" size="16" color="#999" />
</view>
</template>
<view class="batch-todo-actions">
<button class="batch-todo-cancel" @tap="closeBatchTodoPicker">取消</button>
<button class="batch-todo-confirm" @tap="applyBatchTodoConfig">确定</button>
</view>
</view>
</view>
<view v-if="todoExecutorPickerVisible" class="picker-mask" @click="closeTodoExecutorPicker">
<view class="todo-executor-picker" :style="{ '--keyboard-height': `${keyboardHeight}px` }" @click.stop>
<view class="picker-header"><text class="picker-title">请选择团队和处理人</text><uni-icons type="closeempty" size="22"
color="#666" @click="closeTodoExecutorPicker" /></view>
<view class="todo-executor-search">
<uni-icons type="search" size="18" color="#999" />
<input v-model="todoExecutorKeyword" class="todo-executor-search-input" :adjust-position="false"
placeholder="搜索团队或处理人" />
</view>
<scroll-view scroll-y class="todo-executor-list" :refresher-enabled="true"
:refresher-triggered="todoExecutorRefreshing" @refresherrefresh="refreshTodoExecutors">
<view v-for="team in filteredTodoTeams" :key="team.teamId" class="todo-team-group">
<view class="todo-team-title">{{ team.name }}</view>
<view v-for="member in getTodoExecutorsForTeam(team)" :key="member.userid" class="todo-executor-item"
@tap="selectTodoExecutor(member, team)">
<text>{{ member.name }}</text><text v-if="isSelectedTodoExecutor(member, team)"
class="todo-selected">已选择</text>
</view>
<view v-if="!getTodoExecutorsForTeam(team).length" class="todo-empty">暂无团队成员</view>
</view>
<view v-if="!filteredTodoTeams.length" class="todo-empty">暂无匹配的团队或处理人</view>
</scroll-view>
</view>
</view>
<view v-if="projectPickerVisible" class="picker-mask" @click="closeProjectPicker">
<view class="project-picker" :style="{ '--keyboard-height': `${keyboardHeight}px` }" @click.stop>
<view class="picker-header">
<text class="picker-title">选择权益项目或套餐</text>
<uni-icons type="closeempty" size="22" color="#666" @click="closeProjectPicker" />
</view>
<view class="search-box">
<uni-icons type="search" size="18" color="#999" />
<input v-model="projectKeyword" class="search-input" :adjust-position="false" placeholder="输入项目或套餐名称搜索"
@input="searchProjects" />
</view>
<scroll-view scroll-y class="project-list" @scrolltolower="loadMoreProjects">
<view v-if="projectList.length" class="picker-group-title">单项目</view>
<view v-for="project in projectList" :key="`project_${project._id}`" class="project-item"
@click="selectProject(project)">
<view class="project-info">
<view class="project-name">{{ project.projectName }}</view>
<view class="project-depts">{{ project.deptNames || "未配置治疗科室" }}</view>
</view>
<view v-if="showBenefitAmount" class="project-price">¥{{ Number(project.price || 0).toFixed(2) }}</view>
</view>
<view v-if="packageList.length" class="picker-group-title">套餐</view>
<view v-for="packageItem in packageList" :key="`package_${packageItem._id}`" class="project-item"
@click="selectPackage(packageItem)">
<view class="project-info">
<view class="project-name">{{ packageItem.name }}</view>
<view class="project-depts">{{ packageItem.packageType === "free" ?
`自由组合:${packageItem.projects.length}${packageItem.projectNumber}` : "固定组合" }} · {{
(packageItem.projects ||
[]).map((item) => item.projectName).join("、")}}</view>
</view>
<view v-if="showBenefitAmount" class="project-price">¥{{ Number(packageItem.packagePrice || 0).toFixed(2)
}}
</view>
</view>
<view v-if="!projectLoading && !projectList.length && !packageList.length" class="empty-text">暂无可用权益项目或套餐
</view>
<view v-if="projectLoading" class="empty-text">加载中...</view>
<view v-else-if="projectList.length && !projectHasMore" class="empty-text">已加载全部权益项目</view>
</scroll-view>
</view>
</view>
<view v-if="billDeptPickerVisible" class="picker-mask" @click="closeBillDeptPicker">
<view class="project-picker bill-dept-modal" @click.stop>
<view class="picker-header">
<text class="picker-title">请选择开单科室</text>
<uni-icons type="closeempty" size="22" color="#666" @click="closeBillDeptPicker" />
</view>
<view class="search-box">
<uni-icons type="search" size="18" color="#999" />
<input v-model="billDeptKeyword" class="search-input" :adjust-position="false" placeholder="搜索科室名称" />
</view>
<scroll-view scroll-y class="bill-dept-list">
<view v-for="item in filteredBillDeptOptions" :key="item._id" class="bill-dept-option"
:style="{ paddingLeft: `${20 + item.level * 36}rpx` }" @tap="selectBillDept(item)">
<uni-icons v-if="item.children?.length" :type="item.expanded ? 'bottom' : 'right'" size="16" color="#999"
@tap.stop="toggleBillDept(item)" />
<view v-else class="bill-dept-option-spacer"></view>
<text :class="item._id === billDeptId ? 'bill-dept-option-name selected' : 'bill-dept-option-name'">{{
item.deptName }}</text>
<text v-if="item.children?.length" class="bill-dept-level-tag">多级</text>
<uni-icons v-if="item._id === billDeptId" type="checkmarkempty" size="18" color="#0877f1" />
</view>
<view v-if="!filteredBillDeptOptions.length" class="empty-text">暂无匹配科室</view>
</scroll-view>
</view>
</view>
</view>
<template #footer>
<view v-if="!batchTodoVisible && !todoExecutorPickerVisible && !projectPickerVisible && !billDeptPickerVisible"
class="footer-bar">
<view v-if="showBenefitAmount" class="footer-summary">
<view class="total-summary" @tap="toggleBenefitDetail">
<text class="summary-label">总金额</text>
<text class="summary-total-value">¥{{ benefitSummary.totalAmount.toFixed(2) }}</text>
<uni-icons :type="benefitDetailVisible ? 'top' : 'bottom'" size="16" color="#6b7280" />
</view>
<text class="detail-trigger" @tap="toggleBenefitDetail">明细</text>
</view>
<view v-if="showBenefitAmount" class="benefit-detail-panel"
:class="{ 'benefit-detail-panel-visible': benefitDetailVisible }">
<view class="detail-header">
<text class="detail-project-name">权益项目</text>
<text class="detail-quantity">数量</text>
<text class="detail-discount">折扣</text>
<text class="detail-amount">最终金额</text>
</view>
<view v-for="item in benefitDetailItems" :key="item.key" class="detail-row">
<text class="detail-project-name detail-project-name-value">{{ item.name }}</text>
<text class="detail-quantity">{{ item.quantity }}</text>
<text class="detail-discount">{{ item.isFree ? "赠送" : `${item.discount.toFixed(1)}` }}</text>
<text class="detail-amount">¥{{ item.totalAmount.toFixed(2) }}</text>
</view>
<view v-if="!benefitDetailItems.length" class="detail-empty">暂无已选择权益</view>
<view class="detail-row detail-row-total">
<text>合计</text>
<text class="detail-amount">¥{{ benefitSummary.totalAmount.toFixed(2) }}</text>
</view>
</view>
<view class="footer-actions">
<button class="submit-button" :loading="saving" @click="submitEntries">生成权益</button>
</view>
</view>
</template>
</full-page>
</template>
<script setup>
import { computed, nextTick, onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import api from "@/utils/api.js";
import useAccountStore from "@/store/account.js";
import fullPage from "@/components/full-page.vue";
import { TODO_EVENT_TYPE_LABELS } from "@/utils/todo-const.js";
const PENDING_FOLLOWUP_SEND_STORAGE_KEY = "ykt_followup_pending_send";
const accountStore = useAccountStore();
const pageRef = ref(null);
const groupId = ref("");
const patientId = ref("");
const patientName = ref("");
const teamId = ref("");
const teamName = ref("");
const corpId = ref("");
const showBenefitAmount = ref(true);
const staffList = ref([]);
const billDeptOptions = ref([]);
const billDeptId = ref("");
const billDeptName = ref("");
const billDoctorUserId = ref("");
const billDoctorName = ref("");
const billDeptKeyword = ref("");
const billDeptPickerVisible = ref(false);
const billDeptExpanded = ref(new Set());
const entries = ref([]);
const saving = ref(false);
const projectPickerVisible = ref(false);
const projectPickerIndex = ref(-1);
const keyboardHeight = ref(0);
const projectKeyword = ref("");
const projectList = ref([]);
const packageList = ref([]);
const projectLoading = ref(false);
const projectHasMore = ref(true);
const packageHasMore = ref(true);
const projectPage = ref(1);
const projectPageSize = 30;
const billDeptValue = ref("");
const VALID_YEAR_SPAN = 50;
const validTimeRange = ref([[], [], []]);
const validTimeIndex = ref([0, 0, 0]);
const todoDateTarget = ref(null);
const teamMembers = ref([]);
const todoTeams = ref([]);
const todoExecutorPickerVisible = ref(false);
const todoExecutorTarget = ref(null);
const todoExecutorKeyword = ref("");
const todoExecutorRefreshing = ref(false);
const batchTodoVisible = ref(false);
const batchTodoConfig = ref({
todoEnabled: false,
deductBenefit: false,
executorUserId: "",
executorName: "",
executeTeamId: "",
executeTeamName: "",
});
const batchExecutorTarget = ref(null);
const benefitDetailVisible = ref(false);
const todoTypeOptions = Object.entries(TODO_EVENT_TYPE_LABELS).map(([value, label]) => ({ value, label }));
let searchTimer = null;
let entryKey = 0;
function toggleBenefitDetail() {
benefitDetailVisible.value = !benefitDetailVisible.value;
}
const benefitDetailItems = computed(() => entries.value.flatMap((entry) => {
if (entry.isPackage) {
return (entry.projects || [])
.filter((project) => project.selected)
.map((project) => ({
key: `${entry.key}_${project.key}`,
name: project.project?.projectName || "未命名权益",
quantity: Math.max(0, toNumber(project.usageCount, 1)),
discount: toNumber(project.discount),
totalAmount: Math.max(0, toNumber(project.totalPrice)),
isFree: false,
}));
}
if (!entry.project?._id) return [];
return [{
key: entry.key,
name: entry.project.projectName || "未命名权益",
quantity: Math.max(0, toNumber(entry.usageCount, 1)),
discount: toNumber(entry.discount),
totalAmount: Math.max(0, toNumber(entry.isFree ? 0 : entry.totalPrice)),
isFree: Boolean(entry.isFree),
}];
}));
const benefitSummary = computed(() => entries.value.reduce((summary, entry) => {
if (entry.isPackage) {
summary.selectedCount += 1;
const originalAmount = toNumber(entry.packagePrice || entry.price) * Math.max(0, toNumber(entry.usageCount, 1));
const totalAmount = entry.isFree ? 0 : Math.max(0, toNumber(entry.totalPrice));
summary.originalAmount += originalAmount;
summary.totalAmount += totalAmount;
summary.savedAmount += Math.max(0, originalAmount - totalAmount);
return summary;
}
if (!entry.project?._id) return summary;
summary.selectedCount += 1;
const usageCount = Math.max(0, toNumber(entry.usageCount, 1));
const originalAmount = toNumber(entry.price) * usageCount;
const totalAmount = entry.isFree ? 0 : Math.max(0, toNumber(entry.totalPrice));
summary.originalAmount += originalAmount;
summary.totalAmount += totalAmount;
summary.savedAmount += Math.max(0, originalAmount - totalAmount);
return summary;
}, {
selectedCount: 0,
originalAmount: 0,
savedAmount: 0,
totalAmount: 0,
}));
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function getTodayParts() {
const now = new Date();
return {
year: now.getFullYear(),
month: now.getMonth() + 1,
day: now.getDate(),
};
}
function getDaysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
function buildYearOptions() {
const { year } = getTodayParts();
return Array.from({ length: VALID_YEAR_SPAN + 1 }, (_, index) => `${year + index}`);
}
function buildMonthOptions(year) {
const today = getTodayParts();
const startMonth = year === today.year ? today.month : 1;
return Array.from({ length: 12 - startMonth + 1 }, (_, index) => `${startMonth + index}`);
}
function buildDayOptions(year, month) {
const today = getTodayParts();
const startDay = year === today.year && month === today.month ? today.day : 1;
const maxDay = getDaysInMonth(year, month);
return Array.from({ length: maxDay - startDay + 1 }, (_, index) => `${startDay + index}`);
}
function parseValidTime(value) {
const today = getTodayParts();
if (!value) return today;
const [year, month, day] = String(value).split("-").map(Number);
if (!year || !month || !day) return today;
const selected = { year, month, day };
const selectedText = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
return selectedText < formatDate(new Date()) ? today : selected;
}
function syncValidTimePicker(selected) {
const years = buildYearOptions();
const year = selected.year;
const months = buildMonthOptions(year);
const monthValues = months.map((item) => Number(item.replace("月", "")));
const month = monthValues.includes(selected.month) ? selected.month : monthValues[0];
const days = buildDayOptions(year, month);
const dayValues = days.map((item) => Number(item.replace("日", "")));
const day = dayValues.includes(selected.day) ? selected.day : dayValues[0];
validTimeRange.value = [years, months, days];
validTimeIndex.value = [
years.findIndex((item) => Number(item.replace("年", "")) === year),
monthValues.indexOf(month),
dayValues.indexOf(day),
];
}
function prepareValidTimePicker(entry) {
syncValidTimePicker(parseValidTime(entry.validTime));
}
function onValidTimeColumnChange(event) {
const { column, value } = event.detail;
const nextIndex = [...validTimeIndex.value];
nextIndex[column] = value;
const year = Number(validTimeRange.value[0][nextIndex[0]].replace("年", ""));
if (column === 0) {
const months = buildMonthOptions(year);
const month = Number(months[0].replace("月", ""));
const days = buildDayOptions(year, month);
validTimeRange.value = [validTimeRange.value[0], months, days];
nextIndex[1] = 0;
nextIndex[2] = 0;
} else if (column === 1) {
const month = Number(validTimeRange.value[1][nextIndex[1]].replace("月", ""));
const days = buildDayOptions(year, month);
validTimeRange.value = [validTimeRange.value[0], validTimeRange.value[1], days];
nextIndex[2] = Math.min(nextIndex[2], days.length - 1);
}
validTimeIndex.value = nextIndex;
}
prepareValidTimePicker({ validTime: "" });
function createEntry() {
return {
key: ++entryKey,
project: null,
price: 0,
totalPrice: 0,
discount: 10,
isFree: false,
isGift: false,
isDiscount: false,
lowestDiscount: 10,
enableDiscount: false,
minDiscount: 10,
usageCount: 1,
validTime: "",
billDeptId: "",
billDeptName: "",
billDoctorUserId: "",
billDoctorName: "",
dept: null,
treatmentDoctorUserId: "",
treatmentDoctorName: "",
todoEnabled: false,
todoConfig: null,
};
}
function decodeRouteParam(value = "") {
try {
return decodeURIComponent(value || "");
} catch (error) {
return value || "";
}
}
onLoad((options = {}) => {
uni.onKeyboardHeightChange((res) => {
keyboardHeight.value = Number(res?.height || 0);
});
groupId.value = decodeRouteParam(options.groupId);
patientId.value = decodeRouteParam(options.patientId);
patientName.value = decodeRouteParam(options.patientName);
teamId.value = decodeRouteParam(options.teamId);
teamName.value = decodeRouteParam(options.teamName);
corpId.value = decodeRouteParam(options.corpId) || accountStore.account?.corpId || "";
entries.value = [createEntry()];
loadTeamContext().then(() => {
loadCorpSettings();
loadBillDeptOptions();
loadStaffList();
loadTodoTeams();
});
});
onUnmounted(() => {
uni.offKeyboardHeightChange();
});
async function loadTeamContext() {
if (!teamId.value || !corpId.value) return;
try {
const res = await api("getTeamData", { teamId: teamId.value, corpId: corpId.value }, false);
const team = res?.data || {};
if (team.corpId && team.corpId !== corpId.value) corpId.value = team.corpId;
teamMembers.value = Array.isArray(team.memberList) ? team.memberList.map((member) => (
typeof member === "string" ? { userid: member, name: member } : member
)) : [];
} catch (error) {
console.error("加载服务团队失败", error);
}
}
async function loadStaffList() {
if (!corpId.value) return;
try {
const res = await api("getCorpMember", { corpId: corpId.value, page: 1, pageSize: 1000 }, false);
const list = res?.data?.data || res?.data?.list || res?.data || [];
staffList.value = Array.isArray(list)
? list.filter((staff) => staff?.userid).map((staff) => ({ ...staff, anotherName: staff.anotherName || staff.name || staff.userid }))
: [];
const staffMap = new Map(staffList.value.map((staff) => [staff.userid, staff]));
teamMembers.value = teamMembers.value.map((member) => ({ ...member, ...(staffMap.get(member.userid) || {}) }));
todoTeams.value = todoTeams.value.map((team) => ({
...team,
memberList: (team.memberList || []).map((member) => {
const userid = typeof member === "string" ? member : member?.userid;
return { ...(typeof member === "object" ? member : {}), ...(staffMap.get(userid) || {}), userid };
}),
}));
} catch (error) {
staffList.value = [];
console.error("加载机构员工失败", error);
}
}
async function loadBillDeptOptions() {
if (!corpId.value) return;
try {
// PC 端开单科室使用 getDeptList移动端同名方法映射到 getRealDeptList需使用对应映射。
const res = await api("getTreatmentDeptList", { corpId: corpId.value }, false);
const list = res?.data?.list
|| res?.data?.data?.list
|| res?.list
|| (Array.isArray(res?.data) ? res.data : []);
billDeptOptions.value = buildDeptTree(Array.isArray(list) ? list.filter((dept) => dept?._id) : []);
} catch (error) {
billDeptOptions.value = [];
console.error("加载开单科室失败", error);
}
}
async function loadTodoTeams() {
if (!corpId.value) return;
try {
const res = await api("getAllTeamByCorp", { corpId: corpId.value }, false);
const list = Array.isArray(res?.data)
? res.data
: Array.isArray(res?.data?.data)
? res.data.data
: Array.isArray(res?.data?.list)
? res.data.list
: Array.isArray(res?.list)
? res.list
: [];
const baseTeams = list.length ? list : [{ teamId: teamId.value, name: teamName.value, memberList: teamMembers.value }];
const details = await Promise.all(baseTeams.map(async (team) => {
const currentTeamId = team.teamId || team._id || team.id;
if (!currentTeamId) return null;
if (Array.isArray(team.memberList)) {
return { teamId: currentTeamId, name: team.name || team.teamName || "未命名团队", memberList: team.memberList };
}
const detailRes = await api("getTeamData", { corpId: corpId.value, teamId: currentTeamId }, false);
const detail = detailRes?.data || {};
return { teamId: currentTeamId, name: detail.name || team.name || team.teamName || "未命名团队", memberList: detail.memberList || [] };
}));
todoTeams.value = details.filter(Boolean);
} catch (error) {
todoTeams.value = teamId.value ? [{ teamId: teamId.value, name: teamName.value, memberList: teamMembers.value }] : [];
console.error("加载机构团队失败", error);
}
}
async function loadCorpSettings() {
if (!corpId.value) return;
try {
const res = await api("getCorpInfo", { corpId: corpId.value }, false);
const corpInfo = res?.data || {};
showBenefitAmount.value = corpInfo.showBenefitAmount !== false;
} catch (error) {
showBenefitAmount.value = true;
console.error("加载机构权益配置失败", error);
}
}
function openProjectPicker(index) {
if (!billDeptId.value) {
uni.showToast({ title: "请先选择开单科室", icon: "none" });
return;
}
projectPickerIndex.value = index;
projectPickerVisible.value = true;
projectKeyword.value = "";
loadProjects({ reset: true });
}
function closeProjectPicker() {
projectPickerVisible.value = false;
projectKeyword.value = "";
}
function searchProjects() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadProjects({ reset: true }), 300);
}
function normalizeProject(project = {}) {
const depts = Array.isArray(project.depts) ? project.depts : [];
return {
...project,
depts,
deptNames: depts.map((dept) => dept?.deptName).filter(Boolean).join("、"),
};
}
function getBillDoctors() {
if (!billDeptId.value) return [];
const deptId = String(billDeptId.value);
const allowedJobs = ["doctor", "expertDoctor", "therapist"];
return staffList.value.filter((staff) => {
const hasAllowedJob = Array.isArray(staff.job) && staff.job.some((job) => allowedJobs.includes(job));
const inDept = Array.isArray(staff.deptIds) && staff.deptIds.map(String).includes(deptId);
return hasAllowedJob && inDept;
});
}
function changeBillDoctor(event) {
const doctor = getBillDoctors()[Number(event.detail.value)] || null;
billDoctorUserId.value = doctor?.userid || "";
billDoctorName.value = doctor?.anotherName || doctor?.name || "";
}
const filteredBillDeptOptions = computed(() => {
const keyword = billDeptKeyword.value.trim().toLowerCase();
const result = [];
const filterTree = (nodes) => (nodes || []).reduce((matchedNodes, node) => {
const name = String(node.deptName || node.name || "").toLowerCase();
const children = filterTree(node.children);
if (!keyword || name.includes(keyword) || children.length) {
matchedNodes.push({ ...node, ...(children.length ? { children } : {}) });
}
return matchedNodes;
}, []);
const walk = (nodes, level = 0) => (nodes || []).forEach((node) => {
const children = node.children || [];
const matched = String(node.deptName || node.name || "").toLowerCase().includes(keyword);
if (!keyword || matched || children.length) {
const expanded = Boolean(keyword) || billDeptExpanded.value.has(String(node._id));
result.push({ ...node, level, expanded });
if (expanded) walk(children, level + 1);
}
});
walk(keyword ? filterTree(billDeptOptions.value) : billDeptOptions.value);
return result;
});
function openBillDeptPicker() {
billDeptPickerVisible.value = true;
billDeptKeyword.value = "";
billDeptExpanded.value = new Set(billDeptOptions.value.filter((item) => item.children?.length).map((item) => String(item._id)));
}
function closeBillDeptPicker() {
billDeptPickerVisible.value = false;
}
function toggleBillDept(item) {
const next = new Set(billDeptExpanded.value);
const id = String(item._id);
if (next.has(id)) next.delete(id);
else next.add(id);
billDeptExpanded.value = next;
}
function selectBillDept(dept) {
billDeptId.value = String(dept._id || "");
billDeptName.value = dept.deptName || dept.name || "";
billDeptValue.value = billDeptId.value;
billDoctorUserId.value = "";
billDoctorName.value = "";
closeBillDeptPicker();
}
function buildDeptTree(deptList) {
const deptMap = new Map();
const rootDepts = [];
(deptList || []).forEach((dept) => {
if (dept?._id) deptMap.set(String(dept._id), { ...dept, text: dept.deptName || dept.name || "", value: dept._id });
});
deptMap.forEach((dept) => {
const parent = dept.parentId && deptMap.get(String(dept.parentId));
if (parent) {
if (!parent.children) parent.children = [];
parent.children.push(dept);
} else {
rootDepts.push(dept);
}
});
const sortNodes = (nodes) =>
nodes
.sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0) || String(a.deptName || "").localeCompare(String(b.deptName || ""), "zh-Hans-CN"))
.map((dept) => {
if (!dept.children?.length) return dept;
return { ...dept, children: sortNodes(dept.children) };
});
return sortNodes(rootDepts);
}
async function loadProjects({ reset = false } = {}) {
if (projectLoading.value) return;
if (!reset && !projectHasMore.value && !packageHasMore.value) return;
const page = reset ? 1 : projectPage.value;
projectLoading.value = true;
if (reset) {
projectList.value = [];
packageList.value = [];
projectHasMore.value = true;
packageHasMore.value = true;
}
try {
const [res, packageRes] = await Promise.all([api("getProjectList", {
corpId: corpId.value,
page,
pageSize: projectPageSize,
projectName: projectKeyword.value.trim(),
projectStatus: "enable",
showDepts: true,
billDeptId: billDeptId.value,
}, false), api("getProjectPackageList", {
corpId: corpId.value,
page,
pageSize: projectPageSize,
name: projectKeyword.value.trim(),
enable: true,
}, false)]);
const list = res?.data?.list || res?.list || res?.data || [];
const nextList = Array.isArray(list) ? list.map(normalizeProject) : [];
projectList.value = reset ? nextList : projectList.value.concat(nextList);
const packages = packageRes?.data?.list || packageRes?.list || packageRes?.data || [];
const nextPackages = Array.isArray(packages) ? packages.filter((item) => Array.isArray(item.projects) && item.projects.length) : [];
packageList.value = reset ? nextPackages : packageList.value.concat(nextPackages);
projectPage.value = page + 1;
projectHasMore.value = nextList.length >= projectPageSize;
packageHasMore.value = nextPackages.length >= projectPageSize;
} catch (error) {
if (reset) {
projectList.value = [];
packageList.value = [];
}
projectHasMore.value = false;
packageHasMore.value = false;
uni.showToast({ title: "项目加载失败", icon: "none" });
} finally {
projectLoading.value = false;
}
}
function loadMoreProjects() {
loadProjects();
}
function toNumber(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function toFixedNumber(value, precision = 2) {
return Number(toNumber(value).toFixed(precision));
}
function clampNumber(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function calculateTotalPrice(entry) {
if (entry.isFree) {
entry.discount = 0;
entry.totalPrice = 0;
return;
}
entry.discount = normalizeDiscount(entry);
const total = toNumber(entry.price) * toNumber(entry.usageCount, 1) * (toNumber(entry.discount) / 10);
entry.totalPrice = toFixedNumber(Math.max(0, total), 2);
}
function changeDiscount(event, entry) {
const pickerRange = getDiscountPickerRange(entry);
const integer = pickerRange[0][Number(event.detail.value[0])] || "10";
const decimal = pickerRange[1][Number(event.detail.value[1])] || "0";
entry.discount = normalizeDiscount({ ...entry, discount: `${integer}.${decimal}` });
if (entry.isPackage) {
calculatePackageTotal(entry);
} else {
calculateTotalPrice(entry);
}
}
function calculateDiscount(entry) {
if (entry.isFree) {
entry.discount = 0;
entry.totalPrice = 0;
return;
}
const originalTotal = toNumber(entry.price) * toNumber(entry.usageCount);
entry.discount = originalTotal > 0
? normalizeDiscount({ ...entry, discount: toFixedNumber((toNumber(entry.totalPrice) / originalTotal) * 10, 1) })
: normalizeDiscount(entry);
}
function handleFreeChange(event, entry) {
if (!entry.isGift) {
entry.isFree = false;
return;
}
entry.isFree = Array.isArray(event.detail.value) && event.detail.value.includes("free");
if (entry.isFree) {
entry.discount = 0;
entry.totalPrice = 0;
return;
}
entry.discount = normalizeDiscount({ ...entry, discount: 10 });
calculateTotalPrice(entry);
}
function getDiscountMin(entry) {
const minDiscount = entry.isPackage ? entry.minDiscount : entry.lowestDiscount;
return Number(minDiscount) > 0 ? Number(minDiscount) : 0;
}
function getDiscountPickerRange(entry) {
const supportsDiscount = entry.isPackage ? entry.enableDiscount : entry.isDiscount;
if (!supportsDiscount) return [["10"], ["0"]];
const min = Math.min(10, Math.max(0, toNumber(getDiscountMin(entry))));
const integers = [];
for (let value = Math.floor(min); value <= 10; value += 1) {
integers.push(String(value));
}
return [integers, ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]];
}
function getDiscountPickerValue(entry) {
const discount = normalizeDiscount(entry).toFixed(1).split(".");
const range = getDiscountPickerRange(entry);
const integerIndex = Math.max(0, range[0].indexOf(discount[0]));
const decimalIndex = Math.max(0, range[1].indexOf(discount[1] || "0"));
return [integerIndex, decimalIndex];
}
function normalizeDiscount(entry) {
const discount = toNumber(entry.discount, 0);
const supportsDiscount = entry.isPackage ? entry.enableDiscount : entry.isDiscount;
if (!supportsDiscount) return 10;
return toFixedNumber(clampNumber(discount, getDiscountMin(entry), 10), 1);
}
function getProjectDepts(entry) {
return Array.isArray(entry?.project?.depts) ? entry.project.depts : [];
}
function getTreatmentDoctors(entry) {
if (!entry?.dept?._id) return [];
const allowedJobs = ["doctor", "expertDoctor", "therapist"];
const deptId = String(entry.dept._id);
return staffList.value.filter((staff) => {
const hasAllowedJob = Array.isArray(staff.job) && staff.job.some((job) => allowedJobs.includes(job));
const inDept = Array.isArray(staff.deptIds) && staff.deptIds.map(String).includes(deptId);
return hasAllowedJob && inDept;
});
}
function selectProject(project) {
const entry = entries.value[projectPickerIndex.value];
if (!entry) return;
const normalizedProject = normalizeProject(project);
entry.project = normalizedProject;
entry.price = Number(normalizedProject.price || 0);
entry.isGift = normalizedProject.isGift === true;
entry.isDiscount = normalizedProject.isDiscount === true;
entry.lowestDiscount = Number(normalizedProject.lowestDiscount || 0);
entry.discount = normalizeDiscount({ ...entry, discount: 10 });
entry.isFree = false;
calculateTotalPrice(entry);
entry.dept = getProjectDepts(entry).length === 1 ? getProjectDepts(entry)[0] : null;
entry.treatmentDoctorUserId = "";
entry.treatmentDoctorName = "";
closeProjectPicker();
}
function createPackageProjectEntry(project = {}, packageItem = {}) {
const normalizedProject = normalizeProject(project);
const hasValidDays = packageItem.hasValidDays === true;
const validDays = Number(packageItem.validDays || 0);
const validDate = hasValidDays && validDays > 0
? formatDate(new Date(Date.now() + validDays * 24 * 60 * 60 * 1000))
: "";
return {
...createEntry(),
project: normalizedProject,
price: Number(normalizedProject.price ?? project.price ?? 0),
usageCount: Number(project.number || 1),
packageUnitUsageCount: Number(project.number || 1),
discount: packageItem.packageType === "free" ? 10 : Number(project.discount || 10),
enableDiscount: packageItem.enableDiscount === true,
minDiscount: Number(packageItem.minDiscount || 0),
totalPrice: Number(project.totalPrice || 0),
selected: packageItem.packageType === "fixed" || project.required === true,
required: project.required === true,
hasValidDays,
validDays,
validTime: validDate,
dept: normalizedProject.depts?.length === 1 ? normalizedProject.depts[0] : null,
};
}
async function selectPackage(packageItem) {
const targetIndex = projectPickerIndex.value;
if (targetIndex < 0) return;
try {
uni.showLoading({ title: "加载套餐项目" });
const ids = packageItem.projects.map((item) => item._id).filter(Boolean);
const res = await api("getProjectList", { corpId: corpId.value, ids, page: 1, pageSize: ids.length, projectStatus: "enable", showDepts: true }, false);
const fullProjects = res?.data?.list || res?.list || res?.data || [];
const projects = packageItem.projects.map((item) => {
const fullProject = Array.isArray(fullProjects) ? fullProjects.find((project) => project._id === item._id) : null;
return createPackageProjectEntry({ ...(fullProject || {}), ...item }, packageItem);
});
const requiredCount = projects.filter((item) => item.required).length;
const entry = {
...createEntry(),
isPackage: true,
packageId: packageItem._id,
packageName: packageItem.name,
packageType: packageItem.packageType,
packagePrice: Number(packageItem.packagePrice || 0),
price: Number(packageItem.packagePrice || 0),
usageCount: 1,
discount: 10,
enableDiscount: packageItem.enableDiscount === true,
minDiscount: Number(packageItem.minDiscount || 0),
totalPrice: Number(packageItem.packagePrice || 0),
projectNumber: Number(packageItem.projectNumber || projects.length),
requiredCount,
optionalCount: Math.max(Number(packageItem.projectNumber || projects.length) - requiredCount, 0),
projects,
};
syncPackageSelection(entry);
syncPackageTotals(entry);
entries.value.splice(targetIndex, 1, entry);
closeProjectPicker();
} catch (error) {
uni.showToast({ title: "套餐加载失败", icon: "none" });
} finally {
uni.hideLoading();
}
}
function syncPackageSelection(entry) {
entry.selectedCount = entry.projects.filter((item) => item.selected).length;
entry.optionalSelectedCount = Math.max(entry.selectedCount - entry.requiredCount, 0);
}
function isPackageSelectionFull(entry) {
return Number(entry.selectedCount || 0) >= Number(entry.projectNumber || 0);
}
function togglePackageProject(entry, projectEntry) {
if (projectEntry.required) return;
if (!projectEntry.selected && isPackageSelectionFull(entry)) {
uni.showToast({ title: "已达到可选项目上限", icon: "none" });
return;
}
projectEntry.selected = !projectEntry.selected;
if (projectEntry.selected) projectEntry.usageCount = Number(entry.usageCount || 1) * Number(projectEntry.packageUnitUsageCount || 1);
syncPackageSelection(entry);
syncPackageTotals(entry);
}
function calculatePackageTotal(entry) {
entry.usageCount = Math.max(1, Number(entry.usageCount || 1));
entry.discount = normalizeDiscount(entry);
entry.totalPrice = toFixedNumber(Number(entry.price || 0) * entry.usageCount * entry.discount / 10, 2);
entry.projects.filter((project) => project.selected).forEach((project) => {
project.usageCount = entry.usageCount * Number(project.packageUnitUsageCount || 1);
});
syncPackageTotals(entry);
}
function getDefaultTodoConfig(entry) {
const planDate = formatDate(new Date());
return {
taskContent: entry.project?.projectName || "",
eventType: "benefitExecution",
planDate,
expireDate: entry.validTime || formatDate(new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)),
executorUserId: "",
executorName: "",
executeTeamId: "",
executeTeamName: "",
deductBenefit: false,
};
}
function toggleTodo(event, entry) {
entry.todoEnabled = Boolean(event.detail.value);
if (entry.todoEnabled) entry.todoConfig = entry.todoConfig || getDefaultTodoConfig(entry);
}
function openBatchTodoPicker() {
batchTodoVisible.value = true;
}
function closeBatchTodoPicker() {
batchTodoVisible.value = false;
}
function openBatchExecutorPicker() {
batchExecutorTarget.value = { todoConfig: batchTodoConfig.value };
todoExecutorTarget.value = batchExecutorTarget.value;
todoExecutorKeyword.value = "";
todoExecutorPickerVisible.value = true;
if (!todoTeams.value.length) refreshTodoExecutors();
}
function applyBatchTodoConfig() {
const applyConfig = (entry) => {
if (!entry?.project?._id) return;
entry.todoEnabled = Boolean(batchTodoConfig.value.todoEnabled);
if (!entry.todoEnabled) {
entry.todoConfig = null;
return;
}
entry.todoConfig = entry.todoConfig || getDefaultTodoConfig(entry);
entry.todoConfig.deductBenefit = Boolean(batchTodoConfig.value.deductBenefit);
if (batchTodoConfig.value.executorUserId) {
Object.assign(entry.todoConfig, {
executorUserId: batchTodoConfig.value.executorUserId,
executorName: batchTodoConfig.value.executorName,
executeTeamId: batchTodoConfig.value.executeTeamId,
executeTeamName: batchTodoConfig.value.executeTeamName,
});
}
};
entries.value.forEach((entry) => {
if (entry.isPackage) {
entry.projects.filter((project) => project.selected).forEach(applyConfig);
} else {
applyConfig(entry);
}
});
closeBatchTodoPicker()
}
function getTodoTypeLabel(value) {
return TODO_EVENT_TYPE_LABELS[value] || "请选择类型";
}
function changeTodoType(event, entry) {
entry.todoConfig.eventType = todoTypeOptions[Number(event.detail.value)]?.value || "benefitExecution";
}
function getTodoExecutors(entry) {
const team = todoTeams.value.find((item) => String(item.teamId) === String(entry.todoConfig?.executeTeamId));
return (team?.memberList || []).map((member) => {
const userid = typeof member === "string" ? member : member?.userid;
const staff = staffList.value.find((item) => item.userid === userid);
return { userid, name: staff?.anotherName || staff?.name || member?.anotherName || member?.name || userid };
}).filter((member) => member.userid);
}
function openTodoExecutorPicker(entry) {
todoExecutorTarget.value = entry;
todoExecutorKeyword.value = "";
todoExecutorPickerVisible.value = true;
if (!todoTeams.value.length) refreshTodoExecutors();
}
function closeTodoExecutorPicker() {
todoExecutorPickerVisible.value = false;
todoExecutorTarget.value = null;
todoExecutorKeyword.value = "";
}
function getTodoExecutorsForTeam(team) {
return (team?.memberList || []).map((member) => {
const userid = typeof member === "string" ? member : member?.userid;
const staff = staffList.value.find((item) => item.userid === userid);
return { userid, name: staff?.anotherName || staff?.name || member?.anotherName || member?.name || userid };
}).filter((member) => member.userid);
}
const filteredTodoTeams = computed(() => {
const keyword = todoExecutorKeyword.value.trim().toLowerCase();
if (!keyword) return todoTeams.value;
return todoTeams.value.map((team) => {
const members = getTodoExecutorsForTeam(team).filter((member) => (
String(member.name || "").toLowerCase().includes(keyword)
|| String(member.userid || "").toLowerCase().includes(keyword)
));
const teamMatched = String(team.name || "").toLowerCase().includes(keyword);
return { team: teamMatched ? team : { ...team, memberList: members }, matched: teamMatched || members.length > 0 };
}).filter((item) => item.matched).map((item) => item.team);
});
async function refreshTodoExecutors() {
if (todoExecutorRefreshing.value) return;
todoExecutorRefreshing.value = true;
try {
await Promise.all([loadStaffList(), loadTodoTeams()]);
} finally {
todoExecutorRefreshing.value = false;
}
}
function selectTodoExecutor(member, team) {
const entry = todoExecutorTarget.value;
if (!entry?.todoConfig) return;
entry.todoConfig.executeTeamId = team.teamId;
entry.todoConfig.executeTeamName = team.name;
entry.todoConfig.executorUserId = member.userid;
entry.todoConfig.executorName = member.name;
closeTodoExecutorPicker();
}
function isSelectedTodoExecutor(member, team) {
const config = todoExecutorTarget.value?.todoConfig;
return config?.executeTeamId === team.teamId && config?.executorUserId === member.userid;
}
function prepareTodoDatePicker(entry, field) {
todoDateTarget.value = { entry, field };
prepareValidTimePicker({ validTime: entry.todoConfig[field] });
}
function changeTodoDate(event) {
const target = todoDateTarget.value;
if (!target) return;
const [yearIndex, monthIndex, dayIndex] = event.detail.value || validTimeIndex.value;
const year = Number(validTimeRange.value[0][yearIndex].replace("年", ""));
const month = Number(validTimeRange.value[1][monthIndex].replace("月", ""));
const day = Number(validTimeRange.value[2][dayIndex].replace("日", ""));
const selectedDate = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
target.entry.todoConfig[target.field] = selectedDate;
validTimeIndex.value = [yearIndex, monthIndex, dayIndex];
if (target.field === "planDate" && !target.entry.validTime) {
target.entry.todoConfig.expireDate = formatDate(new Date(new Date(`${target.entry.todoConfig.planDate}T00:00:00`).getTime() + 7 * 24 * 60 * 60 * 1000));
}
todoDateTarget.value = null;
}
function syncPackageTotals(entry) {
const selected = entry.projects.filter((item) => item.selected);
const baseTotal = selected.reduce((sum, item) => sum + Number(item.price || 0) * Number(item.usageCount || 1), 0);
let allocated = 0;
selected.forEach((item, index) => {
const total = index === selected.length - 1
? Number(entry.totalPrice || 0) - allocated
: (baseTotal ? Number(entry.totalPrice || 0) * (Number(item.price || 0) * Number(item.usageCount || 1)) / baseTotal : 0);
item.totalPrice = toFixedNumber(Math.max(total, 0), 2);
item.discount = item.price && item.usageCount ? toFixedNumber(item.totalPrice / (item.price * item.usageCount) * 10, 1) : 0;
allocated += item.totalPrice;
});
}
function changeDept(event, entry) {
entry.dept = getProjectDepts(entry)[Number(event.detail.value)] || null;
entry.treatmentDoctorUserId = "";
entry.treatmentDoctorName = "";
}
function changeTreatmentDoctor(event, entry) {
const doctor = getTreatmentDoctors(entry)[Number(event.detail.value)] || null;
entry.treatmentDoctorUserId = doctor?.userid || "";
entry.treatmentDoctorName = doctor?.anotherName || doctor?.name || "";
}
function changeValidTime(event, entry) {
const [yearIndex, monthIndex, dayIndex] = event.detail.value || validTimeIndex.value;
const year = Number(validTimeRange.value[0][yearIndex].replace("年", ""));
const month = Number(validTimeRange.value[1][monthIndex].replace("月", ""));
const day = Number(validTimeRange.value[2][dayIndex].replace("日", ""));
const selectedDate = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
if (selectedDate < formatDate(new Date())) {
uni.showToast({
title: "不能选择过去的日期",
icon: "none",
duration: 2000,
});
return;
}
entry.validTime = selectedDate;
validTimeIndex.value = [yearIndex, monthIndex, dayIndex];
}
function clearValidTime(entry) {
entry.validTime = "";
}
function addEntry() {
const entry = createEntry();
entries.value.push(entry);
nextTick(() => {
pageRef.value?.scrollToBottom?.();
});
}
function removeEntry(index) {
entries.value.splice(index, 1);
if (!entries.value.length) entries.value.push(createEntry());
}
function goBack() {
uni.navigateBack();
}
function goBackToConversation() {
if (!groupId.value) {
goBack();
return;
}
const conversationID = `GROUP${groupId.value}`;
uni.redirectTo({
url: `/pages/message/index?conversationID=${encodeURIComponent(conversationID)}&groupID=${encodeURIComponent(groupId.value)}&fromCase=true`,
});
}
function validateEntries() {
if (!billDeptId.value) return "请选择开单科室";
for (let index = 0; index < entries.value.length; index += 1) {
const entry = entries.value[index];
if (entry.isPackage) {
if (!Number(entry.usageCount) || Number(entry.usageCount) <= 0) return `${entry.packageName}数量必须大于0`;
if (!Number.isFinite(Number(entry.discount)) || Number(entry.discount) < 0 || Number(entry.discount) > 10) return `${entry.packageName}折扣需在0到10之间`;
if (!entry.enableDiscount && Number(entry.discount) !== 10) return `${entry.packageName}不支持折扣`;
if (entry.enableDiscount && Number(entry.discount) < getDiscountMin(entry)) {
return `${entry.packageName}折扣不能低于${getDiscountMin(entry)}`;
}
if (entry.packageType === "free" && entry.selectedCount !== entry.projectNumber) {
return `${entry.packageName}需要选择${entry.projectNumber}项,当前已选择${entry.selectedCount}`;
}
const selectedProjects = entry.projects.filter((item) => item.selected);
for (const projectEntry of selectedProjects) {
if (!projectEntry.dept?._id) return `${projectEntry.project.projectName}请选择治疗科室`;
if (Number(projectEntry.validDays || 0) > 0 && !projectEntry.validTime) {
return `${projectEntry.project.projectName}请选择有效日期`;
}
const todoError = validateTodo(projectEntry);
if (todoError) return todoError;
}
continue;
}
const label = `${index + 1}个权益项目`;
if (!entry.project?._id) return `${label}请选择项目`;
if (Number(entry.price) < 0 || Number.isNaN(Number(entry.price))) return `${label}价格不能小于0`;
if (!Number.isFinite(Number(entry.discount)) || Number(entry.discount) < 0 || Number(entry.discount) > 10) return `${label}折扣需在0到10之间`;
if (!entry.isDiscount && Number(entry.discount) !== 10) return `${label}不支持折扣`;
if (entry.isDiscount && Number(entry.discount) < getDiscountMin(entry)) {
return `${label}折扣不能低于${getDiscountMin(entry)}`;
}
if (!entry.isGift && entry.isFree) return `${label}不支持赠送`;
if (!Number.isFinite(Number(entry.totalPrice)) || Number(entry.totalPrice) < 0) return `${label}总价不能小于0`;
if (!Number(entry.usageCount) || Number(entry.usageCount) <= 0) return `${label}数量必须大于0`;
if (!entry.dept?._id) return `${label}请选择治疗科室`;
const todoError = validateTodo(entry);
if (todoError) return todoError;
}
if (!patientId.value) return "当前会话缺少患者档案关联";
return "";
}
function validateTodo(entry) {
if (!entry.todoEnabled) return "";
const config = entry.todoConfig || {};
if (!String(config.taskContent || "").trim()) return `${entry.project?.projectName || "权益项目"}请填写待办内容`;
if (!config.eventType) return `${entry.project?.projectName || "权益项目"}请选择待办类型`;
if (!config.planDate || !config.expireDate || config.expireDate < config.planDate) return `${entry.project?.projectName || "权益项目"}待办日期不正确`;
if (!config.executeTeamId) return `${entry.project?.projectName || "权益项目"}请选择处理团队`;
if (!config.executorUserId) return `${entry.project?.projectName || "权益项目"}请选择待办处理人`;
return "";
}
function getResponseId(data) {
if (typeof data === "string") return data;
if (!data || typeof data !== "object") return "";
return data._id || data.id || getResponseId(data.data);
}
async function linkBenefitTodos(entry, treatmentRecordId, userId) {
if (!entry.todoEnabled) return;
const config = entry.todoConfig;
const todoIds = [];
const quantity = Math.max(1, Number(entry.usageCount || 1));
for (let sequence = 1; sequence <= quantity; sequence += 1) {
const res = await api("createTodo", {
corpId: corpId.value,
params: {
corpId: corpId.value,
customerId: patientId.value,
customerName: patientName.value,
taskContent: String(config.taskContent).trim(),
eventType: config.eventType,
eventStatus: "untreated",
executeMethod: "todo",
plannedExecutionTime: new Date(`${config.planDate}T00:00:00`).getTime(),
expireTime: new Date(`${config.expireDate}T23:59:59`).getTime(),
executorUserId: config.executorUserId,
executeTeamId: config.executeTeamId,
executeTeamName: config.executeTeamName,
creatorUserId: userId,
benefitRecordId: treatmentRecordId,
benefitProjectId: entry.project._id,
benefitUsageCount: 1,
benefitTotalUsageCount: quantity,
taskQuantity: 1,
taskSequence: sequence,
benefitDeductOnComplete: Boolean(config.deductBenefit),
},
}, false);
if (!res?.success) throw new Error(res?.message || "待办生成失败");
const todoId = getResponseId(res?.data || res);
if (!todoId) throw new Error("待办生成成功但未返回待办ID");
todoIds.push(todoId);
}
const updateRes = await api("updateTreatmentRecord", {
corpId: corpId.value,
id: treatmentRecordId,
params: {
benefitTodoId: todoIds[0],
benefitTodoIds: todoIds,
benefitTodoConfig: {
benefitDeductOnComplete: Boolean(config.deductBenefit),
plannedExecutionTime: new Date(`${config.planDate}T00:00:00`).getTime(),
expireTime: new Date(`${config.expireDate}T23:59:59`).getTime(),
},
},
}, false);
if (!updateRes?.success) throw new Error(updateRes?.message || "权益待办关联失败");
}
function buildBenefitMessage(createdEntries) {
const items = createdEntries.map((entry) => ({
projectId: entry.project._id,
projectName: entry.project.projectName,
usageCount: Number(entry.usageCount || 0),
validTime: entry.validTime ? new Date(`${entry.validTime} 23:59:59`).getTime() : null,
validTimeText: entry.validTime || "无限期",
}));
return {
conversationID: groupId.value ? `GROUP${groupId.value}` : "",
groupId: groupId.value,
createdAt: Date.now(),
messages: [
{
type: "benefit",
content: {
title: "医生为您录入专属权益",
patientId: patientId.value,
patientName: patientName.value,
corpId: corpId.value,
teamId: teamId.value,
items,
},
},
],
context: {
userId: accountStore.doctorInfo?.userid || accountStore.account?.userid || "",
customerId: patientId.value,
customerName: patientName.value,
corpId: corpId.value,
teamId: teamId.value,
},
};
}
function queueBenefitMessage(createdEntries) {
if (!groupId.value || !createdEntries.length) return;
uni.setStorageSync(PENDING_FOLLOWUP_SEND_STORAGE_KEY, buildBenefitMessage(createdEntries));
}
async function submitEntries() {
if (saving.value) return;
const errorMessage = validateEntries();
if (errorMessage) {
uni.showToast({ title: errorMessage, icon: "none" });
return;
}
saving.value = true;
const userId = accountStore.doctorInfo?.userid || accountStore.account?.userid || "";
try {
const createdEntries = [];
const submitEntries = entries.value.flatMap((entry) => {
if (!entry.isPackage) return [entry];
return entry.projects.filter((item) => item.selected).map((item) => ({
...item,
packageId: entry.packageId,
packageName: entry.packageName,
packageType: entry.packageType,
packagePrice: Number(entry.packagePrice || 0),
}));
});
for (const entry of submitEntries) {
const count = Number(entry.usageCount);
const price = Number(entry.price);
const discount = Number(entry.discount);
const totalPrice = entry.isFree ? 0 : Number(entry.totalPrice || 0);
const treatmentData = {
customerId: patientId.value,
customerName: patientName.value,
projectId: entry.project._id,
projectName: entry.project.projectName,
usageCount: count,
restUsageCount: count,
price,
totalPrice,
discount,
isFree: Boolean(entry.isFree),
treatmentDeptName: entry.dept.deptName,
treatmentDeptId: entry.dept._id,
treatmentDept_id: entry.dept._id,
treatmentDoctorUserId: entry.treatmentDoctorUserId,
treatmentStatus: "init",
deductUsageCount: 0,
billType: "门诊",
billDeptId: billDeptId.value,
billDeptName: billDeptName.value,
billDoctorUserId: billDoctorUserId.value,
billDoctorName: billDoctorName.value,
billdCreator: userId,
createTreatementTime: Date.now(),
billTime: Date.now(),
validTime: entry.validTime ? new Date(`${entry.validTime} 23:59:59`).getTime() : null,
hasValidDays: Boolean(entry.hasValidDays),
validDays: Number(entry.validDays || 0),
packageId: entry.packageId || "",
packageName: entry.packageName || "",
packageType: entry.packageType || "",
packagePrice: Number(entry.packagePrice || 0),
teamId: teamId.value,
corpId: corpId.value,
};
const res = await api("addTreatmentRecord", { corpId: corpId.value, userId, operationSource: "wxApp", params: treatmentData }, false);
if (!res?.success) throw new Error(res?.message || "权益录入失败");
const treatmentRecordId = getResponseId(res?.data || res);
if (!treatmentRecordId) throw new Error("权益录入成功但未返回权益记录ID");
await linkBenefitTodos(entry, treatmentRecordId, userId);
createdEntries.push(entry);
}
queueBenefitMessage(createdEntries);
uni.showToast({ title: "权益录入成功", icon: "success" });
setTimeout(goBackToConversation, 500);
} catch (error) {
uni.showToast({ title: error.message || "权益录入失败", icon: "none" });
} finally {
saving.value = false;
}
}
</script>
<style scoped lang="scss">
.benefit-page-shell {
background: #f5f6f8;
}
.benefit-page-shell :global(.safeareaBottom) {
background: #fff;
}
.benefit-page {
min-height: 100%;
padding: 20rpx 24rpx 32rpx;
box-sizing: border-box;
}
.patient-card {
position: relative;
display: flex;
align-items: stretch;
background: #fff;
border: 1rpx solid #e5e7eb;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 20rpx;
overflow: hidden;
box-shadow: 0 10rpx 28rpx rgba(15, 23, 42, 0.06);
}
.patient-card-accent {
width: 8rpx;
border-radius: 999rpx;
background: #d1d5db;
margin-right: 18rpx;
flex-shrink: 0;
}
.patient-card-main {
flex: 1;
min-width: 0;
}
.patient-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.patient-name-wrap {
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12rpx;
}
.patient-name {
color: #1f2937;
font-size: 36rpx;
font-weight: 700;
line-height: 1.2;
max-width: 420rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.patient-badge {
display: inline-flex;
align-items: center;
height: 36rpx;
padding: 0 14rpx;
border-radius: 999rpx;
background: #f3f4f6;
color: #6b7280;
font-size: 22rpx;
}
.patient-status {
flex-shrink: 0;
height: 36rpx;
line-height: 36rpx;
padding: 0 12rpx;
border-radius: 999rpx;
background: #f3f4f6;
color: #6b7280;
font-size: 22rpx;
}
.patient-meta {
display: block;
margin-top: 20rpx;
}
.patient-meta-item {
min-width: 0;
padding: 14rpx 16rpx;
border-radius: 12rpx;
background: #f8f9fb;
}
.patient-meta-label {
display: block;
color: #9ca3af;
font-size: 20rpx;
line-height: 1.2;
}
.patient-meta-value {
display: block;
margin-top: 6rpx;
color: #6b7280;
font-size: 24rpx;
font-weight: 500;
line-height: 1.35;
word-break: break-all;
}
.section-title {
margin: 12rpx 4rpx 16rpx;
color: #1f2937;
font-size: 28rpx;
font-weight: 700;
}
.section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.section-title-row .section-title {
flex: 1;
min-width: 0;
}
.entry-card {
background: #fff;
border-radius: 18rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 10rpx 28rpx rgba(15, 23, 42, 0.05);
}
.package-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.package-name {
color: #111827;
font-size: 30rpx;
font-weight: 600;
line-height: 1.4;
}
.package-price {
flex-shrink: 0;
color: #e85c2f;
font-size: 30rpx;
font-weight: 700;
}
.package-summary {
display: flex;
flex-wrap: wrap;
gap: 10rpx 20rpx;
margin-top: 18rpx;
padding: 16rpx;
border-radius: 12rpx;
background: #f5f7fa;
color: #606266;
font-size: 24rpx;
}
.package-project-card {
margin-top: 20rpx;
padding: 20rpx;
border: 1rpx solid #e5e7eb;
border-radius: 14rpx;
background: #fafbfc;
}
.package-project-title {
display: flex;
align-items: center;
gap: 10rpx;
color: #1f2937;
font-size: 28rpx;
font-weight: 600;
}
.required-checkbox {
width: 42rpx;
height: 42rpx;
margin-right: 4rpx;
border: 3rpx solid #0877f1;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.required-project-card {
border: 2rpx solid #0877f1;
background: #f5f9ff;
}
.required-badge {
flex-shrink: 0;
padding: 4rpx 10rpx;
border: 2rpx solid #0877f1;
border-radius: 6rpx;
color: #0877f1;
background: #fff;
font-size: 22rpx;
line-height: 28rpx;
}
.package-project-detail {
margin-top: 10rpx;
color: #6b7280;
font-size: 24rpx;
}
.todo-switch-row,
.todo-deduct-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20rpx;
color: #374151;
font-size: 26rpx;
font-weight: 600;
}
.todo-config-card {
margin-top: 16rpx;
padding: 20rpx;
border: 1rpx solid #dbe5f0;
border-radius: 14rpx;
background: #f7f9fc;
}
.todo-config-card .project-input {
margin-top: 16rpx;
}
.todo-input {
display: block;
width: 100%;
height: 76rpx;
box-sizing: border-box;
padding: 0 20rpx;
border: 1rpx solid #d1d5db;
border-radius: 12rpx;
background: #fff;
color: #111827;
font-size: 28rpx;
}
.todo-executor-picker {
position: fixed;
right: 0;
bottom: var(--keyboard-height);
left: 0;
width: 100%;
height: 72vh;
max-height: calc(100vh - var(--keyboard-height));
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
padding: 24rpx;
border-radius: 24rpx 24rpx 0 0;
background: #fff;
}
.todo-executor-search {
flex: 0 0 64rpx;
width: 100%;
height: 64rpx;
min-height: 64rpx;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12rpx;
margin: 20rpx 0;
padding: 0 20rpx;
border-radius: 10rpx;
background: #f5f6f8;
}
.todo-executor-search-input {
display: block;
flex: 1;
width: 0;
height: 64rpx;
min-height: 64rpx;
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0;
background: transparent;
font-size: 28rpx;
line-height: 64rpx;
}
.todo-executor-list {
flex: 1;
min-height: 0;
height: auto;
max-height: none;
}
.todo-team-group {
margin-top: 16rpx;
border: 1rpx solid #e5e7eb;
border-radius: 12rpx;
overflow: hidden;
}
.todo-team-title {
padding: 16rpx 20rpx;
background: #f3f6fa;
color: #374151;
font-size: 26rpx;
font-weight: 700;
}
.todo-executor-item {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 76rpx;
padding: 0 20rpx;
border-top: 1rpx solid #f0f2f5;
color: #1f2937;
font-size: 26rpx;
}
.todo-selected {
color: #0877f1;
font-size: 22rpx;
}
.todo-empty {
padding: 24rpx 20rpx;
color: #9ca3af;
font-size: 24rpx;
text-align: center;
}
.readonly-input {
display: flex;
align-items: center;
height: 76rpx;
box-sizing: border-box;
padding: 0 20rpx;
border-radius: 12rpx;
background: #f3f4f6;
color: #606266;
font-size: 28rpx;
font-weight: 600;
}
.field-label {
margin-bottom: 12rpx;
color: #374151;
font-size: 26rpx;
font-weight: 600;
}
.project-name-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.project-name-row .field-label {
margin-bottom: 12rpx;
}
.batch-operation-button {
flex-shrink: 0;
padding: 8rpx 16rpx;
border: 1rpx solid #0877f1;
border-radius: 8rpx;
color: #0877f1;
background: #f5f9ff;
font-size: 24rpx;
line-height: 1.3;
}
.batch-todo-picker {
position: fixed;
right: 0;
bottom: var(--keyboard-height);
left: 0;
z-index: 10001;
box-sizing: border-box;
padding: 28rpx 28rpx 32rpx;
border-radius: 24rpx 24rpx 0 0;
background: #fff;
}
.batch-todo-picker .picker-header {
padding-bottom: 20rpx;
border-bottom: 1rpx solid #eef2f7;
}
.batch-todo-row,
.batch-todo-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
min-height: 76rpx;
padding: 0 4rpx;
color: #374151;
font-size: 28rpx;
}
.batch-todo-row+.batch-todo-row {
margin-top: 8rpx;
}
.batch-todo-field {
min-height: 82rpx;
margin-top: 12rpx;
padding: 0 20rpx;
border: 1rpx solid #d1d5db;
border-radius: 12rpx;
background: #fff;
}
.batch-todo-field text:nth-child(2) {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
.batch-todo-actions {
display: flex;
gap: 20rpx;
margin-top: 28rpx;
}
.batch-todo-actions button {
flex: 1;
height: 76rpx;
line-height: 76rpx;
border-radius: 12rpx;
font-size: 28rpx;
}
.batch-todo-cancel {
color: #606266;
border: 1rpx solid #d1d5db;
background: #fff;
}
.batch-todo-confirm {
color: #fff;
background: #0877f1;
box-shadow: 0 8rpx 18rpx rgba(8, 119, 241, 0.2);
}
.bill-dept-picker {
display: block;
min-height: 76rpx;
border-radius: 12rpx;
background: #fff;
}
.dept-search-input {
display: flex;
align-items: center;
height: 68rpx;
margin-bottom: 12rpx;
padding: 0 18rpx;
box-sizing: border-box;
border: 1rpx solid #d1d5db;
border-radius: 12rpx;
background: #fff;
}
.dept-search-field {
flex: 1;
min-width: 0;
height: 64rpx;
margin-left: 10rpx;
border: 0;
background: transparent;
color: #111827;
font-size: 28rpx;
}
.bill-dept-picker :deep(.uni-data-tree-input) {
min-height: 76rpx;
}
.bill-dept-picker :deep(.input-value) {
min-height: 76rpx;
box-sizing: border-box;
border: 1rpx solid #d1d5db;
border-radius: 12rpx;
background: #fff;
}
.project-input,
.picker-value {
min-height: 76rpx;
box-sizing: border-box;
border: 1rpx solid #d1d5db;
border-radius: 12rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
color: #111827;
}
.project-input.required-select {
border: 2rpx solid #0877f1;
background: #f5f9ff;
}
.number-input {
display: block;
width: 100%;
height: 76rpx;
min-width: 0;
box-sizing: border-box;
margin: 0;
padding: 0 20rpx;
border: 1rpx solid #b8c1cc;
border-radius: 12rpx;
background: #fff;
color: #1f2937;
-webkit-text-fill-color: #1f2937;
caret-color: #0877f1;
font-size: 28rpx;
font-weight: 600;
line-height: 76rpx;
opacity: 1;
text-align: left;
}
.locked-input {
background: #f3f4f6;
color: #9ca3af;
}
.value-text {
color: #111827;
font-size: 28rpx;
font-weight: 500;
}
.placeholder-text {
color: #9ca3af;
font-size: 28rpx;
}
.discount-picker {
display: block;
width: 100%;
}
.discount-picker .number-input {
width: 100%;
}
.discount-picker.discount-disabled {
opacity: 0.62;
pointer-events: none;
}
.discount-picker.discount-disabled .number-input {
border-color: #d1d5db;
background: #f3f4f6;
color: #9ca3af;
-webkit-text-fill-color: #9ca3af;
}
.field-row {
display: flex;
gap: 20rpx;
margin-top: 20rpx;
}
.field-group {
margin-top: 20rpx;
}
.field-block {
flex: 1;
min-width: 0;
}
.field-block-full {
flex-basis: 100%;
}
.discount-input-wrap {
display: flex;
align-items: center;
gap: 12rpx;
}
.discount-input-wrap .number-input {
flex: 1;
min-width: 0;
}
.free-check {
flex-shrink: 0;
}
.free-check-label {
display: flex;
align-items: center;
gap: 4rpx;
color: #374151;
font-size: 24rpx;
}
.valid-time-wrap {
display: flex;
align-items: center;
gap: 12rpx;
}
.valid-time-picker {
flex: 1;
min-width: 0;
}
.disabled-picker {
opacity: 0.65;
}
.valid-time-locked-tip {
flex-shrink: 0;
color: #9ca3af;
font-size: 24rpx;
white-space: nowrap;
}
.unlimited-button {
flex-shrink: 0;
height: 76rpx;
line-height: 76rpx;
padding: 0 20rpx;
border: 1rpx solid #d1d5db;
border-radius: 12rpx;
color: #0877f1;
font-size: 26rpx;
background: #f8fbff;
}
.total-price {
min-height: 40rpx;
color: #e85c2f;
font-size: 30rpx;
font-weight: 600;
line-height: 40rpx;
}
.number-input {
overflow: visible;
}
.number-input[disabled] {
color: #374151;
-webkit-text-fill-color: #374151;
background: #f3f4f6;
}
.total-input {
color: #c2410c;
font-weight: 600;
}
.add-entry {
height: 80rpx;
border: 1rpx dashed #0877f1;
border-radius: 14rpx;
display: flex;
justify-content: center;
align-items: center;
gap: 8rpx;
color: #0877f1;
font-size: 28rpx;
background: #fff;
margin-top: 8rpx;
}
.remove-entry {
margin-top: 20rpx;
color: #fa5151;
font-size: 26rpx;
text-align: right;
}
.footer-bar {
position: relative;
display: flex;
align-items: center;
gap: 20rpx;
padding: 14rpx 24rpx 18rpx;
background: #fff;
border-top: 1rpx solid #eef2f7;
box-shadow: 0 -4rpx 16rpx rgba(15, 23, 42, 0.04);
box-sizing: border-box;
z-index: 0;
}
.footer-summary {
flex: 1;
display: flex;
align-items: center;
gap: 14rpx;
min-width: 0;
}
.total-summary {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 6rpx;
min-width: 0;
white-space: nowrap;
}
.summary-label {
color: #6b7280;
font-size: 22rpx;
line-height: 1.2;
white-space: nowrap;
}
.summary-saving {
color: #16a34a;
}
.summary-total-value {
color: #e85c2f;
font-size: 32rpx;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.detail-trigger {
flex-shrink: 0;
padding-left: 14rpx;
border-left: 1rpx solid #edf0f3;
color: #6b7280;
font-size: 24rpx;
line-height: 1.2;
white-space: nowrap;
}
.benefit-detail-panel {
position: absolute;
right: 0;
bottom: 100%;
left: 0;
max-height: calc(100vh - 220rpx);
overflow: hidden;
padding: 20rpx 24rpx 18rpx;
border-top: 1rpx solid #eef2f7;
border-radius: 24rpx 24rpx 0 0;
background: #fff;
box-shadow: 0 -8rpx 24rpx rgba(15, 23, 42, 0.08);
box-sizing: border-box;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(100%);
transform-origin: bottom center;
transition:
opacity 180ms ease,
transform 180ms ease,
visibility 180ms ease;
}
.benefit-detail-panel-visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
}
.detail-header,
.detail-row {
display: flex;
align-items: center;
gap: 12rpx;
min-height: 60rpx;
color: #6b7280;
font-size: 27rpx;
}
.detail-header {
min-height: 48rpx;
color: #9ca3af;
font-size: 24rpx;
}
.detail-project-name {
flex: 1;
min-width: 0;
}
.detail-project-name-value {
overflow: hidden;
color: #374151;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-quantity,
.detail-discount {
flex: 0 0 72rpx;
text-align: center;
white-space: nowrap;
}
.detail-amount {
flex: 0 0 130rpx;
color: #374151;
text-align: right;
white-space: nowrap;
}
.detail-empty {
padding: 22rpx 0;
color: #9ca3af;
font-size: 26rpx;
text-align: center;
}
.detail-row-total {
margin-top: 8rpx;
padding-top: 14rpx;
border-top: 1rpx solid #eef2f7;
color: #1f2937;
font-weight: 600;
}
.detail-row-total .detail-amount {
color: #e85c2f;
font-weight: 700;
}
.footer-actions {
flex: 0 0 250rpx;
}
.footer-actions button {
width: 100%;
min-height: 88rpx;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14rpx;
font-size: 30rpx;
line-height: 1.2;
}
.submit-button {
color: #fff;
background: linear-gradient(135deg, #0f84ff 0%, #0877f1 100%);
box-shadow: 0 10rpx 24rpx rgba(8, 119, 241, 0.22);
}
.picker-mask {
position: fixed;
inset: 0;
z-index: 99999;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: flex-end;
margin-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
}
.project-picker {
position: fixed;
right: 0;
left: 0;
bottom: var(--keyboard-height);
width: 100%;
height: 75vh;
max-height: calc(100vh - var(--keyboard-height));
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
padding: 24rpx;
box-sizing: border-box;
}
.bill-dept-modal {
height: 70vh;
}
.bill-dept-list {
flex: 1;
min-height: 0;
margin-top: 20rpx;
}
.bill-dept-option {
display: flex;
align-items: center;
min-height: 76rpx;
border-bottom: 1rpx solid #f0f0f0;
color: #374151;
}
.bill-dept-option-spacer {
width: 16rpx;
flex-shrink: 0;
}
.bill-dept-option-name {
margin-left: 10rpx;
font-size: 28rpx;
}
.bill-dept-option-name.selected {
color: #0877f1;
font-weight: 600;
}
.bill-dept-level-tag {
margin-left: 12rpx;
padding: 2rpx 8rpx;
border-radius: 6rpx;
background: #eef6ff;
color: #0877f1;
font-size: 20rpx;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.picker-title {
color: #222;
font-size: 32rpx;
font-weight: 600;
}
.search-box {
flex: 0 0 64rpx;
width: 100%;
height: 64rpx;
min-height: 64rpx;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12rpx;
background: #f5f6f8;
border-radius: 10rpx;
padding: 0 20rpx;
margin: 20rpx 0;
}
.search-input {
display: block;
flex: 1;
width: 0;
height: 64rpx;
min-height: 64rpx;
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0;
background: transparent;
font-size: 28rpx;
line-height: 64rpx;
}
.project-list {
flex: 1;
min-height: 0;
height: auto;
max-height: none;
}
.picker-group-title {
padding: 18rpx 8rpx 8rpx;
color: #374151;
font-size: 26rpx;
font-weight: 700;
}
.project-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20rpx;
padding: 24rpx 8rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.project-info {
flex: 1;
min-width: 0;
}
.project-name {
color: #333;
font-size: 28rpx;
line-height: 1.35;
}
.project-depts {
margin-top: 8rpx;
color: #8b95a1;
font-size: 24rpx;
line-height: 1.35;
word-break: break-all;
}
.project-price {
color: #fa5151;
font-size: 26rpx;
}
.empty-text {
text-align: center;
color: #999;
padding: 60rpx 0;
font-size: 28rpx;
}
</style>