feat(project): implement all-orders page with modular structure
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 55s

This commit is contained in:
2026-02-27 10:19:10 +08:00
parent f2c50f69d1
commit 50877b6ba9
21 changed files with 1560 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import type { OrderAllDetailDto } from '#/api/order';
import { Drawer, Empty, Spin, Tag } from 'ant-design-vue';
import { formatCurrency } from '../composables/order-all-page/helpers';
interface Props {
detail: null | OrderAllDetailDto;
loading: boolean;
open: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'close'): void;
}>();
function resolveStatusTagColor(status: string) {
if (status.includes('退款')) return 'red';
if (status.includes('取消')) return 'default';
if (status.includes('完成')) return 'green';
if (status.includes('接单') || status.includes('取餐')) return 'orange';
if (status.includes('制作') || status.includes('配送')) return 'blue';
return 'default';
}
</script>
<template>
<Drawer
:open="props.open"
width="560"
:title="`订单详情 ${props.detail?.orderNo ?? ''}`"
@close="emit('close')"
>
<Spin :spinning="props.loading">
<template v-if="props.detail">
<div class="oa-section">
<div class="oa-section-title">基本信息</div>
<div class="oa-info-grid">
<div>
<span class="label">订单号</span>{{ props.detail.orderNo }}
</div>
<div>
<span class="label">渠道</span>{{ props.detail.channel }}
</div>
<div>
<span class="label">状态</span>
<Tag :color="resolveStatusTagColor(props.detail.status)">
{{ props.detail.status }}
</Tag>
</div>
<div>
<span class="label">支付方式</span>
<span>{{ props.detail.paymentMethod }}</span>
</div>
<div>
<span class="label">下单时间</span>
<span>{{ props.detail.orderedAt }}</span>
</div>
<div>
<span class="label">支付时间</span>
<span>{{ props.detail.paidAt || '--' }}</span>
</div>
<div>
<span class="label">完成时间</span>
<span>{{ props.detail.finishedAt || '--' }}</span>
</div>
</div>
</div>
<div class="oa-section">
<div class="oa-section-title">顾客信息</div>
<div class="oa-info-grid">
<div>
<span class="label">姓名</span>
<span>{{ props.detail.customerName }}</span>
</div>
<div>
<span class="label">手机号</span>
<span>{{ props.detail.customerPhone }}</span>
</div>
<div class="full">
<span class="label">收货地址</span>
<span>{{ props.detail.customerAddress }}</span>
</div>
</div>
</div>
<div class="oa-section">
<div class="oa-section-title">商品明细</div>
<table class="oa-detail-table">
<thead>
<tr>
<th>商品</th>
<th>规格</th>
<th>数量</th>
<th>单价</th>
<th class="right">小计</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in props.detail.items"
:key="`${item.name}-${item.spec}-${index}`"
>
<td>{{ item.name }}</td>
<td>{{ item.spec }}</td>
<td>{{ item.quantity }}</td>
<td>{{ formatCurrency(item.unitPrice) }}</td>
<td class="right">{{ formatCurrency(item.subTotal) }}</td>
</tr>
</tbody>
</table>
<div class="oa-amount-summary">
<div>
<span>商品合计</span>
<span>{{ formatCurrency(props.detail.itemsAmount) }}</span>
</div>
<div>
<span>配送费</span>
<span>{{ formatCurrency(props.detail.deliveryFee) }}</span>
</div>
<div>
<span>优惠减免</span>
<span class="discount">
-{{ formatCurrency(props.detail.discountAmount) }}
</span>
</div>
<div class="total">
<span>实付金额</span>
<span>{{ formatCurrency(props.detail.paidAmount) }}</span>
</div>
</div>
</div>
<div class="oa-section">
<div class="oa-section-title">状态时间线</div>
<div class="oa-timeline">
<div
v-for="(item, index) in props.detail.timeline"
:key="`${item.time}-${index}`"
class="oa-timeline-item"
>
<span class="dot"></span>
<span class="text">{{ item.label }}</span>
<span class="time">{{ item.time }}</span>
</div>
</div>
</div>
<div class="oa-section">
<div class="oa-section-title">备注</div>
<div class="oa-remark">{{ props.detail.remark || '无' }}</div>
</div>
</template>
<Empty v-else description="暂无详情" />
</Spin>
</Drawer>
</template>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { Button, Input, Select } from 'ant-design-vue';
import StoreScopeToolbar from '../../../shared/components/StoreScopeToolbar.vue';
import {
CHANNEL_OPTIONS,
PAYMENT_OPTIONS,
STATUS_OPTIONS,
} from '../composables/order-all-page/constants';
interface OptionItem {
label: string;
value: string;
}
interface FilterState {
channel: string;
endDate: string;
keyword: string;
paymentMethod: string;
startDate: string;
status: string;
}
interface Props {
filters: FilterState;
isExporting: boolean;
isStoreLoading: boolean;
selectedStoreId: string;
storeOptions: OptionItem[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'export'): void;
(event: 'reset'): void;
(event: 'search'): void;
(event: 'update:channel', value: string): void;
(event: 'update:endDate', value: string): void;
(event: 'update:keyword', value: string): void;
(event: 'update:paymentMethod', value: string): void;
(event: 'update:selectedStoreId', value: string): void;
(event: 'update:startDate', value: string): void;
(event: 'update:status', value: string): void;
}>();
</script>
<template>
<StoreScopeToolbar
:selected-store-id="props.selectedStoreId"
:store-options="props.storeOptions"
:is-store-loading="props.isStoreLoading"
:show-copy-button="false"
@update:selected-store-id="(value) => emit('update:selectedStoreId', value)"
>
<template #actions>
<div class="oa-filter-actions">
<Input
class="oa-date-input"
type="date"
:value="props.filters.startDate"
@update:value="
(value) => emit('update:startDate', String(value ?? ''))
"
/>
<span class="oa-date-sep">~</span>
<Input
class="oa-date-input"
type="date"
:value="props.filters.endDate"
@update:value="(value) => emit('update:endDate', String(value ?? ''))"
/>
<Select
class="oa-select"
:value="props.filters.status"
:options="STATUS_OPTIONS"
@update:value="
(value) => emit('update:status', String(value ?? 'all'))
"
/>
<Select
class="oa-select"
:value="props.filters.channel"
:options="CHANNEL_OPTIONS"
@update:value="
(value) => emit('update:channel', String(value ?? 'all'))
"
/>
<Select
class="oa-select"
:value="props.filters.paymentMethod"
:options="PAYMENT_OPTIONS"
@update:value="
(value) => emit('update:paymentMethod', String(value ?? 'all'))
"
/>
<Input
class="oa-search"
:value="props.filters.keyword"
placeholder="订单号/手机号"
allow-clear
@update:value="(value) => emit('update:keyword', String(value ?? ''))"
@press-enter="emit('search')"
/>
<Button @click="emit('reset')">重置</Button>
<Button type="primary" @click="emit('search')">查询</Button>
<Button :loading="props.isExporting" @click="emit('export')">
导出
</Button>
</div>
</template>
</StoreScopeToolbar>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { OrderAllStatsDto } from '#/api/order';
import { formatCurrency } from '../composables/order-all-page/helpers';
interface Props {
stats: OrderAllStatsDto;
}
const props = defineProps<Props>();
</script>
<template>
<div class="oa-stats">
<span>
筛选结果<strong>{{ props.stats.totalOrders }} </strong>
</span>
<span>
总金额<strong>{{ formatCurrency(props.stats.totalAmount) }}</strong>
</span>
<span>
平均客单价<strong>{{ formatCurrency(props.stats.averageAmount) }}</strong>
</span>
<span>
退款
<strong class="oa-stats-refund">{{ props.stats.refundCount }} </strong>
</span>
</div>
</template>

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import type { TablePaginationConfig, TableProps } from 'ant-design-vue';
import type { OrderAllListItemDto } from '#/api/order';
import { h } from 'vue';
import { Button, Table, Tag } from 'ant-design-vue';
import { formatCurrency } from '../composables/order-all-page/helpers';
interface PaginationState {
page: number;
pageSize: number;
total: number;
}
interface Props {
loading: boolean;
pagination: PaginationState;
rows: OrderAllListItemDto[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'detail', orderNo: string): void;
(event: 'pageChange', page: number, pageSize: number): void;
}>();
function resolveChannelTagColor(channel: string) {
if (channel.includes('外卖')) return 'blue';
if (channel.includes('自提')) return 'green';
if (channel.includes('堂食')) return 'orange';
return 'default';
}
function resolveStatusTagColor(status: string) {
if (status.includes('退款')) return 'red';
if (status.includes('取消')) return 'default';
if (status.includes('完成')) return 'success';
if (status.includes('接单') || status.includes('取餐')) return 'orange';
if (status.includes('制作') || status.includes('配送')) return 'processing';
return 'default';
}
const columns: TableProps['columns'] = [
{
title: '订单号',
dataIndex: 'orderNo',
width: 170,
},
{
title: '下单时间',
dataIndex: 'orderedAt',
width: 180,
},
{
title: '渠道',
dataIndex: 'channel',
width: 100,
customRender: ({ text }) =>
h(Tag, { color: resolveChannelTagColor(String(text ?? '')) }, () =>
String(text ?? '--'),
),
},
{
title: '顾客',
dataIndex: 'customer',
width: 100,
},
{
title: '商品',
dataIndex: 'itemsSummary',
ellipsis: true,
},
{
title: '金额',
dataIndex: 'amount',
width: 120,
customRender: ({ text }) => formatCurrency(Number(text || 0)),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
customRender: ({ text }) =>
h(Tag, { color: resolveStatusTagColor(String(text ?? '')) }, () =>
String(text ?? '--'),
),
},
{
title: '操作',
key: 'action',
width: 90,
customRender: ({ record }) =>
h(
Button,
{
type: 'link',
onClick: () => emit('detail', String(record.orderNo ?? '')),
},
() => '详情',
),
},
];
function handleTableChange(next: TablePaginationConfig) {
emit('pageChange', Number(next.current || 1), Number(next.pageSize || 10));
}
function resolveRowClassName(record: OrderAllListItemDto) {
return record.isDimmed ? 'oa-row-dim' : '';
}
</script>
<template>
<div class="oa-table-card">
<Table
row-key="orderNo"
:columns="columns"
:data-source="props.rows"
:loading="props.loading"
:pagination="{
current: props.pagination.page,
pageSize: props.pagination.pageSize,
total: props.pagination.total,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
}"
:row-class-name="resolveRowClassName"
@change="handleTableChange"
/>
</div>
</template>

