feat(M08-D): 建立后台运营总览
This commit is contained in:
+41
-11
@@ -10,7 +10,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-list" aria-label="后台模块">
|
||||
<button class="nav-item active" type="button">
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: activeModule === 'overview' }"
|
||||
type="button"
|
||||
@click="activeModule = 'overview'"
|
||||
>
|
||||
<LayoutDashboard :size="18" />
|
||||
<span>运营总览</span>
|
||||
</button>
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: activeModule === 'cleaning' }"
|
||||
type="button"
|
||||
@click="activeModule = 'cleaning'"
|
||||
>
|
||||
<Sparkles :size="18" />
|
||||
<span>保洁运营</span>
|
||||
</button>
|
||||
@@ -32,8 +46,8 @@
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">M08-B</p>
|
||||
<h2>保洁任务与结算</h2>
|
||||
<p class="eyebrow">{{ activeModule === 'overview' ? 'M08-D' : 'M08-B' }}</p>
|
||||
<h2>{{ activeModule === 'overview' ? '平台运营总览' : '保洁任务与结算' }}</h2>
|
||||
</div>
|
||||
<div class="token-box">
|
||||
<el-input
|
||||
@@ -50,6 +64,13 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<OperationsOverviewPanel
|
||||
v-if="activeModule === 'overview'"
|
||||
:session="session"
|
||||
@open-cleaning="activeModule = 'cleaning'"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<section class="metric-grid" aria-label="保洁概览">
|
||||
<div class="metric">
|
||||
<span>待验收</span>
|
||||
@@ -198,17 +219,19 @@
|
||||
<CleaningFieldHandoffPanel />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
Download,
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
RadioTower,
|
||||
RotateCcw,
|
||||
@@ -223,6 +246,7 @@ import CleaningSettlementsPanel from './components/CleaningSettlementsPanel.vue'
|
||||
import CleaningStatisticsPanel from './components/CleaningStatisticsPanel.vue';
|
||||
import CleanersPanel from './components/CleanersPanel.vue';
|
||||
import CleaningFieldHandoffPanel from './components/CleaningFieldHandoffPanel.vue';
|
||||
import OperationsOverviewPanel from './components/OperationsOverviewPanel.vue';
|
||||
import {
|
||||
ApiError,
|
||||
assignCleaningTask,
|
||||
@@ -257,7 +281,9 @@ import type {
|
||||
} from './types';
|
||||
import { money } from './format';
|
||||
|
||||
const tokenDraft = ref(localStorage.getItem('qipai.admin.token') || '');
|
||||
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
||||
const tokenDraft = ref(savedToken.value);
|
||||
const activeModule = ref<'overview' | 'cleaning'>('overview');
|
||||
const activeTab = ref('tasks');
|
||||
const lastError = ref('');
|
||||
const lastMessage = ref('');
|
||||
@@ -265,7 +291,7 @@ const taskStatus = ref<TaskStatus | ''>('SUBMITTED');
|
||||
const settlementStatus = ref<SettlementStatus | ''>('CONFIRMED');
|
||||
const cleanerStatus = ref<UserStatus | ''>('ACTIVE');
|
||||
const cleanerSearch = ref('');
|
||||
const session = computed(() => ({ token: tokenDraft.value }));
|
||||
const session = computed(() => ({ token: savedToken.value }));
|
||||
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
|
||||
const tasks = reactive<PageResult<CleaningTask>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
const settlements = reactive<PageResult<CleaningSettlement>>({
|
||||
@@ -310,8 +336,14 @@ const operationalQueueTotal = computed(() => (
|
||||
));
|
||||
|
||||
function saveToken() {
|
||||
localStorage.setItem('qipai.admin.token', tokenDraft.value.trim());
|
||||
savedToken.value = tokenDraft.value.trim();
|
||||
localStorage.setItem('qipai.admin.token', savedToken.value);
|
||||
ElMessage.success('已保存');
|
||||
if (activeModule.value === 'cleaning' && savedToken.value) void loadCleaningWorkspace();
|
||||
}
|
||||
|
||||
function loadCleaningWorkspace() {
|
||||
return Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
|
||||
}
|
||||
|
||||
async function runAction(work: () => Promise<void>, success: string) {
|
||||
@@ -659,9 +691,7 @@ async function handleResetCleanerSessions(userId: string) {
|
||||
}, '已重置保洁员会话');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (session.value.token) {
|
||||
void Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
|
||||
}
|
||||
watch(activeModule, (module) => {
|
||||
if (module === 'cleaning' && session.value.token) void loadCleaningWorkspace();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
CleaningTask,
|
||||
CleaningTaskEvent,
|
||||
CleaningTaskMember,
|
||||
BusinessStatistics,
|
||||
ManagedStore,
|
||||
ManagedUser,
|
||||
PageResult,
|
||||
PayoutStateFilter,
|
||||
@@ -32,6 +34,18 @@ export interface ApiSession {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function listManagedStores(session: ApiSession) {
|
||||
return request<ManagedStore[]>(session, '/stores');
|
||||
}
|
||||
|
||||
export function getBusinessStatistics(
|
||||
session: ApiSession,
|
||||
input: { storeId: string; from: string; to: string }
|
||||
) {
|
||||
const params = new URLSearchParams(input);
|
||||
return request<BusinessStatistics>(session, `/statistics?${params}`);
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
session: ApiSession,
|
||||
path: string,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<section class="overview-page" aria-label="平台运营总览">
|
||||
<div class="overview-toolbar panel">
|
||||
<div>
|
||||
<p class="section-kicker">授权数据范围</p>
|
||||
<h3>门店经营快照</h3>
|
||||
<p>实收只统计成功支付,房态为当前快照。</p>
|
||||
</div>
|
||||
<div class="overview-filters">
|
||||
<el-select
|
||||
v-model="selectedStoreId"
|
||||
aria-label="选择门店"
|
||||
placeholder="选择授权门店"
|
||||
filterable
|
||||
:loading="loadingStores"
|
||||
>
|
||||
<el-option
|
||||
v-for="store in stores"
|
||||
:key="store.id"
|
||||
:label="`${store.name} · ${store.city || '未配置城市'}`"
|
||||
:value="store.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-radio-group v-model="periodDays" aria-label="统计周期">
|
||||
<el-radio-button :value="7">近 7 日</el-radio-button>
|
||||
<el-radio-button :value="30">近 30 日</el-radio-button>
|
||||
<el-radio-button :value="90">近 90 日</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button :icon="RefreshCw" :loading="loadingStatistics" @click="loadStatistics">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-empty
|
||||
v-else-if="!session.token.trim()"
|
||||
description="请先在右上角保存后台访问令牌"
|
||||
/>
|
||||
<el-empty
|
||||
v-else-if="!loadingStores && stores.length === 0"
|
||||
description="当前账号没有可查看的门店"
|
||||
/>
|
||||
|
||||
<template v-if="selectedStore && statistics">
|
||||
<section class="overview-context">
|
||||
<div>
|
||||
<strong>{{ selectedStore.name }}</strong>
|
||||
<span>{{ storeAddress }}</span>
|
||||
</div>
|
||||
<el-tag :type="selectedStore.businessStatus === 'OPEN' ? 'success' : 'warning'">
|
||||
{{ storeStatusText(selectedStore.businessStatus) }}
|
||||
</el-tag>
|
||||
<span>{{ rangeText }}</span>
|
||||
</section>
|
||||
|
||||
<section class="overview-metrics" aria-label="经营核心指标">
|
||||
<article class="overview-metric primary">
|
||||
<span>成功实收</span>
|
||||
<strong>{{ money(statistics.summary.collectedAmountCents) }}</strong>
|
||||
<small>预订金额 {{ money(statistics.summary.bookedAmountCents) }}</small>
|
||||
</article>
|
||||
<article class="overview-metric">
|
||||
<span>订单 / 活跃</span>
|
||||
<strong>{{ statistics.summary.orderTotal }} / {{ statistics.summary.activeOrderTotal }}</strong>
|
||||
<small>已完成 {{ statistics.summary.finishedOrderTotal }}</small>
|
||||
</article>
|
||||
<article class="overview-metric">
|
||||
<span>下单会员</span>
|
||||
<strong>{{ statistics.summary.payingMemberTotal }}</strong>
|
||||
<small>按 OWNER 订单去重</small>
|
||||
</article>
|
||||
<article class="overview-metric">
|
||||
<span>验券成功</span>
|
||||
<strong>{{ statistics.summary.voucherSucceeded }}</strong>
|
||||
<small>失败 {{ statistics.summary.voucherFailed }}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="overview-grid">
|
||||
<article class="panel overview-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">实时资源</p>
|
||||
<h3>当前房态</h3>
|
||||
</div>
|
||||
<DoorOpen :size="22" />
|
||||
</div>
|
||||
<div class="room-state-grid">
|
||||
<div><strong>{{ statistics.summary.roomTotal }}</strong><span>房间总数</span></div>
|
||||
<div><strong>{{ statistics.summary.availableRoomTotal }}</strong><span>当前空闲</span></div>
|
||||
<div><strong>{{ statistics.summary.attentionRoomTotal }}</strong><span>需要关注</span></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel overview-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">资金口径</p>
|
||||
<h3>支付渠道</h3>
|
||||
</div>
|
||||
<Landmark :size="22" />
|
||||
</div>
|
||||
<div v-if="statistics.paymentChannels.length" class="channel-list">
|
||||
<div v-for="channel in statistics.paymentChannels" :key="channel.channel">
|
||||
<span>{{ channelText(channel.channel) }} · {{ channel.total }} 笔</span>
|
||||
<strong>{{ money(channel.amountCents) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="周期内暂无成功收款" />
|
||||
</article>
|
||||
|
||||
<article class="panel overview-card revenue-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">成功支付</p>
|
||||
<h3>每日收入趋势</h3>
|
||||
</div>
|
||||
<TrendingUp :size="22" />
|
||||
</div>
|
||||
<div v-if="statistics.dailyRevenue.length" class="revenue-bars">
|
||||
<div v-for="row in statistics.dailyRevenue" :key="row.date" class="revenue-row">
|
||||
<span>{{ shortDate(row.date) }}</span>
|
||||
<div><i :style="{ width: revenueWidth(row.amountCents) }" /></div>
|
||||
<strong>{{ money(row.amountCents) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="周期内暂无收入趋势" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel module-board" aria-label="核心业务导航">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">M08-D 工作区</p>
|
||||
<h3>核心业务导航</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="module-grid">
|
||||
<button type="button" @click="emit('open-cleaning')">
|
||||
<Sparkles :size="22" />
|
||||
<span><strong>保洁运营</strong><small>任务、结算、统计与现场联调</small></span>
|
||||
<ArrowRight :size="18" />
|
||||
</button>
|
||||
<div v-for="item in upcomingModules" :key="item.name" class="module-placeholder">
|
||||
<component :is="item.icon" :size="22" />
|
||||
<span><strong>{{ item.name }}</strong><small>{{ item.description }}</small></span>
|
||||
<el-tag size="small" type="info">后续增量</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
ArrowRight,
|
||||
DoorOpen,
|
||||
Landmark,
|
||||
RadioTower,
|
||||
RefreshCw,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
Store,
|
||||
TrendingUp,
|
||||
Users,
|
||||
WalletCards
|
||||
} from '@lucide/vue';
|
||||
import { ApiError, getBusinessStatistics, listManagedStores, type ApiSession } from '../api';
|
||||
import { money } from '../format';
|
||||
import type { BusinessStatistics, ManagedStore } from '../types';
|
||||
|
||||
const props = defineProps<{ session: ApiSession }>();
|
||||
const emit = defineEmits<{ (event: 'open-cleaning'): void }>();
|
||||
|
||||
const stores = ref<ManagedStore[]>([]);
|
||||
const selectedStoreId = ref('');
|
||||
const periodDays = ref(30);
|
||||
const statistics = ref<BusinessStatistics | null>(null);
|
||||
const loadingStores = ref(false);
|
||||
const loadingStatistics = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
const selectedStore = computed(() => stores.value.find((item) => item.id === selectedStoreId.value));
|
||||
const storeAddress = computed(() => {
|
||||
const store = selectedStore.value;
|
||||
return store ? [store.city, store.district, store.address].filter(Boolean).join(' · ') || '地址未配置' : '';
|
||||
});
|
||||
const rangeText = computed(() => statistics.value
|
||||
? `${statistics.value.from.slice(0, 10)} 至 ${statistics.value.to.slice(0, 10)}` : '');
|
||||
const maxRevenue = computed(() => Math.max(
|
||||
1,
|
||||
...(statistics.value?.dailyRevenue.map((item) => item.amountCents) ?? [1])
|
||||
));
|
||||
const upcomingModules = [
|
||||
{ name: '门店与房间', description: '配置、房态和价格', icon: markRaw(Store) },
|
||||
{ name: '订单与团购', description: '订单处置、核销和退款', icon: markRaw(ShoppingBag) },
|
||||
{ name: '会员与员工', description: '画像、权益和账号权限', icon: markRaw(Users) },
|
||||
{ name: '设备运营', description: '拓扑、告警和控制', icon: markRaw(RadioTower) },
|
||||
{ name: '支付与分账', description: '收款、退款和分账记录', icon: markRaw(WalletCards) }
|
||||
];
|
||||
|
||||
async function loadStores() {
|
||||
if (!props.session.token.trim()) return;
|
||||
loadingStores.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
stores.value = await listManagedStores(props.session);
|
||||
const nextStoreId = stores.value.some((item) => item.id === selectedStoreId.value)
|
||||
? selectedStoreId.value : stores.value[0]?.id ?? '';
|
||||
if (nextStoreId === selectedStoreId.value && nextStoreId) {
|
||||
await loadStatistics();
|
||||
} else {
|
||||
selectedStoreId.value = nextStoreId;
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
loadingStores.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStatistics() {
|
||||
if (!props.session.token.trim() || !selectedStoreId.value) return;
|
||||
loadingStatistics.value = true;
|
||||
errorMessage.value = '';
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - periodDays.value * 86400000);
|
||||
try {
|
||||
statistics.value = await getBusinessStatistics(props.session, {
|
||||
storeId: selectedStoreId.value,
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
statistics.value = null;
|
||||
showError(error);
|
||||
} finally {
|
||||
loadingStatistics.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(error: unknown) {
|
||||
errorMessage.value = error instanceof ApiError
|
||||
? `${error.message}(${error.code}${error.traceId ? ` · ${error.traceId}` : ''})`
|
||||
: error instanceof Error ? error.message : '加载运营总览失败';
|
||||
}
|
||||
|
||||
function revenueWidth(amountCents: number) {
|
||||
return `${Math.max(4, Math.round(amountCents / maxRevenue.value * 100))}%`;
|
||||
}
|
||||
|
||||
function shortDate(value: string) {
|
||||
return value.slice(5, 10);
|
||||
}
|
||||
|
||||
function storeStatusText(status: ManagedStore['businessStatus']) {
|
||||
return ({ OPEN: '营业中', CLOSED: '已关闭', SUSPENDED: '已暂停' })[status];
|
||||
}
|
||||
|
||||
function channelText(channel: string) {
|
||||
return ({ WECHAT: '微信支付', GROUP_BUY: '团购验券', BALANCE: '会员余额' } as Record<string, string>)[channel]
|
||||
?? channel;
|
||||
}
|
||||
|
||||
watch(() => props.session.token, (token, previous) => {
|
||||
if (token.trim() && token !== previous) void loadStores();
|
||||
});
|
||||
watch([selectedStoreId, periodDays], ([storeId], previous) => {
|
||||
if (storeId && previous) void loadStatistics();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (props.session.token.trim()) void loadStores();
|
||||
});
|
||||
</script>
|
||||
@@ -216,6 +216,261 @@ textarea {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.overview-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.overview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.overview-toolbar h3,
|
||||
.section-heading h3 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.overview-toolbar p:not(.section-kicker) {
|
||||
margin: 6px 0 0;
|
||||
color: #687990;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
margin: 0;
|
||||
color: #4d6b92;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.overview-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.overview-filters > .el-select {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.overview-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 42px;
|
||||
padding: 0 4px;
|
||||
color: #687990;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.overview-context > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.overview-context strong {
|
||||
color: #18212f;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.overview-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overview-metric {
|
||||
min-height: 116px;
|
||||
padding: 18px;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.overview-metric.primary {
|
||||
border-color: #aac7ec;
|
||||
background: linear-gradient(135deg, #edf5ff, #fff);
|
||||
}
|
||||
|
||||
.overview-metric span,
|
||||
.overview-metric small {
|
||||
color: #687990;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.overview-metric strong {
|
||||
display: block;
|
||||
margin: 12px 0 8px;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.revenue-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #365477;
|
||||
}
|
||||
|
||||
.room-state-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.room-state-grid > div {
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #f4f7fa;
|
||||
}
|
||||
|
||||
.room-state-grid strong,
|
||||
.room-state-grid span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.room-state-grid strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.room-state-grid span {
|
||||
margin-top: 5px;
|
||||
color: #687990;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.channel-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.channel-list > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #edf0f4;
|
||||
color: #5c6f88;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.channel-list strong {
|
||||
color: #18212f;
|
||||
}
|
||||
|
||||
.revenue-bars {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.revenue-row {
|
||||
display: grid;
|
||||
grid-template-columns: 54px minmax(80px, 1fr) 100px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.revenue-row > div {
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #edf1f6;
|
||||
}
|
||||
|
||||
.revenue-row i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #5d91d0;
|
||||
}
|
||||
|
||||
.revenue-row strong {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.module-board {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.module-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.module-grid > button,
|
||||
.module-placeholder {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 72px;
|
||||
padding: 13px;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #365477;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.module-grid > button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.module-grid > button:hover {
|
||||
border-color: #8cb5ec;
|
||||
background: #f6faff;
|
||||
}
|
||||
|
||||
.module-grid span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.module-grid strong {
|
||||
color: #18212f;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.module-grid small {
|
||||
overflow: hidden;
|
||||
color: #687990;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.module-placeholder {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
border: 1px solid #d8e0ea;
|
||||
@@ -960,6 +1215,20 @@ textarea {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.overview-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.overview-filters {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.overview-metrics,
|
||||
.module-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.handoff-owner-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -1007,6 +1276,31 @@ textarea {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overview-filters,
|
||||
.overview-context {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.overview-filters > .el-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overview-metrics,
|
||||
.overview-grid,
|
||||
.module-grid,
|
||||
.room-state-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.revenue-card {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.revenue-row {
|
||||
grid-template-columns: 48px minmax(60px, 1fr) 82px;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -22,6 +22,39 @@ export interface PageResult<T> {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ManagedStore {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
city: string;
|
||||
district: string;
|
||||
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface BusinessStatistics {
|
||||
storeId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
summary: {
|
||||
orderTotal: number;
|
||||
activeOrderTotal: number;
|
||||
finishedOrderTotal: number;
|
||||
bookedAmountCents: number;
|
||||
collectedAmountCents: number;
|
||||
payingMemberTotal: number;
|
||||
voucherTotal: number;
|
||||
voucherSucceeded: number;
|
||||
voucherFailed: number;
|
||||
roomTotal: number;
|
||||
availableRoomTotal: number;
|
||||
attentionRoomTotal: number;
|
||||
};
|
||||
orderStatuses: Array<{ status: string; total: number; amountCents: number }>;
|
||||
paymentChannels: Array<{ channel: string; total: number; amountCents: number }>;
|
||||
dailyRevenue: Array<{ date: string; total: number; amountCents: number }>;
|
||||
}
|
||||
|
||||
export interface ManagedUser {
|
||||
id: string;
|
||||
userType: string;
|
||||
|
||||
@@ -26,34 +26,36 @@ export async function registerBusinessStatisticsRoutes(
|
||||
app: FastifyInstance,
|
||||
options: BusinessStatisticsRouteOptions
|
||||
) {
|
||||
app.get('/app-api/management/statistics', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options);
|
||||
if (!actor) return;
|
||||
const query = querySchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
const to = query.data.to ?? new Date();
|
||||
const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000);
|
||||
const duration = to.getTime() - from.getTime();
|
||||
if (duration <= 0 || duration > 93 * 86400000) return invalid(reply, request.traceId);
|
||||
try {
|
||||
return {
|
||||
code: 0,
|
||||
data: await options.repository.overview(actor, {
|
||||
storeId: query.data.storeId,
|
||||
from,
|
||||
to
|
||||
}),
|
||||
traceId: request.traceId
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof BusinessStatisticsError)) throw error;
|
||||
return reply.status(403).send({
|
||||
code: error.code,
|
||||
message: 'Business statistics permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
});
|
||||
for (const path of ['/app-api/management/statistics', '/admin-api/statistics']) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options);
|
||||
if (!actor) return;
|
||||
const query = querySchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
const to = query.data.to ?? new Date();
|
||||
const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000);
|
||||
const duration = to.getTime() - from.getTime();
|
||||
if (duration <= 0 || duration > 93 * 86400000) return invalid(reply, request.traceId);
|
||||
try {
|
||||
return {
|
||||
code: 0,
|
||||
data: await options.repository.overview(actor, {
|
||||
storeId: query.data.storeId,
|
||||
from,
|
||||
to
|
||||
}),
|
||||
traceId: request.traceId
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof BusinessStatisticsError)) throw error;
|
||||
return reply.status(403).send({
|
||||
code: error.code,
|
||||
message: 'Business statistics permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function requireActor(
|
||||
|
||||
@@ -111,6 +111,13 @@ assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.summary.collectedAmountCents, 21000);
|
||||
assert.equal(routed.actor.userId, '22');
|
||||
assert.equal(routed.input.storeId, '11');
|
||||
const adminResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/statistics?storeId=11&from=2026-08-01T00%3A00%3A00.000Z&to=2026-09-01T00%3A00%3A00.000Z',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(adminResponse.statusCode, 200);
|
||||
assert.equal(adminResponse.json().data.summary.orderTotal, 12);
|
||||
const invalidRange = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/statistics?storeId=11&from=2026-01-01&to=2026-09-01',
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const root = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
|
||||
const read = (path) => readFileSync(join(root, path), 'utf8');
|
||||
|
||||
const app = read('admin/src/App.vue');
|
||||
for (const pattern of [
|
||||
'OperationsOverviewPanel',
|
||||
"activeModule = 'overview'",
|
||||
"activeModule = 'cleaning'",
|
||||
'平台运营总览',
|
||||
'运营总览',
|
||||
'savedToken',
|
||||
'loadCleaningWorkspace'
|
||||
]) {
|
||||
assert.match(app, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const overview = read('admin/src/components/OperationsOverviewPanel.vue');
|
||||
for (const pattern of [
|
||||
'listManagedStores',
|
||||
'getBusinessStatistics',
|
||||
'近 7 日',
|
||||
'近 30 日',
|
||||
'近 90 日',
|
||||
'成功实收',
|
||||
'预订金额',
|
||||
'下单会员',
|
||||
'验券成功',
|
||||
'当前房态',
|
||||
'支付渠道',
|
||||
'每日收入趋势',
|
||||
'核心业务导航',
|
||||
'open-cleaning',
|
||||
'后续增量',
|
||||
'collectedAmountCents',
|
||||
'availableRoomTotal',
|
||||
'paymentChannels',
|
||||
'dailyRevenue',
|
||||
'errorMessage'
|
||||
]) {
|
||||
assert.match(overview, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const api = read('admin/src/api.ts');
|
||||
assert.match(api, /request<ManagedStore\[]>\(session, '\/stores'\)/);
|
||||
assert.match(api, /request<BusinessStatistics>\(session, `\/statistics\?\$\{params\}`\)/);
|
||||
|
||||
const routes = read('backend/src/routes/business-statistics.ts');
|
||||
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
||||
|
||||
const routeTest = read('backend/tests/business-statistics.test.mjs');
|
||||
assert.match(routeTest, /url: '\/admin-api\/statistics\?/);
|
||||
|
||||
const styles = read('admin/src/styles.css');
|
||||
for (const pattern of [
|
||||
'.overview-page',
|
||||
'.overview-toolbar',
|
||||
'.overview-metrics',
|
||||
'.overview-grid',
|
||||
'.revenue-bars',
|
||||
'.module-grid',
|
||||
'@media (max-width: 980px)',
|
||||
'@media (max-width: 560px)'
|
||||
]) {
|
||||
assert.match(styles, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
console.log('PASS: M08-D admin operations overview and scoped business statistics are present.');
|
||||
@@ -10,6 +10,7 @@ $ErrorActionPreference = "Stop"
|
||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-readme-config.ps1
|
||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-backend.ps1
|
||||
node scripts/check-admin-m08-b.mjs
|
||||
node scripts/check-admin-m08-d.mjs
|
||||
node scripts/check-miniapp-m08-c.mjs
|
||||
|
||||
if (Test-Path "admin/package.json") {
|
||||
|
||||
Reference in New Issue
Block a user