Files
gao-customer-h5/src/pages/home/QuickEntryPage.vue
T
2026-08-14 18:26:11 +08:00

775 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="permissionCodes.eventRecord.create" class="quick-entry-page">
<t-form class="form" label-align="left" label-width="6rem">
<t-form-item label="客户编码" required-mark>
<t-input
v-model.trim="scanCode"
placeholder="输入或扫描二维码"
borderless
@blur="applyScanCode"
:tips="scanResult"
:status="scanStatus"
>
<template #suffix-icon>
<t-button
theme="primary"
size="extra-small"
@click="toggleScanner"
>
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
</t-button>
</template>
</t-input>
</t-form-item>
<div v-if="scannerVisible" class="code-scanner-wrapper">
<code-scanner
visible
:status-text="scannerTips"
active-text="正在识别客户编码,请将二维码移入取景框"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/>
</div>
<t-form-item label="客户姓名" required-mark>
<t-input
v-model.trim="customerName"
placeholder="输入或选择已存档客户"
borderless
@blur="applyCustomerName"
:tips="customerTips"
:status="customerStatus"
>
<template #suffix-icon>
<t-button
theme="primary"
size="extra-small"
@click="selectorVisible = true"
>选择</t-button>
</template>
</t-input>
</t-form-item>
<t-form-item label="客户照片">
<photo-field
v-model="customerPhoto"
alt-text="客户照片"
:loading="customerPhotoUploading"
:preview="false"
@add="openCustomerPhotoPicker"
@clear="customerPhotoTempFileId = ''"
/>
</t-form-item>
<t-form-item label-width="0">
<event-selector
v-model="selectedEventIdList"
class="registration-event-selector"
:event-list="eventList"
/>
</t-form-item>
<div v-if="selectedNumberEventList.length" class="event-value-list">
<t-form-item
v-for="item in selectedNumberEventList"
:key="item.id"
:label="`${item.name || '未命名事件'}数值`"
:required-mark="item.requireValue === true"
label-align="top"
>
<t-input
v-model.trim="numberValueTextMap[item.id || '']"
type="number"
align="right"
borderless
:placeholder="getNumberValuePlaceholder(item)"
/>
</t-form-item>
</div>
<div v-if="showSourceValueInput" class="source-value-panel">
<div class="source-value-title">自定义积分</div>
<t-input
v-model.trim="pointValueText"
type="number"
align="right"
borderless
:placeholder="sourceValuePlaceholder"
/>
</div>
<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
title="登记事件照片"
v-model="recordPhotoList"
:loading="recordPhotoUploading"
@add="openRecordPhotoPicker"
/>
</div>
</t-form>
<input
ref="customerPhotoInputRef"
class="file-input"
type="file"
accept="image/*"
@change="handleCustomerPhotoSelected"
>
<input
ref="recordPhotoInputRef"
class="file-input"
type="file"
accept="image/*"
multiple
@change="handleRecordPhotoSelected"
>
<t-button theme="primary" class="save" :disabled="!canSubmit" :loading="saving" @click="saveRecord">提交</t-button>
</div>
<customer-selector
v-model="selectedCustomerId"
v-model:visible="selectorVisible"
@change="handleCustomerChange"
/>
</template>
<script setup lang="ts">
import { AttachmentAPI, Toolkit, useUserStore } from "timi-web";
import type { TdInputProps } from "tdesign-mobile-vue";
import { Toast } from "tdesign-mobile-vue";
import CustomerAPI from "@/api/CustomerAPI";
import EventAPI from "@/api/EventAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PhotoField from "@/components/PhotoField.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem";
import { getCustomerPhotoURL } from "@/utils/customer";
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
import { loadSourceValueRange } from "@/utils/point";
import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompression";
import EventSelector from "@/components/EventSelector.vue";
import EventRecordAPI from "@/api/EventRecordAPI.ts";
defineOptions({
name: "QuickEntryPage"
});
const settingStore = useSettingStore();
const router = useRouter();
const userStore = useUserStore();
const eventList = ref<EventItem[]>([]);
const selectedEventIdList = ref<string[]>([]);
const numberValueTextMap = reactive<Record<string, string>>({});
const selectedNumberEventList = computed(() => selectedEventIdList.value
.map(eventId => eventList.value.find(item => item.id === eventId))
.filter((item): item is EventItem => item?.valueType === "NUMBER"));
const pointValueText = ref("");
const sourceValueRange = ref<[number, number]>();
const showSourceValueInput = computed(() => (
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> {
if (!selectedEventIdList.value.length || !userStore.hasPermissions(permissionCodes.point.sourceValue)) {
sourceValueRange.value = undefined;
return;
}
try {
sourceValueRange.value = await loadSourceValueRange(selectedEventIdList.value);
} catch {
sourceValueRange.value = undefined;
}
}
function resolveSourceValue(): number | undefined {
const value = Number(pointValueText.value);
return Number.isSafeInteger(value) && 0 < value ? value : undefined;
}
function getNumberValuePlaceholder(event: EventItem): string {
return getEventNumberValuePlaceholder(event);
}
function resolveNumberValue(eventId: string): number | undefined {
const valueText = numberValueTextMap[eventId] || "";
if (!valueText.trim()) {
return undefined;
}
const event = eventList.value.find(item => item.id === eventId);
return event ? resolveEventNumberValue(event, valueText) : undefined;
}
function validateNumberValueList(): boolean {
for (const eventId of selectedEventIdList.value) {
const event = eventList.value.find(item => item.id === eventId);
if (!event) {
continue;
}
const valueText = numberValueTextMap[eventId] || "";
if (event.valueType !== "NUMBER") {
if (valueText.trim()) {
showToast(`${event.name || "当前事件"}不能提交数值`, "warning");
return false;
}
continue;
}
const numberValue = resolveNumberValue(eventId);
if (valueText.trim() && numberValue === undefined) {
showToast(`${event.name || "当前事件"}数值格式不正确,请检查单位和小数位`, "warning");
return false;
}
if (event.requireValue && numberValue === undefined) {
showToast(`请填写${event.name || "当前事件"}数值`, "warning");
return false;
}
}
return true;
}
const note = ref("");
const saving = ref(false);
const canSubmit = computed(() => !!inputCode.value && !!customerName.value.trim() && !customerPhotoUploading.value && !recordPhotoUploading.value);
let latestDecodedText = "";
const inputCode = computed(() => currentCode.value || scanCode.value || "");
type QuickPhoto = {
tempFileId: string;
url: string;
};
// ---------- 扫码 ----------
const scanCode = ref("");
const currentCode = ref("");
const scanResult = ref("");
const scanStatus = ref<TdInputProps["status"]>("warning");
const scannerVisible = ref(false);
const scannerTips = ref("");
const boundCodeCustomer = ref<Customer>();
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> {
const normalizedCode = scanCode.value.trim();
if (!normalizedCode) {
return;
}
currentCode.value = normalizedCode;
await checkCustomerCode(normalizedCode);
applyBoundCustomerIfNeeded();
refreshInputTips();
}
function handleScanStatusChange(value: string): void {
scannerTips.value = value;
}
// ---------- 补录客户 ----------
const customerName = ref("");
const currentCustomer = ref<Customer>();
const customerTips = ref("");
const customerStatus = ref<TdInputProps["status"]>("warning");
const selectorVisible = ref(false);
const selectedCustomerId = ref("");
const checkedCode = ref("");
/**
* 检验新客户编码
*
* @param code
*/
async function ensureNewCustomerCode(code: string): Promise<boolean> {
await checkCustomerCode(code);
applyBoundCustomerIfNeeded();
refreshInputTips();
return !isCodeBoundToOtherCustomer();
}
// 检验编码
async function ensureCodeChecked(): Promise<void> {
const code = inputCode.value;
if (!code || checkedCode.value === code) {
return;
}
// 检验编码客户
await checkCustomerCode(code);
}
/**
* 选择客户
*
* @param id 客户 ID
*/
async function handleCustomerChange(id: string): Promise<void> {
const customer = await CustomerAPI.detail(id);
currentCustomer.value = customer;
selectedCustomerId.value = customer.id || "";
customerName.value = customer.name || "";
syncCustomerPhoto(customer);
customerTips.value = "";
if (!inputCode.value && customer.code) {
// 补充编码
fillCodeFromCustomer(customer);
return;
}
if (inputCode.value) {
await ensureCodeChecked();
}
refreshInputTips();
}
async function applyCustomerName(): Promise<void> {
const normalizedName = customerName.value.trim();
if (!normalizedName) {
currentCustomer.value = undefined;
selectedCustomerId.value = "";
customerTips.value = "";
refreshInputTips();
return;
}
const matched = await CustomerAPI.findByName(normalizedName) || undefined;
if (!matched) {
currentCustomer.value = undefined;
selectedCustomerId.value = "";
customerTips.value = "客户暂无档案,提交登记时会自动新开客户档案";
refreshInputTips();
return;
}
currentCustomer.value = matched;
selectedCustomerId.value = matched.id || "";
customerName.value = matched.name || normalizedName;
syncCustomerPhoto(matched);
customerTips.value = "已匹配客户";
if (!inputCode.value && matched.code) {
fillCodeFromCustomer(matched);
return;
}
if (inputCode.value) {
await ensureCodeChecked();
}
refreshInputTips();
}
/**
* 补充客户编码
*
* @param customer 客户
*/
function fillCodeFromCustomer(customer: Customer): void {
const code = customer.code || "";
if (!code) {
// 未绑定编码
refreshInputTips();
return;
}
scanCode.value = code;
currentCode.value = code;
checkedCode.value = code;
boundCodeCustomer.value = customer;
refreshInputTips();
}
/**
* 检验编码
*
* @param code 编码
*/
async function checkCustomerCode(code: string): Promise<void> {
const normalizedCode = code.trim();
if (!normalizedCode) {
checkedCode.value = "";
boundCodeCustomer.value = undefined;
refreshInputTips();
return;
}
checkedCode.value = normalizedCode;
boundCodeCustomer.value = await CustomerAPI.findByCode(normalizedCode) || undefined;
scannerVisible.value = false;
}
function applyBoundCustomerIfNeeded(): void {
const boundCustomer = boundCodeCustomer.value;
if (!boundCustomer?.id || currentCustomer.value?.id || customerName.value.trim()) {
return;
}
currentCustomer.value = boundCustomer;
selectedCustomerId.value = boundCustomer.id;
customerName.value = boundCustomer.name || "";
syncCustomerPhoto(boundCustomer);
customerTips.value = "";
}
// 刷新提示
function refreshInputTips(): void {
scanStatus.value = "warning";
customerStatus.value = "warning";
const code = inputCode.value;
if (!code) {
scanResult.value = "";
return;
}
if (checkedCode.value !== code) {
scanResult.value = "";
return;
}
if (isCodeBoundToOtherCustomer()) {
scanResult.value = `编码已绑定客户 ${getCustomerName(boundCodeCustomer.value)},请使用新的编码`;
return;
}
if (boundCodeCustomer.value?.id) {
scanResult.value = "";
return;
}
if (currentCustomer.value?.code && currentCustomer.value.code !== code) {
scanResult.value = `客户已有编码 ${currentCustomer.value.code},将会替换新的编码`;
scanStatus.value = "error";
return;
}
scanResult.value = currentCustomer.value?.id ? "提交将会使用此编码" : "编码可用";
}
function isCodeBoundToOtherCustomer(): boolean {
const boundCustomerId = boundCodeCustomer.value?.id;
if (!boundCustomerId) {
return false;
}
return currentCustomer.value?.id !== boundCustomerId;
}
function getCustomerName(customer?: Customer): string {
return customer?.name || customer?.code || "未命名客户";
}
// ---------- 客户照片 ----------
const customerPhoto = ref("");
const customerPhotoTempFileId = ref("");
const customerPhotoInputRef = ref<HTMLInputElement>();
const customerPhotoUploading = ref(false);
function openCustomerPhotoPicker(): void {
customerPhotoInputRef.value?.click();
}
function syncCustomerPhoto(customer?: Customer): void {
customerPhotoTempFileId.value = "";
customerPhoto.value = getCustomerPhotoURL(customer);
}
async function handleCustomerPhotoSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
customerPhotoUploading.value = true;
try {
const compressedFile = await compressUploadImage(file);
const [tempFile] = await AttachmentAPI.upload(compressedFile);
if (!tempFile?.id) {
showToast("客户照片上传失败", "warning");
return;
}
customerPhotoTempFileId.value = tempFile.id;
customerPhoto.value = AttachmentAPI.getTempReadURL(tempFile.id);
} finally {
customerPhotoUploading.value = false;
input.value = "";
}
}
// ---------- 记录照片 ----------
const recordPhotoInputRef = ref<HTMLInputElement>();
const recordPhotoUploading = ref(false);
const recordPhotoItemList = ref<QuickPhoto[]>([]);
function openRecordPhotoPicker(): void {
recordPhotoInputRef.value?.click();
}
const recordPhotoList = computed<string[]>({
get() {
return recordPhotoItemList.value.map(item => item.url);
},
set(value) {
const urlSet = new Set(value);
recordPhotoItemList.value = recordPhotoItemList.value.filter(item => urlSet.has(item.url));
}
});
async function handleRecordPhotoSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const fileList = Array.from(input.files || []);
if (!fileList.length) {
return;
}
recordPhotoUploading.value = true;
try {
const compressedFileList = await compressUploadImageList(fileList);
const tempFileList = await AttachmentAPI.upload(compressedFileList);
for (let i = 0; i < tempFileList.length; i++) {
const tempFile = tempFileList[i];
if (!tempFile.id) {
continue;
}
recordPhotoItemList.value.push({
tempFileId: tempFile.id,
url: AttachmentAPI.getTempReadURL(tempFile.id)
});
}
} finally {
recordPhotoUploading.value = false;
input.value = "";
}
}
// ---------- 提交 ----------
async function saveRecord(): Promise<void> {
const customerCode = validateForm();
if (!customerCode) {
return;
}
saving.value = true;
try {
await ensureCustomerMatched();
if (!await ensureNewCustomerCode(customerCode)) {
return;
}
const customer: Customer = {
id: currentCustomer.value?.id,
code: customerCode,
name: customerName.value.trim(),
eventRecordList: selectedEventIdList.value.map(eventId => ({
eventId,
numberValue: resolveNumberValue(eventId),
pointValue: showSourceValueInput.value ? resolveSourceValue() : undefined,
remark: note.value || "快速补录登记"
}))
};
// 客户照片
if (customerPhotoTempFileId.value) {
customer.attachmentList = [{
tempFileId: customerPhotoTempFileId.value,
attachType: "PHOTO"
}];
}
// 记录照片
if (Toolkit.isNotEmpty(customer.eventRecordList)) {
customer.eventRecordList![0].attachmentList = recordPhotoItemList.value.map(item => ({
tempFileId: item.tempFileId,
attachType: "PHOTO"
}));
}
await EventRecordAPI.createWithCustomer(customer);
resetFormAfterSubmit();
await settingStore.playRegisterSuccessSound();
showToast("登记已提交");
await router.replace("/customers");
} finally {
saving.value = false;
}
}
async function ensureCustomerMatched(): Promise<void> {
if (currentCustomer.value?.id) {
return;
}
await applyCustomerName();
}
function resetFormAfterSubmit(): void {
note.value = "";
scanCode.value = "";
currentCode.value = "";
customerName.value = "";
selectedCustomerId.value = "";
currentCustomer.value = undefined;
boundCodeCustomer.value = undefined;
customerPhoto.value = "";
customerPhotoTempFileId.value = "";
recordPhotoItemList.value = [];
pointValueText.value = "";
for (const eventId of Object.keys(numberValueTextMap)) {
delete numberValueTextMap[eventId];
}
refreshInputTips();
}
function validateForm(): string {
const customerCode = (currentCode.value || scanCode.value).trim();
if (!customerCode) {
showToast("请先输入或扫码客户编码", "warning");
return "";
}
if (!customerName.value.trim()) {
showToast("请填写客户姓名", "warning");
return "";
}
if (!validateNumberValueList()) {
return "";
}
if (showSourceValueInput.value) {
const pointValue = resolveSourceValue();
const range = sourceValueRange.value;
if (!pointValue || !range || pointValue < range[0] || range[1] < pointValue) {
showToast(`自定义积分必须是 ${range?.[0] || 1}-${range?.[1] || 0} 的正整数`, "warning");
return "";
}
}
return customerCode;
}
function showToast(message: string, theme: "success" | "warning" = "success"): void {
Toast({
theme,
direction: "row",
message
});
}
// ---------- 初始化 ----------
onMounted(async () => {
eventList.value = await EventAPI.listValid();
});
watch(selectedEventIdList, async () => {
await loadSourceValueRule();
}, { deep: true });
watch(scanCode, (value) => {
const normalizedCode = value.trim();
currentCode.value = normalizedCode;
if (checkedCode.value && checkedCode.value !== normalizedCode) {
checkedCode.value = "";
boundCodeCustomer.value = undefined;
}
refreshInputTips();
});
</script>
<style scoped lang="less">
.quick-entry-page {
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
.form {
padding: .5rem 0;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
.code-scanner-wrapper,
.photo-part {
padding: 1rem;
}
.textarea {
width: 100%;
}
.event-value-list {
padding: 0 1rem;
}
.source-value-panel {
padding: .75rem 1rem 1rem;
.source-value-title {
font-weight: 700;
}
.t-input {
margin-top: .5rem;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
}
}
}
.file-input {
display: none;
}
.empty-block {
padding: 1rem;
color: #52607a;
text-align: center;
background: #f8fbff;
border-radius: 1rem;
}
.save {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
}
}
:global(html.theme-dark) {
.quick-entry-page {
:deep(.form-item .t-form__label) {
color: #94a3b8;
}
.form {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
.registration-event-selector {
:deep(.item),
.empty-block {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
}
.source-value-panel .t-input {
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
:deep(.textarea .t-textarea__inner) {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
}
}
</style>