|
|
@@ -0,0 +1,810 @@
|
|
|
+<template>
|
|
|
+ <div class="app-container template-editor-page">
|
|
|
+ <!-- 顶部标题栏 -->
|
|
|
+ <div class="editor-page-header">
|
|
|
+ <div class="header-left">
|
|
|
+ <div class="page-title">{{ isEdit ? '编辑模板' : '新增模板' }}</div>
|
|
|
+ <div class="page-desc">左侧选择组件,中间预览,右侧操作组件顺序</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="header-right">
|
|
|
+ <el-button size="mini" @click="goBack">返回</el-button>
|
|
|
+ <el-button size="mini" type="primary" @click="submitForm">保存模板</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 顶部表单区域 -->
|
|
|
+ <div class="template-base-form">
|
|
|
+ <el-form :model="form" label-width="90px" size="small">
|
|
|
+ <el-row :gutter="16">
|
|
|
+ <el-col :span="8">
|
|
|
+ <el-form-item label="模板名称">
|
|
|
+ <el-input v-model="form.templateName" placeholder="请输入模板名称" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+
|
|
|
+ <el-col :span="8">
|
|
|
+ <el-form-item label="模板类型" prop="templateType">
|
|
|
+ <el-radio-group v-model="form.templateType">
|
|
|
+ <el-radio label="home">首页模板</el-radio>
|
|
|
+ <el-radio label="user">个人中心模板</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+
|
|
|
+ <el-col :span="8">
|
|
|
+ <el-form-item label="模板封面">
|
|
|
+ <el-input v-model="form.coverUrl" placeholder="请输入模板封面地址" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <el-row :gutter="16">
|
|
|
+ <el-col :span="8">
|
|
|
+ <el-form-item label="状态">
|
|
|
+ <el-radio-group v-model="form.status">
|
|
|
+ <el-radio :label="1">启用</el-radio>
|
|
|
+ <el-radio :label="0">停用</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+
|
|
|
+ <el-col :span="16">
|
|
|
+ <el-form-item label="备注">
|
|
|
+ <el-input v-model="form.remark" placeholder="请输入备注" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+ </el-form>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 编辑器主体 -->
|
|
|
+ <div class="editor-main">
|
|
|
+ <!-- 左侧组件库 -->
|
|
|
+ <div class="component-library">
|
|
|
+ <div class="panel-title">组件库</div>
|
|
|
+
|
|
|
+ <el-select
|
|
|
+ v-model="componentQuery.componentTypeCode"
|
|
|
+ placeholder="组件类型"
|
|
|
+ clearable
|
|
|
+ size="mini"
|
|
|
+ style="width: 100%; margin-bottom: 10px;"
|
|
|
+ @change="loadComponentList"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="item in typeOptions"
|
|
|
+ :key="item.value"
|
|
|
+ :label="item.label"
|
|
|
+ :value="item.value"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+
|
|
|
+ <div class="component-card-list">
|
|
|
+ <div
|
|
|
+ v-for="item in componentList"
|
|
|
+ :key="item.id"
|
|
|
+ class="component-card"
|
|
|
+ :class="{ disabled: usedComponentCodes.includes(item.componentCode) }"
|
|
|
+ @click="addComponent(item)"
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ v-if="item.previewImage"
|
|
|
+ :src="item.previewImage"
|
|
|
+ class="component-thumb"
|
|
|
+ />
|
|
|
+ <div v-else class="component-thumb icon-thumb">
|
|
|
+ <i :class="item.icon || 'el-icon-picture'"></i>
|
|
|
+ </div>
|
|
|
+ <div class="component-name">{{ item.componentName }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 中间手机画布 -->
|
|
|
+ <div class="canvas-panel">
|
|
|
+ <div class="panel-title">页面画布(手机预览)</div>
|
|
|
+
|
|
|
+ <div class="phone-frame modern-phone">
|
|
|
+ <div class="phone-status-bar modern-status-bar">
|
|
|
+ <span class="status-time">9:41</span>
|
|
|
+ <div class="status-icons">
|
|
|
+ <span class="signal-bars">
|
|
|
+ <i></i><i></i><i></i><i></i>
|
|
|
+ </span>
|
|
|
+ <span class="wifi-icon">◜◝</span>
|
|
|
+ <span class="battery-icon">
|
|
|
+ <span class="battery-body"></span>
|
|
|
+ <span class="battery-cap"></span>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="phone-screen modern-phone-screen">
|
|
|
+ <draggable
|
|
|
+ v-model="form.components"
|
|
|
+ class="canvas-list"
|
|
|
+ animation="200"
|
|
|
+ :move="checkMove"
|
|
|
+ @end="normalizeComponentsOrder"
|
|
|
+ >
|
|
|
+ <transition-group>
|
|
|
+ <div
|
|
|
+ v-for="(item, index) in form.components"
|
|
|
+ :key="item.id"
|
|
|
+ class="canvas-item"
|
|
|
+ :class="{ active: selectedIndex === index }"
|
|
|
+ @click.stop="selectComponent(index)"
|
|
|
+ >
|
|
|
+ <component
|
|
|
+ :is="getPreviewComponent(item.componentCode)"
|
|
|
+ class="preview-runtime"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </transition-group>
|
|
|
+ </draggable>
|
|
|
+
|
|
|
+ <div v-if="!form.components.length" class="empty-tip">
|
|
|
+ 请从左侧点击组件添加到页面
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧组件操作 -->
|
|
|
+ <div class="config-panel">
|
|
|
+ <div class="panel-title">组件操作</div>
|
|
|
+
|
|
|
+ <div v-if="selectedComponent" class="config-box">
|
|
|
+ <div class="config-row">
|
|
|
+ <span class="label">组件编码:</span>
|
|
|
+ <span class="value">{{ selectedComponent.componentCode }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="config-row">
|
|
|
+ <span class="label">当前位置:</span>
|
|
|
+ <span class="value">第 {{ selectedIndex + 1 }} 个</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="config-actions">
|
|
|
+ <el-button
|
|
|
+ size="mini"
|
|
|
+ @click="moveUp"
|
|
|
+ :disabled="
|
|
|
+ selectedIndex <= 0 ||
|
|
|
+ selectedComponent.componentCode === 'Banner' ||
|
|
|
+ selectedComponent.componentCode === 'RecommendedProducts'"
|
|
|
+ >
|
|
|
+ 上移
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ size="mini"
|
|
|
+ @click="moveDown"
|
|
|
+ :disabled="
|
|
|
+ selectedIndex >= form.components.length - 1 ||
|
|
|
+ selectedComponent.componentCode === 'Banner' ||
|
|
|
+ selectedComponent.componentCode === 'RecommendedProducts'"
|
|
|
+ >
|
|
|
+ 下移
|
|
|
+ </el-button>
|
|
|
+
|
|
|
+ <el-button
|
|
|
+ size="mini"
|
|
|
+ type="danger"
|
|
|
+ @click="removeSelected"
|
|
|
+ >
|
|
|
+ 删除组件
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else class="config-empty">
|
|
|
+ 请点击中间画布中的组件
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import draggable from 'vuedraggable'
|
|
|
+import {
|
|
|
+ getDecorationTemplate,
|
|
|
+ saveDecorationTemplate,
|
|
|
+ listDecorationComponent,
|
|
|
+ listComponentTypeOptions
|
|
|
+} from '@/api/decoration/decoration'
|
|
|
+
|
|
|
+import BannerPreview from '@/components/decoration/BannerPreview.vue'
|
|
|
+import NoticeBarPreview from '@/components/decoration/NoticeBarPreview.vue'
|
|
|
+import GoodsListPreview from '@/components/decoration/GoodsListPreview.vue'
|
|
|
+import CouponCardPreview from '@/components/decoration/CouponCardPreview.vue'
|
|
|
+import BrandStoryPreview from '@/components/decoration/BrandStoryPreview.vue'
|
|
|
+import BottomCtaPreview from '@/components/decoration/BottomCtaPreview.vue'
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'DecorationTemplateEditor',
|
|
|
+ components: {
|
|
|
+ draggable
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ selectedIndex: -1,
|
|
|
+ typeOptions: [],
|
|
|
+ componentList: [],
|
|
|
+ componentQuery: {
|
|
|
+ componentTypeCode: '',
|
|
|
+ status: 1
|
|
|
+ },
|
|
|
+ form: {
|
|
|
+ id: null,
|
|
|
+ templateName: '',
|
|
|
+ templateType: 'home',
|
|
|
+ coverUrl: '',
|
|
|
+ status: 1,
|
|
|
+ remark: '',
|
|
|
+ components: []
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ isEdit() {
|
|
|
+ return !!this.$route.query.id
|
|
|
+ },
|
|
|
+ selectedComponent() {
|
|
|
+ if (this.selectedIndex < 0) return null
|
|
|
+ return this.form.components[this.selectedIndex] || null
|
|
|
+ },
|
|
|
+ usedComponentCodes() {
|
|
|
+ return this.form.components.map(item => item.componentCode)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ created() {
|
|
|
+ this.loadTypeOptions()
|
|
|
+ this.loadComponentList()
|
|
|
+ this.loadDetail()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ goBack() {
|
|
|
+ this.$router.push('/decoration/template/list')
|
|
|
+ },
|
|
|
+
|
|
|
+ loadTypeOptions() {
|
|
|
+ listComponentTypeOptions().then(res => {
|
|
|
+ this.typeOptions = res.data || []
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ loadComponentList() {
|
|
|
+ listDecorationComponent(this.componentQuery).then(res => {
|
|
|
+ this.componentList = res.data || []
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ loadDetail() {
|
|
|
+ const id = this.$route.query.id
|
|
|
+ if (!id) return
|
|
|
+
|
|
|
+ getDecorationTemplate(id).then(res => {
|
|
|
+ const data = res.data || {}
|
|
|
+ const templateData = JSON.parse(data.templateData || '{}')
|
|
|
+
|
|
|
+ this.form = {
|
|
|
+ id: data.id,
|
|
|
+ templateName: data.templateName,
|
|
|
+ templateType: data.templateType,
|
|
|
+ coverUrl: data.coverUrl,
|
|
|
+ status: data.status,
|
|
|
+ remark: data.remark,
|
|
|
+ components: templateData.components || []
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+
|
|
|
+ selectComponent(index) {
|
|
|
+ this.selectedIndex = index
|
|
|
+ },
|
|
|
+
|
|
|
+ removeSelected() {
|
|
|
+ if (this.selectedIndex < 0) return
|
|
|
+ this.form.components.splice(this.selectedIndex, 1)
|
|
|
+ this.selectedIndex = -1
|
|
|
+ },
|
|
|
+
|
|
|
+
|
|
|
+ getPreviewComponent(code) {
|
|
|
+ const map = {
|
|
|
+ Banner: BannerPreview,
|
|
|
+ MenuBar: NoticeBarPreview,
|
|
|
+ FlashSale: GoodsListPreview,
|
|
|
+ TodayGroupDeals: CouponCardPreview,
|
|
|
+ LimitedTimeDiscount: BrandStoryPreview,
|
|
|
+ TrendingList: BottomCtaPreview,
|
|
|
+ RecommendedProducts: GoodsListPreview
|
|
|
+ }
|
|
|
+ return map[code] || BannerPreview
|
|
|
+ },
|
|
|
+
|
|
|
+ isFixedTop(componentCode) {
|
|
|
+ return componentCode === 'Banner'
|
|
|
+ },
|
|
|
+
|
|
|
+ isFixedBottom(componentCode) {
|
|
|
+ return componentCode === 'RecommendedProducts'
|
|
|
+ },
|
|
|
+
|
|
|
+ addComponent(item) {
|
|
|
+ // 每个组件只能添加一次
|
|
|
+ if (this.usedComponentCodes.includes(item.componentCode)) {
|
|
|
+ this.$message.warning('该组件已经添加过了,不能重复添加')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ this.form.components.push({
|
|
|
+ id: 'cmp_' + Date.now() + '_' + Math.floor(Math.random() * 10000),
|
|
|
+ componentCode: item.componentCode,
|
|
|
+ props: {}
|
|
|
+ })
|
|
|
+
|
|
|
+ this.normalizeComponentsOrder()
|
|
|
+ },
|
|
|
+
|
|
|
+ normalizeComponentsOrder() {
|
|
|
+ const oldSelected = this.selectedComponent ? this.selectedComponent.id : null
|
|
|
+
|
|
|
+ const list = this.form.components || []
|
|
|
+
|
|
|
+ const topList = []
|
|
|
+ const middleList = []
|
|
|
+ const bottomList = []
|
|
|
+
|
|
|
+ list.forEach(item => {
|
|
|
+ if (this.isFixedTop(item.componentCode)) {
|
|
|
+ topList.push(item)
|
|
|
+ } else if (this.isFixedBottom(item.componentCode)) {
|
|
|
+ bottomList.push(item)
|
|
|
+ } else {
|
|
|
+ middleList.push(item)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ this.form.components = topList.concat(middleList, bottomList)
|
|
|
+
|
|
|
+ if (oldSelected) {
|
|
|
+ this.selectedIndex = this.form.components.findIndex(item => item.id === oldSelected)
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ checkMove(evt) {
|
|
|
+ const dragged = evt.draggedContext && evt.draggedContext.element
|
|
|
+ const related = evt.relatedContext && evt.relatedContext.element
|
|
|
+
|
|
|
+ if (!dragged) return true
|
|
|
+
|
|
|
+ // Banner 不允许被拖动到非顶部
|
|
|
+ if (this.isFixedTop(dragged.componentCode)) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ // RecommendedProducts 不允许被拖动到非底部
|
|
|
+ if (this.isFixedBottom(dragged.componentCode)) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ // 其他组件不能拖到 Banner 上面
|
|
|
+ if (related && this.isFixedTop(related.componentCode)) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ // 其他组件不能拖到 RecommendedProducts 下面
|
|
|
+ if (related && this.isFixedBottom(related.componentCode)) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ return true
|
|
|
+ },
|
|
|
+
|
|
|
+ moveUp() {
|
|
|
+ if (this.selectedIndex <= 0) return
|
|
|
+
|
|
|
+ const current = this.form.components[this.selectedIndex]
|
|
|
+ const prev = this.form.components[this.selectedIndex - 1]
|
|
|
+
|
|
|
+ if (this.isFixedTop(current.componentCode)) {
|
|
|
+ this.$message.warning('轮播图必须在最上面')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (prev && this.isFixedTop(prev.componentCode)) {
|
|
|
+ this.$message.warning('不能移动到轮播图上方')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const arr = this.form.components
|
|
|
+ this.$set(arr, this.selectedIndex, prev)
|
|
|
+ this.$set(arr, this.selectedIndex - 1, current)
|
|
|
+ this.selectedIndex = this.selectedIndex - 1
|
|
|
+
|
|
|
+ this.normalizeComponentsOrder()
|
|
|
+ },
|
|
|
+
|
|
|
+ moveDown() {
|
|
|
+ if (this.selectedIndex < 0 || this.selectedIndex >= this.form.components.length - 1) return
|
|
|
+
|
|
|
+ const current = this.form.components[this.selectedIndex]
|
|
|
+ const next = this.form.components[this.selectedIndex + 1]
|
|
|
+
|
|
|
+ if (this.isFixedBottom(current.componentCode)) {
|
|
|
+ this.$message.warning('推荐商品必须在最下面')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (next && this.isFixedBottom(next.componentCode)) {
|
|
|
+ this.$message.warning('不能移动到推荐商品下方')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const arr = this.form.components
|
|
|
+ this.$set(arr, this.selectedIndex, next)
|
|
|
+ this.$set(arr, this.selectedIndex + 1, current)
|
|
|
+ this.selectedIndex = this.selectedIndex + 1
|
|
|
+
|
|
|
+ this.normalizeComponentsOrder()
|
|
|
+ },
|
|
|
+
|
|
|
+ submitForm() {
|
|
|
+ // 保存前再兜底整理一次顺序
|
|
|
+ this.normalizeComponentsOrder()
|
|
|
+
|
|
|
+ const payload = {
|
|
|
+ id: this.form.id,
|
|
|
+ templateName: this.form.templateName,
|
|
|
+ templateType: this.form.templateType,
|
|
|
+ coverUrl: this.form.coverUrl,
|
|
|
+ status: this.form.status,
|
|
|
+ remark: this.form.remark,
|
|
|
+ templateData: JSON.stringify({
|
|
|
+ pageConfig: {
|
|
|
+ templateName: this.form.templateName
|
|
|
+ },
|
|
|
+ components: this.form.components
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ saveDecorationTemplate(payload).then(() => {
|
|
|
+ this.$message.success('保存成功')
|
|
|
+ this.goBack()
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.template-editor-page {
|
|
|
+ height: calc(100vh - 84px);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+/* 页面顶部 */
|
|
|
+.editor-page-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.page-title {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.page-desc {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ margin-top: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 顶部表单 */
|
|
|
+.template-base-form {
|
|
|
+ background: #fff;
|
|
|
+ padding: 16px 16px 2px;
|
|
|
+ border-radius: 8px;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 主体三栏 */
|
|
|
+.editor-main {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ gap: 16px;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+/* 左侧组件库 */
|
|
|
+.component-library {
|
|
|
+ width: 280px;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ padding: 12px;
|
|
|
+ overflow-y: auto;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 中间画布 */
|
|
|
+.canvas-panel {
|
|
|
+ flex: 1;
|
|
|
+ padding: 12px;
|
|
|
+ background: #f5f7fa;
|
|
|
+ overflow: auto;
|
|
|
+ min-width: 0;
|
|
|
+ border-radius: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 右侧操作区 */
|
|
|
+.config-panel {
|
|
|
+ width: 240px;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ padding: 12px;
|
|
|
+ background: #fff;
|
|
|
+ overflow-y: auto;
|
|
|
+ border-radius: 8px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.panel-title {
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: bold;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 左侧组件卡片 */
|
|
|
+.component-card-list {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.component-card {
|
|
|
+ width: calc(50% - 5px);
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 8px;
|
|
|
+ cursor: pointer;
|
|
|
+ background: #fff;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.component-card:hover {
|
|
|
+ border-color: #409EFF;
|
|
|
+ box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
|
|
|
+}
|
|
|
+
|
|
|
+.component-thumb {
|
|
|
+ width: 100%;
|
|
|
+ height: 70px;
|
|
|
+ object-fit: cover;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.icon-thumb {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 26px;
|
|
|
+ background: #f5f7fa;
|
|
|
+}
|
|
|
+
|
|
|
+.component-name {
|
|
|
+ margin-top: 8px;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 手机 */
|
|
|
+.phone-frame.modern-phone {
|
|
|
+ width: 370px;
|
|
|
+ height: 760px;
|
|
|
+ margin: 0 auto;
|
|
|
+ background: #0f0f10;
|
|
|
+ border-radius: 42px;
|
|
|
+ padding: 10px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ box-shadow:
|
|
|
+ 0 18px 40px rgba(0, 0, 0, 0.18),
|
|
|
+ 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
|
+ position: relative;
|
|
|
+ border: 2px solid #1b1b1d;
|
|
|
+}
|
|
|
+
|
|
|
+.modern-status-bar {
|
|
|
+ height: 28px;
|
|
|
+ padding: 0 16px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ color: #111;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ background: #fff;
|
|
|
+ border-top-left-radius: 32px;
|
|
|
+ border-top-right-radius: 32px;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.status-time {
|
|
|
+ color: #222;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.status-icons {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.signal-bars {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-end;
|
|
|
+ gap: 2px;
|
|
|
+ height: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.signal-bars i {
|
|
|
+ display: block;
|
|
|
+ width: 2px;
|
|
|
+ background: #222;
|
|
|
+ border-radius: 1px;
|
|
|
+}
|
|
|
+
|
|
|
+.signal-bars i:nth-child(1) { height: 4px; }
|
|
|
+.signal-bars i:nth-child(2) { height: 6px; }
|
|
|
+.signal-bars i:nth-child(3) { height: 8px; }
|
|
|
+.signal-bars i:nth-child(4) { height: 10px; }
|
|
|
+
|
|
|
+.wifi-icon {
|
|
|
+ font-size: 10px;
|
|
|
+ color: #222;
|
|
|
+ line-height: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.battery-icon {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.battery-body {
|
|
|
+ width: 18px;
|
|
|
+ height: 9px;
|
|
|
+ border: 1.5px solid #222;
|
|
|
+ border-radius: 2px;
|
|
|
+ position: relative;
|
|
|
+ display: inline-block;
|
|
|
+}
|
|
|
+
|
|
|
+.battery-body::after {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ left: 1px;
|
|
|
+ top: 1px;
|
|
|
+ width: 11px;
|
|
|
+ height: 5px;
|
|
|
+ background: #9adf7f;
|
|
|
+ border-radius: 1px;
|
|
|
+}
|
|
|
+
|
|
|
+.battery-cap {
|
|
|
+ width: 2px;
|
|
|
+ height: 5px;
|
|
|
+ background: #222;
|
|
|
+ border-radius: 1px;
|
|
|
+ display: inline-block;
|
|
|
+}
|
|
|
+
|
|
|
+.modern-phone-screen {
|
|
|
+ height: calc(100% - 28px);
|
|
|
+ background: #fff;
|
|
|
+ border-bottom-left-radius: 32px;
|
|
|
+ border-bottom-right-radius: 32px;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 10px 10px 14px;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.modern-phone-screen::-webkit-scrollbar {
|
|
|
+ width: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.modern-phone-screen::-webkit-scrollbar-thumb {
|
|
|
+ background: rgba(0, 0, 0, 0.15);
|
|
|
+ border-radius: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 画布组件 */
|
|
|
+.canvas-list {
|
|
|
+ min-height: 200px;
|
|
|
+}
|
|
|
+
|
|
|
+.canvas-item {
|
|
|
+ background: #fff;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 10px;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ cursor: move;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.canvas-item:hover {
|
|
|
+ border-color: #409EFF;
|
|
|
+ box-shadow: 0 2px 10px rgba(64, 158, 255, 0.12);
|
|
|
+}
|
|
|
+
|
|
|
+.canvas-item.active {
|
|
|
+ border-color: #409EFF;
|
|
|
+ box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.12);
|
|
|
+}
|
|
|
+
|
|
|
+.preview-runtime {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-tip {
|
|
|
+ text-align: center;
|
|
|
+ color: #909399;
|
|
|
+ padding: 40px 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* 右侧操作面板 */
|
|
|
+.config-box {
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 12px;
|
|
|
+ background: #fafafa;
|
|
|
+}
|
|
|
+
|
|
|
+.config-row {
|
|
|
+ margin-bottom: 12px;
|
|
|
+ line-height: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.config-row .label {
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.config-row .value {
|
|
|
+ color: #303133;
|
|
|
+ word-break: break-all;
|
|
|
+}
|
|
|
+
|
|
|
+.config-actions {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.config-empty {
|
|
|
+ color: #909399;
|
|
|
+ text-align: center;
|
|
|
+ padding: 40px 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.component-card.disabled {
|
|
|
+ opacity: 0.45;
|
|
|
+ cursor: not-allowed;
|
|
|
+}
|
|
|
+
|
|
|
+.component-card.disabled:hover {
|
|
|
+ border-color: #dcdfe6;
|
|
|
+ box-shadow: none;
|
|
|
+}
|
|
|
+</style>
|