add store
This commit is contained in:
@@ -190,7 +190,7 @@ async function doSubmit(): Promise<void> {
|
||||
customerId: customer.id,
|
||||
eventId: selectedEventId.value,
|
||||
remark: "客户快速扫码登记"
|
||||
})
|
||||
});
|
||||
scanStatus.value = `登记成功:${customer.name || customer.code || scanCode.value}。`;
|
||||
scanCode.value = "";
|
||||
matchedCustomer.value = undefined;
|
||||
@@ -362,4 +362,3 @@ async function doSubmit(): Promise<void> {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ const fetch = Toolkit.debounce(async () => {
|
||||
likesExample: {
|
||||
code: keyword.value,
|
||||
name: keyword.value,
|
||||
telephone: keyword.value,
|
||||
telephone: keyword.value
|
||||
}
|
||||
});
|
||||
pageResult.total = result.total;
|
||||
@@ -336,4 +336,3 @@ async function doExport(): Promise<void> {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.store.read" class="business-day-list">
|
||||
<header class="summary">
|
||||
<div>
|
||||
<strong v-text="storeName" />
|
||||
<span v-text="storeCodeText" />
|
||||
</div>
|
||||
<t-tag theme="default" variant="outline" content="无记录表示未配置" />
|
||||
</header>
|
||||
<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">
|
||||
<div class="content" @click="openDayEditor(item)">
|
||||
<div class="header">
|
||||
<span class="date" v-text="formatDateValue(item.dateValue)" />
|
||||
<t-tag size="small" :theme="getBusinessDayStatusTheme(item.openStatus)" variant="outline" :content="getBusinessDayStatusText(item.openStatus)" />
|
||||
</div>
|
||||
<span class="meta" v-text="item.openStatus === 'OPEN' ? `营业日序号:${item.businessDayNo || '-'}` : `原因:${item.reason || '-'}`" />
|
||||
</div>
|
||||
<t-button v-can="permissionCodes.store.delete" class="delete" shape="circle" theme="danger" variant="text" @click="confirmRemoveDay(item)">
|
||||
<t-icon name="delete" />
|
||||
</t-button>
|
||||
</div>
|
||||
<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.store.create" class="add" theme="primary" @click="openDayEditor()">
|
||||
<template #icon>
|
||||
<t-icon name="add" />
|
||||
</template>
|
||||
新增营业日
|
||||
</t-button>
|
||||
<t-popup v-model="editorVisible" placement="bottom">
|
||||
<div class="editor">
|
||||
<h3 v-text="editingId ? '编辑营业日' : '新增营业日'" />
|
||||
<t-form label-align="left">
|
||||
<t-form-item label="日期">
|
||||
<button class="picker-btn" type="button" @click="openDatePicker">
|
||||
<span v-text="formModel.dateText || '选择日期'" />
|
||||
<t-icon name="calendar" />
|
||||
</button>
|
||||
</t-form-item>
|
||||
<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 label="原因">
|
||||
<t-input v-model.trim="formModel.reason" placeholder="休息原因或备注" borderless />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<p class="hint">切换营业或休息可能影响连续登记统计,营业日序号由后端自动维护。</p>
|
||||
<t-button v-can="savePermissionCode" theme="primary" block :loading="isSaving" @click="saveDay">保存</t-button>
|
||||
</div>
|
||||
</t-popup>
|
||||
<t-popup v-model="datePickerVisible" placement="bottom">
|
||||
<t-date-time-picker
|
||||
v-if="datePickerVisible"
|
||||
v-model="datePickerDraft"
|
||||
:mode="['date']"
|
||||
format="YYYY-MM-DD"
|
||||
title="营业日期"
|
||||
@cancel="datePickerVisible = false"
|
||||
@confirm="confirmDatePicker"
|
||||
/>
|
||||
</t-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
|
||||
import { type Page, type PageResult } from "timi-web";
|
||||
import StoreAPI from "@/api/StoreAPI";
|
||||
import type { Store, StoreBusinessDay, StoreBusinessDayOpenStatus } from "@/types/Store";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { getBusinessDayStatusText, getBusinessDayStatusTheme } from "@/utils/store";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreBusinessDayList"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const storeId = computed(() => String(route.params.id || ""));
|
||||
|
||||
// ---------- 门店信息 ----------
|
||||
|
||||
const store = ref<Store>();
|
||||
const storeName = computed(() => store.value?.name || "门店");
|
||||
const storeCodeText = computed(() => `编码:${store.value?.code || "-"}`);
|
||||
|
||||
// ---------- 列表数据 ----------
|
||||
|
||||
const isRefreshing = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isNoMore = ref(false);
|
||||
const page = reactive<Page<StoreBusinessDay>>({
|
||||
index: 0,
|
||||
size: 20,
|
||||
equalsExample: {
|
||||
storeId: storeId.value
|
||||
},
|
||||
orderMap: {
|
||||
dateValue: "DESC"
|
||||
}
|
||||
});
|
||||
const pageResult = reactive<PageResult<StoreBusinessDay>>({
|
||||
total: 0,
|
||||
list: []
|
||||
});
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
page.index = 0;
|
||||
page.equalsExample = {
|
||||
storeId: storeId.value
|
||||
};
|
||||
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.businessDayList(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 editorVisible = ref(false);
|
||||
const datePickerVisible = ref(false);
|
||||
const datePickerDraft = ref("");
|
||||
const isSaving = ref(false);
|
||||
const editingId = ref("");
|
||||
const formModel = reactive({
|
||||
dateText: "",
|
||||
openStatus: "OPEN" as StoreBusinessDayOpenStatus,
|
||||
reason: ""
|
||||
});
|
||||
const savePermissionCode = computed(() => editingId.value ? permissionCodes.store.update : permissionCodes.store.create);
|
||||
|
||||
function openDayEditor(day?: StoreBusinessDay): void {
|
||||
editingId.value = day?.id || "";
|
||||
Object.assign(formModel, {
|
||||
dateText: day?.dateValue ? formatDateValue(day.dateValue) : formatDateValue(toDateValue(new Date())),
|
||||
openStatus: day?.openStatus || "OPEN",
|
||||
reason: day?.reason || ""
|
||||
});
|
||||
editorVisible.value = true;
|
||||
}
|
||||
|
||||
function openDatePicker(): void {
|
||||
datePickerDraft.value = formModel.dateText;
|
||||
datePickerVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmDatePicker(): void {
|
||||
formModel.dateText = datePickerDraft.value;
|
||||
datePickerVisible.value = false;
|
||||
}
|
||||
|
||||
async function saveDay(): Promise<void> {
|
||||
if (!validateDay()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const day: StoreBusinessDay = {
|
||||
id: editingId.value || undefined,
|
||||
storeId: storeId.value,
|
||||
dateValue: parseDateValue(formModel.dateText),
|
||||
openStatus: formModel.openStatus,
|
||||
reason: formModel.reason || undefined
|
||||
};
|
||||
if (editingId.value) {
|
||||
await StoreAPI.updateBusinessDay(day);
|
||||
} else {
|
||||
await StoreAPI.createBusinessDay(day);
|
||||
}
|
||||
Toast.success("营业日已保存");
|
||||
editorVisible.value = false;
|
||||
await refresh();
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateDay(): boolean {
|
||||
if (!formModel.dateText) {
|
||||
Toast.warning("请选择日期");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- 删除 ----------
|
||||
|
||||
async function confirmRemoveDay(day: StoreBusinessDay): Promise<void> {
|
||||
if (!day.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dayId = day.id;
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "删除营业日",
|
||||
content: `确定删除 ${formatDateValue(day.dateValue)} 的营业日配置吗?已有登记记录的营业日后端会拒绝删除。`,
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: "删除",
|
||||
onCancel() {
|
||||
dialog.destroy();
|
||||
},
|
||||
async onConfirm() {
|
||||
dialog.destroy();
|
||||
await removeDay(dayId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function removeDay(id: string): Promise<void> {
|
||||
await StoreAPI.removeBusinessDay(id);
|
||||
Toast.success("营业日已删除");
|
||||
await refresh();
|
||||
}
|
||||
|
||||
// ---------- 日期 ----------
|
||||
|
||||
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 {
|
||||
store.value = await StoreAPI.detail(storeId.value);
|
||||
await fetch();
|
||||
} catch {
|
||||
Toast.error("营业日数据加载失败");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.business-day-list {
|
||||
gap: .75rem;
|
||||
display: grid;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
padding: .75rem;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: .75rem;
|
||||
justify-content: space-between;
|
||||
|
||||
div {
|
||||
gap: .25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #52607a;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
padding: .75rem;
|
||||
align-items: center;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
background: #fff;
|
||||
border-radius: .75rem;
|
||||
|
||||
.content {
|
||||
gap: .375rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #52607a;
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.delete {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.editor {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
flex-direction: column;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #52607a;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-btn {
|
||||
gap: .5rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.business-day-list {
|
||||
.summary,
|
||||
.item,
|
||||
.editor {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.summary span,
|
||||
.item .meta,
|
||||
.editor .hint {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.date,
|
||||
.summary strong,
|
||||
.editor h3 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div v-can="savePermissionCode" class="store-edit">
|
||||
<t-form class="form" label-align="left">
|
||||
<t-form-item 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 label="状态">
|
||||
<button class="picker-btn" type="button" @click="openStatusPicker">
|
||||
<span v-text="getStoreStatusText(formModel.status)" />
|
||||
<t-icon name="chevron-right" />
|
||||
</button>
|
||||
</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-input v-model.trim="formModel.sortText" type="number" 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>
|
||||
<div v-if="!isCreateMode" class="links">
|
||||
<t-cell title="营业日管理" note="配置营业或休息日期" arrow hover @click="router.push(`/management/stores/${routeId}/business-days`)" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<t-button v-if="!isCreateMode" v-can="permissionCodes.store.delete" theme="danger" :loading="isRemoving" @click="confirmRemoveStore">删除</t-button>
|
||||
<t-button theme="primary" :loading="isSaving" @click="saveStore">保存</t-button>
|
||||
</div>
|
||||
<t-popup v-model="statusPickerVisible" placement="bottom">
|
||||
<t-picker
|
||||
v-model="statusPickerDraft"
|
||||
:columns="statusPickerColumns"
|
||||
title="门店状态"
|
||||
@cancel="statusPickerVisible = false"
|
||||
@confirm="confirmStatusPicker"
|
||||
/>
|
||||
</t-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
|
||||
import StoreAPI from "@/api/StoreAPI";
|
||||
import type { Store, StoreStatus } from "@/types/Store";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { getStoreStatusText } from "@/utils/store";
|
||||
|
||||
type PickerValue = string | number;
|
||||
|
||||
defineOptions({
|
||||
name: "StoreEdit"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const routeId = computed(() => String(route.params.id || ""));
|
||||
|
||||
// ---------- 路由权限 ----------
|
||||
|
||||
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: "",
|
||||
sortText: "0"
|
||||
});
|
||||
|
||||
// ---------- 状态选择 ----------
|
||||
|
||||
const statusPickerVisible = ref(false);
|
||||
const statusPickerDraft = ref<PickerValue[]>(["ACTIVE"]);
|
||||
const statusPickerColumns = [
|
||||
[
|
||||
{ label: "启用", value: "ACTIVE" },
|
||||
{ label: "停用", value: "INACTIVE" }
|
||||
]
|
||||
];
|
||||
|
||||
function openStatusPicker(): void {
|
||||
statusPickerDraft.value = [formModel.status];
|
||||
statusPickerVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmStatusPicker(): void {
|
||||
const nextStatus = String(statusPickerDraft.value[0] || "ACTIVE") as StoreStatus;
|
||||
formModel.status = nextStatus;
|
||||
statusPickerVisible.value = false;
|
||||
}
|
||||
|
||||
// ---------- 页面数据 ----------
|
||||
|
||||
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 || "",
|
||||
sortText: String(current.sort ?? 0)
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 保存 ----------
|
||||
|
||||
async function saveStore(): Promise<void> {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const store: Store = {
|
||||
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: Number(formModel.sortText || 0)
|
||||
};
|
||||
const savedId = isCreateMode.value ? await StoreAPI.create(store) : formModel.id;
|
||||
if (!isCreateMode.value) {
|
||||
await StoreAPI.update(store);
|
||||
}
|
||||
Toast.success("门店已保存");
|
||||
await router.replace(`/management/stores/${savedId}`);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (!formModel.code) {
|
||||
Toast.warning("请填写门店编码");
|
||||
return false;
|
||||
}
|
||||
if (!formModel.name) {
|
||||
Toast.warning("请填写门店名称");
|
||||
return false;
|
||||
}
|
||||
if (Number.isNaN(Number(formModel.sortText || 0))) {
|
||||
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 {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.picker-btn {
|
||||
gap: .5rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.store.read" class="store-list">
|
||||
<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="toEdit(item)">
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<span class="name" v-text="item.name || '未命名门店'" />
|
||||
<t-tag size="small" :theme="getStoreStatusTheme(item.status)" variant="outline" :content="getStoreStatusText(item.status)" />
|
||||
</div>
|
||||
<span class="meta" v-text="`编码:${item.code || '-'}`" />
|
||||
<span class="meta" v-text="item.telephone ? `电话:${item.telephone}` : '电话:-'" />
|
||||
<span class="meta" v-text="item.address ? `地址:${item.address}` : '地址:-'" />
|
||||
</div>
|
||||
<t-icon name="chevron-right" size="1.25rem" />
|
||||
</div>
|
||||
<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 } from "timi-web";
|
||||
import StoreAPI from "@/api/StoreAPI";
|
||||
import type { Store } from "@/types/Store";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { getStoreStatusText, getStoreStatusTheme } from "@/utils/store";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreList"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// ---------- 列表数据 ----------
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
await fetch();
|
||||
} catch {
|
||||
Toast.error("门店列表加载失败");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.store-list {
|
||||
gap: .75rem;
|
||||
display: grid;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
|
||||
|
||||
.item {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
padding: .75rem;
|
||||
align-items: center;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
background: #fff;
|
||||
border-radius: .75rem;
|
||||
|
||||
.content {
|
||||
gap: .375rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #52607a;
|
||||
font-size: .875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -22,6 +22,20 @@
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-cell-group v-if="canSelectStore" class="store-card" theme="card">
|
||||
<t-cell
|
||||
class="cell"
|
||||
title="当前门店"
|
||||
:note="currentStoreStore.currentStoreName"
|
||||
arrow
|
||||
hover
|
||||
@click="openStorePicker"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<t-icon name="shop" size="1.75rem" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-cell-group v-for="group in visibleMenuGroupList" :key="group.title" :title="group.title" theme="card">
|
||||
<t-cell
|
||||
v-for="item in group.items"
|
||||
@@ -42,14 +56,26 @@
|
||||
<p>技术支持:夜雨</p>
|
||||
<p>Copyright © 2026 imyeyu.com</p>
|
||||
</footer>
|
||||
<t-popup v-model="storePickerVisible" placement="bottom">
|
||||
<t-picker
|
||||
v-model="storePickerDraft"
|
||||
:columns="storePickerColumns"
|
||||
title="当前门店"
|
||||
@cancel="storePickerVisible = false"
|
||||
@confirm="confirmStorePicker"
|
||||
/>
|
||||
</t-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UserAPI, useUserStore } from "timi-web";
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import { corePermissionCodes, coreRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
|
||||
type UserAvatarSource = Parameters<typeof UserAPI.getAvatarURL>[0];
|
||||
type PickerValue = string | number;
|
||||
|
||||
type MenuItem = {
|
||||
icon: string;
|
||||
@@ -69,6 +95,7 @@ defineOptions({
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
|
||||
const currentUser = computed(() => userStore.loginUser.user);
|
||||
|
||||
@@ -77,6 +104,7 @@ const currentUserName = computed(() => currentUser.value?.nick || currentUser.va
|
||||
const currentUserDesc = computed(() => currentUser.value?.phoneNo || currentUser.value?.email || "暂无联系方式");
|
||||
|
||||
const currentUserAvatar = computed(() => buildUserAvatar(currentUser.value));
|
||||
const canSelectStore = computed(() => userStore.hasPermissions(permissionCodes.store.read));
|
||||
const canManagePermission = computed(() => (
|
||||
userStore.hasRoles(coreRoleCodes.admin) || userStore.hasRoles(coreRoleCodes.userRoleAdmin)
|
||||
) && userStore.hasPermissions([
|
||||
@@ -90,10 +118,48 @@ const visibleMenuGroupList = computed<MenuGroup[]>(() => menuGroupList.value
|
||||
}))
|
||||
.filter(group => group.items.length));
|
||||
|
||||
// ---------- 当前门店 ----------
|
||||
|
||||
const storePickerVisible = ref(false);
|
||||
const storePickerDraft = ref<PickerValue[]>([]);
|
||||
const storePickerColumns = computed(() => [
|
||||
currentStoreStore.storeList.map(item => ({
|
||||
label: item.name || item.code || "未命名门店",
|
||||
value: item.id || ""
|
||||
})).filter(item => item.value)
|
||||
]);
|
||||
|
||||
function openStorePicker(): void {
|
||||
if (!currentStoreStore.storeList.length) {
|
||||
Toast.warning("暂无可选门店");
|
||||
return;
|
||||
}
|
||||
|
||||
storePickerDraft.value = [currentStoreStore.currentStoreId];
|
||||
storePickerVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmStorePicker(): void {
|
||||
const storeId = String(storePickerDraft.value[0] || "");
|
||||
if (!storeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentStoreStore.selectStore(storeId);
|
||||
storePickerVisible.value = false;
|
||||
Toast.success("当前门店已切换");
|
||||
}
|
||||
|
||||
const menuGroupList = computed<MenuGroup[]>(() => [
|
||||
{
|
||||
title: "客户",
|
||||
items: [
|
||||
{
|
||||
icon: "shop",
|
||||
path: "/management/stores",
|
||||
title: "门店管理",
|
||||
visible: userStore.hasPermissions(permissionCodes.store.read)
|
||||
},
|
||||
{
|
||||
icon: "usergroup",
|
||||
path: "/management/customers",
|
||||
@@ -176,6 +242,12 @@ function buildUserAvatar(user?: UserAvatarSource): string {
|
||||
async function go(path: string): Promise<void> {
|
||||
await router.push(path);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (canSelectStore.value) {
|
||||
await currentStoreStore.loadStoreList();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -203,6 +275,13 @@ async function go(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
.store-card {
|
||||
|
||||
.cell {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 1.25rem 1rem calc(1rem + env(safe-area-inset-bottom, 0));
|
||||
text-align: center;
|
||||
|
||||
Reference in New Issue
Block a user