feat(M08-D): 补门店与房间管理
This commit is contained in:
+21
-5
@@ -28,9 +28,14 @@
|
|||||||
<Sparkles :size="18" />
|
<Sparkles :size="18" />
|
||||||
<span>保洁运营</span>
|
<span>保洁运营</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-item" type="button" disabled>
|
<button
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: activeModule === 'stores' }"
|
||||||
|
type="button"
|
||||||
|
@click="activeModule = 'stores'"
|
||||||
|
>
|
||||||
<Store :size="18" />
|
<Store :size="18" />
|
||||||
<span>门店</span>
|
<span>门店房间</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-item" type="button" disabled>
|
<button class="nav-item" type="button" disabled>
|
||||||
<RadioTower :size="18" />
|
<RadioTower :size="18" />
|
||||||
@@ -46,8 +51,8 @@
|
|||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ activeModule === 'overview' ? 'M08-D' : 'M08-B' }}</p>
|
<p class="eyebrow">{{ activeModuleMeta.stage }}</p>
|
||||||
<h2>{{ activeModule === 'overview' ? '平台运营总览' : '保洁任务与结算' }}</h2>
|
<h2>{{ activeModuleMeta.title }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="token-box">
|
<div class="token-box">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -70,6 +75,11 @@
|
|||||||
@open-cleaning="activeModule = 'cleaning'"
|
@open-cleaning="activeModule = 'cleaning'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StoresRoomsPanel
|
||||||
|
v-else-if="activeModule === 'stores'"
|
||||||
|
:session="session"
|
||||||
|
/>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="metric-grid" aria-label="保洁概览">
|
<section class="metric-grid" aria-label="保洁概览">
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
@@ -247,6 +257,7 @@ 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 OperationsOverviewPanel from './components/OperationsOverviewPanel.vue';
|
||||||
|
import StoresRoomsPanel from './components/StoresRoomsPanel.vue';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
assignCleaningTask,
|
assignCleaningTask,
|
||||||
@@ -283,7 +294,7 @@ import { money } from './format';
|
|||||||
|
|
||||||
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
||||||
const tokenDraft = ref(savedToken.value);
|
const tokenDraft = ref(savedToken.value);
|
||||||
const activeModule = ref<'overview' | 'cleaning'>('overview');
|
const activeModule = ref<'overview' | 'stores' | 'cleaning'>('overview');
|
||||||
const activeTab = ref('tasks');
|
const activeTab = ref('tasks');
|
||||||
const lastError = ref('');
|
const lastError = ref('');
|
||||||
const lastMessage = ref('');
|
const lastMessage = ref('');
|
||||||
@@ -292,6 +303,11 @@ 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: savedToken.value }));
|
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 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>>({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
CleaningTaskEvent,
|
CleaningTaskEvent,
|
||||||
CleaningTaskMember,
|
CleaningTaskMember,
|
||||||
BusinessStatistics,
|
BusinessStatistics,
|
||||||
|
ManagedRoom,
|
||||||
ManagedStore,
|
ManagedStore,
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
PageResult,
|
PageResult,
|
||||||
@@ -14,6 +15,10 @@ import type {
|
|||||||
SettlementStatus,
|
SettlementStatus,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
TransferMode,
|
TransferMode,
|
||||||
|
RoomConfigurationStatus,
|
||||||
|
RoomInput,
|
||||||
|
RoomOperationalStatus,
|
||||||
|
StoreInput,
|
||||||
UserStatus,
|
UserStatus,
|
||||||
WechatTransferPreflight
|
WechatTransferPreflight
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -38,6 +43,71 @@ export function listManagedStores(session: ApiSession) {
|
|||||||
return request<ManagedStore[]>(session, '/stores');
|
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(
|
export function getBusinessStatistics(
|
||||||
session: ApiSession,
|
session: ApiSession,
|
||||||
input: { storeId: string; from: string; to: string }
|
input: { storeId: string; from: string; to: string }
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1157,6 +1157,174 @@ textarea {
|
|||||||
white-space: nowrap;
|
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 {
|
.el-button {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@@ -1241,6 +1409,21 @@ textarea {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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 {
|
.trend-row {
|
||||||
grid-template-columns: 92px minmax(80px, 1fr);
|
grid-template-columns: 92px minmax(80px, 1fr);
|
||||||
}
|
}
|
||||||
@@ -1306,6 +1489,41 @@ textarea {
|
|||||||
flex-direction: column;
|
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 {
|
.toolbar-actions {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,76 @@ export interface ManagedStore {
|
|||||||
address: string;
|
address: string;
|
||||||
city: string;
|
city: string;
|
||||||
district: string;
|
district: string;
|
||||||
|
longitude: number | null;
|
||||||
|
latitude: number | null;
|
||||||
|
contactPhone: string;
|
||||||
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
|
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
|
||||||
timezone: string;
|
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 {
|
export interface BusinessStatistics {
|
||||||
storeId: string;
|
storeId: string;
|
||||||
from: string;
|
from: string;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const storeSchema = z.object({
|
|||||||
timezone: z.string().trim().min(1).max(64).default('Asia/Shanghai'),
|
timezone: z.string().trim().min(1).max(64).default('Asia/Shanghai'),
|
||||||
businessStatus: z.enum(['OPEN', 'CLOSED', 'SUSPENDED']).default('OPEN'),
|
businessStatus: z.enum(['OPEN', 'CLOSED', 'SUSPENDED']).default('OPEN'),
|
||||||
wifiSsid: z.string().trim().max(128).default(''),
|
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(''),
|
notificationUrl: z.union([z.string().url(), z.literal('')]).default(''),
|
||||||
sortOrder: z.number().int().min(-100000).max(100000).default(0),
|
sortOrder: z.number().int().min(-100000).max(100000).default(0),
|
||||||
businessHours: hoursSchema.default([])
|
businessHours: hoursSchema.default([])
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface StoreInput {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
|
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
|
||||||
wifiSsid: string;
|
wifiSsid: string;
|
||||||
wifiPassword: string;
|
wifiPassword?: string;
|
||||||
notificationUrl: string;
|
notificationUrl: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
businessHours: Array<{
|
businessHours: Array<{
|
||||||
@@ -60,7 +60,10 @@ interface StoreRow extends RowDataPacket {
|
|||||||
id: string; name: string; address: string; city: string; district: string;
|
id: string; name: string; address: string; city: string; district: string;
|
||||||
longitude: string | null; latitude: string | null;
|
longitude: string | null; latitude: string | null;
|
||||||
contactPhone: string; timezone: string; businessStatus: string; wifiSsid: string;
|
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 {
|
interface RoomRow extends RowDataPacket {
|
||||||
id: string; storeId: string; categoryName: string; name: string; roomNo: string; capacity: number;
|
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,
|
`SELECT s.id, s.name, s.address, s.city, s.district, s.longitude, s.latitude,
|
||||||
s.contact_phone AS contactPhone, s.timezone,
|
s.contact_phone AS contactPhone, s.timezone,
|
||||||
s.business_status AS businessStatus, s.wifi_ssid AS wifiSsid,
|
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
|
s.notification_url AS notificationUrl, s.sort_order AS sortOrder
|
||||||
FROM qipai_stores s
|
FROM qipai_stores s
|
||||||
WHERE s.tenant_id = ? AND s.deleted_at IS NULL AND ${scope.sql}
|
WHERE s.tenant_id = ? AND s.deleted_at IS NULL AND ${scope.sql}
|
||||||
ORDER BY s.sort_order, s.id`,
|
ORDER BY s.sort_order, s.id`,
|
||||||
[actor.tenantId, ...scope.params]
|
[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) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
longitude: row.longitude === null ? null : Number(row.longitude),
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[actor.tenantId, input.name, input.address, input.city, input.district, input.longitude ?? null,
|
[actor.tenantId, input.name, input.address, input.city, input.district, input.longitude ?? null,
|
||||||
input.latitude ?? null, input.contactPhone, input.timezone, input.businessStatus,
|
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);
|
const storeId = String(result.insertId);
|
||||||
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
|
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
|
||||||
@@ -125,12 +153,12 @@ export class StoreRoomRepository {
|
|||||||
`UPDATE qipai_stores SET name = ?, address = ?, city = ?, district = ?,
|
`UPDATE qipai_stores SET name = ?, address = ?, city = ?, district = ?,
|
||||||
longitude = ?, latitude = ?,
|
longitude = ?, latitude = ?,
|
||||||
contact_phone = ?, timezone = ?, business_status = ?, wifi_ssid = ?,
|
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 = ?`,
|
WHERE tenant_id = ? AND id = ?`,
|
||||||
[input.name, input.address, input.city, input.district,
|
[input.name, input.address, input.city, input.district,
|
||||||
input.longitude ?? null, input.latitude ?? null,
|
input.longitude ?? null, input.latitude ?? null,
|
||||||
input.contactPhone, input.timezone, input.businessStatus, input.wifiSsid,
|
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.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
|
||||||
await this.audit(connection, actor, 'STORE_UPDATED', 'STORE', storeId);
|
await this.audit(connection, actor, 'STORE_UPDATED', 'STORE', storeId);
|
||||||
|
|||||||
@@ -18,11 +18,18 @@ const storeActor = {
|
|||||||
};
|
};
|
||||||
const repository = new StoreRoomRepository({
|
const repository = new StoreRoomRepository({
|
||||||
async execute(sql) {
|
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 [[], []];
|
return [[], []];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
assert.equal((await repository.listStores(storeActor))[0].id, '11');
|
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({
|
assert.deepEqual(await repository.listRooms({
|
||||||
...storeActor,
|
...storeActor,
|
||||||
access: { roles: ['STAFF'], capabilities: ['store.operation.read'], storeIds: ['11'] }
|
access: { roles: ['STAFF'], capabilities: ['store.operation.read'], storeIds: ['11'] }
|
||||||
@@ -39,6 +46,7 @@ const token = signAccessToken({
|
|||||||
}, secret, 900);
|
}, secret, 900);
|
||||||
let roomInput;
|
let roomInput;
|
||||||
let roomStatusInput;
|
let roomStatusInput;
|
||||||
|
let storeInput;
|
||||||
let currentAccess = tenantActor.access;
|
let currentAccess = tenantActor.access;
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
storeRoom: {
|
storeRoom: {
|
||||||
@@ -59,7 +67,7 @@ const app = await buildApp({
|
|||||||
repository: {
|
repository: {
|
||||||
async listStores() { return []; },
|
async listStores() { return []; },
|
||||||
async createStore() { return { storeId: '11' }; },
|
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 archiveStore() { return { storeId: '11', archived: true }; },
|
||||||
async listRooms() { return []; },
|
async listRooms() { return []; },
|
||||||
async createRoom(_actor, input) { roomInput = input; return { roomId: '31' }; },
|
async createRoom(_actor, input) { roomInput = input; return { roomId: '31' }; },
|
||||||
@@ -97,6 +105,18 @@ const managementStores = await app.inject({
|
|||||||
headers: { authorization: `Bearer ${token}` }
|
headers: { authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
assert.equal(managementStores.statusCode, 200);
|
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({
|
const statusUpdated = await app.inject({
|
||||||
method: 'PATCH', url: '/app-api/management/rooms/31/status',
|
method: 'PATCH', url: '/app-api/management/rooms/31/status',
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ for (const pattern of [
|
|||||||
'OperationsOverviewPanel',
|
'OperationsOverviewPanel',
|
||||||
"activeModule = 'overview'",
|
"activeModule = 'overview'",
|
||||||
"activeModule = 'cleaning'",
|
"activeModule = 'cleaning'",
|
||||||
|
"activeModule = 'stores'",
|
||||||
'平台运营总览',
|
'平台运营总览',
|
||||||
|
'StoresRoomsPanel',
|
||||||
'运营总览',
|
'运营总览',
|
||||||
'savedToken',
|
'savedToken',
|
||||||
'loadCleaningWorkspace'
|
'loadCleaningWorkspace'
|
||||||
@@ -47,6 +49,36 @@ for (const pattern of [
|
|||||||
const api = read('admin/src/api.ts');
|
const api = read('admin/src/api.ts');
|
||||||
assert.match(api, /request<ManagedStore\[]>\(session, '\/stores'\)/);
|
assert.match(api, /request<ManagedStore\[]>\(session, '\/stores'\)/);
|
||||||
assert.match(api, /request<BusinessStatistics>\(session, `\/statistics\?\$\{params\}`\)/);
|
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');
|
const routes = read('backend/src/routes/business-statistics.ts');
|
||||||
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
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');
|
const routeTest = read('backend/tests/business-statistics.test.mjs');
|
||||||
assert.match(routeTest, /url: '\/admin-api\/statistics\?/);
|
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');
|
const styles = read('admin/src/styles.css');
|
||||||
for (const pattern of [
|
for (const pattern of [
|
||||||
'.overview-page',
|
'.overview-page',
|
||||||
@@ -62,6 +99,9 @@ for (const pattern of [
|
|||||||
'.overview-grid',
|
'.overview-grid',
|
||||||
'.revenue-bars',
|
'.revenue-bars',
|
||||||
'.module-grid',
|
'.module-grid',
|
||||||
|
'.store-layout',
|
||||||
|
'.store-card',
|
||||||
|
'.hours-editor',
|
||||||
'@media (max-width: 980px)',
|
'@media (max-width: 980px)',
|
||||||
'@media (max-width: 560px)'
|
'@media (max-width: 560px)'
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user