Files
gao-customer-h5/src/pages/event/EventRecordEdit.vue
T
2026-08-11 22:25:30 +08:00

822 lines
22 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>
<div v-can="pageAccess" class="registration-record-edit">
<t-form class="form" label-align="left">
<t-form-item label="客户" required-mark>
<t-input
:model-value="customerDisplayText"
class="customer"
readonly
borderless
placeholder="选择或扫码客户"
@click="!isEditMode && openSelectedCustomerList()"
>
<template v-if="!isEditMode" #suffix-icon>
<div class="actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
@click.stop="selectorVisible = true"
>选择</t-button>
<t-button
theme="primary"
size="extra-small"
@click.stop="toggleScanner"
>
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
</t-button>
</div>
</template>
</t-input>
</t-form-item>
<div v-if="scannerVisible" class="code-scanner-wrapper">
<code-scanner
visible
:status-text="scanStatus"
active-text="正在识别客户编码,请将条码或二维码移入取景框。"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/>
</div>
<customer-profile-card
v-if="showSelectedCustomerProfile"
class="customer-profile"
:customer="selectedCustomer"
/>
<event-selector
v-model="selectedEventIdList"
class="registration-event-selector"
:event-list="eventList"
:disabled="isEditMode"
required-mark
/>
<div v-if="isEditMode" class="edit-point-tip">已产生积分的登记记录不会因修改客户、事件或时间而重算;如需修改这些事实,请删除后重新创建。</div>
<t-form-item v-if="showSourceValueInput" label="自定义积分" required-mark>
<t-input
v-model.trim="pointValueText"
type="number"
align="right"
borderless
:placeholder="sourceValuePlaceholder"
/>
</t-form-item>
<t-form-item v-if="!isEditMode" label="登记时间" content-align="right">
<div class="time-actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
:content="registeredAtText || '此刻'"
@click="openRegisteredAtPicker"
/>
<t-button
v-if="registeredAtText"
theme="primary"
variant="text"
size="extra-small"
content="此刻"
@click="resetRegisteredAt"
/>
</div>
</t-form-item>
<t-form-item v-else label="登记时间" content-align="right">
<span class="readonly-time" v-text="registeredAtText || '此刻'" />
</t-form-item>
<t-form-item label="登记备注">
<t-textarea
v-model.trim="note"
class="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="登记说明"
/>
</t-form-item>
<div class="photo-part">
<photo-scroller v-model="photoList" :loading="isPhotoUploading" @add="capturePhoto" />
</div>
</t-form>
<t-cell-group
v-if="recentRecordList.length"
title="最近登记事件列表"
class="record-list"
theme="card"
>
<t-cell
v-for="item in recentRecordList"
:key="item.id"
:title="getRecordEventName(item)"
:description="item.registeredAt ? Time.toPassedDateTime(item.registeredAt) : '-'"
arrow
hover
@click="toRecordDetail(item.id || '')"
>
<template #leftIcon>
<t-image v-if="getRecordPhotoURL(item)" :src="getRecordPhotoURL(item)" alt="事件照片" fit="cover" class="record-photo" />
</template>
</t-cell>
</t-cell-group>
<div v-else class="empty">当前客户暂无事件记录</div>
<customer-selector
v-if="!isEditMode"
v-model="selectedCustomerIdList"
v-model:visible="selectorVisible"
:selection-mode="isEditMode ? 'single' : 'multiple'"
@change="handleCustomerChange"
/>
<t-popup v-model="selectedCustomerListVisible" placement="bottom">
<div class="selected-customer-popup bg-white">
<div class="popup-header">
<t-button variant="text" size="small" @click="selectedCustomerListVisible = false">关闭</t-button>
<h3>已选客户</h3>
<t-button theme="primary" variant="text" size="small" @click="openCustomerSelector">选择</t-button>
</div>
<customer-list
class="list"
:customer-list="selectedCustomerList"
:removable="!isEditMode"
empty-text="暂未选择客户"
@remove="removeSelectedCustomer"
/>
</div>
</t-popup>
<input
ref="recordPhotoInputRef"
class="file-input"
type="file"
accept="image/*"
multiple
@change="handleRecordPhotoSelected"
>
<t-button theme="primary" class="save" :disabled="!canSubmit" :loading="isSaving" @click="submitRecord">
<span v-text="saveButtonText" />
</t-button>
</div>
<t-popup v-model="registeredAtPickerVisible" placement="bottom">
<t-date-time-picker
v-if="registeredAtPickerVisible"
v-model="registeredAtDraftText"
:start="minimumDateTimeText"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="登记时间"
@cancel="registeredAtPickerVisible = false"
@confirm="confirmRegisteredAt"
/>
</t-popup>
</template>
<script setup lang="ts">
import { AttachmentAPI, Time, type Attachment, useUserStore } from "timi-web";
import { Toast } from "tdesign-mobile-vue";
import CustomerAPI from "@/api/CustomerAPI";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerList from "@/components/CustomerList.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import EventSelector from "@/components/EventSelector.vue";
import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem";
import type { EventRecord } from "@/types/EventRecord";
import { getEventRecordPhotoURL } from "@/utils/eventRecord";
import { permissionCodes } from "@/utils/permission";
import { loadSourceValueRange } from "@/utils/point";
import { compressUploadImageList } from "@/utils/imageCompression";
defineOptions({
name: "EventRecordEdit"
});
const router = useRouter();
const route = useRoute();
const settingStore = useSettingStore();
const userStore = useUserStore();
type EditablePhoto = {
attachment?: Partial<Attachment>;
tempFileId?: string;
url: string;
};
// ---------- 路由权限 ----------
const routeId = computed(() => String(route.params.id || ""));
const isEditMode = computed(() => !!routeId.value);
const pageAccess = computed(() => isEditMode.value ? permissionCodes.eventRecord.update : permissionCodes.eventRecord.create);
const saveButtonText = computed(() => isEditMode.value ? "保存记录" : "提交");
// ---------- 客户 ----------
const customerCode = ref("");
const selectorVisible = ref(false);
const selectedCustomerListVisible = ref(false);
const selectedCustomerIdList = ref<string[]>([]);
const selectedCustomerList = ref<Customer[]>([]);
const selectedCustomer = computed(() => selectedCustomerList.value[0]);
const showSelectedCustomerProfile = computed(() => selectedCustomerList.value.length === 1 && !!selectedCustomer.value?.id);
const customerDisplayText = computed(() => {
if (1 < selectedCustomerList.value.length) {
return `已选择 ${selectedCustomerList.value.length} 位客户`;
}
return selectedCustomer.value?.name || selectedCustomer.value?.code || customerCode.value;
});
async function handleCustomerChange(value: string | string[]): Promise<void> {
selectedCustomerIdList.value = Array.isArray(value) ? value : [value];
await loadSelectedCustomerList();
await loadRecentRecordList();
}
async function loadSelectedCustomerList(): Promise<void> {
const customerList = await Promise.all(selectedCustomerIdList.value.map(id => CustomerAPI.detail(id)));
selectedCustomerList.value = customerList.filter(item => !!item.id);
const firstCustomer = selectedCustomer.value;
customerCode.value = firstCustomer?.code || "";
scanCode.value = firstCustomer?.code || "";
}
function openSelectedCustomerList(): void {
selectedCustomerListVisible.value = true;
}
async function openCustomerSelector(): Promise<void> {
selectedCustomerListVisible.value = false;
await nextTick();
selectorVisible.value = true;
}
async function removeSelectedCustomer(customer: Customer): Promise<void> {
if (!customer.id || isEditMode.value) {
return;
}
selectedCustomerIdList.value = selectedCustomerIdList.value.filter(id => id !== customer.id);
selectedCustomerList.value = selectedCustomerList.value.filter(item => item.id !== customer.id);
await loadRecentRecordList();
}
async function toRecordDetail(id: string): Promise<void> {
if (!id) {
return;
}
await router.push(`/management/registration-event-records/${id}`);
}
async function loadSelectedCustomer(): Promise<void> {
const code = customerCode.value.trim();
if (!code) {
selectedCustomerList.value = [];
selectedCustomerIdList.value = [];
recentRecordList.value = [];
return;
}
const customer = await CustomerAPI.findByCode(code);
if (!customer) {
selectedCustomerList.value = [];
selectedCustomerIdList.value = [];
recentRecordList.value = [];
scanStatus.value = `客户 ${code} 不存在,请先建档。`;
return;
}
if (customer.id) {
selectedCustomerIdList.value = isEditMode.value
? [customer.id]
: [...new Set([...selectedCustomerIdList.value, customer.id])];
selectedCustomerList.value = isEditMode.value
? [customer]
: [...selectedCustomerList.value.filter(item => item.id !== customer.id), customer];
}
await loadRecentRecordList();
}
// ---------- 最近记录 ----------
const recentRecordList = ref<EventRecord[]>([]);
async function loadRecentRecordList(): Promise<void> {
const customerId = selectedCustomer.value?.id;
if (!customerId) {
recentRecordList.value = [];
return;
}
const endAt = Time.toTimestamp(new Date());
const result = await EventRecordAPI.list({
index: 0,
size: 5,
customerId,
beginAt: 0,
endAt
});
recentRecordList.value = (result.list || [])
.sort((left, right) => (right.registeredAt || 0) - (left.registeredAt || 0))
.slice(0, 5);
}
function getRecordEventName(record: EventRecord): string {
return record.event?.name || (record.eventId ? eventNameMap.value.get(record.eventId) : "") || "未知事件";
}
function getRecordPhotoURL(record: EventRecord): string {
return getEventRecordPhotoURL(record);
}
// ---------- 扫码 ----------
const scanCode = ref(customerCode.value);
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。");
let latestDecodedText = "";
async function toggleScanner(): Promise<void> {
scannerVisible.value = !scannerVisible.value;
if (scannerVisible.value) {
latestDecodedText = "";
}
}
async function handleDecodedText(rawText: string): Promise<void> {
const normalizedText = rawText.trim();
if (!normalizedText || normalizedText === latestDecodedText) {
return;
}
latestDecodedText = normalizedText;
scanCode.value = normalizedText;
await settingStore.playScanSuccessSound();
await applyScanCode();
}
async function applyScanCode(): Promise<void> {
customerCode.value = scanCode.value.trim();
scanStatus.value = `已识别 ${customerCode.value},正在加载客户档案。`;
await loadSelectedCustomer();
if (!selectedCustomer.value) {
return;
}
scanStatus.value = `已识别 ${selectedCustomer.value.code || customerCode.value},已自动选中 ${selectedCustomer.value.name || "未命名客户"}。`;
scannerVisible.value = false;
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
// ---------- 事件 ----------
const eventList = ref<EventItem[]>([]);
const selectedEventIdList = ref<string[]>([]);
const eventNameMap = computed(() => {
const map = new Map<string, string>();
for (const item of eventList.value) {
if (item.id) {
map.set(item.id, item.name || "未命名事件");
}
}
return map;
});
function appendRecordEvent(record: EventRecord): void {
if (!record.event?.id || eventList.value.some(item => item.id === record.event?.id)) {
return;
}
eventList.value = [record.event, ...eventList.value];
}
// ---------- 积分规则联动 ----------
const pointValueText = ref("");
const sourceValueRange = ref<[number, number]>();
let sourceValueLoadToken = 0;
const showSourceValueInput = computed(() => (
!isEditMode.value
&& userStore.hasPermissions(permissionCodes.point.sourceValue)
&& !!sourceValueRange.value
));
const sourceValuePlaceholder = computed(() => {
const range = sourceValueRange.value;
return range ? `请输入 ${range[0]}-${range[1]} 的正整数` : "请输入积分";
});
async function loadSourceValueRule(): Promise<void> {
const token = sourceValueLoadToken + 1;
sourceValueLoadToken = token;
sourceValueRange.value = undefined;
if (isEditMode.value || !userStore.hasPermissions(permissionCodes.point.sourceValue) || !selectedEventIdList.value.length) {
return;
}
try {
sourceValueRange.value = await loadSourceValueRange(selectedEventIdList.value);
if (token !== sourceValueLoadToken) {
return;
}
} catch {
sourceValueRange.value = undefined;
}
}
function resolveSourceValue(): number | undefined {
const value = Number(pointValueText.value);
return Number.isSafeInteger(value) && 0 < value ? value : undefined;
}
// ---------- 照片 ----------
const recordPhotoInputRef = ref<HTMLInputElement>();
const photoItemList = ref<EditablePhoto[]>([]);
const isPhotoUploading = ref(false);
const photoList = computed<string[]>({
get() {
return photoItemList.value.map(item => item.url);
},
set(value) {
const urlSet = new Set(value);
photoItemList.value = photoItemList.value.filter(item => urlSet.has(item.url));
}
});
function capturePhoto(): void {
recordPhotoInputRef.value?.click();
}
async function handleRecordPhotoSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const fileList = Array.from(input.files || []);
if (!fileList.length) {
return;
}
isPhotoUploading.value = true;
try {
const compressedFileList = await compressUploadImageList(fileList);
const tempFileList = await AttachmentAPI.upload(compressedFileList);
for (const item of tempFileList) {
if (!item.id) {
continue;
}
photoItemList.value.push({
tempFileId: item.id,
url: AttachmentAPI.getTempReadURL(item.id)
});
}
} finally {
isPhotoUploading.value = false;
input.value = "";
}
}
// ---------- 登记时间 ----------
const minimumDateTimeText = "1970-01-01 00:00";
const registeredAtText = ref("");
const registeredAtDraftText = ref("");
const registeredAtPickerVisible = ref(false);
function openRegisteredAtPicker(): void {
registeredAtDraftText.value = registeredAtText.value || Time.formatDateTime(new Date());
registeredAtPickerVisible.value = true;
}
function confirmRegisteredAt(value?: string | number): void {
registeredAtText.value = String(value || registeredAtDraftText.value);
registeredAtPickerVisible.value = false;
}
function resetRegisteredAt(): void {
registeredAtText.value = "";
registeredAtDraftText.value = "";
}
function buildRegisteredAt(): number {
return Time.toTimestamp(parseDateTime(registeredAtText.value));
}
function parseDateTime(value: string): Date {
if (!value) {
return new Date();
}
const [dateText, timeText = "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, 0, 0);
}
// ---------- 提交 ----------
const note = ref("");
const isSaving = ref(false);
const editingRecord = ref<EventRecord>();
const canSubmit = computed(() => selectedCustomerList.value.some(item => !!item.id) && 0 < selectedEventIdList.value.length);
async function submitRecord(): Promise<void> {
if (!validateForm()) {
return;
}
isSaving.value = true;
try {
if (isEditMode.value) {
await updateRecord();
return;
}
const customerIdList = selectedCustomerList.value.map(customer => customer.id).filter((item): item is string => !!item);
for (const eventId of selectedEventIdList.value) {
await EventRecordAPI.create({
eventId,
customerIdList,
remark: note.value || "客户现场登记",
registeredAt: buildRegisteredAt(),
pointValue: showSourceValueInput.value ? resolveSourceValue() : undefined,
attachmentList: buildTempFileIdList().map(tempFileId => ({
tempFileId,
attachType: "PHOTO"
}))
});
}
selectedCustomerList.value = [];
selectedCustomerIdList.value = [];
note.value = "";
pointValueText.value = "";
photoItemList.value = [];
await loadRecentRecordList();
await settingStore.playRegisterSuccessSound();
Toast.success("登记已提交");
await router.replace("/customers");
} finally {
isSaving.value = false;
}
}
function validateForm(): boolean {
if (!selectedCustomerList.value.some(item => !!item.id)) {
Toast.warning("请先选择或扫码客户");
return false;
}
if (!selectedEventIdList.value.length) {
Toast.warning("请至少选择一个登记事件");
return false;
}
if (showSourceValueInput.value) {
const pointValue = resolveSourceValue();
const range = sourceValueRange.value;
if (!pointValue || !range || pointValue < range[0] || range[1] < pointValue) {
Toast.warning(`自定义积分必须是 ${range?.[0] || 1}-${range?.[1] || 0} 的正整数`);
return false;
}
}
return true;
}
async function updateRecord(): Promise<void> {
if (!editingRecord.value?.id || !editingRecord.value.customerId || !editingRecord.value.eventId) {
Toast.warning("登记事件记录不存在");
return;
}
await EventRecordAPI.update({
...editingRecord.value,
customerId: editingRecord.value.customerId,
eventId: editingRecord.value.eventId,
remark: note.value,
registeredAt: editingRecord.value.registeredAt,
attachmentList: buildPhotoAttachmentList()
});
Toast.success("记录已保存");
await router.replace(`/management/registration-event-records/${editingRecord.value.id}`);
}
function buildTempFileIdList(): string[] {
return photoItemList.value.map(item => item.tempFileId).filter((item): item is string => !!item);
}
function buildPhotoAttachmentList(): Array<Partial<Attachment>> {
return photoItemList.value.map((item) => {
if (item.attachment) {
return item.attachment;
}
return {
tempFileId: item.tempFileId,
attachType: "PHOTO"
};
});
}
// ---------- 页面数据 ----------
async function loadPageData(): Promise<void> {
eventList.value = await EventAPI.listValid();
if (isEditMode.value) {
await loadRecordForEdit();
return;
}
selectedEventIdList.value = eventList.value.slice(0, 1).map(item => item.id).filter((item): item is string => Boolean(item));
}
async function loadRecordForEdit(): Promise<void> {
const record = await EventRecordAPI.detail(routeId.value);
editingRecord.value = record;
appendRecordEvent(record);
selectedCustomerList.value = record.customer ? [record.customer] : [];
customerCode.value = record.customer?.code || record.customerId || "";
selectedCustomerIdList.value = record.customer?.id ? [record.customer.id] : [];
selectedEventIdList.value = record.eventId ? [record.eventId] : [];
registeredAtText.value = record.registeredAt ? Time.formatDateTime(new Date(record.registeredAt)) : "";
note.value = record.remark || "";
pointValueText.value = "";
photoItemList.value = (record.attachmentList || [])
.filter(item => item.attachType === "PHOTO" || !item.attachType)
.filter(item => !!item.id)
.map(item => ({
attachment: item,
url: AttachmentAPI.getReadURL(item.id || "")
}));
await loadRecentRecordList();
}
onMounted(loadPageData);
watch(selectedEventIdList, async () => {
await loadSourceValueRule();
}, { deep: true });
watch(customerCode, async (value) => {
if (!value) {
return;
}
scanCode.value = value;
});
</script>
<style scoped lang="less">
.registration-record-edit {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
flex-direction: column;
.form,
.record-panel {
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
}
.form {
padding: .5rem 0;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
.customer {
.actions {
gap: .5rem;
display: flex;
}
}
.code-scanner-wrapper,
.photo-part {
padding: 1rem;
}
.textarea {
width: 100%;
}
.customer-profile {
margin: 1rem;
}
.registration-event-selector {
padding: 0 1rem;
margin-top: 1rem;
}
.time-actions {
gap: .5rem;
display: flex;
align-items: center;
justify-content: flex-end;
}
.edit-point-tip {
color: #9a3412;
padding: 0 1rem 1rem;
font-size: .8125rem;
line-height: 1.5;
}
.readonly-time {
color: #52607a;
}
}
.record-photo {
width: 3.5rem;
height: 3.5rem;
border-radius: .875rem;
}
.empty {
color: #52607a;
margin: 0 1rem;
padding: 1rem;
text-align: center;
background: #f7faff;
border-radius: 1rem;
}
.save {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
}
.file-input {
display: none;
}
}
.selected-customer-popup {
height: 70vh;
overflow: auto;
padding-top: 1rem;
border-radius: 1.25rem 1.25rem 0 0;
.popup-header {
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
}
}
.list {
margin-top: 1rem;
}
}
:global(html.theme-dark) {
.registration-record-edit {
:deep(.form-item .t-form__label),
.selector-text p,
.empty-block {
color: #94a3b8;
}
.form,
.record-panel,
.selected-customer-popup {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
:deep(.textarea .t-textarea__inner) {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
.empty-block {
background: rgba(30, 41, 59, .9);
}
.selector-text strong {
color: #e2e8f0;
}
.selector-action {
color: #93c5fd;
}
.edit-point-tip,
.readonly-time {
color: #fbbf24;
}
}
}
</style>