Files
gao-customer-h5/src/pages/home/QuickEntryPage.vue
T
2026-08-22 01:08:23 +08:00

869 lines
23 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-width="0">
<div class="photo">
<photo-scroller
title="客户照片列表"
v-model="customerPhotoList"
:photo-items="customerPhotoItemList"
:add-actions="customerPhotoAddActions"
:loading="customerPhotoUploading"
@add-photo="openCustomerPhotoPicker('PHOTO')"
@add-point-card="openCustomerPhotoPicker('POINT_CARD')"
@add-other="openCustomerPhotoPicker('OTHER')"
/>
<div class="tips orange" v-if="!hasCustomerDeletePermission">当前账户权限新增客户需要上传照片和积分卡</div>
</div>
</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" v-text="sourceValueAction === 'CONSUME' ? '消耗积分' : '发放积分'" />
<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"
>
</div>
<customer-selector
v-model="selectedCustomerId"
v-model:visible="selectorVisible"
@change="handleCustomerChange"
/>
</template>
<script setup lang="ts">
import { AttachmentAPI, Toolkit, useUserStore, type Attachment } from "timi-web";
import type { TdInputProps } from "tdesign-mobile-vue";
import { Message } from "@/utils/message";
import CustomerAPI from "@/api/CustomerAPI";
import EventAPI from "@/api/EventAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerSelector from "@/components/CustomerSelector.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 type { PointAction } from "@/types/Point";
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
import { loadSourceValueConfig, type SourceValueConfig } from "@/utils/point";
import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompression";
import EventSelector from "@/components/EventSelector.vue";
import EventRecordAPI from "@/api/EventRecordAPI.ts";
import { useNavBarStore } from "@/store/navBarStore";
defineOptions({
name: "QuickEntryPage"
});
const settingStore = useSettingStore();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const hasCustomerDeletePermission = computed(() => userStore.hasPermissions(permissionCodes.customer.delete));
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 sourceValueConfig = ref<SourceValueConfig>();
const sourceValueAction = ref<PointAction>("EARN");
const showSourceValueInput = computed(() => (
userStore.hasPermissions(permissionCodes.point.sourceValue)
&& !!sourceValueConfig.value
));
const sourceValuePlaceholder = computed(() => {
const range = sourceValueConfig.value?.range;
return range ? `请输入 ${range[0]}-${range[1]} 的数量` : "请输入数量";
});
async function loadSourceValueRule(): Promise<void> {
if (!selectedEventIdList.value.length || !userStore.hasPermissions(permissionCodes.point.sourceValue)) {
sourceValueConfig.value = undefined;
return;
}
try {
sourceValueConfig.value = await loadSourceValueConfig(selectedEventIdList.value);
sourceValueAction.value = sourceValueConfig.value?.action || "EARN";
} catch {
sourceValueConfig.value = undefined;
}
}
function resolveSourceValue(): number | undefined {
const value = Number(pointValueText.value);
return Number.isSafeInteger(value) && 0 < value ? sourceValueAction.value === "CONSUME" ? -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()) {
Message.warning(`${event.name || "当前事件"}不能提交数值`);
return false;
}
continue;
}
const numberValue = resolveNumberValue(eventId);
if (valueText.trim() && numberValue === undefined) {
Message.warning(`${event.name || "当前事件"}数值格式不正确,请检查单位和小数位`);
return false;
}
if (event.requireValue && numberValue === undefined) {
Message.warning(`请填写${event.name || "当前事件"}数值`);
return false;
}
}
return true;
}
const note = ref("");
const saving = ref(false);
let latestDecodedText = "";
const inputCode = computed(() => currentCode.value || scanCode.value || "");
type QuickPhoto = {
tempFileId: string;
url: string;
};
type CustomerAttachmentType = "PHOTO" | "POINT_CARD" | "OTHER";
type QuickCustomerPhoto = {
attachment?: Attachment;
attachType: CustomerAttachmentType;
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 customerPhotoAddActions = [
{
icon: "user-avatar",
text: "拍照片",
emit: "add-photo",
theme: "primary"
},
{
icon: "cardmembership",
text: "拍积分卡",
emit: "add-point-card",
theme: "primary"
},
{
icon: "file-attachment",
text: "其他文件",
emit: "add-other",
theme: "default"
}
] as const;
const customerPhotoItemList = ref<QuickCustomerPhoto[]>([]);
const customerPhotoInputRef = ref<HTMLInputElement>();
const customerPhotoUploading = ref(false);
const customerPhotoPickerTarget = ref<CustomerAttachmentType>("PHOTO");
const customerPhotoList = computed<string[]>({
get() {
return customerPhotoItemList.value.map(item => item.url);
},
set(value) {
const urlSet = new Set(value);
customerPhotoItemList.value = customerPhotoItemList.value.filter(item => urlSet.has(item.url));
}
});
function openCustomerPhotoPicker(target: CustomerAttachmentType): void {
customerPhotoPickerTarget.value = target;
customerPhotoInputRef.value?.click();
}
function syncCustomerPhoto(customer?: Customer): void {
customerPhotoItemList.value = (customer?.attachmentList || [])
.filter(item => !!item.id)
.map(item => ({
attachment: item,
attachType: normalizeCustomerAttachmentType(item.attachType),
url: AttachmentAPI.getReadURL(item.id || "")
}));
}
function normalizeCustomerAttachmentType(attachType?: string): CustomerAttachmentType {
if (attachType === "POINT_CARD" || attachType === "OTHER") {
return attachType;
}
return "PHOTO";
}
function validateCustomerPhotoList(): boolean {
if (hasCustomerDeletePermission.value) {
return true;
}
const attachTypeSet = new Set(customerPhotoItemList.value.map(item => item.attachType));
if (!attachTypeSet.has("PHOTO")) {
Message.warning("请上传客户照片");
return false;
}
if (!attachTypeSet.has("POINT_CARD")) {
Message.warning("请上传积分卡照片");
return false;
}
return true;
}
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) {
Message.warning("客户照片上传失败");
return;
}
customerPhotoItemList.value.push({
attachType: customerPhotoPickerTarget.value,
tempFileId: tempFile.id,
url: 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> {
if (saving.value) {
return;
}
if (customerPhotoUploading.value || recordPhotoUploading.value) {
Message.warning("照片正在上传,请稍候");
return;
}
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 || "快速补录登记"
}))
};
// 客户照片
const customerAttachmentList = buildCustomerAttachmentList();
if (customerAttachmentList.length) {
customer.attachmentList = customerAttachmentList;
}
// 记录照片
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();
Message.success("登记已提交");
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;
customerPhotoItemList.value = [];
recordPhotoItemList.value = [];
pointValueText.value = "";
for (const eventId of Object.keys(numberValueTextMap)) {
delete numberValueTextMap[eventId];
}
refreshInputTips();
}
function buildCustomerAttachmentList(): Attachment[] {
return customerPhotoItemList.value.flatMap(item => {
if (item.attachment) {
return [item.attachment];
}
if (item.tempFileId) {
return [{
attachType: item.attachType,
tempFileId: item.tempFileId
}];
}
return [];
});
}
function validateForm(): string {
const customerCode = (currentCode.value || scanCode.value).trim();
if (!customerCode) {
Message.warning("请先输入或扫码客户编码");
return "";
}
if (!customerName.value.trim()) {
Message.warning("请填写客户姓名");
return "";
}
if (!validateCustomerPhotoList()) {
return "";
}
if (!validateNumberValueList()) {
return "";
}
if (showSourceValueInput.value) {
const pointValue = resolveSourceValue();
const range = sourceValueConfig.value?.range;
if (!pointValue || !range || Math.abs(pointValue) < range[0] || range[1] < Math.abs(pointValue)) {
Message.warning(`积分数量必须是 ${range?.[0] || 1}-${range?.[1] || 0} 的正整数`);
return "";
}
}
return customerCode;
}
// ---------- 初始化 ----------
onMounted(async () => {
navBarStore.clearRight();
if (userStore.hasPermissions(permissionCodes.eventRecord.create)) {
navBarStore.setRightText("保存", "var(--td-brand-color)", saveRecord);
}
eventList.value = await EventAPI.listValid();
});
onUnmounted(() => {
navBarStore.clearRight();
});
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;
}
}
.photo {
width: 100%;
.tips {
font-size: .875rem;
text-align: center;
margin-top: .5rem;
}
}
}
.file-input {
display: none;
}
}
: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) {
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>