vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/hook/AdminGoodsSave.php

457 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?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;
}
// 查询 level=2 城市列表(用于动态替换 produce_region 下拉选项)
$cityList = Db::name('Region')
->field('id, pid, name, level, letters')
->where(['level' => 2, 'is_enable' => 1])
->order('sort asc, id asc')
->select()
->toArray();
// 获取已保存的生产地/城市 ID用于回显
$savedProduceRegion = intval($data['produce_region'] ?? 0);
$initData = [
'isTicket' => $isTicket,
'vrGoodsConfig' => $vrGoodsConfig,
'templates' => $templateData,
'cityList' => $cityList,
'savedProduceRegion' => $savedProduceRegion,
];
$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)) {
// 构建有效模板 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 => {
// 确保 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;
}
};
// ── 原生字段强制必填控制(票务商品专用) ──
// 当 isTicket 勾选时:强制 batch_number_expire、coding、produce_region 为必填
// 当 isTicket 取消时:恢复原始状态(仅移除插件添加的 required
const applyTicketRequired = (required) => {
const fields = [
{ name: 'batch_number_expire', label: '演出日期(批号有效期)', origLabel: '批号有效期' },
{ name: 'coding', label: '演出编码(商品编码)', origLabel: '商品编码' },
{ name: 'produce_region', label: '演出城市(生产地)', origLabel: '生产地' },
];
fields.forEach(({ name, label, origLabel }) => {
const input = document.querySelector('input[name="' + name + '"], select[name="' + name + '"]');
if (!input) return;
const formGroup = input.closest('.am-form-group');
if (required) {
//覆盖 label 文字(追加新名称,保留原名称)
if (formGroup) {
const labelEl = formGroup.querySelector(':scope > label');
if (labelEl && !labelEl.getAttribute('data-vr-orig-label')) {
// 保存原始 label 并覆盖
const origText = labelEl.textContent.trim();
labelEl.setAttribute('data-vr-orig-label', origText);
labelEl.textContent = label;
}
}
// 添加必填
if (!input.hasAttribute('required')) {
input.setAttribute('required', '');
input.setAttribute('data-vr-required-src', '1');
}
// 添加红 * 视觉提示
if (formGroup) {
const labelEl = formGroup.querySelector(':scope > label');
if (labelEl && !labelEl.querySelector('.vr-ticket-field-star')) {
const star = document.createElement('span');
star.className = 'vr-ticket-field-star';
star.textContent = ' *';
star.style.cssText = 'color:#d2322d;font-weight:bold;';
labelEl.appendChild(star);
}
}
// 添加 input 占位提示
if (!input.getAttribute('placeholder') || input.getAttribute('placeholder').indexOf('票务') === -1) {
input.setAttribute('data-vr-orig-placeholder', input.getAttribute('placeholder') || '');
input.setAttribute('placeholder', '票务商品「' + label.split('')[0] + '」必填');
}
} else {
// 仅移除插件添加的 required不影响后台模板预置的 required
if (input.getAttribute('data-vr-required-src') === '1') {
input.removeAttribute('required');
input.removeAttribute('data-vr-required-src');
}
// 恢复原生 label
if (formGroup) {
const labelEl = formGroup.querySelector(':scope > label');
if (labelEl && labelEl.hasAttribute('data-vr-orig-label')) {
labelEl.textContent = labelEl.getAttribute('data-vr-orig-label');
labelEl.removeAttribute('data-vr-orig-label');
}
const star = formGroup.querySelector('.vr-ticket-field-star');
if (star) star.remove();
}
// 恢复原生 placeholder
if (input.hasAttribute('data-vr-orig-placeholder')) {
input.setAttribute('placeholder', input.getAttribute('data-vr-orig-placeholder'));
input.removeAttribute('data-vr-orig-placeholder');
}
}
});
};
// 备份原始的 produce_region 选项 HTML
let originalRegionOptionsHtml = '';
// ── 动态替换 produce_region 下拉选项为 level=2 城市 ──
const replaceCityOptions = () => {
const select = document.querySelector('select[name="produce_region"]');
if (!select) return;
// 备份原始选项 HTML仅在第一次被替换前备份
if (!originalRegionOptionsHtml) {
originalRegionOptionsHtml = select.innerHTML;
}
// 优先使用 PHP 传递的保存值(城市 ID这是最可靠的数据源
const savedValue = AppData.savedProduceRegion || 0;
// 清空所有选项
select.innerHTML = '<option value="">请选择</option>';
// 填充 level=2 城市列表
const cities = AppData.cityList || [];
cities.forEach(city => {
const opt = document.createElement('option');
opt.value = city.id;
opt.textContent = city.name;
// 如果当前城市 ID 等于保存的值,设置为选中
if (savedValue > 0 && String(savedValue) === String(city.id)) {
opt.selected = true;
}
select.appendChild(opt);
});
// 触发chosen组件更新如果使用了chosen插件
if ($.fn.chosen) {
$(select).trigger('chosen:updated');
}
};
const restoreCityOptions = () => {
const select = document.querySelector('select[name="produce_region"]');
if (!select || !originalRegionOptionsHtml) return;
select.innerHTML = originalRegionOptionsHtml;
if ($.fn.chosen) {
$(select).trigger('chosen:updated');
}
};
watch(isTicket, (val) => {
applyTicketRequired(val);
if (val) {
// 票务商品模式:替换城市下拉选项
replaceCityOptions();
} else {
// 恢复原始选项
restoreCityOptions();
}
}, { immediate: true });
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;
}
}