feat(M08-D): 建立后台运营总览

This commit is contained in:
Codex
2026-08-10 11:22:13 +08:00
parent b08a2cca43
commit 65f2e7c916
9 changed files with 775 additions and 39 deletions
+41 -11
View File
@@ -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>
+14
View File
@@ -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>
+294
View File
@@ -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;
+33
View File
@@ -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;
+3 -1
View File
@@ -26,7 +26,8 @@ export async function registerBusinessStatisticsRoutes(
app: FastifyInstance,
options: BusinessStatisticsRouteOptions
) {
app.get('/app-api/management/statistics', async (request, reply) => {
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);
@@ -55,6 +56,7 @@ export async function registerBusinessStatisticsRoutes(
}
});
}
}
async function requireActor(
request: FastifyRequest,
@@ -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',
+71
View File
@@ -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.');
+1
View File
@@ -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") {