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