2026-04-18 21:46:37 +00:00
|
|
|
|
<?php
|
|
|
|
|
|
namespace app\plugins\vr_ticket\hook;
|
|
|
|
|
|
|
|
|
|
|
|
use think\facade\Db;
|
|
|
|
|
|
|
|
|
|
|
|
class AdminGoodsSave
|
|
|
|
|
|
{
|
|
|
|
|
|
public function handle($params = [])
|
|
|
|
|
|
{
|
|
|
|
|
|
$data = $params['data'] ?? [];
|
|
|
|
|
|
$isTicket = ($data['item_type'] ?? '') === 'ticket' ? 1 : 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 解析原有配置
|
|
|
|
|
|
$vrGoodsConfig = [];
|
|
|
|
|
|
$rawConfig = $data['vr_goods_config'] ?? '';
|
|
|
|
|
|
if (!empty($rawConfig)) {
|
|
|
|
|
|
$parsed = json_decode($rawConfig, true);
|
|
|
|
|
|
if (json_last_error() === JSON_ERROR_NONE) {
|
|
|
|
|
|
if (is_string($parsed)) {
|
|
|
|
|
|
$parsed = json_decode($parsed, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (is_array($parsed)) {
|
|
|
|
|
|
$vrGoodsConfig = $parsed;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 查询模板
|
|
|
|
|
|
$templates = Db::name(self::table('seat_templates'))
|
|
|
|
|
|
->where('status', 1)
|
|
|
|
|
|
->field('id, name, seat_map')
|
|
|
|
|
|
->select()
|
|
|
|
|
|
->toArray();
|
|
|
|
|
|
|
|
|
|
|
|
$templateData = [];
|
|
|
|
|
|
foreach ($templates as $t) {
|
|
|
|
|
|
$seatMap = json_decode($t['seat_map'] ?? '{}', true);
|
|
|
|
|
|
// 补全缺失的 room.id(老格式 seat_map 里没有 id 字段)
|
|
|
|
|
|
if (!empty($seatMap['rooms'])) {
|
|
|
|
|
|
foreach ($seatMap['rooms'] as $rIdx => &$room) {
|
|
|
|
|
|
if (empty($room['id'])) {
|
|
|
|
|
|
$room['id'] = 'room_' . $rIdx;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
unset($room);
|
|
|
|
|
|
}
|
|
|
|
|
|
$t['seat_map'] = $seatMap;
|
|
|
|
|
|
$templateData[] = $t;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$initData = [
|
|
|
|
|
|
'isTicket' => $isTicket,
|
|
|
|
|
|
'vrGoodsConfig' => $vrGoodsConfig,
|
|
|
|
|
|
'templates' => $templateData,
|
|
|
|
|
|
];
|
|
|
|
|
|
$jsonInitData = base64_encode(json_encode($initData, JSON_UNESCAPED_UNICODE));
|
|
|
|
|
|
|
|
|
|
|
|
$html = <<<EOF
|
|
|
|
|
|
<div id="vr-ticket-plugin-app" style="margin: 20px 0; border: 1px solid #ebedf0; padding: 15px; border-radius: 4px; background: #fafafa;">
|
|
|
|
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
|
|
|
|
|
<div class="am-form-group">
|
|
|
|
|
|
<label>票务商品设置 <span class="am-form-group-label-tips">(使用插件覆盖基础多规格体系)</span></label>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label class="am-checkbox-inline">
|
|
|
|
|
|
<input type="checkbox" v-model="isTicket" />
|
|
|
|
|
|
<span style="margin-left:5px;">设为在线选座的票务商品(勾选后将开启场馆节点配置)</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-show="isTicket" style="margin-top:20px; border-top:1px solid #eee; padding-top:15px;" v-cloak>
|
|
|
|
|
|
<div class="am-form-group">
|
|
|
|
|
|
<label>请选择场馆模板(可多选)</label>
|
|
|
|
|
|
<div style="margin-top:10px;">
|
|
|
|
|
|
<label v-for="t in templates" :key="t.id" class="am-checkbox-inline" style="margin-right:20px;">
|
|
|
|
|
|
<input type="checkbox" :value="t.id" v-model="selectedTemplateIds" @change="onTemplateChange" />
|
|
|
|
|
|
{{ t.name }}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="templates.length === 0" style="color:#999;font-size:12px;">暂无启用的场馆模板。</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-for="config in configs" :key="config.template_id" style="margin-top: 15px; background: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 4px;">
|
|
|
|
|
|
<p style="font-weight:bold;margin-bottom:10px;">► 场馆配置:{{ getTemplateName(config.template_id) }}</p>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 场次管理 -->
|
|
|
|
|
|
<div class="am-form-group" style="background: #fffbf0; padding: 12px; border: 1px solid #f5e79e; border-radius: 4px; margin-bottom:15px;">
|
|
|
|
|
|
<label style="font-weight:bold;">
|
|
|
|
|
|
<span style="color:#d2322d; margin-right:4px;">*</span>场次时段设置
|
|
|
|
|
|
<span style="color:#999; font-size:12px; font-weight:normal; margin-left:8px;">(至少设置一个场次)</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div style="margin-top:8px;">
|
|
|
|
|
|
<div v-for="(session, idx) in config.sessions" :key="idx"
|
|
|
|
|
|
style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
|
|
|
|
|
|
<input type="time" v-model="session.start"
|
|
|
|
|
|
@change="validateSession(session)"
|
|
|
|
|
|
class="am-form-field"
|
|
|
|
|
|
style="width:110px; display:inline-block;" />
|
|
|
|
|
|
<span style="color:#666;">至</span>
|
|
|
|
|
|
<input type="time" v-model="session.end"
|
|
|
|
|
|
@change="validateSession(session)"
|
|
|
|
|
|
class="am-form-field"
|
|
|
|
|
|
style="width:110px; display:inline-block;" />
|
|
|
|
|
|
<button type="button" class="am-btn am-btn-danger am-btn-xs"
|
|
|
|
|
|
@click="removeSession(config, idx)">删除</button>
|
|
|
|
|
|
<span v-if="session._error" style="color:red; font-size:12px;">{{ session._error }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="button" class="am-btn am-btn-default am-btn-xs"
|
|
|
|
|
|
style="margin-top:4px;" @click="addSession(config)">+ 添加场次</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="am-form-group">
|
|
|
|
|
|
<label>演播厅选择</label>
|
|
|
|
|
|
<div style="display:flex; flex-wrap:wrap; gap:15px; margin-top:5px;">
|
|
|
|
|
|
<label v-for="room in getRooms(config.template_id)" :key="room.id" class="am-checkbox-inline">
|
|
|
|
|
|
<input type="checkbox" :value="room.id" v-model="config.selected_rooms" />
|
|
|
|
|
|
<span style="margin-left:5px;">{{ room.name }}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<span v-if="getRooms(config.template_id).length === 0" style="color:#999;font-size:12px;">该场馆内无放映室/演播厅数据</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="am-form-group" v-if="config.selected_rooms.length > 0">
|
|
|
|
|
|
<label>分区选择 (仅限已选的演播厅)</label>
|
|
|
|
|
|
<div v-for="roomId in config.selected_rooms" :key="roomId" style="margin-left:20px; margin-top:10px;">
|
|
|
|
|
|
<p style="color:#666; font-size:13px; margin-bottom:5px;">• {{ getRoomName(config.template_id, roomId) }}</p>
|
|
|
|
|
|
<div style="display:flex; flex-wrap:wrap; gap:10px;">
|
|
|
|
|
|
<label v-for="sec in getSections(config.template_id, roomId)" :key="sec.char" class="am-checkbox-inline">
|
|
|
|
|
|
<input type="checkbox" :value="sec.char" v-model="config.selected_sections[roomId]" />
|
|
|
|
|
|
<span style="display:inline-block; width:12px; height:12px; margin:0 5px; vertical-align:middle;"
|
|
|
|
|
|
:style="{backgroundColor: sec.color || '#ccc'}"></span>
|
|
|
|
|
|
{{ sec.name }} ({{ sec.char }}) - ¥{{ sec.price }}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<span v-if="getSections(config.template_id, roomId).length === 0" style="color:#999;font-size:12px;">该放映室无分区数据</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<input type="hidden" name="vr_is_ticket" :value="isTicket ? 1 : 0" />
|
|
|
|
|
|
<input type="hidden" name="vr_goods_config_base64" :value="outputBase64" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
|
[v-cloak] { display: none !important; }
|
|
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
const AppData = JSON.parse(decodeURIComponent(escape(atob('{$jsonInitData}'))));
|
|
|
|
|
|
const { createApp, ref, computed, watch } = Vue;
|
|
|
|
|
|
|
|
|
|
|
|
createApp({
|
|
|
|
|
|
setup() {
|
|
|
|
|
|
const isTicket = ref(AppData.isTicket === 1);
|
|
|
|
|
|
const templates = ref(AppData.templates || []);
|
|
|
|
|
|
const configs = ref([]);
|
|
|
|
|
|
const selectedTemplateIds = ref([]);
|
|
|
|
|
|
|
|
|
|
|
|
// 辅助函数移至顶部,确保初始化时可用
|
|
|
|
|
|
const getTemplateName = (tid) => {
|
|
|
|
|
|
const t = templates.value.find(x => x.id === tid);
|
|
|
|
|
|
return t ? t.name : 'Unknown';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getRooms = (tid) => {
|
|
|
|
|
|
const t = templates.value.find(x => x.id === tid);
|
|
|
|
|
|
return (t && t.seat_map && t.seat_map.rooms) ? t.seat_map.rooms : [];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getRoomName = (tid, roomId) => {
|
|
|
|
|
|
const r = getRooms(tid).find(x => x.id === roomId);
|
|
|
|
|
|
return r ? r.name : 'Unknown Room';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getSections = (tid, roomId) => {
|
|
|
|
|
|
const r = getRooms(tid).find(x => x.id === roomId);
|
|
|
|
|
|
if (!r) return [];
|
|
|
|
|
|
if (r.sections && r.sections.length > 0) {
|
|
|
|
|
|
return r.sections.filter(s => s.char && s.char !== '_' && s.char !== '-');
|
|
|
|
|
|
}
|
|
|
|
|
|
return Object.entries(r.seats || {})
|
|
|
|
|
|
.filter(([char]) => char !== '_' && char !== '-')
|
|
|
|
|
|
.map(([char, info]) => ({
|
|
|
|
|
|
char,
|
|
|
|
|
|
name: info.name || info.label || char,
|
|
|
|
|
|
price: parseFloat(info.price) || 0,
|
|
|
|
|
|
color: info.color || '#ccc',
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const defaultSessions = () => [{ start: '08:00', end: '23:59' }];
|
|
|
|
|
|
|
|
|
|
|
|
// 还原已保存的配置并清洗历史脏数据
|
|
|
|
|
|
if (AppData.vrGoodsConfig && Array.isArray(AppData.vrGoodsConfig)) {
|
2026-04-20 04:13:29 +00:00
|
|
|
|
// 构建有效模板 ID 集合(只含 status=1 的模板)
|
|
|
|
|
|
const validTemplateIds = new Set((AppData.templates || []).map(t => t.id));
|
|
|
|
|
|
|
|
|
|
|
|
configs.value = AppData.vrGoodsConfig
|
|
|
|
|
|
// 过滤掉软删除模板的配置(幽灵配置)
|
|
|
|
|
|
.filter(c => validTemplateIds.has(c.template_id))
|
|
|
|
|
|
.map(c => {
|
2026-04-18 21:46:37 +00:00
|
|
|
|
// 确保 sessions 结构正确
|
|
|
|
|
|
if (!c.sessions || c.sessions.length === 0) {
|
|
|
|
|
|
c.sessions = defaultSessions();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!c.selected_sections) c.selected_sections = {};
|
|
|
|
|
|
|
|
|
|
|
|
// 【核心清洗】过滤非法 room ID
|
|
|
|
|
|
const validRooms = getRooms(c.template_id);
|
|
|
|
|
|
const validRoomIds = validRooms.map(r => String(r.id));
|
|
|
|
|
|
c.selected_rooms = (c.selected_rooms || [])
|
|
|
|
|
|
.map(rid => String(rid))
|
|
|
|
|
|
.filter(rid => rid && rid !== 'null' && rid !== 'undefined' && validRoomIds.includes(rid));
|
|
|
|
|
|
|
|
|
|
|
|
// 清理无效分区键
|
|
|
|
|
|
const newSections = {};
|
|
|
|
|
|
c.selected_rooms.forEach(rid => {
|
|
|
|
|
|
if (c.selected_sections[rid]) {
|
|
|
|
|
|
newSections[rid] = c.selected_sections[rid];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
c.selected_sections = newSections;
|
|
|
|
|
|
|
|
|
|
|
|
return c;
|
|
|
|
|
|
});
|
|
|
|
|
|
selectedTemplateIds.value = configs.value.map(c => c.template_id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const outputBase64 = computed(() => {
|
|
|
|
|
|
const clean = configs.value.map(c => {
|
|
|
|
|
|
const activeSections = {};
|
|
|
|
|
|
(c.selected_rooms || []).forEach(rid => {
|
|
|
|
|
|
if (c.selected_sections[rid]) {
|
|
|
|
|
|
activeSections[rid] = c.selected_sections[rid];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
selected_sections: activeSections,
|
|
|
|
|
|
sessions: (c.sessions || []).map(s => ({ start: s.start, end: s.end }))
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
return btoa(unescape(encodeURIComponent(JSON.stringify(clean))));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const onTemplateChange = () => {
|
|
|
|
|
|
const oldConfigs = [...configs.value];
|
|
|
|
|
|
configs.value = selectedTemplateIds.value.map(tid => {
|
|
|
|
|
|
const existing = oldConfigs.find(c => c.template_id === tid);
|
|
|
|
|
|
if (existing) return existing;
|
|
|
|
|
|
return {
|
|
|
|
|
|
template_id: tid,
|
|
|
|
|
|
selected_rooms: [],
|
|
|
|
|
|
selected_sections: {},
|
|
|
|
|
|
sessions: defaultSessions(),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addSession = (config) => {
|
|
|
|
|
|
config.sessions.push({ start: '08:00', end: '23:59' });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const removeSession = (config, idx) => {
|
|
|
|
|
|
if (config.sessions.length <= 1) {
|
|
|
|
|
|
alert('至少需要保留一个场次时段');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
config.sessions.splice(idx, 1);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const validateSession = (session) => {
|
|
|
|
|
|
if (session.start && session.end && session.start >= session.end) {
|
|
|
|
|
|
session._error = '结束时间必须晚于开始时间';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
delete session._error;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
watch(configs, (newConfigs) => {
|
|
|
|
|
|
newConfigs.forEach(conf => {
|
|
|
|
|
|
if (!conf.selected_sections) conf.selected_sections = {};
|
|
|
|
|
|
(conf.selected_rooms || []).forEach(roomId => {
|
|
|
|
|
|
if (roomId && !conf.selected_sections[roomId]) {
|
|
|
|
|
|
conf.selected_sections[roomId] = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}, { deep: true });
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
isTicket, templates, configs, selectedTemplateIds, outputBase64,
|
|
|
|
|
|
onTemplateChange, addSession, removeSession, validateSession,
|
|
|
|
|
|
getTemplateName, getRooms, getRoomName, getSections,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}).mount('#vr-ticket-plugin-app');
|
|
|
|
|
|
</script>
|
|
|
|
|
|
EOF;
|
|
|
|
|
|
|
|
|
|
|
|
return $html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static function table($name)
|
|
|
|
|
|
{
|
|
|
|
|
|
return 'vr_' . $name;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|