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