feat(M08-D): 补多小程序与租户配置

This commit is contained in:
Codex
2026-08-10 12:37:53 +08:00
parent b3fba4b815
commit c7f27bc724
12 changed files with 517 additions and 1 deletions
+10 -1
View File
@@ -10,6 +10,7 @@
</div>
</div>
<nav class="nav-list" aria-label="后台模块">
<button class="nav-item" :class="{ active: activeModule === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
<button
class="nav-item"
:class="{ active: activeModule === 'overview' }"
@@ -112,6 +113,11 @@
@open-cleaning="activeModule = 'cleaning'"
/>
<PlatformAppsPanel
v-else-if="activeModule === 'platformApps'"
:session="session"
/>
<StoresRoomsPanel
v-else-if="activeModule === 'stores'"
:session="session"
@@ -306,6 +312,7 @@ import {
Download,
LayoutDashboard,
ListTodo,
PanelsTopLeft,
RadioTower,
RotateCcw,
Save,
@@ -328,6 +335,7 @@ import PaymentsPanel from './components/PaymentsPanel.vue';
import ThirdPartyPanel from './components/ThirdPartyPanel.vue';
import MembersStaffPanel from './components/MembersStaffPanel.vue';
import DevicesPanel from './components/DevicesPanel.vue';
import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -364,7 +372,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value);
const activeModule = ref<'overview' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeModule = ref<'overview' | 'platformApps' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeTab = ref('tasks');
const lastError = ref('');
const lastMessage = ref('');
@@ -375,6 +383,7 @@ const cleanerSearch = ref('');
const session = computed(() => ({ token: savedToken.value }));
const activeModuleMeta = computed(() => ({
overview: { stage: 'M08-D', title: '平台运营总览' },
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
stores: { stage: 'M08-D', title: '门店与房间管理' },
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
+16
View File
@@ -19,6 +19,7 @@ import type {
OrderHistoryItem,
OrderStatus,
PaymentAuthorizationStatus,
PlatformApplication,
ProfitSharingSnapshot,
PayoutStateFilter,
StaffRole,
@@ -29,6 +30,7 @@ import type {
RoomInput,
RoomOperationalStatus,
StoreInput,
TenantApplicationConfig,
ThirdPartyMode,
ThirdPartyProvider,
ThirdPartyRecords,
@@ -419,6 +421,20 @@ export function controlDevice(session: ApiSession, action: 'door' | 'power' | 's
return request<{ commandId?: string; status?: string }>(session, `/device-control/${action}`, { method: 'POST', body: JSON.stringify(input) });
}
export function listPlatformApplications(session: ApiSession) {
return request<PlatformApplication[]>(session, '/platform-apps');
}
export function updatePlatformApplicationConfig(session: ApiSession, platformAppId: string, input: TenantApplicationConfig) {
return request<{ platformAppId: string; updated: boolean }>(session, `/platform-apps/${encodeURIComponent(platformAppId)}/config`, { method: 'PUT', body: JSON.stringify(input) });
}
export function bindPlatformApplication(session: ApiSession, input: {
appId: string; appName: string; appStatus: 'ACTIVE' | 'DISABLED'; config: TenantApplicationConfig;
}) {
return request<{ platformAppId: string; bound: boolean }>(session, '/platform-apps/bind', { method: 'POST', body: JSON.stringify(input) });
}
export function createStaffUser(
session: ApiSession,
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
@@ -0,0 +1,50 @@
<template>
<section class="platform-apps-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<el-alert title="AppSecret、微信支付密钥和证书只允许配置在服务端环境变量中,本页面不会读取、保存或回显。" type="info" show-icon :closable="false" />
<section class="platform-app-metrics"><span><small>已绑定应用</small><strong>{{ apps.length }}</strong></span><span><small>启用绑定</small><strong>{{ activeCount }}</strong></span><span><small>默认应用</small><strong>{{ defaultCount }}</strong></span><span><small>品牌配置完整</small><strong>{{ configuredCount }}</strong></span></section>
<section class="panel">
<header class="panel-toolbar platform-app-toolbar"><div><p class="section-kicker">M08-D · 多应用租户</p><h3>多小程序与租户品牌配置</h3></div><div class="toolbar-actions"><el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button><el-button type="primary" :icon="Plus" @click="openBind">绑定应用</el-button></div></header>
<el-table v-loading="loading" :data="apps" class="data-table" row-key="platformAppId">
<el-table-column label="逻辑应用" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.appName }}</strong><span>{{ row.appId }} · #{{ row.platformAppId }}</span></div></template></el-table-column>
<el-table-column label="绑定" width="130"><template #default="{ row }"><div class="stack"><el-tag :type="row.bindingStatus === 'ACTIVE' ? 'success' : 'info'">{{ row.bindingStatus }}</el-tag><span>{{ row.isDefault ? '租户默认' : '普通绑定' }}</span></div></template></el-table-column>
<el-table-column label="品牌" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.brandName }}</strong><span><i class="theme-dot" :style="{ background: row.themeColor }" />{{ row.themeColor }} · {{ row.servicePhone || '未留客服电话' }}</span></div></template></el-table-column>
<el-table-column label="默认门店" min-width="160"><template #default="{ row }">{{ row.defaultStoreId ? storeName(row.defaultStoreId) : '未配置' }}</template></el-table-column>
<el-table-column label="分享" min-width="200"><template #default="{ row }"><div class="stack"><strong>{{ row.shareTitle || '-' }}</strong><span>{{ row.shareImageUrl ? '已配置分享图' : '未配置分享图' }}</span></div></template></el-table-column>
<el-table-column label="更新时间" width="150"><template #default="{ row }">{{ shortDate(row.updatedAt) }}</template></el-table-column>
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Settings2" @click="openEdit(row)">配置</el-button></template></el-table-column>
</el-table>
</section>
<el-dialog v-model="dialog" :title="form.platformAppId ? '编辑租户应用配置' : '绑定逻辑应用'" width="min(760px, 94vw)">
<el-form label-position="top" class="asset-form">
<template v-if="!form.platformAppId"><el-form-item label="微信 AppID"><el-input v-model="form.appId" autocomplete="off" /></el-form-item><el-form-item label="应用名称"><el-input v-model="form.appName" /></el-form-item><el-form-item label="全局应用状态"><el-select v-model="form.appStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item></template>
<el-form-item label="品牌名称"><el-input v-model="form.brandName" /></el-form-item><el-form-item label="主题色"><el-color-picker v-model="form.themeColor" /><el-input v-model="form.themeColor" class="color-input" /></el-form-item><el-form-item label="客服电话"><el-input v-model="form.servicePhone" /></el-form-item><el-form-item label="加盟电话"><el-input v-model="form.franchisePhone" /></el-form-item><el-form-item label="默认门店"><el-select v-model="form.defaultStoreId" clearable><el-option label="不指定" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="绑定状态"><el-select v-model="form.bindingStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item><el-form-item label="租户默认应用"><el-switch v-model="form.isDefault" /></el-form-item><el-form-item label="Logo URL" class="form-span-2"><el-input v-model="form.logoUrl" /></el-form-item><el-form-item label="分享标题" class="form-span-2"><el-input v-model="form.shareTitle" /></el-form-item><el-form-item label="分享图片 URL" class="form-span-2"><el-input v-model="form.shareImageUrl" /></el-form-item><el-form-item label="非敏感扩展配置 JSON" class="form-span-2"><el-input v-model="form.extraConfigText" type="textarea" :rows="5" placeholder='{"bookingMode":"STANDARD"}' /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Plus, RefreshCw, Settings2 } from '@lucide/vue';
import { ApiError, bindPlatformApplication, listManagedStores, listPlatformApplications, updatePlatformApplicationConfig, type ApiSession } from '../api';
import { shortDate } from '../format';
import type { ManagedStore, PlatformApplication, TenantApplicationConfig } from '../types';
const props = defineProps<{ session: ApiSession }>();
const apps = ref<PlatformApplication[]>([]); const stores = ref<ManagedStore[]>([]); const loading = ref(false); const saving = ref(false); const lastError = ref(''); const dialog = ref(false);
const form = reactive({ platformAppId: '', appId: '', appName: '', appStatus: 'ACTIVE' as 'ACTIVE' | 'DISABLED', brandName: '', logoUrl: '', themeColor: '#1677ff', servicePhone: '', franchisePhone: '', shareTitle: '', shareImageUrl: '', defaultStoreId: '', bindingStatus: 'ACTIVE' as 'ACTIVE' | 'DISABLED', isDefault: false, extraConfigText: '{}' });
const activeCount = computed(() => apps.value.filter((item) => item.bindingStatus === 'ACTIVE').length); const defaultCount = computed(() => apps.value.filter((item) => item.isDefault).length); const configuredCount = computed(() => apps.value.filter((item) => item.brandName && item.themeColor && item.shareTitle).length);
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
async function loadData() { if (!props.session.token) return; loading.value = true; try { [apps.value, stores.value] = await Promise.all([capture(() => listPlatformApplications(props.session)), capture(() => listManagedStores(props.session))]); } catch { /* displayed */ } finally { loading.value = false; } }
function defaults() { return { platformAppId: '', appId: '', appName: '', appStatus: 'ACTIVE', brandName: '', logoUrl: '', themeColor: '#1677ff', servicePhone: '', franchisePhone: '', shareTitle: '', shareImageUrl: '', defaultStoreId: '', bindingStatus: 'ACTIVE', isDefault: apps.value.length === 0, extraConfigText: '{}' }; }
function openBind() { Object.assign(form, defaults()); dialog.value = true; }
function openEdit(row: PlatformApplication) { Object.assign(form, { platformAppId: row.platformAppId, appId: row.appId, appName: row.appName, appStatus: row.appStatus, brandName: row.brandName, logoUrl: row.logoUrl, themeColor: row.themeColor, servicePhone: row.servicePhone, franchisePhone: row.franchisePhone, shareTitle: row.shareTitle, shareImageUrl: row.shareImageUrl, defaultStoreId: row.defaultStoreId || '', bindingStatus: row.bindingStatus, isDefault: row.isDefault, extraConfigText: JSON.stringify(row.extraConfig, null, 2) }); dialog.value = true; }
function configInput(): TenantApplicationConfig | null { let extraConfig: unknown; try { extraConfig = JSON.parse(form.extraConfigText || '{}'); } catch { ElMessage.warning('扩展配置必须是 JSON 对象'); return null; } if (!extraConfig || typeof extraConfig !== 'object' || Array.isArray(extraConfig)) { ElMessage.warning('扩展配置必须是 JSON 对象'); return null; } return { brandName: form.brandName.trim(), logoUrl: form.logoUrl.trim(), themeColor: form.themeColor, servicePhone: form.servicePhone.trim(), franchisePhone: form.franchisePhone.trim(), shareTitle: form.shareTitle.trim(), shareImageUrl: form.shareImageUrl.trim(), defaultStoreId: form.defaultStoreId || null, extraConfig: extraConfig as Record<string, unknown>, bindingStatus: form.bindingStatus, isDefault: form.isDefault }; }
async function save() { const config = configInput(); if (!config || !config.brandName || !/^#[0-9A-Fa-f]{6}$/.test(config.themeColor) || (!form.platformAppId && (!form.appId || !form.appName))) return ElMessage.warning('请填写 AppID、应用名称、品牌名称和有效主题色'); saving.value = true; try { if (form.platformAppId) await capture(() => updatePlatformApplicationConfig(props.session, form.platformAppId, config)); else await capture(() => bindPlatformApplication(props.session, { appId: form.appId.trim(), appName: form.appName.trim(), appStatus: form.appStatus, config })); dialog.value = false; ElMessage.success('应用配置已保存'); await loadData(); } catch { /* displayed */ } finally { saving.value = false; } }
function storeName(id: string) { return stores.value.find((item) => item.id === id)?.name || `门店 #${id}`; }
watch(() => props.session.token, (token) => { if (token) void loadData(); }, { immediate: true });
</script>
+48
View File
@@ -1658,6 +1658,52 @@ textarea {
margin-top: 18px;
}
.platform-apps-page {
display: grid;
gap: 14px;
}
.platform-app-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.platform-app-metrics span {
display: grid;
gap: 6px;
padding: 14px;
background: #fff;
border: 1px solid #d8e0ea;
border-radius: 8px;
}
.platform-app-metrics small {
color: #69788c;
}
.platform-app-metrics strong {
color: #172033;
font-size: 22px;
}
.platform-app-toolbar h3 {
margin: 0;
}
.theme-dot {
display: inline-block;
width: 9px;
height: 9px;
margin-right: 5px;
border-radius: 50%;
}
.color-input {
width: 120px;
margin-left: 8px;
}
.el-button {
border-radius: 8px;
}
@@ -1766,6 +1812,7 @@ textarea {
.people-metrics,
.member-detail-grid,
.device-metrics,
.platform-app-metrics,
.device-topology-grid,
.payment-tools-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1880,6 +1927,7 @@ textarea {
.people-metrics,
.member-detail-grid,
.device-metrics,
.platform-app-metrics,
.device-topology-grid,
.device-control-grid,
.payment-tools-grid {
+33
View File
@@ -437,6 +437,39 @@ export interface DeviceTopology {
maintenance: DeviceMaintenance[];
}
export interface PlatformApplication {
platformAppId: string;
appId: string;
appName: string;
appStatus: 'ACTIVE' | 'DISABLED';
bindingStatus: 'ACTIVE' | 'DISABLED';
isDefault: boolean;
brandName: string;
logoUrl: string;
themeColor: string;
servicePhone: string;
franchisePhone: string;
shareTitle: string;
shareImageUrl: string;
defaultStoreId: string | null;
extraConfig: Record<string, unknown>;
updatedAt: string;
}
export interface TenantApplicationConfig {
brandName: string;
logoUrl: string;
themeColor: string;
servicePhone: string;
franchisePhone: string;
shareTitle: string;
shareImageUrl: string;
defaultStoreId: string | null;
extraConfig: Record<string, unknown>;
bindingStatus: 'ACTIVE' | 'DISABLED';
isDefault: boolean;
}
export interface CleaningTask {
id: string;
taskNo: string;