feat(Phase 3-1): Vue3 交互式场馆配置表单编辑器 save.html
- admin/view/venue/save.html:Vue3 CDN 嵌入
- 实时彩色座位预览(每格 30x30px,背景色=zone.color,悬浮显示座位号/分区/价格)
- 动态增删分区(char/name/price/color + 预设色板)
- 每排座位行编辑器(动态 input)
- 双向同步:改 zone char/color → 预览即时刷新
- 初始默认值:3 区(VIP区/看台区/普通区),6座/排
- mounted() 回填:zones_json / map_json / venue_json
- computed 序列化:zonesJson / seatMapRowsJson / venueJson → 隐藏字段
- Venue.php 补充:新增 venue_json 字段供 Vue3 回填
- venue_json = {name, address, image}
关联:docs/11_EDITOR_AND_INJECTION_DESIGN.md v3.0
refactor/vr-ticket-20260416
parent
136efb9b92
commit
49e256a9ab
|
|
@ -183,6 +183,11 @@ class Venue extends Base
|
||||||
$row['venue_address'] = $seatMap['venue']['address'] ?? '';
|
$row['venue_address'] = $seatMap['venue']['address'] ?? '';
|
||||||
$row['venue_image'] = $seatMap['venue']['image'] ?? '';
|
$row['venue_image'] = $seatMap['venue']['image'] ?? '';
|
||||||
$row['zones_json'] = json_encode($seatMap['sections'] ?? [], JSON_UNESCAPED_UNICODE);
|
$row['zones_json'] = json_encode($seatMap['sections'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||||
|
$row['venue_json'] = json_encode([
|
||||||
|
'name' => $seatMap['venue']['name'] ?? '',
|
||||||
|
'address' => $seatMap['venue']['address'] ?? '',
|
||||||
|
'image' => $seatMap['venue']['image'] ?? '',
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
$row['map_json'] = json_encode($seatMap['map'] ?? [], JSON_UNESCAPED_UNICODE);
|
$row['map_json'] = json_encode($seatMap['map'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||||
$info = $row;
|
$info = $row;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,453 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{if isset($info['id'])}编辑{else}添加{/if}场馆</title>
|
||||||
|
<link rel="stylesheet" href="__STATIC__/layui/css/layui.css">
|
||||||
|
<style>
|
||||||
|
[v-cloak] { display: none; }
|
||||||
|
.venue-editor { margin-top: 20px; }
|
||||||
|
.venue-editor .section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0 10px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 2px solid #009688;
|
||||||
|
color: #009688;
|
||||||
|
}
|
||||||
|
/* 座位预览 */
|
||||||
|
.seat-preview-wrap {
|
||||||
|
margin: 15px 0;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #e2e2e2;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
.seat-preview-wrap .preview-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.seat-preview-wrap .preview-row:last-child { margin-bottom: 0; }
|
||||||
|
.seat-preview-wrap .seat-cell {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
.seat-preview-wrap .seat-cell:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.seat-preview-wrap .seat-cell .seat-tooltip {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 38px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
color: #fff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.seat-preview-wrap .seat-cell:hover .seat-tooltip { display: block; }
|
||||||
|
.seat-preview-wrap .seat-cell .seat-tooltip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-top-color: rgba(0,0,0,0.85);
|
||||||
|
}
|
||||||
|
.seat-preview-stats {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.seat-preview-stats span {
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
/* 分区配置 */
|
||||||
|
.zone-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #e2e2e2;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.zone-row .zone-char { width: 60px; }
|
||||||
|
.zone-row .zone-name { flex: 1; }
|
||||||
|
.zone-row .zone-price { width: 100px; }
|
||||||
|
.zone-row .zone-color { width: 50px; height: 36px; padding: 2px; border: 1px solid #ccc; border-radius: 3px; cursor: pointer; }
|
||||||
|
/* 座位排布 */
|
||||||
|
.seat-map-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.seat-map-row label {
|
||||||
|
width: 70px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.seat-map-row input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
/* 颜色预设 */
|
||||||
|
.color-presets {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.color-presets .preset-swatch {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
.color-presets .preset-swatch:hover { transform: scale(1.2); }
|
||||||
|
/* 工具栏按钮 */
|
||||||
|
.toolbar { margin: 10px 0; }
|
||||||
|
/* 隐藏字段同步占位提示 */
|
||||||
|
.sync-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="layui-fluid" style="padding:20px;">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">
|
||||||
|
{if isset($info['id'])}编辑{else}添加{/if}场馆
|
||||||
|
</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<form class="layui-form" method="POST" lay-filter="venue-form">
|
||||||
|
|
||||||
|
<!-- 场馆基本信息 -->
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">场馆名称</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="name" value="{$info.name|default=''}" class="layui-input"
|
||||||
|
lay-verify="required" placeholder="请输入场馆名称">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
Vue3 交互式场馆编辑器
|
||||||
|
============================================================ -->
|
||||||
|
<div id="venue-editor" v-cloak>
|
||||||
|
|
||||||
|
<div class="section-title">票务配置</div>
|
||||||
|
|
||||||
|
<!-- 场馆票务名 & 地址 -->
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">场馆名(票务用)</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" v-model="venue.name" class="layui-input" placeholder="票务系统展示的场馆名称">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">场馆地址</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" v-model="venue.address" class="layui-input" placeholder="场馆详细地址">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">场馆图片</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" v-model="venue.image" class="layui-input" placeholder="场馆图片URL(可选)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
实时座位预览
|
||||||
|
============================================================ -->
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">座位预览</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<div class="seat-preview-wrap">
|
||||||
|
<template v-for="(rowStr, rowIdx) in seatMapRows" :key="rowIdx">
|
||||||
|
<div class="preview-row" v-if="rowStr.trim() !== ''">
|
||||||
|
<template v-for="(ch, colIdx) in rowStr.trim()" :key="rowIdx + '_' + colIdx">
|
||||||
|
<div class="seat-cell"
|
||||||
|
:style="{ backgroundColor: getZoneColor(ch) }"
|
||||||
|
:title="getSeatTooltip(rowIdx, colIdx, ch)">
|
||||||
|
{{ ch }}{{ colIdx + 1 }}
|
||||||
|
<div class="seat-tooltip">{{ getSeatTooltip(rowIdx, colIdx, ch) }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="totalSeats === 0" style="color:#999;font-size:13px;text-align:center;padding:10px 0;">
|
||||||
|
暂无座位,请添加分区和排布
|
||||||
|
</div>
|
||||||
|
<div class="seat-preview-stats" v-if="totalSeats > 0">
|
||||||
|
<span>总座位数:<strong>{{ totalSeats }}</strong></span>
|
||||||
|
<span>总排数:<strong>{{ seatMapRows.filter(r => r.trim()).length }}</strong></span>
|
||||||
|
<span>分区数:<strong>{{ activeZones.length }}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
分区配置
|
||||||
|
============================================================ -->
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">分区配置</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<div v-for="(zone, idx) in zones" :key="idx" class="zone-row">
|
||||||
|
<input type="text" v-model="zone.char" maxlength="1"
|
||||||
|
class="layui-input zone-char"
|
||||||
|
placeholder="字符"
|
||||||
|
@input="onZoneChange">
|
||||||
|
<input type="text" v-model="zone.name"
|
||||||
|
class="layui-input zone-name"
|
||||||
|
placeholder="分区名称,如 VIP区">
|
||||||
|
<input type="number" v-model="zone.price"
|
||||||
|
class="layui-input zone-price"
|
||||||
|
placeholder="价格" min="0">
|
||||||
|
<input type="color" v-model="zone.color"
|
||||||
|
class="zone-color"
|
||||||
|
@input="onZoneChange">
|
||||||
|
<button type="button" class="layui-btn layui-btn-xs layui-btn-danger"
|
||||||
|
@click="removeZone(idx)">删除</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" class="layui-btn layui-btn-sm" @click="addZone">
|
||||||
|
<i class="layui-icon layui-icon-add-1"></i> 添加分区
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="color-presets">
|
||||||
|
<span style="font-size:12px;color:#888;margin-right:4px;">预设色:</span>
|
||||||
|
<template v-for="c in colorPresets" :key="c">
|
||||||
|
<div class="preset-swatch"
|
||||||
|
:style="{ backgroundColor: c }"
|
||||||
|
:title="c"
|
||||||
|
@click="applyPresetColor(c)"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
座位排布
|
||||||
|
============================================================ -->
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">座位排布</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<div v-for="(row, idx) in seatMapRows" :key="idx" class="seat-map-row">
|
||||||
|
<label>第 {{ idx + 1 }} 排:</label>
|
||||||
|
<input type="text"
|
||||||
|
:value="row"
|
||||||
|
@input="updateSeatMapRow(idx, $event)"
|
||||||
|
placeholder="输入座位字符,如 AAAAAA">
|
||||||
|
<button type="button" class="layui-btn layui-btn-xs layui-btn-danger"
|
||||||
|
@click="removeSeatRow(idx)">删除</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" class="layui-btn layui-btn-sm" @click="addSeatRow">
|
||||||
|
<i class="layui-icon layui-icon-add-1"></i> 添加排
|
||||||
|
</button>
|
||||||
|
<span class="sync-hint">每排字符对应上方分区,例:ABABAB 表示交替座位</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- / Vue3 编辑器 -->
|
||||||
|
|
||||||
|
<!-- 隐藏字段:提交给 PHP -->
|
||||||
|
<input type="hidden" name="zones" :value="zonesJson">
|
||||||
|
<input type="hidden" name="seat_map_rows" :value="seatMapRowsJson">
|
||||||
|
<input type="hidden" name="venue_json" :value="venueJson">
|
||||||
|
|
||||||
|
<div class="layui-form-item" style="margin-top:20px;">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<button type="submit" class="layui-btn" lay-submit lay-filter="venue-submit">
|
||||||
|
提交
|
||||||
|
</button>
|
||||||
|
<a href="{:PluginsAdminUrl('vr_ticket/admin/venue/index')}" class="layui-btn layui-btn-primary">
|
||||||
|
返回
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="__STATIC__/layui/layui.js"></script>
|
||||||
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// ============================================================
|
||||||
|
// Vue3 编辑器
|
||||||
|
// ============================================================
|
||||||
|
var COLOR_PRESETS = [
|
||||||
|
'#e74c3c', '#3498db', '#2ecc71', '#9b59b6',
|
||||||
|
'#f39c12', '#1abc9c', '#34495e', '#e67e22',
|
||||||
|
'#16a085', '#8e44ad', '#27ae60', '#c0392b'
|
||||||
|
];
|
||||||
|
|
||||||
|
var DEFAULT_ZONE = { char: '', name: '', price: 0, color: '#3498db' };
|
||||||
|
|
||||||
|
var app = Vue.createApp({
|
||||||
|
data: function() {
|
||||||
|
return {
|
||||||
|
venue: { name: '', address: '', image: '' },
|
||||||
|
zones: [
|
||||||
|
{ char: 'A', name: 'VIP区', price: 899, color: '#e74c3c' },
|
||||||
|
{ char: 'B', name: '看台区', price: 599, color: '#3498db' }
|
||||||
|
],
|
||||||
|
seatMapRows: ['AAAAAA', 'BBBBBB', 'CCCCCC'],
|
||||||
|
colorPresets: COLOR_PRESETS
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
activeZones: function() {
|
||||||
|
var map = {};
|
||||||
|
this.zones.forEach(function(z) {
|
||||||
|
if (z.char.trim()) map[z.char.trim().toUpperCase()] = z;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
zonesJson: function() {
|
||||||
|
return JSON.stringify(this.zones);
|
||||||
|
},
|
||||||
|
seatMapRowsJson: function() {
|
||||||
|
return JSON.stringify(this.seatMapRows);
|
||||||
|
},
|
||||||
|
venueJson: function() {
|
||||||
|
return JSON.stringify(this.venue);
|
||||||
|
},
|
||||||
|
totalSeats: function() {
|
||||||
|
var total = 0;
|
||||||
|
this.seatMapRows.forEach(function(row) {
|
||||||
|
if (row && row.trim) total += row.trim().length;
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getZoneColor: function(ch) {
|
||||||
|
var z = this.activeZones[ch.toUpperCase()];
|
||||||
|
return z ? z.color : '#cccccc';
|
||||||
|
},
|
||||||
|
getZone: function(ch) {
|
||||||
|
return this.activeZones[ch.toUpperCase()] || null;
|
||||||
|
},
|
||||||
|
getSeatTooltip: function(rowIdx, colIdx, ch) {
|
||||||
|
var zone = this.getZone(ch);
|
||||||
|
if (!zone) return ch + (colIdx + 1);
|
||||||
|
return zone.name + ' · ¥' + zone.price + ' · ' + ch + (colIdx + 1);
|
||||||
|
},
|
||||||
|
addZone: function() {
|
||||||
|
this.zones.push(Vue.util.extend({}, DEFAULT_ZONE));
|
||||||
|
},
|
||||||
|
removeZone: function(idx) {
|
||||||
|
this.zones.splice(idx, 1);
|
||||||
|
},
|
||||||
|
applyPresetColor: function(color) {
|
||||||
|
// 把颜色应用到最后一个未填写颜色的 zone
|
||||||
|
var lastEmpty = -1;
|
||||||
|
for (var i = 0; i < this.zones.length; i++) {
|
||||||
|
if (!this.zones[i].color || this.zones[i].color === '#cccccc') {
|
||||||
|
lastEmpty = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastEmpty >= 0) {
|
||||||
|
this.zones[lastEmpty].color = color;
|
||||||
|
} else {
|
||||||
|
this.zones[this.zones.length - 1].color = color;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addSeatRow: function() {
|
||||||
|
var lastRow = this.seatMapRows.length > 0
|
||||||
|
? this.seatMapRows[this.seatMapRows.length - 1]
|
||||||
|
: 'A';
|
||||||
|
var ch = lastRow.trim().split('')[0] || 'A';
|
||||||
|
var count = 6;
|
||||||
|
this.seatMapRows.push(new Array(count + 1).join(ch));
|
||||||
|
},
|
||||||
|
removeSeatRow: function(idx) {
|
||||||
|
this.seatMapRows.splice(idx, 1);
|
||||||
|
},
|
||||||
|
updateSeatMapRow: function(idx, event) {
|
||||||
|
this.seatMapRows[idx] = event.target.value.toUpperCase();
|
||||||
|
},
|
||||||
|
onZoneChange: function() {
|
||||||
|
// 强制 Vue 重新渲染预览(响应式已处理,这里可扩展自定义校验)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted: function() {
|
||||||
|
// 编辑时回填
|
||||||
|
var zonesRaw = '{$info.zones_json|raw}' || '[]';
|
||||||
|
var mapRaw = '{$info.map_json|raw}' || '[]';
|
||||||
|
var venueRaw = '{$info.venue_json|raw}' || '{}';
|
||||||
|
|
||||||
|
if (zonesRaw && zonesRaw !== '[]') {
|
||||||
|
try { this.zones = JSON.parse(zonesRaw); } catch(e) {}
|
||||||
|
}
|
||||||
|
if (mapRaw && mapRaw !== '[]') {
|
||||||
|
try { this.seatMapRows = JSON.parse(mapRaw); } catch(e) {}
|
||||||
|
}
|
||||||
|
if (venueRaw && venueRaw !== '{}') {
|
||||||
|
try { this.venue = JSON.parse(venueRaw); } catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.mount('#venue-editor');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// layui 初始化
|
||||||
|
// ============================================================
|
||||||
|
layui.use(['form', 'layer'], function(){
|
||||||
|
var form = layui.form;
|
||||||
|
var layer = layui.layer;
|
||||||
|
|
||||||
|
form.on('submit(venue-submit)', function(data){
|
||||||
|
// 数据已通过隐藏字段序列化,此处不做额外处理
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
form.render();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue