builder.blade.php 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. @extends('base.base')
  2. @section('base')
  3. <div class="main-panel">
  4. <div class="content-wrapper">
  5. <div class="page-header">
  6. <h3 class="page-title">
  7. <span class="page-title-icon bg-gradient-primary text-white mr-2"><i class="mdi mdi-view-dashboard"></i></span>
  8. 页面编排管理
  9. </h3>
  10. </div>
  11. <div class="row">
  12. <div class="col-lg-12 grid-margin stretch-card">
  13. <div class="card"><div class="card-body">
  14. {{-- ===== 顶部工具栏 ===== --}}
  15. <div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:12px;">
  16. <label>页面</label>
  17. <select id="pageIdSelect" class="form-control" style="max-width:260px;">
  18. @foreach($pages as $p)
  19. <option value="{{ $p->id }}" {{ $defaultPageId == $p->id ? 'selected' : '' }}>
  20. #{{ $p->id }} {{ $p->path }} {{ $p->title ? ('- '.$p->title) : '' }}
  21. </option>
  22. @endforeach
  23. </select>
  24. <label>模版预览</label>
  25. <select id="stateNoSelect" class="form-control" style="max-width:260px;">
  26. <option value="0">全部显示(忽略state)</option>
  27. @foreach($channels as $ch)
  28. <option value="{{ $ch->StateNo }}">
  29. StateNo={{ $ch->StateNo }} Ch={{ $ch->Channel }} {{ $ch->Remarks ? ('('.$ch->Remarks.')') : '' }}
  30. </option>
  31. @endforeach
  32. </select>
  33. <button class="btn btn-sm btn-gradient-primary" id="reloadBtn">加载</button>
  34. <span id="statusHint" style="color:#666;"></span>
  35. </div>
  36. {{-- ===== Tabs ===== --}}
  37. <ul class="nav nav-tabs" role="tablist">
  38. <li class="nav-item"><a class="nav-link active" data-toggle="tab" href="#tab-preview">页面预览编排</a></li>
  39. <li class="nav-item"><a class="nav-link" data-toggle="tab" href="#tab-games">游戏卡片编辑</a></li>
  40. <li class="nav-item"><a class="nav-link" data-toggle="tab" href="#tab-route">路由排序</a></li>
  41. <li class="nav-item"><a class="nav-link" data-toggle="tab" href="#tab-style">样式管理</a></li>
  42. </ul>
  43. <div class="tab-content" style="padding-top:16px;">
  44. {{-- ==================== 页面预览编排 ==================== --}}
  45. <div class="tab-pane fade show active" id="tab-preview">
  46. <div style="display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; align-items:center;">
  47. <button class="btn btn-sm btn-gradient-info" id="saveLayoutBtn">保存模块排序</button>
  48. <span style="color:#999; font-size:13px;">
  49. 上下拖动模块调整顺序。灰色半透明=当前模版下不显示(state不匹配)。红色虚线=state=0(已弃用)。
  50. </span>
  51. </div>
  52. <div class="site-preview-wrap">
  53. {{-- 左侧导航 --}}
  54. <div class="sp-sidebar" id="sidebarPreview"></div>
  55. {{-- 主内容区 --}}
  56. <div class="sp-main" id="mainPreview"></div>
  57. </div>
  58. </div>
  59. {{-- ==================== 游戏卡片编辑 ==================== --}}
  60. <div class="tab-pane fade" id="tab-games">
  61. <div style="display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap; align-items:center;">
  62. <label>选择游戏模块</label>
  63. <select id="gameModuleSelect" class="form-control" style="max-width:360px;"></select>
  64. <button class="btn btn-sm btn-gradient-success" id="saveGamesBtn">保存游戏排序</button>
  65. </div>
  66. <div style="display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap;">
  67. <input id="gameSearchInput" class="form-control" placeholder="搜索: id / title / brand" style="max-width:240px;">
  68. <button class="btn btn-sm btn-light" id="gameSearchBtn">搜索</button>
  69. <button class="btn btn-sm btn-light pv-switch active" data-cols="3">手机3列</button>
  70. <button class="btn btn-sm btn-light pv-switch" data-cols="5">Pad5列</button>
  71. <button class="btn btn-sm btn-light pv-switch" data-cols="7">电脑7列</button>
  72. </div>
  73. <div style="margin-bottom:6px; font-weight:600;">当前模块游戏 <span id="gameCountHint"></span></div>
  74. <div id="moduleGameCards" class="gg cols-3"></div>
  75. <hr>
  76. <div style="margin-bottom:6px; font-weight:600;">搜索结果 (点击添加)</div>
  77. <div id="gameSearchResult" class="gg cols-3"></div>
  78. </div>
  79. {{-- ==================== 路由排序 ==================== --}}
  80. <div class="tab-pane fade" id="tab-route">
  81. <div style="display:flex; gap:8px; align-items:center; margin-bottom:10px;">
  82. <label>父级路由</label>
  83. <select id="routeParentSelect" class="form-control" style="max-width:360px;"></select>
  84. <button class="btn btn-sm btn-gradient-info" id="saveRouteBtn">保存排序</button>
  85. </div>
  86. <div id="routeSortList" class="list-group"></div>
  87. </div>
  88. {{-- ==================== 样式管理 ==================== --}}
  89. <div class="tab-pane fade" id="tab-style">
  90. <table class="table table-bordered">
  91. <thead><tr><th style="width:70px;">ID</th><th style="width:150px;">备注</th><th>style JSON</th><th style="width:80px;">操作</th></tr></thead>
  92. <tbody id="styleBody"></tbody>
  93. </table>
  94. </div>
  95. </div>{{-- /tab-content --}}
  96. </div></div>
  97. </div></div>
  98. </div>
  99. </div>
  100. <style>
  101. /* ===== 模拟网站布局 ===== */
  102. .site-preview-wrap { display:flex; gap:0; border:1px solid #333; border-radius:10px; overflow:hidden; background:#1a1a2e; min-height:500px; }
  103. .sp-sidebar { width:180px; min-width:180px; background:#12122a; padding:10px 0; overflow-y:auto; max-height:700px; }
  104. .sp-main { flex:1; padding:16px; overflow-y:auto; max-height:700px; background:#1a1a2e; }
  105. .sp-sidebar .sb-item { display:flex; align-items:center; gap:6px; padding:8px 14px; color:rgba(255,255,255,0.7); font-size:13px; cursor:pointer; border-left:3px solid transparent; }
  106. .sp-sidebar .sb-item:hover { background:rgba(255,255,255,0.06); }
  107. .sp-sidebar .sb-item.active { border-left-color:#b66dff; color:#fff; }
  108. .sp-sidebar .sb-item .sb-icon { width:18px; text-align:center; font-size:14px; }
  109. /* 模块预览卡片 */
  110. .mp-module { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); border-radius:8px; padding:10px 12px; margin-bottom:10px; cursor:move; position:relative; }
  111. .mp-module.state-off { opacity:0.35; }
  112. .mp-module.state-dead { opacity:0.25; border:2px dashed #d32f2f; }
  113. .mp-module .mp-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }
  114. .mp-module .mp-head .mp-type { font-size:11px; padding:1px 6px; border-radius:3px; font-weight:600; }
  115. .mp-module .mp-head .mp-title { color:#fff; font-size:13px; font-weight:500; }
  116. .mp-module .mp-head .mp-state-tag { font-size:10px; padding:1px 5px; border-radius:3px; cursor:pointer; }
  117. .mp-module .mp-body { color:rgba(255,255,255,0.5); font-size:11px; }
  118. /* 类型颜色 */
  119. .tp-gamelist { background:#2e7d32; color:#fff; }
  120. .tp-smallgamelist { background:#1565c0; color:#fff; }
  121. .tp-banner { background:#e65100; color:#fff; }
  122. .tp-gametabs { background:#7b1fa2; color:#fff; }
  123. .tp-gametab { background:#c62828; color:#fff; }
  124. .tp-func { background:#455a64; color:#fff; }
  125. /* banner 预览 */
  126. .banner-row { display:flex; gap:8px; overflow-x:auto; padding-bottom:6px; }
  127. .banner-row img { height:80px; border-radius:6px; flex-shrink:0; }
  128. /* game tabs 预览 */
  129. .tab-row { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:6px; }
  130. .tab-pill { padding:4px 10px; border-radius:14px; font-size:11px; background:rgba(255,255,255,0.08); color:rgba(255,255,255,0.7); }
  131. .tab-pill.active { background:#b66dff; color:#fff; }
  132. /* 子模块容器 */
  133. .mp-children { margin-top:8px; padding:8px; background:rgba(0,0,0,0.15); border-radius:6px; min-height:30px; }
  134. /* 游戏缩略图行 */
  135. .game-thumb-row { display:flex; gap:6px; flex-wrap:wrap; }
  136. .game-thumb { width:60px; text-align:center; }
  137. .game-thumb img { width:56px; height:56px; object-fit:cover; border-radius:6px; background:#333; }
  138. .game-thumb .gt-name { font-size:9px; color:rgba(255,255,255,0.5); line-height:1.1; margin-top:2px; height:20px; overflow:hidden; }
  139. /* ===== state 编辑弹窗 ===== */
  140. .state-popup { position:absolute; right:0; top:30px; z-index:100; background:#fff; border:1px solid #ddd; border-radius:6px; padding:10px; min-width:240px; box-shadow:0 4px 16px rgba(0,0,0,0.15); }
  141. .state-popup label { display:flex; align-items:center; gap:6px; font-size:12px; color:#333; cursor:pointer; padding:2px 0; }
  142. .state-popup .sp-actions { display:flex; gap:6px; margin-top:8px; }
  143. /* ===== Tab 管理区 ===== */
  144. .tab-mgr { background:rgba(0,0,0,0.2); border-radius:6px; padding:8px 10px; margin-bottom:8px; }
  145. .tab-mgr-label { color:rgba(255,255,255,0.6); font-size:12px; margin-bottom:6px; }
  146. .tab-sortable { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:8px; }
  147. .tab-editable { display:inline-flex; align-items:center; gap:5px; padding:4px 8px; border-radius:16px; background:rgba(255,255,255,0.08); color:rgba(255,255,255,0.8); font-size:12px; cursor:move; border:1px solid rgba(255,255,255,0.12); }
  148. .tab-editable:hover { background:rgba(255,255,255,0.14); border-color:rgba(255,255,255,0.25); }
  149. .tab-editable .te-icon { font-size:13px; }
  150. .tab-editable .te-title { cursor:pointer; text-decoration:underline; text-decoration-style:dotted; text-underline-offset:2px; }
  151. .tab-editable .te-title:hover { color:#fff; }
  152. .tab-editable .te-info { color:rgba(255,255,255,0.35); font-size:10px; }
  153. .tab-editable .te-del { cursor:pointer; color:rgba(255,100,100,0.6); font-size:13px; margin-left:2px; }
  154. .tab-editable .te-del:hover { color:#ff5252; }
  155. .te-add-btn { background:rgba(255,255,255,0.06) !important; color:rgba(255,255,255,0.5) !important; border:1px dashed rgba(255,255,255,0.2) !important; font-size:12px !important; margin-right:6px; }
  156. .te-add-btn:hover { background:rgba(255,255,255,0.12) !important; color:#fff !important; }
  157. .te-save-order-btn { background:rgba(76,175,80,0.15) !important; color:rgba(76,175,80,0.9) !important; border:1px solid rgba(76,175,80,0.3) !important; font-size:12px !important; }
  158. .te-save-order-btn:hover { background:rgba(76,175,80,0.3) !important; }
  159. /* ===== 游戏卡片网格 ===== */
  160. .gg { display:grid; gap:8px; }
  161. .gg.cols-3 { grid-template-columns:repeat(3,minmax(0,1fr)); }
  162. .gg.cols-5 { grid-template-columns:repeat(5,minmax(0,1fr)); }
  163. .gg.cols-7 { grid-template-columns:repeat(7,minmax(0,1fr)); }
  164. .gc { border:1px solid #eee; background:#fff; border-radius:6px; padding:6px; cursor:move; min-height:90px; }
  165. .gc img { width:100%; min-height:156px; object-fit:cover; border-radius:4px; background:#f3f3f3; }
  166. .gc .gc-t { font-size:11px; line-height:1.2; margin-top:3px; height:28px; overflow:hidden; }
  167. .pv-switch.active { border:1px solid #b66dff; color:#b66dff; }
  168. #routeSortList .list-group-item { cursor:move; }
  169. </style>
  170. <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
  171. <script>
  172. const GAME_TYPES = ['ModuleGameList','ModuleSmallGameList','ModuleRollSmallGameList'];
  173. const TYPE_MAP = {
  174. 'ModuleAutoBanner': {cls:'tp-banner', label:'Banner', desc:'轮播Banner'},
  175. 'ModuleWinList': {cls:'tp-func', label:'WinList', desc:'中奖榜单'},
  176. 'ModuleSearch': {cls:'tp-func', label:'Search', desc:'搜索入口'},
  177. 'ModuleGameTabs': {cls:'tp-gametabs', label:'GameTabs', desc:'游戏分类Tab容器'},
  178. 'GameTab': {cls:'tp-gametab', label:'Tab标签', desc:'Tab标签'},
  179. 'ModuleHomeWithdraw': {cls:'tp-func', label:'Withdraw', desc:'提现入口'},
  180. 'ModuleGameList': {cls:'tp-gamelist', label:'GameList', desc:'完整游戏列表'},
  181. 'ModuleSmallGameList': {cls:'tp-smallgamelist', label:'SmallList', desc:'小游戏预览'},
  182. 'ModuleRollSmallGameList':{cls:'tp-smallgamelist', label:'RollSmall', desc:'滚动小游戏'},
  183. };
  184. function tm(type) { return TYPE_MAP[type] || {cls:'tp-func', label:type, desc:type}; }
  185. function isGameType(type) { return GAME_TYPES.includes(type); }
  186. function esc(s) { return $('<div/>').text(s||'').html(); }
  187. function csrf() { return $('meta[name="csrf-token"]').attr('content'); }
  188. function hint(msg) { $('#statusHint').text(msg); setTimeout(()=>{ if($('#statusHint').text()===msg) $('#statusHint').text(''); },2500); }
  189. function pint(v,d) { const n=Number(v); return Number.isFinite(n)?n:d; }
  190. const S = {
  191. pageId: {{ (int)$defaultPageId }},
  192. stateNo: 0,
  193. routes:[], modules:[], pageModules:[], moduleParents:[], moduleGames:{}, styles:[], banners:[], channels:[],
  194. selGameModule: null,
  195. };
  196. function stateVisible(stateVal) {
  197. if (S.stateNo === 0) return true;
  198. return (stateVal & S.stateNo) === S.stateNo;
  199. }
  200. function stateDead(stateVal) { return stateVal === 0; }
  201. function stateTagHtml(table, id, stateVal) {
  202. const dead = stateDead(stateVal);
  203. const visible = stateVisible(stateVal);
  204. const bg = dead ? '#d32f2f' : (visible ? '#4caf50' : '#ff9800');
  205. const txt = dead ? '弃用' : (visible ? '显示' : '隐藏');
  206. const bits = stateVal.toString(2);
  207. return '<span class="mp-state-tag" style="background:'+bg+';color:#fff;" onclick="openStateEditor(event,\''+table+'\','+id+','+stateVal+')" title="state='+stateVal+' (0b'+bits+')">'+txt+' s:'+stateVal+'</span>';
  208. }
  209. // ==================== state 编辑器弹窗 ====================
  210. function openStateEditor(e, table, id, currentState) {
  211. e.stopPropagation();
  212. $('.state-popup').remove();
  213. const channels = S.channels || [];
  214. const uniqueStates = [];
  215. const seen = {};
  216. channels.forEach(ch => {
  217. if (!seen[ch.StateNo]) { seen[ch.StateNo] = true; uniqueStates.push(ch); }
  218. });
  219. let checkboxes = uniqueStates.map(ch => {
  220. const bit = Number(ch.StateNo);
  221. const checked = (currentState & bit) === bit ? 'checked' : '';
  222. return '<label><input type="checkbox" data-bit="'+bit+'" '+checked+'> StateNo='+bit+' (Ch '+ch.Channel + (ch.Remarks ? ' '+esc(ch.Remarks) : '') +')</label>';
  223. }).join('');
  224. const popup = $('<div class="state-popup">' +
  225. '<div style="font-weight:600;margin-bottom:6px;">State 模版选择 <small style="color:#999;">位运算</small></div>' +
  226. '<div style="margin-bottom:4px;"><label><input type="checkbox" id="stateAllCheck"> 全选/全不选</label></div>' +
  227. checkboxes +
  228. '<div style="margin-top:4px;"><label style="color:#d32f2f;"><input type="checkbox" id="stateDeadCheck" '+(currentState===0?'checked':'')+
  229. '> 设为弃用(state=0)</label></div>' +
  230. '<div class="sp-actions">' +
  231. '<button class="btn btn-sm btn-gradient-primary" id="stateSaveBtn">保存</button>' +
  232. '<button class="btn btn-sm btn-light" onclick="$(\'.state-popup\').remove()">取消</button>' +
  233. '</div></div>');
  234. $(e.target).closest('.mp-module,.list-group-item,tr').css('position','relative').append(popup);
  235. popup.find('#stateAllCheck').on('change', function() {
  236. popup.find('input[data-bit]').prop('checked', this.checked);
  237. });
  238. popup.find('#stateDeadCheck').on('change', function() {
  239. if (this.checked) popup.find('input[data-bit]').prop('checked', false);
  240. });
  241. popup.find('input[data-bit]').on('change', function() {
  242. popup.find('#stateDeadCheck').prop('checked', false);
  243. });
  244. popup.find('#stateSaveBtn').on('click', async function() {
  245. let newState = 0;
  246. if (popup.find('#stateDeadCheck').is(':checked')) {
  247. newState = 0;
  248. } else {
  249. popup.find('input[data-bit]:checked').each(function() { newState |= Number($(this).attr('data-bit')); });
  250. }
  251. const res = await $.ajax({
  252. url:'/admin/game_site/state', method:'POST',
  253. headers:{'X-CSRF-TOKEN':csrf()},
  254. data:{table, id, state:newState}
  255. });
  256. alert(res.msg||'已保存');
  257. popup.remove();
  258. await loadAll();
  259. });
  260. }
  261. // ==================== 加载 ====================
  262. async function loadAll() {
  263. const res = await $.get('/admin/game_site/data?page_id='+S.pageId);
  264. if (res.code!==200) { alert(res.msg||'失败'); return; }
  265. Object.assign(S, res.data);
  266. renderSidebar(); renderMain(); renderGameModuleSelect(); renderRoutes(); renderStyles();
  267. hint('已加载');
  268. }
  269. // ==================== 侧边栏(仿站点) ====================
  270. function renderSidebar() {
  271. const sideRoutes = S.routes.filter(r => Number(r.side)===1 && r.parent_id===null)
  272. .sort((a,b) => Number(a.index)-Number(b.index));
  273. let html = '<div class="sb-item active"><div class="sb-icon">🏠</div> 首页</div>';
  274. sideRoutes.forEach(r => {
  275. const vis = stateVisible(r.state);
  276. const dead = stateDead(r.state);
  277. const opacity = dead ? 'opacity:0.2;' : (!vis ? 'opacity:0.4;' : '');
  278. html += '<div class="sb-item" style="'+opacity+'" title="state='+r.state+'">' +
  279. '<div class="sb-icon">📁</div>' + esc(r.title||r.path) +
  280. '</div>';
  281. });
  282. $('#sidebarPreview').html(html);
  283. }
  284. // ==================== 主内容区(仿站点布局) ====================
  285. function renderMain() {
  286. const byId = {}; S.modules.forEach(m => byId[m.id]=m);
  287. const links = S.module_parents||[];
  288. const p2c = {};
  289. links.forEach(l => { const p=Number(l.parent_id), c=Number(l.module_id); if(!p2c[p])p2c[p]=[]; if(!p2c[p].includes(c))p2c[p].push(c); });
  290. const linkedSet = new Set(links.map(l => Number(l.module_id)));
  291. const roots = S.page_modules.filter(m => !linkedSet.has(Number(m.id)))
  292. .sort((a,b) => Number(a.pos_index)-Number(b.pos_index));
  293. let html = '<div class="module-sortable" data-container="root">';
  294. roots.forEach(m => { html += renderModulePreview(m, byId, p2c); });
  295. html += '</div>';
  296. $('#mainPreview').html(html);
  297. document.querySelectorAll('.module-sortable').forEach(el => {
  298. Sortable.create(el, { group:'module-layout', animation:140, handle:'.mp-head' });
  299. });
  300. document.querySelectorAll('.tab-sortable').forEach(el => {
  301. Sortable.create(el, { animation:120 });
  302. });
  303. bindTabEvents();
  304. }
  305. function renderModulePreview(m, byId, p2c) {
  306. const meta = tm(m.type);
  307. const vis = stateVisible(m.state);
  308. const dead = stateDead(m.state);
  309. const cls = dead ? 'state-dead' : (!vis ? 'state-off' : '');
  310. let inner = '';
  311. if (m.type === 'ModuleAutoBanner') {
  312. const mBanners = (S.banners||[]).filter(b => Number(b.link_module)===Number(m.id) && stateVisible(b.state))
  313. .sort((a,b) => Number(b.b_order)-Number(a.b_order));
  314. if (mBanners.length) {
  315. inner = '<div class="banner-row">' + mBanners.map(b =>
  316. '<img src="'+esc(b.img)+'" alt="'+esc(b.alt)+'" title="'+esc(b.alt)+'">'
  317. ).join('') + '</div>';
  318. } else {
  319. inner = '<div style="color:rgba(255,255,255,0.3);">无Banner</div>';
  320. }
  321. } else if (m.type === 'ModuleGameTabs') {
  322. const childIds = (p2c[m.id]||[]).filter(id => !!byId[id]);
  323. childIds.sort((a,b) => Number(byId[a].pos_index)-Number(byId[b].pos_index));
  324. const tabs = childIds.map(id => byId[id]).filter(c => c.type==='GameTab');
  325. const nonTabs = childIds.map(id => byId[id]).filter(c => c.type!=='GameTab');
  326. inner = '<div class="tab-mgr" data-tabs-parent="'+m.id+'">';
  327. inner += '<div class="tab-mgr-label">Tab标签管理 <small style="color:rgba(255,255,255,0.4);">(拖拽排序 | 点击编辑 | ✕删除)</small></div>';
  328. inner += '<div class="tab-sortable" data-tabs-parent="'+m.id+'">';
  329. tabs.forEach(t => {
  330. const tVis = stateVisible(t.state); const tDead = stateDead(t.state);
  331. const tabOpacity = tDead ? 'opacity:0.3;' : (!tVis ? 'opacity:0.5;' : '');
  332. inner += '<div class="tab-editable" data-id="'+t.id+'" style="'+tabOpacity+'">' +
  333. '<span class="te-icon" title="icon: '+esc(t.icon)+'">'+(t.icon ? '🎮' : '📑')+'</span>' +
  334. '<span class="te-title" title="点击编辑">'+esc(t.title||t.icon||'Tab')+'</span>' +
  335. '<span class="te-info">type='+t.tabtype+'</span>' +
  336. stateTagHtml('modules', t.id, t.state) +
  337. '<span class="te-del" data-id="'+t.id+'" title="删除此Tab">✕</span>' +
  338. '</div>';
  339. });
  340. inner += '</div>';
  341. inner += '<button class="btn btn-sm te-add-btn" data-parent="'+m.id+'">+ 新增Tab</button>';
  342. inner += '<button class="btn btn-sm te-save-order-btn" data-parent="'+m.id+'">💾 保存Tab排序</button>';
  343. inner += '</div>';
  344. inner += '<div class="mp-children module-sortable" data-container="m_'+m.id+'">';
  345. nonTabs.forEach(c => { inner += renderModulePreview(c, byId, p2c); });
  346. inner += '</div>';
  347. } else if (isGameType(m.type)) {
  348. const games = S.module_games[m.id] || [];
  349. const show = games.slice(0, 7);
  350. inner = '<div class="game-thumb-row">' + show.map(g =>
  351. '<div class="game-thumb"><img src="'+esc(g.img)+'"><div class="gt-name">'+esc(g.title)+'</div></div>'
  352. ).join('') + (games.length>7 ? '<div class="game-thumb" style="display:flex;align-items:center;color:rgba(255,255,255,0.4);">+' + (games.length-7) + '</div>' : '') + '</div>';
  353. } else if (m.type === 'ModuleHomeWithdraw') {
  354. inner = '<div style="display:flex;align-items:center;gap:8px;"><span style="color:rgba(255,255,255,0.5);">💰 Total Free Bonus $0</span><span style="background:#4caf50;color:#fff;padding:3px 12px;border-radius:14px;font-size:12px;">Withdraw</span></div>';
  355. } else if (m.type === 'ModuleSearch') {
  356. inner = '<div style="background:rgba(255,255,255,0.06);border-radius:6px;padding:6px 12px;color:rgba(255,255,255,0.3);">🔍 Search games...</div>';
  357. }
  358. return '<div class="mp-module '+cls+'" data-id="'+m.id+'">' +
  359. '<div class="mp-head">' +
  360. '<div><span class="mp-type '+meta.cls+'">'+meta.label+'</span> ' +
  361. '<span class="mp-title">#'+m.id+' '+esc(m.title||'-')+'</span></div>' +
  362. '<div>'+stateTagHtml('modules', m.id, m.state)+'</div>' +
  363. '</div>' +
  364. '<div class="mp-body">'+inner+'</div>' +
  365. '</div>';
  366. }
  367. // ==================== 游戏编辑器 ====================
  368. function renderGameModuleSelect() {
  369. const gms = S.modules.filter(m => isGameType(m.type));
  370. const opts = gms.map(m => '<option value="'+m.id+'">['+tm(m.type).label+'] #'+m.id+' '+esc(m.title||'-')+' ('+(S.module_games[m.id]||[]).length+'个游戏)</option>');
  371. $('#gameModuleSelect').html(opts.length ? opts.join('') : '<option value="">无游戏模块</option>');
  372. if (gms.length && (!S.selGameModule || !gms.some(m=>Number(m.id)===S.selGameModule))) S.selGameModule = Number(gms[0].id);
  373. if (S.selGameModule) { $('#gameModuleSelect').val(String(S.selGameModule)); renderMG(S.selGameModule); }
  374. }
  375. function gcHtml(g, removable) {
  376. const btn = removable
  377. ? '<button class="btn btn-sm btn-light remove-gc" data-id="'+g.id+'" style="padding:0 4px;margin-top:3px;">移除</button>'
  378. : '<button class="btn btn-sm btn-outline-success add-gc" data-id="'+g.id+'" style="padding:0 4px;margin-top:3px;">添加</button>';
  379. return '<div class="gc" data-id="'+g.id+'"><img src="'+esc(g.img)+'"><div class="gc-t">#'+g.id+' '+esc(g.title)+'</div><small>'+esc(g.brand)+'</small><br>'+btn+'</div>';
  380. }
  381. function renderMG(mid) {
  382. const gs = S.module_games[mid]||[];
  383. $('#gameCountHint').text('('+gs.length+'个)');
  384. const el = document.getElementById('moduleGameCards');
  385. $(el).html(gs.map(g=>gcHtml(g,true)).join('')||'<div style="color:#999;padding:10px;">暂无游戏</div>');
  386. Sortable.create(el, {animation:120, filter:'.remove-gc'});
  387. $('#moduleGameCards .remove-gc').off('click').on('click', function(){
  388. const gid = pint($(this).attr('data-id'),0);
  389. S.module_games[mid] = (S.module_games[mid]||[]).filter(g=>Number(g.id)!==gid);
  390. renderMG(mid);
  391. });
  392. }
  393. async function searchGames() {
  394. const q=$('#gameSearchInput').val()||'';
  395. const res = await $.get('/admin/game_site/games/search?q='+encodeURIComponent(q)+'&limit=60');
  396. if(res.code!==200){ alert(res.msg); return; }
  397. const list = res.data||[];
  398. $('#gameSearchResult').html(list.map(g=>gcHtml(g,false)).join(''));
  399. $('#gameSearchResult .add-gc').off('click').on('click',function(){
  400. const gid=pint($(this).attr('data-id'),0);
  401. const hit=list.find(v=>Number(v.id)===gid);
  402. if(!hit||!S.selGameModule) return;
  403. const arr=S.module_games[S.selGameModule]||[];
  404. if(arr.some(v=>Number(v.id)===gid)){ hint('已在列表中'); return; }
  405. arr.push(hit); S.module_games[S.selGameModule]=arr;
  406. renderMG(S.selGameModule); hint('#'+gid+' 已添加');
  407. });
  408. }
  409. // ==================== 路由排序 ====================
  410. function renderRoutes() {
  411. const opts = ['<option value="">顶级路由</option>'];
  412. S.routes.forEach(r => { opts.push('<option value="'+r.id+'">#'+r.id+' '+esc(r.path)+(r.title?' - '+esc(r.title):'')+'</option>'); });
  413. $('#routeParentSelect').html(opts.join(''));
  414. renderRouteList();
  415. }
  416. function renderRouteList() {
  417. const pv=$('#routeParentSelect').val(); const pid = pv===''?null:pint(pv,null);
  418. const list = S.routes.filter(r=>{ const rp=r.parent_id===null?null:Number(r.parent_id); return rp===pid; })
  419. .sort((a,b)=>Number(a.index)-Number(b.index));
  420. const html = list.map(r => {
  421. const vis=stateVisible(r.state); const dead=stateDead(r.state);
  422. const bg = dead?'background:rgba(211,47,47,0.08);':(!vis?'background:rgba(0,0,0,0.03);':'');
  423. return '<div class="list-group-item" data-id="'+r.id+'" style="position:relative;'+bg+'">' +
  424. '<span>#'+r.id+' '+esc(r.path)+(r.title?' - '+esc(r.title):'')+'</span> '+
  425. '<small style="color:#999;">idx='+r.index+' style='+r.style+'</small> '+
  426. stateTagHtml('routes', r.id, r.state) +
  427. '</div>';
  428. }).join('');
  429. $('#routeSortList').html(html||'<div class="text-muted">无路由</div>');
  430. const el=document.getElementById('routeSortList');
  431. if(el&&list.length) Sortable.create(el,{animation:120});
  432. }
  433. // ==================== 样式 ====================
  434. function renderStyles() {
  435. const rows = (S.styles||[]).map(s =>
  436. '<tr><td>'+s.styleid+'</td><td>'+esc(s.remarks)+'</td>' +
  437. '<td><pre style="max-height:160px;overflow:auto;margin:0;white-space:pre-wrap;font-size:11px;">'+esc(s.style)+'</pre></td>' +
  438. '<td><button class="btn btn-sm btn-gradient-info ed-style" data-id="'+s.styleid+'">编辑</button></td></tr>'
  439. ).join('');
  440. $('#styleBody').html(rows);
  441. $('.ed-style').off('click').on('click', async function(){
  442. const id=pint($(this).attr('data-id'),0);
  443. const obj=S.styles.find(v=>Number(v.styleid)===id); if(!obj)return;
  444. const sv=prompt('style JSON',obj.style||''); if(sv===null)return;
  445. const rv=prompt('备注 remarks',obj.remarks||''); if(rv===null)return;
  446. await $.ajax({url:'/admin/game_site/styles/'+id,method:'POST',headers:{'X-CSRF-TOKEN':csrf()},data:{style:sv,remarks:rv}});
  447. await loadAll();
  448. });
  449. }
  450. // ==================== Tab 管理事件 ====================
  451. function bindTabEvents() {
  452. $('.te-title').off('click').on('click', function(e) {
  453. e.stopPropagation();
  454. const tabEl = $(this).closest('.tab-editable');
  455. const tabId = pint(tabEl.attr('data-id'), 0);
  456. const tabModule = S.modules.find(m => Number(m.id) === tabId);
  457. if (!tabModule) return;
  458. const newTitle = prompt('Tab 标题', tabModule.title || '');
  459. if (newTitle === null) return;
  460. const newIcon = prompt('Tab 图标 (icon名称)', tabModule.icon || '');
  461. if (newIcon === null) return;
  462. const newTabtype = prompt('Tab tabtype (关联游戏类目编号)', String(tabModule.tabtype || 0));
  463. if (newTabtype === null) return;
  464. $.ajax({
  465. url: '/admin/game_site/tabs/' + tabId + '/update',
  466. method: 'POST',
  467. headers: {'X-CSRF-TOKEN': csrf()},
  468. data: {title: newTitle, icon: newIcon, tabtype: Number(newTabtype)}
  469. }).then(res => {
  470. hint(res.msg || '已更新');
  471. loadAll();
  472. });
  473. });
  474. $('.te-del').off('click').on('click', function(e) {
  475. e.stopPropagation();
  476. const tabId = pint($(this).attr('data-id'), 0);
  477. if (!confirm('确定删除 Tab #' + tabId + '?此操作不可撤销。')) return;
  478. $.ajax({
  479. url: '/admin/game_site/tabs/' + tabId + '/delete',
  480. method: 'POST',
  481. headers: {'X-CSRF-TOKEN': csrf()}
  482. }).then(res => {
  483. hint(res.msg || '已删除');
  484. loadAll();
  485. });
  486. });
  487. $('.te-add-btn').off('click').on('click', function(e) {
  488. e.stopPropagation();
  489. const parentId = pint($(this).attr('data-parent'), 0);
  490. const title = prompt('新 Tab 标题');
  491. if (!title) return;
  492. const icon = prompt('图标 (icon名称,可留空)', '') || '';
  493. const tabtype = prompt('tabtype (关联游戏类目编号)', '0');
  494. if (tabtype === null) return;
  495. $.ajax({
  496. url: '/admin/game_site/tabs/create',
  497. method: 'POST',
  498. headers: {'X-CSRF-TOKEN': csrf()},
  499. data: {parent_id: parentId, title, icon, tabtype: Number(tabtype), state: 16383}
  500. }).then(res => {
  501. hint(typeof res.msg === 'string' ? res.msg : '已创建');
  502. loadAll();
  503. });
  504. });
  505. $('.te-save-order-btn').off('click').on('click', function(e) {
  506. e.stopPropagation();
  507. const parentId = pint($(this).attr('data-parent'), 0);
  508. const sortable = $(this).siblings('.tab-sortable');
  509. const ids = [];
  510. sortable.children('.tab-editable').each(function() {
  511. ids.push(pint($(this).attr('data-id'), 0));
  512. });
  513. if (!ids.length) { alert('无Tab可排序'); return; }
  514. $.ajax({
  515. url: '/admin/game_site/tabs/reorder',
  516. method: 'POST',
  517. headers: {'X-CSRF-TOKEN': csrf()},
  518. data: {parent_id: parentId, ordered_ids: ids}
  519. }).then(res => {
  520. hint(res.msg || '排序已保存');
  521. loadAll();
  522. });
  523. });
  524. }
  525. // ==================== 保存动作 ====================
  526. async function saveLayout() {
  527. const items=[];
  528. $('.module-sortable').each(function(){
  529. const c=$(this).attr('data-container');
  530. const pid = c==='root'?null:pint(c.replace('m_',''),null);
  531. $(this).children('.mp-module').each(function(idx){
  532. items.push({module_id:pint($(this).attr('data-id'),0), parent_id:pid, pos_index:idx+1});
  533. });
  534. });
  535. if(!items.length){alert('无内容');return;}
  536. const res=await $.ajax({url:'/admin/game_site/modules/layout',method:'POST',headers:{'X-CSRF-TOKEN':csrf()},data:{page_id:S.pageId,items}});
  537. alert(res.msg||'OK'); await loadAll();
  538. }
  539. async function saveGames() {
  540. if(!S.selGameModule){alert('选择模块');return;}
  541. const ids=[]; $('#moduleGameCards .gc').each(function(){ids.push(pint($(this).attr('data-id'),0));});
  542. const res=await $.ajax({url:'/admin/game_site/modules/'+S.selGameModule+'/games',method:'POST',headers:{'X-CSRF-TOKEN':csrf()},data:{game_ids:ids.filter(v=>v>0)}});
  543. alert(res.msg||'OK'); await loadAll();
  544. }
  545. async function saveRouteOrder() {
  546. const pv=$('#routeParentSelect').val(); const pid=pv===''?null:pint(pv,null);
  547. const ids=[]; $('#routeSortList .list-group-item').each(function(){ids.push(pint($(this).attr('data-id'),0));});
  548. if(!ids.length){alert('无内容');return;}
  549. const res=await $.ajax({url:'/admin/game_site/routes/reorder',method:'POST',headers:{'X-CSRF-TOKEN':csrf()},data:{parent_id:pid,ordered_ids:ids}});
  550. alert(res.msg||'OK'); await loadAll();
  551. }
  552. // ==================== 事件 ====================
  553. $(function(){
  554. $('#pageIdSelect').on('change',function(){ S.pageId=pint($(this).val(),S.pageId); });
  555. $('#stateNoSelect').on('change',function(){ S.stateNo=pint($(this).val(),0); renderSidebar(); renderMain(); renderRouteList(); hint('模版视角切换: StateNo='+S.stateNo); });
  556. $('#reloadBtn').on('click',()=>loadAll());
  557. $('#saveLayoutBtn').on('click',()=>saveLayout());
  558. $('#saveGamesBtn').on('click',()=>saveGames());
  559. $('#saveRouteBtn').on('click',()=>saveRouteOrder());
  560. $('#routeParentSelect').on('change',()=>renderRouteList());
  561. $('#gameModuleSelect').on('change',function(){ S.selGameModule=pint($(this).val(),null); if(S.selGameModule)renderMG(S.selGameModule); });
  562. $('#gameSearchBtn').on('click',()=>searchGames());
  563. $('#gameSearchInput').on('keypress',function(e){ if(e.which===13) searchGames(); });
  564. $('.pv-switch').on('click',function(){
  565. const c=$(this).attr('data-cols');
  566. ['#moduleGameCards','#gameSearchResult'].forEach(s=>{ $(s).removeClass('cols-3 cols-5 cols-7').addClass('cols-'+c); });
  567. $('.pv-switch').removeClass('active'); $(this).addClass('active');
  568. });
  569. loadAll();
  570. });
  571. </script>
  572. @endsection