feat(M08-D): 补广告与装修管理
This commit is contained in:
+10
-1
@@ -11,6 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<nav class="nav-list" aria-label="后台模块">
|
<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 === '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
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: activeModule === 'overview' }"
|
:class="{ active: activeModule === 'overview' }"
|
||||||
@@ -118,6 +119,11 @@
|
|||||||
:session="session"
|
:session="session"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ContentManagementPanel
|
||||||
|
v-else-if="activeModule === 'content'"
|
||||||
|
:session="session"
|
||||||
|
/>
|
||||||
|
|
||||||
<StoresRoomsPanel
|
<StoresRoomsPanel
|
||||||
v-else-if="activeModule === 'stores'"
|
v-else-if="activeModule === 'stores'"
|
||||||
:session="session"
|
:session="session"
|
||||||
@@ -312,6 +318,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
|
Images,
|
||||||
PanelsTopLeft,
|
PanelsTopLeft,
|
||||||
RadioTower,
|
RadioTower,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@@ -336,6 +343,7 @@ import ThirdPartyPanel from './components/ThirdPartyPanel.vue';
|
|||||||
import MembersStaffPanel from './components/MembersStaffPanel.vue';
|
import MembersStaffPanel from './components/MembersStaffPanel.vue';
|
||||||
import DevicesPanel from './components/DevicesPanel.vue';
|
import DevicesPanel from './components/DevicesPanel.vue';
|
||||||
import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
|
import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
|
||||||
|
import ContentManagementPanel from './components/ContentManagementPanel.vue';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
assignCleaningTask,
|
assignCleaningTask,
|
||||||
@@ -372,7 +380,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' | '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 activeTab = ref('tasks');
|
||||||
const lastError = ref('');
|
const lastError = ref('');
|
||||||
const lastMessage = ref('');
|
const lastMessage = ref('');
|
||||||
@@ -384,6 +392,7 @@ const session = computed(() => ({ token: savedToken.value }));
|
|||||||
const activeModuleMeta = computed(() => ({
|
const activeModuleMeta = computed(() => ({
|
||||||
overview: { stage: 'M08-D', title: '平台运营总览' },
|
overview: { stage: 'M08-D', title: '平台运营总览' },
|
||||||
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
|
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
|
||||||
|
content: { stage: 'M08-D', title: '广告投放与门店装修' },
|
||||||
stores: { stage: 'M08-D', title: '门店与房间管理' },
|
stores: { stage: 'M08-D', title: '门店与房间管理' },
|
||||||
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
|
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
|
||||||
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
|
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import type {
|
import type {
|
||||||
|
Advertisement,
|
||||||
|
AdvertisementInput,
|
||||||
CleaningSettlement,
|
CleaningSettlement,
|
||||||
CleaningSettlementDetail,
|
CleaningSettlementDetail,
|
||||||
CleaningStatistics,
|
CleaningStatistics,
|
||||||
CleaningTask,
|
CleaningTask,
|
||||||
CleaningTaskEvent,
|
CleaningTaskEvent,
|
||||||
CleaningTaskMember,
|
CleaningTaskMember,
|
||||||
|
DecorationComponent,
|
||||||
|
DecorationVersion,
|
||||||
DeviceTopology,
|
DeviceTopology,
|
||||||
DeviceType,
|
DeviceType,
|
||||||
BusinessStatistics,
|
BusinessStatistics,
|
||||||
@@ -14,6 +18,7 @@ import type {
|
|||||||
ManagedOrder,
|
ManagedOrder,
|
||||||
ManagedStore,
|
ManagedStore,
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
|
MediaAsset,
|
||||||
PageResult,
|
PageResult,
|
||||||
OrderAction,
|
OrderAction,
|
||||||
OrderHistoryItem,
|
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) });
|
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(
|
export function createStaffUser(
|
||||||
session: ApiSession,
|
session: ApiSession,
|
||||||
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
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>
|
||||||
@@ -6,6 +6,233 @@
|
|||||||
"Segoe UI", sans-serif;
|
"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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -470,6 +470,54 @@ export interface TenantApplicationConfig {
|
|||||||
isDefault: boolean;
|
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 {
|
export interface CleaningTask {
|
||||||
id: string;
|
id: string;
|
||||||
taskNo: string;
|
taskNo: string;
|
||||||
|
|||||||
@@ -31,11 +31,27 @@ export interface AdvertisementInput {
|
|||||||
interface IdRow extends RowDataPacket { id: string }
|
interface IdRow extends RowDataPacket { id: string }
|
||||||
interface VersionRow extends RowDataPacket { nextVersion: number }
|
interface VersionRow extends RowDataPacket { nextVersion: number }
|
||||||
interface ContentRow extends RowDataPacket {
|
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;
|
targetType: string; targetValue: string; startsAt: Date | null; endsAt: Date | null;
|
||||||
status: string; sortOrder: number;
|
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 {
|
export class ContentError extends Error {
|
||||||
constructor(public readonly code: string) { super(code); }
|
constructor(public readonly code: string) { super(code); }
|
||||||
}
|
}
|
||||||
@@ -44,7 +60,7 @@ export class ContentRepository {
|
|||||||
constructor(private readonly pool: MySqlPool) {}
|
constructor(private readonly pool: MySqlPool) {}
|
||||||
|
|
||||||
async registerAsset(actor: ManagementActor, storeId: string | undefined, image: StoredImage) {
|
async registerAsset(actor: ManagementActor, storeId: string | undefined, image: StoredImage) {
|
||||||
if (storeId) this.assertStoreScope(actor, storeId);
|
this.assertAssetWriteScope(actor, storeId);
|
||||||
return this.transaction(async (connection) => {
|
return this.transaction(async (connection) => {
|
||||||
const [result] = await connection.execute<ResultSetHeader>(
|
const [result] = await connection.execute<ResultSetHeader>(
|
||||||
`INSERT INTO qipai_media_assets
|
`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) {
|
async saveDecoration(actor: ManagementActor, input: DecorationInput) {
|
||||||
this.assertStoreScope(actor, input.storeId);
|
this.assertStoreScope(actor, input.storeId);
|
||||||
return this.transaction(async (connection) => {
|
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) {
|
async publishDecoration(actor: ManagementActor, decorationId: string, storeId: string) {
|
||||||
this.assertStoreScope(actor, storeId);
|
this.assertStoreScope(actor, storeId);
|
||||||
return this.transaction(async (connection) => {
|
return this.transaction(async (connection) => {
|
||||||
@@ -114,7 +167,8 @@ export class ContentRepository {
|
|||||||
const scope = this.adScope(actor);
|
const scope = this.adScope(actor);
|
||||||
const [rows] = await this.pool.execute<ContentRow[]>(
|
const [rows] = await this.pool.execute<ContentRow[]>(
|
||||||
`SELECT a.id, a.scope_type AS scopeType, a.store_id AS storeId, a.title,
|
`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.target_value AS targetValue, a.starts_at AS startsAt, a.ends_at AS endsAt,
|
||||||
a.status, a.sort_order AS sortOrder
|
a.status, a.sort_order AS sortOrder
|
||||||
FROM qipai_advertisements a
|
FROM qipai_advertisements a
|
||||||
@@ -123,11 +177,17 @@ export class ContentRepository {
|
|||||||
ORDER BY a.sort_order, a.id DESC`,
|
ORDER BY a.sort_order, a.id DESC`,
|
||||||
[actor.tenantId, ...scope.params]
|
[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) {
|
async saveAdvertisement(actor: ManagementActor, input: AdvertisementInput) {
|
||||||
this.assertAdScope(actor, input);
|
this.assertAdScope(actor, input);
|
||||||
|
const assetScope = this.adAssetScope(input);
|
||||||
return this.transaction(async (connection) => {
|
return this.transaction(async (connection) => {
|
||||||
const [result] = await connection.execute<ResultSetHeader>(
|
const [result] = await connection.execute<ResultSetHeader>(
|
||||||
`INSERT INTO qipai_advertisements
|
`INSERT INTO qipai_advertisements
|
||||||
@@ -135,10 +195,12 @@ export class ContentRepository {
|
|||||||
target_value, starts_at, ends_at, status, sort_order, created_by)
|
target_value, starts_at, ends_at, status, sort_order, created_by)
|
||||||
SELECT ?, ?, ?, ?, m.id, ?, ?, ?, ?, ?, ?, ?
|
SELECT ?, ?, ?, ?, m.id, ?, ?, ?, ?, ?, ?, ?
|
||||||
FROM qipai_media_assets m
|
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,
|
[actor.tenantId, input.scopeType, input.storeId ?? null, input.title,
|
||||||
input.targetType, input.targetValue, input.startsAt ?? null, input.endsAt ?? null,
|
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');
|
if (result.affectedRows !== 1) throw new ContentError('IMAGE_ASSET_NOT_FOUND');
|
||||||
const advertisementId = String(result.insertId);
|
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) {
|
private assertAdScope(actor: ManagementActor, input: AdvertisementInput) {
|
||||||
const tenantManager = actor.access.capabilities.includes('tenant.manage')
|
const tenantManager = actor.access.capabilities.includes('tenant.manage')
|
||||||
|| actor.access.roles.includes('PLATFORM_ADMIN');
|
|| 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) {
|
private assertStoreScope(actor: ManagementActor, storeId: string) {
|
||||||
if (actor.access.capabilities.includes('tenant.manage')
|
if (actor.access.capabilities.includes('tenant.manage')
|
||||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
|| 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) {
|
private async lockStore(connection: PoolConnection, tenantId: string, storeId: string) {
|
||||||
const [rows] = await connection.execute<IdRow[]>(
|
const [rows] = await connection.execute<IdRow[]>(
|
||||||
`SELECT id FROM qipai_stores
|
`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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql',
|
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql',
|
||||||
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
|
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
|
||||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.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: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'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/2026062627_m08b_cleaning_collaboration.verify.sql',
|
||||||
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql',
|
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql',
|
||||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.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: [
|
down: [
|
||||||
|
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
|
||||||
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
|
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
|
||||||
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
|
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
|
||||||
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
|
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
|
||||||
@@ -259,7 +262,8 @@ export async function executeMigrationPlan(
|
|||||||
2, 9, 5, 2, 1,
|
2, 9, 5, 2, 1,
|
||||||
1, 7, 5, 1,
|
1, 7, 5, 1,
|
||||||
4, 1, 1, 1,
|
4, 1, 1, 1,
|
||||||
2, 1, 1
|
2, 1, 1,
|
||||||
|
1, 1, 1
|
||||||
][index] ?? 1;
|
][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -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 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 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({
|
const componentSchema = z.object({
|
||||||
type: z.enum(['HERO', 'NOTICE', 'GALLERY', 'CONTACT', 'ROOM_LIST']),
|
type: z.enum(['HERO', 'NOTICE', 'GALLERY', 'CONTACT', 'ROOM_LIST']),
|
||||||
props: z.record(z.unknown())
|
props: z.record(z.unknown())
|
||||||
@@ -34,8 +37,9 @@ const adSchema = z.object({
|
|||||||
|
|
||||||
export interface ContentRouteOptions {
|
export interface ContentRouteOptions {
|
||||||
repository: Pick<ContentRepository,
|
repository: Pick<ContentRepository,
|
||||||
'registerAsset' | 'saveDecoration' | 'publishDecoration'
|
'registerAsset' | 'listAssets' | 'saveDecoration' | 'listDecorations'
|
||||||
| 'listAdvertisements' | 'saveAdvertisement'>;
|
| 'publishDecoration' | 'listAdvertisements' | 'saveAdvertisement'
|
||||||
|
| 'updateAdvertisement'>;
|
||||||
mediaStorage: MediaStorage;
|
mediaStorage: MediaStorage;
|
||||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
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)
|
(_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) => {
|
app.post('/admin-api/media/images', async (request, reply) => {
|
||||||
const actor = await requireContentManager(request, reply, options);
|
const actor = await requireContentManager(request, reply, options);
|
||||||
if (!actor) return;
|
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) => {
|
app.post('/admin-api/decorations', async (request, reply) => {
|
||||||
const actor = await requireContentManager(request, reply, options);
|
const actor = await requireContentManager(request, reply, options);
|
||||||
const body = decorationSchema.safeParse(request.body);
|
const body = decorationSchema.safeParse(request.body);
|
||||||
@@ -109,6 +133,19 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
|
|||||||
traceId: request.traceId
|
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(
|
async function requireContentManager(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import sharp from 'sharp';
|
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 { MediaStorage, MediaValidationError } from '../dist/content/media-storage.js';
|
||||||
import { ContentError, ContentRepository } from '../dist/content/content-repository.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'
|
(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.');
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ const cleaningTransferStateVerifySql = read('database/migrations/2026062729_m08b
|
|||||||
const staffManagementUpSql = read('database/migrations/2026081001_m08c_staff_management_access.up.sql');
|
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 staffManagementDownSql = read('database/migrations/2026081001_m08c_staff_management_access.down.sql');
|
||||||
const staffManagementVerifySql = read('database/migrations/2026081001_m08c_staff_management_access.verify.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 = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'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, /fully_granted_staff_roles/);
|
||||||
assert.match(staffManagementVerifySql, /HAVING COUNT\(DISTINCT p\.code\) = 2/);
|
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.');
|
||||||
|
|||||||
@@ -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, /2026062627_m08b_cleaning_collaboration\.up\.sql/);
|
||||||
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.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, /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.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
const verifyPlan = await loadMigrationPlan('verify');
|
const verifyPlan = await loadMigrationPlan('verify');
|
||||||
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
||||||
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
||||||
|
assert.match(verifyPlan.file, /2026081002_m08d_content_asset_scope\.verify\.sql$/);
|
||||||
|
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const fakePool = {
|
const fakePool = {
|
||||||
|
|||||||
@@ -129,13 +129,13 @@ async function readMigrationVersions(pool) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT version, name
|
`SELECT version, name
|
||||||
FROM qipai_schema_migrations
|
FROM qipai_schema_migrations
|
||||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ORDER BY version`,
|
ORDER BY version`,
|
||||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||||
'2026062220']
|
'2026062220', '2026081002']
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -1684,6 +1684,24 @@ async function assertContentManagement(pool, context) {
|
|||||||
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
|
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
|
||||||
checksumSha256: 'a'.repeat(64)
|
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, {
|
const draft1 = await repository.saveDecoration(actor, {
|
||||||
storeId, templateCode: 'classic', schemaVersion: 1,
|
storeId, templateCode: 'classic', schemaVersion: 1,
|
||||||
content: { components: [{ type: 'HERO', props: { assetId: asset.assetId } }] }
|
content: { components: [{ type: 'HERO', props: { assetId: asset.assetId } }] }
|
||||||
@@ -1705,13 +1723,22 @@ async function assertContentManagement(pool, context) {
|
|||||||
{ version: 1, status: 'ARCHIVED' },
|
{ version: 1, status: 'ARCHIVED' },
|
||||||
{ version: 2, status: 'PUBLISHED' }
|
{ version: 2, status: 'PUBLISHED' }
|
||||||
]);
|
]);
|
||||||
|
assert.equal((await repository.listDecorations(actor, storeId)).length, 2);
|
||||||
const ad = await repository.saveAdvertisement(actor, {
|
const ad = await repository.saveAdvertisement(actor, {
|
||||||
scopeType: 'STORE', storeId, title: 'Store banner', imageAssetId: asset.assetId,
|
scopeType: 'STORE', storeId, title: 'Store banner', imageAssetId: asset.assetId,
|
||||||
targetType: 'PAGE', targetValue: '/pages/index/index',
|
targetType: 'PAGE', targetValue: '/pages/index/index',
|
||||||
startsAt: null, endsAt: null, status: 'ACTIVE', sortOrder: 1
|
startsAt: null, endsAt: null, status: 'ACTIVE', sortOrder: 1
|
||||||
});
|
});
|
||||||
assert.match(ad.advertisementId, /^[1-9]\d*$/);
|
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(
|
await assert.rejects(
|
||||||
() => repository.saveAdvertisement(actor, {
|
() => repository.saveAdvertisement(actor, {
|
||||||
scopeType: 'PLATFORM', title: 'forbidden', imageAssetId: asset.assetId,
|
scopeType: 'PLATFORM', title: 'forbidden', imageAssetId: asset.assetId,
|
||||||
@@ -1998,7 +2025,8 @@ try {
|
|||||||
{ version: '2026062217', name: 'm05c_third_party' },
|
{ version: '2026062217', name: 'm05c_third_party' },
|
||||||
{ version: '2026062218', name: 'm05d_profit_sharing' },
|
{ version: '2026062218', name: 'm05d_profit_sharing' },
|
||||||
{ version: '2026062219', name: 'm06b_device_topology' },
|
{ 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);
|
await assertTaskDurability(pool);
|
||||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||||
@@ -2048,7 +2076,8 @@ try {
|
|||||||
{ version: '2026062217', name: 'm05c_third_party' },
|
{ version: '2026062217', name: 'm05c_third_party' },
|
||||||
{ version: '2026062218', name: 'm05d_profit_sharing' },
|
{ version: '2026062218', name: 'm05d_profit_sharing' },
|
||||||
{ version: '2026062219', name: 'm06b_device_topology' },
|
{ 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);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: second up and verify restored the schema.');
|
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
|
SELECT table_name, index_name FROM information_schema.statistics
|
||||||
WHERE table_schema = DATABASE()
|
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_store_decorations' AND index_name = 'uq_qipai_decorations_version')
|
||||||
OR (table_name = 'qipai_advertisements' AND index_name = 'idx_qipai_ads_delivery'))
|
OR (table_name = 'qipai_advertisements' AND index_name = 'idx_qipai_ads_delivery'))
|
||||||
GROUP BY table_name, index_name ORDER BY table_name;
|
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';
|
||||||
@@ -17,6 +17,7 @@ for (const pattern of [
|
|||||||
"activeModule = 'people'",
|
"activeModule = 'people'",
|
||||||
"activeModule = 'devices'",
|
"activeModule = 'devices'",
|
||||||
"activeModule = 'platformApps'",
|
"activeModule = 'platformApps'",
|
||||||
|
"activeModule = 'content'",
|
||||||
'平台运营总览',
|
'平台运营总览',
|
||||||
'StoresRoomsPanel',
|
'StoresRoomsPanel',
|
||||||
'OrdersPanel',
|
'OrdersPanel',
|
||||||
@@ -25,6 +26,7 @@ for (const pattern of [
|
|||||||
'MembersStaffPanel',
|
'MembersStaffPanel',
|
||||||
'DevicesPanel',
|
'DevicesPanel',
|
||||||
'PlatformAppsPanel',
|
'PlatformAppsPanel',
|
||||||
|
'ContentManagementPanel',
|
||||||
'运营总览',
|
'运营总览',
|
||||||
'savedToken',
|
'savedToken',
|
||||||
'loadCleaningWorkspace'
|
'loadCleaningWorkspace'
|
||||||
@@ -185,6 +187,27 @@ const platformRoutes = read('backend/src/routes/platform-management.ts');
|
|||||||
assert.match(platformRoutes, /'\/admin-api\/platform-apps'/);
|
assert.match(platformRoutes, /'\/admin-api\/platform-apps'/);
|
||||||
assert.match(platformRoutes, /PLATFORM_MANAGEMENT_FORBIDDEN/);
|
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');
|
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'/);
|
||||||
|
|
||||||
@@ -223,6 +246,9 @@ for (const pattern of [
|
|||||||
'.device-metrics',
|
'.device-metrics',
|
||||||
'.device-topology-grid',
|
'.device-topology-grid',
|
||||||
'.platform-app-metrics',
|
'.platform-app-metrics',
|
||||||
|
'.content-metrics',
|
||||||
|
'.asset-gallery',
|
||||||
|
'.component-editor',
|
||||||
'@media (max-width: 980px)',
|
'@media (max-width: 980px)',
|
||||||
'@media (max-width: 560px)'
|
'@media (max-width: 560px)'
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user