init project

This commit is contained in:
Timi
2026-08-01 18:01:23 +08:00
parent 286fa1ba56
commit 11c48bf00c
107 changed files with 19350 additions and 1 deletions
@@ -0,0 +1,630 @@
<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="选择或扫码客户"
>
<template #suffix-icon>
<div class="actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
@click="selectorVisible = true"
>选择</t-button>
<t-button
theme="primary"
size="extra-small"
@click="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="selectedCustomer"
class="customer-profile"
:customer="selectedCustomer"
/>
<registration-event-selector
v-model="selectedRegistrationEventIdList"
class="registration-event-selector"
:registration-event-list="registrationEventList"
required-mark
/>
<t-form-item label="登记时间" content-align="right">
<t-button
theme="primary"
variant="outline"
size="extra-small"
:content="registeredAtText || '此刻'"
@click="openRegisteredAtPicker"
/>
</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" @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="selectedCustomerId"
v-model:visible="selectorVisible"
@change="handleCustomerChange"
/>
<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-model="registeredAtDraftText"
:default-value="registeredAtDraftText"
: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 CustomerSelector from "@/components/CustomerSelector.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.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 selectedCustomerId = ref("");
const selectedCustomer = ref<Customer>();
const registrationEventList = ref<RegistrationEvent[]>([]);
const selectedRegistrationEventIdList = ref<string[]>([]);
const recentRecordList = ref<RegistrationEventRecord[]>([]);
const registeredAtText = ref("");
const registeredAtDraftText = ref("");
const registeredAtPickerVisible = ref(false);
const note = ref("");
const recordPhotoInputRef = ref<HTMLInputElement>();
const photoItemList = ref<EditablePhoto[]>([]);
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 canSubmit = computed(() => !!selectedCustomer.value?.id && 0 < selectedRegistrationEventIdList.value.length);
const customerDisplayText = computed(() => 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);
selectedCustomer.value = record.customer;
customerCode.value = record.customer?.code || record.customerId || "";
selectedCustomerId.value = 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(id: string): Promise<void> {
const customer = await CustomerAPI.detail(id);
selectedCustomer.value = customer;
selectedCustomerId.value = customer.id || "";
customerCode.value = customer.code || "";
scanCode.value = customer.code || "";
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) {
selectedCustomer.value = undefined;
selectedCustomerId.value = "";
recentRecordList.value = [];
return;
}
const customer = await CustomerAPI.nullableDetailByCode(code);
if (!customer) {
selectedCustomer.value = undefined;
selectedCustomerId.value = "";
recentRecordList.value = [];
scanStatus.value = `客户 ${code} 不存在,请先建档。`;
return;
}
selectedCustomer.value = customer;
selectedCustomerId.value = customer.id || "";
await loadRecentRecordList();
}
async function resolveSubmitCustomer(): Promise<Customer | undefined> {
if (!validateForm()) {
return undefined;
}
if (selectedCustomer.value?.id) {
return selectedCustomer.value;
}
showToast("请先选择或扫码已有客户", "warning");
return undefined;
}
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;
}
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)
});
}
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;
}
async function submitRecord(): Promise<void> {
const submitCustomer = await resolveSubmitCustomer();
if (!submitCustomer?.id) {
return;
}
saving.value = true;
try {
if (isEditMode.value) {
await updateRecord();
return;
}
for (const registrationEventId of selectedRegistrationEventIdList.value) {
await RegistrationEventRecordAPI.create({
registrationEventId,
customerId: submitCustomer.id,
remark: note.value || "客户现场登记",
registeredAt: buildRegisteredAt(),
attachmentIdList: buildTempFileIdList()
});
}
note.value = "";
photoItemList.value = [];
await loadRecentRecordList();
await settingStore.playRegisterSuccessSound();
showToast("登记已提交");
await router.replace("/customers");
} finally {
saving.value = false;
}
}
function validateForm(): boolean {
if (!selectedCustomer.value?.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;
}
}
.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;
}
}
:global(html.theme-dark) {
.registration-record-edit {
:deep(.form-item .t-form__label),
.selector-text p,
.empty-block {
color: #94a3b8;
}
.form,
.record-panel {
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>