feat(M08-D): 补广告与装修管理

This commit is contained in:
Codex
2026-08-10 12:57:05 +08:00
parent ec4219ae31
commit c1c02421a1
17 changed files with 913 additions and 21 deletions
+10 -1
View File
@@ -11,6 +11,7 @@
</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 === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
<button
class="nav-item"
:class="{ active: activeModule === 'overview' }"
@@ -118,6 +119,11 @@
:session="session"
/>
<ContentManagementPanel
v-else-if="activeModule === 'content'"
:session="session"
/>
<StoresRoomsPanel
v-else-if="activeModule === 'stores'"
:session="session"
@@ -312,6 +318,7 @@ import {
Download,
LayoutDashboard,
ListTodo,
Images,
PanelsTopLeft,
RadioTower,
RotateCcw,
@@ -336,6 +343,7 @@ 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 ContentManagementPanel from './components/ContentManagementPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -372,7 +380,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value);
const activeModule = ref<'overview' | 'platformApps' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeTab = ref('tasks');
const lastError = ref('');
const lastMessage = ref('');
@@ -384,6 +392,7 @@ const session = computed(() => ({ token: savedToken.value }));
const activeModuleMeta = computed(() => ({
overview: { stage: 'M08-D', title: '平台运营总览' },
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
content: { stage: 'M08-D', title: '广告投放与门店装修' },
stores: { stage: 'M08-D', title: '门店与房间管理' },
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
+64
View File
@@ -1,10 +1,14 @@
import type {
Advertisement,
AdvertisementInput,
CleaningSettlement,
CleaningSettlementDetail,
CleaningStatistics,
CleaningTask,
CleaningTaskEvent,
CleaningTaskMember,
DecorationComponent,
DecorationVersion,
DeviceTopology,
DeviceType,
BusinessStatistics,
@@ -14,6 +18,7 @@ import type {
ManagedOrder,
ManagedStore,
ManagedUser,
MediaAsset,
PageResult,
OrderAction,
OrderHistoryItem,
@@ -435,6 +440,65 @@ export function bindPlatformApplication(session: ApiSession, input: {
return request<{ platformAppId: string; bound: boolean }>(session, '/platform-apps/bind', { method: 'POST', body: JSON.stringify(input) });
}
export function listMediaAssets(session: ApiSession, storeId?: string) {
const query = storeId ? `?${new URLSearchParams({ storeId })}` : '';
return request<MediaAsset[]>(session, `/media/images${query}`);
}
export async function uploadMediaImage(session: ApiSession, file: File, storeId?: string) {
const headers = new Headers({
'content-type': 'application/octet-stream',
'x-image-content-type': file.type,
'x-file-name': file.name.replace(/[^\x20-\x7e]/g, '_') || 'image'
});
if (storeId) headers.set('x-store-id', storeId);
return request<{ assetId: string; url: string }>(session, '/media/images', {
method: 'POST', headers, body: await file.arrayBuffer()
});
}
export function listDecorations(session: ApiSession, storeId: string) {
return request<DecorationVersion[]>(
session, `/decorations?${new URLSearchParams({ storeId })}`
);
}
export function saveDecoration(session: ApiSession, input: {
storeId: string; templateCode: string; schemaVersion: number;
content: { components: DecorationComponent[] };
}) {
return request<{ decorationId: string; version: number }>(session, '/decorations', {
method: 'POST', body: JSON.stringify(input)
});
}
export function publishDecoration(session: ApiSession, decorationId: string, storeId: string) {
return request<{ decorationId: string; published: boolean }>(
session,
`/decorations/${encodeURIComponent(decorationId)}/publish?${new URLSearchParams({ storeId })}`,
{ method: 'POST' }
);
}
export function listAdvertisements(session: ApiSession) {
return request<Advertisement[]>(session, '/advertisements');
}
export function saveAdvertisement(session: ApiSession, input: AdvertisementInput) {
return request<{ advertisementId: string }>(session, '/advertisements', {
method: 'POST', body: JSON.stringify(input)
});
}
export function updateAdvertisement(
session: ApiSession, advertisementId: string, input: AdvertisementInput
) {
return request<{ advertisementId: string }>(
session, `/advertisements/${encodeURIComponent(advertisementId)}`,
{ method: 'PUT', body: JSON.stringify(input) }
);
}
export function createStaffUser(
session: ApiSession,
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
@@ -0,0 +1,162 @@
<template>
<section class="content-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="content-metrics">
<span><small>可用素材</small><strong>{{ assets.length }}</strong></span>
<span><small>装修版本</small><strong>{{ decorations.length }}</strong></span>
<span><small>已发布装修</small><strong>{{ publishedDecorationCount }}</strong></span>
<span><small>生效广告</small><strong>{{ activeAdvertisementCount }}</strong></span>
</section>
<section class="panel">
<header class="panel-toolbar content-toolbar">
<div><p class="section-kicker">M08-D · 内容运营</p><h3>媒体素材门店装修与广告投放</h3></div>
<div class="toolbar-actions">
<el-select v-model="selectedStoreId" placeholder="选择门店" filterable @change="loadScopedData">
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
<el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button>
</div>
</header>
<el-tabs v-model="activeTab" class="content-tabs">
<el-tab-pane label="媒体素材" name="assets">
<div class="content-tab-actions">
<p>图片会压缩并校验格式门店素材仅能用于当前门店广告</p>
<div class="toolbar-actions">
<el-select v-model="uploadScope" class="compact-select">
<el-option label="当前门店" value="STORE" />
<el-option label="租户公共" value="TENANT" />
</el-select>
<label class="file-button" :class="{ disabled: uploading }">
<Upload :size="16" />{{ uploading ? '上传中…' : '上传图片' }}
<input type="file" accept="image/jpeg,image/png,image/webp" :disabled="uploading" @change="uploadImage" />
</label>
</div>
</div>
<div v-loading="loading" class="asset-gallery">
<article v-for="asset in assets" :key="asset.id" class="asset-card">
<el-image :src="asset.url" fit="cover" :preview-src-list="[asset.url]" preview-teleported />
<div><strong>#{{ asset.id }}</strong><el-tag size="small" :type="asset.storeId ? 'info' : 'success'">{{ asset.storeId ? '门店素材' : '租户公共' }}</el-tag></div>
<p>{{ asset.width }} × {{ asset.height }} · {{ fileSize(asset.byteSize) }}</p>
</article>
<el-empty v-if="!loading && assets.length === 0" description="当前范围暂无素材" />
</div>
</el-tab-pane>
<el-tab-pane label="门店装修" name="decorations">
<div class="content-tab-actions">
<p>每次保存生成不可变草稿版本发布后自动归档上一版</p>
<el-button type="primary" :icon="Plus" :disabled="!selectedStoreId" @click="openDecoration">新建草稿</el-button>
</div>
<el-table v-loading="loading" :data="decorations" row-key="id" class="data-table">
<el-table-column label="版本" width="100"><template #default="{ row }"><strong>V{{ row.version }}</strong></template></el-table-column>
<el-table-column label="模板" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.templateCode }}</strong><span>Schema {{ row.schemaVersion }}</span></div></template></el-table-column>
<el-table-column label="组件" width="100"><template #default="{ row }">{{ row.content.components.length }}</template></el-table-column>
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="decorationTag(row.status)">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="创建时间" min-width="160"><template #default="{ row }">{{ shortDate(row.createdAt) }}</template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button size="small" @click="inspectDecoration = row">查看</el-button><el-button v-if="row.status !== 'PUBLISHED'" size="small" type="primary" :loading="publishingId === row.id" @click="publish(row)">发布</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="广告投放" name="advertisements">
<div class="content-tab-actions">
<p>按平台租户或门店范围投放支持排期排序与停用</p>
<el-button type="primary" :icon="Plus" @click="openAdvertisement()">新建广告</el-button>
</div>
<el-table v-loading="loading" :data="advertisements" row-key="id" class="data-table">
<el-table-column label="广告" min-width="240"><template #default="{ row }"><div class="ad-title"><el-image :src="row.imageUrl" fit="cover" /><div class="stack"><strong>{{ row.title }}</strong><span>#{{ row.id }} · {{ row.targetType }}</span></div></div></template></el-table-column>
<el-table-column label="范围" width="140"><template #default="{ row }"><div class="stack"><el-tag>{{ row.scopeType }}</el-tag><span>{{ row.storeId ? storeName(row.storeId) : '全部' }}</span></div></template></el-table-column>
<el-table-column label="排期" min-width="210"><template #default="{ row }"><div class="stack"><span>{{ row.startsAt ? shortDate(row.startsAt) : '立即开始' }}</span><span> {{ row.endsAt ? shortDate(row.endsAt) : '长期' }}</span></div></template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : row.status === 'DRAFT' ? 'warning' : 'info'">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Pencil" @click="openAdvertisement(row)">编辑</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="decorationDialog" title="新建装修草稿" width="min(820px, 95vw)">
<el-form label-position="top" class="decoration-form">
<el-form-item label="模板代码"><el-input v-model="decorationForm.templateCode" /></el-form-item>
<el-form-item label="Schema 版本"><el-input-number v-model="decorationForm.schemaVersion" :min="1" :max="100" /></el-form-item>
</el-form>
<div class="component-editor">
<header><strong>页面组件按顺序渲染</strong><el-button size="small" :icon="Plus" @click="addComponent">添加组件</el-button></header>
<article v-for="(component, index) in decorationForm.components" :key="component.key">
<el-select v-model="component.type"><el-option v-for="type in componentTypes" :key="type" :label="type" :value="type" /></el-select>
<el-input v-model="component.propsText" type="textarea" :rows="3" placeholder='组件属性 JSON,例如 {"title":"欢迎光临"}' />
<el-button text type="danger" :icon="Trash2" @click="decorationForm.components.splice(index, 1)">移除</el-button>
</article>
<el-empty v-if="decorationForm.components.length === 0" description="尚未添加组件" :image-size="70" />
</div>
<template #footer><el-button @click="decorationDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="createDecoration">保存草稿</el-button></template>
</el-dialog>
<el-drawer v-model="decorationDrawer" title="装修版本内容" size="min(560px, 94vw)">
<template v-if="inspectDecoration"><el-descriptions :column="1" border><el-descriptions-item label="版本">V{{ inspectDecoration.version }}</el-descriptions-item><el-descriptions-item label="模板">{{ inspectDecoration.templateCode }}</el-descriptions-item><el-descriptions-item label="状态">{{ inspectDecoration.status }}</el-descriptions-item></el-descriptions><pre class="json-preview">{{ JSON.stringify(inspectDecoration.content, null, 2) }}</pre></template>
</el-drawer>
<el-dialog v-model="advertisementDialog" :title="advertisementForm.id ? '编辑广告' : '新建广告'" width="min(760px, 95vw)">
<el-form label-position="top" class="asset-form">
<el-form-item label="广告标题"><el-input v-model="advertisementForm.title" maxlength="128" show-word-limit /></el-form-item>
<el-form-item label="投放范围"><el-select v-model="advertisementForm.scopeType" @change="syncAdvertisementScope"><el-option label="平台" value="PLATFORM" /><el-option label="租户" value="TENANT" /><el-option label="门店" value="STORE" /></el-select></el-form-item>
<el-form-item v-if="advertisementForm.scopeType === 'STORE'" label="投放门店"><el-select v-model="advertisementForm.storeId" filterable><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="advertisementForm.imageAssetId" filterable><el-option v-for="asset in advertisementAssets" :key="asset.id" :label="`#${asset.id} · ${asset.width}×${asset.height}${asset.storeId ? ' · 门店' : ' · 公共'}`" :value="asset.id"><span class="asset-option"><img :src="asset.url" alt="" />#{{ asset.id }} · {{ asset.width }}×{{ asset.height }}</span></el-option></el-select></el-form-item>
<el-form-item label="跳转类型"><el-select v-model="advertisementForm.targetType"><el-option label="不跳转" value="NONE" /><el-option label="小程序页面" value="PAGE" /><el-option label="外部链接" value="URL" /></el-select></el-form-item>
<el-form-item label="跳转目标"><el-input v-model="advertisementForm.targetValue" :disabled="advertisementForm.targetType === 'NONE'" placeholder="页面路径或 HTTPS URL" /></el-form-item>
<el-form-item label="开始时间"><el-date-picker v-model="advertisementForm.startsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" clearable /></el-form-item>
<el-form-item label="结束时间"><el-date-picker v-model="advertisementForm.endsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" clearable /></el-form-item>
<el-form-item label="状态"><el-select v-model="advertisementForm.status"><el-option label="草稿" value="DRAFT" /><el-option label="生效" value="ACTIVE" /><el-option label="停用" value="INACTIVE" /></el-select></el-form-item>
<el-form-item label="排序"><el-input-number v-model="advertisementForm.sortOrder" :min="-100000" :max="100000" /></el-form-item>
</el-form>
<template #footer><el-button @click="advertisementDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="persistAdvertisement">保存</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Pencil, Plus, RefreshCw, Trash2, Upload } from '@lucide/vue';
import {
ApiError, listAdvertisements, listDecorations, listManagedStores, listMediaAssets,
publishDecoration, saveAdvertisement, saveDecoration, updateAdvertisement,
uploadMediaImage, type ApiSession
} from '../api';
import { shortDate } from '../format';
import type {
Advertisement, AdvertisementInput, DecorationComponent, DecorationComponentType,
DecorationVersion, ManagedStore, MediaAsset
} from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]); const assets = ref<MediaAsset[]>([]); const decorations = ref<DecorationVersion[]>([]); const advertisements = ref<Advertisement[]>([]);
const selectedStoreId = ref(''); const activeTab = ref('assets'); const uploadScope = ref<'STORE' | 'TENANT'>('STORE'); const loading = ref(false); const uploading = ref(false); const saving = ref(false); const publishingId = ref(''); const lastError = ref('');
const decorationDialog = ref(false); const advertisementDialog = ref(false); const inspectDecoration = ref<DecorationVersion | null>(null);
const decorationDrawer = computed({ get: () => Boolean(inspectDecoration.value), set: (value) => { if (!value) inspectDecoration.value = null; } });
const componentTypes: DecorationComponentType[] = ['HERO', 'NOTICE', 'GALLERY', 'CONTACT', 'ROOM_LIST']; let componentKey = 0;
const decorationForm = reactive({ templateCode: 'STANDARD', schemaVersion: 1, components: [] as Array<{ key: number; type: DecorationComponentType; propsText: string }> });
const advertisementForm = reactive({ id: '', scopeType: 'STORE' as AdvertisementInput['scopeType'], storeId: '', title: '', imageAssetId: '', targetType: 'NONE' as AdvertisementInput['targetType'], targetValue: '', startsAt: '', endsAt: '', status: 'DRAFT' as AdvertisementInput['status'], sortOrder: 0 });
const publishedDecorationCount = computed(() => decorations.value.filter((item) => item.status === 'PUBLISHED').length);
const activeAdvertisementCount = computed(() => advertisements.value.filter((item) => item.status === 'ACTIVE').length);
const advertisementAssets = computed(() => assets.value.filter((asset) => advertisementForm.scopeType === 'STORE' ? !asset.storeId || asset.storeId === advertisementForm.storeId : !asset.storeId));
function errorMessage(error: unknown) { return error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; }
async function loadData() { if (!props.session.token) return; loading.value = true; lastError.value = ''; try { const [storeRows, adRows] = await Promise.all([listManagedStores(props.session), listAdvertisements(props.session)]); stores.value = storeRows; advertisements.value = adRows; if (!selectedStoreId.value || !stores.value.some((item) => item.id === selectedStoreId.value)) selectedStoreId.value = stores.value[0]?.id || ''; await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { loading.value = false; } }
async function loadScopedData() { if (!props.session.token) return; loading.value = true; lastError.value = ''; try { const [assetRows, decorationRows] = await Promise.all([listMediaAssets(props.session, selectedStoreId.value || undefined), selectedStoreId.value ? listDecorations(props.session, selectedStoreId.value) : Promise.resolve([])]); assets.value = assetRows; decorations.value = decorationRows; } catch (error) { lastError.value = errorMessage(error); } finally { loading.value = false; } }
async function uploadImage(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file) return; if (uploadScope.value === 'STORE' && !selectedStoreId.value) return ElMessage.warning('请先选择素材所属门店'); if (file.size > 8 * 1024 * 1024) return ElMessage.warning('图片不得超过 8 MB'); uploading.value = true; lastError.value = ''; try { await uploadMediaImage(props.session, file, uploadScope.value === 'STORE' ? selectedStoreId.value : undefined); ElMessage.success('素材上传成功'); await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { uploading.value = false; } }
function addComponent() { decorationForm.components.push({ key: ++componentKey, type: 'HERO', propsText: '{}' }); }
function openDecoration() { decorationForm.templateCode = 'STANDARD'; decorationForm.schemaVersion = 1; decorationForm.components.splice(0); addComponent(); decorationDialog.value = true; }
async function createDecoration() { if (!selectedStoreId.value || !decorationForm.templateCode.trim()) return ElMessage.warning('请选择门店并填写模板代码'); const components: DecorationComponent[] = []; try { for (const item of decorationForm.components) { const parsed = JSON.parse(item.propsText || '{}'); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('组件属性必须是 JSON 对象'); components.push({ type: item.type, props: parsed }); } } catch (error) { return ElMessage.warning(error instanceof Error ? error.message : '组件属性 JSON 无效'); } saving.value = true; try { const result = await saveDecoration(props.session, { storeId: selectedStoreId.value, templateCode: decorationForm.templateCode.trim(), schemaVersion: decorationForm.schemaVersion, content: { components } }); decorationDialog.value = false; ElMessage.success(`装修草稿 V${result.version} 已保存`); await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { saving.value = false; } }
async function publish(row: DecorationVersion) { publishingId.value = row.id; try { await publishDecoration(props.session, row.id, row.storeId); ElMessage.success(`装修 V${row.version} 已发布`); await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { publishingId.value = ''; } }
function openAdvertisement(row?: Advertisement) { if (row?.storeId && row.storeId !== selectedStoreId.value) { selectedStoreId.value = row.storeId; void loadScopedData(); } Object.assign(advertisementForm, row ? { id: row.id, scopeType: row.scopeType, storeId: row.storeId || '', title: row.title, imageAssetId: row.imageAssetId, targetType: row.targetType, targetValue: row.targetValue, startsAt: row.startsAt ? row.startsAt.slice(0, 19).replace('T', ' ') : '', endsAt: row.endsAt ? row.endsAt.slice(0, 19).replace('T', ' ') : '', status: row.status, sortOrder: row.sortOrder } : { id: '', scopeType: 'STORE', storeId: selectedStoreId.value, title: '', imageAssetId: '', targetType: 'NONE', targetValue: '', startsAt: '', endsAt: '', status: 'DRAFT', sortOrder: 0 }); advertisementDialog.value = true; }
function syncAdvertisementScope() { if (advertisementForm.scopeType === 'STORE') advertisementForm.storeId ||= selectedStoreId.value; else advertisementForm.storeId = ''; advertisementForm.imageAssetId = ''; }
function advertisementInput(): AdvertisementInput | null { if (!advertisementForm.title.trim() || !advertisementForm.imageAssetId) { ElMessage.warning('请填写广告标题并选择图片'); return null; } if (advertisementForm.scopeType === 'STORE' && !advertisementForm.storeId) { ElMessage.warning('请选择投放门店'); return null; } if (advertisementForm.targetType !== 'NONE' && !advertisementForm.targetValue.trim()) { ElMessage.warning('请填写跳转目标'); return null; } if (advertisementForm.startsAt && advertisementForm.endsAt && advertisementForm.endsAt <= advertisementForm.startsAt) { ElMessage.warning('结束时间必须晚于开始时间'); return null; } return { scopeType: advertisementForm.scopeType, storeId: advertisementForm.scopeType === 'STORE' ? advertisementForm.storeId : null, title: advertisementForm.title.trim(), imageAssetId: advertisementForm.imageAssetId, targetType: advertisementForm.targetType, targetValue: advertisementForm.targetType === 'NONE' ? '' : advertisementForm.targetValue.trim(), startsAt: advertisementForm.startsAt || null, endsAt: advertisementForm.endsAt || null, status: advertisementForm.status, sortOrder: advertisementForm.sortOrder }; }
async function persistAdvertisement() { const input = advertisementInput(); if (!input) return; saving.value = true; try { if (advertisementForm.id) await updateAdvertisement(props.session, advertisementForm.id, input); else await saveAdvertisement(props.session, input); advertisementDialog.value = false; ElMessage.success('广告已保存'); advertisements.value = await listAdvertisements(props.session); } catch (error) { lastError.value = errorMessage(error); } finally { saving.value = false; } }
function storeName(id: string) { return stores.value.find((item) => item.id === id)?.name || `门店 #${id}`; }
function fileSize(bytes: number) { return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`; }
function decorationTag(status: string) { return status === 'PUBLISHED' ? 'success' : status === 'DRAFT' ? 'warning' : 'info'; }
watch(() => props.session.token, (token) => { if (token) void loadData(); }, { immediate: true });
</script>
+227
View File
@@ -6,6 +6,233 @@
"Segoe UI", sans-serif;
}
.content-page {
display: grid;
gap: 16px;
}
.content-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.content-metrics > span {
display: grid;
gap: 6px;
padding: 16px 18px;
border: 1px solid #dbe3ec;
border-radius: 14px;
background: #fff;
box-shadow: 0 8px 24px rgba(31, 49, 72, .06);
}
.content-metrics small,
.content-tab-actions p,
.asset-card p {
color: #66758a;
}
.content-metrics strong {
font-size: 26px;
}
.content-toolbar .el-select {
width: 220px;
}
.content-tabs {
padding: 0 18px 18px;
}
.content-tab-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.content-tab-actions p {
margin: 0;
font-size: 13px;
}
.compact-select {
width: 120px;
}
.file-button {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 32px;
padding: 0 14px;
color: #fff;
border-radius: 6px;
background: #3370d4;
cursor: pointer;
font-size: 14px;
}
.file-button input {
display: none;
}
.file-button.disabled {
cursor: wait;
opacity: .6;
}
.asset-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
min-height: 180px;
}
.asset-gallery > .el-empty {
grid-column: 1 / -1;
}
.asset-card {
overflow: hidden;
border: 1px solid #dbe3ec;
border-radius: 12px;
background: #fff;
}
.asset-card > .el-image {
width: 100%;
height: 130px;
background: #f5f7fa;
}
.asset-card > div,
.asset-card > p {
margin: 0;
padding: 9px 11px 0;
}
.asset-card > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.asset-card > p {
padding-bottom: 11px;
font-size: 12px;
}
.ad-title {
display: flex;
align-items: center;
gap: 10px;
}
.ad-title > .el-image {
flex: 0 0 76px;
width: 76px;
height: 48px;
border-radius: 7px;
background: #f5f7fa;
}
.decoration-form {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 14px;
}
.component-editor {
display: grid;
gap: 12px;
}
.component-editor > header {
display: flex;
align-items: center;
justify-content: space-between;
}
.component-editor > article {
display: grid;
grid-template-columns: 150px minmax(0, 1fr) auto;
align-items: start;
gap: 10px;
padding: 12px;
border: 1px solid #dbe3ec;
border-radius: 10px;
background: #f5f7fa;
}
.json-preview {
overflow: auto;
margin-top: 16px;
padding: 14px;
border-radius: 10px;
background: #14202b;
color: #d9e7ef;
font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap;
}
.asset-option {
display: inline-flex;
align-items: center;
gap: 8px;
}
.asset-option img {
width: 40px;
height: 26px;
border-radius: 4px;
object-fit: cover;
}
@media (max-width: 980px) {
.content-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.component-editor > article {
grid-template-columns: 130px minmax(0, 1fr);
}
.component-editor > article > .el-button {
grid-column: 2;
justify-self: end;
}
}
@media (max-width: 560px) {
.content-metrics,
.decoration-form {
grid-template-columns: 1fr;
}
.content-tab-actions,
.content-tab-actions .toolbar-actions {
align-items: stretch;
flex-direction: column;
}
.content-toolbar .el-select,
.compact-select {
width: 100%;
}
.component-editor > article {
grid-template-columns: 1fr;
}
.component-editor > article > .el-button {
grid-column: auto;
}
}
* {
box-sizing: border-box;
}
+48
View File
@@ -470,6 +470,54 @@ export interface TenantApplicationConfig {
isDefault: boolean;
}
export interface MediaAsset {
id: string;
storeId: string | null;
url: string;
mimeType: string;
byteSize: number;
width: number;
height: number;
createdAt: string;
}
export type DecorationComponentType = 'HERO' | 'NOTICE' | 'GALLERY' | 'CONTACT' | 'ROOM_LIST';
export interface DecorationComponent {
type: DecorationComponentType;
props: Record<string, unknown>;
}
export interface DecorationVersion {
id: string;
storeId: string;
templateCode: string;
schemaVersion: number;
content: { components: DecorationComponent[] };
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
version: number;
publishedAt: string | null;
createdAt: string;
}
export interface AdvertisementInput {
scopeType: 'PLATFORM' | 'TENANT' | 'STORE';
storeId: string | null;
title: string;
imageAssetId: string;
targetType: 'NONE' | 'PAGE' | 'URL';
targetValue: string;
startsAt: string | null;
endsAt: string | null;
status: 'DRAFT' | 'ACTIVE' | 'INACTIVE';
sortOrder: number;
}
export interface Advertisement extends AdvertisementInput {
id: string;
imageUrl: string;
}
export interface CleaningTask {
id: string;
taskNo: string;
+160 -6
View File
@@ -31,11 +31,27 @@ export interface AdvertisementInput {
interface IdRow extends RowDataPacket { id: string }
interface VersionRow extends RowDataPacket { nextVersion: number }
interface ContentRow extends RowDataPacket {
id: string; scopeType: string; storeId: string | null; title: string; imageUrl: string;
id: string; scopeType: string; storeId: string | null; title: string;
imageAssetId: string; imageUrl: string;
targetType: string; targetValue: string; startsAt: Date | null; endsAt: Date | null;
status: string; sortOrder: number;
}
interface AssetRow extends RowDataPacket {
id: string; storeId: string | null; url: string; mimeType: string; byteSize: number;
width: number; height: number; createdAt: Date;
}
interface DecorationRow extends RowDataPacket {
id: string; storeId: string; templateCode: string; schemaVersion: number;
content: string | Record<string, unknown>; status: string; version: number;
publishedAt: Date | null; createdAt: Date;
}
interface AdvertisementScopeRow extends RowDataPacket {
scopeType: AdvertisementInput['scopeType']; storeId: string | null;
}
export class ContentError extends Error {
constructor(public readonly code: string) { super(code); }
}
@@ -44,7 +60,7 @@ export class ContentRepository {
constructor(private readonly pool: MySqlPool) {}
async registerAsset(actor: ManagementActor, storeId: string | undefined, image: StoredImage) {
if (storeId) this.assertStoreScope(actor, storeId);
this.assertAssetWriteScope(actor, storeId);
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_media_assets
@@ -61,6 +77,24 @@ export class ContentRepository {
});
}
async listAssets(actor: ManagementActor, storeId?: string) {
if (storeId) this.assertStoreScope(actor, storeId);
const scope = this.assetReadScope(actor, storeId);
const [rows] = await this.pool.execute<AssetRow[]>(
`SELECT id, store_id AS storeId, public_url AS url, mime_type AS mimeType,
byte_size AS byteSize, width, height, created_at AS createdAt
FROM qipai_media_assets
WHERE tenant_id = ? AND deleted_at IS NULL AND ${scope.sql}
ORDER BY id DESC LIMIT 200`,
[actor.tenantId, ...scope.params]
);
return rows.map((row) => ({
...row,
id: String(row.id),
storeId: row.storeId === null ? null : String(row.storeId)
}));
}
async saveDecoration(actor: ManagementActor, input: DecorationInput) {
this.assertStoreScope(actor, input.storeId);
return this.transaction(async (connection) => {
@@ -85,6 +119,25 @@ export class ContentRepository {
});
}
async listDecorations(actor: ManagementActor, storeId: string) {
this.assertStoreScope(actor, storeId);
const [rows] = await this.pool.execute<DecorationRow[]>(
`SELECT id, store_id AS storeId, template_code AS templateCode,
schema_version AS schemaVersion, content_json AS content,
status, version, published_at AS publishedAt, created_at AS createdAt
FROM qipai_store_decorations
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL
ORDER BY version DESC LIMIT 100`,
[actor.tenantId, storeId]
);
return rows.map((row) => ({
...row,
id: String(row.id),
storeId: String(row.storeId),
content: parseJson(row.content)
}));
}
async publishDecoration(actor: ManagementActor, decorationId: string, storeId: string) {
this.assertStoreScope(actor, storeId);
return this.transaction(async (connection) => {
@@ -114,7 +167,8 @@ export class ContentRepository {
const scope = this.adScope(actor);
const [rows] = await this.pool.execute<ContentRow[]>(
`SELECT a.id, a.scope_type AS scopeType, a.store_id AS storeId, a.title,
m.public_url AS imageUrl, a.target_type AS targetType,
a.image_asset_id AS imageAssetId, m.public_url AS imageUrl,
a.target_type AS targetType,
a.target_value AS targetValue, a.starts_at AS startsAt, a.ends_at AS endsAt,
a.status, a.sort_order AS sortOrder
FROM qipai_advertisements a
@@ -123,11 +177,17 @@ export class ContentRepository {
ORDER BY a.sort_order, a.id DESC`,
[actor.tenantId, ...scope.params]
);
return rows.map((row) => ({ ...row, id: String(row.id), storeId: row.storeId && String(row.storeId) }));
return rows.map((row) => ({
...row,
id: String(row.id),
imageAssetId: String(row.imageAssetId),
storeId: row.storeId === null ? null : String(row.storeId)
}));
}
async saveAdvertisement(actor: ManagementActor, input: AdvertisementInput) {
this.assertAdScope(actor, input);
const assetScope = this.adAssetScope(input);
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_advertisements
@@ -135,10 +195,12 @@ export class ContentRepository {
target_value, starts_at, ends_at, status, sort_order, created_by)
SELECT ?, ?, ?, ?, m.id, ?, ?, ?, ?, ?, ?, ?
FROM qipai_media_assets m
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL`,
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL
AND ${assetScope.sql}`,
[actor.tenantId, input.scopeType, input.storeId ?? null, input.title,
input.targetType, input.targetValue, input.startsAt ?? null, input.endsAt ?? null,
input.status, input.sortOrder, actor.userId, actor.tenantId, input.imageAssetId]
input.status, input.sortOrder, actor.userId, actor.tenantId, input.imageAssetId,
...assetScope.params]
);
if (result.affectedRows !== 1) throw new ContentError('IMAGE_ASSET_NOT_FOUND');
const advertisementId = String(result.insertId);
@@ -147,6 +209,45 @@ export class ContentRepository {
});
}
async updateAdvertisement(
actor: ManagementActor, advertisementId: string, input: AdvertisementInput
) {
this.assertAdScope(actor, input);
const assetScope = this.adAssetScope(input);
return this.transaction(async (connection) => {
const [advertisements] = await connection.execute<AdvertisementScopeRow[]>(
`SELECT scope_type AS scopeType, store_id AS storeId
FROM qipai_advertisements
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
[actor.tenantId, advertisementId]
);
const existing = advertisements[0];
if (!existing) throw new ContentError('ADVERTISEMENT_NOT_FOUND');
this.assertExistingAdScope(actor, existing);
const [assets] = await connection.execute<IdRow[]>(
`SELECT m.id FROM qipai_media_assets m
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL
AND ${assetScope.sql}`,
[actor.tenantId, input.imageAssetId, ...assetScope.params]
);
if (!assets[0]) throw new ContentError('IMAGE_ASSET_NOT_FOUND');
await connection.execute(
`UPDATE qipai_advertisements
SET scope_type = ?, store_id = ?, title = ?, image_asset_id = ?,
target_type = ?, target_value = ?, starts_at = ?, ends_at = ?,
status = ?, sort_order = ?
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.scopeType, input.storeId ?? null, input.title, input.imageAssetId,
input.targetType, input.targetValue, input.startsAt ?? null, input.endsAt ?? null,
input.status, input.sortOrder, actor.tenantId, advertisementId]
);
await this.audit(connection, actor, 'ADVERTISEMENT_UPDATED', 'ADVERTISEMENT', advertisementId);
return { advertisementId };
});
}
private assertAdScope(actor: ManagementActor, input: AdvertisementInput) {
const tenantManager = actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN');
@@ -160,6 +261,26 @@ export class ContentRepository {
}
}
private assertExistingAdScope(actor: ManagementActor, advertisement: AdvertisementScopeRow) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
if (advertisement.scopeType !== 'STORE' || !advertisement.storeId
|| !actor.access.storeIds.includes(String(advertisement.storeId))) {
throw new ContentError('ADVERTISEMENT_SCOPE_FORBIDDEN');
}
}
private assertAssetWriteScope(actor: ManagementActor, storeId?: string) {
if (storeId) {
this.assertStoreScope(actor, storeId);
return;
}
if (!actor.access.capabilities.includes('tenant.manage')
&& !actor.access.roles.includes('PLATFORM_ADMIN')) {
throw new ContentError('GLOBAL_ASSET_FORBIDDEN');
}
}
private assertStoreScope(actor: ManagementActor, storeId: string) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
@@ -180,6 +301,30 @@ export class ContentRepository {
};
}
private assetReadScope(actor: ManagementActor, storeId?: string) {
if (storeId) {
return { sql: '(store_id IS NULL OR store_id = ?)', params: [storeId] };
}
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) {
return { sql: '1 = 1', params: [] as string[] };
}
if (actor.access.storeIds.length === 0) {
return { sql: 'store_id IS NULL', params: [] as string[] };
}
return {
sql: `(store_id IS NULL OR store_id IN (${actor.access.storeIds.map(() => '?').join(',')}))`,
params: actor.access.storeIds
};
}
private adAssetScope(input: AdvertisementInput) {
if (input.scopeType === 'STORE' && input.storeId) {
return { sql: '(m.store_id IS NULL OR m.store_id = ?)', params: [input.storeId] };
}
return { sql: 'm.store_id IS NULL', params: [] as string[] };
}
private async lockStore(connection: PoolConnection, tenantId: string, storeId: string) {
const [rows] = await connection.execute<IdRow[]>(
`SELECT id FROM qipai_stores
@@ -218,3 +363,12 @@ export class ContentRepository {
}
}
}
function parseJson(value: string | Record<string, unknown>) {
if (typeof value !== 'string') return value;
try {
return JSON.parse(value) as Record<string, unknown>;
} catch {
throw new ContentError('INVALID_DECORATION_CONTENT');
}
}
+7 -3
View File
@@ -50,7 +50,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
'database/migrations/2026081001_m08c_staff_management_access.up.sql'
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
'database/migrations/2026081002_m08d_content_asset_scope.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -82,9 +83,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
'database/migrations/2026081001_m08c_staff_management_access.verify.sql'
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql'
],
down: [
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
@@ -259,7 +262,8 @@ export async function executeMigrationPlan(
2, 9, 5, 2, 1,
1, 7, 5, 1,
4, 1, 1, 1,
2, 1, 1
2, 1, 1,
1, 1, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
+39 -2
View File
@@ -9,6 +9,9 @@ import { MediaStorage, MediaValidationError } from '../content/media-storage.js'
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
const storeQuerySchema = z.object({ storeId: z.string().regex(/^[1-9]\d{0,19}$/) });
const optionalStoreQuerySchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const componentSchema = z.object({
type: z.enum(['HERO', 'NOTICE', 'GALLERY', 'CONTACT', 'ROOM_LIST']),
props: z.record(z.unknown())
@@ -34,8 +37,9 @@ const adSchema = z.object({
export interface ContentRouteOptions {
repository: Pick<ContentRepository,
'registerAsset' | 'saveDecoration' | 'publishDecoration'
| 'listAdvertisements' | 'saveAdvertisement'>;
'registerAsset' | 'listAssets' | 'saveDecoration' | 'listDecorations'
| 'publishDecoration' | 'listAdvertisements' | 'saveAdvertisement'
| 'updateAdvertisement'>;
mediaStorage: MediaStorage;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
@@ -50,6 +54,16 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
(_request, body, done) => done(null, body)
);
}
app.get('/admin-api/media/images', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const query = optionalStoreQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listAssets(actor, query.data.storeId),
traceId: request.traceId
}));
});
app.post('/admin-api/media/images', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
if (!actor) return;
@@ -72,6 +86,16 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
});
});
});
app.get('/admin-api/decorations', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const query = storeQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listDecorations(actor, query.data.storeId),
traceId: request.traceId
}));
});
app.post('/admin-api/decorations', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const body = decorationSchema.safeParse(request.body);
@@ -109,6 +133,19 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
traceId: request.traceId
}));
});
app.put('/admin-api/advertisements/:id', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const params = idSchema.safeParse(request.params);
const body = adSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateAdvertisement(actor, params.data.id, body.data),
traceId: request.traceId
}));
});
}
async function requireContentManager(
+64 -1
View File
@@ -3,6 +3,8 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import sharp from 'sharp';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
import { MediaStorage, MediaValidationError } from '../dist/content/media-storage.js';
import { ContentError, ContentRepository } from '../dist/content/content-repository.js';
@@ -47,4 +49,65 @@ await assert.rejects(
(error) => error instanceof ContentError && error.code === 'PLATFORM_AD_FORBIDDEN'
);
console.log('PASS: M03-B image compression, tenant paths and advertisement scope validation are present.');
await assert.rejects(
() => repository.registerAsset(storeActor, undefined, {
storagePath: 'asset.webp', publicUrl: '/asset.webp', mimeType: 'image/webp',
byteSize: 100, width: 10, height: 10, checksumSha256: '0'.repeat(64)
}),
(error) => error instanceof ContentError && error.code === 'GLOBAL_ASSET_FORBIDDEN'
);
const secret = 'test-only-content-management-secret-32';
const token = signAccessToken({
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1
}, secret, 900);
let listedAssetStoreId;
let listedDecorationStoreId;
let updatedAdvertisement;
const app = await buildApp({
content: {
jwtSecret: secret,
authRepository: {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1,
nickname: '', avatarUrl: '', phone: '' }
};
}
},
accessControl: { async getAccessProfile() { return storeActor.access; } },
mediaStorage: { async storeImage() { throw new Error('not used'); } },
repository: {
async listAssets(_actor, storeId) { listedAssetStoreId = storeId; return [{ id: '31' }]; },
async listDecorations(_actor, storeId) { listedDecorationStoreId = storeId; return [{ id: '41' }]; },
async updateAdvertisement(_actor, id, input) { updatedAdvertisement = { id, input }; return { advertisementId: id }; },
async listAdvertisements() { return []; },
async registerAsset() {}, async saveDecoration() {}, async publishDecoration() {},
async saveAdvertisement() {}
}
}
});
const authorization = { authorization: `Bearer ${token}` };
const assets = await app.inject({ method: 'GET', url: '/admin-api/media/images?storeId=11', headers: authorization });
assert.equal(assets.statusCode, 200);
assert.equal(listedAssetStoreId, '11');
const decorations = await app.inject({ method: 'GET', url: '/admin-api/decorations?storeId=11', headers: authorization });
assert.equal(decorations.statusCode, 200);
assert.equal(listedDecorationStoreId, '11');
const updated = await app.inject({
method: 'PUT', url: '/admin-api/advertisements/51', headers: authorization,
payload: {
scopeType: 'STORE', storeId: '11', title: '门店轮播', imageAssetId: '31',
targetType: 'PAGE', targetValue: '/pages/booking/index', status: 'ACTIVE', sortOrder: 10
}
});
assert.equal(updated.statusCode, 200);
assert.equal(updatedAdvertisement.id, '51');
assert.equal(updatedAdvertisement.input.storeId, '11');
const missingStore = await app.inject({ method: 'GET', url: '/admin-api/decorations', headers: authorization });
assert.equal(missingStore.statusCode, 400);
await app.close();
console.log('PASS: content media, decoration and advertisement management contracts are scoped and editable.');
+11 -1
View File
@@ -99,6 +99,9 @@ const cleaningTransferStateVerifySql = read('database/migrations/2026062729_m08b
const staffManagementUpSql = read('database/migrations/2026081001_m08c_staff_management_access.up.sql');
const staffManagementDownSql = read('database/migrations/2026081001_m08c_staff_management_access.down.sql');
const staffManagementVerifySql = read('database/migrations/2026081001_m08c_staff_management_access.verify.sql');
const contentAssetScopeUpSql = read('database/migrations/2026081002_m08d_content_asset_scope.up.sql');
const contentAssetScopeDownSql = read('database/migrations/2026081002_m08d_content_asset_scope.down.sql');
const contentAssetScopeVerifySql = read('database/migrations/2026081002_m08d_content_asset_scope.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -463,4 +466,11 @@ assert.match(staffManagementDownSql, /DELETE rp FROM qipai_role_permissions/);
assert.match(staffManagementVerifySql, /fully_granted_staff_roles/);
assert.match(staffManagementVerifySql, /HAVING COUNT\(DISTINCT p\.code\) = 2/);
console.log('PASS: M01-B through M08-C migration contracts are present.');
assert.match(contentAssetScopeUpSql, /scope_store_id BIGINT UNSIGNED/);
assert.match(contentAssetScopeUpSql, /uq_qipai_media_scope_checksum/);
assert.match(contentAssetScopeUpSql, /'2026081002'/);
assert.match(contentAssetScopeDownSql, /SET a\.image_asset_id = duplicate_group\.retained_id/);
assert.match(contentAssetScopeDownSql, /uq_qipai_media_tenant_checksum/);
assert.match(contentAssetScopeVerifySql, /generation_expression/);
console.log('PASS: M01-B through M08-D migration contracts are present.');
+3 -1
View File
@@ -40,13 +40,15 @@ assert.match(plan.file, /2026062626_m08b_cleaning_settlements\.up\.sql/);
assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql/);
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql$/);
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
const verifyPlan = await loadMigrationPlan('verify');
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081002_m08d_content_asset_scope\.verify\.sql$/);
const calls = [];
const fakePool = {
@@ -129,13 +129,13 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220']
'2026062220', '2026081002']
);
return rows;
}
@@ -1684,6 +1684,24 @@ async function assertContentManagement(pool, context) {
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
checksumSha256: 'a'.repeat(64)
});
const duplicateAsset = await repository.registerAsset(actor, storeId, {
storagePath: `tenants/${context.tenantId}/stores/${storeId}/duplicate.webp`,
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/duplicate.webp`,
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
checksumSha256: 'a'.repeat(64)
});
assert.equal(duplicateAsset.assetId, asset.assetId);
const globalAsset = await repository.registerAsset(actor, undefined, {
storagePath: `tenants/${context.tenantId}/global/sanitized.webp`,
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/global/sanitized.webp`,
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
checksumSha256: 'a'.repeat(64)
});
assert.notEqual(globalAsset.assetId, asset.assetId);
assert.deepEqual(
(await repository.listAssets(actor, storeId)).map((item) => item.id).sort(),
[asset.assetId, globalAsset.assetId].sort()
);
const draft1 = await repository.saveDecoration(actor, {
storeId, templateCode: 'classic', schemaVersion: 1,
content: { components: [{ type: 'HERO', props: { assetId: asset.assetId } }] }
@@ -1705,13 +1723,22 @@ async function assertContentManagement(pool, context) {
{ version: 1, status: 'ARCHIVED' },
{ version: 2, status: 'PUBLISHED' }
]);
assert.equal((await repository.listDecorations(actor, storeId)).length, 2);
const ad = await repository.saveAdvertisement(actor, {
scopeType: 'STORE', storeId, title: 'Store banner', imageAssetId: asset.assetId,
targetType: 'PAGE', targetValue: '/pages/index/index',
startsAt: null, endsAt: null, status: 'ACTIVE', sortOrder: 1
});
assert.match(ad.advertisementId, /^[1-9]\d*$/);
assert.equal((await repository.listAdvertisements(actor))[0].scopeType, 'STORE');
await repository.updateAdvertisement(actor, ad.advertisementId, {
scopeType: 'STORE', storeId, title: 'Updated store banner', imageAssetId: asset.assetId,
targetType: 'NONE', targetValue: '', startsAt: null, endsAt: null,
status: 'INACTIVE', sortOrder: 2
});
const savedAd = (await repository.listAdvertisements(actor))[0];
assert.equal(savedAd.scopeType, 'STORE');
assert.equal(savedAd.title, 'Updated store banner');
assert.equal(savedAd.imageAssetId, asset.assetId);
await assert.rejects(
() => repository.saveAdvertisement(actor, {
scopeType: 'PLATFORM', title: 'forbidden', imageAssetId: asset.assetId,
@@ -1998,7 +2025,8 @@ try {
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' }
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -2048,7 +2076,8 @@ try {
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' }
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -5,7 +5,8 @@ ORDER BY table_name;
SELECT table_name, index_name FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND ((table_name = 'qipai_media_assets' AND index_name = 'uq_qipai_media_tenant_checksum')
AND ((table_name = 'qipai_media_assets'
AND index_name IN ('uq_qipai_media_tenant_checksum', 'uq_qipai_media_scope_checksum'))
OR (table_name = 'qipai_store_decorations' AND index_name = 'uq_qipai_decorations_version')
OR (table_name = 'qipai_advertisements' AND index_name = 'idx_qipai_ads_delivery'))
GROUP BY table_name, index_name ORDER BY table_name;
@@ -0,0 +1,31 @@
UPDATE qipai_advertisements a
INNER JOIN qipai_media_assets current_asset
ON current_asset.tenant_id = a.tenant_id AND current_asset.id = a.image_asset_id
INNER JOIN (
SELECT tenant_id, checksum_sha256, MIN(id) AS retained_id
FROM qipai_media_assets
GROUP BY tenant_id, checksum_sha256
) duplicate_group
ON duplicate_group.tenant_id = current_asset.tenant_id
AND duplicate_group.checksum_sha256 = current_asset.checksum_sha256
SET a.image_asset_id = duplicate_group.retained_id
WHERE a.image_asset_id <> duplicate_group.retained_id;
DELETE duplicate_asset
FROM qipai_media_assets duplicate_asset
INNER JOIN (
SELECT tenant_id, checksum_sha256, MIN(id) AS retained_id
FROM qipai_media_assets
GROUP BY tenant_id, checksum_sha256
) duplicate_group
ON duplicate_group.tenant_id = duplicate_asset.tenant_id
AND duplicate_group.checksum_sha256 = duplicate_asset.checksum_sha256
WHERE duplicate_asset.id <> duplicate_group.retained_id;
ALTER TABLE qipai_media_assets
DROP INDEX uq_qipai_media_scope_checksum,
DROP COLUMN scope_store_id,
ADD UNIQUE KEY uq_qipai_media_tenant_checksum (tenant_id, checksum_sha256);
DELETE FROM qipai_schema_migrations
WHERE version = '2026081002';
@@ -0,0 +1,9 @@
ALTER TABLE qipai_media_assets
DROP INDEX uq_qipai_media_tenant_checksum,
ADD COLUMN scope_store_id BIGINT UNSIGNED
GENERATED ALWAYS AS (IFNULL(store_id, 0)) STORED AFTER store_id,
ADD UNIQUE KEY uq_qipai_media_scope_checksum
(tenant_id, scope_store_id, checksum_sha256);
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026081002', 'm08d_content_asset_scope');
@@ -0,0 +1,16 @@
SELECT column_name, generation_expression, extra
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_media_assets'
AND column_name = 'scope_store_id';
SELECT index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS columns_in_order
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'qipai_media_assets'
AND index_name = 'uq_qipai_media_scope_checksum'
GROUP BY index_name;
SELECT version, name
FROM qipai_schema_migrations
WHERE version = '2026081002';
+26
View File
@@ -17,6 +17,7 @@ for (const pattern of [
"activeModule = 'people'",
"activeModule = 'devices'",
"activeModule = 'platformApps'",
"activeModule = 'content'",
'平台运营总览',
'StoresRoomsPanel',
'OrdersPanel',
@@ -25,6 +26,7 @@ for (const pattern of [
'MembersStaffPanel',
'DevicesPanel',
'PlatformAppsPanel',
'ContentManagementPanel',
'运营总览',
'savedToken',
'loadCleaningWorkspace'
@@ -185,6 +187,27 @@ const platformRoutes = read('backend/src/routes/platform-management.ts');
assert.match(platformRoutes, /'\/admin-api\/platform-apps'/);
assert.match(platformRoutes, /PLATFORM_MANAGEMENT_FORBIDDEN/);
const content = read('admin/src/components/ContentManagementPanel.vue');
for (const pattern of [
'listMediaAssets',
'uploadMediaImage',
'listDecorations',
'saveDecoration',
'publishDecoration',
'listAdvertisements',
'saveAdvertisement',
'updateAdvertisement',
'媒体素材',
'门店装修',
'广告投放'
]) {
assert.match(content, new RegExp(pattern));
}
const contentRoutes = read('backend/src/routes/content-management.ts');
assert.match(contentRoutes, /'\/admin-api\/media\/images'/);
assert.match(contentRoutes, /'\/admin-api\/decorations'/);
assert.match(contentRoutes, /'\/admin-api\/advertisements\/:id'/);
const routes = read('backend/src/routes/business-statistics.ts');
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
@@ -223,6 +246,9 @@ for (const pattern of [
'.device-metrics',
'.device-topology-grid',
'.platform-app-metrics',
'.content-metrics',
'.asset-gallery',
'.component-editor',
'@media (max-width: 980px)',
'@media (max-width: 560px)'
]) {