feat(M08-D): 补门店与房间管理

This commit is contained in:
Codex
2026-08-10 11:40:09 +08:00
parent 04afce4417
commit 1ca783e91b
9 changed files with 1017 additions and 14 deletions
+21 -5
View File
@@ -28,9 +28,14 @@
<Sparkles :size="18" />
<span>保洁运营</span>
</button>
<button class="nav-item" type="button" disabled>
<button
class="nav-item"
:class="{ active: activeModule === 'stores' }"
type="button"
@click="activeModule = 'stores'"
>
<Store :size="18" />
<span>门店</span>
<span>门店房间</span>
</button>
<button class="nav-item" type="button" disabled>
<RadioTower :size="18" />
@@ -46,8 +51,8 @@
<section class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">{{ activeModule === 'overview' ? 'M08-D' : 'M08-B' }}</p>
<h2>{{ activeModule === 'overview' ? '平台运营总览' : '保洁任务与结算' }}</h2>
<p class="eyebrow">{{ activeModuleMeta.stage }}</p>
<h2>{{ activeModuleMeta.title }}</h2>
</div>
<div class="token-box">
<el-input
@@ -70,6 +75,11 @@
@open-cleaning="activeModule = 'cleaning'"
/>
<StoresRoomsPanel
v-else-if="activeModule === 'stores'"
:session="session"
/>
<template v-else>
<section class="metric-grid" aria-label="保洁概览">
<div class="metric">
@@ -247,6 +257,7 @@ 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 StoresRoomsPanel from './components/StoresRoomsPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -283,7 +294,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value);
const activeModule = ref<'overview' | 'cleaning'>('overview');
const activeModule = ref<'overview' | 'stores' | 'cleaning'>('overview');
const activeTab = ref('tasks');
const lastError = ref('');
const lastMessage = ref('');
@@ -292,6 +303,11 @@ const settlementStatus = ref<SettlementStatus | ''>('CONFIRMED');
const cleanerStatus = ref<UserStatus | ''>('ACTIVE');
const cleanerSearch = ref('');
const session = computed(() => ({ token: savedToken.value }));
const activeModuleMeta = computed(() => ({
overview: { stage: 'M08-D', title: '平台运营总览' },
stores: { stage: 'M08-D', title: '门店与房间管理' },
cleaning: { stage: 'M08-B', title: '保洁任务与结算' }
})[activeModule.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>>({
+70
View File
@@ -6,6 +6,7 @@ import type {
CleaningTaskEvent,
CleaningTaskMember,
BusinessStatistics,
ManagedRoom,
ManagedStore,
ManagedUser,
PageResult,
@@ -14,6 +15,10 @@ import type {
SettlementStatus,
TaskStatus,
TransferMode,
RoomConfigurationStatus,
RoomInput,
RoomOperationalStatus,
StoreInput,
UserStatus,
WechatTransferPreflight
} from './types';
@@ -38,6 +43,71 @@ export function listManagedStores(session: ApiSession) {
return request<ManagedStore[]>(session, '/stores');
}
export function createManagedStore(session: ApiSession, input: StoreInput) {
return request<{ storeId: string }>(session, '/stores', {
method: 'POST', body: JSON.stringify(input)
});
}
export function updateManagedStore(session: ApiSession, storeId: string, input: StoreInput) {
return request<{ storeId: string }>(session, `/stores/${storeId}`, {
method: 'PUT', body: JSON.stringify(input)
});
}
export function archiveManagedStore(session: ApiSession, storeId: string) {
return request<{ storeId: string; archived: boolean }>(session, `/stores/${storeId}`, {
method: 'DELETE'
});
}
export function listManagedRooms(session: ApiSession, storeId: string) {
return request<ManagedRoom[]>(session, `/stores/${storeId}/rooms`);
}
export function createManagedRoom(session: ApiSession, input: RoomInput) {
return request<{ roomId: string }>(session, '/rooms', {
method: 'POST', body: JSON.stringify(input)
});
}
export function updateManagedRoom(session: ApiSession, roomId: string, input: RoomInput) {
return request<{ roomId: string }>(session, `/rooms/${roomId}`, {
method: 'PUT', body: JSON.stringify(input)
});
}
export function updateManagedRoomStatus(
session: ApiSession,
roomId: string,
input: {
storeId: string;
configurationStatus?: RoomConfigurationStatus;
operationalStatus?: RoomOperationalStatus;
reason: string;
}
) {
return request<{ roomId: string }>(session, `/rooms/${roomId}/status`, {
method: 'PATCH', body: JSON.stringify(input)
});
}
export function archiveManagedRoom(session: ApiSession, roomId: string, storeId: string) {
return request<{ roomId: string; archived: boolean }>(
session, `/rooms/${roomId}?${new URLSearchParams({ storeId })}`, { method: 'DELETE' }
);
}
export function addManagedRoomDisabledPeriod(
session: ApiSession,
roomId: string,
input: { storeId: string; startsAt: string; endsAt: string; reason: string }
) {
return request<{ disabledPeriodId: string }>(session, `/rooms/${roomId}/disabled-periods`, {
method: 'POST', body: JSON.stringify(input)
});
}
export function getBusinessStatistics(
session: ApiSession,
input: { storeId: string; from: string; to: string }
+545
View File
@@ -0,0 +1,545 @@
<template>
<section class="stores-page">
<el-alert
v-if="lastError"
class="alert-line"
:title="lastError"
type="error"
show-icon
closable
@close="lastError = ''"
/>
<section class="panel">
<header class="panel-toolbar store-toolbar">
<div>
<p class="section-kicker">M08-D · 门店资产</p>
<h3>门店与房间管理</h3>
</div>
<div class="toolbar-actions">
<el-input v-model="search" class="search-input" clearable placeholder="搜索门店、城市或地址" />
<el-select v-model="statusFilter" class="filter-select" placeholder="营业状态">
<el-option label="全部状态" value="" />
<el-option label="营业中" value="OPEN" />
<el-option label="已闭店" value="CLOSED" />
<el-option label="暂停营业" value="SUSPENDED" />
</el-select>
<el-button :icon="RefreshCw" :loading="loadingStores" @click="loadStores">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openStoreForm()">新增门店</el-button>
</div>
</header>
<div class="store-layout">
<aside class="store-list" aria-label="门店列表">
<button
v-for="store in filteredStores"
:key="store.id"
class="store-card"
:class="{ active: selectedStoreId === store.id }"
type="button"
@click="selectStore(store.id)"
>
<span class="store-card-title">
<strong>{{ store.name }}</strong>
<el-tag :type="storeStatusType(store.businessStatus)" size="small">
{{ storeStatusLabel(store.businessStatus) }}
</el-tag>
</span>
<span>{{ [store.city, store.district].filter(Boolean).join(' · ') || '未填写地区' }}</span>
<small>{{ store.address || '未填写地址' }}</small>
</button>
<el-empty v-if="!loadingStores && filteredStores.length === 0" description="没有匹配门店" />
</aside>
<div v-if="selectedStore" class="store-detail">
<header class="store-detail-header">
<div>
<p class="section-kicker">门店 #{{ selectedStore.id }}</p>
<h3>{{ selectedStore.name }}</h3>
<p>{{ selectedStore.address || '未填写地址' }}</p>
</div>
<div class="row-actions">
<el-button :icon="Edit3" @click="openStoreForm(selectedStore)">编辑门店</el-button>
<el-button type="danger" plain :icon="Archive" @click="archiveStore(selectedStore)">
归档
</el-button>
</div>
</header>
<div class="detail-grid store-summary-grid">
<span><small>联系电话</small><strong>{{ selectedStore.contactPhone || '-' }}</strong></span>
<span><small>时区</small><strong>{{ selectedStore.timezone }}</strong></span>
<span><small>访客 Wi-Fi</small><strong>{{ wifiSummary(selectedStore) }}</strong></span>
<span><small>通知地址</small><strong>{{ selectedStore.notificationUrl || '-' }}</strong></span>
</div>
<section class="business-hours-board">
<strong>营业时段</strong>
<div class="hours-chip-list">
<span v-for="hour in normalizedHours(selectedStore.businessHours)" :key="hour.weekday">
{{ weekdayLabel(hour.weekday) }}
<b>{{ hour.isClosed ? '休息' : `${minuteLabel(hour.openMinute)}${minuteLabel(hour.closeMinute)}` }}</b>
</span>
</div>
</section>
<header class="rooms-toolbar">
<div>
<p class="section-kicker">房间配置</p>
<h3>{{ rooms.length }} 个房间</h3>
</div>
<div class="toolbar-actions">
<el-input v-model="roomSearch" class="search-input" clearable placeholder="搜索房号、名称、分类" />
<el-select v-model="roomStatusFilter" class="filter-select" placeholder="房态">
<el-option label="全部房态" value="" />
<el-option v-for="item in operationalStatuses" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button :icon="RefreshCw" :loading="loadingRooms" @click="loadRooms">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openRoomForm()">新增房间</el-button>
</div>
</header>
<el-table v-loading="loadingRooms" :data="filteredRooms" class="data-table" row-key="id">
<el-table-column label="房间" min-width="180">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.roomNo }} · {{ row.name }}</strong>
<span>{{ row.categoryName }} · {{ row.capacity }} </span>
</div>
</template>
</el-table-column>
<el-table-column label="价格" min-width="150">
<template #default="{ row }">
<div class="stack">
<strong>{{ money(row.basePriceCents) }}/时段</strong>
<span>节假日 {{ money(row.holidayPriceCents) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" min-width="150">
<template #default="{ row }">
<div class="profile-badges">
<el-tag :type="row.configurationStatus === 'ENABLED' ? 'success' : 'info'" size="small">
{{ row.configurationStatus === 'ENABLED' ? '已启用' : '已停用' }}
</el-tag>
<el-tag :type="roomStatusType(row.operationalStatus)" size="small">
{{ roomStatusLabel(row.operationalStatus) }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="标签" min-width="150">
<template #default="{ row }">
<div class="profile-badges">
<el-tag v-for="tag in row.tags.slice(0, 3)" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
<span v-if="row.tags.length === 0">-</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="285" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openRoomForm(row)">编辑</el-button>
<el-button link type="primary" @click="openStatusForm(row)">改状态</el-button>
<el-button link @click="openDisabledPeriodForm(row)">停用时段</el-button>
<el-button link type="danger" @click="archiveRoom(row)">归档</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else class="store-detail-empty" description="请选择门店查看详情与房间" />
</div>
</section>
<el-dialog v-model="storeDialog.open" :title="storeDialog.id ? '编辑门店' : '新增门店'" width="min(860px, 94vw)">
<el-form label-position="top" class="asset-form">
<el-form-item label="门店名称"><el-input v-model="storeForm.name" maxlength="128" /></el-form-item>
<el-form-item label="营业状态">
<el-select v-model="storeForm.businessStatus">
<el-option label="营业中" value="OPEN" />
<el-option label="已闭店" value="CLOSED" />
<el-option label="暂停营业" value="SUSPENDED" />
</el-select>
</el-form-item>
<el-form-item label="城市"><el-input v-model="storeForm.city" /></el-form-item>
<el-form-item label="区县"><el-input v-model="storeForm.district" /></el-form-item>
<el-form-item label="详细地址" class="form-span-2"><el-input v-model="storeForm.address" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="storeForm.contactPhone" /></el-form-item>
<el-form-item label="时区"><el-input v-model="storeForm.timezone" /></el-form-item>
<el-form-item label="经度"><el-input-number v-model="storeForm.longitude" :min="-180" :max="180" :precision="6" controls-position="right" /></el-form-item>
<el-form-item label="纬度"><el-input-number v-model="storeForm.latitude" :min="-90" :max="90" :precision="6" controls-position="right" /></el-form-item>
<el-form-item label="Wi-Fi 名称"><el-input v-model="storeForm.wifiSsid" /></el-form-item>
<el-form-item :label="storeDialog.id ? 'Wi-Fi 新密码(留空保持原值)' : 'Wi-Fi 密码'">
<el-input v-model="storeForm.wifiPassword" type="password" show-password autocomplete="new-password" />
</el-form-item>
<el-form-item label="通知地址" class="form-span-2"><el-input v-model="storeForm.notificationUrl" placeholder="https://..." /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="storeForm.sortOrder" :min="-100000" :max="100000" /></el-form-item>
</el-form>
<section class="hours-editor">
<strong>每周营业时间</strong>
<div v-for="hour in storeForm.businessHours" :key="hour.weekday" class="hours-editor-row">
<span>{{ weekdayLabel(hour.weekday) }}</span>
<el-switch v-model="hour.isClosed" active-text="休息" inactive-text="营业" />
<el-time-select v-model="hour.openTime" :disabled="hour.isClosed" start="00:00" step="00:15" end="23:45" placeholder="开门" />
<el-time-select v-model="hour.closeTime" :disabled="hour.isClosed" start="00:00" step="00:15" end="23:45" placeholder="闭店" />
</div>
</section>
<template #footer>
<el-button @click="storeDialog.open = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveStore">保存门店</el-button>
</template>
</el-dialog>
<el-dialog v-model="roomDialog.open" :title="roomDialog.id ? '编辑房间' : '新增房间'" width="min(900px, 94vw)">
<el-form label-position="top" class="asset-form room-form">
<el-form-item label="房号"><el-input v-model="roomForm.roomNo" /></el-form-item>
<el-form-item label="房间名称"><el-input v-model="roomForm.name" /></el-form-item>
<el-form-item label="分类"><el-input v-model="roomForm.categoryName" /></el-form-item>
<el-form-item label="容纳人数"><el-input-number v-model="roomForm.capacity" :min="1" :max="100" /></el-form-item>
<el-form-item label="基础价(元)"><el-input-number v-model="roomForm.basePriceYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="工作日价(元)"><el-input-number v-model="roomForm.weekdayPriceYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="节假日价(元)"><el-input-number v-model="roomForm.holidayPriceYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="过夜价(元)"><el-input-number v-model="roomForm.overnightPriceYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="全天价(元)"><el-input-number v-model="roomForm.fullDayPriceYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="最低消费(元)"><el-input-number v-model="roomForm.minimumSpendYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="押金(元)"><el-input-number v-model="roomForm.depositYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="最短预订(分钟)"><el-input-number v-model="roomForm.minimumMinutes" :min="15" :max="1440" :step="15" /></el-form-item>
<el-form-item label="最早提前入场(分钟)"><el-input-number v-model="roomForm.maxAdvanceStartMinutes" :min="0" :max="1440" /></el-form-item>
<el-form-item label="最多提前预订(天)"><el-input-number v-model="roomForm.maxAdvanceDays" :min="0" :max="365" /></el-form-item>
<el-form-item label="配置状态">
<el-select v-model="roomForm.configurationStatus"><el-option label="启用" value="ENABLED" /><el-option label="停用" value="DISABLED" /></el-select>
</el-form-item>
<el-form-item label="运营房态">
<el-select v-model="roomForm.operationalStatus"><el-option v-for="item in operationalStatuses" :key="item.value" :label="item.label" :value="item.value" /></el-select>
</el-form-item>
<el-form-item label="标签" class="form-span-2">
<el-select v-model="roomForm.tags" multiple filterable allow-create default-first-option placeholder="输入后回车添加标签" />
</el-form-item>
<el-form-item label="图片 URL(每行一个)" class="form-span-2"><el-input v-model="roomForm.imagesText" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="roomForm.sortOrder" :min="-100000" :max="100000" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="roomDialog.open = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveRoom">保存房间</el-button>
</template>
</el-dialog>
<el-dialog v-model="statusDialog.open" title="变更房间状态" width="min(520px, 94vw)">
<el-form label-position="top">
<el-form-item label="配置状态"><el-select v-model="statusDialog.configurationStatus" clearable><el-option label="启用" value="ENABLED" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item>
<el-form-item label="运营房态"><el-select v-model="statusDialog.operationalStatus" clearable><el-option v-for="item in operationalStatuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="变更原因"><el-input v-model="statusDialog.reason" type="textarea" maxlength="255" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="statusDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRoomStatus">确认变更</el-button></template>
</el-dialog>
<el-dialog v-model="periodDialog.open" title="新增房间停用时段" width="min(560px, 94vw)">
<el-form label-position="top">
<el-form-item label="开始时间"><el-date-picker v-model="periodDialog.startsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="结束时间"><el-date-picker v-model="periodDialog.endsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="原因"><el-input v-model="periodDialog.reason" maxlength="255" /></el-form-item>
</el-form>
<template #footer><el-button @click="periodDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveDisabledPeriod">保存时段</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Archive, Edit3, Plus, RefreshCw } from '@lucide/vue';
import {
ApiError,
addManagedRoomDisabledPeriod,
archiveManagedRoom,
archiveManagedStore,
createManagedRoom,
createManagedStore,
listManagedRooms,
listManagedStores,
updateManagedRoom,
updateManagedRoomStatus,
updateManagedStore,
type ApiSession
} from '../api';
import { money } from '../format';
import type {
ManagedRoom,
ManagedStore,
RoomConfigurationStatus,
RoomInput,
RoomOperationalStatus,
StoreInput
} from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]);
const rooms = ref<ManagedRoom[]>([]);
const selectedStoreId = ref('');
const search = ref('');
const statusFilter = ref<ManagedStore['businessStatus'] | ''>('');
const roomSearch = ref('');
const roomStatusFilter = ref<RoomOperationalStatus | ''>('');
const loadingStores = ref(false);
const loadingRooms = ref(false);
const saving = ref(false);
const lastError = ref('');
const storeDialog = reactive({ open: false, id: '' });
const roomDialog = reactive({ open: false, id: '' });
const statusDialog = reactive<{
open: boolean; roomId: string; configurationStatus: RoomConfigurationStatus | '';
operationalStatus: RoomOperationalStatus | ''; reason: string;
}>({ open: false, roomId: '', configurationStatus: '', operationalStatus: '', reason: '' });
const periodDialog = reactive({ open: false, roomId: '', startsAt: '', endsAt: '', reason: '' });
const operationalStatuses: Array<{ value: RoomOperationalStatus; label: string }> = [
{ value: 'AVAILABLE', label: '空闲' },
{ value: 'MAINTENANCE', label: '维护中' },
{ value: 'RESERVED', label: '已预订' },
{ value: 'IN_USE', label: '使用中' },
{ value: 'CLEANING_REQUIRED', label: '待清洁' }
];
type StoreHourForm = { weekday: number; openTime: string; closeTime: string; isClosed: boolean };
const storeForm = reactive({
name: '', address: '', city: '', district: '', longitude: null as number | null,
latitude: null as number | null, contactPhone: '', timezone: 'Asia/Shanghai',
businessStatus: 'OPEN' as ManagedStore['businessStatus'], wifiSsid: '', wifiPassword: '',
notificationUrl: '', sortOrder: 0, businessHours: defaultHourForms() as StoreHourForm[]
});
const roomForm = reactive({
categoryName: '标准间', name: '', roomNo: '', capacity: 4,
basePriceYuan: 0, weekdayPriceYuan: 0, holidayPriceYuan: 0, overnightPriceYuan: 0,
fullDayPriceYuan: 0, minimumSpendYuan: 0, depositYuan: 0, minimumMinutes: 60,
maxAdvanceStartMinutes: 0, maxAdvanceDays: 30,
configurationStatus: 'ENABLED' as RoomConfigurationStatus,
operationalStatus: 'AVAILABLE' as RoomOperationalStatus,
tags: [] as string[], imagesText: '', sortOrder: 0
});
const filteredStores = computed(() => {
const keyword = search.value.trim().toLowerCase();
return stores.value.filter((store) => {
const matchesStatus = !statusFilter.value || store.businessStatus === statusFilter.value;
const haystack = `${store.name} ${store.city} ${store.district} ${store.address}`.toLowerCase();
return matchesStatus && (!keyword || haystack.includes(keyword));
});
});
const selectedStore = computed(() => stores.value.find((store) => store.id === selectedStoreId.value));
const filteredRooms = computed(() => {
const keyword = roomSearch.value.trim().toLowerCase();
return rooms.value.filter((room) => {
const matchesStatus = !roomStatusFilter.value || room.operationalStatus === roomStatusFilter.value;
const haystack = `${room.roomNo} ${room.name} ${room.categoryName} ${room.tags.join(' ')}`.toLowerCase();
return matchesStatus && (!keyword || haystack.includes(keyword));
});
});
async function runAction<T>(work: () => Promise<T>, success?: string) {
lastError.value = '';
try {
const result = await work();
if (success) ElMessage.success(success);
return result;
} catch (error) {
lastError.value = error instanceof ApiError
? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}`
: error instanceof Error ? error.message : '操作失败';
throw error;
}
}
async function loadStores() {
if (!props.session.token) return;
loadingStores.value = true;
try {
const result = await runAction(() => listManagedStores(props.session));
stores.value = result;
if (!result.some((store) => store.id === selectedStoreId.value)) {
selectedStoreId.value = result[0]?.id || '';
}
if (selectedStoreId.value) await loadRooms();
} catch { /* error already displayed */ } finally { loadingStores.value = false; }
}
async function loadRooms() {
if (!selectedStoreId.value || !props.session.token) { rooms.value = []; return; }
loadingRooms.value = true;
try { rooms.value = await runAction(() => listManagedRooms(props.session, selectedStoreId.value)); }
catch { rooms.value = []; }
finally { loadingRooms.value = false; }
}
function selectStore(storeId: string) {
if (selectedStoreId.value === storeId) return;
selectedStoreId.value = storeId;
roomSearch.value = '';
roomStatusFilter.value = '';
void loadRooms();
}
function openStoreForm(store?: ManagedStore) {
storeDialog.id = store?.id || '';
Object.assign(storeForm, {
name: store?.name || '', address: store?.address || '', city: store?.city || '',
district: store?.district || '', longitude: store?.longitude ?? null,
latitude: store?.latitude ?? null, contactPhone: store?.contactPhone || '',
timezone: store?.timezone || 'Asia/Shanghai', businessStatus: store?.businessStatus || 'OPEN',
wifiSsid: store?.wifiSsid || '', wifiPassword: '', notificationUrl: store?.notificationUrl || '',
sortOrder: store?.sortOrder || 0,
businessHours: normalizedHours(store?.businessHours || []).map((hour) => ({
weekday: hour.weekday, openTime: minuteLabel(hour.openMinute),
closeTime: minuteLabel(hour.closeMinute), isClosed: hour.isClosed
}))
});
storeDialog.open = true;
}
async function saveStore() {
if (!storeForm.name.trim()) return ElMessage.warning('请填写门店名称');
saving.value = true;
const input: StoreInput = {
name: storeForm.name.trim(), address: storeForm.address.trim(), city: storeForm.city.trim(),
district: storeForm.district.trim(), longitude: storeForm.longitude,
latitude: storeForm.latitude, contactPhone: storeForm.contactPhone.trim(),
timezone: storeForm.timezone.trim() || 'Asia/Shanghai', businessStatus: storeForm.businessStatus,
wifiSsid: storeForm.wifiSsid.trim(), notificationUrl: storeForm.notificationUrl.trim(),
sortOrder: storeForm.sortOrder,
businessHours: storeForm.businessHours.map((hour) => ({
weekday: hour.weekday, openMinute: timeToMinute(hour.openTime),
closeMinute: timeToMinute(hour.closeTime), isClosed: hour.isClosed
}))
};
if (storeForm.wifiPassword) input.wifiPassword = storeForm.wifiPassword;
try {
const result = storeDialog.id
? await runAction(() => updateManagedStore(props.session, storeDialog.id, input), '门店已更新')
: await runAction(() => createManagedStore(props.session, input), '门店已创建');
storeDialog.open = false;
selectedStoreId.value = result.storeId;
await loadStores();
} catch { /* error already displayed */ } finally { saving.value = false; }
}
async function archiveStore(store: ManagedStore) {
try {
await ElMessageBox.confirm('仅无未归档房间的门店可归档,操作会写入审计日志。', `归档 ${store.name}`, { type: 'warning' });
await runAction(() => archiveManagedStore(props.session, store.id), '门店已归档');
await loadStores();
} catch (error) { if (error !== 'cancel' && error !== 'close' && error instanceof ApiError) lastError.value = error.code; }
}
function openRoomForm(room?: ManagedRoom) {
roomDialog.id = room?.id || '';
Object.assign(roomForm, {
categoryName: room?.categoryName || '标准间', name: room?.name || '', roomNo: room?.roomNo || '',
capacity: room?.capacity || 4, basePriceYuan: centsToYuan(room?.basePriceCents),
weekdayPriceYuan: centsToYuan(room?.weekdayPriceCents), holidayPriceYuan: centsToYuan(room?.holidayPriceCents),
overnightPriceYuan: centsToYuan(room?.overnightPriceCents), fullDayPriceYuan: centsToYuan(room?.fullDayPriceCents),
minimumSpendYuan: centsToYuan(room?.minimumSpendCents), depositYuan: centsToYuan(room?.depositCents),
minimumMinutes: room?.minimumMinutes || 60, maxAdvanceStartMinutes: room?.maxAdvanceStartMinutes || 0,
maxAdvanceDays: room?.maxAdvanceDays ?? 30, configurationStatus: room?.configurationStatus || 'ENABLED',
operationalStatus: room?.operationalStatus || 'AVAILABLE', tags: [...(room?.tags || [])],
imagesText: (room?.images || []).join('\n'), sortOrder: room?.sortOrder || 0
});
roomDialog.open = true;
}
async function saveRoom() {
if (!selectedStoreId.value || !roomForm.name.trim() || !roomForm.roomNo.trim()) {
return ElMessage.warning('请填写房号和房间名称');
}
const input: RoomInput = {
storeId: selectedStoreId.value, categoryName: roomForm.categoryName.trim(),
name: roomForm.name.trim(), roomNo: roomForm.roomNo.trim(), capacity: roomForm.capacity,
basePriceCents: yuanToCents(roomForm.basePriceYuan), weekdayPriceCents: yuanToCents(roomForm.weekdayPriceYuan),
holidayPriceCents: yuanToCents(roomForm.holidayPriceYuan), overnightPriceCents: yuanToCents(roomForm.overnightPriceYuan),
fullDayPriceCents: yuanToCents(roomForm.fullDayPriceYuan), minimumSpendCents: yuanToCents(roomForm.minimumSpendYuan),
depositCents: yuanToCents(roomForm.depositYuan), minimumMinutes: roomForm.minimumMinutes,
maxAdvanceStartMinutes: roomForm.maxAdvanceStartMinutes, maxAdvanceDays: roomForm.maxAdvanceDays,
configurationStatus: roomForm.configurationStatus, operationalStatus: roomForm.operationalStatus,
tags: roomForm.tags.map((item) => item.trim()).filter(Boolean),
images: roomForm.imagesText.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
sortOrder: roomForm.sortOrder
};
saving.value = true;
try {
if (roomDialog.id) await runAction(() => updateManagedRoom(props.session, roomDialog.id, input), '房间已更新');
else await runAction(() => createManagedRoom(props.session, input), '房间已创建');
roomDialog.open = false;
await loadRooms();
} catch { /* error already displayed */ } finally { saving.value = false; }
}
function openStatusForm(room: ManagedRoom) {
Object.assign(statusDialog, {
open: true, roomId: room.id, configurationStatus: room.configurationStatus,
operationalStatus: room.operationalStatus, reason: ''
});
}
async function saveRoomStatus() {
if (!statusDialog.reason.trim()) return ElMessage.warning('请填写状态变更原因');
saving.value = true;
try {
await runAction(() => updateManagedRoomStatus(props.session, statusDialog.roomId, {
storeId: selectedStoreId.value,
configurationStatus: statusDialog.configurationStatus || undefined,
operationalStatus: statusDialog.operationalStatus || undefined,
reason: statusDialog.reason.trim()
}), '房间状态已更新');
statusDialog.open = false;
await loadRooms();
} catch { /* error already displayed */ } finally { saving.value = false; }
}
function openDisabledPeriodForm(room: ManagedRoom) {
Object.assign(periodDialog, { open: true, roomId: room.id, startsAt: '', endsAt: '', reason: '' });
}
async function saveDisabledPeriod() {
if (!periodDialog.startsAt || !periodDialog.endsAt) return ElMessage.warning('请选择完整停用时段');
saving.value = true;
try {
await runAction(() => addManagedRoomDisabledPeriod(props.session, periodDialog.roomId, {
storeId: selectedStoreId.value, startsAt: periodDialog.startsAt,
endsAt: periodDialog.endsAt, reason: periodDialog.reason.trim()
}), '停用时段已添加');
periodDialog.open = false;
} catch { /* error already displayed */ } finally { saving.value = false; }
}
async function archiveRoom(room: ManagedRoom) {
try {
await ElMessageBox.confirm('归档后该房间不再出现在可用房间列表。', `归档 ${room.roomNo}`, { type: 'warning' });
await runAction(() => archiveManagedRoom(props.session, room.id, selectedStoreId.value), '房间已归档');
await loadRooms();
} catch (error) { if (error !== 'cancel' && error !== 'close' && error instanceof ApiError) lastError.value = error.code; }
}
function defaultHourForms(): StoreHourForm[] {
return Array.from({ length: 7 }, (_, index) => ({
weekday: index + 1, openTime: '09:00', closeTime: '22:00', isClosed: false
}));
}
function normalizedHours(hours: ManagedStore['businessHours']) {
const byDay = new Map(hours.map((hour) => [hour.weekday, hour]));
return Array.from({ length: 7 }, (_, index) => byDay.get(index + 1) || {
weekday: index + 1, openMinute: 540, closeMinute: 1320, isClosed: false
});
}
function weekdayLabel(weekday: number) { return ['一', '二', '三', '四', '五', '六', '日'][weekday - 1] ? `${['一', '二', '三', '四', '五', '六', '日'][weekday - 1]}` : `${weekday}`; }
function minuteLabel(value: number) { return `${String(Math.floor(value / 60)).padStart(2, '0')}:${String(value % 60).padStart(2, '0')}`; }
function timeToMinute(value: string) { const [hour, minute] = value.split(':').map(Number); return (hour || 0) * 60 + (minute || 0); }
function centsToYuan(value?: number) { return Number(((value || 0) / 100).toFixed(2)); }
function yuanToCents(value: number) { return Math.round(Number(value || 0) * 100); }
function storeStatusLabel(status: ManagedStore['businessStatus']) { return ({ OPEN: '营业中', CLOSED: '已闭店', SUSPENDED: '暂停营业' } as const)[status]; }
function storeStatusType(status: ManagedStore['businessStatus']) { return status === 'OPEN' ? 'success' : status === 'SUSPENDED' ? 'warning' : 'info'; }
function roomStatusLabel(status: RoomOperationalStatus) { return operationalStatuses.find((item) => item.value === status)?.label || status; }
function roomStatusType(status: RoomOperationalStatus) { return status === 'AVAILABLE' ? 'success' : status === 'MAINTENANCE' || status === 'CLEANING_REQUIRED' ? 'warning' : 'primary'; }
function wifiSummary(store: ManagedStore) { return store.wifiSsid ? `${store.wifiSsid}${store.wifiConfigured ? ' · 已设密码' : ''}` : '未配置'; }
watch(() => props.session.token, (token) => { if (token) void loadStores(); }, { immediate: true });
</script>
+218
View File
@@ -1157,6 +1157,174 @@ textarea {
white-space: nowrap;
}
.stores-page {
display: grid;
gap: 14px;
}
.store-toolbar h3,
.store-detail-header h3,
.rooms-toolbar h3 {
margin: 0;
}
.store-layout {
display: grid;
grid-template-columns: minmax(230px, 300px) minmax(0, 1fr);
min-height: 640px;
}
.store-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-right: 1px solid #e6ebf2;
background: #f8fafc;
}
.store-card {
display: grid;
gap: 5px;
width: 100%;
padding: 12px;
color: #526177;
text-align: left;
background: #fff;
border: 1px solid #dfe6ee;
border-radius: 9px;
cursor: pointer;
}
.store-card:hover,
.store-card.active {
border-color: #2878ff;
box-shadow: 0 4px 16px rgba(40, 120, 255, 0.1);
}
.store-card.active {
background: #f1f6ff;
}
.store-card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #18212f;
}
.store-card small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-detail {
min-width: 0;
padding: 18px;
}
.store-detail-empty {
min-height: 420px;
}
.store-detail-header,
.rooms-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.store-detail-header p:not(.section-kicker) {
margin: 5px 0 0;
color: #69788c;
}
.store-summary-grid {
margin-top: 16px;
}
.store-summary-grid strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.business-hours-board {
display: grid;
gap: 9px;
margin: 14px 0 22px;
padding: 12px;
border: 1px solid #e1e8f0;
border-radius: 8px;
background: #f8fafc;
}
.hours-chip-list {
display: grid;
grid-template-columns: repeat(7, minmax(76px, 1fr));
gap: 6px;
}
.hours-chip-list span {
display: grid;
gap: 3px;
padding: 7px;
color: #60708a;
font-size: 11px;
text-align: center;
background: #fff;
border: 1px solid #e1e8f0;
border-radius: 6px;
}
.hours-chip-list b {
color: #243247;
font-size: 11px;
}
.rooms-toolbar {
margin-bottom: 12px;
padding-top: 16px;
border-top: 1px solid #e6ebf2;
}
.asset-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 14px;
}
.asset-form .el-form-item {
min-width: 0;
}
.asset-form .el-input-number,
.asset-form .el-select {
width: 100%;
}
.form-span-2 {
grid-column: span 2;
}
.hours-editor {
display: grid;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e6ebf2;
}
.hours-editor-row {
display: grid;
grid-template-columns: 44px 126px minmax(120px, 1fr) minmax(120px, 1fr);
gap: 8px;
align-items: center;
}
.el-button {
border-radius: 8px;
}
@@ -1241,6 +1409,21 @@ textarea {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.store-layout {
grid-template-columns: 1fr;
}
.store-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-right: 0;
border-bottom: 1px solid #e6ebf2;
}
.hours-chip-list {
grid-template-columns: repeat(4, minmax(76px, 1fr));
}
.trend-row {
grid-template-columns: 92px minmax(80px, 1fr);
}
@@ -1306,6 +1489,41 @@ textarea {
flex-direction: column;
}
.store-list {
grid-template-columns: 1fr;
}
.store-detail {
padding: 12px;
}
.store-detail-header,
.rooms-toolbar {
align-items: stretch;
flex-direction: column;
}
.store-summary-grid,
.asset-form {
grid-template-columns: 1fr;
}
.form-span-2 {
grid-column: span 1;
}
.hours-chip-list {
grid-template-columns: repeat(2, minmax(76px, 1fr));
}
.hours-editor-row {
grid-template-columns: 40px 1fr;
}
.hours-editor-row .el-select {
grid-column: span 2;
}
.toolbar-actions {
justify-content: flex-end;
}
+66
View File
@@ -28,10 +28,76 @@ export interface ManagedStore {
address: string;
city: string;
district: string;
longitude: number | null;
latitude: number | null;
contactPhone: string;
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
timezone: string;
wifiSsid: string;
wifiConfigured: boolean;
notificationUrl: string;
sortOrder: number;
businessHours: StoreBusinessHour[];
}
export interface StoreBusinessHour {
weekday: number;
openMinute: number;
closeMinute: number;
isClosed: boolean;
}
export interface StoreInput {
name: string;
address: string;
city: string;
district: string;
longitude?: number | null;
latitude?: number | null;
contactPhone: string;
timezone: string;
businessStatus: ManagedStore['businessStatus'];
wifiSsid: string;
wifiPassword?: string;
notificationUrl: string;
sortOrder: number;
businessHours: StoreBusinessHour[];
}
export type RoomConfigurationStatus = 'ENABLED' | 'DISABLED';
export type RoomOperationalStatus =
| 'AVAILABLE'
| 'MAINTENANCE'
| 'RESERVED'
| 'IN_USE'
| 'CLEANING_REQUIRED';
export interface ManagedRoom {
id: string;
storeId: string;
categoryName: string;
name: string;
roomNo: string;
capacity: number;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceStartMinutes: number;
maxAdvanceDays: number;
configurationStatus: RoomConfigurationStatus;
operationalStatus: RoomOperationalStatus;
tags: string[];
images: string[];
sortOrder: number;
}
export type RoomInput = Omit<ManagedRoom, 'id'>;
export interface BusinessStatistics {
storeId: string;
from: string;
+1 -1
View File
@@ -29,7 +29,7 @@ const storeSchema = z.object({
timezone: z.string().trim().min(1).max(64).default('Asia/Shanghai'),
businessStatus: z.enum(['OPEN', 'CLOSED', 'SUSPENDED']).default('OPEN'),
wifiSsid: z.string().trim().max(128).default(''),
wifiPassword: z.string().max(255).default(''),
wifiPassword: z.string().max(255).optional(),
notificationUrl: z.union([z.string().url(), z.literal('')]).default(''),
sortOrder: z.number().int().min(-100000).max(100000).default(0),
businessHours: hoursSchema.default([])
+34 -6
View File
@@ -13,7 +13,7 @@ export interface StoreInput {
timezone: string;
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
wifiSsid: string;
wifiPassword: string;
wifiPassword?: string;
notificationUrl: string;
sortOrder: number;
businessHours: Array<{
@@ -60,7 +60,10 @@ interface StoreRow extends RowDataPacket {
id: string; name: string; address: string; city: string; district: string;
longitude: string | null; latitude: string | null;
contactPhone: string; timezone: string; businessStatus: string; wifiSsid: string;
notificationUrl: string; sortOrder: number;
wifiConfigured: number; notificationUrl: string; sortOrder: number;
}
interface StoreBusinessHourRow extends RowDataPacket {
storeId: string; weekday: number; openMinute: number; closeMinute: number; isClosed: number;
}
interface RoomRow extends RowDataPacket {
id: string; storeId: string; categoryName: string; name: string; roomNo: string; capacity: number;
@@ -85,17 +88,42 @@ export class StoreRoomRepository {
`SELECT s.id, s.name, s.address, s.city, s.district, s.longitude, s.latitude,
s.contact_phone AS contactPhone, s.timezone,
s.business_status AS businessStatus, s.wifi_ssid AS wifiSsid,
CASE WHEN s.wifi_password <> '' THEN 1 ELSE 0 END AS wifiConfigured,
s.notification_url AS notificationUrl, s.sort_order AS sortOrder
FROM qipai_stores s
WHERE s.tenant_id = ? AND s.deleted_at IS NULL AND ${scope.sql}
ORDER BY s.sort_order, s.id`,
[actor.tenantId, ...scope.params]
);
if (rows.length === 0) return [];
const storeIds = rows.map((row) => String(row.id));
const [hourRows] = await this.pool.execute<StoreBusinessHourRow[]>(
`SELECT store_id AS storeId, weekday, open_minute AS openMinute,
close_minute AS closeMinute, is_closed AS isClosed
FROM qipai_store_business_hours
WHERE tenant_id = ? AND store_id IN (${storeIds.map(() => '?').join(',')})
ORDER BY store_id, weekday`,
[actor.tenantId, ...storeIds]
);
const hoursByStore = new Map<string, StoreInput['businessHours']>();
for (const hour of hourRows) {
const storeId = String(hour.storeId);
const items = hoursByStore.get(storeId) ?? [];
items.push({
weekday: Number(hour.weekday),
openMinute: Number(hour.openMinute),
closeMinute: Number(hour.closeMinute),
isClosed: Boolean(hour.isClosed)
});
hoursByStore.set(storeId, items);
}
return rows.map((row) => ({
...row,
id: String(row.id),
longitude: row.longitude === null ? null : Number(row.longitude),
latitude: row.latitude === null ? null : Number(row.latitude)
latitude: row.latitude === null ? null : Number(row.latitude),
wifiConfigured: Boolean(row.wifiConfigured),
businessHours: hoursByStore.get(String(row.id)) ?? []
}));
}
@@ -109,7 +137,7 @@ export class StoreRoomRepository {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, input.name, input.address, input.city, input.district, input.longitude ?? null,
input.latitude ?? null, input.contactPhone, input.timezone, input.businessStatus,
input.wifiSsid, input.wifiPassword, input.notificationUrl, input.sortOrder]
input.wifiSsid, input.wifiPassword ?? '', input.notificationUrl, input.sortOrder]
);
const storeId = String(result.insertId);
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
@@ -125,12 +153,12 @@ export class StoreRoomRepository {
`UPDATE qipai_stores SET name = ?, address = ?, city = ?, district = ?,
longitude = ?, latitude = ?,
contact_phone = ?, timezone = ?, business_status = ?, wifi_ssid = ?,
wifi_password = ?, notification_url = ?, sort_order = ?
wifi_password = COALESCE(?, wifi_password), notification_url = ?, sort_order = ?
WHERE tenant_id = ? AND id = ?`,
[input.name, input.address, input.city, input.district,
input.longitude ?? null, input.latitude ?? null,
input.contactPhone, input.timezone, input.businessStatus, input.wifiSsid,
input.wifiPassword, input.notificationUrl, input.sortOrder, actor.tenantId, storeId]
input.wifiPassword ?? null, input.notificationUrl, input.sortOrder, actor.tenantId, storeId]
);
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
await this.audit(connection, actor, 'STORE_UPDATED', 'STORE', storeId);
+22 -2
View File
@@ -18,11 +18,18 @@ const storeActor = {
};
const repository = new StoreRoomRepository({
async execute(sql) {
if (sql.includes('FROM qipai_stores')) return [[{ id: 11, name: 'A', longitude: null, latitude: null }], []];
if (sql.includes('FROM qipai_stores')) return [[{
id: 11, name: 'A', longitude: null, latitude: null, wifiConfigured: 1
}], []];
if (sql.includes('FROM qipai_store_business_hours')) return [[{
storeId: 11, weekday: 1, openMinute: 540, closeMinute: 1320, isClosed: 0
}], []];
return [[], []];
}
});
assert.equal((await repository.listStores(storeActor))[0].id, '11');
assert.equal((await repository.listStores(storeActor))[0].wifiConfigured, true);
assert.equal((await repository.listStores(storeActor))[0].businessHours[0].openMinute, 540);
assert.deepEqual(await repository.listRooms({
...storeActor,
access: { roles: ['STAFF'], capabilities: ['store.operation.read'], storeIds: ['11'] }
@@ -39,6 +46,7 @@ const token = signAccessToken({
}, secret, 900);
let roomInput;
let roomStatusInput;
let storeInput;
let currentAccess = tenantActor.access;
const app = await buildApp({
storeRoom: {
@@ -59,7 +67,7 @@ const app = await buildApp({
repository: {
async listStores() { return []; },
async createStore() { return { storeId: '11' }; },
async updateStore() { return { storeId: '11' }; },
async updateStore(_actor, _storeId, input) { storeInput = input; return { storeId: '11' }; },
async archiveStore() { return { storeId: '11', archived: true }; },
async listRooms() { return []; },
async createRoom(_actor, input) { roomInput = input; return { roomId: '31' }; },
@@ -97,6 +105,18 @@ const managementStores = await app.inject({
headers: { authorization: `Bearer ${token}` }
});
assert.equal(managementStores.statusCode, 200);
const updatedStore = await app.inject({
method: 'PUT', url: '/admin-api/stores/11',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'A 店', address: '测试路 1 号', city: '上海', district: '浦东新区',
contactPhone: '13800000000', timezone: 'Asia/Shanghai', businessStatus: 'OPEN',
wifiSsid: 'qipai-guest', notificationUrl: '', sortOrder: 1,
businessHours: [{ weekday: 1, openMinute: 540, closeMinute: 1320, isClosed: false }]
}
});
assert.equal(updatedStore.statusCode, 200);
assert.equal(storeInput.wifiPassword, undefined);
const statusUpdated = await app.inject({
method: 'PATCH', url: '/app-api/management/rooms/31/status',
headers: { authorization: `Bearer ${token}` },
+40
View File
@@ -10,7 +10,9 @@ for (const pattern of [
'OperationsOverviewPanel',
"activeModule = 'overview'",
"activeModule = 'cleaning'",
"activeModule = 'stores'",
'平台运营总览',
'StoresRoomsPanel',
'运营总览',
'savedToken',
'loadCleaningWorkspace'
@@ -47,6 +49,36 @@ for (const pattern of [
const api = read('admin/src/api.ts');
assert.match(api, /request<ManagedStore\[]>\(session, '\/stores'\)/);
assert.match(api, /request<BusinessStatistics>\(session, `\/statistics\?\$\{params\}`\)/);
for (const pattern of [
'createManagedStore',
'updateManagedStore',
'archiveManagedStore',
'listManagedRooms',
'createManagedRoom',
'updateManagedRoom',
'updateManagedRoomStatus',
'addManagedRoomDisabledPeriod'
]) {
assert.match(api, new RegExp(pattern));
}
const stores = read('admin/src/components/StoresRoomsPanel.vue');
for (const pattern of [
'门店与房间管理',
'搜索门店、城市或地址',
'每周营业时间',
'wifiPassword',
'listManagedStores',
'listManagedRooms',
'updateManagedRoomStatus',
'新增房间',
'停用时段',
'configurationStatus',
'operationalStatus',
'archiveManagedRoom'
]) {
assert.match(stores, new RegExp(pattern));
}
const routes = read('backend/src/routes/business-statistics.ts');
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
@@ -54,6 +86,11 @@ assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statist
const routeTest = read('backend/tests/business-statistics.test.mjs');
assert.match(routeTest, /url: '\/admin-api\/statistics\?/);
const storeRepository = read('backend/src/stores/store-room-repository.ts');
assert.match(storeRepository, /wifi_password = COALESCE\(\?, wifi_password\)/);
assert.match(storeRepository, /FROM qipai_store_business_hours/);
assert.match(storeRepository, /wifiConfigured/);
const styles = read('admin/src/styles.css');
for (const pattern of [
'.overview-page',
@@ -62,6 +99,9 @@ for (const pattern of [
'.overview-grid',
'.revenue-bars',
'.module-grid',
'.store-layout',
'.store-card',
'.hours-editor',
'@media (max-width: 980px)',
'@media (max-width: 560px)'
]) {