ykt-wxapp/pages/message/benefit-entry.vue

1421 lines
44 KiB
Vue
Raw 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>
<full-page ref="pageRef" pageStyle="background:#f5f6f8" 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="section-title">权益项目</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>
<input class="number-input" type="digit" v-model="entry.discount" @input="calculatePackageTotal(entry)" />
</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">
<view class="package-project-title">
<checkbox
v-if="entry.packageType === 'free'"
:checked="projectEntry.selected"
:disabled="projectEntry.required || (!projectEntry.selected && isPackageSelectionFull(entry))"
color="#0877f1"
@click="togglePackageProject(entry, projectEntry)"
/>
<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"><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 && projectEntry.hasValidDays" class="field-group">
<view class="field-label">有效期</view>
<view class="valid-time-picker" @tap="prepareValidTimePicker(projectEntry)"><picker mode="multiSelector" :range="validTimeRange" :value="validTimeIndex" @columnchange="onValidTimeColumnChange" @change="changeValidTime($event, projectEntry)"><view class="picker-value value-text">{{ projectEntry.validTime }}</view></picker></view>
</view>
</view>
<view class="remove-entry" @click="removeEntry(index)">删除套餐</view>
</template>
<template v-else>
<view class="field-label">项目名称</view>
<view class="project-input" @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)" />
</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">
<input class="number-input" type="digit" v-model="entry.discount" :disabled="entry.isFree" @input="calculateTotalPrice(entry)" />
<checkbox-group 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" @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">
<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="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="projectPickerVisible" class="picker-mask" @click="closeProjectPicker">
<view class="project-picker" @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" 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>
<template #footer>
<view class="footer-bar">
<button class="cancel-button" @click="goBack">取消</button>
<button class="submit-button" :loading="saving" @click="submitEntries">生成权益</button>
</view>
</template>
</full-page>
</template>
<script setup>
import { nextTick, 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";
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 entries = ref([]);
const saving = ref(false);
const projectPickerVisible = ref(false);
const projectPickerIndex = ref(-1);
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 VALID_YEAR_SPAN = 50;
const validTimeRange = ref([[], [], []]);
const validTimeIndex = ref([0, 0, 0]);
let searchTimer = null;
let entryKey = 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,
usageCount: 1,
validTime: "",
dept: null,
treatmentDoctorUserId: "",
treatmentDoctorName: "",
};
}
function decodeRouteParam(value = "") {
try {
return decodeURIComponent(value || "");
} catch (error) {
return value || "";
}
}
onLoad((options = {}) => {
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();
loadStaffList();
});
});
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;
} 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 }))
: [];
} catch (error) {
staffList.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) {
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("、"),
};
}
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,
}, 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;
}
const total = toNumber(entry.price) * toNumber(entry.usageCount, 1) * (toNumber(entry.discount) / 10);
entry.totalPrice = toFixedNumber(Math.max(0, total), 2);
}
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
? toFixedNumber(clampNumber((toNumber(entry.totalPrice) / originalTotal) * 10, 0, 10), 1)
: 10;
}
function handleFreeChange(event, entry) {
entry.isFree = Array.isArray(event.detail.value) && event.detail.value.includes("free");
if (entry.isFree) {
entry.discount = 0;
entry.totalPrice = 0;
return;
}
entry.discount = 10;
calculateTotalPrice(entry);
}
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.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),
discount: packageItem.packageType === "free" ? 10 : Number(project.discount || 10),
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,
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;
syncPackageSelection(entry);
syncPackageTotals(entry);
}
function calculatePackageTotal(entry) {
entry.usageCount = Math.max(1, Number(entry.usageCount || 1));
entry.discount = clampNumber(toNumber(entry.discount, 10), 0, 10);
entry.totalPrice = toFixedNumber(Number(entry.price || 0) * entry.usageCount * entry.discount / 10, 2);
syncPackageTotals(entry);
}
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() {
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.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 (projectEntry.hasValidDays && !projectEntry.validTime) return `${projectEntry.project.projectName}请选择有效日期`;
}
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 (!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}请选择治疗科室`;
}
if (!patientId.value) return "当前会话缺少患者档案关联";
return "";
}
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: "门诊",
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", { userId, operationSource: "wxApp", params: treatmentData }, false);
if (!res?.success) throw new Error(res?.message || "权益录入失败");
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 {
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;
}
.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;
}
.package-project-detail {
margin-top: 10rpx;
color: #6b7280;
font-size: 24rpx;
}
.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-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;
}
.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;
}
.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;
}
.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 {
display: flex;
gap: 20rpx;
padding: 20rpx 24rpx;
background: rgba(245, 246, 248, 0.96);
border-top: 1rpx solid #eef2f7;
backdrop-filter: blur(12px);
box-sizing: border-box;
}
.footer-bar button {
flex: 1;
min-height: 88rpx;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14rpx;
font-size: 30rpx;
line-height: 1.2;
}
.cancel-button {
color: #0877f1;
background: #fff;
border: 1rpx solid #0877f1;
}
.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: 20;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: flex-end;
}
.project-picker {
width: 100%;
max-height: 75vh;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
padding: 24rpx;
box-sizing: border-box;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.picker-title {
color: #222;
font-size: 32rpx;
font-weight: 600;
}
.search-box {
display: flex;
align-items: center;
gap: 12rpx;
background: #f5f6f8;
border-radius: 10rpx;
padding: 0 20rpx;
margin: 20rpx 0;
}
.search-input {
flex: 1;
height: 72rpx;
font-size: 28rpx;
}
.project-list {
max-height: 55vh;
}
.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>