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;