feat(M08-D): 建立后台运营总览
This commit is contained in:
+41
-11
@@ -10,7 +10,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav-list" aria-label="后台模块">
|
<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" />
|
<Sparkles :size="18" />
|
||||||
<span>保洁运营</span>
|
<span>保洁运营</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -32,8 +46,8 @@
|
|||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">M08-B</p>
|
<p class="eyebrow">{{ activeModule === 'overview' ? 'M08-D' : 'M08-B' }}</p>
|
||||||
<h2>保洁任务与结算</h2>
|
<h2>{{ activeModule === 'overview' ? '平台运营总览' : '保洁任务与结算' }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="token-box">
|
<div class="token-box">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -50,6 +64,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<OperationsOverviewPanel
|
||||||
|
v-if="activeModule === 'overview'"
|
||||||
|
:session="session"
|
||||||
|
@open-cleaning="activeModule = 'cleaning'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<section class="metric-grid" aria-label="保洁概览">
|
<section class="metric-grid" aria-label="保洁概览">
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<span>待验收</span>
|
<span>待验收</span>
|
||||||
@@ -198,17 +219,19 @@
|
|||||||
<CleaningFieldHandoffPanel />
|
<CleaningFieldHandoffPanel />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</el-config-provider>
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Download,
|
Download,
|
||||||
|
LayoutDashboard,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
RadioTower,
|
RadioTower,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@@ -223,6 +246,7 @@ import CleaningSettlementsPanel from './components/CleaningSettlementsPanel.vue'
|
|||||||
import CleaningStatisticsPanel from './components/CleaningStatisticsPanel.vue';
|
import CleaningStatisticsPanel from './components/CleaningStatisticsPanel.vue';
|
||||||
import CleanersPanel from './components/CleanersPanel.vue';
|
import CleanersPanel from './components/CleanersPanel.vue';
|
||||||
import CleaningFieldHandoffPanel from './components/CleaningFieldHandoffPanel.vue';
|
import CleaningFieldHandoffPanel from './components/CleaningFieldHandoffPanel.vue';
|
||||||
|
import OperationsOverviewPanel from './components/OperationsOverviewPanel.vue';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
assignCleaningTask,
|
assignCleaningTask,
|
||||||
@@ -257,7 +281,9 @@ import type {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { money } from './format';
|
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 activeTab = ref('tasks');
|
||||||
const lastError = ref('');
|
const lastError = ref('');
|
||||||
const lastMessage = ref('');
|
const lastMessage = ref('');
|
||||||
@@ -265,7 +291,7 @@ const taskStatus = ref<TaskStatus | ''>('SUBMITTED');
|
|||||||
const settlementStatus = ref<SettlementStatus | ''>('CONFIRMED');
|
const settlementStatus = ref<SettlementStatus | ''>('CONFIRMED');
|
||||||
const cleanerStatus = ref<UserStatus | ''>('ACTIVE');
|
const cleanerStatus = ref<UserStatus | ''>('ACTIVE');
|
||||||
const cleanerSearch = ref('');
|
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 loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
|
||||||
const tasks = reactive<PageResult<CleaningTask>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
const tasks = reactive<PageResult<CleaningTask>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
const settlements = reactive<PageResult<CleaningSettlement>>({
|
const settlements = reactive<PageResult<CleaningSettlement>>({
|
||||||
@@ -310,8 +336,14 @@ const operationalQueueTotal = computed(() => (
|
|||||||
));
|
));
|
||||||
|
|
||||||
function saveToken() {
|
function saveToken() {
|
||||||
localStorage.setItem('qipai.admin.token', tokenDraft.value.trim());
|
savedToken.value = tokenDraft.value.trim();
|
||||||
|
localStorage.setItem('qipai.admin.token', savedToken.value);
|
||||||
ElMessage.success('已保存');
|
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) {
|
async function runAction(work: () => Promise<void>, success: string) {
|
||||||
@@ -659,9 +691,7 @@ async function handleResetCleanerSessions(userId: string) {
|
|||||||
}, '已重置保洁员会话');
|
}, '已重置保洁员会话');
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
watch(activeModule, (module) => {
|
||||||
if (session.value.token) {
|
if (module === 'cleaning' && session.value.token) void loadCleaningWorkspace();
|
||||||
void Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
CleaningTask,
|
CleaningTask,
|
||||||
CleaningTaskEvent,
|
CleaningTaskEvent,
|
||||||
CleaningTaskMember,
|
CleaningTaskMember,
|
||||||
|
BusinessStatistics,
|
||||||
|
ManagedStore,
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
PageResult,
|
PageResult,
|
||||||
PayoutStateFilter,
|
PayoutStateFilter,
|
||||||
@@ -32,6 +34,18 @@ export interface ApiSession {
|
|||||||
token: string;
|
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>(
|
async function request<T>(
|
||||||
session: ApiSession,
|
session: ApiSession,
|
||||||
path: string,
|
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;
|
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 {
|
.panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid #d8e0ea;
|
border: 1px solid #d8e0ea;
|
||||||
@@ -960,6 +1215,20 @@ textarea {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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 {
|
.handoff-owner-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -1007,6 +1276,31 @@ textarea {
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.panel-toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -22,6 +22,39 @@ export interface PageResult<T> {
|
|||||||
pageSize: number;
|
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 {
|
export interface ManagedUser {
|
||||||
id: string;
|
id: string;
|
||||||
userType: string;
|
userType: string;
|
||||||
|
|||||||
@@ -26,34 +26,36 @@ export async function registerBusinessStatisticsRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
options: BusinessStatisticsRouteOptions
|
options: BusinessStatisticsRouteOptions
|
||||||
) {
|
) {
|
||||||
app.get('/app-api/management/statistics', async (request, reply) => {
|
for (const path of ['/app-api/management/statistics', '/admin-api/statistics']) {
|
||||||
const actor = await requireActor(request, reply, options);
|
app.get(path, async (request, reply) => {
|
||||||
if (!actor) return;
|
const actor = await requireActor(request, reply, options);
|
||||||
const query = querySchema.safeParse(request.query);
|
if (!actor) return;
|
||||||
if (!query.success) return invalid(reply, request.traceId);
|
const query = querySchema.safeParse(request.query);
|
||||||
const to = query.data.to ?? new Date();
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000);
|
const to = query.data.to ?? new Date();
|
||||||
const duration = to.getTime() - from.getTime();
|
const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000);
|
||||||
if (duration <= 0 || duration > 93 * 86400000) return invalid(reply, request.traceId);
|
const duration = to.getTime() - from.getTime();
|
||||||
try {
|
if (duration <= 0 || duration > 93 * 86400000) return invalid(reply, request.traceId);
|
||||||
return {
|
try {
|
||||||
code: 0,
|
return {
|
||||||
data: await options.repository.overview(actor, {
|
code: 0,
|
||||||
storeId: query.data.storeId,
|
data: await options.repository.overview(actor, {
|
||||||
from,
|
storeId: query.data.storeId,
|
||||||
to
|
from,
|
||||||
}),
|
to
|
||||||
traceId: request.traceId
|
}),
|
||||||
};
|
traceId: request.traceId
|
||||||
} catch (error) {
|
};
|
||||||
if (!(error instanceof BusinessStatisticsError)) throw error;
|
} catch (error) {
|
||||||
return reply.status(403).send({
|
if (!(error instanceof BusinessStatisticsError)) throw error;
|
||||||
code: error.code,
|
return reply.status(403).send({
|
||||||
message: 'Business statistics permission is required.',
|
code: error.code,
|
||||||
traceId: request.traceId
|
message: 'Business statistics permission is required.',
|
||||||
});
|
traceId: request.traceId
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireActor(
|
async function requireActor(
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ assert.equal(response.statusCode, 200);
|
|||||||
assert.equal(response.json().data.summary.collectedAmountCents, 21000);
|
assert.equal(response.json().data.summary.collectedAmountCents, 21000);
|
||||||
assert.equal(routed.actor.userId, '22');
|
assert.equal(routed.actor.userId, '22');
|
||||||
assert.equal(routed.input.storeId, '11');
|
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({
|
const invalidRange = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/app-api/management/statistics?storeId=11&from=2026-01-01&to=2026-09-01',
|
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-readme-config.ps1
|
||||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-backend.ps1
|
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-backend.ps1
|
||||||
node scripts/check-admin-m08-b.mjs
|
node scripts/check-admin-m08-b.mjs
|
||||||
|
node scripts/check-admin-m08-d.mjs
|
||||||
node scripts/check-miniapp-m08-c.mjs
|
node scripts/check-miniapp-m08-c.mjs
|
||||||
|
|
||||||
if (Test-Path "admin/package.json") {
|
if (Test-Path "admin/package.json") {
|
||||||
|
|||||||
Reference in New Issue
Block a user