View File

@@ -0,0 +1,55 @@
import type { OptionItem, OrderAllFilterState } from '../../types';
import type {
OrderAllChannelFilter,
OrderAllPaymentFilter,
OrderAllStatusFilter,
} from '#/api/order';
import { getTodayDateString } from './helpers';
export const STATUS_OPTIONS: OptionItem[] = [
{ label: '全部状态', value: 'all' },
{ label: '待接单', value: 'pending' },
{ label: '制作中', value: 'making' },
{ label: '配送中', value: 'delivering' },
{ label: '待取餐', value: 'pickup' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
{ label: '已退款', value: 'refunded' },
];
export const CHANNEL_OPTIONS: OptionItem[] = [
{ label: '全部渠道', value: 'all' },
{ label: '外卖', value: 'delivery' },
{ label: '自提', value: 'pickup' },
{ label: '堂食', value: 'dine_in' },
];
export const PAYMENT_OPTIONS: OptionItem[] = [
{ label: '全部支付', value: 'all' },
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝', value: 'alipay' },
{ label: '余额支付', value: 'balance' },
{ label: '现金', value: 'cash' },
{ label: '刷卡', value: 'card' },
];
export function createDefaultFilters(): OrderAllFilterState {
const today = getTodayDateString();
return {
status: 'all' as OrderAllStatusFilter,
channel: 'all' as OrderAllChannelFilter,
paymentMethod: 'all' as OrderAllPaymentFilter,
keyword: '',
startDate: today,
endDate: today,
};
}
export const DEFAULT_STATS = {
totalOrders: 0,
totalAmount: 0,
averageAmount: 0,
refundCount: 0,
};

View File

@@ -0,0 +1,102 @@
import type { OrderAllFilterState, OrderAllPaginationState } from '../../types';
import type { OrderAllListItemDto, OrderAllStatsDto } from '#/api/order';
import type { StoreListItemDto } from '#/api/store';
import { getOrderAllListApi, getOrderAllStatsApi } from '#/api/order';
import { getStoreListApi } from '#/api/store';
import { buildQueryPayload } from './helpers';
interface DataActionOptions {
filters: OrderAllFilterState;
isListLoading: { value: boolean };
isStatsLoading: { value: boolean };
isStoreLoading: { value: boolean };
pagination: OrderAllPaginationState;
rows: { value: OrderAllListItemDto[] };
selectedStoreId: { value: string };
stats: OrderAllStatsDto;
stores: { value: StoreListItemDto[] };
}
/**
* 文件职责:全部订单数据加载动作。
*/
export function createDataActions(options: DataActionOptions) {
function resetStats() {
options.stats.totalOrders = 0;
options.stats.totalAmount = 0;
options.stats.averageAmount = 0;
options.stats.refundCount = 0;
}
async function loadStores() {
options.isStoreLoading.value = true;
try {
const result = await getStoreListApi({ page: 1, pageSize: 200 });
options.stores.value = result.items;
if (result.items.length === 0) {
options.selectedStoreId.value = '';
options.rows.value = [];
options.pagination.total = 0;
resetStats();
return;
}
const matched = result.items.some(
(store) => store.id === options.selectedStoreId.value,
);
if (!matched) {
options.selectedStoreId.value = result.items[0]?.id ?? '';
}
} finally {
options.isStoreLoading.value = false;
}
}
async function loadPageData() {
if (!options.selectedStoreId.value) {
options.rows.value = [];
options.pagination.total = 0;
resetStats();
return;
}
const queryPayload = buildQueryPayload(
options.selectedStoreId.value,
options.filters,
options.pagination.page,
options.pagination.pageSize,
);
const { page: _page, pageSize: _pageSize, ...filterPayload } = queryPayload;
options.isListLoading.value = true;
options.isStatsLoading.value = true;
try {
const [listResult, statsResult] = await Promise.all([
getOrderAllListApi(queryPayload),
getOrderAllStatsApi(filterPayload),
]);
options.rows.value = listResult.items;
options.pagination.total = listResult.total;
options.pagination.page = listResult.page;
options.pagination.pageSize = listResult.pageSize;
options.stats.totalOrders = statsResult.totalOrders;
options.stats.totalAmount = statsResult.totalAmount;
options.stats.averageAmount = statsResult.averageAmount;
options.stats.refundCount = statsResult.refundCount;
} finally {
options.isListLoading.value = false;
options.isStatsLoading.value = false;
}
}
return {
loadPageData,
loadStores,
};
}

View File

@@ -0,0 +1,45 @@
import type { OrderAllDetailDto } from '#/api/order';
import { getOrderAllDetailApi } from '#/api/order';
interface DrawerActionOptions {
detail: { value: null | OrderAllDetailDto };
isDetailLoading: { value: boolean };
isDrawerOpen: { value: boolean };
selectedStoreId: { value: string };
}
/**
* 文件职责:全部订单详情抽屉动作。
*/
export function createDrawerActions(options: DrawerActionOptions) {
function setDrawerOpen(value: boolean) {
options.isDrawerOpen.value = value;
if (!value) {
options.detail.value = null;
}
}
async function openDetail(orderNo: string) {
if (!options.selectedStoreId.value || !orderNo) {
return;
}
options.isDrawerOpen.value = true;
options.detail.value = null;
options.isDetailLoading.value = true;
try {
options.detail.value = await getOrderAllDetailApi({
storeId: options.selectedStoreId.value,
orderNo,
});
} finally {
options.isDetailLoading.value = false;
}
}
return {
openDetail,
setDrawerOpen,
};
}

View File

@@ -0,0 +1,58 @@
import type { OrderAllFilterState } from '../../types';
import { message } from 'ant-design-vue';
import { exportOrderAllCsvApi } from '#/api/order';
import { downloadBase64File, isDateRangeInvalid } from './helpers';
interface ExportActionOptions {
filters: OrderAllFilterState;
isExporting: { value: boolean };
selectedStoreId: { value: string };
}
/**
* 文件职责:全部订单导出动作。
*/
export function createExportActions(options: ExportActionOptions) {
async function handleExport() {
if (!options.selectedStoreId.value) {
return;
}
if (isDateRangeInvalid(options.filters)) {
message.warning('开始日期不能晚于结束日期');
return;
}
options.isExporting.value = true;
try {
const result = await exportOrderAllCsvApi({
storeId: options.selectedStoreId.value,
startDate: options.filters.startDate || undefined,
endDate: options.filters.endDate || undefined,
status:
options.filters.status === 'all' ? undefined : options.filters.status,
channel:
options.filters.channel === 'all'
? undefined
: options.filters.channel,
paymentMethod:
options.filters.paymentMethod === 'all'
? undefined
: options.filters.paymentMethod,
keyword: options.filters.keyword.trim() || undefined,
});
downloadBase64File(result.fileName, result.fileContentBase64);
message.success(`导出成功,共 ${result.totalCount} 条记录`);
} finally {
options.isExporting.value = false;
}
}
return {
handleExport,
};
}

View File

@@ -0,0 +1,83 @@
import type { OrderAllFilterState, OrderAllPaginationState } from '../../types';
import { message } from 'ant-design-vue';
import { createDefaultFilters } from './constants';
import { isDateRangeInvalid } from './helpers';
interface FilterActionOptions {
filters: OrderAllFilterState;
loadPageData: () => Promise<void>;
pagination: OrderAllPaginationState;
}
/**
* 文件职责:全部订单筛选与分页行为。
*/
export function createFilterActions(options: FilterActionOptions) {
function setStatus(value: string) {
options.filters.status = (value || 'all') as OrderAllFilterState['status'];
}
function setChannel(value: string) {
options.filters.channel = (value ||
'all') as OrderAllFilterState['channel'];
}
function setPaymentMethod(value: string) {
options.filters.paymentMethod = (value ||
'all') as OrderAllFilterState['paymentMethod'];
}
function setStartDate(value: string) {
options.filters.startDate = value;
}
function setEndDate(value: string) {
options.filters.endDate = value;
}
function setKeyword(value: string) {
options.filters.keyword = value;
}
async function handleSearch() {
if (isDateRangeInvalid(options.filters)) {
message.warning('开始日期不能晚于结束日期');
return;
}
options.pagination.page = 1;
await options.loadPageData();
}
async function handleReset() {
const defaults = createDefaultFilters();
options.filters.status = defaults.status;
options.filters.channel = defaults.channel;
options.filters.paymentMethod = defaults.paymentMethod;
options.filters.keyword = defaults.keyword;
options.filters.startDate = defaults.startDate;
options.filters.endDate = defaults.endDate;
options.pagination.page = 1;
await options.loadPageData();
}
async function handlePageChange(page: number, pageSize: number) {
options.pagination.page = page;
options.pagination.pageSize = pageSize;
await options.loadPageData();
}
return {
handlePageChange,
handleReset,
handleSearch,
setChannel,
setEndDate,
setKeyword,
setPaymentMethod,
setStartDate,
setStatus,
};
}

View File

@@ -0,0 +1,91 @@
import type { OrderAllFilterState, OrderAllQueryPayload } from '../../types';
import type {
OrderAllChannelFilter,
OrderAllPaymentFilter,
OrderAllStatusFilter,
} from '#/api/order';
export function getTodayDateString() {
const now = new Date();
const year = now.getFullYear();
const month = `${now.getMonth() + 1}`.padStart(2, '0');
const day = `${now.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}
function normalizeStatus(
status: OrderAllStatusFilter,
): OrderAllStatusFilter | undefined {
return status === 'all' ? undefined : status;
}
function normalizeChannel(
channel: OrderAllChannelFilter,
): OrderAllChannelFilter | undefined {
return channel === 'all' ? undefined : channel;
}
function normalizePayment(
paymentMethod: OrderAllPaymentFilter,
): OrderAllPaymentFilter | undefined {
return paymentMethod === 'all' ? undefined : paymentMethod;
}
export function buildQueryPayload(
storeId: string,
filters: OrderAllFilterState,
page: number,
pageSize: number,
): OrderAllQueryPayload {
return {
storeId,
page,
pageSize,
startDate: filters.startDate || undefined,
endDate: filters.endDate || undefined,
status: normalizeStatus(filters.status),
channel: normalizeChannel(filters.channel),
paymentMethod: normalizePayment(filters.paymentMethod),
keyword: filters.keyword.trim() || undefined,
};
}
export function formatCurrency(value: number) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Number.isFinite(value) ? value : 0);
}
export function decodeBase64ToBlob(base64: string) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.codePointAt(index) ?? 0;
}
return new Blob([bytes], { type: 'text/csv;charset=utf-8;' });
}
export function downloadBase64File(
fileName: string,
fileContentBase64: string,
) {
const blob = decodeBase64ToBlob(fileContentBase64);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}
export function isDateRangeInvalid(filters: OrderAllFilterState) {
if (!filters.startDate || !filters.endDate) {
return false;
}
return filters.startDate > filters.endDate;
}

View File

@@ -0,0 +1,146 @@
import type { OrderAllDetailDto, OrderAllListItemDto } from '#/api/order';
import type { StoreListItemDto } from '#/api/store';
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
import {
createDefaultFilters,
DEFAULT_STATS,
} from './order-all-page/constants';
import { createDataActions } from './order-all-page/data-actions';
import { createDrawerActions } from './order-all-page/drawer-actions';
import { createExportActions } from './order-all-page/export-actions';
import { createFilterActions } from './order-all-page/filter-actions';
/**
* 文件职责:全部订单页面状态与动作编排。
*/
export function useOrderAllPage() {
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const filters = reactive(createDefaultFilters());
const rows = ref<OrderAllListItemDto[]>([]);
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0,
});
const stats = reactive({ ...DEFAULT_STATS });
const isListLoading = ref(false);
const isStatsLoading = ref(false);
const detail = ref<null | OrderAllDetailDto>(null);
const isDrawerOpen = ref(false);
const isDetailLoading = ref(false);
const isExporting = ref(false);
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const { loadPageData, loadStores } = createDataActions({
stores,
selectedStoreId,
filters,
rows,
pagination,
stats,
isStoreLoading,
isListLoading,
isStatsLoading,
});
const {
handlePageChange,
handleReset,
handleSearch,
setChannel,
setEndDate,
setKeyword,
setPaymentMethod,
setStartDate,
setStatus,
} = createFilterActions({
filters,
pagination,
loadPageData,
});
const { openDetail, setDrawerOpen } = createDrawerActions({
selectedStoreId,
detail,
isDrawerOpen,
isDetailLoading,
});
const { handleExport } = createExportActions({
selectedStoreId,
filters,
isExporting,
});
function setSelectedStoreId(value: string) {
selectedStoreId.value = value;
}
watch(selectedStoreId, async (storeId) => {
if (!storeId) {
rows.value = [];
pagination.total = 0;
stats.totalOrders = DEFAULT_STATS.totalOrders;
stats.totalAmount = DEFAULT_STATS.totalAmount;
stats.averageAmount = DEFAULT_STATS.averageAmount;
stats.refundCount = DEFAULT_STATS.refundCount;
detail.value = null;
return;
}
pagination.page = 1;
await loadPageData();
});
onMounted(() => {
void loadStores();
});
onActivated(() => {
if (stores.value.length === 0 || !selectedStoreId.value) {
void loadStores();
}
});
return {
detail,
filters,
handleExport,
handlePageChange,
handleReset,
handleSearch,
isDetailLoading,
isDrawerOpen,
isExporting,
isListLoading,
isStatsLoading,
isStoreLoading,
openDetail,
pagination,
rows,
selectedStoreId,
setChannel,
setDrawerOpen,
setEndDate,
setKeyword,
setPaymentMethod,
setSelectedStoreId,
setStartDate,
setStatus,
stats,
storeOptions,
};
}

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import OrderDetailDrawer from './components/OrderDetailDrawer.vue';
import OrderFilterBar from './components/OrderFilterBar.vue';
import OrderStatsBar from './components/OrderStatsBar.vue';
import OrderTableCard from './components/OrderTableCard.vue';
import { useOrderAllPage } from './composables/useOrderAllPage';
const {
detail,
filters,
handleExport,
handlePageChange,
handleReset,
handleSearch,
isDetailLoading,
isDrawerOpen,
isExporting,
isListLoading,
isStoreLoading,
openDetail,
pagination,
rows,
selectedStoreId,
setChannel,
setDrawerOpen,
setEndDate,
setKeyword,
setPaymentMethod,
setSelectedStoreId,
setStartDate,
setStatus,
stats,
storeOptions,
} = useOrderAllPage();
</script>
<template>
<Page title="全部订单" content-class="page-order-all">
<div class="oa-page">
<OrderFilterBar
:selected-store-id="selectedStoreId"
:store-options="storeOptions"
:is-store-loading="isStoreLoading"
:filters="filters"
:is-exporting="isExporting"
@update:selected-store-id="setSelectedStoreId"
@update:start-date="setStartDate"
@update:end-date="setEndDate"
@update:status="setStatus"
@update:channel="setChannel"
@update:payment-method="setPaymentMethod"
@update:keyword="setKeyword"
@search="handleSearch"
@reset="handleReset"
@export="handleExport"
/>
<OrderStatsBar :stats="stats" />
<OrderTableCard
:rows="rows"
:loading="isListLoading"
:pagination="pagination"
@page-change="handlePageChange"
@detail="openDetail"
/>
</div>
<OrderDetailDrawer
:open="isDrawerOpen"
:loading="isDetailLoading"
:detail="detail"
@close="setDrawerOpen(false)"
/>
</Page>
</template>
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -0,0 +1,11 @@
.page-order-all {
.ant-card {
border-radius: 10px;
}
}
.oa-page {
display: flex;
flex-direction: column;
gap: 12px;
}

View File

@@ -0,0 +1,125 @@
.oa-section {
margin-bottom: 20px;
.oa-section-title {
padding-left: 10px;
margin-bottom: 12px;
font-size: 14px;
font-weight: 600;
color: rgb(0 0 0 / 88%);
border-left: 3px solid #1677ff;
}
}
.oa-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 20px;
font-size: 13px;
.label {
color: rgb(0 0 0 / 45%);
}
.full {
grid-column: 1 / -1;
}
}
.oa-detail-table {
width: 100%;
font-size: 13px;
border-collapse: collapse;
th {
padding: 8px 10px;
font-size: 12px;
font-weight: 500;
color: rgb(0 0 0 / 45%);
text-align: left;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
&.right {
text-align: right;
}
}
td {
padding: 8px 10px;
color: rgb(0 0 0 / 88%);
border-bottom: 1px solid #f5f5f5;
&.right {
text-align: right;
}
}
}
.oa-amount-summary {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
font-size: 13px;
> div {
display: flex;
justify-content: space-between;
color: rgb(0 0 0 / 65%);
}
.discount {
color: #ff4d4f;
}
.total {
padding-top: 8px;
margin-top: 4px;
font-weight: 600;
color: rgb(0 0 0 / 88%);
border-top: 1px solid #f0f0f0;
}
}
.oa-timeline {
padding-left: 4px;
.oa-timeline-item {
position: relative;
display: flex;
gap: 8px;
align-items: center;
padding: 0 0 12px 16px;
.dot {
position: absolute;
top: 5px;
left: 0;
width: 8px;
height: 8px;
background: #1677ff;
border-radius: 50%;
}
.text {
font-size: 13px;
font-weight: 500;
color: rgb(0 0 0 / 88%);
}
.time {
font-size: 13px;
color: rgb(0 0 0 / 45%);
}
}
}
.oa-remark {
padding: 10px 14px;
font-size: 13px;
color: rgb(0 0 0 / 65%);
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 8px;
}

View File

@@ -0,0 +1,5 @@
@import './base.less';
@import './layout.less';
@import './table.less';
@import './drawer.less';
@import './responsive.less';

View File

@@ -0,0 +1,45 @@
.oa-filter-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
.oa-date-input {
width: 145px;
}
.oa-date-sep {
font-size: 13px;
color: rgb(0 0 0 / 45%);
}
.oa-select {
width: 120px;
}
.oa-search {
width: 200px;
}
}
.oa-stats {
display: flex;
gap: 24px;
padding: 0 4px;
font-size: 13px;
color: rgb(0 0 0 / 65%);
span {
white-space: nowrap;
strong {
margin-left: 4px;
font-weight: 600;
color: rgb(0 0 0 / 88%);
}
}
.oa-stats-refund {
color: #ff4d4f;
}
}

View File

@@ -0,0 +1,28 @@
@media (max-width: 1600px) {
.oa-stats {
flex-wrap: wrap;
gap: 12px 18px;
}
}
@media (max-width: 768px) {
.oa-filter-actions {
.oa-date-input,
.oa-select,
.oa-search {
width: 100%;
}
.oa-date-sep {
display: none;
}
}
.oa-info-grid {
grid-template-columns: 1fr;
.full {
grid-column: auto;
}
}
}

View File

@@ -0,0 +1,20 @@
.oa-table-card {
padding: 6px 8px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 10px;
.ant-table-wrapper {
.ant-table-thead > tr > th {
white-space: nowrap;
}
}
}
.oa-row-dim {
opacity: 0.55;
}
.oa-row-dim:hover td {
opacity: 0.75;
}

View File

@@ -0,0 +1,52 @@
import type {
OrderAllChannelFilter,
OrderAllDetailDto,
OrderAllListItemDto,
OrderAllPaymentFilter,
OrderAllStatsDto,
OrderAllStatusFilter,
} from '#/api/order';
export interface OrderAllFilterState {
channel: OrderAllChannelFilter;
endDate: string;
keyword: string;
paymentMethod: OrderAllPaymentFilter;
startDate: string;
status: OrderAllStatusFilter;
}
export interface OrderAllPaginationState {
page: number;
pageSize: number;
total: number;
}
export interface OrderAllPageState {
detail: null | OrderAllDetailDto;
filters: OrderAllFilterState;
isDetailLoading: boolean;
isDrawerOpen: boolean;
isExporting: boolean;
isListLoading: boolean;
isStatsLoading: boolean;
rows: OrderAllListItemDto[];
stats: OrderAllStatsDto;
}
export interface OptionItem {
label: string;
value: string;
}
export interface OrderAllQueryPayload {
channel?: OrderAllChannelFilter;
endDate?: string;
keyword?: string;
page: number;
pageSize: number;
paymentMethod?: OrderAllPaymentFilter;
startDate?: string;
status?: OrderAllStatusFilter;
storeId: string;
}