refactor file tree
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="store"
|
||||
v-can="permissionCodes.store.read"
|
||||
class="store-business-day-calendar"
|
||||
>
|
||||
<header class="summary bg-white">
|
||||
<header class="header">
|
||||
<div class="text-bold" v-text="store.name" />
|
||||
<t-tag variant="outline" :content="store.code" />
|
||||
</header>
|
||||
<div class="address gray">
|
||||
<span>地址:</span>
|
||||
<span v-text="store.address" />
|
||||
</div>
|
||||
</header>
|
||||
<div class="calendar-card">
|
||||
<t-calendar
|
||||
:key="calendarRenderKey"
|
||||
class="calendar"
|
||||
:value="selectedCalendarDate"
|
||||
:min-date="calendarMinDate"
|
||||
:max-date="calendarMaxDate"
|
||||
:format="calendarFormat"
|
||||
switch-mode="month"
|
||||
:use-popup="false"
|
||||
@panel-change="handlePanelChange"
|
||||
@select="selectCalendarDate"
|
||||
/>
|
||||
<div v-if="isCalendarLoading" class="loading" v-text="'正在加载当月营业日'" />
|
||||
<div v-else-if="!businessDayList.length" class="empty" v-text="'当月暂无营业日配置,点击日期即可新增'" />
|
||||
</div>
|
||||
</div>
|
||||
<t-popup v-model="editorVisible" placement="bottom">
|
||||
<div class="editor">
|
||||
<div class="header">
|
||||
<div class="text-bold" v-text="editingId ? '编辑营业日' : '新增营业日'" />
|
||||
<div v-text="formModel.dateText" />
|
||||
</div>
|
||||
<t-form label-align="left">
|
||||
<t-form-item label="状态">
|
||||
<t-radio-group v-model="formModel.openStatus" variant="default-filled">
|
||||
<t-radio :block="false" borderless value="OPEN">营业</t-radio>
|
||||
<t-radio :block="false" borderless value="CLOSED">休息</t-radio>
|
||||
</t-radio-group>
|
||||
</t-form-item>
|
||||
<t-form-item
|
||||
class="reason"
|
||||
:class="{ 'is-hidden': formModel.openStatus !== 'CLOSED' }"
|
||||
label="原因"
|
||||
>
|
||||
<t-input
|
||||
v-model.trim="formModel.reason"
|
||||
:disabled="formModel.openStatus !== 'CLOSED'"
|
||||
placeholder="休息原因或备注"
|
||||
borderless
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<span class="hint orange">切换营业或休息可能影响连续登记统计,系统重排营业日事件序列可能需要较长时间。</span>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
v-if="editingId"
|
||||
v-can="deletePermissionCode"
|
||||
class="action"
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
:loading="isDeleting"
|
||||
@click="confirmRemoveDay"
|
||||
>删除</t-button>
|
||||
<t-button
|
||||
v-can="savePermissionCode"
|
||||
class="action"
|
||||
theme="primary"
|
||||
:loading="isSaving"
|
||||
@click="saveDay"
|
||||
>保存</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</t-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogPlugin, Toast, type CalendarProps, type TDate } from "tdesign-mobile-vue";
|
||||
import { useUserStore } from "timi-web";
|
||||
import StoreAPI from "@/api/StoreAPI";
|
||||
import type { Store, StoreBusinessDay, StoreBusinessDayOpenStatus } from "@/types/Store";
|
||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreBusinessDayCalendar"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const storeId = computed(() => String(route.params.id || ""));
|
||||
const isStoreManagerScope = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager) && !userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||
|
||||
// ---------- 门店信息 ----------
|
||||
|
||||
const store = ref<Store>();
|
||||
|
||||
// ---------- 日历数据 ----------
|
||||
|
||||
const today = new Date();
|
||||
const calendarMinDate = new Date(today.getFullYear() - 2, 0, 1);
|
||||
const calendarMaxDate = new Date(today.getFullYear() + 2, 11, 31);
|
||||
const selectedCalendarDate = ref<Date>(today);
|
||||
const calendarMonth = reactive({
|
||||
year: today.getFullYear(),
|
||||
month: today.getMonth() + 1
|
||||
});
|
||||
const businessDayList = ref<StoreBusinessDay[]>([]);
|
||||
const isCalendarLoading = ref(false);
|
||||
const calendarRenderKey = ref(0);
|
||||
let calendarRequestId = 0;
|
||||
|
||||
const businessDayMap = computed(() => new Map(
|
||||
businessDayList.value
|
||||
.filter(item => item.dateValue)
|
||||
.map(item => [item.dateValue as number, item])
|
||||
));
|
||||
|
||||
const calendarFormat = computed<NonNullable<CalendarProps["format"]>>(() => {
|
||||
const dayMap = businessDayMap.value;
|
||||
return (dateItem: TDate) => {
|
||||
const day = dayMap.get(toDateValue(dateItem.date));
|
||||
if (!day) {
|
||||
return dateItem;
|
||||
}
|
||||
return {
|
||||
...dateItem,
|
||||
className: `business-day-${day.openStatus!.toLowerCase()}`,
|
||||
suffix: day.openStatus === "OPEN" ? "营" : "休"
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
async function loadCalendar(year: number, month: number): Promise<void> {
|
||||
const requestId = ++calendarRequestId;
|
||||
isCalendarLoading.value = true;
|
||||
try {
|
||||
const beginDate = new Date(year, month - 1, 1);
|
||||
const endDate = new Date(year, month, 0);
|
||||
const result = await StoreAPI.businessDayCalendar(storeId.value, toDateValue(beginDate), toDateValue(endDate));
|
||||
if (requestId === calendarRequestId) {
|
||||
businessDayList.value = result || [];
|
||||
calendarRenderKey.value += 1;
|
||||
}
|
||||
} finally {
|
||||
if (requestId === calendarRequestId) {
|
||||
isCalendarLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePanelChange(context: { year: number; month: number }): Promise<void> {
|
||||
if (context.year === calendarMonth.year && context.month === calendarMonth.month) {
|
||||
return;
|
||||
}
|
||||
calendarMonth.year = context.year;
|
||||
calendarMonth.month = context.month;
|
||||
selectedCalendarDate.value = new Date(context.year, context.month - 1, 1);
|
||||
try {
|
||||
await loadCalendar(context.year, context.month);
|
||||
} catch {
|
||||
Toast.error("营业日加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 编辑 ----------
|
||||
|
||||
const editorVisible = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isDeleting = ref(false);
|
||||
const editingId = ref("");
|
||||
const formModel = reactive({
|
||||
dateText: "",
|
||||
openStatus: "OPEN" as StoreBusinessDayOpenStatus,
|
||||
reason: "正常休息"
|
||||
});
|
||||
const savePermissionCode = computed(() => {
|
||||
if (isStoreManagerScope.value) {
|
||||
return permissionCodes.store.update;
|
||||
}
|
||||
|
||||
return editingId.value ? permissionCodes.store.update : permissionCodes.store.create;
|
||||
});
|
||||
const deletePermissionCode = computed(() => isStoreManagerScope.value ? permissionCodes.store.update : permissionCodes.store.delete);
|
||||
|
||||
function openDayEditor(day?: StoreBusinessDay, dateValue = toDateValue(new Date())): void {
|
||||
editingId.value = day?.id || "";
|
||||
Object.assign(formModel, {
|
||||
dateText: formatDateValue(day?.dateValue || dateValue),
|
||||
openStatus: day?.openStatus || "OPEN",
|
||||
reason: day ? day.reason : "正常休息"
|
||||
});
|
||||
editorVisible.value = true;
|
||||
}
|
||||
|
||||
function selectCalendarDate(date: Date): void {
|
||||
selectedCalendarDate.value = date;
|
||||
const dateValue = toDateValue(date);
|
||||
openDayEditor(businessDayMap.value.get(dateValue), dateValue);
|
||||
}
|
||||
|
||||
async function saveDay(): Promise<void> {
|
||||
if (!validateDay() || isSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const day: StoreBusinessDay = {
|
||||
id: editingId.value || undefined,
|
||||
storeId: storeId.value,
|
||||
dateValue: parseDateValue(formModel.dateText),
|
||||
openStatus: formModel.openStatus,
|
||||
reason: formModel.openStatus === "CLOSED" ? formModel.reason || undefined : undefined
|
||||
};
|
||||
if (editingId.value) {
|
||||
await StoreAPI.updateBusinessDay(day);
|
||||
} else {
|
||||
await StoreAPI.createBusinessDay(day);
|
||||
}
|
||||
Toast.success("营业日已保存");
|
||||
editorVisible.value = false;
|
||||
await loadCalendar(calendarMonth.year, calendarMonth.month);
|
||||
} catch {
|
||||
Toast.error("营业日保存失败");
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateDay(): boolean {
|
||||
if (!formModel.dateText) {
|
||||
Toast.warning("请选择日期");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- 删除 ----------
|
||||
|
||||
function confirmRemoveDay(): void {
|
||||
if (!editingId.value || isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dayId = editingId.value;
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "删除营业日",
|
||||
content: `确定删除 ${formModel.dateText} 的营业日配置吗?已有登记记录的营业日系统会拒绝删除`,
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: "删除",
|
||||
onCancel() {
|
||||
dialog.destroy();
|
||||
},
|
||||
async onConfirm() {
|
||||
dialog.destroy();
|
||||
await removeDay(dayId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function removeDay(id: string): Promise<void> {
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await StoreAPI.removeBusinessDay(id);
|
||||
Toast.success("营业日已删除");
|
||||
editorVisible.value = false;
|
||||
await loadCalendar(calendarMonth.year, calendarMonth.month);
|
||||
} catch {
|
||||
Toast.error("营业日删除失败");
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 日期 ----------
|
||||
|
||||
function formatDateValue(value?: number): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const text = String(value);
|
||||
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function parseDateValue(text: string): number {
|
||||
return Number(text.replaceAll("-", ""));
|
||||
}
|
||||
|
||||
function toDateValue(date: Date): number {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return Number(`${year}${month}${day}`);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [currentStore] = await Promise.all([
|
||||
StoreAPI.detail(storeId.value),
|
||||
loadCalendar(calendarMonth.year, calendarMonth.month)
|
||||
]);
|
||||
store.value = currentStore;
|
||||
} catch {
|
||||
Toast.error("营业日数据加载失败");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.store-business-day-calendar {
|
||||
gap: .75rem;
|
||||
display: grid;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem;
|
||||
|
||||
.summary {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
padding: .75rem;
|
||||
border-radius: .75rem;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.address {
|
||||
font-size: .875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
gap: .375rem;
|
||||
display: flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
|
||||
:deep(.t-calendar-header) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.t-calendar) {
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
border-radius: .75rem;
|
||||
|
||||
&__title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__months {
|
||||
height: unset;
|
||||
}
|
||||
|
||||
&__dates-item {
|
||||
height: 40px;
|
||||
align-items: unset;
|
||||
|
||||
&.business-day-open {
|
||||
--td-calendar-item-suffix-color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
&.business-day-closed .t-calendar__dates-item-suffix {
|
||||
--td-calendar-item-suffix-color: var(--td-error-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.business-day-open),
|
||||
:deep(.business-day-closed) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:deep(.business-day-closed .t-calendar__dates-item-suffix) {
|
||||
}
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
color: #52607a;
|
||||
padding: .75rem 1rem;
|
||||
font-size: .8125rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reason.is-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.hint {
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
|
||||
.action {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.store-business-day-calendar {
|
||||
.summary,
|
||||
.calendar-card,
|
||||
.editor {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.summary span,
|
||||
.loading,
|
||||
.empty,
|
||||
.editor .hint {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.summary strong,
|
||||
.editor h3 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,566 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.stat.read" class="data-charts-page">
|
||||
<section class="summary">
|
||||
<div v-for="item in summaryList" :key="item.label" class="summary-item">
|
||||
<span v-text="item.label" />
|
||||
<strong v-text="item.value" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<event-record-calendar
|
||||
v-model="monthCursor"
|
||||
v-model:selected-event-id-list="selectedEventIdList"
|
||||
:event-list="eventList"
|
||||
empty-text="当前月份暂无登记事件记录"
|
||||
:loading="isCalendarLoading"
|
||||
:records="monthRecords"
|
||||
/>
|
||||
|
||||
<section
|
||||
v-for="item in chartList"
|
||||
:key="item.id"
|
||||
class="chart-section"
|
||||
>
|
||||
<h2 v-text="item.title" />
|
||||
<div :ref="element => setChartRef(item.id, element)" class="chart" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ECharts, EChartsOption } from "echarts";
|
||||
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 CustomerAPI from "@/api/CustomerAPI";
|
||||
import EventAPI from "@/api/EventAPI";
|
||||
import EventRecordCalendar from "@/components/EventRecordCalendar.vue";
|
||||
import type { CustomerDailyStatView, CustomerGenderStatView } from "@/types/Customer";
|
||||
import type { EventItem } from "@/types/EventItem";
|
||||
import type { EventRecord, EventRecordCalendarView, EventRecordDailyStatView } from "@/types/EventRecord";
|
||||
import { Time } from "timi-web";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
interface SummaryItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ChartProfile {
|
||||
id: string;
|
||||
title: string;
|
||||
option: EChartsOption;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: "StoreDataCharts"
|
||||
});
|
||||
|
||||
use([
|
||||
BarChart,
|
||||
LineChart,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
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 isLoading = ref(false);
|
||||
const isCalendarLoading = ref(false);
|
||||
const monthCursor = ref(getMonthStart(new Date()));
|
||||
const genderColorMap = {
|
||||
MALE: "#2563eb",
|
||||
FEMALE: "#db2777",
|
||||
UNKNOWN: "#64748b"
|
||||
} as const;
|
||||
const beginDate = getDateStart(addDays(new Date(), -6));
|
||||
const endDate = getDateEnd(new Date());
|
||||
const dateTextList = buildDateTextList(beginDate, endDate);
|
||||
const timestampRange = {
|
||||
beginAt: Time.toTimestamp(beginDate),
|
||||
endAt: Time.toTimestamp(endDate)
|
||||
};
|
||||
|
||||
// ---------- 摘要 ----------
|
||||
|
||||
const summaryList = computed<SummaryItem[]>(() => [
|
||||
{
|
||||
label: "当前客户",
|
||||
value: `${getLatestTotalCustomerCount()} 人`
|
||||
},
|
||||
{
|
||||
label: "本周新增",
|
||||
value: `${sumCustomerNewCount()} 人`
|
||||
},
|
||||
{
|
||||
label: "本周登记",
|
||||
value: `${sumActionDailyCount()} 次`
|
||||
}
|
||||
]);
|
||||
|
||||
// ---------- 图表配置 ----------
|
||||
|
||||
const chartList = computed<ChartProfile[]>(() => [
|
||||
{
|
||||
id: "customer-growth",
|
||||
title: "客户增长趋势",
|
||||
option: buildCustomerGrowthOption()
|
||||
},
|
||||
{
|
||||
id: "record-trend",
|
||||
title: "登记量趋势",
|
||||
option: buildRecordTrendOption()
|
||||
},
|
||||
{
|
||||
id: "customer-value",
|
||||
title: "事件结构",
|
||||
option: buildEventStructureOption()
|
||||
},
|
||||
{
|
||||
id: "customer-gender",
|
||||
title: "性别统计",
|
||||
option: buildGenderStatOption()
|
||||
}
|
||||
]);
|
||||
|
||||
watch(chartList, async () => {
|
||||
await nextTick();
|
||||
renderCharts();
|
||||
});
|
||||
|
||||
// ---------- 日历 ----------
|
||||
|
||||
watch(monthCursor, async () => {
|
||||
await loadMonthRecords();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadChartsData();
|
||||
await loadMonthRecords();
|
||||
await nextTick();
|
||||
renderCharts();
|
||||
window.addEventListener("resize", resizeCharts);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", resizeCharts);
|
||||
chartInstances.forEach(item => item.dispose());
|
||||
chartInstances.clear();
|
||||
});
|
||||
|
||||
// ---------- 图表实例 ----------
|
||||
|
||||
function setChartRef(id: string, element: Element | ComponentPublicInstance | null): void {
|
||||
if (element instanceof HTMLElement) {
|
||||
chartRefs.set(id, element);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCharts(): void {
|
||||
for (const item of chartList.value) {
|
||||
const element = chartRefs.get(item.id);
|
||||
if (!element) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const chart = getChartInstance(item.id, element);
|
||||
chart.setOption(item.option, true);
|
||||
}
|
||||
}
|
||||
|
||||
function getChartInstance(id: string, element: HTMLElement): ECharts {
|
||||
const instance = chartInstances.get(id);
|
||||
if (instance) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
const chart = init(element);
|
||||
chartInstances.set(id, chart);
|
||||
return chart;
|
||||
}
|
||||
|
||||
function resizeCharts(): void {
|
||||
chartInstances.forEach(item => item.resize());
|
||||
}
|
||||
|
||||
// ---------- 页面数据 ----------
|
||||
|
||||
async function loadChartsData(): Promise<void> {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
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([
|
||||
CustomerAPI.dailyStat({
|
||||
beginCreatedAt: timestampRange.beginAt,
|
||||
endCreatedAt: timestampRange.endAt
|
||||
}),
|
||||
CustomerAPI.genderStat(),
|
||||
Promise.all(valideventList.map(async item => ({
|
||||
eventId: item.id,
|
||||
stats: []
|
||||
})))
|
||||
]);
|
||||
customerDailyStatList.value = customerStatList;
|
||||
customerGenderStatList.value = genderStatList;
|
||||
recordDailyStatMap.value = Object.fromEntries(recordDailyStatList.map(item => [item.eventId, item.stats]));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonthRecords(): Promise<void> {
|
||||
const begin = getCalendarBeginDate(monthCursor.value);
|
||||
const end = getDateEnd(addDays(begin, 41));
|
||||
isCalendarLoading.value = true;
|
||||
try {
|
||||
await Promise.resolve({
|
||||
beginAt: Time.toTimestamp(begin),
|
||||
endAt: Time.toTimestamp(end)
|
||||
});
|
||||
const calendarRecordList: EventRecordCalendarView[] = [];
|
||||
monthRecords.value = calendarRecordList.map(buildCalendarRecord);
|
||||
} finally {
|
||||
isCalendarLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 图表构建 ----------
|
||||
|
||||
function buildBaseOption(): EChartsOption {
|
||||
return {
|
||||
color: ["#2563eb", "#0f766e", "#d97706", "#db2777", "#7c3aed"],
|
||||
grid: {
|
||||
top: 48,
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
containLabel: true
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
icon: "roundRect",
|
||||
textStyle: {
|
||||
color: "#52607a"
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis"
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: dateTextList.map(item => item.slice(5)),
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: "#52607a"
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLabel: {
|
||||
color: "#52607a"
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: "rgba(148, 163, 184, .2)"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomerGrowthOption(): EChartsOption {
|
||||
return {
|
||||
...buildBaseOption(),
|
||||
series: [
|
||||
{
|
||||
name: "客户总量",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
data: dateTextList.map(dateText => getCustomerDailyStat(dateText)?.totalCustomerCount || 0)
|
||||
},
|
||||
{
|
||||
name: "新客户量",
|
||||
type: "bar",
|
||||
data: dateTextList.map(dateText => getCustomerDailyStat(dateText)?.newCustomerCount || 0)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function buildRecordTrendOption(): EChartsOption {
|
||||
return {
|
||||
...buildBaseOption(),
|
||||
series: eventList.value.map(action => ({
|
||||
name: action.name || "未命名事件",
|
||||
type: "bar",
|
||||
stack: "record",
|
||||
itemStyle: getEventColor(action) ? {
|
||||
color: getEventColor(action)
|
||||
} : undefined,
|
||||
data: dateTextList.map(dateText => countRecordByEventAndDate(action.id || "", dateText))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function buildEventStructureOption(): EChartsOption {
|
||||
return {
|
||||
...buildBaseOption(),
|
||||
legend: {
|
||||
show: false
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: eventList.value.map(item => item.name || "未命名事件"),
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: "#52607a"
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: "登记次数",
|
||||
type: "bar",
|
||||
data: eventList.value.map(item => ({
|
||||
value: countRecordByEvent(item.id || ""),
|
||||
itemStyle: getEventColor(item) ? {
|
||||
color: getEventColor(item)
|
||||
} : undefined
|
||||
}))
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
function buildGenderStatOption(): EChartsOption {
|
||||
return {
|
||||
...buildBaseOption(),
|
||||
tooltip: {
|
||||
trigger: "item"
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: ["男", "女", "未知"],
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: "#52607a"
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "男",
|
||||
type: "bar",
|
||||
itemStyle: {
|
||||
color: genderColorMap.MALE
|
||||
},
|
||||
data: [getGenderCount("MALE"), 0, 0]
|
||||
},
|
||||
{
|
||||
name: "女",
|
||||
type: "bar",
|
||||
itemStyle: {
|
||||
color: genderColorMap.FEMALE
|
||||
},
|
||||
data: [0, getGenderCount("FEMALE"), 0]
|
||||
},
|
||||
{
|
||||
name: "未知",
|
||||
type: "bar",
|
||||
itemStyle: {
|
||||
color: genderColorMap.UNKNOWN
|
||||
},
|
||||
data: [0, 0, getGenderCount("UNKNOWN")]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 统计计算 ----------
|
||||
|
||||
function getLatestTotalCustomerCount(): number {
|
||||
const latest = customerDailyStatList.value[customerDailyStatList.value.length - 1];
|
||||
return latest?.totalCustomerCount || 0;
|
||||
}
|
||||
|
||||
function sumCustomerNewCount(): number {
|
||||
return customerDailyStatList.value.reduce((sum, item) => sum + (item.newCustomerCount || 0), 0);
|
||||
}
|
||||
|
||||
function sumActionDailyCount(): number {
|
||||
return eventList.value.reduce((sum, item) => sum + countRecordByEvent(item.id || ""), 0);
|
||||
}
|
||||
|
||||
function getCustomerDailyStat(dateText: string): CustomerDailyStatView | undefined {
|
||||
const dateValue = Number(dateText.replaceAll("-", ""));
|
||||
return customerDailyStatList.value.find(item => item.dateValue === dateValue);
|
||||
}
|
||||
|
||||
function countRecordByEventAndDate(eventId: string, dateText: string): number {
|
||||
const dateValue = Number(dateText.replaceAll("-", ""));
|
||||
return recordDailyStatMap.value[eventId]?.find(item => item.dateValue === dateValue)?.dailyCount || 0;
|
||||
}
|
||||
|
||||
function countRecordByEvent(eventId: string): number {
|
||||
return (recordDailyStatMap.value[eventId] || []).reduce((sum, item) => sum + (item.dailyCount || 0), 0);
|
||||
}
|
||||
|
||||
function getEventColor(event: EventItem): string | undefined {
|
||||
return event.color || undefined;
|
||||
}
|
||||
|
||||
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,
|
||||
customerId: record.customerId,
|
||||
eventId: record.eventId,
|
||||
registeredAt: record.registeredAt,
|
||||
customer: record.customerId || record.customerCode || record.customerName ? {
|
||||
id: record.customerId,
|
||||
code: record.customerCode,
|
||||
name: record.customerName
|
||||
} : undefined
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 日期工具 ----------
|
||||
|
||||
function getDateStart(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function getMonthStart(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function getCalendarBeginDate(date: Date): Date {
|
||||
const monthStart = getMonthStart(date);
|
||||
return addDays(monthStart, -monthStart.getDay());
|
||||
}
|
||||
|
||||
function getDateEnd(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const value = new Date(date);
|
||||
value.setDate(value.getDate() + days);
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildDateTextList(begin: Date, end: Date): string[] {
|
||||
const list: string[] = [];
|
||||
for (let item = getDateStart(begin); item <= end; item = addDays(item, 1)) {
|
||||
list.push(Time.formatDate(item));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.data-charts-page {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 1rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.summary {
|
||||
gap: .75rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
.summary-item {
|
||||
padding: .875rem .625rem;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
align-items: center;
|
||||
border-radius: 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
span {
|
||||
color: #52607a;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-top: .375rem;
|
||||
color: #0f172a;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1.25rem;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 .875rem 0;
|
||||
color: #0f172a;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 16rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.data-charts-page {
|
||||
.summary {
|
||||
.summary-item {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
|
||||
span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
|
||||
h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div v-can="savePermissionCode" class="store-edit">
|
||||
<t-form class="form" label-align="left">
|
||||
<t-form-item v-if="!isStoreManagerScope" label="编码">
|
||||
<t-input v-model.trim="formModel.code" placeholder="门店编码" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="名称">
|
||||
<t-input v-model.trim="formModel.name" placeholder="门店名称" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item v-if="!isStoreManagerScope" label="启用" content-align="right">
|
||||
<t-switch v-model="formModel.status" :custom-value="['ACTIVE', 'INACTIVE']" />
|
||||
</t-form-item>
|
||||
<t-form-item label="电话">
|
||||
<t-input v-model.trim="formModel.telephone" type="tel" placeholder="联系电话" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="地址">
|
||||
<t-input v-model.trim="formModel.address" placeholder="门店地址" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model.trim="formModel.remark" class="textarea" placeholder="备注" autosize borderless />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<t-cell-group v-if="!isCreateMode" theme="card">
|
||||
<t-cell title="营业日管理" note="配置营业或休息日期" arrow hover @click="router.push(`/management/stores/${routeId}/business-days`)" />
|
||||
</t-cell-group>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
v-if="!isCreateMode && !isStoreManagerScope"
|
||||
v-can="permissionCodes.store.delete"
|
||||
class="action"
|
||||
theme="danger"
|
||||
:loading="isRemoving"
|
||||
@click="confirmRemoveStore"
|
||||
>删除</t-button>
|
||||
<t-button
|
||||
class="action"
|
||||
theme="primary"
|
||||
:loading="isSaving"
|
||||
@click="saveStore"
|
||||
>保存</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
|
||||
import { useUserStore } from "timi-web";
|
||||
import StoreAPI from "@/api/StoreAPI";
|
||||
import type { Store, StoreStatus } from "@/types/Store";
|
||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreEdit"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const routeId = computed(() => String(route.params.id || ""));
|
||||
const isStoreManagerScope = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager) && !userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||
|
||||
// ---------- 路由权限 ----------
|
||||
|
||||
const isCreateMode = computed(() => !routeId.value);
|
||||
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.store.create : permissionCodes.store.update);
|
||||
|
||||
// ---------- 表单 ----------
|
||||
|
||||
const isSaving = ref(false);
|
||||
const isRemoving = ref(false);
|
||||
const formModel = reactive({
|
||||
id: "",
|
||||
code: "",
|
||||
name: "",
|
||||
status: "ACTIVE" as StoreStatus,
|
||||
telephone: "",
|
||||
address: "",
|
||||
remark: ""
|
||||
});
|
||||
const sortValue = ref(0);
|
||||
|
||||
// ---------- 页面数据 ----------
|
||||
|
||||
async function fillForm(): Promise<void> {
|
||||
if (!routeId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = await StoreAPI.detail(routeId.value);
|
||||
Object.assign(formModel, {
|
||||
id: current.id || "",
|
||||
code: current.code || "",
|
||||
name: current.name || "",
|
||||
status: current.status || "ACTIVE",
|
||||
telephone: current.telephone || "",
|
||||
address: current.address || "",
|
||||
remark: current.remark || ""
|
||||
});
|
||||
sortValue.value = current.sort ?? 0;
|
||||
}
|
||||
|
||||
// ---------- 保存 ----------
|
||||
|
||||
async function saveStore(): Promise<void> {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const store = buildStorePayload();
|
||||
if (isCreateMode.value) {
|
||||
await StoreAPI.create(store);
|
||||
} else {
|
||||
await StoreAPI.update(store);
|
||||
}
|
||||
Toast.success("门店已保存");
|
||||
if (isStoreManagerScope.value) {
|
||||
return;
|
||||
}
|
||||
await router.replace("/management/stores");
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildStorePayload(): Store {
|
||||
if (isStoreManagerScope.value) {
|
||||
return {
|
||||
id: formModel.id || undefined,
|
||||
name: formModel.name,
|
||||
telephone: formModel.telephone || undefined,
|
||||
address: formModel.address || undefined,
|
||||
remark: formModel.remark || undefined
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: formModel.id || undefined,
|
||||
code: formModel.code,
|
||||
name: formModel.name,
|
||||
status: formModel.status,
|
||||
telephone: formModel.telephone || undefined,
|
||||
address: formModel.address || undefined,
|
||||
remark: formModel.remark || undefined,
|
||||
sort: sortValue.value
|
||||
};
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (!isStoreManagerScope.value && !formModel.code) {
|
||||
Toast.warning("请填写门店编码");
|
||||
return false;
|
||||
}
|
||||
if (!formModel.name) {
|
||||
Toast.warning("请填写门店名称");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function confirmRemoveStore(): Promise<void> {
|
||||
if (!formModel.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "删除门店",
|
||||
content: `确定删除 ${formModel.name} 吗?删除后无法恢复。`,
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: "删除",
|
||||
onCancel() {
|
||||
dialog.destroy();
|
||||
},
|
||||
async onConfirm() {
|
||||
dialog.destroy();
|
||||
await removeStore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function removeStore(): Promise<void> {
|
||||
if (isRemoving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRemoving.value = true;
|
||||
try {
|
||||
await StoreAPI.remove(formModel.id);
|
||||
Toast.success("门店已删除");
|
||||
await router.replace("/management/stores");
|
||||
} finally {
|
||||
isRemoving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await fillForm();
|
||||
} catch {
|
||||
Toast.error("门店数据加载失败");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.store-edit {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: .75rem;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
display: flex;
|
||||
position: fixed;
|
||||
|
||||
.action {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.store.read" class="store-list">
|
||||
<t-pull-down-refresh
|
||||
v-model="isRefreshing"
|
||||
:refresh-timeout="30000"
|
||||
@refresh="refresh"
|
||||
@scrolltolower="fetch"
|
||||
>
|
||||
<t-cell-group theme="card">
|
||||
<t-cell
|
||||
class="item"
|
||||
v-for="item in pageResult.list"
|
||||
:key="item.id"
|
||||
arrow
|
||||
@click="toEdit(item)"
|
||||
>
|
||||
<template #title>
|
||||
<div class="header">
|
||||
<div class="text-bold" v-text="item.name || '未命名门店'"></div>
|
||||
<t-tag size="small" :theme="getStoreStatusTheme(item.status)" variant="outline" :content="getStoreStatusText(item.status)" />
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="description">
|
||||
<span class="gray" v-text="item.telephone ? `电话:${item.telephone}` : '电话:-'" />
|
||||
<span class="gray" v-text="item.address ? `地址:${item.address}` : '地址:-'" />
|
||||
</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>
|
||||
<div class="actions">
|
||||
<t-button v-can="permissionCodes.store.export" theme="default" :loading="isExporting" @click="exportExcel">
|
||||
<template #icon>
|
||||
<t-icon name="download" />
|
||||
</template>
|
||||
导出
|
||||
</t-button>
|
||||
<t-button v-can="permissionCodes.store.create" theme="primary" @click="router.push('/management/stores/create')">
|
||||
<template #icon>
|
||||
<t-icon name="add" />
|
||||
</template>
|
||||
新增
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { Time, Toolkit, type Page, type PageResult, useUserStore } from "timi-web";
|
||||
import StoreAPI from "@/api/StoreAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { Store } from "@/types/Store";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { getStoreStatusText, getStoreStatusTheme } from "@/utils/store";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreList"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// ---------- 列表数据 ----------
|
||||
|
||||
const isRefreshing = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isNoMore = ref(false);
|
||||
const page = reactive<Page<Store>>({
|
||||
index: 0,
|
||||
size: 20,
|
||||
orderMap: {
|
||||
sort: "ASC",
|
||||
createdAt: "DESC"
|
||||
}
|
||||
});
|
||||
const pageResult = reactive<PageResult<Store>>({
|
||||
total: 0,
|
||||
list: []
|
||||
});
|
||||
|
||||
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 fetch(): Promise<void> {
|
||||
if (isLoading.value || isNoMore.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const result = await StoreAPI.list(page);
|
||||
pageResult.total = result.total || 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 操作 ----------
|
||||
|
||||
const isExporting = ref(false);
|
||||
|
||||
async function exportExcel(): Promise<void> {
|
||||
if (isExporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isExporting.value = true;
|
||||
try {
|
||||
const blob = await StoreAPI.exportXSSF({
|
||||
index: 0,
|
||||
size: 10000,
|
||||
orderMap: page.orderMap
|
||||
});
|
||||
Toolkit.downloadBlob(blob, `门店列表-${Time.formatDate(new Date())}.xlsx`);
|
||||
Toast.success("导出已开始");
|
||||
} finally {
|
||||
isExporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toEdit(item: Store): Promise<void> {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push(`/management/stores/${item.id}`);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.clearRight();
|
||||
if (userStore.hasPermissions(permissionCodes.store.update)) {
|
||||
navBarStore.setRightText("排序", "var(--td-brand-color)", () => {
|
||||
router.push("/management/stores/sort");
|
||||
});
|
||||
}
|
||||
try {
|
||||
await fetch();
|
||||
} catch {
|
||||
Toast.error("门店列表加载失败");
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
navBarStore.clearRight();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.store-list {
|
||||
gap: .75rem;
|
||||
display: grid;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||
|
||||
.item {
|
||||
|
||||
.header {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: .75rem;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
display: grid;
|
||||
position: fixed;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.store-list {
|
||||
.item {
|
||||
background: #1e293b;
|
||||
|
||||
.name {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.store.update" class="store-sort">
|
||||
<div class="tip" v-text="'按住右侧拖动手柄调整门店顺序,保存后生效'" />
|
||||
<div ref="sortListRef" class="items">
|
||||
<t-cell-group v-if="storeList.length" theme="card">
|
||||
<t-cell
|
||||
v-for="(item, index) in storeList"
|
||||
:key="item.id"
|
||||
class="cell"
|
||||
:title="item.name || '未命名门店'"
|
||||
:description="`编码:${item.code || '-'}`"
|
||||
:class="{ dragging: draggingIndex === index }"
|
||||
>
|
||||
<template #note>
|
||||
<t-tag :theme="getStatusTheme(item.status)" variant="outline" :content="getStatusText(item.status)" />
|
||||
</template>
|
||||
<template #rightIcon>
|
||||
<t-icon
|
||||
class="handle"
|
||||
role="button"
|
||||
name="move"
|
||||
aria-label="拖动排序"
|
||||
@pointerdown.stop="startDrag($event, index)"
|
||||
@pointermove.stop="moveDrag"
|
||||
@pointerup.stop="finishDrag"
|
||||
@pointercancel.stop="finishDrag"
|
||||
@lostpointercapture.stop="finishDrag"
|
||||
@touchstart.stop="startTouchDrag($event, index)"
|
||||
@touchmove.stop="moveTouchDrag"
|
||||
@touchend.stop="finishTouchDrag"
|
||||
@touchcancel.stop="finishTouchDrag"
|
||||
/>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty-block" v-text="isLoading ? '正在加载门店' : '暂无门店'" />
|
||||
</div>
|
||||
<t-button
|
||||
class="save"
|
||||
theme="primary"
|
||||
:loading="isSaving"
|
||||
:disabled="!sortDirty || isSaving || isLoading"
|
||||
@click="saveSort"
|
||||
>
|
||||
保存排序
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast, type TagProps } from "tdesign-mobile-vue";
|
||||
import StoreAPI from "@/api/StoreAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { Store, StoreStatus } from "@/types/Store";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreSort"
|
||||
});
|
||||
|
||||
const navBarStore = useNavBarStore();
|
||||
|
||||
// ---------- 列表数据 ----------
|
||||
|
||||
const storeList = ref<Store[]>([]);
|
||||
const sortListRef = ref<HTMLElement>();
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const sortDirty = ref(false);
|
||||
|
||||
// ---------- 拖拽状态 ----------
|
||||
|
||||
const draggingIndex = ref(-1);
|
||||
let pointerId: number | undefined;
|
||||
let pointerStartY = 0;
|
||||
let pointerMoved = false;
|
||||
let pointerTarget: HTMLElement | undefined;
|
||||
let dragInputType: "pointer" | "touch" | undefined;
|
||||
|
||||
// ---------- 展示文案 ----------
|
||||
|
||||
function getStatusText(status?: StoreStatus): string {
|
||||
return status === "ACTIVE" ? "启用" : "停用";
|
||||
}
|
||||
|
||||
function getStatusTheme(status?: StoreStatus): TagProps["theme"] {
|
||||
return status === "ACTIVE" ? "primary" : "default";
|
||||
}
|
||||
|
||||
// ---------- 拖拽排序 ----------
|
||||
|
||||
function resolveDropIndex(clientY: number): number {
|
||||
const elements = sortListRef.value?.querySelectorAll<HTMLElement>(".cell");
|
||||
if (!elements?.length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (let index = 0; index < elements.length; index++) {
|
||||
const rect = elements[index].getBoundingClientRect();
|
||||
if (clientY < rect.top + rect.height / 2) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return elements.length - 1;
|
||||
}
|
||||
|
||||
function beginDrag(index: number, clientY: number, target: EventTarget | null): void {
|
||||
pointerStartY = clientY;
|
||||
pointerMoved = false;
|
||||
draggingIndex.value = index;
|
||||
pointerTarget = target instanceof HTMLElement ? target : undefined;
|
||||
}
|
||||
|
||||
function moveDragAt(clientY: number): void {
|
||||
if (0 > draggingIndex.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pointerMoved && 8 > Math.abs(clientY - pointerStartY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerMoved = true;
|
||||
const targetIndex = resolveDropIndex(clientY);
|
||||
if (0 > targetIndex || targetIndex === draggingIndex.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [item] = storeList.value.splice(draggingIndex.value, 1);
|
||||
storeList.value.splice(targetIndex, 0, item);
|
||||
draggingIndex.value = targetIndex;
|
||||
sortDirty.value = true;
|
||||
}
|
||||
|
||||
function finishDragState(): void {
|
||||
if (pointerMoved) {
|
||||
sortDirty.value = true;
|
||||
}
|
||||
pointerId = undefined;
|
||||
pointerTarget = undefined;
|
||||
dragInputType = undefined;
|
||||
draggingIndex.value = -1;
|
||||
}
|
||||
|
||||
function startDrag(event: PointerEvent, index: number): void {
|
||||
if (isSaving.value || isLoading.value || dragInputType || !event.isPrimary || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
pointerId = event.pointerId;
|
||||
dragInputType = "pointer";
|
||||
beginDrag(index, event.clientY, event.currentTarget);
|
||||
pointerTarget?.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function moveDrag(event: PointerEvent): void {
|
||||
if (dragInputType !== "pointer" || pointerId !== event.pointerId || 0 > draggingIndex.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
moveDragAt(event.clientY);
|
||||
}
|
||||
|
||||
function finishDrag(event: PointerEvent): void {
|
||||
if (dragInputType !== "pointer" || pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointerTarget?.hasPointerCapture(event.pointerId)) {
|
||||
pointerTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
finishDragState();
|
||||
}
|
||||
|
||||
function startTouchDrag(event: TouchEvent, index: number): void {
|
||||
if (isSaving.value || isLoading.value || dragInputType || event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const touch = event.touches[0];
|
||||
pointerId = touch.identifier;
|
||||
dragInputType = "touch";
|
||||
beginDrag(index, touch.clientY, event.currentTarget);
|
||||
}
|
||||
|
||||
function moveTouchDrag(event: TouchEvent): void {
|
||||
if (dragInputType !== "touch" || pointerId === undefined || 0 > draggingIndex.value || event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
moveDragAt(event.touches[0].clientY);
|
||||
}
|
||||
|
||||
function finishTouchDrag(event: TouchEvent): void {
|
||||
if (dragInputType !== "touch" || pointerId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.changedTouches[0];
|
||||
if (touch && touch.identifier !== pointerId) {
|
||||
return;
|
||||
}
|
||||
finishDragState();
|
||||
}
|
||||
|
||||
// ---------- 保存 ----------
|
||||
|
||||
async function saveSort(): Promise<void> {
|
||||
if (!sortDirty.value || isSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const idList = storeList.value
|
||||
.map(item => item.id)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
await StoreAPI.sort(idList);
|
||||
storeList.value.forEach((item, index) => {
|
||||
item.sort = index;
|
||||
});
|
||||
sortDirty.value = false;
|
||||
Toast.success("门店排序已保存");
|
||||
} catch {
|
||||
Toast.error("门店排序保存失败");
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 页面数据 ----------
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const result = await StoreAPI.list({
|
||||
index: 0,
|
||||
size: 100,
|
||||
orderMap: {
|
||||
sort: "ASC",
|
||||
createdAt: "DESC"
|
||||
}
|
||||
});
|
||||
storeList.value = result.list || [];
|
||||
sortDirty.value = false;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.clearRight();
|
||||
try {
|
||||
await loadData();
|
||||
} catch {
|
||||
Toast.error("门店加载失败");
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
navBarStore.clearRight();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.store-sort {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 6rem;
|
||||
|
||||
.tip {
|
||||
color: #52607a;
|
||||
padding: 0 1rem .75rem;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.items {
|
||||
.cell {
|
||||
position: relative;
|
||||
|
||||
&.dragging {
|
||||
z-index: 1;
|
||||
box-shadow: 0 .5rem 1rem rgba(15, 23, 42, .16);
|
||||
}
|
||||
|
||||
.handle {
|
||||
color: #52607a;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
&.dragging,
|
||||
&.dragging * {
|
||||
touch-action: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
color: #52607a;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.save {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.store-sort {
|
||||
.tip,
|
||||
.empty-block,
|
||||
.handle {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.cell.dragging {
|
||||
box-shadow: 0 .5rem 1rem rgba(2, 6, 23, .28);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user