refactor event

This commit is contained in:
Timi
2026-08-06 20:53:22 +08:00
parent 1243068fc3
commit 9df6dfaf59
37 changed files with 1584 additions and 2316 deletions
+45 -96
View File
@@ -1,36 +1,10 @@
import { axios, type Page, type PageResult } from "timi-web";
import type { Customer, CustomerDailyStatView, CustomerGenderStatView, CustomerInviteUpdateReq, CustomerSearchReq, CustomerTrendStatReq, QuickRegisterReq } from "@/types/Customer";
import type { EventRecord } from "@/types/EventRecord";
import type { Customer, CustomerDailyStatView, CustomerGenderStatView, CustomerTrendStatReq } from "@/types/Customer";
const BASE_URI = "/gao/customer";
async function list(req: Page<Customer>): Promise<PageResult<Customer>> {
return await axios.post(`${BASE_URI}/list`, req);
}
async function search(req: CustomerSearchReq): Promise<Customer[]> {
const [codeMatched, nameMatched] = await Promise.all([
req.keyword ? nullableDetailByCode(req.keyword) : undefined,
req.keyword ? nullableDetailByName(req.keyword) : undefined
]);
return [codeMatched, nameMatched].filter((item, index, list): item is Customer => !!item && index === list.findIndex(current => current?.id === item.id));
}
async function searchPage(req: Page<CustomerSearchReq>): Promise<PageResult<Customer>> {
return await axios.post(`${BASE_URI}/list`, {
...req,
equalsExample: buildCustomerListExample(req.equalsExample)
});
}
async function exportExcel(req: CustomerSearchReq): Promise<Blob> {
return await axios.post(`${BASE_URI}/export`, {
index: 0,
size: 100000,
equalsExample: buildCustomerListExample(req)
}, {
responseType: "blob"
});
async function list(page: Page<Customer>): Promise<PageResult<Customer>> {
return await axios.post(`${BASE_URI}/list`, page);
}
async function detail(id: string): Promise<Customer> {
@@ -39,52 +13,12 @@ async function detail(id: string): Promise<Customer> {
});
}
async function nullableDetailByCode(code: string): Promise<Customer | null> {
return await axios.post(`${BASE_URI}/find/code`, undefined, {
params: { code }
});
async function create(customer: Customer): Promise<string> {
return await axios.post(`${BASE_URI}/create`, customer);
}
async function nullableDetailByName(name: string): Promise<Customer | null> {
return await axios.post(`${BASE_URI}/find/name`, undefined, {
params: { name }
});
}
async function invitedList(introducerCustomerId: string): Promise<Customer[]> {
return await axios.post(`${BASE_URI}/invited/list`, undefined, {
params: { introducerCustomerId }
});
}
async function create(req: Customer): Promise<Customer> {
const id = await axios.post(`${BASE_URI}/create`, req) as unknown as string;
return {
...req,
id
};
}
async function quickRegister(req: QuickRegisterReq): Promise<EventRecord> {
const customer = await nullableDetailByCode(req.customerCode);
if (!customer?.id) {
throw new Error("客户不存在,无法登记");
}
const record: EventRecord = {
customerId: customer.id,
eventId: req.eventId,
remark: req.remark,
attachmentIdList: req.attachmentIdList
};
await axios.post("/gao/event/record/create", {
...record,
eventId: req.eventId
});
return record;
}
async function update(req: Customer): Promise<void> {
return await axios.post(`${BASE_URI}/update`, req);
async function update(page: Customer): Promise<void> {
return await axios.post(`${BASE_URI}/update`, page);
}
async function remove(id: string): Promise<void> {
@@ -93,15 +27,41 @@ async function remove(id: string): Promise<void> {
});
}
async function updateInvite(req: CustomerInviteUpdateReq): Promise<void> {
if (!req.introducerCustomerId) {
await deleteInvite(req.customerId);
return;
async function findById(id: string): Promise<Customer | undefined> {
return await axios.post(`${BASE_URI}/find/id`, null, {
params: { id }
});
}
async function findByCode(code: string): Promise<Customer | undefined> {
return await axios.post(`${BASE_URI}/find/code`, null, {
params: { code }
});
}
async function findByName(name: string): Promise<Customer | undefined> {
return await axios.post(`${BASE_URI}/find/name`, null, {
params: { name }
})
}
async function exportXSSF(page: Page<Customer>): Promise<Blob> {
return await axios.post(`${BASE_URI}/export`, page, {
responseType: "blob"
});
}
async function listInvited(introducerCustomerId: string): Promise<Customer[]> {
return await axios.post(`${BASE_URI}/invited/list`, undefined, {
params: { introducerCustomerId }
});
}
async function saveInvite(introducerCustomerId: string, customerId: string): Promise<void> {
return await axios.post(`${BASE_URI}/invite/save`, undefined, {
params: {
customerId: req.customerId,
introducerCustomerId: req.introducerCustomerId
introducerCustomerId,
customerId
}
});
}
@@ -120,29 +80,18 @@ async function genderStat(): Promise<CustomerGenderStatView[]> {
return await axios.post(`${BASE_URI}/state/gender`);
}
function buildCustomerListExample(req?: CustomerSearchReq): Partial<Customer> {
if (!req?.introducerCustomerId) {
return {};
}
return {
introducerCustomerId: req.introducerCustomerId
};
}
export default {
list,
search,
searchPage,
exportExcel,
detail,
nullableDetailByCode,
nullableDetailByName,
invitedList,
create,
quickRegister,
update,
remove,
updateInvite,
findById,
findByCode,
findByName,
exportXSSF,
listInvited,
saveInvite,
deleteInvite,
dailyStat,
genderStat
+6 -7
View File
@@ -7,7 +7,7 @@ async function list(req: Page<EventItem>): Promise<PageResult<EventItem>> {
return await axios.post(`${BASE_URI}/list`, req);
}
async function enabledList(): Promise<EventItem[]> {
async function listValid(): Promise<EventItem[]> {
return await axios.post(`${BASE_URI}/list/valid`);
}
@@ -17,12 +17,12 @@ async function detail(id: string): Promise<EventItem> {
});
}
async function create(req: EventItem): Promise<void> {
return await axios.post(`${BASE_URI}/create`, req);
async function create(event: EventItem): Promise<void> {
return await axios.post(`${BASE_URI}/create`, event);
}
async function update(req: EventItem): Promise<void> {
return await axios.post(`${BASE_URI}/update`, req);
async function update(event: EventItem): Promise<void> {
return await axios.post(`${BASE_URI}/update`, event);
}
async function sort(idList: string[]): Promise<void> {
@@ -37,11 +37,10 @@ async function remove(id: string): Promise<void> {
export default {
list,
enabledList,
listValid,
detail,
create,
update,
sort,
remove
};
+26 -116
View File
@@ -1,92 +1,33 @@
import { axios, type Page, type PageResult } from "timi-web";
import type {
CustomerEventRankResponse,
CustomerEventRankView,
EventRecord,
EventRecordCalendarView,
EventRecordCreateReq,
EventRecordDailyStatView,
EventRecordListItemView,
EventRecordPage,
EventRecordPageReq,
EventRecordTrendStatReq
} from "@/types/EventRecord";
import { axios, type PageResult } from "timi-web";
import type { CustomerEventRankView, EventRecord, EventRecordPage, EventRecordRankPage } from "@/types/EventRecord";
import type { Customer } from "@/types/Customer.ts";
const BASE_URI = "/gao/event/record";
async function list(req: Page<EventRecord>): Promise<PageResult<EventRecord>> {
return await axios.post(`${BASE_URI}/list`, buildRecordPage(req));
async function list(page: EventRecordPage): Promise<PageResult<EventRecord>> {
return await axios.post(`${BASE_URI}/list`, page);
}
async function detail(id: string): Promise<EventRecord> {
return await axios.post(`${BASE_URI}/detail`, undefined, {
return await axios.post(`${BASE_URI}/detail`, null, {
params: { id }
});
}
async function rangeList(req: EventRecordPage): Promise<EventRecord[]> {
const result: PageResult<EventRecord> = await axios.post(`${BASE_URI}/list`, buildRecordPage({
index: 0,
size: 1000,
equalsExample: req
}));
return result.list;
}
async function calendarRangeList(req: EventRecordPage): Promise<EventRecordCalendarView[]> {
const result = await rangeList(req);
return result.map(item => ({
id: item.id,
customerId: item.customerId,
customerCode: item.customer?.code || "",
customerName: item.customer?.name || "",
eventId: item.eventId,
registeredAt: item.registeredAt
}));
}
async function rangePage(req: Page<EventRecordPage>): Promise<PageResult<EventRecordListItemView>> {
const result: PageResult<EventRecord> = await axios.post(`${BASE_URI}/list`, buildRecordPage(req));
return {
total: result.total,
list: result.list.map(item => ({
id: item.id,
customerId: item.customerId,
customerName: item.customer?.name || "",
customerPhotoAttachmentId: item.customer?.attachmentList?.[0]?.id,
eventId: item.eventId,
eventName: item.event?.name || "",
registeredAt: item.registeredAt
}))
};
}
async function rangeExport(req: EventRecordPage): Promise<Blob> {
return await axios.post(`${BASE_URI}/export`, buildRecordPage({
index: 0,
size: 100000,
equalsExample: req
}), {
responseType: "blob"
});
}
async function create(req: EventRecordCreateReq): Promise<EventRecord[]> {
const recordList = req.customerIdList.map(customerId => ({
customerId,
eventId: req.eventId,
remark: req.remark,
registeredAt: req.registeredAt,
attachmentIdList: req.attachmentIdList
}));
for (const record of recordList) {
async function create(record: EventRecord): Promise<void> {
await axios.post(`${BASE_URI}/create`, record);
}
return recordList;
async function createBatch(recordList: EventRecord[]): Promise<void> {
await axios.post(`${BASE_URI}/create/batch`, recordList);
}
async function update(req: EventRecord): Promise<void> {
await axios.post(`${BASE_URI}/update`, req);
async function createWithCustomer(customer: Customer): Promise<void> {
await axios.post(`${BASE_URI}/create/customer`, customer);
}
async function update(record: EventRecord): Promise<void> {
await axios.post(`${BASE_URI}/update`, record);
}
async function remove(id: string): Promise<void> {
@@ -95,56 +36,25 @@ async function remove(id: string): Promise<void> {
});
}
async function customerRegistrationRank(req?: Partial<EventRecordPage>): Promise<CustomerEventRankView[]> {
const result: PageResult<CustomerEventRankResponse> = await axios.post(`${BASE_URI}/rank`, buildRecordPage({
index: 0,
size: 100,
equalsExample: req
}));
return result.list.map(item => ({
rank: item.rank,
customerId: item.customerId,
customerCode: item.customer?.code || "",
customerName: item.customer?.name || "",
customerPhotoAttachmentId: item.customer?.attachmentList?.[0]?.id,
totalEventDays: item.totalEventDays,
totalEventCount: item.totalEventCount,
recentContinuousEventDays: item.recentContinuousEventDays,
latestRegisteredAt: item.latestRegisteredAt
}));
async function eventRank(page: EventRecordRankPage): Promise<CustomerEventRankView> {
return await axios.post(`${BASE_URI}/rank`, page);
}
async function allCustomerEventDailyStat(req: EventRecordTrendStatReq): Promise<EventRecordDailyStatView[]> {
void req;
return [];
}
function buildRecordPage(req: Page<EventRecord | EventRecordPage> | Page<Partial<EventRecordPage>>): EventRecordPageReq {
const equalsExample = req.equalsExample || {};
const eventIdList = "eventIdList" in equalsExample ? equalsExample.eventIdList : undefined;
const singleEventId = "eventId" in equalsExample ? equalsExample.eventId : undefined;
return {
index: req.index,
size: req.size,
customerId: equalsExample.customerId,
eventIdList: eventIdList || (singleEventId ? [singleEventId] : undefined),
keyword: "keyword" in equalsExample ? equalsExample.keyword : undefined,
beginAt: "beginAt" in equalsExample ? equalsExample.beginAt : undefined,
endAt: "endAt" in equalsExample ? equalsExample.endAt : undefined
};
async function exportXSSF(page: EventRecordPage): Promise<Blob> {
return await axios.post(`${BASE_URI}/export`, page, {
responseType: "blob"
});
}
export default {
list,
detail,
rangeList,
calendarRangeList,
rangePage,
rangeExport,
exportXSSF,
create,
createBatch,
createWithCustomer,
update,
remove,
customerRegistrationRank,
allCustomerEventDailyStat
eventRank
};
+6 -7
View File
@@ -3,8 +3,8 @@ import type { ManagedUser } from "@/types/ManagedUser";
const BASE_URI = "/gao/user";
async function list(req: Page<ManagedUser>): Promise<PageResult<ManagedUser>> {
return await axios.post(`${BASE_URI}/list`, req);
async function list(page: Page<ManagedUser>): Promise<PageResult<ManagedUser>> {
return await axios.post(`${BASE_URI}/list`, page);
}
async function detail(id: string): Promise<ManagedUser> {
@@ -13,12 +13,12 @@ async function detail(id: string): Promise<ManagedUser> {
});
}
async function create(req: ManagedUser): Promise<ManagedUser> {
return await axios.post(`${BASE_URI}/create`, req);
async function create(user: ManagedUser): Promise<ManagedUser> {
return await axios.post(`${BASE_URI}/create`, user);
}
async function update(req: ManagedUser): Promise<ManagedUser> {
return await axios.post(`${BASE_URI}/update`, req);
async function update(user: ManagedUser): Promise<ManagedUser> {
return await axios.post(`${BASE_URI}/update`, user);
}
async function remove(id: string): Promise<void> {
@@ -39,4 +39,3 @@ export default {
remove,
roleList
};
+6 -4
View File
@@ -109,11 +109,13 @@ async function loadCustomers(): Promise<void> {
searchToken = token;
loading.value = true;
try {
const result = await CustomerAPI.searchPage({
const result = await CustomerAPI.list({
index: pageIndex.value,
size: pageSize,
equalsExample: {
keyword: keyword.value || undefined
likesExample: {
code: keyword.value || undefined,
name: keyword.value || undefined,
telephone: keyword.value || undefined
}
});
if (token !== searchToken) {
@@ -253,7 +255,7 @@ onUnmounted(() => {
.search-wrapper {
z-index: 1;
padding: 1rem 0 .5rem 0;
box-shadow: var(--td-shadow-1);
box-shadow: 0 4px 4px rgba(0, 0, 0, .05);
.search {
--td-input-vertical-padding: 8px;
+1
View File
@@ -7,6 +7,7 @@
:class="{ clickable: !!href }"
@click="handleClick"
>
<t-icon v-if="value" name="call" />
<span v-text="displayText" />
</component>
</template>
+141 -111
View File
@@ -50,12 +50,12 @@
</t-form-item>
<t-form-item label="客户照片">
<photo-field
v-model="supplementPhoto"
v-model="customerPhoto"
alt-text="客户照片"
:loading="supplementPhotoUploading"
:loading="customerPhotoUploading"
:preview="false"
@add="selectSupplementPhoto"
@clear="clearSupplementPhoto"
@add="customerPhotoInputRef?.click"
@clear="customerPhotoTempFileId = ''"
/>
</t-form-item>
<t-form-item label-width="0">
@@ -76,18 +76,18 @@
<div class="photo-part">
<photo-scroller
title="登记事件照片"
v-model="photoList"
v-model="recordPhotoList"
:loading="recordPhotoUploading"
@add="selectRecordPhoto"
@add="recordPhotoInputRef?.click"
/>
</div>
</t-form>
<input
ref="supplementPhotoInputRef"
ref="customerPhotoInputRef"
class="file-input"
type="file"
accept="image/*"
@change="handleSupplementPhotoSelected"
@change="handleCustomerPhotoSelected"
>
<input
ref="recordPhotoInputRef"
@@ -107,7 +107,7 @@
</template>
<script setup lang="ts">
import { AttachmentAPI } from "timi-web";
import { AttachmentAPI, Toolkit } from "timi-web";
import type { TdInputProps } from "tdesign-mobile-vue";
import { Toast } from "tdesign-mobile-vue";
import CustomerAPI from "@/api/CustomerAPI";
@@ -123,6 +123,7 @@ import { getCustomerPhotoURL } from "@/utils/customer";
import { permissionCodes } from "@/utils/permission";
import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompression";
import EventSelector from "@/components/EventSelector.vue";
import EventRecordAPI from "@/api/EventRecordAPI.ts";
defineOptions({
name: "QuickEntryPage"
@@ -130,21 +131,25 @@ defineOptions({
const settingStore = useSettingStore();
const router = useRouter();
const supplementPhoto = ref("");
const supplementPhotoTempFileId = ref("");
const supplementPhotoInputRef = ref<HTMLInputElement>();
const recordPhotoInputRef = ref<HTMLInputElement>();
const recordPhotoTempFileIdList = ref<string[]>([]);
const supplementPhotoUploading = ref(false);
const recordPhotoUploading = ref(false);
const eventList = ref<EventItem[]>([]);
const selectedEventIdList = ref<string[]>([]);
const note = ref("");
const photoList = ref<string[]>([]);
const saving = ref(false);
const canSubmit = computed(() => !!getInputCode() && !!customerName.value.trim());
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("");
@@ -152,7 +157,6 @@ const scanResult = ref("");
const scanStatus = ref<TdInputProps["status"]>("warning");
const scannerVisible = ref(false);
const scannerTips = ref("");
const checkedCode = ref("");
const boundCodeCustomer = ref<Customer>();
async function toggleScanner(): Promise<void> {
@@ -195,7 +199,13 @@ 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();
@@ -203,18 +213,34 @@ async function ensureNewCustomerCode(code: string): Promise<boolean> {
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 || "";
syncSupplementPhotoFromCustomer(customer);
syncCustomerPhoto(customer);
customerTips.value = "";
if (!getInputCode() && customer.code) {
if (!inputCode.value && customer.code) {
// 补充编码
fillCodeFromCustomer(customer);
return;
}
if (getInputCode()) {
if (inputCode.value) {
await ensureCodeChecked();
}
refreshInputTips();
@@ -229,7 +255,7 @@ async function applyCustomerName(): Promise<void> {
refreshInputTips();
return;
}
const matched = await findCustomerByName(normalizedName);
const matched = await CustomerAPI.findByName(normalizedName) || undefined;
if (!matched) {
currentCustomer.value = undefined;
selectedCustomerId.value = "";
@@ -240,30 +266,30 @@ async function applyCustomerName(): Promise<void> {
currentCustomer.value = matched;
selectedCustomerId.value = matched.id || "";
customerName.value = matched.name || normalizedName;
syncSupplementPhotoFromCustomer(matched);
syncCustomerPhoto(matched);
customerTips.value = "已匹配客户";
if (!getInputCode() && matched.code) {
if (!inputCode.value && matched.code) {
fillCodeFromCustomer(matched);
return;
}
if (getInputCode()) {
if (inputCode.value) {
await ensureCodeChecked();
}
refreshInputTips();
}
async function findCustomerByName(name: string): Promise<Customer | undefined> {
const list = await CustomerAPI.search({ keyword: name });
return list.find(item => item.name?.trim() === name);
}
/**
* 补充客户编码
*
* @param customer 客户
*/
function fillCodeFromCustomer(customer: Customer): void {
const code = customer.code || "";
if (!code) {
// 未绑定编码
refreshInputTips();
return;
}
scanCode.value = code;
currentCode.value = code;
checkedCode.value = code;
@@ -271,15 +297,11 @@ function fillCodeFromCustomer(customer: Customer): void {
refreshInputTips();
}
async function ensureCodeChecked(): Promise<void> {
const code = getInputCode();
if (!code || checkedCode.value === code) {
return;
}
await checkCustomerCode(code);
}
/**
* 检验编码
*
* @param code 编码
*/
async function checkCustomerCode(code: string): Promise<void> {
const normalizedCode = code.trim();
if (!normalizedCode) {
@@ -288,9 +310,8 @@ async function checkCustomerCode(code: string): Promise<void> {
refreshInputTips();
return;
}
checkedCode.value = normalizedCode;
boundCodeCustomer.value = await CustomerAPI.nullableDetailByCode(normalizedCode) || undefined;
boundCodeCustomer.value = await CustomerAPI.findByCode(normalizedCode) || undefined;
scannerVisible.value = false;
}
@@ -303,41 +324,36 @@ function applyBoundCustomerIfNeeded(): void {
currentCustomer.value = boundCustomer;
selectedCustomerId.value = boundCustomer.id;
customerName.value = boundCustomer.name || "";
syncSupplementPhotoFromCustomer(boundCustomer);
syncCustomerPhoto(boundCustomer);
customerTips.value = "";
}
// 刷新提示
function refreshInputTips(): void {
scanStatus.value = "warning";
customerStatus.value = "warning";
const code = getInputCode();
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 ? "提交将会使用此编码" : "编码可用";
}
@@ -346,58 +362,59 @@ function isCodeBoundToOtherCustomer(): boolean {
if (!boundCustomerId) {
return false;
}
return currentCustomer.value?.id !== boundCustomerId;
}
function getInputCode(): string {
return (currentCode.value || scanCode.value).trim();
}
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 selectSupplementPhoto(): void {
supplementPhotoInputRef.value?.click();
function syncCustomerPhoto(customer?: Customer): void {
customerPhotoTempFileId.value = "";
customerPhoto.value = getCustomerPhotoURL(customer);
}
function clearSupplementPhoto(): void {
supplementPhotoTempFileId.value = "";
}
function syncSupplementPhotoFromCustomer(customer?: Customer): void {
supplementPhotoTempFileId.value = "";
supplementPhoto.value = getCustomerPhotoURL(customer);
}
async function handleSupplementPhotoSelected(event: Event): Promise<void> {
async function handleCustomerPhotoSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
supplementPhotoUploading.value = true;
customerPhotoUploading.value = true;
try {
const compressedFile = await compressUploadImage(file);
const [tempFile] = await AttachmentAPI.uploadTemp(compressedFile);
const [tempFile] = await AttachmentAPI.upload(compressedFile);
if (!tempFile?.id) {
showToast("客户照片上传失败", "warning");
return;
}
supplementPhotoTempFileId.value = tempFile.id;
supplementPhoto.value = AttachmentAPI.getTempReadURL(tempFile.id);
customerPhotoTempFileId.value = tempFile.id;
customerPhoto.value = AttachmentAPI.getTempReadURL(tempFile.id);
} finally {
supplementPhotoUploading.value = false;
customerPhotoUploading.value = false;
input.value = "";
}
}
function selectRecordPhoto(): void {
recordPhotoInputRef.value?.click();
// ---------- 记录照片 ----------
const recordPhotoInputRef = ref<HTMLInputElement>();
const recordPhotoUploading = ref(false);
const recordPhotoItemList = ref<QuickPhoto[]>([]);
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;
@@ -408,13 +425,16 @@ async function handleRecordPhotoSelected(event: Event): Promise<void> {
recordPhotoUploading.value = true;
try {
const compressedFileList = await compressUploadImageList(fileList);
const tempFileList = await AttachmentAPI.uploadTemp(compressedFileList);
for (const item of tempFileList) {
if (!item.id) {
const tempFileList = await AttachmentAPI.upload(compressedFileList);
for (let i = 0; i < tempFileList.length; i++) {
const tempFile = tempFileList[i];
if (!tempFile.id) {
continue;
}
recordPhotoTempFileIdList.value.push(item.id);
photoList.value.push(AttachmentAPI.getTempReadURL(item.id));
recordPhotoItemList.value.push({
tempFileId: tempFile.id,
url: AttachmentAPI.getTempReadURL(tempFile.id)
});
}
} finally {
recordPhotoUploading.value = false;
@@ -429,28 +449,37 @@ async function saveRecord(): Promise<void> {
if (!customerCode) {
return;
}
saving.value = true;
try {
await ensureCustomerMatched();
if (!await ensureNewCustomerCode(customerCode)) {
return;
}
await applyCustomerName();
await saveCustomerProfile(customerCode);
if (selectedEventIdList.value.length) {
for (const eventId of selectedEventIdList.value) {
await CustomerAPI.quickRegister({
customerCode,
const customer: Customer = {
id: currentCustomer.value?.id,
code: customerCode,
name: customerName.value.trim(),
eventRecordList: selectedEventIdList.value.map(eventId => ({
eventId,
remark: note.value || "前台快速登记",
attachmentIdList: recordPhotoTempFileIdList.value
});
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"
}));
}
note.value = "";
customerName.value = "";
photoList.value = [];
recordPhotoTempFileIdList.value = [];
await EventRecordAPI.createWithCustomer(customer);
resetFormAfterSubmit();
await settingStore.playRegisterSuccessSound();
showToast("登记已提交");
await router.replace("/customers");
@@ -459,24 +488,26 @@ async function saveRecord(): Promise<void> {
}
}
async function saveCustomerProfile(customerCode: string): Promise<void> {
const normalizedName = customerName.value.trim();
const attachmentIdList = supplementPhotoTempFileId.value ? [supplementPhotoTempFileId.value] : undefined;
async function ensureCustomerMatched(): Promise<void> {
if (currentCustomer.value?.id) {
await CustomerAPI.update({
...currentCustomer.value,
code: customerCode,
name: normalizedName,
attachmentIdList
});
return;
}
currentCustomer.value = await CustomerAPI.create({
code: customerCode,
name: normalizedName,
attachmentIdList
});
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 = [];
refreshInputTips();
}
function validateForm(): string {
@@ -489,7 +520,6 @@ function validateForm(): string {
showToast("请填写客户姓名", "warning");
return "";
}
return customerCode;
}
@@ -504,7 +534,7 @@ function showToast(message: string, theme: "success" | "warning" = "success"): v
// ---------- 初始化 ----------
onMounted(async () => {
eventList.value = await EventAPI.enabledList();
eventList.value = await EventAPI.listValid();
});
watch(scanCode, (value) => {
+76 -153
View File
@@ -25,18 +25,18 @@
<t-cell
v-for="item in recentRecordList"
:key="item.id"
:title="getRecordEventName(item)"
:description="formatTime(item.registeredAt)"
:title="item.event?.name || '未知事件'"
:description="Time.toPassedDateTime(item.registeredAt)"
:note="item.remark || '无备注'"
arrow
hover
@click="goRecordDetail(item.id || '')"
@click="toRecordDetail(item.id || '')"
>
<template #leftIcon>
<t-image
v-if="getRecordPhotoURL(item)"
:src="getRecordPhotoURL(item)"
:alt="getRecordEventName(item)"
v-if="getEventRecordPhotoURL(item)"
:src="getEventRecordPhotoURL(item)"
:alt="item.event?.name || '未知事件'"
fit="cover"
class="record-photo"
/>
@@ -48,7 +48,7 @@
theme="primary"
:disabled="!canSubmitRegister"
:loading="submitting"
@click="submitRegister"
@click="doSubmit"
>登记</t-button>
</div>
<t-popup v-model="eventSelectorVisible" placement="bottom">
@@ -60,7 +60,7 @@
:key="item.id"
:title="item.name"
:note="item.id === selectedEventId ? '当前使用' : ''"
@click="selectEvent(item.id || '')"
@click="selectEvent(item.id)"
/>
</t-cell-group>
<div v-else class="empty-block">暂无可用登记事件</div>
@@ -81,72 +81,116 @@ import type { EventItem } from "@/types/EventItem";
import type { EventRecord } from "@/types/EventRecord";
import { permissionCodes } from "@/utils/permission";
import { getEventRecordPhotoURL } from "@/utils/eventRecord";
import { Time } from "timi-web";
import { Storage, Time } from "timi-web";
defineOptions({
name: "QuickSignInPage"
});
const router = useRouter();
const EventItemStorageKey = "quick-scan-registration-event-id";
const settingStore = useSettingStore();
const scanCode = ref("");
const scanStatus = ref("正在启动扫码预览。");
const scannerSessionKey = ref(0);
const matchedCustomer = ref<Customer>();
const recentRecordList = ref<EventRecord[]>([]);
const eventList = ref<EventItem[]>([]);
const selectedEventId = ref("");
const eventSelectorVisible = ref(false);
const submitting = ref(false);
let latestDecodedText = "";
// ---------- 登记事件 ----------
const EventItemStorageKey = "quick-scan-event-id";
const eventSelectorVisible = ref(false);
const selectedEventId = ref("");
const eventList = ref<EventItem[]>([]);
const selectedEventName = computed(() => eventList.value.find(item => item.id === selectedEventId.value)?.name || "请选择登记事件");
const canSubmitRegister = computed(() => !!matchedCustomer.value && !!selectedEventId.value);
async function loadActions(): Promise<void> {
eventList.value = await EventAPI.listValid();
const storedEventId = Storage.getString(EventItemStorageKey);
const storedAction = eventList.value.find(item => item.id === storedEventId);
selectedEventId.value = storedAction?.id || eventList.value[0]?.id || "";
if (selectedEventId.value) {
Storage.setString(EventItemStorageKey, selectedEventId);
}
}
function selectEvent(eventId?: string): void {
if (!eventId) {
return;
}
selectedEventId.value = eventId;
Storage.setString(EventItemStorageKey, selectedEventId);
eventSelectorVisible.value = false;
Toast.success("登记事件已切换");
}
onMounted(loadActions);
// ---------- 扫码 ----------
const scanCode = ref("");
const scanStatus = ref("正在启动扫码预览");
const scannerSessionKey = ref(0);
const matchedCustomer = ref<Customer>();
let 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 loadCustomer(normalizedText);
if (matchedCustomer.value) {
scanStatus.value = `已识别 ${normalizedText},可直接登记。`;
scanStatus.value = `已识别 ${normalizedText},可直接登记`;
return;
}
scanStatus.value = `已识别 ${normalizedText},但未匹配客户档案。`;
scanStatus.value = `已识别 ${normalizedText},但未匹配客户档案`;
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
// ---------- 提交 ----------
// ---------- 读取客户 ----------
async function submitRegister(): Promise<void> {
if (!validateForm()) {
const recentRecordList = ref<EventRecord[]>([]);
async function loadCustomer(code: string): Promise<void> {
const customer = await CustomerAPI.findByCode(code);
if (!customer) {
matchedCustomer.value = undefined;
recentRecordList.value = [];
return;
}
matchedCustomer.value = customer;
recentRecordList.value = (await EventRecordAPI.list({
index: 0,
size: 5,
customerId: matchedCustomer.value.id!
})).list;
}
async function toRecordDetail(id: string): Promise<void> {
if (!id) {
return;
}
await router.push(`/management/registration-event-records/${id}`);
}
// ---------- 表单 ----------
const submitting = ref(false);
const canSubmitRegister = computed(() => !!matchedCustomer.value && !!selectedEventId.value);
async function doSubmit(): Promise<void> {
const customer = matchedCustomer.value;
if (!customer) {
return;
}
submitting.value = true;
try {
await CustomerAPI.quickRegister({
customerCode: customer.code || scanCode.value,
await EventRecordAPI.create({
customerId: customer.id,
eventId: selectedEventId.value,
remark: "客户快速扫码登记"
});
})
scanStatus.value = `登记成功:${customer.name || customer.code || scanCode.value}。`;
scanCode.value = "";
matchedCustomer.value = undefined;
@@ -158,127 +202,6 @@ async function submitRegister(): Promise<void> {
submitting.value = false;
}
}
function validateForm(): boolean {
if (!selectedEventId.value) {
showToast("请先选择登记事件", "warning");
return false;
}
if (!matchedCustomer.value) {
scanStatus.value = "当前编码未匹配客户,无法登记。";
showToast("请先扫码匹配客户", "warning");
return false;
}
return true;
}
async function loadCustomer(code: string): Promise<void> {
const customer = await CustomerAPI.nullableDetailByCode(code);
if (!customer) {
matchedCustomer.value = undefined;
recentRecordList.value = [];
return;
}
matchedCustomer.value = customer;
await loadRecentRecordList();
}
async function loadRecentRecordList(): Promise<void> {
const customerId = matchedCustomer.value?.id;
if (!customerId) {
recentRecordList.value = [];
return;
}
recentRecordList.value = (await EventRecordAPI.rangeList({
customerId,
beginAt: 0,
endAt: Time.toTimestamp(new Date())
}))
.sort((left, right) => (right.registeredAt || 0) - (left.registeredAt || 0))
.slice(0, 5);
}
async function goRecordDetail(id: string): Promise<void> {
if (!id) {
return;
}
await router.push(`/management/registration-event-records/${id}`);
}
function getRecordEventName(record: EventRecord): string {
return record.event?.name || "未知事件";
}
function getRecordPhotoURL(record: EventRecord): string {
return getEventRecordPhotoURL(record);
}
function formatTime(value?: number): string {
if (!value) {
return "-";
}
const date = Time.toDateObject(value);
if (!date) {
return "-";
}
return Time.formatDateTime(date);
}
// ---------- 登记事件 ----------
async function loadActions(): Promise<void> {
eventList.value = await EventAPI.enabledList();
const storedEventId = loadStoredEventId();
const storedAction = eventList.value.find(item => item.id === storedEventId);
selectedEventId.value = storedAction?.id || eventList.value[0]?.id || "";
saveStoredEventId(selectedEventId.value);
}
function selectEvent(eventId: string | number | Array<string | number>): void {
if (Array.isArray(eventId)) {
return;
}
const nextEventId = String(eventId);
if (!nextEventId) {
return;
}
selectedEventId.value = nextEventId;
saveStoredEventId(nextEventId);
eventSelectorVisible.value = false;
showToast("登记事件已切换");
}
function loadStoredEventId(): string {
if (typeof window === "undefined") {
return "";
}
return window.localStorage.getItem(EventItemStorageKey) || "";
}
function saveStoredEventId(eventId: string): void {
if (typeof window === "undefined" || !eventId) {
return;
}
window.localStorage.setItem(EventItemStorageKey, eventId);
}
function showToast(message: string, theme: "success" | "warning" = "success"): void {
Toast({
theme,
direction: "row",
message
});
}
onMounted(loadActions);
</script>
<style scoped lang="less">
+10 -458
View File
@@ -7,7 +7,7 @@
v-if="selectedCustomer"
theme="primary"
@click="clearCustomerSelection"
>排行</t-button>
>清除</t-button>
<t-button class="customer" block theme="primary" variant="outline" @click="selectorVisible = true">
<template #icon>
<t-icon name="user-search" />
@@ -30,24 +30,6 @@
@status-change="handleScanStatusChange"
/>
</div>
<div v-if="!selectedCustomer" class="line filter">
<t-dropdown-menu class="menu">
<t-dropdown-item
v-model="rankDateFilterType"
:label="rankDateFilterLabel"
:options="rankDateOptions"
@change="selectRankDateFilter"
/>
<t-dropdown-item
v-model="rankEventIdList"
:label="rankEventFilterLabel"
:options="rankEventOptions"
options-columns="2"
multiple
@change="reloadCustomerRegistrationRank"
/>
</t-dropdown-menu>
</div>
</div>
</div>
<section v-if="selectedCustomer" class="summary">
@@ -74,110 +56,33 @@
<h2 v-text="item.title" />
<div :ref="element => setChartRef(item.id, element)" class="chart" />
</section>
<pull-down-refresh
v-if="!selectedCustomer"
v-model="rankRefreshing"
class="rank-refresh"
:refresh-timeout="30000"
@refresh="refreshCustomerRegistrationRank"
@scrolltolower="loadNextRankPage"
>
<div class="rank-content">
<div class="rank-summary" v-text="rankSummaryText"></div>
<t-cell-group
v-if="customerRegistrationRankList.length"
class="rank-list"
theme="card"
>
<t-cell
v-for="item in visibleCustomerRegistrationRankList"
:key="item.customerId"
class="item"
arrow
hover
@click="selectRankCustomer(item.customerId || '')"
>
<template #leftIcon>
<span class="left">
<t-image
v-if="getRankCustomerPhotoURL(item)"
:src="getRankCustomerPhotoURL(item)"
:alt="item.customerName || '客户头像'"
fit="cover"
class="photo"
/>
<span v-else class="photo fallback" v-text="getRankCustomerInitial(item)" />
</span>
</template>
<template #title>
<t-tag theme="primary" :content="`No.${item.rank}`" />
<span class="word-space" v-text="item.customerName || '未知客户'" />
</template>
<template #description>
<div>
<span v-text="`累计登记 ${item.totalEventCount || 0} 次,`" />
<span v-text="`累计登记 ${item.totalEventDays || 0} 天`" />
</div>
<div>
<span v-text="`最近 ${item.recentContinuousEventDays || 0} 天连续登记`" />
</div>
</template>
</t-cell>
</t-cell-group>
<div v-else class="empty-block" v-text="rankLoading ? '正在加载排行榜' : '暂无登记排行数据'" />
<div v-if="1 < rankPageCount" class="rank-load-state" v-text="rankCanLoadMore ? '上拉加载更多' : '已加载全部'" />
</div>
</pull-down-refresh>
<customer-selector
v-model="selectedCustomerId"
v-model:visible="selectorVisible"
@change="handleCustomerSelected"
/>
<t-popup v-model="rankStartPickerVisible" placement="bottom">
<t-date-time-picker
v-model="rankDateRange[0]"
:default-value="rankDateRange[0]"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="开始时间"
@confirm="confirmRankStartTime"
/>
</t-popup>
<t-popup v-model="rankEndPickerVisible" placement="bottom">
<t-date-time-picker
v-model="rankDateRange[1]"
:default-value="rankDateRange[1]"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="结束时间"
@confirm="confirmRankEndTime"
/>
</t-popup>
</div>
</template>
<script setup lang="ts">
import type {ECharts, EChartsOption} from "echarts";
import {PullDownRefresh, type DropdownValue} from "tdesign-mobile-vue";
import {BarChart, LineChart} from "echarts/charts";
import {GridComponent, LegendComponent, TooltipComponent} from "echarts/components";
import {init, use} from "echarts/core";
import {CanvasRenderer} from "echarts/renderers";
import {AttachmentAPI, Time} from "timi-web";
import {Time} from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import type {Customer} from "@/types/Customer";
import type {EventItem} from "@/types/EventItem";
import type {CustomerEventRankView, EventRecord, EventRecordPage} from "@/types/EventRecord";
import type {EventRecord} from "@/types/EventRecord";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import EventRecordCalendar from "@/components/EventRecordCalendar.vue";
import {useSettingStore} from "@/store/settingStore";
import {permissionCodes} from "@/utils/permission";
type RankDateFilterType = "all" | "today" | "thisMonth" | "custom";
interface SummaryItem {
label: string;
value: string;
@@ -207,12 +112,6 @@ const router = useRouter();
const route = useRoute();
const chartRefs = new Map<string, HTMLElement>();
const chartInstances = new Map<string, ECharts>();
const rankDateOptions: Array<{ label: string; value: RankDateFilterType }> = [
{label: "全部", value: "all"},
{label: "今天", value: "today"},
{label: "本月", value: "thisMonth"},
{label: "自定义范围", value: "custom"}
];
const routeCustomerId = computed(() => String(route.params.id || ""));
const routeCustomerCode = computed(() => String(route.query.customerCode || ""));
const customerCode = ref(routeCustomerCode.value);
@@ -226,19 +125,6 @@ const selectedEventIdList = ref<string[]>([]);
const monthCursor = ref(getMonthStart(new Date()));
const monthRecords = ref<EventRecord[]>([]);
const calendarLoading = ref(false);
const customerRegistrationRankList = ref<CustomerEventRankView[]>([]);
const rankPageSize = 10;
const rankPageIndex = ref(1);
const rankDateFilterType = ref<RankDateFilterType>("all");
const rankDateRange = ref(buildRankPresetRange("all"));
const rankEventIdList = ref<string[]>([]);
const rankLoading = ref(false);
const rankRefreshing = ref(false);
const rankStartPickerVisible = ref(false);
const rankEndPickerVisible = ref(false);
const rankCache = new Map<string, CustomerEventRankView[]>();
const rankPendingRequestMap = new Map<string, Promise<CustomerEventRankView[]>>();
let rankRequestToken = 0;
let latestDecodedText = "";
const eventNameMap = computed(() => {
@@ -302,33 +188,6 @@ const chartList = computed<ChartProfile[]>(() => {
}
];
});
const rankDateFilterLabel = computed(() => rankDateOptions.find(item => item.value === rankDateFilterType.value)?.label || "全部");
const rankEventOptions = computed(() => eventList.value.map(item => ({
label: item.name || "未命名事件",
value: item.id || ""
})).filter(item => item.value));
const rankEventFilterLabel = computed(() => {
if (!rankEventIdList.value.length || rankEventIdList.value.length === eventList.value.length) {
return "全部事件";
}
return eventList.value
.filter(item => item.id && rankEventIdList.value.includes(item.id))
.map(item => item.name || "未命名事件")
.join("、");
});
const rankSummaryText = computed(() => {
if (rankDateFilterType.value === "custom") {
return `${rankDateRange.value[0] || "-"} ~ ${rankDateRange.value[1] || "-"}|${rankEventFilterLabel.value}`;
}
return `范围:${rankDateFilterLabel.value}|${rankEventFilterLabel.value}`;
});
const visibleCustomerRegistrationRankList = computed(() => {
return customerRegistrationRankList.value.slice(0, rankPageIndex.value * rankPageSize);
});
const rankPageCount = computed(() => Math.max(1, Math.ceil(customerRegistrationRankList.value.length / rankPageSize)));
const rankCanLoadMore = computed(() => rankPageIndex.value < rankPageCount.value);
watch(chartList, async () => {
await nextTick();
renderCharts();
@@ -356,7 +215,6 @@ watch([selectedCustomer, monthCursor], async () => {
onMounted(async () => {
await loadActions();
await loadCustomerRegistrationRank();
if (routeCustomerId.value) {
await loadCustomerById(routeCustomerId.value);
} else if (customerCode.value) {
@@ -409,9 +267,8 @@ function resizeCharts(): void {
// ---------- 数据 ----------
async function loadActions(): Promise<void> {
eventList.value = await EventAPI.enabledList();
eventList.value = await EventAPI.listValid();
selectedEventIdList.value = eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
rankEventIdList.value = selectedEventIdList.value;
}
async function loadCustomer(code: string): Promise<void> {
@@ -423,7 +280,7 @@ async function loadCustomer(code: string): Promise<void> {
return;
}
const customer = await CustomerAPI.nullableDetailByCode(normalizedCode);
const customer = await CustomerAPI.findByCode(normalizedCode);
if (!customer) {
selectedCustomer.value = undefined;
selectedCustomerId.value = "";
@@ -470,10 +327,6 @@ async function clearCustomerSelection(): Promise<void> {
}
}
async function selectRankCustomer(id: string): Promise<void> {
await loadCustomerById(id);
}
async function loadMonthRecords(): Promise<void> {
const customerId = selectedCustomer.value?.id;
if (!customerId) {
@@ -485,113 +338,18 @@ async function loadMonthRecords(): Promise<void> {
const endDate = getDateEnd(addDays(beginDate, 41));
calendarLoading.value = true;
try {
monthRecords.value = await EventRecordAPI.rangeList({
monthRecords.value = (await EventRecordAPI.list({
index: 0,
size: Number.MAX_SAFE_INTEGER,
customerId,
beginAt: Time.toTimestamp(beginDate),
endAt: Time.toTimestamp(endDate)
});
})).list;
} finally {
calendarLoading.value = false;
}
}
async function loadCustomerRegistrationRank(force = false): Promise<void> {
const cacheKey = buildRankCacheKey();
if (!force) {
const cachedList = rankCache.get(cacheKey);
if (cachedList) {
customerRegistrationRankList.value = cachedList;
rankPageIndex.value = 1;
return;
}
} else {
rankCache.delete(cacheKey);
}
const token = rankRequestToken + 1;
rankRequestToken = token;
rankPageIndex.value = 1;
rankLoading.value = true;
let request = force ? undefined : rankPendingRequestMap.get(cacheKey);
if (!request) {
request = EventRecordAPI.customerRegistrationRank(buildRankStatReq());
rankPendingRequestMap.set(cacheKey, request);
}
try {
const result = await request;
if (rankPendingRequestMap.get(cacheKey) === request) {
rankCache.set(cacheKey, result);
}
if (token !== rankRequestToken) {
return;
}
customerRegistrationRankList.value = result;
rankPageIndex.value = 1;
} finally {
if (rankPendingRequestMap.get(cacheKey) === request) {
rankPendingRequestMap.delete(cacheKey);
}
if (token === rankRequestToken) {
rankLoading.value = false;
}
}
}
async function reloadCustomerRegistrationRank(): Promise<void> {
await loadCustomerRegistrationRank();
}
async function refreshCustomerRegistrationRank(): Promise<void> {
rankRefreshing.value = true;
try {
await loadCustomerRegistrationRank(true);
} finally {
rankRefreshing.value = false;
}
}
function loadNextRankPage(): void {
if (!rankCanLoadMore.value || rankLoading.value) {
return;
}
rankPageIndex.value += 1;
}
async function selectRankDateFilter(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
rankDateFilterType.value = String(value) as RankDateFilterType;
if (rankDateFilterType.value !== "custom") {
rankDateRange.value = buildRankPresetRange(rankDateFilterType.value);
await loadCustomerRegistrationRank();
return;
}
if (!rankDateRange.value[0] || !rankDateRange.value[1]) {
rankDateRange.value = buildRankPresetRange("today");
}
rankStartPickerVisible.value = true;
}
function confirmRankStartTime(): void {
rankStartPickerVisible.value = false;
rankEndPickerVisible.value = true;
}
async function confirmRankEndTime(): Promise<void> {
rankEndPickerVisible.value = false;
normalizeRankDateRange();
await loadCustomerRegistrationRank();
}
function normalizeRankDateRange(): void {
if (rankDateRange.value[0] && rankDateRange.value[1] && rankDateRange.value[1] < rankDateRange.value[0]) {
rankDateRange.value = [rankDateRange.value[1], rankDateRange.value[0]];
}
}
// ---------- 扫码 ----------
async function toggleScanner(): Promise<void> {
@@ -632,17 +390,6 @@ function getRecordEventName(record?: EventRecord): string {
return record.event?.name || (record.eventId ? eventNameMap.value.get(record.eventId) : "") || "未知事件";
}
function getRankCustomerPhotoURL(item: CustomerEventRankView): string {
if (!item.customerPhotoAttachmentId) {
return "";
}
return AttachmentAPI.getReadURL(item.customerPhotoAttachmentId);
}
function getRankCustomerInitial(item: CustomerEventRankView): string {
return (item.customerName || "客").slice(0, 1);
}
// ---------- 图表 ----------
function buildBaseOption(): EChartsOption {
@@ -795,56 +542,6 @@ function getDateEnd(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
}
function buildRankStatReq(): Partial<EventRecordPage> | undefined {
const selectedAllEvent = !rankEventIdList.value.length || rankEventIdList.value.length === eventList.value.length;
if (rankDateFilterType.value === "all") {
return selectedAllEvent ? undefined : {
eventIdList: rankEventIdList.value
};
}
return {
beginAt: Time.toTimestamp(parseRankDateTime(rankDateRange.value[0], false)),
endAt: Time.toTimestamp(parseRankDateTime(rankDateRange.value[1], true)),
eventIdList: selectedAllEvent ? undefined : rankEventIdList.value
};
}
function buildRankCacheKey(): string {
const req = buildRankStatReq();
return JSON.stringify({
beginAt: req?.beginAt,
endAt: req?.endAt,
eventIdList: req?.eventIdList?.slice().sort()
});
}
function buildRankPresetRange(type: Exclude<RankDateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
const now = new Date();
if (type === "thisMonth") {
const monthStart = getMonthStart(now);
return [`${Time.formatDate(monthStart)} 00:00`, `${Time.formatDate(getDateEnd(now))} 23:59`];
}
const dayStart = new Date(now);
dayStart.setHours(0, 0, 0, 0);
return [`${Time.formatDate(dayStart)} 00:00`, `${Time.formatDate(getDateEnd(now))} 23:59`];
}
function parseRankDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "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, isEnd ? 59 : 0, 0);
}
</script>
<style scoped lang="less">
@@ -891,33 +588,6 @@ function parseRankDateTime(value: string, isEnd: boolean): Date {
&.scanner-wrapper {
margin: 1rem .5rem;
}
&.filter {
.menu {
--td-spacer-1: 6px;
width: 100%;
overflow: hidden;
margin-top: -1rem;
padding-top: .8rem;
background: #fff;
&:after {
display: none;
}
:deep(.t-dropdown-menu__item) {
min-width: 0;
}
:deep(.t-dropdown-menu__title) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
}
}
}
@@ -971,63 +641,6 @@ function parseRankDateTime(value: string, isEnd: boolean): Date {
margin: 0 1rem;
}
.rank-summary {
text-align: center;
}
.rank-content {
gap: 1rem;
display: flex;
flex-direction: column;
}
.rank-list {
margin: 0;
.item {
:deep(.t-cell__title) {
flex: 1 1 0;
min-width: 0;
}
:deep(.t-cell__title-text),
:deep(.t-cell__description) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.left {
gap: .5rem;
display: flex;
align-items: center;
.photo {
width: 4.3rem;
height: 4.3rem;
border-radius: .5rem;
&.fallback {
color: #2563eb;
display: flex;
font-size: 1rem;
background: #dbeafe;
align-items: center;
font-weight: 700;
justify-content: center;
}
}
}
}
}
.rank-load-state {
padding: .25rem 0;
color: #52607a;
font-size: .875rem;
text-align: center;
}
.chart-section {
h2 {
margin-bottom: .875rem;
@@ -1039,28 +652,12 @@ function parseRankDateTime(value: string, isEnd: boolean): Date {
}
}
.empty-block {
padding: 1rem;
color: #52607a;
margin: 0 1rem;
text-align: center;
background: #f7faff;
border-radius: .75rem;
}
}
:global(html.theme-dark) {
.customer-data-charts-page {
.selector {
background: #10161f;
.filter-line {
.filter-menu {
background: #172033;
border-color: rgba(148, 163, 184, .16);
}
}
}
.summary {
@@ -1079,8 +676,7 @@ function parseRankDateTime(value: string, isEnd: boolean): Date {
}
.calendar-section,
.chart-section,
.rank-section {
.chart-section {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
@@ -1089,50 +685,6 @@ function parseRankDateTime(value: string, isEnd: boolean): Date {
}
}
.rank-section {
.rank-header {
p {
color: #94a3b8;
}
.rank-filter {
border-color: rgba(148, 163, 184, .16);
}
}
.rank-no {
color: #bfdbfe;
background: rgba(37, 99, 235, .28);
}
.rank-photo {
&.fallback {
color: #bfdbfe;
background: rgba(37, 99, 235, .28);
}
}
.rank-stat {
strong {
color: #e2e8f0;
}
small {
color: #94a3b8;
}
}
}
.empty-block {
color: #94a3b8;
background: rgba(30, 41, 59, .9);
}
.rank-load-state {
color: #94a3b8;
}
}
}
</style>
+68 -112
View File
@@ -4,7 +4,7 @@
class="customer"
:customer="customer"
/>
<t-cell-group v-if="customer" theme="card">
<t-cell-group v-if="customer" class="info" theme="card">
<t-cell title="出生日期">
<span
v-if="isLunarBirthdate"
@@ -24,7 +24,7 @@
<t-cell title="录入时间" :note="createdAtText" />
</t-cell-group>
<t-cell-group v-if="customer && canReadStat" theme="card">
<t-cell title="客户数据图表" arrow @click="goCustomerDataCharts">
<t-cell title="客户数据图表" arrow @click="toCustomerDataCharts">
<template #leftIcon>
<t-icon name="chart" size="1.5rem" />
</template>
@@ -36,18 +36,16 @@
<body-area-selector v-model="selectedAreas" />
</div>
</t-cell-group>
<t-cell-group v-if="inviter" :title="`由 ${inviter.name} 邀请`" class="invite" theme="card">
<customer-profile-card
class="profile-card"
:customer="inviter"
@click="goCustomerDetail(inviter)"
/>
<t-cell-group v-if="inviter" :title="`由 ${inviter.name} 邀请`" class="invite-list" theme="card">
<customer-profile-card :customer="inviter" @click="toCustomerDetail(inviter)"/>
</t-cell-group>
<div class="invite">
<div class="invite-list">
<div class="header">
<div class="title">邀请客户</div>
<div v-can="permissionCodes.customer.update" class="actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
content="选择添加"
@click="selectorVisible = true"
@@ -67,15 +65,12 @@
:key="item.id || item.code || index"
>
<t-cell
class="cell"
:title="item.name || item.code || '-'"
arrow
hover
@click="goCustomerDetail(item)"
@click="toCustomerDetail(item)"
>
<template #title>
<div class="title">
<span v-text="item.name || item.code" />
</div>
</template>
<template #description>
<telephone-link :value="item.telephone" placeholder="未填写电话" />
</template>
@@ -103,7 +98,7 @@
class="delete bg-white"
theme="danger"
variant="outline"
:loading="removing"
:loading="isRemoving"
@click="confirmRemoveCustomer"
>
删除客户
@@ -178,7 +173,7 @@ const selectorVisible = ref(false);
const scanPopupVisible = ref(false);
const pendingInvitee = ref<Customer>();
const scanStatus = ref("点击扫码开始识别。");
const removing = ref(false);
const isRemoving = ref(false);
const showSolarBirthdate = ref(false);
let latestDecodedText = "";
@@ -188,7 +183,7 @@ const creatorName = computed(() => {
const creatorUser = customer.value?.creatorUser;
return creatorUser?.nick || creatorUser?.name || customer.value?.creatorUserId || "无";
});
const createdAtText = computed(() => formatTime(customer.value?.createdAt));
const createdAtText = computed(() => customer.value?.createdAt ? Time.toPassedDateTime(customer.value.createdAt) : "无");
const isLunarBirthdate = computed(() => customer.value?.birthdateCalendar === "LUNAR" && !!customer.value?.birthdate);
const solarBirthdateText = computed(() => formatSolarBirthdate(customer.value));
const birthdateText = computed(() => showSolarBirthdate.value && isLunarBirthdate.value ? solarBirthdateText.value : formatBirthdate(customer.value));
@@ -219,7 +214,7 @@ async function loadCustomer(): Promise<void> {
customer.value = undefined;
invitees.value = [];
console.warn("客户详情加载失败", err);
showToast("客户详情加载失败", "warning");
Toast.warning("客户详情加载失败");
}
}
@@ -230,7 +225,7 @@ async function loadInvitees(): Promise<void> {
return;
}
invitees.value = await CustomerAPI.invitedList(customerId);
invitees.value = await CustomerAPI.listInvited(customerId);
}
async function removeInvitee(item: Customer): Promise<void> {
@@ -240,7 +235,7 @@ async function removeInvitee(item: Customer): Promise<void> {
await CustomerAPI.deleteInvite(item.id);
await loadCustomer();
showToast("已删除邀请关系");
Toast.success("已删除邀请关系");
}
async function confirmRemoveCustomer(): Promise<void> {
@@ -267,13 +262,13 @@ async function confirmRemoveCustomer(): Promise<void> {
}
async function removeCustomer(id: string): Promise<void> {
removing.value = true;
isRemoving.value = true;
try {
await CustomerAPI.remove(id);
showToast("客户已删除");
Toast.success("客户已删除");
await router.replace("/management/customers");
} finally {
removing.value = false;
isRemoving.value = false;
}
}
@@ -284,25 +279,6 @@ async function toEdit(): Promise<void> {
await router.push(`/management/customers/${customer.value.id}/edit`);
}
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 formatBirthdate(value?: Customer): string {
if (!value?.birthdate) {
return "无";
@@ -345,20 +321,17 @@ async function addInvitee(invitee: Customer): Promise<boolean> {
return false;
}
if (invitee.id === customer.value.id) {
showToast("不能把客户本人添加为邀请客户", "warning");
Toast.warning("不能把客户本人添加为邀请客户");
return false;
}
if (isCustomerAncestor(invitee)) {
showToast("不能把上级邀请人添加为邀请客户", "warning");
Toast.warning("不能把上级邀请人添加为邀请客户");
return false;
}
await CustomerAPI.updateInvite({
customerId: invitee.id,
introducerCustomerId: customer.value.id
});
await CustomerAPI.saveInvite(customer.value.id, invitee.id);
await loadCustomer();
showToast(`已添加 ${invitee.name || invitee.code}`);
Toast.success(`已添加 ${invitee.name || invitee.code}`);
return true;
}
@@ -403,7 +376,7 @@ function handleScanStatusChange(value: string): void {
async function confirmScannedInvitee(): Promise<void> {
if (!pendingInvitee.value) {
showToast("请先扫码识别客户", "warning");
Toast.warning("请先扫码识别客户");
return;
}
@@ -413,17 +386,17 @@ async function confirmScannedInvitee(): Promise<void> {
}
async function loadCustomerByCode(code: string): Promise<Customer | undefined> {
return await CustomerAPI.nullableDetailByCode(code) || undefined;
return await CustomerAPI.findByCode(code) || undefined;
}
async function goCustomerDetail(target: Customer): Promise<void> {
async function toCustomerDetail(target: Customer): Promise<void> {
if (!target.id) {
return;
}
await router.push(`/management/customers/${target.id}`);
}
async function goCustomerDataCharts(): Promise<void> {
async function toCustomerDataCharts(): Promise<void> {
if (!customer.value?.id) {
return;
}
@@ -451,9 +424,7 @@ watch(() => route.params.id, async () => {
margin: 0 1rem;
}
:deep(.t-cell__title) {
flex: 0 0 auto;
}
.info {
:deep(.t-cell__title-text) {
white-space: nowrap;
@@ -465,6 +436,7 @@ watch(() => route.params.id, async () => {
white-space: normal;
overflow-wrap: anywhere;
}
}
.conditioning-part {
gap: .5rem;
@@ -473,59 +445,7 @@ watch(() => route.params.id, async () => {
flex-direction: column;
}
.invite {
.inviter-profile {
gap: .5rem;
display: flex;
cursor: pointer;
padding: .75rem;
align-items: center;
.profile-card {
flex: 1;
min-width: 0;
}
}
.item {
.title {
gap: .5rem;
display: flex;
align-items: center;
}
&:last-child {
:deep(.t-cell:after) {
display: none;
}
}
}
.avatar {
width: 3rem;
height: 3rem;
border-radius: .875rem;
&.fallback {
color: #2563eb;
display: flex;
font-size: 1.125rem;
background: #dbeafe;
align-items: center;
font-weight: 600;
justify-content: center;
}
}
.remove {
width: fit-content;
padding: 0 1rem;
display: flex;
align-items: center;
}
.invite-list {
.header {
display: flex;
@@ -542,11 +462,48 @@ watch(() => route.params.id, async () => {
}
.actions {
gap: 1rem;
gap: .875rem;
display: flex;
}
}
.item {
&:last-child {
:deep(.t-cell:after) {
display: none;
}
}
.cell {
padding: 6px 10px 6px 6px;
.avatar {
width: 3.25rem;
height: 3.25rem;
border-radius: .875rem;
&.fallback {
color: #2563eb;
display: flex;
font-size: 1.125rem;
background: #dbeafe;
align-items: center;
font-weight: 600;
justify-content: center;
}
}
}
}
.remove {
width: fit-content;
padding: 0 1rem;
display: flex;
align-items: center;
}
.empty {
color: #52607a;
padding: 1rem;
@@ -627,4 +584,3 @@ watch(() => route.params.id, async () => {
}
}
</style>
+137 -289
View File
@@ -3,7 +3,7 @@
<t-form class="form" label-align="left" label-width="6rem">
<t-form-item label="客户编码">
<t-input
v-model.trim="formModel.code"
v-model.trim="formData.code"
placeholder="客户编码"
borderless
>
@@ -29,50 +29,50 @@
</div>
<t-form-item label="姓名" required-mark>
<t-input
v-model.trim="formModel.name"
v-model.trim="formData.name"
placeholder="姓名"
borderless
/>
</t-form-item>
<t-form-item label="出生日期">
<birthdate-picker
v-model="formModel.birthdate"
v-model:calendar="formModel.birthdateCalendar"
v-model="formData.birthdate"
v-model:calendar="formData.birthdateCalendar"
/>
</t-form-item>
<t-form-item label="年龄">
<t-input
v-model.trim="formModel.age"
v-model.trim="formData.age"
type="number"
:readonly="!!formModel.birthdate"
:placeholder="formModel.birthdate ? '根据出生日期自动计算' : '年龄'"
:readonly="!!formData.birthdate"
:placeholder="formData.birthdate ? '根据出生日期自动计算' : '年龄'"
borderless
/>
</t-form-item>
<t-form-item label="照片">
<photo-field
v-model="formModel.photoURL"
v-model="photoURL"
alt-text="客户照片"
:loading="photoUploading"
:loading="isPhotoUploading"
:preview="false"
@add="selectPhoto"
@clear="resetPhotoState"
@add="photoInputRef?.click"
@clear="photoTempFileId = ''"
/>
</t-form-item>
<t-form-item label="性别">
<t-radio-group class="sex" v-model="formModel.gender" variant="default-filled">
<t-radio-group class="sex" v-model="formData.gender" variant="default-filled">
<t-radio :block="false" borderless value="MALE">男</t-radio>
<t-radio :block="false" borderless value="FEMALE">女</t-radio>
<t-radio :block="false" borderless value="">未知</t-radio>
</t-radio-group>
</t-form-item>
<t-form-item label="手机号">
<t-input v-model.trim="formModel.telephone" type="tel" placeholder="手机号" borderless />
<t-input v-model.trim="formData.telephone" type="tel" placeholder="手机号" borderless />
</t-form-item>
<t-form-item label="介绍人">
<div class="referrer">
<t-input
v-model.trim="formModel.referrer"
:value="formData.introducerCustomer?.name"
readonly
placeholder="选择或扫码"
borderless
@@ -84,17 +84,17 @@
size="extra-small"
@click="selectorVisible = true"
>选择</t-button>
<t-button theme="primary" size="extra-small" @click="toggleScanner('referrer')">
<span v-text="scannerVisible && scannerTarget === 'referrer' ? '关闭扫码' : '扫码'" />
<t-button theme="primary" size="extra-small" @click="toggleScanner('introducer')">
<span v-text="scannerVisible && scannerTarget === 'introducer' ? '关闭扫码' : '扫码'" />
</t-button>
</div>
</div>
</t-form-item>
<div v-if="scannerVisible && scannerTarget === 'referrer'" class="code-scanner-wrapper">
<div v-if="scannerVisible && scannerTarget === 'introducer'" class="code-scanner-wrapper">
<code-scanner
visible
:status-text="scanStatus"
active-text="正在识别介绍人编码。"
active-text="正在识别介绍人编码"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/>
@@ -102,7 +102,7 @@
<t-form-item label="住址">
<t-textarea
class="textarea"
v-model.trim="formModel.address"
v-model.trim="formData.address"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="住址"
/>
@@ -110,7 +110,7 @@
<t-form-item label="病症">
<t-textarea
class="textarea"
v-model.trim="formModel.disease"
v-model.trim="formData.disease"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="病症"
/>
@@ -122,7 +122,7 @@
<t-form-item label="备注">
<t-textarea
class="textarea"
v-model.trim="formModel.note"
v-model.trim="formData.remark"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="客户备注"
/>
@@ -139,8 +139,8 @@
theme="primary"
class="save"
:disabled="!canSubmit"
:loading="saving"
@click="saveCustomer"
:loading="isSubmitting"
@click="doSubmit"
>
<template #icon>
<t-icon name="check" />
@@ -148,9 +148,9 @@
保存
</t-button>
<customer-selector
v-model="referrerCustomerId"
v-model="introducerCustomerId"
v-model:visible="selectorVisible"
@change="handleReferrerChange"
@change="handleIntroducerChange"
/>
</div>
</template>
@@ -164,8 +164,8 @@ import CodeScanner from "@/components/CodeScanner.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PhotoField from "@/components/PhotoField.vue";
import { useSettingStore } from "@/store/settingStore";
import type { BodyAreaValue, Customer, CustomerBirthdateCalendar, CustomerGender } from "@/types/Customer";
import { createBodyAreaValue, getCustomerPhotoURL, normalizeBodyAreaValue } from "@/utils/customer";
import type { BodyAreaValue, Customer } from "@/types/Customer";
import { createBodyAreaValue, normalizeBodyAreaValue } from "@/utils/customer";
import { permissionCodes } from "@/utils/permission";
import { compressUploadImage } from "@/utils/imageCompression";
@@ -173,126 +173,27 @@ defineOptions({
name: "CustomerEdit"
});
type ScanTarget = "code" | "referrer";
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
const isCreateMode = computed(() => route.name === "CustomerCreate");
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.customer.create : permissionCodes.customer.update);
const selectedAreas = ref<BodyAreaValue>(createBodyAreaValue());
const selectorVisible = ref(false);
const referrerCustomerId = ref("");
const photoInputRef = ref<HTMLInputElement>();
const photoTempFileId = ref("");
const photoUploading = ref(false);
const scannerVisible = ref(false);
const scannerTarget = ref<ScanTarget>("code");
const scanStatus = ref("点击扫码开始识别。");
const currentCustomer = ref<Customer>();
const saving = ref(false);
const canSubmit = computed(() => !!formModel.name.trim());
let latestDecodedText = "";
const formModel = reactive({
code: "",
name: "",
gender: "" as CustomerGender | "",
photoURL: "",
telephone: "",
age: "",
birthdate: "",
birthdateCalendar: "SOLAR" as CustomerBirthdateCalendar,
address: "",
referrer: "",
disease: "",
inviterCustomerId: "",
note: ""
});
function resetFormModel(): void {
Object.assign(formModel, {
code: "",
name: "",
gender: "",
photoURL: "",
telephone: "",
age: "",
birthdate: "",
birthdateCalendar: "SOLAR",
address: "",
referrer: "",
disease: "",
inviterCustomerId: "",
note: ""
});
currentCustomer.value = undefined;
selectedAreas.value = createBodyAreaValue();
referrerCustomerId.value = "";
photoTempFileId.value = "";
scannerVisible.value = false;
}
async function fillCreateInviter(): Promise<void> {
const inviterCode = String(route.query.inviterCode || "");
if (!inviterCode) {
return;
}
const inviter = await findCustomer(inviterCode);
if (!inviter) {
return;
}
formModel.referrer = inviter.name || inviter.code || "";
formModel.inviterCustomerId = inviter.id || "";
referrerCustomerId.value = inviter.id || "";
}
async function fillForm(): Promise<void> {
resetFormModel();
if (isCreateMode.value) {
await fillCreateInviter();
return;
}
const id = String(route.params.id || "");
if (!id) {
return;
}
try {
const customer = await CustomerAPI.detail(id);
currentCustomer.value = customer;
Object.assign(formModel, {
code: customer.code || "",
name: customer.name || "",
gender: customer.gender || "",
photoURL: getCustomerPhotoURL(customer),
telephone: customer.telephone || "",
age: customer.age === undefined || customer.age === null ? "" : String(customer.age),
birthdate: customer.birthdate || "",
birthdateCalendar: customer.birthdateCalendar || "SOLAR",
address: customer.address || "",
referrer: customer.introducerCustomer?.name || "",
disease: customer.disease || "",
inviterCustomerId: customer.introducerCustomerId || "",
note: customer.remark || ""
});
selectedAreas.value = normalizeBodyAreaValue(customer.conditioningPart);
referrerCustomerId.value = customer.introducerCustomer?.id || "";
} catch (err) {
console.warn("客户详情加载失败", err);
showToast("客户详情加载失败", "warning");
}
}
// ---------- 扫码 ----------
type ScanTarget = "code" | "introducer";
const scannerVisible = ref(false);
const scannerTarget = ref<ScanTarget>("code");
const scanStatus = ref("点击扫码开始识别");
const introducerCustomerId = ref("");
async function toggleScanner(target: ScanTarget): Promise<void> {
if (scannerVisible.value && scannerTarget.value === target) {
scannerVisible.value = false;
return;
}
scannerTarget.value = target;
scannerVisible.value = true;
latestDecodedText = "";
@@ -303,192 +204,92 @@ async function handleDecodedText(rawText: string): Promise<void> {
if (!normalizedText || normalizedText === latestDecodedText) {
return;
}
latestDecodedText = normalizedText;
await settingStore.playScanSuccessSound();
if (scannerTarget.value === "code") {
formModel.code = normalizedText;
scanStatus.value = `已识别客户编码 ${normalizedText}。`;
// 选择客户扫码
formData.code = normalizedText;
scanStatus.value = `已识别客户编码 ${normalizedText}`;
scannerVisible.value = false;
return;
} else {
// 选择介绍人扫码
await applyIntroducer(normalizedText);
}
await applyReferrer(normalizedText);
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
// ---------- 照片 ----------
function selectPhoto(): void {
photoInputRef.value?.click();
}
function resetPhotoState(): void {
photoTempFileId.value = "";
}
async function handlePhotoSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
photoUploading.value = true;
try {
const compressedFile = await compressUploadImage(file);
const [tempFile] = await AttachmentAPI.uploadTemp(compressedFile);
if (!tempFile?.id) {
showToast("客户照片上传失败", "warning");
return;
}
photoTempFileId.value = tempFile.id;
formModel.photoURL = AttachmentAPI.getTempReadURL(tempFile.id);
} finally {
photoUploading.value = false;
input.value = "";
}
}
// ---------- 介绍人 ----------
async function findCustomer(rawValue: string): Promise<Customer | undefined> {
const normalizedText = rawValue.trim();
if (!normalizedText) {
return undefined;
}
const selectorVisible = ref(false);
const customer = await CustomerAPI.nullableDetailByCode(normalizedText);
if (customer) {
return customer;
}
const list = await CustomerAPI.search({ keyword: normalizedText });
return list[0];
}
async function applyReferrer(rawValue: string): Promise<void> {
const normalizedText = rawValue.trim();
if (!normalizedText) {
formModel.referrer = "";
formModel.inviterCustomerId = "";
referrerCustomerId.value = "";
async function applyIntroducer(code: string): Promise<void> {
if (!code) {
formData.introducerCustomerId = "";
introducerCustomerId.value = "";
scannerVisible.value = false;
return;
}
const matched = await findCustomer(normalizedText);
const matched = await CustomerAPI.findByCode(code);
if (!matched?.id) {
formModel.referrer = "";
formModel.inviterCustomerId = "";
referrerCustomerId.value = "";
scanStatus.value = `未匹配到介绍人 ${normalizedText},请重新选择或扫码。`;
formData.introducerCustomerId = "";
introducerCustomerId.value = "";
scannerVisible.value = false;
showToast("介绍人不存在,请选择或扫码已有客户", "warning");
scannerVisible.value = false;
Toast.warning(`未匹配到 ${code} 编码介绍人,请选择或扫码已有客户`);
return;
}
formModel.referrer = matched.name || matched.code || "";
formModel.inviterCustomerId = matched.id;
referrerCustomerId.value = matched.id;
scanStatus.value = `已匹配介绍人 ${formModel.referrer}。`;
formData.introducerCustomerId = matched.id;
formData.introducerCustomer = matched;
introducerCustomerId.value = matched.id;
scannerVisible.value = false;
}
async function handleReferrerChange(id: string): Promise<void> {
const customer = await CustomerAPI.detail(id);
formModel.referrer = customer.name || customer.code || "";
formModel.inviterCustomerId = customer.id || "";
referrerCustomerId.value = customer.id || "";
scanStatus.value = `已选择介绍人 ${formModel.referrer}。`;
async function handleIntroducerChange(id: string): Promise<void> {
const customer = await CustomerAPI.findById(id);
introducerCustomerId.value = customer!.id!;
formData.introducerCustomer = customer;
}
// ---------- 保存 ----------
// ---------- 表单 ----------
async function saveCustomer(): Promise<void> {
if (!validateForm()) {
const photoInputRef = ref<HTMLInputElement>();
const photoTempFileId = ref("");
const isPhotoUploading = ref(false);
const photoURL = ref();
const selectedAreas = ref<BodyAreaValue>(createBodyAreaValue());
const formData = reactive<Customer>({
});
const canSubmit = computed(() => formData.name?.trim());
async function loadDetail() {
const id = String(route.params.id || "");
if (!id) {
return;
}
const customerCode = formModel.code.trim();
const payload: Customer = {
...(currentCustomer.value || {}),
code: customerCode || null,
name: formModel.name,
gender: formModel.gender || undefined,
age: Number(formModel.age) || undefined,
birthdate: formModel.birthdate || null,
birthdateCalendar: formModel.birthdate ? formModel.birthdateCalendar : undefined,
telephone: formModel.telephone,
address: formModel.address,
remark: formModel.note,
disease: formModel.disease,
conditioningPart: selectedAreas.value,
introducerCustomerId: formModel.inviterCustomerId || null,
attachmentIdList: photoTempFileId.value ? [photoTempFileId.value] : undefined
};
saving.value = true;
try {
let savedCustomer = currentCustomer.value;
if (isCreateMode.value) {
savedCustomer = await CustomerAPI.create(payload);
} else {
await CustomerAPI.update(payload);
}
showToast("客户已保存");
const redirect = String(route.query.redirect || "");
if (redirect) {
await router.replace(redirect);
return;
}
if (savedCustomer?.id) {
await router.replace(`/management/customers/${savedCustomer.id}`);
return;
}
await router.replace("/management/customers");
} finally {
saving.value = false;
const customer = await CustomerAPI.detail(id);
Object.assign(formData, customer);
selectedAreas.value = normalizeBodyAreaValue(customer.conditioningPart);
introducerCustomerId.value = customer.introducerCustomer?.id || "";
} catch (err) {
console.warn("客户详情加载失败", err);
Toast.error("客户详情加载失败");
}
}
onMounted(loadDetail);
watch(() => route.fullPath, loadDetail);
function validateForm(): boolean {
if (!formModel.name.trim()) {
showToast("请填写客户姓名", "warning");
return false;
}
// ---------- 年龄 ----------
return true;
}
function showToast(message: string, theme: "success" | "warning" = "success"): void {
Toast({
theme,
direction: "row",
message
});
}
watch(() => formModel.referrer, (value) => {
if (!value.trim()) {
formModel.inviterCustomerId = "";
referrerCustomerId.value = "";
}
});
watch(() => formModel.birthdate, (value) => {
watch(() => formData.birthdate, (value) => {
if (value) {
formModel.age = String(calculateAge(value));
}
});
function calculateAge(value: string): number {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return 0;
}
if (match) {
const birthYear = Number(match[1]);
const birthMonth = Number(match[2]);
const birthDay = Number(match[3]);
@@ -497,16 +298,63 @@ function calculateAge(value: string): number {
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
age--;
}
return Math.max(age, 0);
formData.age = Math.max(age, 0);
} else {
formData.age = undefined;
}
}
});
// ---------- 照片 ----------
async function handlePhotoSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
isPhotoUploading.value = true;
try {
const compressedFile = await compressUploadImage(file);
const [tempFile] = await AttachmentAPI.upload(compressedFile);
photoTempFileId.value = tempFile.id;
photoURL.value = AttachmentAPI.getTempReadURL(tempFile.id);
} finally {
isPhotoUploading.value = false;
input.value = "";
}
}
onMounted(async () => {
await fillForm();
});
// ---------- 保存 ----------
watch(() => route.fullPath, async () => {
await fillForm();
});
const isSubmitting = ref(false);
async function doSubmit(): Promise<void> {
isSubmitting.value = true;
try {
formData.conditioningPart = selectedAreas.value;
formData.attachmentList = photoTempFileId.value ? [{
tempFileId: photoTempFileId.value
}] : undefined;
if (isCreateMode.value) {
const id = await CustomerAPI.create(formData);
Toast.success("创建成功");
await router.replace(`/management/customers/${id}`);
} else {
await CustomerAPI.update(formData);
Toast.success("保存成功");
const redirect = String(route.query.redirect || "");
if (redirect) {
await router.replace(redirect);
return;
}
}
await router.replace("/management/customers");
} finally {
isSubmitting.value = false;
}
}
</script>
<style scoped lang="less">
+66 -102
View File
@@ -1,5 +1,5 @@
<template>
<div ref="pageRef" v-can="permissionCodes.customer.read" class="customer-list">
<div v-can="permissionCodes.customer.read" class="customer-list">
<div class="search-bar">
<t-input
v-model.trim="keyword"
@@ -11,14 +11,21 @@
</div>
<section class="total">
<span class="gray">客户总数:</span>
<span class="bold" v-text="String(totalCount)" />
<span class="bold" v-text="pageResult.total" />
</section>
<t-pull-down-refresh
v-model="isRefreshing"
class="list-refresh"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="fetch"
>
<section class="items">
<div
class="item"
v-for="(item, index) in customerList"
v-for="(item, index) in pageResult.list"
:key="item.id || item.code || index"
@click="goDetail(item)"
@click="router.push(`/management/customers/${item.id}`)"
>
<t-image v-if="getCustomerPhotoURL(item)" :src="getCustomerPhotoURL(item)" :alt="item.name" fit="cover" class="avatar" />
<div v-else class="avatar fallback" v-text="(item.name || item.code || '客').slice(0, 1)" />
@@ -46,18 +53,19 @@
</div>
</div>
</div>
<div v-if="!customerList.length" class="empty-block" v-text="loading ? '正在加载客户' : '暂无客户数据'" />
<div v-if="customerList.length" class="load-state" v-text="loading ? '正在加载更多' : noMore ? '已经到底了' : '上拉加载更多'" />
<div v-if="!pageResult.list.length" class="empty-block" v-text="isLoading ? '正在加载客户' : '暂无客户数据'" />
<div v-if="pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载更多' : isNoMore ? '已经到底了' : '上拉加载更多'" />
</section>
</t-pull-down-refresh>
<div class="bottom-actions">
<t-button
v-can="permissionCodes.customer.export"
theme="primary"
variant="outline"
class="export bg-white"
:loading="exporting"
:disabled="exporting"
@click="exportCustomers"
:loading="isExporting"
:disabled="isExporting"
@click="doExport"
>
<template #icon>
<t-icon name="download" />
@@ -81,10 +89,10 @@
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { Scroller, Time, Toolkit } from "timi-web";
import { type Page, type PageResult, Time, Toolkit } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import GenderIcon from "@/components/GenderIcon.vue";
import type { Customer, CustomerSearchReq } from "@/types/Customer";
import type { Customer } from "@/types/Customer";
import { getCustomerPhotoURL } from "@/utils/customer";
import { permissionCodes } from "@/utils/permission";
@@ -92,113 +100,69 @@ defineOptions({
name: "CustomerList"
});
const scrollListenerName = "CustomerList";
const router = useRouter();
const pageSize = 20;
const keyword = ref("");
const loading = ref(false);
const exporting = ref(false);
const noMore = ref(false);
const pageIndex = ref(0);
const totalCount = ref(0);
const customerList = ref<Customer[]>([]);
const pageRef = ref<HTMLElement>();
let loadToken = 0;
let keywordTimer: number | undefined;
const isRefreshing = ref(false);
const isExporting = ref(false);
function getPageScrollElement(): HTMLElement | null {
return pageRef.value?.closest(".pages") || null;
}
// ---------- 分页 ----------
async function reloadCustomers(): Promise<void> {
pageIndex.value = 0;
totalCount.value = 0;
customerList.value = [];
noMore.value = false;
await loadCustomers();
}
async function loadCustomers(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
const token = loadToken + 1;
loadToken = token;
loading.value = true;
try {
const result = await CustomerAPI.searchPage({
index: pageIndex.value,
size: pageSize,
equalsExample: buildSearchReq()
const keyword = ref();
const isLoading = ref(false);
const isNoMore = ref(false);
const pageResult = reactive<PageResult<Customer>>({
total: 0,
list: []
});
if (token !== loadToken) {
return;
const page = reactive<Page<Customer>>({
index: 0,
size: 15
});
const fetch = Toolkit.debounce(async () => {
const result = await CustomerAPI.list({
...page,
likesExample: {
code: keyword.value,
name: keyword.value,
telephone: keyword.value,
}
customerList.value = customerList.value.concat(result.list || []);
totalCount.value = result.total || 0;
pageIndex.value += 1;
noMore.value = customerList.value.length >= totalCount.value || (result.list || []).length < pageSize;
});
pageResult.total = result.total;
pageResult.list = pageResult.list.concat(result.list);
page.index += 1;
isNoMore.value = (result.list || []).length < page.size || pageResult.total <= pageResult.list.length;
});
async function refresh(): Promise<void> {
isRefreshing.value = true;
try {
page.index = 0;
page.size = 15;
pageResult.total = 0;
pageResult.list = [];
fetch();
} finally {
if (token === loadToken) {
loading.value = false;
}
isRefreshing.value = false;
}
}
async function exportCustomers(): Promise<void> {
if (exporting.value) {
onMounted(fetch);
// ---------- 导出 ----------
async function doExport(): Promise<void> {
if (isExporting.value) {
return;
}
exporting.value = true;
isExporting.value = true;
try {
const blob = await CustomerAPI.exportExcel(buildSearchReq());
const blob = await CustomerAPI.exportXSSF(page);
Toolkit.downloadBlob(blob, `客户列表-${Time.formatDate(new Date())}.xlsx`);
showToast("导出已开始");
Toast.success("导出已开始");
} finally {
exporting.value = false;
isExporting.value = false;
}
}
async function goDetail(customer: Customer): Promise<void> {
if (!customer.id) {
showToast("客户 ID 缺失,无法打开详情", "warning");
return;
}
await router.push(`/management/customers/${customer.id}`);
}
function buildSearchReq(): CustomerSearchReq {
return {
keyword: keyword.value || undefined
};
}
function showToast(message: string, theme: "success" | "warning" = "success"): void {
Toast({
message,
theme
});
}
watch(keyword, async () => {
window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(async () => {
await reloadCustomers();
}, 300);
});
onMounted(async () => {
await reloadCustomers();
Scroller.addBottomListener(scrollListenerName, loadCustomers, {
canLoad: () => !loading.value && !noMore.value,
getElement: getPageScrollElement
});
});
onUnmounted(() => {
window.clearTimeout(keywordTimer);
Scroller.removeListener(scrollListenerName);
});
</script>
<style scoped lang="less">
+103 -86
View File
@@ -95,8 +95,8 @@
class="delete bg-white"
theme="danger"
variant="outline"
:loading="removing"
:disabled="saving || removing"
:loading="isRemoving"
:disabled="isSaving || isRemoving"
@click="confirmRemoveEventItem"
>
删除事件
@@ -105,8 +105,8 @@
v-can="savePermissionCode"
class="save"
theme="primary"
:loading="saving"
:disabled="removing"
:loading="isSaving"
:disabled="isRemoving"
@click="saveEventItem"
>
保存
@@ -201,6 +201,7 @@ defineOptions({
const route = useRoute();
const router = useRouter();
const routeId = computed(() => String(route.params.id || ""));
const isCreateMode = computed(() => !routeId.value);
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.event.create : permissionCodes.event.update);
const deletePermissionCode = permissionCodes.event.delete;
@@ -208,44 +209,12 @@ const pageAccess = computed(() => ({
value: [permissionCodes.event.read, permissionCodes.user.read, savePermissionCode.value],
mode: "and" as const
}));
// ---------- 基础表单 ----------
const roleOptions = ref<Role[]>([]);
const colorPickerVisible = ref(false);
const colorPickerRenderKey = ref(0);
const colorDraft = ref("#2f88ff");
const beginAtPickerVisible = ref(false);
const endAtPickerVisible = ref(false);
const beginAtDraftText = ref("");
const endAtDraftText = ref("");
const saving = ref(false);
const removing = ref(false);
const colorSwatchList = [
"#2F88FF",
"#0EA5E9",
"#10B981",
"#22C55E",
"#F59E0B",
"#F97316",
"#EF4444",
"#EC4899",
"#8B5CF6",
"#64748B"
];
const limitOptions: LimitOption[] = [
{ label: "不限", value: "NONE" },
{ label: "上午和下午各一次", value: "DAILY_HALF_DAY" },
{ label: "间隔小时内一次", value: "INTERVAL_HOUR" },
{ label: "每个自然日一次", value: "NATURAL_DAY" },
{ label: "每个自然月一次", value: "NATURAL_MONTH" },
{ label: "事件总次数", value: "TOTAL_LIMIT" },
{ label: "单客户总次数", value: "CUSTOMER_TOTAL_LIMIT" }
];
const statusOptions: Array<{ label: string; value: EventStatus }> = [
{ label: "草稿", value: "DRAFT" },
{ label: "可登记", value: "ACTIVE" },
{ label: "不可登记", value: "INACTIVE" }
];
const limitPickerColumns = [limitOptions];
const statusPickerColumns = [statusOptions];
const isSaving = ref(false);
const isRemoving = ref(false);
const formModel = reactive({
id: "",
name: "",
@@ -259,10 +228,57 @@ const formModel = reactive({
endAtText: "",
roleCodeList: [] as string[]
});
// ---------- 颜色 ----------
const colorPickerVisible = ref(false);
const colorPickerRenderKey = ref(0);
const colorDraft = ref("#2f88ff");
const colorSwatchList = [
"#2F88FF",
"#0EA5E9",
"#10B981",
"#22C55E",
"#F59E0B",
"#F97316",
"#EF4444",
"#EC4899",
"#8B5CF6",
"#64748B"
];
// ---------- 次数限制 ----------
const limitOptions: LimitOption[] = [
{ label: "不限", value: "NONE" },
{ label: "上午和下午各一次", value: "DAILY_HALF_DAY" },
{ label: "间隔小时内一次", value: "INTERVAL_HOUR" },
{ label: "每个自然日一次", value: "NATURAL_DAY" },
{ label: "每个自然月一次", value: "NATURAL_MONTH" },
{ label: "事件总次数", value: "TOTAL_LIMIT" },
{ label: "单客户总次数", value: "CUSTOMER_TOTAL_LIMIT" }
];
const limitPickerColumns = [limitOptions];
const limitPickerVisible = ref(false);
const limitPickerDraft = ref<string[]>([formModel.limitType]);
// ---------- 状态 ----------
const statusOptions: Array<{ label: string; value: EventStatus }> = [
{ label: "草稿", value: "DRAFT" },
{ label: "可登记", value: "ACTIVE" },
{ label: "不可登记", value: "INACTIVE" }
];
const statusPickerColumns = [statusOptions];
const statusPickerVisible = ref(false);
const statusPickerDraft = ref<EventStatus[]>([formModel.status]);
// ---------- 生效时间 ----------
const beginAtPickerVisible = ref(false);
const endAtPickerVisible = ref(false);
const beginAtDraftText = ref("");
const endAtDraftText = ref("");
const minimumDateTimeText = "1970-01-01 00:00";
const nowDateTimeText = computed(() => Time.formatDateTime(new Date()));
const endAtMinimumDateTimeText = computed(() => formModel.beginAtText || minimumDateTimeText);
@@ -281,6 +297,8 @@ const limitValuePlaceholder = computed(() => {
return "请输入总次数上限";
});
// ---------- 颜色 ----------
function openColorPicker(): void {
colorDraft.value = formModel.color;
colorPickerVisible.value = true;
@@ -304,6 +322,8 @@ function updateColorDraftFromPalette(context: { color: ColorObject }): void {
colorDraft.value = context.color.hex8;
}
// ---------- 选项文案 ----------
function getLimitText(value: EventLimitType): string {
return limitOptions.find(option => option.value === value)?.label || "不限";
}
@@ -312,6 +332,8 @@ function getStatusText(value: EventStatus): string {
return statusOptions.find(option => option.value === value)?.label || "可登记";
}
// ---------- 次数限制 ----------
function openLimitPicker(): void {
limitPickerDraft.value = [formModel.limitType];
limitPickerVisible.value = true;
@@ -325,6 +347,8 @@ function confirmLimitPicker(value: Array<string | number>): void {
limitPickerVisible.value = false;
}
// ---------- 状态 ----------
function openStatusPicker(): void {
statusPickerDraft.value = [formModel.status];
statusPickerVisible.value = true;
@@ -351,6 +375,8 @@ function resolveLimitValue(): number | undefined {
return 0 < value ? Math.floor(value) : undefined;
}
// ---------- 表单转换 ----------
function resolveTimestamp(valueText: string): number | undefined {
if (!valueText.trim()) {
return undefined;
@@ -363,41 +389,27 @@ function resolveTimestamp(valueText: string): number | undefined {
function validateForm(): boolean {
if (!formModel.name.trim()) {
showToast("请输入登记事件名称", "warning");
Toast.warning("请输入登记事件名称");
return false;
}
if (!formModel.roleCodeList.length) {
showToast("请至少选择一个可登记角色", "warning");
Toast.warning("请至少选择一个可登记角色");
return false;
}
if (isValueLimitType(formModel.limitType) && !resolveLimitValue()) {
showToast("请输入有效的约束数值", "warning");
Toast.warning("请输入有效的约束数值");
return false;
}
const beginAt = resolveTimestamp(formModel.beginAtText);
const endAt = resolveTimestamp(formModel.endAtText);
if (beginAt !== undefined && endAt !== undefined && endAt < beginAt) {
showToast("终止时间不能早于起始时间", "warning");
Toast.warning("终止时间不能早于起始时间");
return false;
}
return true;
}
function buildPayload(): EventItem {
return {
id: formModel.id || undefined,
name: formModel.name.trim(),
description: formModel.description,
color: formModel.color,
status: formModel.status,
requirePhoto: formModel.requirePhoto,
limitType: formModel.limitType,
limitValue: resolveLimitValue(),
beginAt: resolveTimestamp(formModel.beginAtText),
endAt: resolveTimestamp(formModel.endAtText),
roleCodeList: [...formModel.roleCodeList]
};
}
// ---------- 生效时间 ----------
function openBeginAtPicker(): void {
beginAtDraftText.value = formModel.beginAtText || nowDateTimeText.value;
@@ -431,34 +443,48 @@ function clearEndAt(): void {
endAtPickerVisible.value = false;
}
// ---------- 保存 ----------
async function saveEventItem(): Promise<void> {
if (!validateForm()) {
return;
}
saving.value = true;
isSaving.value = true;
try {
const payload = buildPayload();
const payload: EventItem = {
id: formModel.id || undefined,
name: formModel.name.trim(),
description: formModel.description,
color: formModel.color,
status: formModel.status,
requirePhoto: formModel.requirePhoto,
limitType: formModel.limitType,
limitValue: resolveLimitValue(),
beginAt: resolveTimestamp(formModel.beginAtText),
endAt: resolveTimestamp(formModel.endAtText),
roleCodeList: [...formModel.roleCodeList]
};
if (isCreateMode.value) {
await EventAPI.create(payload);
} else {
await EventAPI.update(payload);
}
showToast("登记事件已保存");
Toast.success("登记事件已保存");
await router.replace("/management/registration-events");
} catch {
showToast("登记事件保存失败", "error");
Toast.error("登记事件保存失败");
} finally {
saving.value = false;
isSaving.value = false;
}
}
// ---------- 删除 ----------
async function confirmRemoveEventItem(): Promise<void> {
const id = formModel.id || routeId.value;
if (!id || removing.value) {
if (!id || isRemoving.value) {
return;
}
const eventName = formModel.name.trim() || "未命名事件";
const dialog = DialogPlugin.confirm({
title: "删除登记事件",
@@ -476,36 +502,28 @@ async function confirmRemoveEventItem(): Promise<void> {
}
async function removeEventItem(id: string): Promise<void> {
if (removing.value) {
if (isRemoving.value) {
return;
}
removing.value = true;
isRemoving.value = true;
try {
await EventAPI.remove(id);
showToast("登记事件已删除");
Toast.success("登记事件已删除");
await router.replace("/management/registration-events");
} catch {
showToast("登记事件删除失败", "error");
Toast.error("登记事件删除失败");
} finally {
removing.value = false;
isRemoving.value = false;
}
}
function showToast(message: string, theme: "success" | "error" | "warning" = "success"): void {
Toast({
theme,
direction: "row",
message
});
}
// ---------- 页面数据 ----------
async function fillForm(): Promise<void> {
roleOptions.value = await ManagedUserAPI.roleList();
if (isCreateMode.value) {
return;
}
const current = await EventAPI.detail(routeId.value);
Object.assign(formModel, {
id: current.id || "",
@@ -516,8 +534,8 @@ async function fillForm(): Promise<void> {
requirePhoto: current.requirePhoto ?? false,
limitType: current.limitType || "NONE",
limitValueText: current.limitValue ? String(current.limitValue) : "",
beginAtText: current.beginAt ? Time.formatDateTime(new Date(current.beginAt)) : "",
endAtText: current.endAt ? Time.formatDateTime(new Date(current.endAt)) : "",
beginAtText: current.beginAt ? Time.toDateTime(current.beginAt) : "",
endAtText: current.endAt ? Time.toDateTime(current.endAt) : "",
roleCodeList: [...(current.roleCodeList || [])]
});
colorDraft.value = formModel.color;
@@ -527,7 +545,7 @@ onMounted(async () => {
try {
await fillForm();
} catch {
showToast("登记事件加载失败", "error");
Toast.error("登记事件加载失败");
}
});
</script>
@@ -696,4 +714,3 @@ onMounted(async () => {
}
}
</style>
+75 -38
View File
@@ -1,13 +1,19 @@
<template>
<div v-can="{ value: [permissionCodes.event.read, permissionCodes.user.read], mode: 'and' }" class="registration-event-list">
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="fetch"
>
<t-cell-group theme="card">
<t-cell
class="item"
v-for="item in eventList"
v-for="item in pageResult.list"
:key="item.id"
:arrow="canUpdateEventItem"
:hover="canUpdateEventItem"
@click="handleEventClick(item)"
@click="toEventItem(item)"
>
<template #title>
<div class="header">
@@ -40,8 +46,10 @@
</template>
</t-cell>
</t-cell-group>
<div v-if="!eventList.length" class="empty-block">暂无登记事件</div>
<t-button v-can="permissionCodes.event.create" class="create" theme="primary" @click="createEventItem">
<div v-if="!pageResult.list.length" class="empty-block" v-text="isLoading ? '正在加载登记事件' : '暂无登记事件'" />
<div v-if="pageResult.list.length" class="load-state" v-text="isNoMore ? '已经到底了' : '上拉加载更多'" />
</t-pull-down-refresh>
<t-button v-can="permissionCodes.event.create" class="create" theme="primary" @click="router.push('/management/registration-events/create')">
<template #icon>
<t-icon name="add" />
</template>
@@ -52,11 +60,11 @@
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { useUserStore, type Role } from "timi-web";
import { type Page, type PageResult, type Role, useUserStore } from "timi-web";
import EventAPI from "@/api/EventAPI";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { EventItem, EventStatus, EventLimitType } from "@/types/EventItem";
import type { EventItem, EventLimitType, EventStatus } from "@/types/EventItem";
import { permissionCodes } from "@/utils/permission";
defineOptions({
@@ -66,9 +74,22 @@ defineOptions({
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
// ---------- 列表数据 ----------
const defaultColor = "#2f88ff";
const eventList = ref<EventItem[]>([]);
const roleOptions = ref<Role[]>([]);
const isRefreshing = ref(false);
const isLoading = ref(false);
const isNoMore = ref(false);
const page = reactive<Page<EventItem>>({
index: 0,
size: 15
});
const pageResult = reactive<PageResult<EventItem>>({
total: 0,
list: []
});
const canUpdateEventItem = computed(() => userStore.hasPermissions(permissionCodes.event.update));
type StatusTag = {
@@ -85,29 +106,17 @@ const activeStatusTag: StatusTag = { text: "可登记", theme: "primary" };
const notEffectiveStatusTag: StatusTag = { text: "未生效", theme: "default" };
const expiredStatusTag: StatusTag = { text: "已过期", theme: "danger" };
function createEventItem(): void {
router.push("/management/registration-events/create");
}
async function goSort(): Promise<void> {
await router.push("/management/registration-events/sort");
}
function editEventItem(item: EventItem): void {
if (!item.id) {
return;
}
router.push(`/management/registration-events/${item.id}/edit`);
}
function handleEventClick(item: EventItem): void {
function toEventItem(item: EventItem): void {
if (!canUpdateEventItem.value) {
return;
}
editEventItem(item);
if (!item.id) {
return;
}
router.push(`/management/registration-events/${item.id}/edit`);
}
// ---------- 展示文案 ----------
function getStatusTag(item: EventItem): StatusTag {
const status = item.status || "ACTIVE";
@@ -143,31 +152,60 @@ function getRoleName(code: string): string {
return role?.name || code;
}
// ---------- 页面数据 ----------
async function fetch(): Promise<void> {
if (isLoading.value || isNoMore.value) {
return;
}
isLoading.value = true;
try {
const result = await EventAPI.list(page);
pageResult.total = result.total;
pageResult.list = pageResult.list.concat(result.list || []);
page.index += 1;
isNoMore.value = (result.list || []).length < page.size || pageResult.total <= pageResult.list.length;
} finally {
isLoading.value = false;
}
}
async function refresh(): Promise<void> {
isRefreshing.value = true;
try {
page.index = 0;
pageResult.total = 0;
pageResult.list = [];
isNoMore.value = false;
await fetch();
} finally {
isRefreshing.value = false;
}
}
async function loadData(): Promise<void> {
const [eventResult, roles] = await Promise.all([
EventAPI.list({
index: 0,
size: 100
}),
EventAPI.list(page),
ManagedUserAPI.roleList()
]);
roleOptions.value = roles || [];
eventList.value = eventResult.list || [];
pageResult.total = eventResult.total;
pageResult.list = eventResult.list || [];
page.index = 1;
isNoMore.value = pageResult.list.length >= pageResult.total || pageResult.list.length < page.size;
}
onMounted(async () => {
navBarStore.clearRight();
if (canUpdateEventItem.value) {
navBarStore.setRightText("排序", "var(--td-brand-color)", goSort);
navBarStore.setRightText("排序", "var(--td-brand-color)", () => {
router.push("/management/registration-events/sort");
});
}
try {
await loadData();
} catch {
Toast({
theme: "error",
direction: "row",
message: "登记事件加载失败"
});
Toast.error("登记事件加载失败");
}
});
@@ -245,4 +283,3 @@ onUnmounted(() => {
}
}
</style>
+14 -19
View File
@@ -3,7 +3,7 @@
<customer-profile-card v-if="record?.customer" class="customer-profile" :customer="record.customer" />
<t-cell-group v-if="record" title="登记信息" theme="card">
<t-cell title="登记事件" :note="record.event?.name || '未知事件'" />
<t-cell title="登记时间" :note="formatTime(record.registeredAt)" />
<t-cell title="登记时间" :note="record.registeredAt ? Time.toPassedDateTime(record.registeredAt) : '-'" />
<t-cell title="登记人" :note="Text.display(record.operator?.nick || record.operator?.name)" />
<t-cell title="客户编码" :note="Text.display(record.customer?.code)" />
<t-cell title="备注" :note="Text.display(record.remark)" />
@@ -16,7 +16,7 @@
title="登记照片"
/>
</section>
<t-empty v-else :description="loading ? '正在加载登记事件记录' : '登记事件记录不存在'" />
<t-empty v-else :description="isLoading ? '正在加载登记事件记录' : '登记事件记录不存在'" />
</div>
</template>
@@ -38,8 +38,11 @@ const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
// ---------- 记录数据 ----------
const record = ref<EventRecord>();
const loading = ref(false);
const isLoading = ref(false);
const canUpdateRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.update));
const recordPhotoList = computed(() => {
@@ -47,6 +50,8 @@ const recordPhotoList = computed(() => {
return url ? [url] : [];
});
// ---------- 页面数据 ----------
async function loadRecord(): Promise<void> {
const recordId = String(route.params.id || "");
if (!recordId) {
@@ -54,18 +59,20 @@ async function loadRecord(): Promise<void> {
return;
}
loading.value = true;
isLoading.value = true;
try {
record.value = await EventRecordAPI.detail(recordId);
} catch (err) {
record.value = undefined;
console.warn("登记事件记录加载失败", err);
} finally {
loading.value = false;
isLoading.value = false;
}
}
async function goEdit(): Promise<void> {
// ---------- 导航 ----------
async function toEdit(): Promise<void> {
const recordId = String(route.params.id || "");
if (!recordId) {
return;
@@ -73,18 +80,6 @@ async function goEdit(): Promise<void> {
await router.push(`/management/registration-event-records/${recordId}/edit`);
}
function formatTime(value?: number): string {
if (!value) {
return "-";
}
const date = Time.toDateObject(value);
if (!date) {
return "-";
}
return Time.formatDateTime(date);
}
watch(() => route.params.id, async () => {
await loadRecord();
}, { immediate: true });
@@ -92,7 +87,7 @@ watch(() => route.params.id, async () => {
onMounted(() => {
navBarStore.clearRight();
if (canUpdateRecord.value) {
navBarStore.setRightText("编辑", "var(--td-brand-color)", goEdit);
navBarStore.setRightText("编辑", "var(--td-brand-color)", toEdit);
}
});
+210 -206
View File
@@ -78,7 +78,7 @@
</t-form-item>
<div class="photo-part">
<photo-scroller v-model="photoList" :loading="photoUploading" @add="capturePhoto" />
<photo-scroller v-model="photoList" :loading="isPhotoUploading" @add="capturePhoto" />
</div>
</t-form>
<t-cell-group
@@ -91,10 +91,10 @@
v-for="item in recentRecordList"
:key="item.id"
:title="getRecordEventName(item)"
:description="formatTime(item.registeredAt)"
:description="item.registeredAt ? Time.toPassedDateTime(item.registeredAt) : '-'"
arrow
hover
@click="goRecordDetail(item.id || '')"
@click="toRecordDetail(item.id || '')"
>
<template #leftIcon>
<t-image v-if="getRecordPhotoURL(item)" :src="getRecordPhotoURL(item)" alt="事件照片" fit="cover" class="record-photo" />
@@ -132,7 +132,7 @@
multiple
@change="handleRecordPhotoSelected"
>
<t-button theme="primary" class="save" :disabled="!canSubmit" :loading="saving" @click="submitRecord">
<t-button theme="primary" class="save" :disabled="!canSubmit" :loading="isSaving" @click="submitRecord">
<span v-text="saveButtonText" />
</t-button>
</div>
@@ -177,28 +177,6 @@ defineOptions({
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 eventList = ref<EventItem[]>([]);
const selectedEventIdList = ref<string[]>([]);
const recentRecordList = ref<EventRecord[]>([]);
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<EventRecord>();
let latestDecodedText = "";
type EditablePhoto = {
attachment?: Partial<Attachment>;
@@ -206,13 +184,23 @@ type EditablePhoto = {
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 canSubmit = computed(() => selectedCustomerList.value.some(item => !!item.id) && 0 < selectedEventIdList.value.length);
const customerDisplayText = computed(() => {
if (1 < selectedCustomerList.value.length) {
return `已选择 ${selectedCustomerList.value.length} 位客户`;
@@ -220,67 +208,115 @@ const customerDisplayText = computed(() => {
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 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;
});
// ---------- 页面数据 ----------
async function loadPageData(): Promise<void> {
eventList.value = await EventAPI.enabledList();
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 || "";
photoItemList.value = (record.attachmentList || [])
.filter(item => item.attachType === "PHOTO" || !item.attachType)
.filter(item => !!item.id)
.map(item => ({
attachment: item,
url: AttachmentAPI.getReadURL(item.id || "")
}));
async function handleCustomerChange(value: string | string[]): Promise<void> {
selectedCustomerIdList.value = Array.isArray(value) ? value : [value];
await loadSelectedCustomerList();
await loadRecentRecordList();
}
function appendRecordEvent(record: EventRecord): void {
if (!record.event?.id || eventList.value.some(item => item.id === record.event?.id)) {
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;
}
eventList.value = [record.event, ...eventList.value];
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) {
@@ -316,104 +352,45 @@ 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();
}
const eventList = ref<EventItem[]>([]);
const selectedEventIdList = ref<string[]>([]);
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 || "";
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 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 = [];
function appendRecordEvent(record: EventRecord): void {
if (!record.event?.id || eventList.value.some(item => item.id === record.event?.id)) {
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 endAt = Time.toTimestamp(new Date());
recentRecordList.value = (await EventRecordAPI.rangeList({
customerId,
beginAt: 0,
endAt
}))
.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);
eventList.value = [record.event, ...eventList.value];
}
// ---------- 照片 ----------
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();
}
@@ -424,10 +401,10 @@ async function handleRecordPhotoSelected(event: Event): Promise<void> {
if (!fileList.length) {
return;
}
photoUploading.value = true;
isPhotoUploading.value = true;
try {
const compressedFileList = await compressUploadImageList(fileList);
const tempFileList = await AttachmentAPI.uploadTemp(compressedFileList);
const tempFileList = await AttachmentAPI.upload(compressedFileList);
for (const item of tempFileList) {
if (!item.id) {
continue;
@@ -438,12 +415,17 @@ async function handleRecordPhotoSelected(event: Event): Promise<void> {
});
}
} finally {
photoUploading.value = false;
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());
@@ -460,12 +442,35 @@ function resetRegisteredAt(): void {
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;
}
saving.value = true;
isSaving.value = true;
try {
if (isEditMode.value) {
await updateRecord();
@@ -479,7 +484,10 @@ async function submitRecord(): Promise<void> {
customerIdList,
remark: note.value || "客户现场登记",
registeredAt: buildRegisteredAt(),
attachmentIdList: buildTempFileIdList()
attachmentList: buildTempFileIdList().map(tempFileId => ({
tempFileId,
attachType: "PHOTO"
}))
});
}
selectedCustomerList.value = [];
@@ -488,20 +496,20 @@ async function submitRecord(): Promise<void> {
photoItemList.value = [];
await loadRecentRecordList();
await settingStore.playRegisterSuccessSound();
showToast("登记已提交");
Toast.success("登记已提交");
await router.replace("/customers");
} finally {
saving.value = false;
isSaving.value = false;
}
}
function validateForm(): boolean {
if (!selectedCustomerList.value.some(item => !!item.id)) {
showToast("请先选择或扫码客户", "warning");
Toast.warning("请先选择或扫码客户");
return false;
}
if (!selectedEventIdList.value.length) {
showToast("请至少选择一个登记事件", "warning");
Toast.warning("请至少选择一个登记事件");
return false;
}
@@ -511,7 +519,7 @@ function validateForm(): boolean {
async function updateRecord(): Promise<void> {
const submitCustomer = selectedCustomer.value;
if (!editingRecord.value?.id || !submitCustomer?.id) {
showToast("登记事件记录不存在", "warning");
Toast.warning("登记事件记录不存在");
return;
}
@@ -523,7 +531,7 @@ async function updateRecord(): Promise<void> {
registeredAt: buildRegisteredAt(),
attachmentList: buildPhotoAttachmentList()
});
showToast("记录已保存");
Toast.success("记录已保存");
await router.replace(`/management/registration-event-records/${editingRecord.value.id}`);
}
@@ -544,39 +552,36 @@ function buildPhotoAttachmentList(): Array<Partial<Attachment>> {
});
}
function buildRegisteredAt(): number {
return Time.toTimestamp(parseDateTime(registeredAtText.value));
// ---------- 页面数据 ----------
async function loadPageData(): Promise<void> {
eventList.value = await EventAPI.listValid();
if (isEditMode.value) {
await loadRecordForEdit();
return;
}
function showToast(message: string, theme: "success" | "warning" = "success"): void {
Toast({
theme,
direction: "row",
message
});
selectedEventIdList.value = eventList.value.slice(0, 1).map(item => item.id).filter((item): item is string => Boolean(item));
}
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);
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 || "";
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);
@@ -728,4 +733,3 @@ watch(customerCode, async (value) => {
}
}
</style>
+73 -79
View File
@@ -1,5 +1,5 @@
<template>
<div ref="pageRef" v-can="permissionCodes.eventRecord.read" class="registration-record-list">
<div v-can="permissionCodes.eventRecord.read" class="registration-record-list">
<div class="search-bar">
<t-input
v-model.trim="keyword"
@@ -43,13 +43,19 @@
<div v-text="summaryBehaviorText" />
<div v-text="summaryTotalText" />
</div>
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="reloadRecords"
@scrolltolower="loadRecords"
>
<t-cell-group
v-if="recordList.length"
v-if="pageResult.list.length"
class="record-list"
theme="card"
>
<t-swipe-cell
v-for="item in recordList"
v-for="item in pageResult.list"
:key="item.id"
class="item"
>
@@ -57,10 +63,10 @@
class="cell"
:title="getRecordEventName(item)"
:description="getRecordCustomerName(item)"
:note="formatTime(item.registeredAt)"
:note="item.registeredAt ? Time.toPassedDateTime(item.registeredAt) : '-'"
arrow
hover
@click="goDetail(item.id || '')"
@click="toDetail(item.id || '')"
>
<template #leftIcon>
<t-image
@@ -77,7 +83,7 @@
<t-button
class="delete"
theme="danger"
:loading="deletingId === item.id"
:loading="isDeleting && deletingId === item.id"
@click.stop="confirmRemoveRecord(item)"
>
删除
@@ -85,14 +91,15 @@
</template>
</t-swipe-cell>
</t-cell-group>
<div v-else class="empty-block" v-text="loading ? '正在加载登记事件记录' : '暂无登记事件记录'" />
<div v-if="recordList.length" class="load-state" v-text="loadStateText" />
<div v-else class="empty-block" v-text="isLoading ? '正在加载登记事件记录' : '暂无登记事件记录'" />
<div v-if="pageResult.list.length" class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
<t-button
v-can="permissionCodes.eventRecord.export"
class="export"
theme="primary"
:loading="exporting"
:loading="isExporting"
@click="exportExcel"
>
<template #icon>
@@ -124,7 +131,7 @@
<script setup lang="ts">
import { DialogPlugin, Toast, type DropdownValue } from "tdesign-mobile-vue";
import { AttachmentAPI, Scroller, Time, Toolkit, useUserStore } from "timi-web";
import { AttachmentAPI, Time, Toolkit, type PageResult, useUserStore } from "timi-web";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import { useNavBarStore } from "@/store/navBarStore";
@@ -139,7 +146,6 @@ defineOptions({
});
const pageSize = 20;
const scrollListenerName = "EventRecordList";
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
@@ -161,14 +167,9 @@ const actionFilter = ref<string[]>([]);
const eventList = ref<EventItem[]>([]);
const startVisible = ref(false);
const endVisible = ref(false);
const pageRef = ref<HTMLElement>();
let keywordTimer: number | undefined;
let initialized = false;
function getPageScrollElement(): HTMLElement | null {
return pageRef.value?.closest(".pages") || null;
}
const canCreateRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.create));
const isCustomRange = computed(() => dateFilterType.value === "custom");
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "今天");
@@ -229,63 +230,75 @@ watch(dateRange, async (value) => {
// ---------- 列表分页 ----------
const pageIndex = ref(0);
const totalCount = ref(0);
const recordList = ref<EventRecordListItemView[]>([]);
const loading = ref(false);
const noMore = ref(false);
const isRefreshing = ref(false);
const isLoading = ref(false);
const isNoMore = ref(false);
const page = reactive<EventRecordPage>({
index: 0,
size: pageSize
});
const pageResult = reactive<PageResult<EventRecordListItemView>>({
total: 0,
list: []
});
let loadToken = 0;
const summaryTotalText = computed(() => `共 ${totalCount.value} 条数据,已加载 ${recordList.value.length} 条`);
const summaryTotalText = computed(() => `共 ${pageResult.total} 条数据,已加载 ${pageResult.list.length} 条`);
const loadStateText = computed(() => {
if (loading.value) {
if (isLoading.value) {
return "正在加载更多";
}
return noMore.value ? "没有更多记录" : "上拉加载更多";
return isNoMore.value ? "没有更多记录" : "上拉加载更多";
});
async function reloadRecords(): Promise<void> {
pageIndex.value = 0;
totalCount.value = 0;
recordList.value = [];
noMore.value = false;
isRefreshing.value = true;
try {
page.index = 0;
pageResult.total = 0;
pageResult.list = [];
isNoMore.value = false;
await loadRecords();
} finally {
isRefreshing.value = false;
}
}
async function loadRecords(): Promise<void> {
if (loading.value || noMore.value) {
if (isLoading.value || isNoMore.value) {
return;
}
const token = loadToken + 1;
loadToken = token;
loading.value = true;
isLoading.value = true;
try {
const result = await EventRecordAPI.rangePage({
index: pageIndex.value,
size: pageSize,
equalsExample: buildRangeReq()
const result = await EventRecordAPI.list({
index: page.index,
size: page.size,
...buildRangeReq()
});
if (token !== loadToken) {
return;
}
recordList.value = recordList.value.concat(result.list || []);
totalCount.value = result.total || 0;
pageIndex.value += 1;
noMore.value = recordList.value.length >= totalCount.value || (result.list || []).length < pageSize;
pageResult.list = pageResult.list.concat(result.list || []);
pageResult.total = result.total || 0;
page.index += 1;
isNoMore.value = pageResult.list.length >= pageResult.total || (result.list || []).length < page.size;
} finally {
if (token === loadToken) {
loading.value = false;
isLoading.value = false;
}
}
}
// ---------- 操作 ----------
const exporting = ref(false);
const isExporting = ref(false);
const isDeleting = ref(false);
const deletingId = ref("");
async function loadActions(): Promise<void> {
eventList.value = await EventAPI.enabledList();
eventList.value = await EventAPI.listValid();
actionFilter.value = eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
}
@@ -302,27 +315,31 @@ async function selectDateFilter(type: DropdownValue): Promise<void> {
}
async function exportExcel(): Promise<void> {
if (exporting.value) {
if (isExporting.value) {
return;
}
exporting.value = true;
isExporting.value = true;
try {
const blob = await EventRecordAPI.rangeExport(buildRangeReq());
const blob = await EventRecordAPI.exportXSSF({
index: 0,
size: pageSize,
...buildRangeReq()
});
Toolkit.downloadBlob(blob, `登记事件记录-${Time.formatDate(new Date())}.xlsx`);
showToast("导出已开始");
Toast.success("导出已开始");
} finally {
exporting.value = false;
isExporting.value = false;
}
}
async function goDetail(id: string): Promise<void> {
async function toDetail(id: string): Promise<void> {
if (!id) {
return;
}
await router.push(`/management/registration-event-records/${id}`);
}
async function goCreate(): Promise<void> {
async function toCreate(): Promise<void> {
await router.push("/management/registration-event-records/create");
}
@@ -348,17 +365,19 @@ async function confirmRemoveRecord(record: EventRecordListItemView): Promise<voi
}
async function removeRecord(id: string): Promise<void> {
if (deletingId.value) {
if (isDeleting.value) {
return;
}
isDeleting.value = true;
deletingId.value = id;
try {
await EventRecordAPI.remove(id);
recordList.value = recordList.value.filter(item => item.id !== id);
totalCount.value = Math.max(0, totalCount.value - 1);
showToast("登记记录已删除");
pageResult.list = pageResult.list.filter(item => item.id !== id);
pageResult.total = Math.max(0, pageResult.total - 1);
Toast.success("登记记录已删除");
} finally {
isDeleting.value = false;
deletingId.value = "";
}
}
@@ -384,19 +403,7 @@ function getRecordCustomerInitial(record: EventRecordListItemView): string {
return (record.customerName || "客").slice(0, 1);
}
function formatTime(value?: number): string {
if (!value) {
return "-";
}
const date = Time.toDateObject(value);
if (!date) {
return "-";
}
return Time.formatDateTime(date);
}
function buildRangeReq(): EventRecordPage {
function buildRangeReq(): Omit<EventRecordPage, "index" | "size"> {
const [beginAt, endAt] = buildUnixRange();
const selectedAllEvent = !actionFilter.value.length || actionFilter.value.length === eventList.value.length;
return {
@@ -457,31 +464,19 @@ function parseDateTime(value: string, isEnd: boolean): Date {
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
function showToast(message: string, theme: "success" | "warning" = "success"): void {
Toast({
message,
theme
});
}
onMounted(async () => {
navBarStore.clearRight();
if (canCreateRecord.value) {
navBarStore.setRightText("添加", "var(--td-brand-color)", goCreate);
navBarStore.setRightText("添加", "var(--td-brand-color)", toCreate);
}
await loadActions();
initialized = true;
await reloadRecords();
Scroller.addBottomListener(scrollListenerName, loadRecords, {
canLoad: () => !loading.value && !noMore.value,
getElement: getPageScrollElement
});
});
onUnmounted(() => {
navBarStore.clearRight();
window.clearTimeout(keywordTimer);
Scroller.removeListener(scrollListenerName);
});
</script>
@@ -648,4 +643,3 @@ onUnmounted(() => {
}
}
</style>
+28 -23
View File
@@ -39,13 +39,13 @@
</template>
</t-cell>
</t-cell-group>
<div v-else class="empty-block" v-text="loading ? '正在加载登记事件' : '暂无登记事件'" />
<div v-else class="empty-block" v-text="isLoading ? '正在加载登记事件' : '暂无登记事件'" />
</div>
<t-button
class="save"
theme="primary"
:loading="saving"
:disabled="!sortDirty || saving || loading"
:loading="isSaving"
:disabled="!sortDirty || isSaving || isLoading"
@click="saveSort"
>
保存排序
@@ -65,12 +65,18 @@ defineOptions({
});
const navBarStore = useNavBarStore();
// ---------- 列表数据 ----------
const defaultColor = "#2f88ff";
const eventList = ref<EventItem[]>([]);
const sortListRef = ref<HTMLElement>();
const loading = ref(false);
const saving = ref(false);
const isLoading = ref(false);
const isSaving = ref(false);
const sortDirty = ref(false);
// ---------- 拖拽状态 ----------
const draggingIndex = ref(-1);
let pointerId: number | undefined;
let pointerStartY = 0;
@@ -78,6 +84,8 @@ let pointerMoved = false;
let pointerTarget: HTMLElement | undefined;
let dragInputType: "pointer" | "touch" | undefined;
// ---------- 展示文案 ----------
function getStatusText(status?: EventStatus): string {
if (status === "DRAFT") {
return "草稿";
@@ -98,6 +106,8 @@ function getStatusTheme(status?: EventStatus): TagProps["theme"] {
return "primary";
}
// ---------- 拖拽排序 ----------
function resolveDropIndex(clientY: number): number {
const elements = sortListRef.value?.querySelectorAll<HTMLElement>(".item");
if (!elements?.length) {
@@ -152,7 +162,7 @@ function finishDragState(): void {
}
function startDrag(event: PointerEvent, index: number): void {
if (saving.value || loading.value || dragInputType || !event.isPrimary || event.button !== 0) {
if (isSaving.value || isLoading.value || dragInputType || !event.isPrimary || event.button !== 0) {
return;
}
@@ -184,7 +194,7 @@ function finishDrag(event: PointerEvent): void {
}
function startTouchDrag(event: TouchEvent, index: number): void {
if (saving.value || loading.value || dragInputType || event.touches.length !== 1) {
if (isSaving.value || isLoading.value || dragInputType || event.touches.length !== 1) {
return;
}
@@ -216,12 +226,14 @@ function finishTouchDrag(event: TouchEvent): void {
finishDragState();
}
// ---------- 保存 ----------
async function saveSort(): Promise<void> {
if (!sortDirty.value || saving.value) {
if (!sortDirty.value || isSaving.value) {
return;
}
saving.value = true;
isSaving.value = true;
try {
const idList = eventList.value
.map(item => item.id)
@@ -231,24 +243,18 @@ async function saveSort(): Promise<void> {
item.sort = index;
});
sortDirty.value = false;
showToast("登记事件排序已保存");
Toast.success("登记事件排序已保存");
} catch {
showToast("登记事件排序保存失败", "error");
Toast.error("登记事件排序保存失败");
} finally {
saving.value = false;
isSaving.value = false;
}
}
function showToast(message: string, theme: "success" | "error" = "success"): void {
Toast({
theme,
direction: "row",
message
});
}
// ---------- 页面数据 ----------
async function loadData(): Promise<void> {
loading.value = true;
isLoading.value = true;
try {
const result = await EventAPI.list({
index: 0,
@@ -257,7 +263,7 @@ async function loadData(): Promise<void> {
eventList.value = result.list || [];
sortDirty.value = false;
} finally {
loading.value = false;
isLoading.value = false;
}
}
@@ -266,7 +272,7 @@ onMounted(async () => {
try {
await loadData();
} catch {
showToast("登记事件加载失败", "error");
Toast.error("登记事件加载失败");
}
});
@@ -351,4 +357,3 @@ onUnmounted(() => {
}
}
</style>
+32 -16
View File
@@ -12,7 +12,7 @@
v-model:selected-event-id-list="selectedEventIdList"
:event-list="eventList"
empty-text="当前月份暂无登记事件记录"
:loading="calendarLoading"
:loading="isCalendarLoading"
:records="monthRecords"
/>
@@ -35,7 +35,6 @@ import { init, use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import CustomerAPI from "@/api/CustomerAPI";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import EventRecordCalendar from "@/components/EventRecordCalendar.vue";
import type { CustomerDailyStatView, CustomerGenderStatView } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem";
@@ -69,14 +68,17 @@ use([
const chartRefs = new Map<string, HTMLElement>();
const chartInstances = new Map<string, ECharts>();
// ---------- 数据源 ----------
const eventList = ref<EventItem[]>([]);
const selectedEventIdList = ref<string[]>([]);
const customerDailyStatList = ref<CustomerDailyStatView[]>([]);
const customerGenderStatList = ref<CustomerGenderStatView[]>([]);
const recordDailyStatMap = ref<Record<string, EventRecordDailyStatView[]>>({});
const monthRecords = ref<EventRecord[]>([]);
const loading = ref(false);
const calendarLoading = ref(false);
const isLoading = ref(false);
const isCalendarLoading = ref(false);
const monthCursor = ref(getMonthStart(new Date()));
const genderColorMap = {
MALE: "#2563eb",
@@ -91,6 +93,8 @@ const timestampRange = {
endAt: Time.toTimestamp(endDate)
};
// ---------- 摘要 ----------
const summaryList = computed<SummaryItem[]>(() => [
{
label: "当前客户",
@@ -106,6 +110,8 @@ const summaryList = computed<SummaryItem[]>(() => [
}
]);
// ---------- 图表配置 ----------
const chartList = computed<ChartProfile[]>(() => [
{
id: "customer-growth",
@@ -134,6 +140,8 @@ watch(chartList, async () => {
renderCharts();
});
// ---------- 日历 ----------
watch(monthCursor, async () => {
await loadMonthRecords();
});
@@ -152,6 +160,8 @@ onBeforeUnmount(() => {
chartInstances.clear();
});
// ---------- 图表实例 ----------
function setChartRef(id: string, element: Element | ComponentPublicInstance | null): void {
if (element instanceof HTMLElement) {
chartRefs.set(id, element);
@@ -185,10 +195,12 @@ function resizeCharts(): void {
chartInstances.forEach(item => item.resize());
}
// ---------- 页面数据 ----------
async function loadChartsData(): Promise<void> {
loading.value = true;
isLoading.value = true;
try {
eventList.value = await EventAPI.enabledList();
eventList.value = await EventAPI.listValid();
const valideventList = eventList.value.filter((item): item is EventItem & { id: string } => Boolean(item.id));
selectedEventIdList.value = valideventList.map(item => item.id);
const [customerStatList, genderStatList, recordDailyStatList] = await Promise.all([
@@ -199,36 +211,35 @@ async function loadChartsData(): Promise<void> {
CustomerAPI.genderStat(),
Promise.all(valideventList.map(async item => ({
eventId: item.id,
stats: await EventRecordAPI.allCustomerEventDailyStat({
eventId: item.id,
beginAt: timestampRange.beginAt,
endAt: timestampRange.endAt
})
stats: []
})))
]);
customerDailyStatList.value = customerStatList;
customerGenderStatList.value = genderStatList;
recordDailyStatMap.value = Object.fromEntries(recordDailyStatList.map(item => [item.eventId, item.stats]));
} finally {
loading.value = false;
isLoading.value = false;
}
}
async function loadMonthRecords(): Promise<void> {
const begin = getCalendarBeginDate(monthCursor.value);
const end = getDateEnd(addDays(begin, 41));
calendarLoading.value = true;
isCalendarLoading.value = true;
try {
const calendarRecordList = await EventRecordAPI.calendarRangeList({
await Promise.resolve({
beginAt: Time.toTimestamp(begin),
endAt: Time.toTimestamp(end)
});
const calendarRecordList: EventRecordCalendarView[] = [];
monthRecords.value = calendarRecordList.map(buildCalendarRecord);
} finally {
calendarLoading.value = false;
isCalendarLoading.value = false;
}
}
// ---------- 图表构建 ----------
function buildBaseOption(): EChartsOption {
return {
color: ["#2563eb", "#0f766e", "#d97706", "#db2777", "#7c3aed"],
@@ -381,6 +392,8 @@ function buildGenderStatOption(): EChartsOption {
};
}
// ---------- 统计计算 ----------
function getLatestTotalCustomerCount(): number {
const latest = customerDailyStatList.value[customerDailyStatList.value.length - 1];
return latest?.totalCustomerCount || 0;
@@ -416,6 +429,8 @@ function getGenderCount(genderCode: "MALE" | "FEMALE" | "UNKNOWN"): number {
return customerGenderStatList.value.find(item => item.genderCode === genderCode)?.count || 0;
}
// ---------- 日历转换 ----------
function buildCalendarRecord(record: EventRecordCalendarView): EventRecord {
return {
id: record.id,
@@ -430,6 +445,8 @@ function buildCalendarRecord(record: EventRecordCalendarView): EventRecord {
};
}
// ---------- 日期工具 ----------
function getDateStart(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
@@ -547,4 +564,3 @@ function buildDateTextList(begin: Date, end: Date): string[] {
}
}
</style>
+8 -15
View File
@@ -25,6 +25,9 @@ defineOptions({
const router = useRouter();
const userStore = useUserStore();
// ---------- 表单 ----------
const isSaving = ref(false);
const formModel = reactive({
oldPassword: "",
@@ -32,21 +35,15 @@ const formModel = reactive({
confirmPassword: ""
});
// ---------- 保存 ----------
async function savePassword(): Promise<void> {
if (!formModel.oldPassword || !formModel.newPassword) {
Toast({
theme: "warning",
direction: "row",
message: "请填写旧密码和新密码"
});
Toast.warning("请填写旧密码和新密码");
return;
}
if (formModel.newPassword !== formModel.confirmPassword) {
Toast({
theme: "warning",
direction: "row",
message: "两次输入的新密码不一致"
});
Toast.warning("两次输入的新密码不一致");
return;
}
isSaving.value = true;
@@ -56,11 +53,7 @@ async function savePassword(): Promise<void> {
newValue: formModel.newPassword
});
userStore.clearLoginUser();
Toast({
theme: "success",
direction: "row",
message: "密码已更新,请重新登录"
});
Toast.success("密码已更新,请重新登录");
await router.replace("/login");
} finally {
isSaving.value = false;
+28 -22
View File
@@ -7,7 +7,7 @@
add-text="更换"
alt-text="用户头像"
size="compact"
:loading="avatarUploading"
:loading="isAvatarUploading"
:preview="false"
:show-remove="false"
@add="changeAvatar"
@@ -41,7 +41,7 @@
@change="handleAvatarSelected"
>
<t-cell-group theme="card">
<t-cell title="修改密码" arrow hover @click="goPassword" />
<t-cell title="修改密码" arrow hover @click="toPassword" />
</t-cell-group>
<t-button class="save" theme="primary" :loading="isSaving" @click="saveProfile">保存</t-button>
<t-button class="logout bg-white" theme="danger" variant="outline" @click="confirmLogout">退出登录</t-button>
@@ -65,9 +65,15 @@ defineOptions({
const router = useRouter();
const userStore = useUserStore();
// ---------- 头像 ----------
const avatarInputRef = ref<HTMLInputElement>();
const avatarTempFileId = ref("");
const avatarUploading = ref(false);
const isAvatarUploading = ref(false);
// ---------- 表单 ----------
const isSaving = ref(false);
const formModel = reactive({
@@ -85,6 +91,8 @@ const formModel = reactive({
lastLoginAt: ""
});
// ---------- 页面数据 ----------
function fillForm(): void {
const user = userStore.loginUser.user;
Object.assign(formModel, {
@@ -103,6 +111,8 @@ function fillForm(): void {
});
}
// ---------- 头像 ----------
function changeAvatar(): void {
avatarInputRef.value?.click();
}
@@ -113,37 +123,33 @@ async function handleAvatarSelected(event: Event): Promise<void> {
if (!file) {
return;
}
avatarUploading.value = true;
isAvatarUploading.value = true;
try {
const compressedFile = await compressUploadImage(file);
const [tempFile] = await AttachmentAPI.uploadTemp(compressedFile);
const [tempFile] = await AttachmentAPI.upload(compressedFile);
if (!tempFile?.id) {
Toast({
theme: "error",
direction: "row",
message: "头像上传失败"
});
Toast.error("头像上传失败");
return;
}
avatarTempFileId.value = tempFile.id;
formModel.avatar = AttachmentAPI.getTempReadURL(tempFile.id);
} finally {
avatarUploading.value = false;
isAvatarUploading.value = false;
input.value = "";
}
}
async function goPassword(): Promise<void> {
// ---------- 导航 ----------
async function toPassword(): Promise<void> {
await router.push("/management/profile/password");
}
// ---------- 保存 ----------
async function saveProfile(): Promise<void> {
if (!formModel.name) {
Toast({
theme: "warning",
direction: "row",
message: "请填写用户名"
});
Toast.warning("请填写用户名");
return;
}
isSaving.value = true;
@@ -156,17 +162,15 @@ async function saveProfile(): Promise<void> {
tempFileIdList: avatarTempFileId.value ? [avatarTempFileId.value] : undefined
});
await userStore.updateLoginUser(result);
Toast({
theme: "success",
direction: "row",
message: "资料已保存"
});
Toast.success("资料已保存");
await router.back();
} finally {
isSaving.value = false;
}
}
// ---------- 退出登录 ----------
async function confirmLogout(): Promise<void> {
const dialog = DialogPlugin.confirm({
title: "退出登录",
@@ -188,6 +192,8 @@ async function logout(): Promise<void> {
await router.replace("/login");
}
// ---------- 格式化 ----------
function sexToText(sex?: number): "男" | "女" | "未知" {
if (sex === 1) {
return "男";
@@ -53,6 +53,9 @@ defineOptions({
const globalUIStore = useGlobalUIStore();
const settingStore = useSettingStore();
// ---------- 主题 ----------
const themeOptions: Array<{ label: string; value: ThemeMode }> = [
{ label: "浅色", value: "light" },
{ label: "深色", value: "dark" },
@@ -63,6 +66,8 @@ function handleThemeChange(value: string): void {
globalUIStore.setThemeMode(value as ThemeMode);
}
// ---------- 声音 ----------
async function handleScanSuccessChange(value: boolean): Promise<void> {
settingStore.setSound({
scanSuccess: value
+11 -5
View File
@@ -30,6 +30,9 @@ defineOptions({
const route = useRoute();
const router = useRouter();
const permissionStore = usePermissionSettingsStore();
// ---------- 路由权限 ----------
const routeId = computed(() => String(route.params.id || ""));
const isCreateMode = computed(() => !routeId.value);
const savePermissionCode = computed(() => isCreateMode.value ? corePermissionCodes.permission.create : corePermissionCodes.permission.update);
@@ -37,6 +40,9 @@ const pageAccess = computed(() => ({
value: [corePermissionCodes.role.read, corePermissionCodes.permission.read, savePermissionCode.value],
mode: "and" as const
}));
// ---------- 表单 ----------
const formModel = reactive({
id: "",
moduleCode: moduleCode,
@@ -45,6 +51,8 @@ const formModel = reactive({
description: ""
});
// ---------- 页面数据 ----------
async function fillForm(): Promise<void> {
await permissionStore.loadAll();
const current = await permissionStore.loadPermissionDetail(routeId.value);
@@ -57,6 +65,8 @@ async function fillForm(): Promise<void> {
});
}
// ---------- 保存 ----------
async function savePermission(): Promise<void> {
const permission = await permissionStore.upsertPermission({
id: formModel.id,
@@ -65,11 +75,7 @@ async function savePermission(): Promise<void> {
name: formModel.name,
description: formModel.description
}, isCreateMode.value);
Toast({
theme: "success",
direction: "row",
message: "权限已保存"
});
Toast.success("权限已保存");
await router.push(isCreateMode.value ? "/management/permissions/items" : `/management/permissions/items/${permission?.id || formModel.id}`);
}
+22 -5
View File
@@ -1,5 +1,10 @@
<template>
<div v-role="permissionManagementRoleAccess" v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }" class="permission-list">
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
>
<t-cell-group v-if="sortedPermissions.length" theme="card">
<t-swipe-cell
v-for="item in sortedPermissions"
@@ -19,7 +24,8 @@
</t-swipe-cell>
</t-cell-group>
<t-empty v-else-if="!permissionStore.loading" description="暂无权限" />
<t-button v-can="corePermissionCodes.permission.create" class="add" theme="primary" type="button" @click="goCreate">
</t-pull-down-refresh>
<t-button v-can="corePermissionCodes.permission.create" class="add" theme="primary" type="button" @click="router.push('/management/permissions/items/create')">
<template #icon>
<t-icon name="add" />
</template>
@@ -39,16 +45,27 @@ defineOptions({
const router = useRouter();
const permissionStore = usePermissionSettingsStore();
const sortedPermissions = computed<Permission[]>(() => [...permissionStore.permissionList].sort((left, right) => left.code.localeCompare(right.code)));
const isRefreshing = ref(false);
async function goCreate(): Promise<void> {
await router.push("/management/permissions/items/create");
}
// ---------- 列表数据 ----------
const sortedPermissions = computed<Permission[]>(() => [...permissionStore.permissionList].sort((left, right) => left.code.localeCompare(right.code)));
async function removePermission(id: string): Promise<void> {
await permissionStore.removePermission(id);
}
// ---------- 页面数据 ----------
async function refresh(): Promise<void> {
isRefreshing.value = true;
try {
await permissionStore.loadAll(true);
} finally {
isRefreshing.value = false;
}
}
onMounted(async () => {
await permissionStore.loadAll();
});
+11 -10
View File
@@ -4,22 +4,22 @@
class="permission-settings"
>
<t-cell-group title="权限配置" theme="card">
<t-cell v-can="corePermissionCodes.role.read" title="角色管理" :note="`${permissionStore.roleList.length} 个角色`" arrow hover @click="go('/management/permissions/roles')">
<t-cell v-can="corePermissionCodes.role.read" title="角色管理" :note="`${permissionStore.roleList.length} 个角色`" arrow hover @click="router.push('/management/permissions/roles')">
<template #leftIcon>
<t-icon name="user-setting" size="1.75rem" />
</template>
</t-cell>
<t-cell v-can="corePermissionCodes.permission.read" title="权限管理" :note="`${permissionStore.permissionList.length} 个权限`" arrow hover @click="go('/management/permissions/items')">
<t-cell v-can="corePermissionCodes.permission.read" title="权限管理" :note="`${permissionStore.permissionList.length} 个权限`" arrow hover @click="router.push('/management/permissions/items')">
<template #leftIcon>
<t-icon name="secured" size="1.75rem" />
</template>
</t-cell>
<t-cell v-can="rolePermissionAssignAccess" title="角色权限授权" note="为角色分配可用权限" arrow hover @click="go('/management/permissions/assign')">
<t-cell v-can="rolePermissionAssignAccess" title="角色权限授权" note="为角色分配可用权限" arrow hover @click="router.push('/management/permissions/assign')">
<template #leftIcon>
<t-icon name="user-checked" size="1.75rem" />
</template>
</t-cell>
<t-cell v-can="corePermissionCodes.userRole.read" title="用户角色授权" note="为用户分配业务角色" arrow hover @click="go('/management/permissions/user-roles')">
<t-cell v-can="corePermissionCodes.userRole.read" title="用户角色授权" note="为用户分配业务角色" arrow hover @click="router.push('/management/permissions/user-roles')">
<template #leftIcon>
<t-icon name="usergroup" size="1.75rem" />
</template>
@@ -38,7 +38,7 @@
:note="`${item.permissionIdList.length} 项权限`"
arrow
hover
@click="goAssign(item.id)"
@click="toAssign(item.id)"
>
<template #title>
<div class="title" v-text="item.name" :style="{ paddingLeft: `${item.level * 1.25}rem` }" />
@@ -59,6 +59,9 @@ defineOptions({
const router = useRouter();
const permissionStore = usePermissionSettingsStore();
// ---------- 权限入口 ----------
const rolePermissionAssignAccess = {
value: [
corePermissionCodes.role.read,
@@ -69,11 +72,7 @@ const rolePermissionAssignAccess = {
mode: "and"
} as const;
async function go(path: string): Promise<void> {
await router.push(path);
}
async function goAssign(roleId: string): Promise<void> {
async function toAssign(roleId: string): Promise<void> {
await router.push({
path: "/management/permissions/assign",
query: {
@@ -82,6 +81,8 @@ async function goAssign(roleId: string): Promise<void> {
});
}
// ---------- 页面数据 ----------
onMounted(async () => {
await permissionStore.loadAll();
});
+14 -5
View File
@@ -48,6 +48,9 @@ defineOptions({
const route = useRoute();
const router = useRouter();
const permissionStore = usePermissionSettingsStore();
// ---------- 路由权限 ----------
const routeId = computed(() => String(route.params.id || ""));
const isCreateMode = computed(() => !routeId.value);
const savePermissionCode = computed(() => isCreateMode.value ? corePermissionCodes.role.create : corePermissionCodes.role.update);
@@ -55,6 +58,9 @@ const pageAccess = computed(() => ({
value: [corePermissionCodes.role.read, corePermissionCodes.permission.read, savePermissionCode.value],
mode: "and" as const
}));
// ---------- 表单 ----------
const formModel = reactive({
id: "",
moduleCode: moduleCode,
@@ -63,6 +69,9 @@ const formModel = reactive({
description: "",
childRoleIdList: [] as string[]
});
// ---------- 子角色 ----------
const childOptions = computed(() => permissionStore.roleList.filter(item => item.id !== formModel.id && !hasChildRole(item.id, formModel.id)));
function hasChildRole(roleId: string, targetRoleId: string, visitedRoleIds = new Set<string>()): boolean {
@@ -79,6 +88,8 @@ function hasChildRole(roleId: string, targetRoleId: string, visitedRoleIds = new
return current.childRoleIdList.some(childRoleId => childRoleId === targetRoleId || hasChildRole(childRoleId, targetRoleId, visitedRoleIds));
}
// ---------- 页面数据 ----------
async function fillForm(): Promise<void> {
await permissionStore.loadAll();
const current = await permissionStore.loadRoleDetail(routeId.value);
@@ -92,6 +103,8 @@ async function fillForm(): Promise<void> {
});
}
// ---------- 保存 ----------
async function saveRole(): Promise<void> {
const role = await permissionStore.upsertRole({
id: formModel.id,
@@ -102,11 +115,7 @@ async function saveRole(): Promise<void> {
permissionIdList: permissionStore.getRoleById(formModel.id)?.permissionIdList || [],
childRoleIdList: [...formModel.childRoleIdList]
}, isCreateMode.value);
Toast({
theme: "success",
direction: "row",
message: "角色已保存"
});
Toast.success("角色已保存");
await router.push(isCreateMode.value ? "/management/permissions/roles" : `/management/permissions/roles/${role?.id || formModel.id}`);
}
+23 -10
View File
@@ -1,5 +1,10 @@
<template>
<div v-role="permissionManagementRoleAccess" v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }" class="role-list">
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
>
<t-cell-group v-if="sortedRoles.length" theme="card">
<t-swipe-cell
v-for="item in sortedRoles"
@@ -11,7 +16,7 @@
:note="item.code"
arrow
hover
@click="goEdit(item.id)"
@click="router.push(`/management/permissions/roles/${item.id}`)"
/>
<template #right>
<t-button v-can="corePermissionCodes.role.delete" class="delete" theme="danger" @click="removeRole(item.id)">删除</t-button>
@@ -19,7 +24,8 @@
</t-swipe-cell>
</t-cell-group>
<t-empty v-else-if="!permissionStore.loading" description="暂无角色" />
<t-button v-can="corePermissionCodes.role.create" class="add" theme="primary" type="button" @click="goCreate">
</t-pull-down-refresh>
<t-button v-can="corePermissionCodes.role.create" class="add" theme="primary" type="button" @click="router.push('/management/permissions/roles/create')">
<template #icon>
<t-icon name="add" />
</template>
@@ -39,20 +45,27 @@ defineOptions({
const router = useRouter();
const permissionStore = usePermissionSettingsStore();
const isRefreshing = ref(false);
// ---------- 列表数据 ----------
const sortedRoles = computed<Role[]>(() => [...permissionStore.roleList].sort((left, right) => (left.name || "").localeCompare(right.name || "")));
async function goCreate(): Promise<void> {
await router.push("/management/permissions/roles/create");
}
async function goEdit(id: string): Promise<void> {
await router.push(`/management/permissions/roles/${id}`);
}
async function removeRole(id: string): Promise<void> {
await permissionStore.removeRole(id);
}
// ---------- 页面数据 ----------
async function refresh(): Promise<void> {
isRefreshing.value = true;
try {
await permissionStore.loadAll(true);
} finally {
isRefreshing.value = false;
}
}
onMounted(async () => {
await permissionStore.loadAll();
});
+15 -5
View File
@@ -60,6 +60,9 @@ defineOptions({
const permissionStore = usePermissionSettingsStore();
const userStore = useUserStore();
const route = useRoute();
// ---------- 角色选择 ----------
const activeRoleId = ref(String(route.query.roleId || ""));
const checkedPermissionIds = ref<string[]>([]);
const showChildRolePermissions = ref(false);
@@ -74,6 +77,9 @@ const roleOptions = computed<DropdownOption[]>(() => permissionStore.roleList
value: item.id,
disabled: false
})));
// ---------- 权限状态 ----------
const checkedPermissionIdSet = computed(() => new Set(checkedPermissionIds.value));
const childRolePermissionIdSet = computed(() => {
if (!activeRole.value) {
@@ -82,6 +88,8 @@ const childRolePermissionIdSet = computed(() => {
return collectChildRolePermissionIds(activeRole.value.childRoleIdList, new Set<string>());
});
// ---------- 角色切换 ----------
async function handleRoleChange(value: DropdownValue): Promise<void> {
activeRoleId.value = Array.isArray(value) ? String(value[0] || "") : String(value);
await loadActiveRolePermissions();
@@ -104,15 +112,15 @@ async function loadActiveRolePermissions(): Promise<void> {
checkedPermissionIds.value = [...role.permissionIdList];
}
// ---------- 保存 ----------
async function savePermissionList(): Promise<void> {
await permissionStore.setRolePermissionList(activeRoleId.value, checkedPermissionIds.value);
Toast({
theme: "success",
direction: "row",
message: "授权已保存"
});
Toast.success("授权已保存");
}
// ---------- 权限勾选 ----------
function isPermissionChecked(permissionId: string): boolean {
return checkedPermissionIdSet.value.has(permissionId);
}
@@ -140,6 +148,8 @@ function setPermissionChecked(permissionId: string, value: boolean): void {
}
}
// ---------- 子角色继承 ----------
function collectChildRolePermissionIds(roleIdList: string[], visitedRoleIdSet: Set<string>): Set<string> {
const permissionIdSet = new Set<string>();
for (const roleId of roleIdList) {
+17 -11
View File
@@ -32,7 +32,7 @@
note="为用户授权业务角色"
arrow
hover
@click="goRoleAssign"
@click="toRoleAssign"
>
<template #leftIcon>
<t-icon name="usergroup" size="1.75rem" />
@@ -55,7 +55,7 @@
<div v-if="!scopeList.length" class="empty-card">未配置</div>
</div>
</section>
<t-button v-can="permissionCodes.user.update" class="edit" theme="primary" @click="goEdit">编辑用户</t-button>
<t-button v-can="permissionCodes.user.update" class="edit" theme="primary" @click="toEdit">编辑用户</t-button>
</div>
</template>
@@ -77,6 +77,9 @@ defineOptions({
const route = useRoute();
const router = useRouter();
const permissionStore = usePermissionSettingsStore();
// ---------- 用户数据 ----------
const managedUser = ref<ManagedUser>();
const eventList = ref<EventItem[]>([]);
@@ -105,7 +108,9 @@ const scopeList = computed(() => {
.map(item => item.name);
});
async function goEdit(): Promise<void> {
// ---------- 导航 ----------
async function toEdit(): Promise<void> {
if (!currentUser.value) {
return;
}
@@ -113,7 +118,7 @@ async function goEdit(): Promise<void> {
await router.push(`/management/users/${currentUser.value.id}/edit`);
}
async function goRoleAssign(): Promise<void> {
async function toRoleAssign(): Promise<void> {
if (!currentUser.value) {
return;
}
@@ -123,6 +128,8 @@ async function goRoleAssign(): Promise<void> {
});
}
// ---------- 头像 ----------
function buildFallbackAvatar(name: string): string {
const text = encodeURIComponent((name.trim() || "用户").slice(0, 2));
return [
@@ -144,6 +151,8 @@ function isUser(user?: Partial<User>): user is User {
return typeof user?.name === "string";
}
// ---------- 事件范围 ----------
function canEventItem(action: EventItem, item: ManagedUser): boolean {
const userRoleCodeSet = buildEffectiveRoleCodeSet(item, permissionStore.roleList);
const roleCodeList = action.roleCodeList || [];
@@ -153,11 +162,13 @@ function canEventItem(action: EventItem, item: ManagedUser): boolean {
return roleCodeList.some(code => userRoleCodeSet.has(code));
}
// ---------- 页面数据 ----------
async function loadData(): Promise<void> {
const routeId = String(route.params.id || "");
const [detail, actions] = await Promise.all([
ManagedUserAPI.detail(routeId),
EventAPI.enabledList(),
EventAPI.listValid(),
permissionStore.loadAll()
]);
managedUser.value = detail;
@@ -168,11 +179,7 @@ onMounted(async () => {
try {
await loadData();
} catch {
Toast({
theme: "error",
direction: "row",
message: "用户详情加载失败"
});
Toast.error("用户详情加载失败");
}
});
</script>
@@ -242,4 +249,3 @@ onMounted(async () => {
}
}
</style>
+24 -26
View File
@@ -27,6 +27,9 @@ defineOptions({
const route = useRoute();
const router = useRouter();
// ---------- 表单 ----------
const currentManagedUser = ref<ManagedUser>();
const isSaving = ref(false);
const formModel = reactive({
@@ -39,6 +42,8 @@ const formModel = reactive({
const isCreateMode = computed(() => !String(route.params.id || ""));
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.user.create : permissionCodes.user.update);
// ---------- 页面数据 ----------
async function fillForm(): Promise<void> {
const routeId = String(route.params.id || "");
if (routeId) {
@@ -63,27 +68,16 @@ async function fillForm(): Promise<void> {
});
}
// ---------- 保存 ----------
async function saveUser(): Promise<void> {
if (!formModel.name) {
Toast({
theme: "warning",
direction: "row",
message: "请填写姓名"
});
return;
}
if (isCreateMode.value && !formModel.password) {
Toast({
theme: "warning",
direction: "row",
message: "请填写密码"
});
if (!validateForm()) {
return;
}
isSaving.value = true;
try {
const payload: ManagedUser = {
const formData: ManagedUser = {
id: formModel.id || undefined,
userId: formModel.userId || undefined,
enabled: currentManagedUser.value?.enabled ?? true,
@@ -96,27 +90,31 @@ async function saveUser(): Promise<void> {
password: isCreateMode.value ? formModel.password : undefined
}
};
const saved = isCreateMode.value ? await ManagedUserAPI.create(payload) : await ManagedUserAPI.update(payload);
Toast({
theme: "success",
direction: "row",
message: "用户已保存"
});
const saved = isCreateMode.value ? await ManagedUserAPI.create(formData) : await ManagedUserAPI.update(formData);
Toast.success("用户已保存");
await router.push(`/management/users/${saved.id}`);
} finally {
isSaving.value = false;
}
}
function validateForm(): boolean {
if (!formModel.name) {
Toast.warning("请填写姓名");
return false;
}
if (isCreateMode.value && !formModel.password) {
Toast.warning("请填写密码");
return false;
}
return true;
}
onMounted(async () => {
try {
await fillForm();
} catch {
Toast({
theme: "error",
direction: "row",
message: "用户数据加载失败"
});
Toast.error("用户数据加载失败");
}
});
</script>
+72 -19
View File
@@ -1,6 +1,12 @@
<template>
<div v-can="permissionCodes.user.read" class="user-list">
<div v-for="item in userList" :key="item.id" class="item" @click="goDetail(item)">
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="fetch"
>
<div v-for="item in pageResult.list" :key="item.id" class="item" @click="toDetail(item)">
<t-image :src="buildUserAvatar(item)" :alt="getUserName(item)" fit="cover" class="avatar" />
<div class="content">
<div class="header">
@@ -10,7 +16,10 @@
<span class="events gray" v-text="`可登记事件:${getActionScope(item)}`" />
</div>
</div>
<t-button v-can="permissionCodes.user.create" class="add" theme="primary" type="button" @click="goCreate">
<div v-if="!pageResult.list.length" class="empty-block" v-text="isLoading ? '正在加载用户' : '暂无用户'" />
<div v-if="pageResult.list.length" class="load-state" v-text="isNoMore ? '已经到底了' : '上拉加载更多'" />
</t-pull-down-refresh>
<t-button v-can="permissionCodes.user.create" class="add" theme="primary" type="button" @click="router.push('/management/users/create')">
<template #icon>
<t-icon name="add" />
</template>
@@ -21,7 +30,7 @@
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { UserAPI, type User } from "timi-web";
import { type Page, type PageResult, UserAPI, type User } from "timi-web";
import EventAPI from "@/api/EventAPI";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import type { ManagedUser } from "@/types/ManagedUser";
@@ -36,8 +45,23 @@ defineOptions({
const router = useRouter();
const permissionStore = usePermissionSettingsStore();
const userList = ref<ManagedUser[]>([]);
// ---------- 列表数据 ----------
const eventList = ref<EventItem[]>([]);
const isRefreshing = ref(false);
const isLoading = ref(false);
const isNoMore = ref(false);
const page = reactive<Page<ManagedUser>>({
index: 0,
size: 20
});
const pageResult = reactive<PageResult<ManagedUser>>({
total: 0,
list: []
});
// ---------- 展示文案 ----------
function getUserName(item: ManagedUser): string {
return item.user?.nick || item.user?.name || "未命名用户";
@@ -54,6 +78,8 @@ function getActionScope(item: ManagedUser): string {
return nameList.join("、") || "未配置";
}
// ---------- 事件范围 ----------
function canEventItem(action: EventItem, item: ManagedUser): boolean {
const userRoleCodeSet = buildEffectiveRoleCodeSet(item, permissionStore.roleList);
const roleCodeList = action.roleCodeList || [];
@@ -63,6 +89,8 @@ function canEventItem(action: EventItem, item: ManagedUser): boolean {
return roleCodeList.some(code => userRoleCodeSet.has(code));
}
// ---------- 头像 ----------
function buildUserAvatar(item: ManagedUser): string {
return isUser(item.user) ? UserAPI.getAvatarURL(item.user) || buildFallbackAvatar(getUserName(item)) : buildFallbackAvatar(getUserName(item));
}
@@ -83,24 +111,54 @@ function buildFallbackAvatar(name: string): string {
].join("");
}
// ---------- 页面数据 ----------
async function loadData(): Promise<void> {
const [userResult, actions] = await Promise.all([
ManagedUserAPI.list({
index: 0,
size: 100
}),
EventAPI.enabledList(),
ManagedUserAPI.list(page),
EventAPI.listValid(),
permissionStore.loadAll()
]);
userList.value = userResult.list || [];
pageResult.total = userResult.total;
pageResult.list = userResult.list || [];
page.index = 1;
isNoMore.value = pageResult.list.length >= pageResult.total || pageResult.list.length < page.size;
eventList.value = actions || [];
}
async function goCreate(): Promise<void> {
await router.push("/management/users/create");
async function refresh(): Promise<void> {
isRefreshing.value = true;
try {
page.index = 0;
pageResult.total = 0;
pageResult.list = [];
isNoMore.value = false;
await fetch();
} finally {
isRefreshing.value = false;
}
}
async function goDetail(item: ManagedUser): Promise<void> {
async function fetch(): Promise<void> {
if (isLoading.value || isNoMore.value) {
return;
}
isLoading.value = true;
try {
const result = await ManagedUserAPI.list(page);
pageResult.total = result.total;
pageResult.list = pageResult.list.concat(result.list || []);
page.index += 1;
isNoMore.value = pageResult.list.length >= pageResult.total || (result.list || []).length < page.size;
} finally {
isLoading.value = false;
}
}
// ---------- 导航 ----------
async function toDetail(item: ManagedUser): Promise<void> {
if (!item.id) {
return;
}
@@ -112,11 +170,7 @@ onMounted(async () => {
try {
await loadData();
} catch {
Toast({
theme: "error",
direction: "row",
message: "用户列表加载失败"
});
Toast.error("用户列表加载失败");
}
});
</script>
@@ -193,4 +247,3 @@ onMounted(async () => {
}
}
</style>
+35 -25
View File
@@ -26,12 +26,12 @@
</template>
</t-cell>
</t-cell-group>
<t-empty v-else-if="!loading" description="暂无可授权角色" />
<t-empty v-else-if="!isLoading" description="暂无可授权角色" />
<t-button
v-can="userRoleWriteAccess"
class="save"
theme="primary"
:loading="saving"
:loading="isSaving"
@click="saveRoleList"
>
保存授权
@@ -42,7 +42,7 @@
<script setup lang="ts">
import type { DropdownOption, DropdownValue } from "tdesign-mobile-vue";
import { Toast } from "tdesign-mobile-vue";
import { OrderType, RoleAPI, type ModuleCode, type Role } from "timi-web";
import { RoleAPI, type ModuleCode, type Role } from "timi-web";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import type { ManagedUser } from "@/types/ManagedUser";
import { corePermissionCodes } from "@/utils/permission";
@@ -54,15 +54,20 @@ defineOptions({
const route = useRoute();
const moduleCode: ModuleCode = "GAO";
const pageSize = 1000;
// ---------- 用户选择 ----------
const userList = ref<ManagedUser[]>([]);
const roleList = ref<Role[]>([]);
const routeManagedUserId = computed(() => String(route.params.id || route.query.managedUserId || ""));
const activeManagedUserId = ref(routeManagedUserId.value);
const checkedRoleIds = ref<string[]>([]);
const loading = ref(false);
const saving = ref(false);
const isLoading = ref(false);
const isSaving = ref(false);
let roleSyncSeq = 0;
// ---------- 权限 ----------
const userRoleWriteAccess = {
value: [
corePermissionCodes.userRole.create,
@@ -70,6 +75,9 @@ const userRoleWriteAccess = {
],
mode: "and"
} as const;
// ---------- 用户状态 ----------
const activeUser = computed(() => userList.value.find(item => item.id === activeManagedUserId.value));
const activeUserLabel = computed(() => activeUser.value ? `当前用户:${getUserName(activeUser.value)}` : "选择用户");
const userOptions = computed<DropdownOption[]>(() => userList.value
@@ -80,13 +88,17 @@ const userOptions = computed<DropdownOption[]>(() => userList.value
disabled: false
})));
// ---------- 用户切换 ----------
async function handleUserChange(value: DropdownValue): Promise<void> {
activeManagedUserId.value = Array.isArray(value) ? String(value[0] || "") : String(value);
await loadActiveUserRoles();
}
// ---------- 页面数据 ----------
async function loadData(): Promise<void> {
loading.value = true;
isLoading.value = true;
try {
const [userResult, roles] = await Promise.all([
ManagedUserAPI.list({
@@ -102,10 +114,12 @@ async function loadData(): Promise<void> {
}
await loadActiveUserRoles();
} finally {
loading.value = false;
isLoading.value = false;
}
}
// ---------- 角色同步 ----------
async function loadActiveUserRoles(): Promise<void> {
const managedUserId = activeManagedUserId.value;
const managedUser = activeUser.value;
@@ -124,38 +138,36 @@ async function loadActiveUserRoles(): Promise<void> {
checkedRoleIds.value = roles.map(getRoleId).filter((item): item is string => !!item);
}
// ---------- 保存 ----------
async function saveRoleList(): Promise<void> {
const current = activeUser.value;
if (!current?.id || !current.userId) {
Toast({
theme: "warning",
direction: "row",
message: "请选择用户"
});
Toast.warning("请选择用户");
return;
}
saving.value = true;
isSaving.value = true;
try {
await RoleAPI.authorize({
userId: current.userId,
moduleCode: moduleCode,
roleIdList: [...checkedRoleIds.value]
});
Toast({
theme: "success",
direction: "row",
message: "用户角色已保存"
});
Toast.success("用户角色已保存");
} finally {
saving.value = false;
isSaving.value = false;
}
}
// ---------- 展示文案 ----------
function getUserName(item: ManagedUser): string {
return item.user?.nick || item.user?.name || item.user?.phoneNo || "未命名用户";
}
// ---------- 角色列表 ----------
async function loadRoleList(): Promise<Role[]> {
const result = await RoleAPI.list({
index: 0,
@@ -164,12 +176,14 @@ async function loadRoleList(): Promise<Role[]> {
moduleCode: moduleCode
},
orderMap: {
code: OrderType.ASC
code: "ASC"
}
});
return result.list || [];
}
// ---------- 角色勾选 ----------
function isRoleChecked(roleId: string): boolean {
return checkedRoleIds.value.includes(roleId);
}
@@ -198,11 +212,7 @@ onMounted(async () => {
try {
await loadData();
} catch {
Toast({
theme: "error",
direction: "row",
message: "用户角色加载失败"
});
Toast.error("用户角色加载失败");
}
});
</script>
+2 -3
View File
@@ -1,6 +1,5 @@
import { defineStore } from "pinia";
import {
OrderType,
PermissionAPI,
RoleAPI,
type ModuleCode,
@@ -105,7 +104,7 @@ export const usePermissionSettingsStore = defineStore("permission-settings", ()
moduleCode: moduleCode
},
orderMap: {
code: OrderType.ASC
code: "ASC"
}
}),
PermissionAPI.list({
@@ -115,7 +114,7 @@ export const usePermissionSettingsStore = defineStore("permission-settings", ()
moduleCode: moduleCode
},
orderMap: {
code: OrderType.ASC
code: "ASC"
}
})
]);
+8 -18
View File
@@ -1,4 +1,5 @@
import type { Attachment, Model, User } from "timi-web";
import type { EventRecord } from "@/types/EventRecord.ts";
export type CustomerGender = "MALE" | "FEMALE";
export type CustomerBirthdateCalendar = "SOLAR" | "LUNAR";
@@ -8,25 +9,26 @@ export type BodyAreaValue = {
};
export type Customer = {
code?: string | null;
code?: string;
name?: string;
gender?: CustomerGender;
age?: number;
birthdate?: string | null;
birthdate?: string;
birthdateCalendar?: CustomerBirthdateCalendar;
telephone?: string;
address?: string;
remark?: string;
disease?: string;
invitedCount?: number;
conditioningPart?: BodyAreaValue;
introducerCustomerId?: string | null;
introducerCustomerId?: string;
creatorUserId?: string;
lastSignInAt?: number;
introducerCustomer?: Customer;
creatorUser?: Partial<User>;
invitedCount?: number;
eventRecord?: EventRecord;
eventRecordList?: EventRecord[];
attachmentList?: Attachment[];
attachmentIdList?: string[];
} & Model;
export type CustomerSearchReq = {
@@ -49,15 +51,3 @@ export type CustomerGenderStatView = {
genderCode?: CustomerGender | "UNKNOWN";
count?: number;
};
export type CustomerInviteUpdateReq = {
customerId: string;
introducerCustomerId?: string;
};
export type QuickRegisterReq = {
customerCode: string;
eventId: string;
remark?: string;
attachmentIdList?: string[];
};
+8 -54
View File
@@ -1,18 +1,19 @@
import type { Attachment, Model, User } from "timi-web";
import type { Attachment, BasePage, Model, User } from "timi-web";
import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem";
export type EventRecord = {
customerId?: string;
customerIdList?: string[];
eventId?: string;
operatorUserId?: string;
remark?: string;
registeredAt?: number;
customer?: Customer;
event?: EventItem;
operator?: Partial<User>;
attachmentList?: Array<Partial<Attachment>>;
attachmentIdList?: string[];
attachmentList?: Attachment[];
} & Model;
export type EventRecordListItemView = {
@@ -26,59 +27,21 @@ export type EventRecordListItemView = {
};
export type EventRecordPage = {
customerId?: string;
eventId?: string;
eventIdList?: string[];
keyword?: string;
beginAt: number;
endAt: number;
};
export type EventRecordPageReq = {
index: number;
size: number;
customerId?: string;
eventIdList?: string[];
keyword?: string;
beginAt?: number;
endAt?: number;
};
} & BasePage;
export type EventRecordStatReq = {
export type EventRecordRankPage = {
customerId?: string;
eventIdList?: string[];
beginAt?: number;
endAt?: number;
};
export type EventRecordTrendStatReq = {
customerId?: string;
eventId: string;
beginAt: number;
endAt: number;
};
export type CustomerEventStatView = {
customerId?: string;
customerName?: string;
eventId?: string;
eventName?: string;
count?: number;
latestRegisteredAt?: number;
};
} & BasePage;
export type CustomerEventRankView = {
rank?: number;
customerId?: string;
customerCode?: string;
customerName?: string;
customerPhotoAttachmentId?: string;
totalEventDays?: number;
totalEventCount?: number;
recentContinuousEventDays?: number;
latestRegisteredAt?: number;
};
export type CustomerEventRankResponse = {
rank?: number;
customerId?: string;
customer?: Customer;
@@ -102,12 +65,3 @@ export type EventRecordCalendarView = {
eventId?: string;
registeredAt?: number;
};
export type EventRecordCreateReq = {
customerIdList: string[];
eventId: string;
remark?: string;
registeredAt?: number;
attachmentIdList?: string[];
};
+1 -2
View File
@@ -2,11 +2,10 @@ import { AttachmentAPI } from "timi-web";
import type { BodyAreaValue, Customer } from "@/types/Customer";
export function getCustomerPhotoURL(customer?: Customer): string {
const attachment = customer?.attachmentList?.find(item => item.attachType === "PHOTO") || customer?.attachmentList?.[0];
const attachment = customer?.attachmentList?.find(item => item.attachType === "PHOTO");
if (!attachment?.id) {
return "";
}
return AttachmentAPI.getReadURL(attachment.id);
}
-2
View File
@@ -6,7 +6,5 @@ export function getEventRecordPhotoURL(record?: EventRecord): string {
if (!attachment?.id) {
return "";
}
return AttachmentAPI.getReadURL(attachment.id);
}