@@ -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 >