Files
gao-customer-h5/src/pages/management/RegistrationEventRecordEdit.vue
T
2026-08-03 19:20:51 +08:00

724 lines
20 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="openSelectedCustomerList"
>
<template #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>
<registration-event-selector
v-model="selectedRegistrationEventIdList"
class="registration-event-selector"
:registration-event-list="registrationEventList"
required-mark
/>
<t-form-item 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 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="photoUploading" @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="getRecordRegistrationEventName(item)"
:description="formatTime(item.registeredAt)"
arrow
hover
@click="goRecordDetail(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-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="saving" @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 } from "timi-web";
import { Toast } from "tdesign-mobile-vue";
import CustomerAPI from "@/api/CustomerAPI";
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerList from "@/components/CustomerList.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import RegistrationEventSelector from "@/components/RegistrationEventSelector.vue";
import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { RegistrationEvent } from "@/types/RegistrationEvent";
import type { RegistrationEventRecord } from "@/types/RegistrationEventRecord";
import { getRegistrationEventRecordPhotoURL } from "@/utils/registrationEventRecord";
import { gaoPermissionCodes } from "@/utils/gaoPermission";
import { compressUploadImageList } from "@/utils/imageCompression";
defineOptions({
name: "RegistrationEventRecordEdit"
});
const router = useRouter();
const route = useRoute();
const settingStore = useSettingStore();
const customerCode = ref("");
const scanCode = ref(customerCode.value);
const selectorVisible = ref(false);
const selectedCustomerListVisible = ref(false);
const selectedCustomerIdList = ref<string[]>([]);
const selectedCustomerList = ref<Customer[]>([]);
const registrationEventList = ref<RegistrationEvent[]>([]);
const selectedRegistrationEventIdList = ref<string[]>([]);
const recentRecordList = ref<RegistrationEventRecord[]>([]);
const minimumDateTimeText = "1970-01-01 00:00";
const registeredAtText = ref("");
const registeredAtDraftText = ref("");
const registeredAtPickerVisible = ref(false);
const note = ref("");
const recordPhotoInputRef = ref<HTMLInputElement>();
const photoItemList = ref<EditablePhoto[]>([]);
const photoUploading = ref(false);
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。");
const saving = ref(false);
const editingRecord = ref<RegistrationEventRecord>();
let latestDecodedText = "";
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 ? gaoPermissionCodes.registrationEventRecord.update : gaoPermissionCodes.registrationEventRecord.create);
const saveButtonText = computed(() => isEditMode.value ? "保存记录" : "提交");
const selectedCustomer = computed(() => selectedCustomerList.value[0]);
const canSubmit = computed(() => selectedCustomerList.value.some(item => !!item.id) && 0 < selectedRegistrationEventIdList.value.length);
const customerDisplayText = computed(() => {
if (1 < selectedCustomerList.value.length) {
return `已选择 ${selectedCustomerList.value.length} 位客户`;
}
return selectedCustomer.value?.name || selectedCustomer.value?.code || customerCode.value;
});
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));
}
});
const registrationEventNameMap = computed(() => {
const map = new Map<string, string>();
for (const item of registrationEventList.value) {
if (item.id) {
map.set(item.id, item.name || "未命名事件");
}
}
return map;
});
// ---------- 页面数据 ----------
async function loadPageData(): Promise<void> {
registrationEventList.value = await RegistrationEventAPI.enabledList();
if (isEditMode.value) {
await loadRecordForEdit();
return;
}
selectedRegistrationEventIdList.value = registrationEventList.value.slice(0, 1).map(item => item.id).filter((item): item is string => Boolean(item));
}
async function loadRecordForEdit(): Promise<void> {
const record = await RegistrationEventRecordAPI.detail(routeId.value);
editingRecord.value = record;
appendRecordRegistrationEvent(record);
selectedCustomerList.value = record.customer ? [record.customer] : [];
customerCode.value = record.customer?.code || record.customerId || "";
selectedCustomerIdList.value = record.customer?.id ? [record.customer.id] : [];
selectedRegistrationEventIdList.value = record.registrationEventId ? [record.registrationEventId] : [];
registeredAtText.value = record.registeredAt ? Time.formatDateTime(new Date(record.registeredAt)) : "";
note.value = record.remark || "";
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();
}
function appendRecordRegistrationEvent(record: RegistrationEventRecord): void {
if (!record.registrationEvent?.id || registrationEventList.value.some(item => item.id === record.registrationEvent?.id)) {
return;
}
registrationEventList.value = [record.registrationEvent, ...registrationEventList.value];
}
// ---------- 扫码 ----------
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;
}
// ---------- 客户 ----------
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 goRecordDetail(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.nullableDetailByCode(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();
}
async function loadRecentRecordList(): Promise<void> {
const customerId = selectedCustomer.value?.id;
if (!customerId) {
recentRecordList.value = [];
return;
}
const endRegisteredAt = Time.toTimestamp(new Date());
recentRecordList.value = (await RegistrationEventRecordAPI.rangeList({
customerId,
beginRegisteredAt: 0,
endRegisteredAt
}))
.sort((left, right) => (right.registeredAt || 0) - (left.registeredAt || 0))
.slice(0, 5);
}
function getRecordRegistrationEventName(record: RegistrationEventRecord): string {
return record.registrationEvent?.name || (record.registrationEventId ? registrationEventNameMap.value.get(record.registrationEventId) : "") || "未知事件";
}
function getRecordPhotoURL(record: RegistrationEventRecord): string {
return getRegistrationEventRecordPhotoURL(record);
}
// ---------- 照片 ----------
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;
}
photoUploading.value = true;
try {
const compressedFileList = await compressUploadImageList(fileList);
const tempFileList = await AttachmentAPI.uploadTemp(compressedFileList);
for (const item of tempFileList) {
if (!item.id) {
continue;
}
photoItemList.value.push({
tempFileId: item.id,
url: AttachmentAPI.getTempReadURL(item.id)
});
}
} finally {
photoUploading.value = false;
input.value = "";
}
}
// ---------- 提交 ----------
function openRegisteredAtPicker(): void {
registeredAtDraftText.value = registeredAtText.value || Time.formatDateTime(new Date());
registeredAtPickerVisible.value = true;
}
function confirmRegisteredAt(): void {
registeredAtText.value = registeredAtDraftText.value;
registeredAtPickerVisible.value = false;
}
function resetRegisteredAt(): void {
registeredAtText.value = "";
registeredAtDraftText.value = "";
}
async function submitRecord(): Promise<void> {
if (!validateForm()) {
return;
}
saving.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 registrationEventId of selectedRegistrationEventIdList.value) {
await RegistrationEventRecordAPI.create({
registrationEventId,
customerIdList,
remark: note.value || "客户现场登记",
registeredAt: buildRegisteredAt(),
attachmentIdList: buildTempFileIdList()
});
}
selectedCustomerList.value = [];
selectedCustomerIdList.value = [];
note.value = "";
photoItemList.value = [];
await loadRecentRecordList();
await settingStore.playRegisterSuccessSound();
showToast("登记已提交");
await router.replace("/customers");
} finally {
saving.value = false;
}
}
function validateForm(): boolean {
if (!selectedCustomerList.value.some(item => !!item.id)) {
showToast("请先选择或扫码客户", "warning");
return false;
}
if (!selectedRegistrationEventIdList.value.length) {
showToast("请至少选择一个登记事件", "warning");
return false;
}
return true;
}
async function updateRecord(): Promise<void> {
const submitCustomer = selectedCustomer.value;
if (!editingRecord.value?.id || !submitCustomer?.id) {
showToast("登记事件记录不存在", "warning");
return;
}
await RegistrationEventRecordAPI.update({
...editingRecord.value,
customerId: submitCustomer.id,
registrationEventId: selectedRegistrationEventIdList.value[0],
remark: note.value,
registeredAt: buildRegisteredAt(),
attachmentList: buildPhotoAttachmentList()
});
showToast("记录已保存");
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"
};
});
}
function buildRegisteredAt(): number {
return Time.toTimestamp(parseDateTime(registeredAtText.value));
}
function showToast(message: string, theme: "success" | "warning" = "success"): void {
Toast({
theme,
direction: "row",
message
});
}
function formatTime(value?: number): string {
if (!value) {
return "-";
}
const date = Time.toDateObject(value);
if (!date) {
return "-";
}
return Time.formatDateTime(date);
}
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);
}
onMounted(loadPageData);
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;
}
}
.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;
}
}
}
</style>