From 037f7279354f1240960035bb82f5c33a89056ec7 Mon Sep 17 00:00:00 2001 From: Firefly Date: Tue, 13 Jan 2026 15:11:27 +0800 Subject: [PATCH 1/2] 111 --- component/auth.html | 13 ++-- component/contact.html | 29 +++++++ component/footer.html | 8 +- component/header.html | 1 + component/room-history.html | 16 ++++ component/window.js | 151 +++++++++++++++++++++++++++++++++--- css/style.css | 81 +++++++++++++++++++ index.html | 21 ++--- js/agreement_update.js | 21 +++++ js/auth-window.js | 136 ++++++++++++++++++++++++++++++++ js/auto_update.js | 22 ++++++ js/contact-window.js | 44 +++++++++++ js/message.js | 70 +++++++++++++++++ js/room-history.js | 58 ++++++++++++++ js/rooms.js | 7 +- js/table-component.js | 57 ++++++++++++++ js/top.js | 59 ++++++++------ js/window-links.js | 119 +++++++++++++++++++++------- privacy.html | 2 +- ranks.html | 32 ++++---- rooms.html | 44 ++++++----- top.html | 36 +++++---- 22 files changed, 895 insertions(+), 132 deletions(-) create mode 100644 component/contact.html create mode 100644 component/room-history.html create mode 100644 js/agreement_update.js create mode 100644 js/auth-window.js create mode 100644 js/auto_update.js create mode 100644 js/contact-window.js create mode 100644 js/message.js create mode 100644 js/room-history.js create mode 100644 js/table-component.js diff --git a/component/auth.html b/component/auth.html index ceca494..ea74a90 100644 --- a/component/auth.html +++ b/component/auth.html @@ -1,5 +1,5 @@ - - diff --git a/component/room-history.html b/component/room-history.html new file mode 100644 index 0000000..26fe8de --- /dev/null +++ b/component/room-history.html @@ -0,0 +1,16 @@ + diff --git a/component/window.js b/component/window.js index 9ab820e..877be9b 100644 --- a/component/window.js +++ b/component/window.js @@ -116,7 +116,7 @@ class MacWindow extends HTMLElement { /* default content styling */ .content { padding: 12px; - height: calc(100% - 12px); + height: calc(100% - 12px - 40px); overflow: auto; } @@ -125,16 +125,27 @@ class MacWindow extends HTMLElement { padding: 0; overflow: hidden; /* iframe handles its own scrolling */ } - :host([embedded]) ::slotted(iframe) { - width: 100%; - height: 100%; - border: 0; - display: block; - -webkit-overflow-scrolling: touch; - scrollbar-width: thin; - } - /* try to hide iframe element scrollbars in webkit (note: doesn't change inner page scrollbars) */ - :host([embedded]) ::slotted(iframe)::-webkit-scrollbar { display: none; } + :host([embedded]) ::slotted(iframe) { + width: 100%; + height: 100%; + border: 0; + display: block; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + } + /* try to hide iframe element scrollbars in webkit (note: doesn't change inner page scrollbars) */ + :host([embedded]) ::slotted(iframe)::-webkit-scrollbar { display: none; } + /* loading overlay appended as a slotted element */ + :host([embedded]) ::slotted(.window-loader) { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.28); + z-index: 5; + pointer-events: none; + } /* small title style for demo */ ::slotted(h2) { @@ -145,6 +156,25 @@ class MacWindow extends HTMLElement { } ::slotted(p) { color: rgba(255,255,255,0.85); margin: 0; } + .titlebar { + height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + gap: 8px; + border-bottom: 1px solid rgba(255,255,255,0.04); + background: linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.06)); + z-index: 3; + } + .nav-left, .nav-right { display:flex; align-items:center; gap:6px; } + .nav-btn { background: rgba(255,255,255,0.04); border: none; color: #fff; padding:6px 8px; border-radius:6px; cursor:pointer } + .title-center { flex:1; display:flex; flex-direction:column; align-items:center; overflow:hidden } + .title-text { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600 } + .address-text { font-size:0.75rem; opacity:0.8; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:70% } + .open-link { color: #aee; text-decoration:none; padding:4px 6px; border-radius:6px; background: rgba(255,255,255,0.02) } + .resizer { position: absolute; right: 6px; bottom: 6px; width: 18px; height: 18px; cursor: se-resize; z-index: 4; border-radius:4px; background: rgba(255,255,255,0.03) } + @media (max-width: 720px) { .stage { left: 50%; top: 50%; transform: translate(-50%, -50%) scale(1); } .window { border-radius: 10px; } @@ -157,10 +187,24 @@ class MacWindow extends HTMLElement {
`; @@ -180,6 +224,9 @@ class MacWindow extends HTMLElement { onKey: this._onKey.bind(this), onBackdropClick: this._onBackdropClick.bind(this) }; + this._resizing = false; + this._currentIframe = null; + this._currentUrl = ''; } connectedCallback() { @@ -189,8 +236,21 @@ class MacWindow extends HTMLElement { this._stage.addEventListener('pointermove', this._bound.onMove); this._stage.addEventListener('pointerleave', this._bound.onLeave); this._closeBtn.addEventListener('click', this._bound.onClose); + // support pointerdown for quicker response on touch devices + this._closeBtn.addEventListener('pointerdown', this._bound.onClose); this._backdrop.addEventListener('click', this._bound.onBackdropClick); + // wire up titlebar controls + this._backBtn = this.shadowRoot.querySelector('.nav-btn.back'); + this._forwardBtn = this.shadowRoot.querySelector('.nav-btn.forward'); + this._openLinkAnchor = this.shadowRoot.querySelector('.open-link'); + this._titleText = this.shadowRoot.querySelector('.title-text'); + this._addressText = this.shadowRoot.querySelector('.address-text'); + this._resizer = this.shadowRoot.querySelector('.resizer'); + if (this._backBtn) this._backBtn.addEventListener('click', ()=> this._navigate(-1)); + if (this._forwardBtn) this._forwardBtn.addEventListener('click', ()=> this._navigate(1)); + if (this._resizer) this._resizer.addEventListener('pointerdown', this._onResizerDown.bind(this)); + // keyboard this.addEventListener('keydown', this._bound.onKey); this.setAttribute('tabindex', '0'); @@ -207,7 +267,11 @@ class MacWindow extends HTMLElement { this._stage.removeEventListener('pointermove', this._bound.onMove); this._stage.removeEventListener('pointerleave', this._bound.onLeave); this._closeBtn.removeEventListener('click', this._bound.onClose); + this._closeBtn.removeEventListener('pointerdown', this._bound.onClose); this._backdrop.removeEventListener('click', this._bound.onBackdropClick); + if (this._backBtn) this._backBtn.removeEventListener('click', ()=> this._navigate(-1)); + if (this._forwardBtn) this._forwardBtn.removeEventListener('click', ()=> this._navigate(1)); + if (this._resizer) this._resizer.removeEventListener('pointerdown', this._onResizerDown); this.removeEventListener('keydown', this._bound.onKey); } @@ -291,6 +355,69 @@ class MacWindow extends HTMLElement { this._inner.style.transform = 'rotateX(0deg) rotateY(0deg) translateZ(0px)'; } + _onResizerDown(e){ + e.preventDefault(); + this._resizing = true; + const startX = e.clientX, startY = e.clientY; + const startRect = this._stage.getBoundingClientRect(); + const startW = startRect.width; + const startH = startRect.height; + const onMove = (ev)=>{ + const nw = Math.max(280, startW + (ev.clientX - startX)); + const nh = Math.max(220, startH + (ev.clientY - startY)); + this._stage.style.width = nw + 'px'; + this._stage.style.height = nh + 'px'; + }; + const onUp = (ev)=>{ + this._resizing = false; + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + // persist size + try{ + if (this._currentUrl) { + window._macWindowSizeCache = window._macWindowSizeCache || {}; + window._macWindowSizeCache[this._currentUrl] = window._macWindowSizeCache[this._currentUrl] || {}; + const rect = this._stage.getBoundingClientRect(); + window._macWindowSizeCache[this._currentUrl].w = Math.round(rect.width); + window._macWindowSizeCache[this._currentUrl].h = Math.round(rect.height); + } + }catch(e){} + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + } + + _navigate(delta){ + if (!this._currentIframe) return; + try { + if (delta < 0) this._currentIframe.contentWindow.history.back(); else this._currentIframe.contentWindow.history.forward(); + } catch(e) { + try { this._currentIframe.contentWindow.postMessage({ type: 'hsn-navigate', delta }, '*'); } catch(e){} + } + } + + registerIframe(iframe, url){ + this._currentIframe = iframe; + this._currentUrl = url || ''; + if (this._openLinkAnchor) this._openLinkAnchor.href = url || ''; + const update = ()=>{ + try{ + const doc = iframe.contentDocument; + const title = doc && (doc.title || (doc.querySelector && doc.querySelector('h1') && doc.querySelector('h1').textContent)) || iframe.getAttribute('title') || ''; + if (this._titleText) this._titleText.textContent = title || '窗口'; + if (this._addressText) this._addressText.textContent = (iframe.contentWindow && iframe.contentWindow.location && iframe.contentWindow.location.href) || url || ''; + }catch(e){ + if (this._titleText) this._titleText.textContent = url || '窗口'; + if (this._addressText) this._addressText.textContent = url || ''; + } + try{ + if (this._backBtn) this._backBtn.disabled = !(iframe.contentWindow && iframe.contentWindow.history && iframe.contentWindow.history.length>1); + }catch(e){} + }; + iframe.addEventListener('load', ()=>{ update(); setTimeout(update,120); }); + setTimeout(update,120); + } + _applyResponsiveSizing() { // 移动端自适应 if (!this._stage) return; diff --git a/css/style.css b/css/style.css index 28a9953..52ee7ad 100644 --- a/css/style.css +++ b/css/style.css @@ -684,3 +684,84 @@ box-shadow: 0 6px 18px rgba(97,232,234,0.12); transform: none; } + +/* 统一表格组件样式(用于 rooms/ranks/top) */ +.table-card { + perspective: 1000px; + width: 95%; + max-width: 1200px; + margin: 0 auto; +} +.table-card .table-scroll { + position: relative; + width: 100%; + -webkit-overflow-scrolling: touch; +} + +/* 将滚动作用于 table 本身以满足视觉需求 */ +table.responsive-table { + display: block; /* allow native horizontal scrolling on table */ + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + white-space: nowrap; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + backdrop-filter: blur(8px); + transform-style: preserve-3d; + transition: transform 0.18s ease; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); + border: 1px solid rgba(255,255,255,0.14); +} + +/* 固定列样式,避免在窄屏下表格列内换行导致宽度异常 */ +table.responsive-table th, table.responsive-table td { + white-space: nowrap; +} + +/* 更合理的表格布局在小屏下取消 3D 位移并使用 fixed 布局 */ +@media (max-width: 768px) { + table.responsive-table { + transform: none !important; + animation: none !important; + table-layout: fixed; + } + table.responsive-table th, table.responsive-table td { + padding: 0.5rem 0.6rem; + font-size: 0.85rem; + text-overflow: ellipsis; + overflow: hidden; + } +} + +/* 细窄的自定义滚动条(作用于 table 元素) */ +table.responsive-table::-webkit-scrollbar { + height: 8px; +} +table.responsive-table::-webkit-scrollbar-track { + background: rgba(255,255,255,0.03); + border-radius: 8px; +} +table.responsive-table::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.12); + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.06); +} +/* Firefox */ +table.responsive-table { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.12) rgba(255,255,255,0.03); } + +/* 长文本滚动处理(用于谱面/长标题) */ +.scrolling-btn{ display:inline-block; max-width:150px; overflow:hidden; position:relative; vertical-align:middle; } +.scrolling-btn > span{ display:inline-block; padding-right:24px; will-change:transform; } +.scrolling-btn.marquee > span{ animation: scroll-left 8s linear infinite; } +@keyframes scroll-left{ + 0%{ transform: translateX(0); } + 100%{ transform: translateX(-100%); } +} + +/* 当容器较窄时启用更温和的滚动 */ +@media (max-width: 768px){ + .scrolling-btn{ max-width:120px; } + .scrolling-btn.marquee > span{ animation-duration:10s; } +} + diff --git a/index.html b/index.html index 68d7869..b827351 100644 --- a/index.html +++ b/index.html @@ -11,19 +11,14 @@ - - -
- logo - -
+ + + +
操作成功!
diff --git a/js/agreement_update.js b/js/agreement_update.js new file mode 100644 index 0000000..6ec6916 --- /dev/null +++ b/js/agreement_update.js @@ -0,0 +1,21 @@ +// Agreement update notifier +(function(){ + const KEY = '__hsn_last_agreement_v'; + async function check(){ + try{ + const r = await fetch('/api/agreements/meta', {cache: 'no-store'}); + if (!r.ok) return; + const d = await r.json(); + const v = d.version || d.updated_at || ''; + const prev = localStorage.getItem(KEY); + if (prev && v && prev !== v){ + if (typeof showMessage === 'function'){ + showMessage('服务条款更新', '服务条款已更新,请查看并同意', 'linear-gradient(135deg, rgba(255,192,32,0.08), rgba(255,96,96,0.06))', 0); + } + } + if (v) localStorage.setItem(KEY, v); + }catch(e){} + } + check(); + setInterval(check, 1000*60*30); +})(); diff --git a/js/auth-window.js b/js/auth-window.js new file mode 100644 index 0000000..97de41b --- /dev/null +++ b/js/auth-window.js @@ -0,0 +1,136 @@ +(function(){ + // Centralized auth window using mac-window + const state = { authMode: 'login', win: null }; + + function createWindowIfNeeded(){ + if (state.win && document.body.contains(state.win)) return state.win; + // create a mac-window instance + const win = document.createElement('mac-window'); + win.id = 'auth-mac-window'; + // smaller default + win.style.setProperty('--width','420px'); + win.style.setProperty('--height','380px'); + document.body.appendChild(win); + state.win = win; + + // when closed remove instance so next open creates fresh one + win.addEventListener('mac-window-close', () => { + try { win.remove(); } catch(e){} + state.win = null; + }); + + return win; + } + + function openAuth(){ + const tmpl = document.getElementById('auth-template'); + if (!tmpl) return console.warn('auth template missing'); + const win = createWindowIfNeeded(); + + // clear previous content + const existing = win.querySelector('#auth-box'); + if (existing) existing.remove(); + + const node = tmpl.content.cloneNode(true); + // append nodes into window (slot area) + win.appendChild(node); + + // wire up buttons + const submitBtn = win.querySelector('#auth-submit'); + const toggleBtn = win.querySelector('#auth-toggle'); + const msgElem = win.querySelector('#auth-msg'); + + if (submitBtn) submitBtn.addEventListener('click', submitAuth); + if (toggleBtn) toggleBtn.addEventListener('click', toggleAuthMode); + + // ensure UI initial state + updateAuthUI(); + + win.open(); + } + + function closeAuth(){ + if (state.win) state.win.close(); + } + + function toggleAuthMode(){ + state.authMode = state.authMode === 'login' ? 'register' : 'login'; + updateAuthUI(); + } + + function updateAuthUI(){ + const win = state.win || document.getElementById('auth-mac-window'); + const title = win && win.querySelector('#auth-title'); + const phiraid = win && win.querySelector('#auth-phiraid'); + const agreementContainer = win && win.querySelector('#agreement-container'); + if (!title) return; + if (state.authMode === 'login'){ + title.textContent = '用户登录'; + if (phiraid) phiraid.classList.add('collapsed'); + if (agreementContainer) agreementContainer.style.display = 'none'; + } else { + title.textContent = '用户注册'; + if (phiraid) phiraid.classList.remove('collapsed'); + if (agreementContainer) agreementContainer.style.display = 'flex'; + } + } + + async function submitAuth(){ + const win = state.win || document.getElementById('auth-mac-window'); + if (!win) return; + const username = win.querySelector('#auth-name').value; + const password = win.querySelector('#auth-password').value; + const phira_id = win.querySelector('#auth-phiraid').value; + const remember = !!(win.querySelector('#remember-me') && win.querySelector('#remember-me').checked); + const agreeTerms = state.authMode === 'register' ? !!(win.querySelector('#agree-terms') && win.querySelector('#agree-terms').checked) : true; + const msg = win.querySelector('#auth-msg'); + if (msg) msg.textContent = '处理中...'; + + try { + if (state.authMode === 'register' && !agreeTerms) { + if (msg) msg.textContent = '请同意用户协议'; + return; + } + const endpoint = state.authMode === 'login' ? '/api/auth/login' : '/api/auth/users'; + const payload = state.authMode === 'login' ? { username, password, remember } : { username, password, phira_id }; + const res = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify(payload) + }); + if (!res.ok) { + const err = await res.json().catch(()=>({})); + if (msg) msg.textContent = err.message || '操作失败'; + return; + } + if (state.authMode === 'login'){ + // refresh global currentUser if applicable + try { + const userRes = await fetch('/api/auth/me'); + if (userRes.ok) { + window.currentUser = await userRes.json(); + if (window.updateUserDisplay) try { window.updateUserDisplay(); } catch(e){} + } + } catch(e){} + } + if (msg) msg.textContent = state.authMode === 'login' ? '登录成功!' : '注册成功!'; + setTimeout(()=>{ + try { closeAuth(); } catch(e){} + if (state.authMode === 'register') state.authMode = 'login'; + }, 800); + } catch (e) { + if (msg) msg.textContent = '网络错误'; + console.error('auth error', e); + } + } + + // expose globals used by header and other scripts + window.openAuth = openAuth; + window.closeAuth = closeAuth; + window.toggleAuthMode = toggleAuthMode; + window.submitAuth = submitAuth; + window.updateAuthUI = updateAuthUI; + + // auto-initialize if template not yet loaded: wait for DOMContentLoaded + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ()=>{}); +})(); diff --git a/js/auto_update.js b/js/auto_update.js new file mode 100644 index 0000000..a8f53ea --- /dev/null +++ b/js/auto_update.js @@ -0,0 +1,22 @@ +// Simple auto-update checker +(function(){ + const CHECK_INTERVAL = 1000 * 60 * 5; // 5 minutes + async function check(){ + try{ + const r = await fetch('/api/version', {cache: 'no-store'}); + if (!r.ok) return; + const data = await r.json(); + if (!window.__hsn_last_version) window.__hsn_last_version = data.version; + if (data.version && data.version !== window.__hsn_last_version){ + window.__hsn_last_version = data.version; + if (typeof showMessage === 'function'){ + showMessage('更新可用', '检测到新版本,建议刷新页面以获取最新内容', 'linear-gradient(135deg, rgba(32,160,255,0.12), rgba(0,200,120,0.08))', 0); + } + } + }catch(e){ + // ignore + } + } + check(); + setInterval(check, CHECK_INTERVAL); +})(); diff --git a/js/contact-window.js b/js/contact-window.js new file mode 100644 index 0000000..e124983 --- /dev/null +++ b/js/contact-window.js @@ -0,0 +1,44 @@ +// Contact window controller +(function(){ + async function openContact(){ + const tplResp = await fetch('./component/contact.html'); + const text = await tplResp.text(); + const div = document.createElement('div'); + div.innerHTML = text; + const tpl = div.querySelector('#contact-template'); + if (!tpl) return; + + const win = document.createElement('mac-window'); + win.style.setProperty('--width','640px'); + win.style.setProperty('--height','420px'); + document.body.appendChild(win); + + const content = tpl.content.cloneNode(true); + const slotWrap = document.createElement('div'); + slotWrap.appendChild(content); + win.appendChild(slotWrap); + + // simple carousel dummy: try to fetch announcements + try{ + const r = await fetch('/api/announcements?limit=5'); + if (r.ok){ + const arr = await r.json(); + const carousel = win.querySelector('#contact-carousel'); + if (carousel && Array.isArray(arr)){ + carousel.innerHTML = ''; + arr.forEach(a=>{ + const it = document.createElement('div'); + it.className = 'contact-item'; + it.textContent = a.title || a.summary || '公告'; + carousel.appendChild(it); + }); + } + } + }catch(e){} + + // expose for global usage + window.openContact = openContact; + } + + window.openContact = openContact; +})(); diff --git a/js/message.js b/js/message.js new file mode 100644 index 0000000..6cddc4c --- /dev/null +++ b/js/message.js @@ -0,0 +1,70 @@ +// Unified message component +(function(){ + const containerId = 'hsn-message-container'; + function ensureContainer(){ + let c = document.getElementById(containerId); + if (!c){ + c = document.createElement('div'); + c.id = containerId; + c.style.position = 'fixed'; + c.style.top = '12px'; + c.style.right = '12px'; + c.style.zIndex = 200000; + c.style.display = 'flex'; + c.style.flexDirection = 'column'; + c.style.gap = '8px'; + document.body.appendChild(c); + } + return c; + } + + function showMessage(title, content, bgColor, timeout=4000){ + const c = ensureContainer(); + const card = document.createElement('div'); + card.style.minWidth = '220px'; + card.style.maxWidth = '420px'; + card.style.background = bgColor || 'rgba(255,255,255,0.06)'; + card.style.backdropFilter = 'blur(12px)'; + card.style.border = '1px solid rgba(255,255,255,0.08)'; + card.style.borderRadius = '12px'; + card.style.padding = '12px'; + card.style.color = '#fff'; + card.style.boxShadow = '0 8px 32px rgba(0,0,0,0.3)'; + card.style.position = 'relative'; + + const close = document.createElement('button'); + close.textContent = '×'; + close.style.position = 'absolute'; + close.style.right = '8px'; + close.style.top = '8px'; + close.style.border = 'none'; + close.style.background = 'rgba(255,0,0,0.7)'; + close.style.color = '#fff'; + close.style.width = '20px'; + close.style.height = '20px'; + close.style.borderRadius = '50%'; + close.style.cursor = 'pointer'; + close.addEventListener('click', ()=>{ if (card.parentNode) card.parentNode.removeChild(card); }); + + const h = document.createElement('div'); + h.textContent = title || ''; + h.style.fontSize = '16px'; + h.style.fontWeight = '700'; + h.style.marginBottom = '6px'; + + const p = document.createElement('div'); + p.innerHTML = content || ''; + p.style.fontSize = '13px'; + + card.appendChild(close); + card.appendChild(h); + card.appendChild(p); + c.appendChild(card); + + if (timeout > 0){ + setTimeout(()=>{ try{ if (card.parentNode) card.parentNode.removeChild(card); }catch(e){} }, timeout); + } + } + + window.showMessage = showMessage; +})(); diff --git a/js/room-history.js b/js/room-history.js new file mode 100644 index 0000000..d20206f --- /dev/null +++ b/js/room-history.js @@ -0,0 +1,58 @@ +// Room play-history window +(function(){ + async function openRoomHistory(roomId){ + if (!roomId) { showMessage && showMessage('错误','未提供房间标识'); return; } + + // load template + let tplText = ''; + try{ + const r = await fetch('./component/room-history.html'); + tplText = await r.text(); + }catch(e){ tplText = ''; } + const wrapper = document.createElement('div'); + wrapper.innerHTML = tplText; + const tpl = wrapper.querySelector('#room-history-template'); + if (!tpl) { showMessage && showMessage('错误','无法加载历史模板'); return; } + + const win = document.createElement('mac-window'); + win.style.setProperty('--width','680px'); + win.style.setProperty('--height','420px'); + document.body.appendChild(win); + + const content = tpl.content.cloneNode(true); + const container = document.createElement('div'); + container.appendChild(content); + win.appendChild(container); + + const meta = win.querySelector('#history-meta'); + const tbody = win.querySelector('#history-table tbody'); + if (meta) meta.textContent = `房间:${roomId}`; + + // try multiple endpoints + const candidates = [`/api/rooms/history/${encodeURIComponent(roomId)}`, `/api/rooms/history?room=${encodeURIComponent(roomId)}`]; + let data = null; + for (const url of candidates){ + try{ + const r = await fetch(url); + if (!r.ok) continue; + data = await r.json(); + break; + }catch(e){} + } + + if (!Array.isArray(data) || data.length === 0){ + tbody.innerHTML = '暂无历史记录'; + return; + } + + tbody.innerHTML = data.map(item => { + const player = item.player_name || item.player || item.user || '匿名'; + const chart = item.chart_name || item.chart || item.song || '未知'; + const score = item.score != null ? item.score : (item.result || '—'); + const t = item.time || item.ts || item.played_at || ''; + return `${player}${chart}${score}${t}`; + }).join(''); + } + + window.openRoomHistory = openRoomHistory; +})(); diff --git a/js/rooms.js b/js/rooms.js index c6763e3..bd4fcbc 100644 --- a/js/rooms.js +++ b/js/rooms.js @@ -149,13 +149,13 @@ const chartInfo = await getChartInfo(room.chart); if (chartInfo) { chartText = chartInfo.name - ? `${chartInfo.name}` + ? `${chartInfo.name}` : `ID: ${room.chart}`; chartImg = chartInfo.illustration ? `` : "无封面"; downloadBtn = chartInfo.file - ? `` + ? `` : "无文件"; } } @@ -164,6 +164,8 @@ const stateDisplay = `${stateInfo.text}`; const hostBtn = ``; const usersBtn = ``; + const roomIdVal = (room.id || room.name || '').toString().replace(/'/g, "\\'"); + const historyBtn = ``; const tr = document.createElement("tr"); tr.innerHTML = ` ${room.name} @@ -175,6 +177,7 @@ ${chartImg} ${downloadBtn} ${usersBtn} + ${historyBtn} `; tbody.appendChild(tr); } diff --git a/js/table-component.js b/js/table-component.js new file mode 100644 index 0000000..8f2baf9 --- /dev/null +++ b/js/table-component.js @@ -0,0 +1,57 @@ +// Shared table helper: adds touch-friendly horizontal scroll behavior +// and optional sticky header handling. Auto-initializes tables with class +// "responsive-table". +(function(){ + function initTable(t){ + if (t.__tableComponentInit) return; t.__tableComponentInit = true; + // make sure the table uses block display for native horizontal scroll + t.style.display = 'block'; + t.style.overflowX = 'auto'; + t.style.webkitOverflowScrolling = 'touch'; + + // add wheel-to-scroll-on-x for desktop mousewheel when shift not held + t.addEventListener('wheel', function(e){ + if (Math.abs(e.deltaX) < Math.abs(e.deltaY)){ + // translate vertical wheel to horizontal scroll for convenience + t.scrollLeft += e.deltaY; + e.preventDefault(); + } + }, { passive: false }); + + // make header visually sticky by cloning header row into a fixed element if desired + // keep minimal: add shadow when scrolled horizontally to hint overflow + function updateShadow(){ + if (t.scrollLeft > 0) t.style.boxShadow = 'inset 8px 0 12px -8px rgba(0,0,0,0.4)'; + else t.style.boxShadow = ''; + } + t.addEventListener('scroll', updateShadow); + updateShadow(); + + // accessibility: expose keyboard horizontal navigation + t.setAttribute('tabindex','0'); + t.addEventListener('keydown', function(e){ + if (e.key === 'ArrowRight') { t.scrollLeft += 60; e.preventDefault(); } + if (e.key === 'ArrowLeft') { t.scrollLeft -= 60; e.preventDefault(); } + if (e.key === 'Home') { t.scrollLeft = 0; } + if (e.key === 'End') { t.scrollLeft = t.scrollWidth; } + }); + } + + function scan(){ + document.querySelectorAll('table.responsive-table').forEach(initTable); + // enable marquee for long scrolling-btn elements + setTimeout(()=>{ + document.querySelectorAll('.scrolling-btn').forEach(el=>{ + try{ + const span = el.querySelector('span'); + if (span && span.scrollWidth > el.clientWidth) el.classList.add('marquee'); + }catch(e){} + }); + }, 120); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', scan); else scan(); + + // expose for manual init + window.HSNTable = { init: initTable }; +})(); diff --git a/js/top.js b/js/top.js index 1fc432f..222f1a3 100644 --- a/js/top.js +++ b/js/top.js @@ -216,12 +216,20 @@ // 谱面名称列 const nameTd = document.createElement("td"); - if (chartInfo && chartInfo.name) { + if (chartInfo && chartInfo.name) { const nameBtn = document.createElement("a"); nameBtn.href = `https://phira.moe/chart/${chart.chart_id}`; nameBtn.target = "_blank"; - nameBtn.className = "chart-name-btn"; - nameBtn.textContent = chartInfo.name; + nameBtn.className = "chart-name-btn scrolling-btn"; + const innerSpan = document.createElement('span'); + innerSpan.textContent = chartInfo.name; + nameBtn.appendChild(innerSpan); + // if text overflows, add marquee class to animate + setTimeout(()=>{ + try{ + if (innerSpan.scrollWidth > nameBtn.clientWidth) nameBtn.classList.add('marquee'); + }catch(e){} + }, 120); nameTd.appendChild(nameBtn); } else { nameTd.textContent = `ID: ${chart.chart_id}`; @@ -267,10 +275,12 @@ // 下载列 const downloadTd = document.createElement("td"); if (chartInfo && chartInfo.file) { - const downloadLink = document.createElement("a"); + const downloadLink = document.createElement('a'); downloadLink.href = chartInfo.file; - downloadLink.download = `chart_${chart.chart_id}.zip`; - const downloadBtn = document.createElement("button"); + // use chart name if available, sanitize filename + const safeName = (chartInfo.name || `chart_${chart.chart_id}`).replace(/[^\w\-\u4e00-\u9fa5\. ]+/g, '').trim(); + downloadLink.download = `${safeName || 'chart_' + chart.chart_id}.zip`; + const downloadBtn = document.createElement('button'); downloadBtn.className = "dl-btn"; downloadBtn.textContent = "下载"; downloadLink.appendChild(downloadBtn); @@ -287,22 +297,27 @@ updatePaginationControls(); } - // 复制谱面ID - function copyChartId(chartId) { - navigator.clipboard.writeText(chartId) - .then(() => { - // 显示复制成功提示 - const notification = document.getElementById('copy-notification'); - notification.classList.add('show'); - setTimeout(() => { - notification.classList.remove('show'); - }, 2000); - }) - .catch(err => { - console.error('复制失败:', err); - alert('复制失败,请手动复制谱面ID'); - }); - } + // 复制谱面ID(前置#) + function copyChartId(chartId) { + const idText = '#' + String(chartId); + navigator.clipboard.writeText(idText) + .then(() => { + if (window.showMessage) { + window.showMessage('已复制', idText, ''); + } else { + const notification = document.getElementById('copy-notification'); + if (notification) { + notification.textContent = `谱面ID ${idText} 已复制到剪贴板!`; + notification.classList.add('show'); + setTimeout(() => notification.classList.remove('show'), 2000); + } + } + }) + .catch(err => { + console.error('复制失败:', err); + alert('复制失败,请手动复制谱面ID'); + }); + } // 更新分页控件状态 function updatePaginationControls() { diff --git a/js/window-links.js b/js/window-links.js index d3018e0..721ca77 100644 --- a/js/window-links.js +++ b/js/window-links.js @@ -8,6 +8,11 @@ if (/^\s*javascript:/i.test(href)) return false; // treat privacy page specially if (/privacy\.html(\b|$)/i.test(href)) return true; + // if link points to a downloadable file, do not treat as external page + const downloadExt = ['.zip', '.osz', '.osu', '.png', '.jpg', '.jpeg', '.gif', '.mp3', '.ogg', '.7z']; + for (let ext of downloadExt) { + if (href.toLowerCase().split('?')[0].endsWith(ext)) return false; + } // protocol-relative or absolute if (/^\/\//.test(href)) return true; if (/^https?:\/\//i.test(href)) { @@ -20,43 +25,67 @@ } } - function ensureMacWindow() { - let win = document.getElementById('global-mac-window'); - if (!win) { - win = document.createElement('mac-window'); - win.id = 'global-mac-window'; - // set a sensible default size - win.style.setProperty('--width','900px'); - win.style.setProperty('--height','640px'); - document.body.appendChild(win); + // create a new mac-window instance and wire stacking/cleanup + function createMacWindow() { + window._macWindowCounter = (window._macWindowCounter || 0) + 1; + window._macWindowZ = window._macWindowZ || 100001; + window._macWindowSizeCache = window._macWindowSizeCache || {}; + const id = 'global-mac-window-' + window._macWindowCounter; + const win = document.createElement('mac-window'); + win.id = id; + // bring this window to front by assigning z + win.style.setProperty('--mac-window-z', (window._macWindowZ++).toString()); + // default size + win.style.setProperty('--width','900px'); + win.style.setProperty('--height','640px'); - // cleanup iframe on close - win.addEventListener('mac-window-close', () => { - const iframe = win.querySelector('iframe'); - if (iframe) iframe.src = 'about:blank'; - // remove embedded mode and any custom sizing - win.removeAttribute('embedded'); - win.style.removeProperty('--width'); - win.style.removeProperty('--height'); - // remove slotted title if any - const title = win.querySelector('h2[data-window-title]'); - if (title) title.remove(); - }); - } + // clicking/touching the window brings it to front + win.addEventListener('pointerdown', () => { + win.style.setProperty('--mac-window-z', (window._macWindowZ++).toString()); + }); + + // cleanup on close: remove element entirely + const onClose = () => { + const iframe = win.querySelector('iframe'); + try { if (iframe) iframe.src = 'about:blank'; } catch(e){} + // remove loader if any + const loader = win.querySelector('.window-loader'); + try { if (loader && loader.remove) loader.remove(); } catch(e){} + // finally remove element + try { win.remove(); } catch(e){} + }; + win.addEventListener('mac-window-close', onClose); + + document.body.appendChild(win); return win; } function openUrlInWindow(url, title, width, height) { - const win = ensureMacWindow(); + const win = createMacWindow(); // remove existing iframe to avoid duplicates let iframe = win.querySelector('iframe'); if (iframe) iframe.remove(); // embedded mode: remove title and let iframe fill the window win.setAttribute('embedded',''); + + // if no explicit width/height provided, check cached size for this URL + window._macWindowSizeCache = window._macWindowSizeCache || {}; + const key = url; + if (!width && window._macWindowSizeCache[key]) { + const s = window._macWindowSizeCache[key]; + if (s.w) win.style.setProperty('--width', s.w + 'px'); + if (s.h) win.style.setProperty('--height', s.h + 'px'); + } if (width) win.style.setProperty('--width', width); if (height) win.style.setProperty('--height', height); + // add a loading overlay while iframe loads + const loader = document.createElement('div'); + loader.className = 'window-loader'; + loader.innerHTML = '
'; + win.appendChild(loader); + iframe = document.createElement('iframe'); iframe.src = url; iframe.setAttribute('tabindex','0'); @@ -66,16 +95,48 @@ iframe.style.height = '100%'; iframe.style.border = '0'; iframe.style.minHeight = '320px'; - // make iframe interactive and allow fullscreen/clipboard when possible iframe.setAttribute('allow','fullscreen; geolocation; microphone; camera; clipboard-read; clipboard-write'); - // some pages may want to be narrower; allow link to specify data-window-width/height + + // remove loader once iframe finishes loading; attempt to cache size when same-origin + iframe.addEventListener('load', () => { + try { if (loader && loader.remove) loader.remove(); } catch(e){} + // try measure content size if same-origin + try { + const doc = iframe.contentDocument || iframe.contentWindow.document; + if (doc) { + const fullH = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight); + const fullW = Math.max(doc.documentElement.scrollWidth, doc.body.scrollWidth); + // only cache reasonable sizes + if (fullH && fullH < 3000) { + window._macWindowSizeCache[key] = window._macWindowSizeCache[key] || {}; + window._macWindowSizeCache[key].h = fullH; + window._macWindowSizeCache[key].w = fullW; + // apply cached height to window for better fit (desktop) + win.style.setProperty('--height', Math.max(320, fullH) + 'px'); + } + } + } catch(e) { + // cross-origin — ignore + } + // try inject minimal reset CSS into same-origin iframe to avoid body margin gaps + try { + const doc = iframe.contentDocument || iframe.contentWindow.document; + if (doc) { + const style = doc.createElement('style'); + style.textContent = 'html,body{height:100%;margin:0;padding:0;background:transparent;}body>*{box-sizing:border-box;}'; + doc.head && doc.head.appendChild(style); + } + } catch(e) { + // cross-origin: cannot inject + } + setTimeout(() => { try { iframe.contentWindow && iframe.contentWindow.focus(); } catch(e){} }, 120); + }, { once: true }); + win.appendChild(iframe); + // let mac-window manage title/navigation if supported + try { if (typeof win.registerIframe === 'function') win.registerIframe(iframe, url); } catch(e){} win.open(); - // focus for accessibility - setTimeout(() => { - try { iframe.contentWindow && iframe.contentWindow.focus(); } catch(e){} - }, 300); } // delegate click handler diff --git a/privacy.html b/privacy.html index 586d650..8713f04 100644 --- a/privacy.html +++ b/privacy.html @@ -148,7 +148,7 @@

1. 协议变更

diff --git a/ranks.html b/ranks.html index 3cd7720..fec106a 100644 --- a/ranks.html +++ b/ranks.html @@ -47,18 +47,22 @@
- - - - - - - - - - - -
名次用户名游玩时间
加载中...
+
+
+ + + + + + + + + + + +
名次用户名游玩时间
加载中...
+
+
- + + + diff --git a/rooms.html b/rooms.html index 580181b..2c077fb 100644 --- a/rooms.html +++ b/rooms.html @@ -37,24 +37,29 @@
服务器状态:检测中...
- - - - - - - - - - - - - - - - - -
房间名房主人数状态循环谱面曲绘下载人员
加载中...
+
+
+ + + + + + + + + + + + + + + + + + +
房间名房主人数状态循环谱面曲绘下载人员历史
加载中...
+
+
@@ -68,6 +73,9 @@ + + + diff --git a/top.html b/top.html index bea3376..00a6d13 100644 --- a/top.html +++ b/top.html @@ -45,21 +45,25 @@
- - - - - - - - - - - - - - -
名次谱面名称谱面ID游玩人数曲绘下载
加载中...
+
+
+ + + + + + + + + + + + + + +
名次谱面名称谱面ID游玩人数曲绘下载
加载中...
+
+
@@ -82,7 +86,9 @@ + + \ No newline at end of file -- Gitee From aada72e8ee57e2db2dc2cb2b40c6f5c98e7efb62 Mon Sep 17 00:00:00 2001 From: Firefly Date: Fri, 6 Feb 2026 16:17:07 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AI-Ready System Spec.md | 405 +++++++ Mapping.md | 145 +++ Original Spec.md | 1712 +++++++++++++++++++++++++++ README.md | 59 +- account.html | 173 --- component/auth.html | 42 - component/contact.html | 29 - component/footer.html | 9 - component/header.html | 32 - component/page-loader.html | 10 - component/room-history.html | 16 - component/window.js | 463 -------- config/app_config.json | 11 + config/user_preferences_schema.json | 27 + css/account.css | 310 ----- css/admin.css | 441 ------- css/footer.css | 125 -- css/index.css | 185 --- css/privacy.css | 128 -- css/ranks.css | 77 -- css/rooms.css | 7 - css/style.css | 767 ------------ css/top.css | 139 --- css/users_manage.css | 542 --------- index.html | 171 ++- js/account.js | 548 --------- js/agreement_update.js | 21 - js/auth-window.js | 136 --- js/auto_update.js | 22 - js/checkserverstatus.js | 20 - js/contact-window.js | 44 - js/index.js | 92 -- js/login-btn.js | 63 - js/message.js | 70 -- js/ranks.js | 731 ------------ js/room-history.js | 58 - js/rooms.js | 460 ------- js/table-component.js | 57 - js/top.js | 473 -------- js/users_manage.js | 510 -------- js/window-links.js | 166 --- logo.png | Bin 210262 -> 0 bytes privacy.html | 210 ---- prompt.md | 35 + ranks.html | 85 -- rooms.html | 85 -- src/css/styles.css | 75 ++ src/js/core/auth.js | 105 ++ src/js/core/event-bus.js | 77 ++ src/js/core/page-loader.js | 231 ++++ src/js/core/sse-client.js | 90 ++ src/js/core/sse-validate.js | 31 + src/js/pages/charts-page.js | 59 + src/js/pages/pages.js | 98 ++ src/js/pages/users-page.js | 68 ++ src/js/ui/footer.js | 17 + src/js/ui/header.js | 37 + src/js/ui/lightbox.js | 36 + src/js/ui/loader.js | 56 + src/js/ui/message-toast.js | 42 + src/js/ui/server-status.js | 90 ++ src/js/window/window-auth.js | 67 ++ src/js/window/window-base.js | 59 + src/js/window/window-chart.js | 87 ++ src/js/window/window-link.js | 18 + src/js/window/window-room.js | 111 ++ src/js/window/window-setting.js | 55 + top.html | 94 -- users_manage.html | 207 ---- 69 files changed, 4017 insertions(+), 7704 deletions(-) create mode 100644 AI-Ready System Spec.md create mode 100644 Mapping.md create mode 100644 Original Spec.md delete mode 100644 account.html delete mode 100644 component/auth.html delete mode 100644 component/contact.html delete mode 100644 component/footer.html delete mode 100644 component/header.html delete mode 100644 component/page-loader.html delete mode 100644 component/room-history.html delete mode 100644 component/window.js create mode 100644 config/app_config.json create mode 100644 config/user_preferences_schema.json delete mode 100644 css/account.css delete mode 100644 css/admin.css delete mode 100644 css/footer.css delete mode 100644 css/index.css delete mode 100644 css/privacy.css delete mode 100644 css/ranks.css delete mode 100644 css/rooms.css delete mode 100644 css/style.css delete mode 100644 css/top.css delete mode 100644 css/users_manage.css delete mode 100644 js/account.js delete mode 100644 js/agreement_update.js delete mode 100644 js/auth-window.js delete mode 100644 js/auto_update.js delete mode 100644 js/checkserverstatus.js delete mode 100644 js/contact-window.js delete mode 100644 js/index.js delete mode 100644 js/login-btn.js delete mode 100644 js/message.js delete mode 100644 js/ranks.js delete mode 100644 js/room-history.js delete mode 100644 js/rooms.js delete mode 100644 js/table-component.js delete mode 100644 js/top.js delete mode 100644 js/users_manage.js delete mode 100644 js/window-links.js delete mode 100644 logo.png delete mode 100644 privacy.html create mode 100644 prompt.md delete mode 100644 ranks.html delete mode 100644 rooms.html create mode 100644 src/css/styles.css create mode 100644 src/js/core/auth.js create mode 100644 src/js/core/event-bus.js create mode 100644 src/js/core/page-loader.js create mode 100644 src/js/core/sse-client.js create mode 100644 src/js/core/sse-validate.js create mode 100644 src/js/pages/charts-page.js create mode 100644 src/js/pages/pages.js create mode 100644 src/js/pages/users-page.js create mode 100644 src/js/ui/footer.js create mode 100644 src/js/ui/header.js create mode 100644 src/js/ui/lightbox.js create mode 100644 src/js/ui/loader.js create mode 100644 src/js/ui/message-toast.js create mode 100644 src/js/ui/server-status.js create mode 100644 src/js/window/window-auth.js create mode 100644 src/js/window/window-base.js create mode 100644 src/js/window/window-chart.js create mode 100644 src/js/window/window-link.js create mode 100644 src/js/window/window-room.js create mode 100644 src/js/window/window-setting.js delete mode 100644 top.html delete mode 100644 users_manage.html diff --git a/AI-Ready System Spec.md b/AI-Ready System Spec.md new file mode 100644 index 0000000..3114161 --- /dev/null +++ b/AI-Ready System Spec.md @@ -0,0 +1,405 @@ +HSNPhira — AI-Ready System Specification + +> 本文档是 AI system spec,用于约束与引导 AI 生成前端 / 组件 / 页面代码。 + +所有标注为 MUST 的规则不可违反。 + + + + +--- + +0. Project Vibe & Core Goals + +Vibe Keywords + +Glassmorphism / Liquid Glass + +3D tilt on hover + +Unified theme color + +Modular / Component-driven + +Event-driven architecture + +SSE real-time updates + + +Core Goals + +HTML / CSS / JS 严格分离 + +页面、组件、逻辑完全模块化 + +所有交互通过统一通信机制 + +强一致性的视觉与交互体验 + + + +--- + +1. Global Rules (Non-Negotiable) + +1.1 MUST Rules + +所有页面跳转 MUST 默认使用 window-link + +所有跨组件通信 MUST 经由 core.eventBus + +所有提示信息 MUST 使用 ui.message.toast + +所有可配置行为 MUST 进入配置文件或用户偏好文件 + + +1.2 SHOULD Rules + +所有 UI 组件 SHOULD 具备 hover / active 微交互 + +所有窗口 SHOULD 支持 3D 倾斜与模糊动画 + + +1.3 MAY Rules + +页面 MAY 根据设备启用不同动效策略 + + + +--- + +2. System Layering + +2.1 Core Layer + +core.pageLoader + +core.eventBus + +core.versionUpdater + + +2.2 UI Layer + +ui.button.capsule + +ui.button.rect + +ui.table.glass3d + +ui.message.toast + + +2.3 Window Layer + +window.base + +window.link + +window.auth.entry + +window.chart.detail + +window.setting.panel + + +2.4 Page Layer + +Home + +Room List + +Chart Rank + +User Rank + +Account + +Tools + + + +--- + +3. Naming Convention + +3.1 Component Naming + +格式: + +.. + +示例: + +core.eventBus + +ui.message.toast + +window.chart.detail + + +3.2 File Naming + +JS: kebab-case + +CSS: kebab-case + +Config: snake_case.json + + + +--- + +4. Communication Specification + +4.1 Event Bus + +所有事件 MUST 预先声明 + +事件支持 sync / async + +提供生命周期钩子: + +beforeEmit + +afterEmit + +onError + + + + +--- + +5. Configuration System + +5.1 App Config + +API mode + +Base URL + +External API + +Background + +Particle effects + + +5.2 User Preference Config + +严格遵循 schema + +支持 boolean / free / option / restricted + + + +--- + +6. Visual & Interaction Rules + +6.1 Theme + +默认主题色:#a1e5ef + #61E8EA + +毛玻璃可继承主题色 + + +6.2 Animation + +所有 hover 动效需平滑 + +Window 打开 / 关闭 MUST 有统一动画 + + + +--- + +7. Page Specifications + +7.1 Home + +左:文字 + 动画 + +右:服务器状态 + 群号 + + +7.2 Room List + +Glass table + +SSE 实时更新 + + +7.3 Rankings + +支持分页 + +支持 window-chart 打开 + + + +--- + +8. Window Specifications + +8.1 window.base + +生命周期完整 + +支持嵌套 + +统一关闭逻辑 + + +8.2 window.link + +接管默认跳转 + +内嵌页面浏览能力 + + +8.3 window.chart.detail + +Chart 信息聚合 + +下载能力 + +排行榜展示 + + + +--- + +9. API Contracts (Summary) + +/api/auth/* + +/api/rooms/* + +/chart/* + +/hot_rank/* + + +> 详细字段定义见原始 API 文档章节 + + + + +--- + +10. Loader & Startup Flow + +1. pageLoader 初始化 + + +2. 配置文件加载 + + +3. 组件注册 + + +4. 首次渲染 + + +5. 动效启用 + + + + +--- + +11. Appendix + +Loading animation HTML / CSS + +Meta SEO rules + +i18n strategy + + + +--- + +12. Original Spec Mapping (Authoritative Reference) + +> 本章节用于将 原始完整规格文档 映射到本 AI-Ready Spec 中,作为“细节权威来源”。 + +当 AI 在实现中需要更具体行为、字段或 UI 细节时,应回溯对应的原始章节,而不是自行推断。 + + + +12.1 页面级映射 + +AI-Ready Spec 原始规格位置 + +7.1 Home 页面 → 主页 → 左侧 / 右侧 +7.2 Room List 页面 → 房间列表 +7.3 Rankings 页面 → 谱面排行 / 用户排行 +Account Page 页面 → 账户管理 +Tools Page 页面 → 谱面下载工具 / Phira下载站 + + +12.2 Window 组件映射 + +AI-Ready Window 原始规格位置 + +window.base window组件及其附属组件 → window组件 +window.link window-link组件 +window.auth.entry window-auth窗口组件 +window.chart.detail window-chart窗口组件 +window.setting.panel window-setting窗口组件 + + +12.3 Core 模块映射 + +Core Module 原始规格位置 + +core.pageLoader 页面加载组件 +core.eventBus 消息总线组件 +core.versionUpdater 页面更新组件 + + +12.4 UI 组件映射 + +UI Component 原始规格位置 + +ui.button.capsule 统一按钮基础样式 → 胶囊状 +ui.button.rect 统一按钮基础样式 → 圆角矩形状 +ui.table.glass3d table组件 +ui.message.toast message组件 +footer.component footer组件 +header.component header组件 + + +12.5 配置系统映射 + +配置类别 原始规格位置 + +App Config 规范 → 配置文件 → 基础网页配置 +User Preference Schema 用户偏好配置文件规范 +Theme / Visual Config 视效与样式规范 + + +12.6 API 映射 + +API Group 原始规格位置 + +Auth APIs api格式 → /api/auth/* +Room APIs /api/rooms/info / listen +Rank APIs /chart/* /hot_rank/* +Status APIs 服务状态查询 /api/status +External Phira API Phira外部 api + + + +--- + +> 规则声明: + +AI-Ready Spec 中的 MUST / SHOULD / MAY 为最高约束层级 + +原始规格文档用于补充实现细节,不得推翻 MUST 规则 + +若两者出现冲突,以 AI-Ready Spec 为准 \ No newline at end of file diff --git a/Mapping.md b/Mapping.md new file mode 100644 index 0000000..f8fe71e --- /dev/null +++ b/Mapping.md @@ -0,0 +1,145 @@ +HSNPhira — Original Spec ↔ AI-Ready Spec Mapping + +> 本文档是独立的映射文件(Index / Mapping),用于把「原始完整规格」精确指向 AI-Ready System Spec。 + +规则:当实现需要任何细节,必须通过本 Mapping 回溯原始规格;禁止自行推断。 + + + + +--- + +使用与约束声明(必须阅读) + +本 Mapping 不定义新规则 + +一切 MUST / SHOULD / MAY 以 AI-Ready System Spec 为最高裁决 + +本文件只回答一个问题:“细节在原始文档的哪里?” + +AI 若无法指出其实现对应的原始章节 → 视为不合规实现 + + + +--- + +1. 页面级映射 + +页面 原始规格位置 + +Home 页面 → 主页 → 左侧 / 右侧 +Room List 页面 → 房间列表 +Chart Rank 页面 → 谱面排行 +User Rank 页面 → 用户排行 +Account 页面 → 账户管理 +Tools 页面 → 谱面下载工具 / Phira 下载站 +Navigation 页面 → 导航页 + + + +--- + +2. Window 组件映射 + +Window 组件 原始规格位置 + +window.base window组件及其附属组件 → window组件 +window.link window-link组件 +window.auth.entry window-auth窗口组件 +window.chart.detail window-chart窗口组件 +window.setting.panel window-setting窗口组件 + + + +--- + +3. Core 模块映射 + +Core 模块 原始规格位置 + +core.pageLoader 页面加载组件 +core.eventBus 消息总线组件 +core.versionUpdater 页面更新组件 + + + +--- + +4. UI 组件映射 + +UI 组件 原始规格位置 + +ui.button.capsule 统一按钮基础样式 → 胶囊状 +ui.button.rect 统一按钮基础样式 → 圆角矩形状 +ui.table.glass3d table组件 +ui.message.toast message组件 +header.component header组件 +footer.component footer组件 + + + +--- + +5. 配置系统映射 + +配置类型 原始规格位置 + +App Config 规范 → 配置文件 → 基础网页配置 +User Preference Schema 用户偏好配置文件规范 +Theme / Visual 视效与样式规范 + + + +--- + +6. API 映射 + +API 分类 原始规格位置 + +Auth api格式 → /api/auth/* +Rooms /api/rooms/info / listen +Rankings /chart/* /hot_rank/* +Status 服务状态查询 /api/status +History /api/history +External Phira Phira 外部 API + + + +--- + +7. 加载 / 启动 / 动效映射 + +功能 原始规格位置 + +Page Loader 生命周期 页面加载组件 → 加载管理 +Loading Animation 页面加载动画 +Startup Flow 加载规范 + + + +--- + +8. i18n / SEO / Meta 映射 + +功能 原始规格位置 + +i18n 国际化支持 +Meta / SEO meta标签规范 + + + +--- + +冲突处理(重要) + +Mapping 没有裁决权 + +任意冲突:无条件以 AI-Ready System Spec 为准 + +Mapping 的职责仅限:定位、索引、防脑补 + + + +--- + +> 建议在所有 AI 对话中: AI-Ready System Spec + 本 Mapping + 原始完整规格 三者同时提供。 \ No newline at end of file diff --git a/Original Spec.md b/Original Spec.md new file mode 100644 index 0000000..5b8bdae --- /dev/null +++ b/Original Spec.md @@ -0,0 +1,1712 @@ +# HSN + + +## HSNPhira-main-frontend + +### 页面 + +#### 主页 + +##### 左侧 + + +###### 显示文字,从上到下依次为:HyperSynapse Network Phira多人游戏服务器 +免费 · 多功能 · 稳定 · 低延迟 +已有 (通过GET /api/auth/visited/count获取) 位用户使用过我们的服务器 + + +###### 拥有从左飘入的动画 + + +##### 右侧 + + +###### 一个毛玻璃卡片,上部载入服务器状态查询组件,中部显示QQ群号 +1049578201与服务器地址 +service.htadiy.com:7865,中部两个内容也使用有鼠标上移浮起效果的毛玻璃卡片包裹,最下部为复制群号和服务器地址的毛玻璃按钮,复制成功有提示 + + +#### 房间列表 + +##### 主体为一个table组件 + + +###### 显示房间名 + + +###### 显示房主 + + + * 房主名被胶囊状按钮包裹,点击跳转其Phira主页 + + +###### 人数 + + + * 显示为人数/100 + + +###### 状态 + + +###### 循环 + + + * 是或否 + + +###### 谱面 + + + * 显示为一个包裹着谱面名的胶囊状按钮,点击打开window-chart窗口组件 + + + * 谱面未选择时显示未选择 + + +###### 曲绘 + + + * 点击以灯箱形式查看谱面图片 + + +###### 人员 + + + * 为一个查看字样的胶囊状按钮,点击打开一个窗口,顶部显示房间内玩家,下面从上往下显示各个房间内用户的被胶囊状按钮包裹的用户名,点击打开其主页,房主的字体有高光 + + +###### 游玩历史 + + + * 为一个查看字样的胶囊状按钮,点击打开一个窗口。每局游戏的游玩信息,在同一个毛玻璃卡片中显示,该毛玻璃卡片顶部为一个长方形毛玻璃卡片, + +显示曲绘、谱名、谱面id,谱名为跳转按钮,谱面id为复制按钮,按下来每行显示两个用户的游玩成绩卡片, + +游玩成绩卡片上部层序中显示该玩家的用户名(跳转按钮), + +下部显示成绩。score最高的一人或几人的成绩卡片发金色高光 + + +##### table组件上有服务器状态查询组件 + + +#### 谱面排行 + +##### 主体为一个table组件 + + +###### 名次 + + +###### 谱面名称 + + + * 按钮,点击打开window-chart窗口组件 + + +###### 谱面ID + + + * 按钮,点击复制,复制到的id前加#号 + + +###### 游玩人数 + + +###### 曲绘 + + + * 查看按钮,灯箱查看 + + +###### 支持翻页 + + +##### table组件上有服务器状态查询组件 + + +#### 用户排行 + +##### 主体为一个table组件 + + +###### 名次 + + +###### 用户名 + + + * 按钮,点击跳转Phira主页 + + +###### 游玩时间,单位分钟 + + +###### 支持翻页 + + +##### table组件上有服务器状态查询组件 + + +#### 账户管理 + +##### 主体为一个毛玻璃卡片,显示用户信息,并支持修改用户信息 + + +#### 用户协议 + +##### 使用根目录下的privacy.html + + +#### 公告页 + + +##### 暂时搁置 + + +#### 谱面下载工具 + +##### 上部为一个输入框,输入谱面id并确定时直接打开对应的window-chart窗口组件,打开前查询是否存在,不存在就提示错误,使用message组件 + + +##### 下方为一个毛玻璃卡片,给予了https://phira.moe/chart链接,并说明如何获取谱面id,如何使用本工具(先通过https://phira.moe/chart搜索找到自己想要下载的谱面,打开这个谱面的页面后点击地址框,https://phira.moe/chart/后的数字就是谱面id) + + +#### Phira下载站 + + +##### 主题为一个毛玻璃卡片,提供Phira安卓端下载https://hk.gh-proxy.org/https://github.com/TeamFlos/phira/releases/download/v0.6.7/Phira-v0.6.7-arm64-v8a.apk,提供Phira Windows端下载https://hk.gh-proxy.org/https://github.com/TeamFlos/phira/releases/download/v0.6.7/Phira-windows-v0.6.7.zip,提供linux下载https://hk.gh-proxy.org/https://github.com/TeamFlos/phira/releases/download/v0.6.7/Phira-linux-v0.6.7.zip + + +#### 导航页 + +##### 使用卡片布局 + + +##### https://github.com/login?return_to=https://github.com/TeamFlos/phira/ + + +###### Phira官方仓库 + + +##### https://phira.moe/ + + +###### Phira官网 + + +##### https://status.dmocken.top/status/phira + + +###### Phira多人游戏服务器状态查询 + + +##### https://phira.dmocken.top/ + + +###### Dmocken的Phira下载站 + + +##### https://phira.htadiy.com/ + + +###### HyperSynapse Network Phira多人游戏服务器官网 + + +##### https://phira.chuzoux.top/ + + +###### FunXLink Studio Phira 联机服务器 Web 监控面板 + + +##### http://qd.phira.huqi-studio.top:50253/ + + +###### PyPhira 房间查询 + + +##### https://iphira.danieltoyama.fun/ + + +###### 户山兔兔的phira服务器房间列表查询 + + +##### https://gitee.com/HyperSynapse-Network/HSNPhira + + +###### HSNPhira官方仓库 + + +##### https://github.com/Pimeng/tphira-mp/ + + +###### tphira官方仓库 + + +##### https://github.com/Evi233/pyphira-mp + + +###### pyphira官方仓库 + + +##### https://docs.qq.com/pdf/DU0FRVHVCd01KdERO + + +###### Phira常见问题自助文档 + + +### 规范 + +#### 配置文件 + +##### 基础网页配置 + + +###### api模式配置(本地/远程) + +###### 远程api地址配置 + + * 远程Base URL配置 + +###### 各个api路由配置 + +###### 页面更新组件的云上版本文件地址配置 + +###### 外部api配置 + + + * Phira外部api Base URL配置(未配置时为https://phira.5wyxi.com) + + +###### 背景图URL配置 + + + * 默认为https://webstatic.cn-nb1.rains3.com/5712×3360.jpeg + + +###### 背景粒子效果组件路径与其自动触发时间段(时间段可选) + + +###### 页面更新组件的云上版本文件地址配置  + +##### 用户偏好配置文件规范 + +###### { + "version": /*字符串,必须,规范版本号,用于格式兼容性管理*/, + "appId": /*字符串,必须,应用标识符,用于配置文件识别*/, + "groups": /*数组,必须,配置分组列表,至少包含一个分组*/ [ + { + "id": /*字符串,必须,分组唯一标识符*/, + "name": /*字符串,必须,分组英文名称*/, + "name_zh": /*字符串,必须,分组中文名称*/ + } + ], + "preferences": /*数组,必须,配置项列表*/ [ + { + "id": /*字符串,必须,配置项唯一标识符*/, + "group": /*字符串,必须,所属分组ID,必须与groups中某个id对应*/, + "type": /*字符串,必须,配置类型,枚举值:boolean|free|option|restricted*/, + "name": /*字符串,必须,配置项英文名称*/, + "name_zh": /*字符串,必须,配置项中文名称*/, + "default": /*任意类型,必须,默认值,类型由type决定*/, + /* 以下字段根据type类型可选存在 */ + "multiple": /*布尔值,当type为"option"时必须,表示是否允许多选,true表示多选,false表示单选*/, + "options": /*数组,当type为"option"时必须,选项列表*/ [ + { + "value": /*字符串,必须,选项值*/, + "name": /*字符串,必须,选项英文名称*/, + "name_zh": /*字符串,必须,选项中文名称*/ + } + ], + "placeholder": /*字符串,当type为"free"时可选,输入框占位符英文*/, + "placeholder_zh": /*字符串,当type为"free"时可选,输入框占位符中文*/, + "constraints": /*对象,当type为"restricted"时必须,数值约束条件*/ { + "min": /*数字,必须,最小值*/, + "max": /*数字,必须,最大值*/, + "step": /*数字,可选,步进值,默认为1*/ + } + } + ] +} + +配置类型说明 + +1. boolean(布尔类型):表示开关配置,默认值必须是 +"true"或 +"false" +2. free(自由类型):表示自由文本输入,默认值必须是字符串 +3. option(选项类型):表示单选或多选配置,默认值在单选时为字符串,多选时为字符串数组 +4. restricted(限制类型):表示数值范围配置,默认值必须是数字,且在约束范围内 + +数据验证规则 + +1. 所有ID字段(分组id、配置项id)必须在各自作用域内保持唯一 +2. 配置项的 +"group"字段必须引用 +"groups"数组中已定义的分组ID +3. 选项类型的配置项必须包含 +"multiple"和 +"options"字段 +4. 限制类型的配置项必须包含 +"constraints"字段,且 +"min"和 +"max"必须为有效数字 +5. 选项类型的 +"default"值必须存在于对应 +"options"数组的 +"value"中 +6. 限制类型的 +"default"值必须在 +"min"和 +"max"定义的范围内 +7. 单选选项的 +"default"必须是字符串,多选选项的 +"default"必须是字符串数组 +8. 布尔类型的 +"default"必须是布尔值 +"true"或 +"false" + +##### 用户偏好配置文件可配置项 + + +###### 主题色hex值 + + +###### 是否将毛玻璃效果背景色设为主题色 + + + * 背景色透明度 + + + * 默认为否 + + +###### 背景粒子效果开关 + + +###### 背景图 + + +###### 背景粒子效果选择 + + +###### 等其他配置项 + + +###### 使用的语言配置 + + +#### api格式 + + +##### HSNPhira-main-backend + +###### POST /api/auth/login +说明:登录用户 + +请求数据格式: + +{ + "username": /*字符串,用户名*/, + "password": /*字符串,密码*/, + "remember": /*布尔值,是否记住用户*/ +} +响应数据格式:登录的用户信息,格式见 /api/auth/me。 + +###### POST /api/auth/logout +说明:登出用户 + +###### GET /api/auth/me +说明:获取当前用户信息 + +响应数据格式: + +{ + "id": /*整数,用户 ID*/, + "group_id": /*整数,用户所在组 ID*/, + "username": /*字符串,用户名*/, + "phira_id": /*整数,用户的绑定的 Phira 账号 ID*/, + "phira_username": /*字符串,用户的 Phira 用户名*/, + "phira_rks": /*浮点数,用户的 Phira rks*/, + "phira_avatar": /*字符串,用户的 Phira 头像链接*/, + "register_time": /*时间字符串,用户注册时间*/, + "last_login_time": /*时间字符串,用户上次登录时间*/, + "last_sync_time": /*时间字符串,用户上次同步 Phira 账号数据时间*/ +} + + +###### POST /api/auth/users +说明:创建用户 + +请求数据格式: + +{ + "group_id": /*整数,可选,用户所在组 ID,默认值为 3(user 组)*/, + "username": /*字符串,用户名*/, + "phira_id_or_username": /*字符串,用户试图绑定的 Phira 账号 ID(字符串形式)或者 Phira 用户名*/, + "password": /*字符串,用户密码*/ +} +响应数据格式:返回一个 SSE 事件流,格式为下面之一: + +event: validating +data: <一个字符串,表示验证所用的 token> +说明:这个事件恰好在进行请求后发送一次,之后用户需在 5min 内用试图绑定的 Phira 账号创建名称是 token 的房间来完成验证。 + +event: timeout +说明:在超时后发送,之后流关闭。 + +event: success +data: <一个 JSON 格式字符串,与 /api/auth/me 格式相同,表示新注册的用户信息> +说明:在验证成功后发送,之后流关闭。 + +event: error +data: <一个字符串,表示错误信息> +说明:发生服务端错误时发送,之后流关闭。 + +: heartbeat +说明:用于检测客户端是否存活,无实际意义,浏览器一般会忽略。 + +特殊说明:若指定了 group_id 字段,则要求请求者t拥有 GROUP_MANAGEMENT 权限。 + +###### GET /chart/{chart_id}/rank  +说明:获取指定谱面的排行榜信息 + +{ + "chart_id": /*整数,谱面唯一标识ID*/, + "last_count": /*整数,谱面最新游玩记录总数*/, + "increase": /*整数,指定时间范围内的游玩增量*/, + "hour_increase": /*整数,最近一小时的游玩增量*/, + "day_increase": /*整数,最近一天的游玩增量*/, + "week_increase": /*整数,最近一周的游玩增量*/, + "month_increase": /*整数,最近一月的游玩增量*/, + "time_range": /*字符串,请求的时间范围*/ +} + + + +###### GET /hot_rank/{time_range} +说明:获取谱面排行榜数据 + +该接口通过路径参数 +"time_range" 指定统计时间范围( +"hour"/ +"day"/ +"week"/ +"month"),可选查询参数 +"page" 控制分页页码(从 1 开始)、 +"per_page" 控制每页返回条数,用于获取指定时段内按游玩增量排序的谱面热门排行榜数据。 + +{ + "last_chart_list_update": /*字符串,谱面列表最后更新时间(ISO 8601格式)*/, + "last_record_update": /*字符串,游玩记录最后更新时间(ISO 8601格式)*/, + "page": /*整数,当前页码*/, + "per_page": /*整数,每页数量*/, + "results": [ + { + "chart_id": /*整数,谱面唯一标识ID*/, + "increase": /*整数,指定时间范围内的游玩增量*/ + } + ], + "time_range": /*字符串,请求的时间范围*/, + "total_results": /*整数,当前页结果数量*/ +} + + + +###### GET /api/rooms/info +说明:获取房间列表 + +响应数据格式: + +[ + { + "name": /*字符串,房间名称*/, + "data": { // 房间数据 + "host": /*整数,房间 host 的 Phira ID*/, + "users": /*列表,包含房间内所有用户 Phira ID*/, + "lock": /*布尔值,房间是否为 lock*/, + "cycle": /*布尔值,房间是否为 cycle*/, + "chart": /*整数或 null,房间当前选择的铺面 ID*/, + "state": /*字符串,SELECTING_CHART 或 WAITING_FOR_READY 或 PLAYING*/, + "playing_users": /*列表,包含还在进行游戏的用户 ID*/, + "rounds": [ // 房间已经进行过的所有轮游戏的信息 + { + "chart": /*整数,该轮铺面 ID*/, + "records": [ // 该轮玩家成绩信息 + { + "id": /*整数,记录 ID*/, + "player": /*整数,玩家 ID*/, + "score": /*整数,分数*/, + "perfect": /*整数,perfect 数量*/, + "good": /*整数,good 数量*/, + "bad": /*整数,bad 数量*/, + "miss": /*整数,miss 数量*/, + "max_combo": /*整数,max combo 数*/, + "accuracy": /*浮点数,精准度*/, + "full_combo": /*布尔值,是否 full combo*/, + "std": /*浮点数,无暇度*/, + "std_score": /*浮点数,无暇度分数*/ + }, + /*每项为一个符合以上格式的 object*/ + ] + }, + /*每项为一个符合以上格式的 object*/ + ], + } + }, + /*每项为一个符合以上格式的 object*/ +] + + +###### GET /api/rooms/listen +说明:监听房间信息更新 + +响应数据格式:一个 SSE 事件流,每个事件的格式如下: + +event: /*字符串,事件类型*/ +data: /*字符串,可解析为一个 json object*/ +特殊说明:此接口使用 Server-Sent Events(SSE)协议,客户端需要支持 SSE。不同事件类型如下: + +事件类型 数据格式 说明 +create_room {"room": /*字符串,房间名*/, "data": /*房间数据,格式见上文*/} 新房间 +update_room {"room": /*字符串,房间名*/, "data": /*部分房间数据*/} 房间数据更新 +join_room {"room": /*字符串,房间名*/, "user": /*整数,用户 Phira ID*/} 用户加入房间 +leave_room {"room": /*字符串,房间名*/, "user": /*整数,用户 Phira ID*/} 用户离开房间 +player_score {"room": /*字符串,房间名*/, "record": /*记录数据,格式见上文*/} 玩家完成游戏 +start_round {"room": /*字符串,房间名*/} 房间开始新一轮 + + +###### GET /api/auth/visited/count +说明:获取曾经使用过服务器的 Phira 用户数量 +响应数据格式: +/整数,表示用户数量/ + + +###### 1. 服务状态查询 + +获取系统运行状态和配置信息 + +端点: GET /status + +请求参数: 无 + +响应字段说明: + +- last_chart_list_update: 最后一次谱面列表更新时间,ISO 8601格式字符串 +- last_record_update: 最后一次游玩记录更新时间,ISO 8601格式字符串 +- cached_charts_count: 当前缓存的谱面总数,整数 +- queue_size: 待更新谱面队列长度,整数 +- update_interval: 单个谱面更新间隔,单位为秒,整数 +- interval: 全局游玩记录获取间隔,单位为秒,整数 +- chart_list_interval: 谱面列表更新间隔,单位为秒,整数 +- per_page: 每次获取谱面数量,整数 +- all_mode: 是否全量模式,布尔值 + +2. 热门排行榜 + +查询指定时间范围内的谱面热度排行榜 + +端点: GET /hot_rank/ + +路径参数: + +- time_range: 统计时间范围,必填,可选值: hour, day, week, month + +查询参数: + +- page: 页码,从1开始,可选,默认值: 1 +- per_page: 每页数量,可选,默认值: 20 + +响应字段说明: + +- last_chart_list_update: 谱面列表最后更新时间,ISO 8601格式字符串 +- last_record_update: 游玩记录最后更新时间,ISO 8601格式字符串 +- page: 当前页码,整数 +- per_page: 每页显示的结果数量,整数 +- results: 排行榜数据数组 + - chart_id: 谱面唯一标识ID,整数 + - increase: 在指定时间范围内该谱面的游玩次数增量,整数 +- time_range: 请求的时间范围,字符串 +- total_results: 当前页实际返回的结果数量,整数 + +错误响应: + +当time_range参数不合法时返回: + +{ + "error": "Invalid time range" +} + +3. 谱面排名详情 + +查询指定谱面在各个时间段的排名和增量信息 + +端点: GET /chart_rank/ + +路径参数: + +- chart_id: 谱面ID,整数,必填 + +响应字段说明: + +- chart_id: 查询的谱面ID,整数 +- ranks: 各时间段排名信息对象 + - hour: 最近1小时的排名信息 + - increase: 最近1小时的游玩增量,整数 + - rank: 在最近1小时热度排行榜中的名次,整数 + - last_updated: 该排名数据的更新时间,ISO 8601格式字符串 + - day: 最近1天的排名信息 + - increase: 最近1天的游玩增量,整数 + - rank: 在最近1天热度排行榜中的名次,整数 + - last_updated: 该排名数据的更新时间,ISO 8601格式字符串 + - week: 最近1周的排名信息 + - increase: 最近1周的游玩增量,整数 + - rank: 在最近1周热度排行榜中的名次,整数 + - last_updated: 该排名数据的更新时间,ISO 8601格式字符串 + - month: 最近1月的排名信息 + - increase: 最近1月的游玩增量,整数 + - rank: 在最近1月热度排行榜中的名次,整数 + - last_updated: 该排名数据的更新时间,ISO 8601格式字符串 + + +###### "/api/history" +- 方法: GET +- 参数: + - +"hours" (可选): 指定返回多少小时的数据,默认为48小时 +- 响应格式: JSON + +响应示例: + +{ + "server_name": /*字符串,服务器名称标识,与健康检查接口一致*/, + "data_points": [ + { + "timestamp": /*字符串,数据点时间戳,ISO 8601格式*/, + "online": /*布尔值,该时间点的服务在线状态。true表示在线,false表示离线或异常*/, + "latency_ms": /*浮点数或null,该时间点的接口响应延迟,单位为毫秒。当online为false时可能为null*/, + "error_message": /*字符串或null,当online为false时的错误描述信息。可选字段,仅在线状态异常时出现*/ + } + ], + "summary": { + "total_points": /*整数,总数据点数*/, + "online_points": /*整数,在线状态的数据点数*/, + "offline_points": /*整数,离线状态的数据点数*/, + "availability_rate": /*浮点数,服务可用率,单位为百分比(0-100)*/, + "avg_latency": /*浮点数,平均延迟,单位为毫秒。仅计算在线状态的数据点*/, + "max_latency": /*浮点数,最大延迟,单位为毫秒。仅计算在线状态的数据点*/, + "min_latency": /*浮点数,最小延迟,单位为毫秒。仅计算在线状态的数据点*/ + } +} + + +###### "/api/status" +- 方法: GET +- 响应格式: JSON + +响应示例: + +{ + "online": /*布尔值,服务在线状态。true表示服务正常,false表示服务异常*/, + "latency_ms": /*浮点数,接口响应延迟,单位为毫秒*/, + "last_check": /*字符串,最后一次健康检查时间,ISO 8601格式*/, + "server_name": /*字符串,服务器名称标识*/, + "timestamp": /*字符串,当前接口响应时间,ISO 8601格式*/ +} + + +###### 获取前N名游玩时间排行榜 + +GET /api/playtime_leaderboard/top/ + +说明:获取指定数量的排行榜前N名用户 + +路径参数: + +- limit: 返回的用户数量,正整数 + +响应数据格式: + +{ + "success": "请求是否成功的布尔值", + "data": [ + { + "user_id": "用户ID,整数", + "total_playtime": "总游玩时间(秒),整数" + } + ], + "timestamp": "请求时间戳,ISO 8601 格式", + "total_users": "返回的用户数量,整数" +} + + +###### PATCH /api/auth/users/ +说明:修改用户 ID 为 id 的用户信息 + +请求数据格式: + +{ + "current_password": /*字符串,请求者账户的当前密码*/, + "group_id": /*整数,可选,用户所在组 ID,默认值为 3(user 组)*/, + "username": /*字符串,可选,用户名*/, + "phira_id": /*整数,可选,用户的绑定的 Phira 账号 ID*/, + "password": /*字符串,可选,用户密码*/ +} +返回数据格式:修改后的用户信息,格式见 /api/auth/me。 + + +##### Phira外部 api + + +###### https://phira.5wyxi.com/chart/{chart-id} + + + * 谱面信息查询 + + + * { + "id": /*整数,谱面唯一标识ID*/, + "name": /*字符串,谱面名称*/, + "level": /*字符串,谱面难度等级显示文本*/, + "difficulty": /*浮点数,谱面数值难度*/, + "charter": /*字符串,谱师名称*/, + "composer": /*字符串,曲师/作曲家*/, + "illustrator": /*字符串,画师/插画作者*/, + "description": /*字符串,谱面描述文本*/, + "ranked": /*布尔值,功能不明但不影响功能*/, + "reviewed": /*布尔值,是否已通过审核*/, + "stable": /*布尔值,是否已经上架*/, + "stableRequest": /*布尔值,是否正在申请上架*/, + "illustration": /*字符串,谱面封面图链接*/, + "preview": /*字符串,预览音频文件链接*/, + "file": /*字符串,谱面文件下载链接*/, + "uploader": /*整数,上传者用户ID*/, + "tags": /*字符串数组,谱面标签列表*/, + "rating": /*浮点数,谱面综合评分*/, + "ratingCount": /*整数,评分人数*/, + "created": /*时间字符串,谱面创建时间*/, + "updated": /*时间字符串,谱面最后更新时间*/, + "chartUpdated": /*时间字符串,谱面数据最后更新时间*/ +} + + + +###### https://phira.5wyxi.com/user/{phira-id} + + + * Phira用户信息查询 + + + * { + "id": /*整数,用户唯一标识ID*/, + "name": /*字符串,用户名*/, + "avatar": /*字符串,用户头像图片链接*/, + "badges": /*数组,用户所在的权限组*/, + "language": /*字符串,用户偏好的语言代码*/, + "bio": /*字符串,用户个人简介*/, + "exp": /*整数,用户经验值*/, + "rks": /*浮点数,用户rks值*/, + "joined": /*时间字符串,用户注册时间*/, + "last_login": /*时间字符串,用户最后登录时间*/, + "roles": /*整数,用户权限*/, + "banned": /*布尔值,用户是否被封禁*/, + "login_banned": /*布尔值,用户是否被禁止登录*/, + "follower_count": /*整数,用户粉丝数量*/, + "following_count": /*整数,用户关注他人数量*/, + "following": /*布尔值,未知*/ +} + + + +###### https://phira.5wyxi.com/record/query/{chart_id}?pageNum={page_size}&page={page_number}&includePlayer={include_player}&best={best_only}&std={std_sort} + + + * 谱面成绩排行榜信息查询 + + + * "pageNum" 指定每页显示记录数(最大值为30), +"page" 指定当前页码, +"includePlayer" 控制是否包含玩家信息, +"best" 控制是否只返回每个玩家的最高分记录, +"std" 控制是否按无暇度分数排序。 + +{ + "count": /*整数,查询结果总数*/, + "results": [ + { + "id": /*整数,成绩记录唯一ID*/, + "player": /*整数,玩家用户ID*/, + "chart": /*整数,谱面ID*/, + "score": /*整数,本次游玩得分*/, + "accuracy": /*浮点数,准度(0-1)*/, + "perfect": /*整数,Perfect判定数量*/, + "good": /*整数,Good判定数量*/, + "bad": /*整数,Bad判定数量*/, + "miss": /*整数,Miss判定数量*/, + "speed": /*浮点数,速度倍率(1为原速)*/, + "max_combo": /*整数,最大连击数*/, + "best": /*布尔值,是否为该玩家该谱面最高分记录*/, + "best_std": /*布尔值,是否为该玩家该谱面最高std记录*/, + "mods": /*整数,Mods位掩码*/, + "full_combo": /*布尔值,是否达成Full Combo*/, + "time": /*时间字符串,成绩记录时间*/, + "std": /*浮点数,标准偏差值*/, + "std_score": /*浮点数,无暇度值*/, + "playerName": /*字符串,玩家用户名*/, + "playerAvatar": /*字符串,玩家头像URL(可为null)*/, + "playerBadges": /*字符串数组,玩家权限组*/ + } + ] +} + + +#### 加载规范 + + +##### 页面优先加载页面加载组件,其他组件向页面加载组件注册组件并接受页面加载组件的加载调配 + + +#### 文件结构规范 + + +##### 根目录下设有资源文件夹,配置文件夹,组件文件夹,js文件夹,css文件夹,组件文件夹下设js,css文件夹 + + +##### 页面的js/css等都相互分离 + + +#### 通讯规范 + + +##### 大部分通信行为都需要经过消息总线组件 + + +#### 国际化支持 + + +##### 使用 jQuery.i18n.properties + + +##### 支持中英文 + + +###### 自动检测用户语言环境,自动切换 + + +#### 视效与样式规范 + + +##### 统一的字体 + + +##### 统一的毛玻璃效果配置 + + +###### 毛玻璃的背景色也可被设为主题色(用户偏好配置文件) + + +##### 统一的主题色 + + +###### 默认使用#a1e5ef与#61E8EA配合(可配置) + + +##### 信息提示统一使用message组件 + + +##### table组件中的按钮统一使用胶囊状按钮 + + +#### meta标签规范 + + +##### 每个页面都需要一个完善的meta标签用于提升页面SEO,同时为了提升SEI,谱面下载工具的meta介绍里必须同时提及谱面下载工具和铺面下载工具(铺面虽然是不正确的说法,但是使用更广) + + +### 组件 + +#### 页面加载组件 + +##### 加载器 + +###### 加载管理 + + + * 异步加载 + + + * 通过优先级信息调度加载 + + + * 生命周期钩子 + + + * 提供如  beforeLoad 、 onProgress 、 onComplete 、 onError  等回调函数或事件 + + + * 用于显示一个精确的加载进度条,提升用户体验 + + + * 执行“所有资源和组件已就绪”后的操作 + + + * 触发页面首次渲染,使毛玻璃效果、3D倾斜动画等需要DOM完全就绪才能正常工作的视觉效果得以正确应用,确保在加载动画结束后页面已经渲染完成 + + + * 移除初始的加载动画 + + + * 弹出早上/下午/晚上好+user名字(若已登录) + + + * 错误处理与重试机制 + + + * 当某个资源加载失败时,能够进行记录,并可能触发重试或提供降级方案,而不是让整个页面加载过程卡住或完全崩溃 + + +###### 确保加载动画跑完后页面已经完成所有效果渲染 + +##### 页面加载动画 + +###### 加载动画 + + * html: +
+
+
+
+
+
+
+css: +.load_11 { + width: 50px; + height: 40px; + display: inline-block; + text-align: center; + font-size: 10px; +} +.load_11 > div { + background-color: #61E8EA; + height: 100%; + width: 6px; + display: inline-block; + -webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out; + animation: sk-stretchdelay 1.2s infinite ease-in-out; +} +.load_11 .rect2 { + -webkit-animation-delay: -1.1s; + animation-delay: -1.1s; +} +.load_11 .rect3 { + -webkit-animation-delay: -1.0s; + animation-delay: -1.0s; +} +.load_11 .rect4 { + -webkit-animation-delay: -0.9s; + animation-delay: -0.9s; +} +.load_11 .rect5 { + -webkit-animation-delay: -0.8s; + animation-delay: -0.8s; +} +@-webkit-keyframes sk-stretchdelay { + 0%, 40%, 100% { -webkit-transform: scaleY(0.4) } + 20% { -webkit-transform: scaleY(1.0) } +} +@keyframes sk-stretchdelay { + 0%, 40%, 100% { + transform: scaleY(0.4); + -webkit-transform: scaleY(0.4); + } 20% { + transform: scaleY(1.0); + -webkit-transform: scaleY(1.0); + } +} + + + + * 全屏覆盖 + + +###### 进度条 + + * 位于加载动画下方 + + + * 进度条下方显示tip + + + * 读取配置文件夹中的tip.txt,按行随机选择读取并显示 + + + * 每个tip停留时间由其字数动态决定,停留时间结束后抽取下一个tip显示 + + +#### 消息总线组件 + +##### 消息总线组件作为统一的事件驱动通信枢纽,为整个应用提供类型安全、生命周期可控的跨组件通信能力。组件采用发布-订阅模式,支持同步/异步事件处理、全局错误拦截和完整的生命周期管理。 + + +##### 跨组件通信 + +###### 事件的注册与监听 + +###### 事件的触发 + +###### 取消监听 + +###### 触发事件的附带数据的传递 + + +##### 核心特性 + + +###### 类型安全的事件系统 + + + * 强类型事件定义:所有事件必须预先注册类型签名,包括事件名称、数据类型和返回值类型 + + + * 事件签名验证:运行时对事件数据进行结构验证,确保数据类型符合预期 + + + +###### 完整的生命周期管理 + + + * 事件生命周期钩子:提供 beforeEmit 、 afterEmit 、 onError 等生命周期钩子 + + + * 监听器生命周期追踪:自动追踪每个监听器的创建、挂载、激活和销毁状态 + + + * 内存泄漏防护:自动清理僵尸监听器,防止内存泄漏 + + +###### 全局错误处理机制 + + + * 统一错误拦截:提供全局错误捕获和自定义错误处理策略 + + + * 错误恢复机制:支持错误重试、降级处理和错误边界定义 + + +###### 高级监听模式 + + + * 一次性监听:通过 once() 方法注册的监听器在触发后自动销毁 + + + * 优先级监听:可设置监听器执行优先级,控制事件处理顺序 + + + * 条件性监听:支持基于条件的监听器触发,满足条件时执行 + + +###### 异步事件支持 + + + * Promise-based API:所有事件触发均返回Promise,支持async/await语法 + + + * 并发事件处理:支持多个监听器并行执行,提高处理效率 + + +#### 统一按钮基础样式 + +##### 胶囊状 + +###### 毛玻璃效果 + +###### 文字超宽时自动启用滚动动画 + +###### 拥有悬停,发光效果 + +##### 圆角矩形状 + +###### 适应文本长度的全宽显示 + +###### 毛玻璃效果 + +###### 拥有悬停,发光效果 + +#### footer组件 + +##### 本组件为页面底部固定式页脚,具有动态交互效果,旨在以简洁的形式展示版权信息并提供联系入口 + + + +##### 视觉与布局 + + +###### 形态:胶囊状(圆弧边框) + + +###### 布局:包含版权文字(© 2025-2026 HyperSynapse Network. 保留所有权利)与“联系我们”按钮(点击打开公告页(不使用window-link),两者之间以细分隔线区分 + + +###### 定位:采用固定定位( position: fixed ),始终位于可视窗口底部,不随页面滚动而移动 + + + +##### 交互与状态 + + +###### 组件有两种视觉状态,可通过 hover 或 click 触发切换 + + +###### 默认状态(收缩) + + + * 尺寸:较小,呈胶囊形 + + + + * 效果:背景为毛玻璃模糊效果 + + +###### 激活状态(放大) + + + * 触发:鼠标悬停或点击组件时触发 + + + * 尺寸:组件宽度放大,更具视觉重心 + + + * 效果:背景变为液态玻璃质感(类似光滑的流体质感) + + + * 微交互:组件内的文字在悬停时有柔和的发光效果 + + +##### 状态切换动画 + + +###### 放大与缩小过程伴有平滑的过渡动画 + + +###### 背景效果从“毛玻璃”到“液态玻璃”同步进行渐变 + + +#### header组件 + +##### 为页面顶部导航组件,采用左、中、右三部分水平布局,包含品牌标识、导航链接与用户交互功能 + + +##### 结构设计 + + +###### 左侧部分 + + + * 内容:显示品牌Logo + + + * 功能:点击Logo可返回首页或刷新当前主页面 + + +###### 中间部分 + + + * 内容:承载页面跳转链接,呈现为圆角矩形按钮样式 + + + * 交互:当导航按钮数量过多导致宽度超出容器时,自动启用小横条样式的水平滚动条,确保内容可横向滚动浏览 + + +###### 右侧部分 + + + * 默认状态:显示为圆角矩形样式的“登录”按钮 + + + * 未登录交互:点击“登录”按钮,触发并弹出  window-auth  认证窗口 + + + * 已登录状态:认证成功后,该区域替换显示为用户头像 + + + * 头像交互:点击用户头像,展开一个具有毛玻璃效果的卡片菜单。菜单内容自上而下依次为 + + + * 用户信息:显示  name  与  id + + + * 分隔横条 + + + * 帐户管理按钮:点击跳转账户管理页面 + + + * Phira主页链接:显示为文本链接,指向  https://phira.moe/user/{phira-id} + + + * 偏好设置按钮:点击弹出  window-setting  窗口组件 + + + * 退出登录按钮:点击后清除登录状态,右侧区域恢复显示为“登录”按钮 + + +##### 样式规范 + + +###### 布局:采用Flexible布局或类似方案,确保三部分宽度自适应且居中对齐 + + +###### 滚动条:中间导航区的水平滚动条需设计为细长横条样式,与整体设计风格保持一致 + + +#### table组件 + +##### 3D毛玻璃卡片 + +###### 桌面端显示鼠标上移卡片3D倾斜效果 + + +###### 移动端显示悬停发光效果 + +##### 表格样式规范 + + +###### 表格宽度超过卡片时在表格组件内显示自定义的白色横条横向滚动条 + +###### 列标题文本颜色为用户偏好文件中的主题色 + + +###### 表格中所有文本鼠标上移后都会呈现发光效果,颜色为统一主题色 + + +#### message组件 + +##### 作为统一的全局消息提示组件,旨在替代项目中零散的消息提示逻辑。它通过监听消息总线组件上的特定事件来触发提示,允许在任意组件中间接调用并自定义提示内容 + + +##### 调用方式 + + +###### 事件注册: message  组件启动时,会在消息总线上自动监听  show-message  事件 + + +###### 事件触发:任何需要显示消息的组件,只需在消息总线上触发  show-message  事件,并传入以下参数对象以自定义消息: + title : {String} 消息卡片的大字标题。 + content : {String} 具体的消息正文。 +  backgroundColor : {String} [可选] 卡片的背景色。支持 CSS 颜色值格式(如十六进制  #RRGGBB 、RGB  rgb(r, g, b)  或颜色关键字)。组件同时提供一组常用颜色的预设常量(如  PRESET_COLORS.INFO 、 PRESET_COLORS.SUCCESS 、 PRESET_COLORS.WARNING 、 PRESET_COLORS.ERROR )供直接使用 + + +##### 任务队列与多卡片管理 + + +###### 任务队列:组件内部维护一个消息任务队列。当短时间内连续触发多个  show-message  事件时,所有消息会进入队列并按顺序依次显示,避免卡片重叠或冲突 + + +###### 多卡片并存:允许同时存在多个消息卡片。新触发的消息卡片会出现在队列中上一个卡片的下方,所有卡片按触发时间顺序从上至下在窗口右上角排列 + + +##### 样式与交互 + + +###### 位置与动效:消息卡片从浏览器窗口的右上角弹出 + + +###### 视觉外观:卡片为圆角矩形,并具有毛玻璃背景效果 + + +###### 内部布局:卡片分为上下两部分 + + + * 上部:显示大号的标题文字 + + + * 下部:显示消息内容文字 + + +###### 自适应宽度:卡片的宽度会根据消息内容的长度自动调整 + + +###### 关闭方式 + + + * 卡片右上角提供一个红色圆点作为关闭按钮,点击可立即关闭 + + + * 消息卡片在显示一段时间后,也会自动关闭。当一个卡片关闭后,其下方的卡片会自动上移填充位置 + + +#### 页面更新组件 + +##### 页面更新组件负责应用版本管理和用户协议更新提示,通过本地与云端版本比对实现智能更新,确保更新过程对当前会话无干扰 + + +##### 核心功能 + +###### 版本检测 + + * 本地版本存储:在localStorage中维护hsn_app_version和hsn_policy_version + + + * 云端版本获取:应用启动时异步请求云端版本配置文件 + + * 静默版本比对:在后台检测版本差异,不中断用户操作 + +###### 智能更新策略 + + * 应用版本更新:检测到新版本时标记需要更新,不清除当前会话缓存 + + * 用户协议更新:检测到协议版本更新时显示提示消息 + + * 下次访问更新:所有更新操作在用户下次打开页面时自动生效 + +###### 统一版本文件结构 + + * { + "appVersion": "字符串,应用版本号", + "policyVersion": "字符串,用户协议版本号" +} + + + +###### 存储策略 + + * 本地存储: localStorage 中分别存储 hsn_app_version和hsn_policy_version + + + * 用户信息保留:更新时保留登录相关数据 + + + * 缓存标记:设置 hsn_cache_invalid 标记指导下次访问的资源加载 + + + * 用户偏好保留 + + + * 更新时暂存用户在用户偏好设置文件中的修改,再逐个应用到新配置文件中 + + + * 更新时不删除用户上传的背景图 + + +###### 更新流程 + + * 启动时静默检测 + + * 读取本地存储的版本信息 + + * 异步请求云端版本文件(添加时间戳避免缓存) + + * 后台比对版本差异 + + * 设置相应更新标记 + + * 版本更新处理 + + * 应用版本更新:设置 hsn_cache_invalid 标记 → 更新本地版本号 → 下次访问时资源重新加载 + + + * 用户协议更新:显示协议更新提示 → 更新本地协议版本号 + + + * 缓存保留策略:清除静态资源缓存但保留用户登录状态相关数据 + + + * 页面更新完成后提示:页面已更新到最新版本 + + +#### 服务器状态查询组件 + + +##### 样式与交互 + + +###### 为一个毛玻璃卡片,背景色有两个样式:红色(服务器目前断联),绿色(服务器目前在线),顶部显示文字服务器在线/断联,下部显示服务器服务可用率,平均延迟 + + +#### 背景自定义组件 + + +##### 背景粒子效果 + +###### 根据页面配置文件自动触发或者根据用户偏好文件设置 + + +###### 有各种开箱即用的粒子效果选择 + + + * 下雪效果,下雨效果等 + + +##### 背景图片 + + +###### 通过页面配置文件配置的URL获取背景图或者使用用户上传到页面中缓存在service work中的背景图,背景图比例不正确时保持其原有比例,不显示超过屏幕部分 + + +#### window组件及其附属组件 + +##### window组件 + +###### 生命周期管理 + + * 完整的创建、挂载、显示、隐藏、销毁流程控制 提供一致的生命周期钩子,便于组件执行初始化或清理操作。 所有 Window 组件都需要此功能来管理自身状态。 + +###### 焦点与交互管理 + + * 确保模态弹窗获得焦点时,页面其他内容不可交互,且键盘事件被正确捕获。 + +###### 依赖与通信管理 + + * 窗口间通信与事件传递 提供一个内置的事件总线或消息机制,允许弹窗之间、弹窗与主页面之间进行通信。 + +###### 样式与动画管理 + + * 一个居中的毛玻璃卡片弹窗,右上角有红色圆形关闭按钮,当光标悬停在关闭按钮区域时,整个弹窗会产生朝向光标方向的3D倾斜效果,并伴有统一的模糊放大出现动画与倾斜淡出关闭动画,其窗口尺寸和内部内容完全由调用它的组件动态指定。 + + * 所有窗口都受最小边距限制,防止超出屏幕影响使用体验 + + +###### 嵌套窗口管理 + + * 支持二级或多级弹窗 能够管理弹窗中再打开弹窗的复杂场景(即嵌套弹窗),并正确维护它们的层级和焦点顺序。 + +##### window-link组件 + +###### 用于接管页面中所有的普通的没有指定不使用window-link组件的跳转行为,使所有跳转均改为使用window组件创建显示内嵌页面的窗口,windows-link组件获取被打开页面的标题并在弹窗顶部显示,标题由按钮包裹,点击新建标签页跳转该页面,window-link组件允许带特定标签的跳转不使用window-link组件打开,window-link组件使用页面加载组件的加载动画,window-link组件在窗口顶部提供内嵌页面前进与后退按钮,相当于一个小型浏览器 + +##### window-auth窗口组件 + +###### 为一个登录注册弹窗,通过window组件创建弹窗。有登录和注册两个功能,默认状态为登录,有错误提示功能,通过新建二级弹窗显示返回的错误信息 + + * 登录状态 + + * 有输入用户名与密码的输入框,输入框下有“忘记密码?”与“注册”文本,以及“记住我”复选框和“同意用户协议”复选框 + + * 使用HSNPhira-main-backend的/api/auth/login + + * 可以通过点击注册状态下的“登录”文本切换 + + * 登录完成后触发消息使header组件更新为登录后状态并自动关闭窗口 + + + * 注册状态 + + * 有输入用户名与密码以及PhiraID的输入框,输入框下有“忘记密码?”与“登录”文本,以及“记住我”复选框和“同意用户协议”复选框 + + * 使用HSNPhira-main-backend的/api/auth/users + + * 可以通过点击登录状态下的“注册”文本切换 + + * 登录/注册按钮 + + * 显示在窗口底部,使用圆角矩形状按钮样式 + + * 同意用户协议文本 + + * “用户协议”四字发光且点击可跳转用户协议页面 + +##### window-chart窗口组件 + +###### 一个使用窗口(window)组件的弹窗,用于集中显示谱面(chart)的详细信息 + + +###### 数据获取 + + + * 组件通过页面传递的 +"chart-id" 参数,获取并显示以下相关信息: + +* chart图片URL +* chart预览音频URL +* chart完整文件URL +* chart名称 +* 谱面难度显示值 +* 谱师ID +* 谱师名 +* 谱面说明 +* HSN提供的谱面热门信息 +* 谱面成绩排行榜信息 + + +###### 布局 + + + * 桌面端 + + + * 左上区域 + + + * 显示谱面图片(chart-img) + + + * 右上区域 + + + * 显示谱面名称(chart-name)、ID(chart-id)、谱师名、谱面难度、HSN谱面周热门排名与周新增游玩人数、谱面说明 + + * 左下区域 + + * 上部显示仿真运行输出的加载提示。当所有谱面信息加载完成后,显示HSN图标字符画及英文“信息来自Phira”,并展示数据获取耗时。下部显示谱面页面跳转按钮,谱面ID复制按钮(id前加#号),谱面文件下载按钮(点击弹出二级弹窗,显示下载曲绘(chart图片URL),下载预览音频(chart预览音频URL),下载谱面完整文件(chart完整文件URL)) + + + * 右下区域 + + + * 显示该谱面的游玩成绩排行榜 + + + * 使用https://phira.5wyxi.com/record/query/{chart-id}?pageNum=20&includePlayer=true&best=true&page=1&std=false + + + * 显示排名 玩家 分数 无瑕度 准度 Perfect Good Bad Miss 时间的值,当列表过长时使用横向小横条样式滚动条,列表底部支持翻页,玩家显示为左侧一个头像,右侧为玩家名,无暇度显示为最小精确到小数点后六位的整数,准度显示为精确到小数点后四位的百分比,时间显示为几天或几个小时前 + + + * 移动端适配: 在小屏移动端视图下,四个部分将按照 左上 → 右上 → 右下 → 左下 的顺序垂直排列 + + +###### 样式与交互 + + + * 四个主要区域均由“毛玻璃”效果卡片包裹 + + + * “左下区域的命令行输出部分”与“右上区域的谱面说明部分”也额外由毛玻璃卡片包裹 + + * 窗口中出现的所有用户名均设计为可点击按钮,点击可跳转至相应用户主页 + + * 将下载的文件保存为其他名字,下载的文件名自动更换为谱面名,图片后缀换为png,音频后缀换为mp3,完整文件后缀换为zip + + +##### window-setting窗口组件 + +###### 组件用途 + + + * 一个基于  window  基础组件构建的模态弹窗,作为应用内所有用户偏好设置的统一管理入口与交互界面 + + +###### 界面布局 + + + * 顶部栏 + + + + * 显示标题文本“页面偏好设置” + + + * 标题右侧提供“重置”按钮(圆角矩形状) + + + * 设置区域 + + + * 所有配置项按逻辑归属于不同的分组。每个分组以分组名称( group-name )作为标题,其下显示一条视觉分隔栏,该组的配置项列表排列在分隔栏下方 + + + * 每个配置项( preference item )的呈现 + + + * 名称 + + + * 配置项的显示名称( name  /  name_zh ) + + + * 控件 + + + * 配置项的显示名称( name  /  name_zh ) + + + * 描述(可选) + + + * 以小号字体在名称下方显示的说明文本 + + + * 底部栏 + + + + * 提供“保存”按钮 + + +###### 配置项控件类型映射 + + + * 遵循项目  用户偏好配置文件规范 ,配置项控件根据其  type  定义进行渲染 + + + * 配置类型 + + + * boolean + + + * 样式 + + + * 开关 + + + * free + + + * 样式 + + + * 输入框 + + + * option + + + * 样式 + + + * 选择器(下拉框) + + + * restricted + + + * 样式 + + + * 带约束的数值输入器 + + +###### 交互与状态逻辑 + + + * 重置功能:点击“重置”按钮,将读取  用户偏好配置文件  中定义的  default  值,重置所有设置项 + + + * 滚动处理:当设置项内容超出窗口可视区域时,显示垂直滚动条 + + + * 保存按钮状态管理 + + + * 初始/已保存状态:“保存”按钮为禁用状态 + + + * 修改后状态:当任何设置项的值发生改变时,“保存”按钮变为可用状态 + + + * 数据安全与二次确认:在这些情形下,若存在未保存的更改,尝试关闭窗口将触发二次确认弹窗 + + + * 点击窗口右上角的关闭按钮 + + + * 点击了“重置”按钮后 + + + * 存在未保存的更改时,试图通过任何方式关闭窗口 + diff --git a/README.md b/README.md index a1250ea..c4f3276 100644 --- a/README.md +++ b/README.md @@ -1 +1,58 @@ -这个分支属[HSN官网](https://phira.htadiy.cc/)的前端源码 \ No newline at end of file +HSNPhira — 前端实现说明 + +概览 +- 本仓库为 HSNPhira 前端实现(静态 HTML/CSS/原生 JS),遵循用户提供的 AI-Ready Spec 与 Original Spec。实现以最小假设为原则:任何缺失字段均不擅自填补,已在代码内用注释标注的假设均需后端确认。 + +已实现功能(精简) +- 项目骨架与样式:`index.html`、`src/css/styles.css` +- 核心模块:`src/js/core/event-bus.js`、`src/js/core/page-loader.js`、`src/js/core/sse-client.js`、`src/js/core/auth.js` +- UI 组件:`src/js/ui/message-toast.js`、`src/js/ui/lightbox.js`、`src/js/ui/header.js`、`src/js/ui/footer.js` +- 窗口组件:`src/js/window/*`(包含 `window-base.js`、`window-chart.js`、`window-room.js` 等) +- 页面脚本:`src/js/pages/pages.js`、`src/js/pages/charts-page.js`、`src/js/pages/users-page.js` +- 房间列表:调用 `/api/rooms/info` 渲染、使用 SSE `/api/rooms/listen` 实时更新(带重连与心跳)、已在表格中加入“曲绘”列与行内查看按钮 + +主要 API(按 Original Spec 使用) +- GET `/api/auth/visited/count` — 访问计数(兼容纯数字或 JSON {count}) +- GET `/api/rooms/info` — 房间列表(预期数组) +- SSE `/api/rooms/listen` — 房间相关实时事件(create_room / update_room / player_score / join_room / leave_room / start_round) +- GET `/hot_rank/{time_range}` — 谱面排行(charts page) +- GET `/chart/{chart-id}` — 外部谱面信息与 `illustration` 字段(用于曲绘展示,默认 external_api_base: https://phira.5wyxi.com) +- Auth: `/api/auth/login`、`/api/auth/logout`、`/api/auth/me` + +配置 +- `config/app_config.json`: + - `api_mode`: `local` | `remote` | `mock`(当前默认 `local`) + - `api_base_url`: 后端基础地址(默认 `http://localhost:7865`) + - `external_api_base`: 外部谱面源(默认 `https://phira.5wyxi.com`) + +运行与预览 +- 建议使用简单静态服务器预览 `index.html`,示例(系统需安装 Python): + +```bash +# 在仓库根目录运行(端口可改) +python -m http.server 8000 +# 或者(Python3 在 Windows 下) +py -3 -m http.server 8000 +``` + +浏览器访问 http://localhost:8000 即可。 + +实现中的假设与注意事项 +- Auth 默认采用 Cookie 会话(fetch 使用 `credentials: 'include'`);如后端使用 token(Bearer),前端需切换实现。 +- `/api/rooms/info` 返回结构以 Original Spec 为准;前端包含基础兼容与防护(字段存在性检查、SSE payload 验证),但若字段名或嵌套不同,将在联调时需要调整。 +- 曲绘获取依赖外部 `/chart/{id}` 返回 `illustration|preview|file` 字段,若后端不提供则无法展示。 +- 注册/验证等流程若依赖 SSE 特殊事件(或验证码),目前以占位/提示处理,需要后端确认契约。 + +开发进度(当前摘录) +- 核心实现:完成 +- 页面与组件:大部分完成,`Implement pages` 与 `SSE production handling` 置为进行中(需后端事件字段确认) +- 测试:根据用户指示不需要自动完成测试流程(保留为未完成) + +下一步建议 +- 与后端联调,确认 SSE 事件字段与 `/api/rooms/info` 的实际返回结构 +- 若需,可将 Auth 切换为 token 策略并补充安全存储说明 +- 添加 README 的部署与打包脚本(若要生产化) + +联系方式 +- 若需要我继续:我可以立即对 README 进行扩展、生成联调检查表,或将文档拆分到 `docs/` 目录下。请指示想要的文档深度. + diff --git a/account.html b/account.html deleted file mode 100644 index 5456cf3..0000000 --- a/account.html +++ /dev/null @@ -1,173 +0,0 @@ - - - - - - - 账号管理 - HyperSynapse Phira - - - - - - -
-
- - - - - - - - -
- -
- - - - - \ No newline at end of file diff --git a/component/auth.html b/component/auth.html deleted file mode 100644 index ea74a90..0000000 --- a/component/auth.html +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/component/contact.html b/component/contact.html deleted file mode 100644 index 6546cb1..0000000 --- a/component/contact.html +++ /dev/null @@ -1,29 +0,0 @@ - diff --git a/component/footer.html b/component/footer.html deleted file mode 100644 index c5fa7c4..0000000 --- a/component/footer.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/component/header.html b/component/header.html deleted file mode 100644 index 78713ca..0000000 --- a/component/header.html +++ /dev/null @@ -1,32 +0,0 @@ -
- - -
- -
- -
- - -
-
\ No newline at end of file diff --git a/component/page-loader.html b/component/page-loader.html deleted file mode 100644 index 0e53fde..0000000 --- a/component/page-loader.html +++ /dev/null @@ -1,10 +0,0 @@ - -
-
-
-
-
-
-
-
-
\ No newline at end of file diff --git a/component/room-history.html b/component/room-history.html deleted file mode 100644 index 26fe8de..0000000 --- a/component/room-history.html +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/component/window.js b/component/window.js deleted file mode 100644 index 877be9b..0000000 --- a/component/window.js +++ /dev/null @@ -1,463 +0,0 @@ -class MacWindow extends HTMLElement { - static get observedAttributes() { return ['open']; } - - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - - const template = document.createElement('template'); - template.innerHTML = ` - - -
- - `; - - this.shadowRoot.appendChild(template.content.cloneNode(true)); - - this._backdrop = this.shadowRoot.querySelector('.backdrop'); - this._stage = this.shadowRoot.querySelector('.stage'); - this._win = this.shadowRoot.querySelector('.window'); - this._inner = this.shadowRoot.querySelector('.inner'); - this._closeBtn = this.shadowRoot.querySelector('.close'); - - this._bound = { - onMove: this._onPointerMove.bind(this), - onLeave: this._onPointerLeave.bind(this), - onClose: this._onClose.bind(this), - onKey: this._onKey.bind(this), - onBackdropClick: this._onBackdropClick.bind(this) - }; - this._resizing = false; - this._currentIframe = null; - this._currentUrl = ''; - } - - connectedCallback() { - // default open if attribute present - if (!this.hasAttribute('role')) this.setAttribute('role', 'application'); - - this._stage.addEventListener('pointermove', this._bound.onMove); - this._stage.addEventListener('pointerleave', this._bound.onLeave); - this._closeBtn.addEventListener('click', this._bound.onClose); - // support pointerdown for quicker response on touch devices - this._closeBtn.addEventListener('pointerdown', this._bound.onClose); - this._backdrop.addEventListener('click', this._bound.onBackdropClick); - - // wire up titlebar controls - this._backBtn = this.shadowRoot.querySelector('.nav-btn.back'); - this._forwardBtn = this.shadowRoot.querySelector('.nav-btn.forward'); - this._openLinkAnchor = this.shadowRoot.querySelector('.open-link'); - this._titleText = this.shadowRoot.querySelector('.title-text'); - this._addressText = this.shadowRoot.querySelector('.address-text'); - this._resizer = this.shadowRoot.querySelector('.resizer'); - if (this._backBtn) this._backBtn.addEventListener('click', ()=> this._navigate(-1)); - if (this._forwardBtn) this._forwardBtn.addEventListener('click', ()=> this._navigate(1)); - if (this._resizer) this._resizer.addEventListener('pointerdown', this._onResizerDown.bind(this)); - - // keyboard - this.addEventListener('keydown', this._bound.onKey); - this.setAttribute('tabindex', '0'); - - // initialize small tilt sensitivity settings - this._maxTilt = 8; // degrees - this._tiltDepth = 18; // px translateZ on hover - - // ensure focusable for accessibility - this._focusOnOpen = true; - } - - disconnectedCallback() { - this._stage.removeEventListener('pointermove', this._bound.onMove); - this._stage.removeEventListener('pointerleave', this._bound.onLeave); - this._closeBtn.removeEventListener('click', this._bound.onClose); - this._closeBtn.removeEventListener('pointerdown', this._bound.onClose); - this._backdrop.removeEventListener('click', this._bound.onBackdropClick); - if (this._backBtn) this._backBtn.removeEventListener('click', ()=> this._navigate(-1)); - if (this._forwardBtn) this._forwardBtn.removeEventListener('click', ()=> this._navigate(1)); - if (this._resizer) this._resizer.removeEventListener('pointerdown', this._onResizerDown); - this.removeEventListener('keydown', this._bound.onKey); - } - - attributeChangedCallback(name, oldVal, newVal) { - if (name === 'open') { - if (this.hasAttribute('open')) { - this.dispatchEvent(new CustomEvent('mac-window-open', { bubbles: true })); - // focus for keyboard esc - this.focus(); - // allow pointer events on host while open - this.style.pointerEvents = 'auto'; - // responsive sizing for mobile - this._applyResponsiveSizing(); - // install resize listener so size adapts when device rotates / viewport changes - this._resizeHandler = () => this._applyResponsiveSizing(); - window.addEventListener('resize', this._resizeHandler); - - // try to focus any iframe inside - const iframe = this.querySelector('iframe'); - if (iframe) { - setTimeout(() => { - try { iframe.contentWindow && iframe.contentWindow.focus(); } catch(e){} - }, 300); - } - } else { - this.dispatchEvent(new CustomEvent('mac-window-close', { bubbles: true })); - // restore pointer-events - this.style.pointerEvents = ''; - // clear responsive sizing and resize listener - this._clearResponsiveSizing(); - if (this._resizeHandler) { - window.removeEventListener('resize', this._resizeHandler); - this._resizeHandler = null; - } - } - } - } - - // public api - open() { this.setAttribute('open', ''); } - close() { this.removeAttribute('open'); } - toggle() { if (this.hasAttribute('open')) this.close(); else this.open(); } - - _onClose(e) { - if (e) e.stopPropagation(); - // add a closing animation: shrink & fade - this._playCloseAnimation(); - } - - _onBackdropClick(e) { - // clicking backdrop closes - this._onClose(e); - } - - _playCloseAnimation() { - // animate the stage and let CSS handle backdrop fade - this._stage.style.transition = 'transform 260ms cubic-bezier(.2,.9,.2,1), opacity 200ms ease'; - this._stage.style.transform = 'translate(-50%, -45%) scale(0.96) translateY(8px)'; - this._stage.style.opacity = '0'; - setTimeout(() => { - this.close(); - // restore styles - this._stage.style.transform = ''; - this._stage.style.opacity = ''; - this._stage.style.transition = ''; - this._clearResponsiveSizing(); - }, 260); - } - - _onPointerMove(e) { - const rect = this._stage.getBoundingClientRect(); - const dx = (e.clientX - rect.left) / rect.width - 0.5; - const dy = (e.clientY - rect.top) / rect.height - 0.5; - const rotateY = dx * this._maxTilt * -1; - const rotateX = dy * this._maxTilt; - const translateZ = Math.max(this._tiltDepth * (1 - Math.hypot(dx, dy) * 2), 0); - this._inner.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(${translateZ}px)`; - } - - _onPointerLeave() { - this._inner.style.transform = 'rotateX(0deg) rotateY(0deg) translateZ(0px)'; - } - - _onResizerDown(e){ - e.preventDefault(); - this._resizing = true; - const startX = e.clientX, startY = e.clientY; - const startRect = this._stage.getBoundingClientRect(); - const startW = startRect.width; - const startH = startRect.height; - const onMove = (ev)=>{ - const nw = Math.max(280, startW + (ev.clientX - startX)); - const nh = Math.max(220, startH + (ev.clientY - startY)); - this._stage.style.width = nw + 'px'; - this._stage.style.height = nh + 'px'; - }; - const onUp = (ev)=>{ - this._resizing = false; - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - // persist size - try{ - if (this._currentUrl) { - window._macWindowSizeCache = window._macWindowSizeCache || {}; - window._macWindowSizeCache[this._currentUrl] = window._macWindowSizeCache[this._currentUrl] || {}; - const rect = this._stage.getBoundingClientRect(); - window._macWindowSizeCache[this._currentUrl].w = Math.round(rect.width); - window._macWindowSizeCache[this._currentUrl].h = Math.round(rect.height); - } - }catch(e){} - }; - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - } - - _navigate(delta){ - if (!this._currentIframe) return; - try { - if (delta < 0) this._currentIframe.contentWindow.history.back(); else this._currentIframe.contentWindow.history.forward(); - } catch(e) { - try { this._currentIframe.contentWindow.postMessage({ type: 'hsn-navigate', delta }, '*'); } catch(e){} - } - } - - registerIframe(iframe, url){ - this._currentIframe = iframe; - this._currentUrl = url || ''; - if (this._openLinkAnchor) this._openLinkAnchor.href = url || ''; - const update = ()=>{ - try{ - const doc = iframe.contentDocument; - const title = doc && (doc.title || (doc.querySelector && doc.querySelector('h1') && doc.querySelector('h1').textContent)) || iframe.getAttribute('title') || ''; - if (this._titleText) this._titleText.textContent = title || '窗口'; - if (this._addressText) this._addressText.textContent = (iframe.contentWindow && iframe.contentWindow.location && iframe.contentWindow.location.href) || url || ''; - }catch(e){ - if (this._titleText) this._titleText.textContent = url || '窗口'; - if (this._addressText) this._addressText.textContent = url || ''; - } - try{ - if (this._backBtn) this._backBtn.disabled = !(iframe.contentWindow && iframe.contentWindow.history && iframe.contentWindow.history.length>1); - }catch(e){} - }; - iframe.addEventListener('load', ()=>{ update(); setTimeout(update,120); }); - setTimeout(update,120); - } - - _applyResponsiveSizing() { - // 移动端自适应 - if (!this._stage) return; - const vw = window.innerWidth; - const vh = window.innerHeight; - const mobileBreakpoint = 720; - if (vw <= mobileBreakpoint) { - const horizontalMargin = 16; - const verticalMargin = 48; - const width = Math.max(280, vw - horizontalMargin * 2); - const height = Math.max(220, vh - verticalMargin * 2); - this._stage.style.width = width + 'px'; - this._stage.style.height = height + 'px'; - this._stage.style.left = '50%'; - this._stage.style.top = '50%'; - this._stage.style.transform = 'translate(-50%, -50%) scale(1)'; - } else { - this._stage.style.width = ''; - this._stage.style.height = ''; - this._stage.style.left = ''; - this._stage.style.top = ''; - this._stage.style.transform = ''; - } - } - - _clearResponsiveSizing() { - if (!this._stage) return; - this._stage.style.width = ''; - this._stage.style.height = ''; - this._stage.style.left = ''; - this._stage.style.top = ''; - this._stage.style.transform = ''; - } - - _onKey(e) { - if (e.key === 'Escape') this._onClose(e); - } -} - -customElements.define('mac-window', MacWindow); - -// For compatibility: expose to window -window.MacWindow = MacWindow; diff --git a/config/app_config.json b/config/app_config.json new file mode 100644 index 0000000..664556c --- /dev/null +++ b/config/app_config.json @@ -0,0 +1,11 @@ +{ + "version": "1.0", + "api_mode": "local", + "api_base_url": "http://localhost:7865", + "external_api_base": "https://phira.5wyxi.com", + "background_image": "https://webstatic.cn-nb1.rains3.com/5712x3360.jpeg", + "particle_effects": { + "enabled": false, + "preset": "default" + } +} diff --git a/config/user_preferences_schema.json b/config/user_preferences_schema.json new file mode 100644 index 0000000..b2e86f7 --- /dev/null +++ b/config/user_preferences_schema.json @@ -0,0 +1,27 @@ +{ + "version": "1.0", + "appId": "hsnphira_frontend", + "groups": [ + { "id": "appearance", "name": "appearance", "name_zh": "外观" } + ], + "preferences": [ + { + "id": "theme_color", + "group": "appearance", + "type": "free", + "name": "theme_color", + "name_zh": "主题色", + "default": "#a1e5ef", + "placeholder": "#a1e5ef", + "placeholder_zh": "例如 #a1e5ef" + }, + { + "id": "enable_glass_theme", + "group": "appearance", + "type": "boolean", + "name": "enable_glass_theme", + "name_zh": "毛玻璃主题", + "default": false + } + ] +} diff --git a/css/account.css b/css/account.css deleted file mode 100644 index 414fa18..0000000 --- a/css/account.css +++ /dev/null @@ -1,310 +0,0 @@ - .account-container.tilting { - animation: none; - transition: transform 0.1s linear; - } - - - .account-container.tilting { - animation: none; - transition: transform 0.12s linear; - } - .glass-panel { - width: 95%; - max-width: 1100px; - margin: 1rem auto; - background: rgba(255,255,255,0.04); - border-radius: 16px; - padding: 2rem; - border: 1px solid rgba(255,255,255,0.12); - backdrop-filter: blur(12px); - transform: translateZ(20px) rotateX(-0.5deg) rotateY(0.5deg); - animation: gentleSway 8s infinite ease-in-out; - box-shadow: 0 12px 48px rgba(0,0,0,0.4); - transition: transform 0.3s ease; - } - .glass-checkbox.checked::after { - content: ''; - position: absolute; - width: 10px; - height: 6px; - border: 2px solid white; - border-top: none; - border-left: none; - transform: rotate(45deg); - left: 50%; - top: 50%; - margin-left: -6px; - margin-top: -6px; - box-shadow: 0 0 6px rgba(255,255,255,0.08); - pointer-events: none; - } - .account-container { - transform: translateZ(20px) rotateX(-0.5deg) rotateY(0.5deg); - animation: gentleSway 8s infinite ease-in-out; - transition: transform 0.3s ease; - } - - .account-header { - display: flex; - align-items: center; - margin-bottom: 2rem; - padding-bottom: 1.5rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - } - .account-avatar { - width: 100px; - height: 100px; - border-radius: 50%; - object-fit: cover; - border: 1px solid rgba(255, 255, 255, 0.2); - margin-right: 1.5rem; - background: rgba(0, 0, 0, 0.3); - } - .account-info { - flex: 1; - min-width: 0; - } - .account-name { - font-size: 1.8rem; - margin-bottom: 0.5rem; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .account-subtitle { - color: #aaa; - margin-bottom: 0.3rem; - font-size: 1rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .account-stats { - display: flex; - margin-top: 1rem; - gap: 1.5rem; - flex-wrap: wrap; - } - .stat-item { - text-align: center; - min-width: 80px; - } - .stat-value { - font-size: 1.5rem; - color: #a1e5ef; - font-weight: bold; - } - .stat-label { - font-size: 0.9rem; - color: #aaa; - } - .form-section { - margin-bottom: 2rem; - } - .section-title { - font-size: 1.3rem; - margin-bottom: 1.2rem; - color: #a1e5ef; - position: relative; - padding-left: 1rem; - } - .section-title::before { - content: ""; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - height: 70%; - width: 4px; - background: #a1e5ef; - border-radius: 4px; - } - .form-group { - margin-bottom: 1.5rem; - } - .form-label { - display: block; - margin-bottom: 0.5rem; - color: #ddd; - } - .form-input { - width: 100%; - padding: 0.8rem 1rem; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; - color: #fff; - font-size: 1rem; - outline: none; - transition: all 0.3s ease; - box-sizing: border-box; - } - .form-input:focus { - border-color: rgba(255, 255, 255, 0.4); - box-shadow: 0 0 10px rgba(255, 255, 255, 0.2); - } - .form-row { - display: flex; - gap: 1.5rem; - margin-bottom: 1.5rem; - } - .form-col { - flex: 1; - min-width: 0; - } - .badge { - display: inline-block; - padding: 0.3rem 0.8rem; - border-radius: 999px; - font-size: 0.85rem; - margin-right: 0.5rem; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(6px); - border: 1px solid rgba(255, 255, 255, 0.2); - } - .badge-admin { - background: rgba(255, 153, 0, 0.2); - color: #ff9900; - } - .badge-dev { - background: rgba(0, 204, 255, 0.2); - color: #00ccff; - } - .btn { - padding: 0.8rem 1.5rem; - border-radius: 8px; - border: none; - font-size: 1rem; - cursor: pointer; - transition: all 0.3s ease; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(10px); - color: white; - font-weight: 500; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - border: 1px solid rgba(255, 255, 255, 0.2); - box-sizing: border-box; - } - .btn:hover { - background: rgba(255, 255, 255, 0.15); - transform: translateY(-2px); - } - .btn-outline { - background: transparent; - border: 1px solid rgba(255, 255, 255, 0.2); - color: #ddd; - } - .btn-outline:hover { - background: rgba(255, 255, 255, 0.1); - } - .btn-container { - display: flex; - gap: 1rem; - margin-top: 1rem; - flex-wrap: wrap; - } - .info-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 1.5rem; - margin-top: 1rem; - } - .info-card { - background: rgba(255, 255, 255, 0.05); - border-radius: 12px; - padding: 1.2rem; - border: 1px solid rgba(255, 255, 255, 0.1); - backdrop-filter: blur(6px); - min-width: 0; - transition: all 0.25s cubic-bezier(.4,2,.3,1); - } - .info-card:hover { - background: rgba(255, 255, 255, 0.12); - box-shadow: 0 8px 32px rgba(97,232,234,0.25), 0 2px 8px rgba(0,0,0,0.18); - border: 1.5px solid #61E8EA; - transform: scale(1.04); - z-index: 2; - } - .info-title:hover, .info-value:hover { - color: #61E8EA; - text-shadow: 0 0 8px #61E8EA88, 0 0 2px #61E8EA; - cursor: pointer; - transition: color 0.2s, text-shadow 0.2s; - text-decoration: none; - } - .info-title { - color: #aaa; - font-size: 0.9rem; - margin-bottom: 0.5rem; - } - .info-value { - font-size: 1.1rem; - color: #fff; - word-break: break-all; - } - .status-indicator { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - margin-right: 0.5rem; - } - .status-active { - background: #0f0; - box-shadow: 0 0 8px #0f0; - } - .status-inactive { - background: #f33; - } - .message { - padding: 0.8rem; - border-radius: 8px; - margin: 1rem 0; - text-align: center; - display: none; - backdrop-filter: blur(6px); - border: 1px solid rgba(255, 255, 255, 0.1); - } - .message-success { - background: rgba(0, 200, 0, 0.2); - color: #0f0; - } - .message-error { - background: rgba(200, 0, 0, 0.2); - color: #f33; - } - .form-actions { - display: flex; - justify-content: flex-end; - gap: 1rem; - margin-top: 1rem; - flex-wrap: wrap; - } - @media (max-width: 768px) { - .account-header { - flex-direction: column; - text-align: center; - } - .account-avatar { - margin-right: 0; - margin-bottom: 1rem; - } - .form-row { - flex-direction: column; - gap: 1rem; - } - .account-stats { - justify-content: center; - } - .btn-container { - flex-direction: column; - gap: 0.5rem; - } - .form-actions { - justify-content: center; - } - } \ No newline at end of file diff --git a/css/admin.css b/css/admin.css deleted file mode 100644 index de96c95..0000000 --- a/css/admin.css +++ /dev/null @@ -1,441 +0,0 @@ - /* 基础样式与重置 */ - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - body { - font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif; - color: #ffffff; - background-color: #0d0d0d; - min-height: 100vh; - overflow-x: hidden; - position: relative; - } - - - /* 3D毛玻璃卡片 */ - .admin-container { - transform-style: preserve-3d; - transform: translateZ(20px); - transition: transform 0.3s ease; - animation: gentleSway 8s infinite ease-in-out; - width: 95%; - max-width: 1200px; - background: rgba(255, 255, 255, 0.05); - border-radius: 16px; - backdrop-filter: blur(12px); - padding: 2rem; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.2); - margin: 1rem; - } - - @keyframes gentleSway { - 0%, 100% { transform: translateZ(20px) rotateX(0.5deg) rotateY(0.5deg); } - 25% { transform: translateZ(20px) rotateX(-0.5deg) rotateY(-1deg); } - 50% { transform: translateZ(20px) rotateX(0.5deg) rotateY(1deg); } - 75% { transform: translateZ(20px) rotateX(-0.5deg) rotateY(0.5deg); } - } - - .admin-container.tilting { - animation: none; - transition: transform 0.1s linear; - } - - .admin-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2rem; - padding-bottom: 1.5rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - } - - .admin-title { - font-size: 2rem; - color: #a1e5ef; /* 修改为冰蓝色 */ - } - - /* 卡片容器 */ - .cards-container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 1.5rem; - } - - /* 状态卡片 */ - .glass-card { - backdrop-filter: blur(12px); - background: rgba(30, 30, 42, 0.25); - border-radius: 16px; - padding: 1.5rem; - border: 1px solid rgba(255, 255, 255, 0.15); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - transition: transform 0.3s ease; - } - - .glass-card:hover { - transform: translateY(-5px); - } - - .glass-card h1, .glass-card h2 { - margin-bottom: 1.2rem; - font-weight: 500; - } - - .status-card { - display: flex; - flex-direction: column; - align-items: center; - } - - .status-dot { - width: 24px; - height: 24px; - border-radius: 50%; - background: #666; - display: inline-block; - margin-right: 10px; - } - - #serverStatusContainer { - display: flex; - align-items: center; - margin-bottom: 1.5rem; - } - - /* 控制按钮 */ - .control-buttons { - display: flex; - justify-content: space-between; - gap: 1rem; - } - - .glass-button-control { - padding: 0.8rem 1.5rem; - backdrop-filter: blur(5px); - background: rgba(80, 90, 200, 0.25); - color: white; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; - cursor: pointer; - font-weight: 500; - transition: all 0.3s ease; - min-width: 100px; - text-align: center; - } - - .glass-button-control:hover { - background: rgba(100, 110, 220, 0.35); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - } - - /* 自定义命令 */ - .cmd-input { - display: flex; - gap: 10px; - } - - #customCommand { - flex: 1; - padding: 0.8rem; - background: rgba(30, 30, 42, 0.4); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - color: white; - } - - #customCommand::placeholder { - color: rgba(255, 255, 255, 0.5); - } - - /* 输出卡片 */ - .output-card { - grid-column: 1 / -1; - } - - #commandOutput { - min-height: 200px; - max-height: 400px; - overflow: auto; - padding: 1rem; - background: rgba(20, 20, 30, 0.4); - border-radius: 8px; - font-family: 'Consolas', 'Courier New', monospace; - white-space: pre-wrap; - border: 1px solid rgba(255, 255, 255, 0.1); - } - - /* 状态颜色 */ - .status-online { - background-color: #4caf50; - } - .status-offline { - background-color: #f44336; - } - .status-loading { - background-color: #ffc107; - } - - /* 消息提示 */ - .message { - padding: 0.8rem; - border-radius: 8px; - margin: 1rem 0; - text-align: center; - display: none; - backdrop-filter: blur(6px); - border: 1px solid rgba(255, 255, 255, 0.1); - } - - .message-success { - background: rgba(0, 200, 0, 0.2); - color: #0f0; - } - - .message-error { - background: rgba(200, 0, 0, 0.2); - color: #f33; - } - - /* 用户管理部分样式 */ - .admin-tabs { - display: flex; - gap: 1rem; - margin-bottom: 1.5rem; - width: 100%; - max-width: 1200px; - } - - .tab-button { - padding: 0.8rem 1.5rem; - border-radius: 8px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #eee; - cursor: pointer; - transition: all 0.3s ease; - font-size: 1rem; - } - - .tab-button.active { - background: rgba(161, 229, 239, 0.25); /* 冰蓝色 */ - border-color: rgba(161, 229, 239, 0.5); /* 冰蓝色 */ - color: #fff; - } - - .tab-button:hover { - background: rgba(255, 255, 255, 0.15); - } - - .tab-content { - display: none; - width: 95%; - max-width: 1200px; - } - - .tab-content.active { - display: block; - } - - .user-count { - background: rgba(161, 229, 239, 0.2); /* 冰蓝色 */ - color: #a1e5ef; /* 冰蓝色 */ - padding: 0.5rem 1rem; - border-radius: 999px; - font-weight: bold; - } - - .filters { - display: flex; - gap: 1rem; - margin-bottom: 1.5rem; - flex-wrap: wrap; - } - - .filter-input { - flex: 1; - min-width: 250px; - padding: 0.8rem 1rem; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; - color: #fff; - font-size: 1rem; - outline: none; - } - - .filter-input:focus { - border-color: rgba(255, 255, 255, 0.4); - box-shadow: 0 0 10px rgba(255, 255, 255, 0.2); - } - - .users-table { - width: 100%; - border-collapse: collapse; - margin-bottom: 2rem; - } - - .users-table th { - text-align: left; - padding: 1rem; - background: rgba(161, 229, 239, 0.1); /* 冰蓝色 */ - color: #a1e5ef; /* 冰蓝色 */ - font-weight: 500; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - } - - .users-table td { - padding: 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); - } - - .user-row:hover { - background: rgba(255, 255, 255, 0.03); - } - - .user-avatar-small { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; - border: 1px solid rgba(255, 255, 255, 0.2); - } - - .user-select { - width: 20px; - height: 20px; - cursor: pointer; - } - - .badge { - display: inline-block; - padding: 0.3rem 0.8rem; - border-radius: 999px; - font-size: 0.85rem; - margin-right: 0.5rem; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - } - - .badge-admin { - background: rgba(161, 229, 239, 0.2); /* 冰蓝色 */ - color: #a1e5ef; /* 冰蓝色 */ - } - - .badge-dev { - background: rgba(0, 204, 255, 0.2); - color: #00ccff; - } - - .actions-cell { - display: flex; - gap: 0.5rem; - } - - .action-btn { - padding: 0.4rem 0.8rem; - border-radius: 6px; - font-size: 0.9rem; - cursor: pointer; - transition: all 0.3s ease; - background: rgba(255, 255, 255, 0.1); - color: white; - border: 1px solid rgba(255, 255, 255, 0.2); - } - - .action-btn:hover { - background: rgba(255, 255, 255, 0.15); - } - - .btn-edit { - background: rgba(0, 150, 255, 0.2); - color: #0096ff; - } - - .batch-actions { - display: flex; - gap: 1rem; - margin-bottom: 2rem; - flex-wrap: wrap; - } - - .batch-form { - display: flex; - gap: 1rem; - align-items: center; - flex-wrap: wrap; - } - - .batch-label { - color: #ddd; - font-size: 0.9rem; - } - - .batch-select { - padding: 0.6rem 1rem; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; - color: #fff; - min-width: 150px; - } - - - .modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; - } - - .modal-title { - font-size: 1.5rem; - color: #a1e5ef; /* 冰蓝色 */ - margin: 0; - } - - .close-modal { - background: none; - border: none; - color: #aaa; - font-size: 1.5rem; - cursor: pointer; - transition: color 0.3s; - } - - .close-modal:hover { - color: #fff; - } - - .modal-body { - margin-bottom: 1.5rem; - } - - .modal-footer { - display: flex; - justify-content: flex-end; - gap: 1rem; - } - - .password-display { - background: rgba(0, 0, 0, 0.3); - padding: 0.8rem; - border-radius: 8px; - font-family: monospace; - word-break: break-all; - margin: 0.5rem 0; - } - - .reveal-btn { - background: rgba(161, 229, 239, 0.2); /* 冰蓝色 */ - color: #a1e5ef; /* 冰蓝色 */ - border: none; - padding: 0.3rem 0.8rem; - border-radius: 4px; - cursor: pointer; - font-size: 0.9rem; - } \ No newline at end of file diff --git a/css/footer.css b/css/footer.css deleted file mode 100644 index 0588692..0000000 --- a/css/footer.css +++ /dev/null @@ -1,125 +0,0 @@ -:root { - --theme-color: var(--theme-color-lime); - --theme-color-active: #61a600; - --theme-color-tint: rgba(112, 192, 0, .1); - --selection-color: rgba(112, 192, 0, .35); - --theme-color-blue: #5ca1ff; - --theme-color-indigo: #4cb6c2; - --theme-color-lime: #70c000; - --theme-color-rose: #ff4b68; - --theme-color-tint-rose: rgba(255, 75, 104, .1); - --theme-color-violet: #bb8cdd; - --black-alpha-5: rgba(0, 0, 0, .05); - --black-alpha-10: rgba(0, 0, 0, .1); - --black-alpha-15: rgba(0, 0, 0, .15); - --black-alpha-20: rgba(0, 0, 0, .2); - --black-alpha-30: rgba(0, 0, 0, .3); - --black-alpha-35: rgba(0, 0, 0, .35); - --black-alpha-50: rgba(0, 0, 0, .5); - --black-alpha-60: rgba(0, 0, 0, .6); - --black-alpha-70: rgba(0, 0, 0, .7); - --black-alpha-80: rgba(0, 0, 0, .8); - --black-alpha-90: rgba(0, 0, 0, .9); - --black-tip-text-color: rgba(0, 0, 0, .8); - --off-white-background-color: rgb(235, 235, 235); - --primary-background-color: white; - --primary-background-color-hover: rgb(250, 250, 250); - --pure-black-text-color: black; - --pure-white-background-color: white; - --pure-white-text-color: white; - --secondary-background-color: rgb(245, 245, 245); - --white-alpha-5: rgba(255, 255, 255, .05); - --white-alpha-10: rgba(255, 255, 255, .1); - --white-alpha-15: rgba(255, 255, 255, .15); - --white-alpha-20: rgba(255, 255, 255, .2); - --white-alpha-25: rgba(255, 255, 255, .25); - --white-alpha-30: rgba(255, 255, 255, .3); - --white-alpha-50: rgba(255, 255, 255, .5); - --white-alpha-60: rgba(255, 255, 255, .6); - --white-alpha-70: rgba(255, 255, 255, .7); - --white-alpha-80: rgba(255, 255, 255, .8); - --white-alpha-90: rgba(255, 255, 255, .9); - --white-background-color-active: rgb(215, 215, 215); - --white-background-color-hover: white; - --cubic-bezier: cubic-bezier(.65, .05, .1, 1); - --error-color: #ff655e; - --fonts: "PingFang SC", "Microsoft Yahei", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --universal-backdrop-filter: blur(30px) saturate(1.25); - --universal-border: none; - --universal-box-shadow: 0 15px 30px rgba(0, 0, 0, .25) -} -.rth13bb13c9 { - align-items: center; - animation-delay: .5s; - animation-duration: .5s; - animation-fill-mode: backwards; - -webkit-backdrop-filter: blur(10px); - backdrop-filter: blur(10px); - background-color: #0000000f; - border-radius: 11px; - bottom: 10px; - color: #fff9; - display: flex; - font-size: 12px; - left: 50%; - line-height: 1; - padding: 3px 3px 3px 8px; - position: fixed; - transform: translateX(-50%); - white-space: nowrap -} - -.rth56954d5e { - color: #ffffff4d -} - -.rth56954d5e:after { - content: " 丨 " -} - -.rthe1492bf5 { - align-items: center; - background-color: #ffffff1a; - border: none; - border-radius: 50%; - color: inherit; - cursor: pointer; - display: flex; - font-size: inherit; - height: 15px; - margin-left: 5px; - justify-content: center; - padding: 0; - transition: .25s; - width: 15px -} - -.rthe1492bf5:focus-visible,.rthe1492bf5:hover { - background-color: #fff3; - color: #fff -} - -.rthe1492bf5:active { - opacity: .6 -} - -.rth78890bf1 { - color: inherit; - text-decoration: none; - transition: color .25s, text-shadow .25s; -} - -.rth78890bf1:focus-visible,.rth78890bf1:hover { - color: #fff; - text-shadow: 0 0 4px #fff; -} - -.rth78890bf1:active { - color: #fff9 -} - -.rth13bb13c9 .rth78890bf1:hover { - color: #fff !important; - text-shadow: 0 0 6px #fff !important; - transition: color .25s, text-shadow .25s !important; -} \ No newline at end of file diff --git a/css/index.css b/css/index.css deleted file mode 100644 index 70740fc..0000000 --- a/css/index.css +++ /dev/null @@ -1,185 +0,0 @@ - body { - position: relative; - font-size: 12px; - } - - header img { - height: 32px; - } - - section { - height: 100vh; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - opacity: 0; - transform: translateY(40px); - transition: all 1s ease-out; - } - - section.visible { - opacity: 1; - transform: translateY(0); - } - - .hero-content { - z-index: 1; - position: relative; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - padding-left: 10%; - width: 100%; - } - - .hero-content h1 { - font-size: 1.5rem; - color: #fff; - animation: fadeInLeft 1s ease-out forwards; - } - - .tagline { - margin-top: 0.5rem; - font-size: 0.75rem; - color: #fff; - animation: fadeInLeft 1.5s ease-out forwards; - } - - .user-count { - margin-top: 0.3rem; - font-size: 0.7rem; - color: #fff; - animation: fadeInLeft 2s ease-out forwards; - } - - .qq-section { - text-align: center; - z-index: 1; - padding: 0 20px; - } - - .qq-section h2 { - font-size: 1.5rem; - color: #fff; - margin-bottom: 1.5rem; - } - - .info-box { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 12px; - padding: 1.5rem; - margin-bottom: 1.5rem; - max-width: 500px; - width: 100%; - backdrop-filter: blur(8px); - } - - .info-item { - margin-bottom: 1rem; - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.8rem; - background: rgba(0, 0, 0, 0.3); - border-radius: 8px; - transition: all 0.3s ease; - } - - .info-item:hover { - background: rgba(0, 0, 0, 0.4); - transform: translateY(-2px); - } - - .info-label { - font-weight: bold; - color: #aaa; - font-size: 0.9rem; - } - - .info-value { - font-size: 1.1rem; - color: #fff; - font-weight: 500; - } - - .buttons { - display: flex; - gap: 1rem; - justify-content: center; - width: 100%; - max-width: 500px; - } - - .qq-section button { - flex: 1; - padding: 0.8rem; - font-size: 1rem; - color: #fff; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 999px; - backdrop-filter: blur(8px); - cursor: pointer; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - } - - .qq-section button.primary { - background: rgba(0, 119, 255, 0.25); - border-color: rgba(0, 119, 255, 0.5); - } - - .qq-section button:hover { - background: rgba(255, 255, 255, 0.1); - transform: scale(1.05); - } - - .qq-section button.primary:hover { - background: rgba(0, 119, 255, 0.35); - } - - .toast { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - background: rgba(0, 0, 0, 0.7); - color: white; - padding: 0.8rem 1.5rem; - border-radius: 30px; - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); - opacity: 0; - transition: opacity 0.3s; - z-index: 2000; - font-size: 0.9rem; - } - - .toast.show { - opacity: 1; - } - - @keyframes fadeInLeft { - from { opacity: 0; transform: translateX(-40px); } - to { opacity: 1; transform: translateX(0); } - } - - @media (max-width: 600px) { - .buttons { - flex-direction: column; - } - - .info-box { - padding: 1rem; - } - - .qq-section h2 { - font-size: 1.3rem; - } - } \ No newline at end of file diff --git a/css/privacy.css b/css/privacy.css deleted file mode 100644 index e920539..0000000 --- a/css/privacy.css +++ /dev/null @@ -1,128 +0,0 @@ - * { - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: "Microsoft YaHei", "Segoe UI", sans-serif; - } - body { - background-color: #f5f7fa; - color: #333; - line-height: 1.6; - padding: 20px; - } - .parallax-bg { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-image: url('https://webstatic.cn-nb1.rains3.com/5712%C3%973360.jpeg'); - background-size: cover; - background-position: center; - background-repeat: no-repeat; - z-index: 0; - } - .agreement-container { - max-width: 1000px; - margin: 0 auto; - background: white; - border-radius: 10px; - box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1); - padding: 30px; - position: relative; - overflow: hidden; - } - .header { - text-align: center; - border-bottom: 2px solid #eaeaea; - padding-bottom: 20px; - margin-bottom: 30px; - } - .header h1 { - color: #2c3e50; - margin-bottom: 15px; - font-size: 28px; - } - .meta-info { - display: flex; - justify-content: center; - gap: 30px; - font-size: 16px; - color: #7f8c8d; - } - .important-notice { - background-color: #fff9e6; - border-left: 4px solid #ffc107; - padding: 20px; - margin: 20px 0; - border-radius: 0 4px 4px 0; - font-size: 15px; - } - .important-notice h3 { - color: #e67e22; - margin-bottom: 10px; - } - .section { - margin-bottom: 30px; - } - .section-title { - color: #2c3e50; - border-bottom: 1px solid #ecf0f1; - padding-bottom: 10px; - margin-bottom: 15px; - font-size: 22px; - } - .subsection { - margin-bottom: 20px; - } - .subsection-title { - color: #34495e; - margin: 15px 0 10px; - font-size: 18px; - } - .clause { - margin-left: 20px; - } - .clause-title { - font-weight: bold; - color: #2c3e50; - } - ul { - padding-left: 20px; - } - li { - margin-bottom: 8px; - } - .highlight { - background-color: #ffffcc; - padding: 2px 4px; - border-radius: 2px; - font-weight: bold; - } - .contact-info { - background-color: #e8f4fd; - padding: 15px; - border-radius: 5px; - margin: 20px 0; - text-align: center; - } - .footer { - text-align: center; - margin-top: 30px; - padding-top: 20px; - border-top: 1px solid #eaeaea; - color: #7f8c8d; - font-size: 14px; - } - @media (max-width: 768px) { - .agreement-container { - padding: 20px; - } - .header h1 { - font-size: 24px; - } - .meta-info { - flex-direction: column; - gap: 10px; - } - } \ No newline at end of file diff --git a/css/ranks.css b/css/ranks.css deleted file mode 100644 index 6a37820..0000000 --- a/css/ranks.css +++ /dev/null @@ -1,77 +0,0 @@ - /* 倾斜触发 overlay(动态创建) */ - #table-tilt-overlay { - position: fixed; - pointer-events: auto; - background: transparent; - z-index: 1002; /* 保证覆盖在主要内容之上以接收鼠标事件 */ - cursor: default; - } - .pagination { - display: flex; - justify-content: center; - margin-top: 20px; - gap: 10px; - } - .pagination button { - padding: 8px 16px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #fff; - border-radius: 8px; - cursor: pointer; - transition: all 0.3s ease; - } - .pagination button:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.2); - } - .pagination button:disabled { - opacity: 0.5; - cursor: not-allowed; - } - .pagination span { - padding: 8px 16px; - color: #fff; - } - /* 搜索框样式 */ - .search-container { - width: 95%; - max-width: 800px; - margin-bottom: 1rem; - } - .search-box { - background: rgba(255, 255, 255, 0.05); - backdrop-filter: blur(8px); - border-radius: 12px; - padding: 0.8rem 1rem; - border: 1px solid rgba(255, 255, 255, 0.2); - display: flex; - align-items: center; - transition: all 0.3s ease; - } - .search-box:focus-within { - background: rgba(255, 255, 255, 0.08); - border: 1px solid #61E8EA; - box-shadow: 0 0 15px rgba(97, 232, 234, 0.2); - } - .search-box input { - flex: 1; - background: transparent; - border: none; - color: #fff; - font-size: 1rem; - outline: none; - padding: 0.5rem; - } - .search-box input::placeholder { - color: rgba(255, 255, 255, 0.6); - } - .search-icon { - color: rgba(255, 255, 255, 0.6); - margin-right: 0.5rem; - } - .search-hint { - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.6); - margin-top: 0.5rem; - text-align: center; - } \ No newline at end of file diff --git a/css/rooms.css b/css/rooms.css deleted file mode 100644 index d0879c8..0000000 --- a/css/rooms.css +++ /dev/null @@ -1,7 +0,0 @@ - #table-tilt-overlay { - position: fixed; - pointer-events: auto; - background: transparent; - z-index: 1002; - cursor: default; - } \ No newline at end of file diff --git a/css/style.css b/css/style.css deleted file mode 100644 index 52ee7ad..0000000 --- a/css/style.css +++ /dev/null @@ -1,767 +0,0 @@ - *, *::before, *::after { box-sizing: border-box; margin:0; padding:0; } - - #page-loader { - position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - width: 100vw; height: 100vh; - background: rgba(0,0,0,0.95); - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - transition: opacity 0.6s; - opacity: 1; - pointer-events: auto; - } - #page-loader.hide { - opacity: 0; - pointer-events: none; - } - .load_11 { - width: 50px; - height: 40px; - display: inline-block; - text-align: center; - font-size: 10px; - } - .load_11 > div { - background-color: #61E8EA; - height: 100%; - width: 6px; - display: inline-block; - -webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out; - animation: sk-stretchdelay 1.2s infinite ease-in-out; - } - .load_11 .rect2 { - -webkit-animation-delay: -1.1s; - animation-delay: -1.1s; - } - .load_11 .rect3 { - -webkit-animation-delay: -1.0s; - animation-delay: -1.0s; - } - .load_11 .rect4 { - -webkit-animation-delay: -0.9s; - animation-delay: -0.9s; - } - .load_11 .rect5 { - -webkit-animation-delay: -0.8s; - animation-delay: -0.8s; - } - @-webkit-keyframes sk-stretchdelay { - 0%, 40%, 100% { -webkit-transform: scaleY(0.4) } - 20% { -webkit-transform: scaleY(1.0) } - } - @keyframes sk-stretchdelay { - 0%, 40%, 100% { - transform: scaleY(0.4); - -webkit-transform: scaleY(0.4); - } - 20% { - transform: scaleY(1.0); - -webkit-transform: scaleY(1.0); - } - } - html, body { - margin: 0; - padding: 0; - font-family: 'Segoe UI', sans-serif; - background: #000; - color: #eee; - overflow-x: hidden; - scroll-behavior: smooth; - } - .parallax-bg { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-image: url('https://webstatic.cn-nb1.rains3.com/5712%C3%973360.jpeg'); - background-size: cover; - background-position: center; - background-repeat: no-repeat; - z-index: 0; - } - header { - position: fixed; - top: 0; - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 1.5rem; - backdrop-filter: blur(12px); - background: rgba(0, 0, 0, 0.1); - z-index: 1000; - box-sizing: border-box; - } - .logo-nav-container { - display: flex; - align-items: center; - flex: 1; - } - #logo-img { - height: 32px; - margin-right: 1.2rem; - } - .nav-links { - display: flex; - } - .nav-links a { - color: #eee; - margin-right: 1.2rem; - text-decoration: none; - transition: 0.3s; - font-size: 0.95rem; - } - .nav-links a:hover { - color: #fff; - text-shadow: 0 0 4px #fff; - } - .user-section { - display: flex; - justify-content: flex-end; - flex: 1; - } - .glass-button { - display: flex; - align-items: center; - padding: 0.4rem 1rem; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(10px); - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.2); - cursor: pointer; - transition: all 0.3s ease; - min-width: 100px; - } - .glass-button:hover { - background: rgba(255, 255, 255, 0.15); - transform: scale(1.05); - } - .login-btn { - color: #eee; - text-decoration: none; - font-size: 0.95rem; - white-space: nowrap; - width: 100%; - text-align: center; - } - .user-info { - display: flex; - align-items: center; - position: relative; - cursor: pointer; - width: 100%; - } - .username { - margin-right: 0.8rem; - font-size: 0.95rem; - color: #eee; - max-width: 120px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .user-avatar { - width: 30px; - height: 30px; - border-radius: 50%; - object-fit: cover; - border: 1px solid rgba(255, 255, 255, 0.3); - } - .dropdown-content { - position: absolute; - top: 100%; - right: 0; - background: rgba(0, 0, 0, 0.45); - backdrop-filter: blur(12px); - border-radius: 8px; - padding: 0.8rem; - min-width: 160px; - box-shadow: 0 4px 16px rgb(0,0,0); - z-index: 1001; - opacity: 0; - visibility: hidden; - transform: translateY(10px); - transition: all 0.3s ease; - border: 1px solid rgba(255, 255, 255, 0.1); - } - .dropdown-content.show { - opacity: 1; - visibility: visible; - } - .dropdown-content span { - display: block; - padding: 0.3rem 0.5rem; - font-weight: bold; - color: #a1e5ef; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - margin-bottom: 0.5rem; - } - .dropdown-content a { - display: block; - padding: 0.5rem; - color: #eee; - text-decoration: none; - border-radius: 4px; - transition: all 0.3s ease; - } - .dropdown-content a:hover { - background: rgba(255, 255, 255, 0.1); - color: #fff; - } - main { - position: relative; - z-index: 2; - padding-top: 100px; - display: flex; - flex-direction: column; - align-items: center; - min-height: 100vh; - padding-bottom: 50px; - } - .status { - margin-bottom: 1rem; - font-size: 1.2rem; - text-align: center; - padding: 0.5rem; - border-radius: 8px; - background: rgba(255, 255, 255, 0.05); - backdrop-filter: blur(6px); - width: 95%; - max-width: 800px; - } - .status.online { - color: #0f0; - border: 1px solid rgba(0, 255, 0, 0.2); - } - .status.offline { - color: #f33; - border: 1px solid rgba(255, 0, 0, 0.2); - } - - /* 3D卡片容器样式 */ - #table-container { - perspective: 1000px; - width: 95%; - max-width: 1200px; - margin: 0 auto; - } - - /* 3D毛玻璃卡片效果 */ - table { - border-collapse: collapse; - background: rgba(255, 255, 255, 0.05); - border-radius: 12px; - overflow: hidden; - backdrop-filter: blur(8px); - animation: fadeIn 1s ease-out, gentleSway 8s infinite ease-in-out; - width: 100%; - transform-style: preserve-3d; - transform: translateZ(20px); - transition: transform 0.3s cubic-bezier(.4,2,.3,1); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.2); - } - - @keyframes gentleSway { - 0%, 100% { transform: translateZ(20px) rotateX(0.5deg) rotateY(0.5deg); } - 25% { transform: translateZ(20px) rotateX(-0.5deg) rotateY(-1deg); } - 50% { transform: translateZ(20px) rotateX(0.5deg) rotateY(1deg); } - 75% { transform: translateZ(20px) rotateX(-0.5deg) rotateY(0.5deg); } - } - - table.tilting { - animation: none; - transition: transform 0.1s linear; - } - - table.hovering { - animation: gentleHover 2s infinite cubic-bezier(.4,2,.3,1); - } - - @keyframes gentleHover { - 0%, 100% { transform: translateZ(20px) translateY(0); } - 50% { transform: translateZ(20px) translateY(-5px); } - } - - th, td { - padding: 0.8rem 1rem; - text-align: left; - font-size: 0.9rem; - } - th { - background: rgba(255, 255, 255, 0.1); - font-weight: bold; - color: #a1e5ef; - } - tr:nth-child(even) { - background: rgba(255, 255, 255, 0.03); - } - .dl-btn, .chart-btn { - padding: 0.3rem 0.8rem; - font-size: 0.85rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #fff; - border-radius: 999px; - backdrop-filter: blur(6px); - cursor: pointer; - transition: 0.3s ease; - white-space: nowrap; - } - .dl-btn:hover, .chart-btn:hover { - background: rgba(255, 255, 255, 0.15); - transform: scale(1.05); - } - .chart-name-btn { - padding: 0.3rem 0.7rem; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(6px); - color: #fff; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 999px; - font-size: 0.85rem; - text-decoration: none; - display: inline-block; - transition: 0.3s ease; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 150px; - } - .chart-name-btn:hover { - background: rgba(255, 255, 255, 0.2); - transform: scale(1.05); - } - #lightbox { - display: flex; - position: fixed; - top: 0; left: 0; - width: 100vw; - height: 100vh; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(6px); - justify-content: center; - align-items: center; - z-index: 9999; - opacity: 0; - visibility: hidden; - transition: opacity 0.3s ease; - } - #lightbox.active { - opacity: 1; - visibility: visible; - } - #lightbox img { - max-width: 90%; - max-height: 90%; - border-radius: 12px; - box-shadow: 0 0 20px rgba(255, 255, 255, 0.2); - animation: zoomIn 0.3s ease; - } - @keyframes fadeIn { - from { opacity: 0; transform: translateY(40px); } - to { opacity: 1; transform: translateY(0); } - } - @keyframes zoomIn { - from { transform: scale(0.7); opacity: 0; } - to { transform: scale(1); opacity: 1; } - } - .modal { - position: fixed; - top: 0; left: 0; - width: 100vw; height: 100vh; - display: none; - justify-content: center; - align-items: center; - background: rgba(0,0,0,0.5); - backdrop-filter: blur(6px); - z-index: 9999; - } - .modal-content { - background: rgba(255,255,255,0.08); - padding: 1rem 1.5rem; - border-radius: 12px; - color: #fff; - backdrop-filter: blur(12px); - max-width: 300px; - width: 90%; - animation: zoomIn 0.3s ease; - border: 1px solid rgba(255, 255, 255, 0.2); - } - .modal-content h3 { - margin-top: 0; - font-size: 1.1rem; - color: #a1e5ef; - } - .modal-content ul { - list-style: none; - padding-left: 0; - } - .modal-content li { - padding: 0.2rem 0; - } - .highlight-host { - font-weight: bold; - color: #61E8EA; - } - .state-label { - padding: 0.2rem 0.5rem; - border-radius: 4px; - font-size: 0.8rem; - } - .state-selecting { - background: rgba(255, 215, 0, 0.2); - color: #61E8EA; - } - .state-ready { - background: rgba(0, 255, 0, 0.2); - color: #0f0; - } - .state-playing { - background: rgba(255, 0, 0, 0.2); - color: #f33; - } - /* 响应式调整 */ - @media (max-width: 768px) { - header { - flex-wrap: wrap; - } - .logo-nav-container { - flex: 0 0 100%; - margin-bottom: 0.5rem; - } - .user-section { - flex: 0 0 100%; - justify-content: flex-end; - } - .nav-links a { - margin-right: 1rem; - } - .username { - max-width: 80px; - } - .control-buttons { - flex-direction: column; - } - .cards-container { - grid-template-columns: 1fr; - } - .filters { - flex-direction: column; - } - .users-table { - display: block; - overflow-x: auto; - } - .batch-form { - flex-direction: column; - align-items: flex-start; - } - th, td { - padding: 0.6rem 0.8rem; - font-size: 0.8rem; - } - table { - display: block; - overflow-x: auto; - } - .search-container { - width: 90%; - } - } - #auth-modal input { - box-sizing: border-box; - display: block; - margin: 0.5rem 0; - padding: 0.8rem; - width: 100%; - border: none; - border-radius: 8px; - background: rgba(255,255,255,0.1); - color: #fff; - backdrop-filter: blur(8px); - font-size: 0.95rem; - outline: none; - transition: all 0.3s ease; - } - #auth-modal input:focus { - box-shadow: 0 0 0.5rem rgba(255, 255, 255, 0.3); - border: 1px solid rgba(255, 255, 255, 0.4); - } - #auth-modal input.collapsed { - display: none; - margin: 0; - height: 0; - padding: 0; - } - #auth-modal button { - width: 100%; - margin: 0.5rem 0; - padding: 0.8rem; - font-size: 1rem; - border-radius: 8px; - border: none; - background: rgba(255,255,255,0.1); - color: #fff; - backdrop-filter: blur(12px); - cursor: pointer; - transition: all 0.3s ease; - border: 1px solid rgba(255, 255, 255, 0.2); - } - #auth-modal button:hover { - background: rgba(255,255,255,0.15); - transform: translateY(-2px); - } - .glass-card { - position: relative; - background: rgba(255,255,255,0.08); - padding: 1.5rem; - border-radius: 16px; - color: #fff; - backdrop-filter: blur(20px); - width: 320px; - max-width: 90%; - border: 1px solid rgba(255, 255, 255, 0.2); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); - } - .checkbox-container { - display: flex; - align-items: center; - margin: 0.5rem 0; - gap: 0.5rem; - flex-wrap: nowrap; - cursor: pointer; - } - .checkbox-container input[type="checkbox"] { - margin-right: 0.5rem; - } - #table-tilt-overlay { - position: fixed; - pointer-events: auto; - background: transparent; - z-index: 1002; - cursor: default; - } - /* 复选框容器修复 */ - .checkbox-container { - display: flex; - align-items: center; - margin: 0.5rem 0; - gap: 0.5rem; - flex-wrap: nowrap; - cursor: pointer; - } - .glass-checkbox { - position: relative; - width: 32px; - height: 32px; - border-radius: 10px; - background: rgba(255,255,255,0.15); - backdrop-filter: blur(8px); - box-shadow: 0 2px 8px rgba(0,0,0,0.12); - display: flex; - align-items: center; - justify-content: center; - margin-left: 0; - flex: 0 0 auto; - overflow: visible; - } - .glass-checkbox input[type="checkbox"] { - -webkit-appearance: none; - appearance: none; - display: block; - position: relative; - box-sizing: border-box; - width: 20px; - height: 20px; - border-radius: 6px; - background: #fff; - border: 2px solid #61E8EA; - box-shadow: 0 1px 4px rgba(97,232,234,0.08); - margin: 0; - cursor: pointer; - outline: none; - } - .glass-checkbox input[type="checkbox"]:checked { - background: #61E8EA; - border-color: #61E8EA; - } - .checkbox-text { - display: inline-block; - white-space: nowrap; - } - #agreement-container label { - display: inline-block; - white-space: nowrap; - margin: 0; - } - .glass-checkbox { - overflow: visible; /* 允许伪元素显示在容器内对齐 */ - } - /* 更可靠的勾形(避免镜像并确保居中)*/ - .glass-checkbox.checked::after { - content: ''; - position: absolute; - width: 6px; - height: 12px; - border: 2px solid white; - border-width: 0 2px 2px 0; - transform: rotate(45deg); - left: 50%; - top: 50%; - margin-left: -4px; - margin-top: -7px; - box-shadow: 0 0 6px rgba(255,255,255,0.08); - pointer-events: none; - } - /* 文字悬停效果 */ - th:hover, td:hover, .status:hover, .modal-content h3:hover, .chart-name-btn:hover { - color: #61E8EA !important; - text-shadow: 0 0 8px #61E8EA88, 0 0 2px #61E8EA; - cursor: pointer; - transition: color 0.2s, text-shadow 0.2s; - text-decoration: none; - } - - /* 卡片悬停效果 */ - table:hover, .modal-content:hover, .glass-card:hover { - background: rgba(255,255,255,0.12); - box-shadow: 0 8px 32px rgba(97,232,234,0.25), 0 2px 8px rgba(0,0,0,0.18); - border: 1.5px solid #61E8EA; - transform: scale(1.03); - transition: all 0.25s cubic-bezier(.4,2,.3,1); - z-index: 2; - } - - /* 用户协议链接样式 */ - #agreement-container a { - color: #61E8EA !important; - text-decoration: none !important; - font-weight: bold; - } - - button, .pagination-btn, .time-range-btn, .dl-btn, .chart-btn, .glass-button, #auth-modal button, a.btn, .chart-name-btn { - padding: 0.3rem 0.8rem; - font-size: 0.85rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #fff; - border-radius: 999px; - backdrop-filter: blur(6px); - cursor: pointer; - transition: transform 0.18s ease, background 0.18s ease; - white-space: nowrap; - text-decoration: none; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - } - - button:hover, .pagination-btn:hover, .time-range-btn:hover, .dl-btn:hover, .chart-btn:hover, .glass-button:hover, a.btn:hover, .chart-name-btn:hover { - background: rgba(255, 255, 255, 0.15); - transform: scale(1.05); - } - - .pagination-btn[disabled], .pagination-btn:disabled { - opacity: 0.5; - transform: none; - cursor: default; - } - - .time-range-btn { - border-radius: 999px; - } - - .time-range-btn.active { - background: linear-gradient(90deg,#61E8EA,#4db6ff); - color:#000; - border: 1px solid rgba(255,255,255,0.25); - box-shadow: 0 6px 18px rgba(97,232,234,0.12); - transform: none; - } - -/* 统一表格组件样式(用于 rooms/ranks/top) */ -.table-card { - perspective: 1000px; - width: 95%; - max-width: 1200px; - margin: 0 auto; -} -.table-card .table-scroll { - position: relative; - width: 100%; - -webkit-overflow-scrolling: touch; -} - -/* 将滚动作用于 table 本身以满足视觉需求 */ -table.responsive-table { - display: block; /* allow native horizontal scrolling on table */ - width: 100%; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - white-space: nowrap; - background: rgba(255, 255, 255, 0.05); - border-radius: 12px; - backdrop-filter: blur(8px); - transform-style: preserve-3d; - transition: transform 0.18s ease; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); - border: 1px solid rgba(255,255,255,0.14); -} - -/* 固定列样式,避免在窄屏下表格列内换行导致宽度异常 */ -table.responsive-table th, table.responsive-table td { - white-space: nowrap; -} - -/* 更合理的表格布局在小屏下取消 3D 位移并使用 fixed 布局 */ -@media (max-width: 768px) { - table.responsive-table { - transform: none !important; - animation: none !important; - table-layout: fixed; - } - table.responsive-table th, table.responsive-table td { - padding: 0.5rem 0.6rem; - font-size: 0.85rem; - text-overflow: ellipsis; - overflow: hidden; - } -} - -/* 细窄的自定义滚动条(作用于 table 元素) */ -table.responsive-table::-webkit-scrollbar { - height: 8px; -} -table.responsive-table::-webkit-scrollbar-track { - background: rgba(255,255,255,0.03); - border-radius: 8px; -} -table.responsive-table::-webkit-scrollbar-thumb { - background: rgba(255,255,255,0.12); - border-radius: 8px; - border: 1px solid rgba(255,255,255,0.06); -} -/* Firefox */ -table.responsive-table { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.12) rgba(255,255,255,0.03); } - -/* 长文本滚动处理(用于谱面/长标题) */ -.scrolling-btn{ display:inline-block; max-width:150px; overflow:hidden; position:relative; vertical-align:middle; } -.scrolling-btn > span{ display:inline-block; padding-right:24px; will-change:transform; } -.scrolling-btn.marquee > span{ animation: scroll-left 8s linear infinite; } -@keyframes scroll-left{ - 0%{ transform: translateX(0); } - 100%{ transform: translateX(-100%); } -} - -/* 当容器较窄时启用更温和的滚动 */ -@media (max-width: 768px){ - .scrolling-btn{ max-width:120px; } - .scrolling-btn.marquee > span{ animation-duration:10s; } -} - diff --git a/css/top.css b/css/top.css deleted file mode 100644 index 81993c7..0000000 --- a/css/top.css +++ /dev/null @@ -1,139 +0,0 @@ - .load-simple { - width: 50px; - height: 50px; - border: 5px solid rgba(97, 232, 234, 0.3); - border-top: 5px solid #61E8EA; - border-radius: 50%; - animation: spin 1s linear infinite; - } - @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - } - - /* 简化文字悬停效果 */ - th:hover, td:hover, .status:hover, .chart-name-btn:hover { - color: #61E8EA !important; - transition: color 0.2s; - text-decoration: none; - } - - /* 简化卡片悬停效果 */ - table:hover { - background: rgba(255,255,255,0.08); - transition: background 0.3s ease; - } - .status.cached { - color: #0f0; - border: 1px solid rgba(255, 204, 0, 0.2); - } - table { - border-collapse: collapse; - background: rgba(255, 255, 255, 0.05); - border-radius: 8px; - overflow: hidden; - width: 100%; - animation: fadeIn 0.5s ease-out; - } - - @keyframes fadeIn { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } - } - - .dl-btn:hover, .chart-btn:hover, .copy-btn:hover { - background: rgba(255, 255, 255, 0.15); - } - - /* 时间范围选择器样式 */ - .time-range-selector { - display: flex; - justify-content: center; - margin-bottom: 1.5rem; - gap: 0.8rem; - width: 95%; - max-width: 800px; - } - .time-range-btn:hover { - background: rgba(255, 255, 255, 0.15); - } - .time-range-btn.active { - background: rgba(97, 232, 234, 0.2); - border-color: #61E8EA; - color: #61E8EA; - } - - /* 分页控件样式 */ - .pagination { - display: flex; - justify-content: center; - align-items: center; - margin-top: 1.5rem; - gap: 0.8rem; - } - .pagination-btn:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.15); - } - .pagination-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } - .pagination-btn.active { - background: rgba(97, 232, 234, 0.2); - border-color: #61E8EA; - color: #61E8EA; - } - .pagination-info { - font-size: 0.9rem; - color: #aaa; - margin: 0 0.5rem; - } - - /* 缓存数据提示 */ - .cache-info { - font-size: 0.85rem; - color: #ffcc00; - text-align: center; - margin-top: 0.5rem; - padding: 0.3rem; - border-radius: 4px; - background: rgba(255, 204, 0, 0.1); - } - - /* 复制成功提示 */ - .copy-notification { - position: fixed; - top: 20px; - left: 50%; - transform: translateX(-50%); - background: rgba(97, 232, 234, 0.2); - color: #61E8EA; - padding: 0.8rem 1.5rem; - border-radius: 4px; - z-index: 10000; - opacity: 0; - visibility: hidden; - transition: opacity 0.3s ease; - } - .copy-notification.show { - opacity: 1; - visibility: visible; - } - - @media (max-width: 768px) { - .nav-links a { - margin-right: 1rem; - font-size: 0.85rem; - } - .time-range-selector { - flex-wrap: wrap; - gap: 0.5rem; - } - .time-range-btn { - padding: 0.5rem 0.8rem; - font-size: 0.8rem; - } - .chart-name-btn { - max-width: 100px; - } - } \ No newline at end of file diff --git a/css/users_manage.css b/css/users_manage.css deleted file mode 100644 index f3c4c38..0000000 --- a/css/users_manage.css +++ /dev/null @@ -1,542 +0,0 @@ - /* 文字悬停效果(去下划线,发光晕) */ - th:hover, td:hover, .form-label:hover, .section-title:hover, .admin-title:hover, .user-count:hover, .info-title:hover, .info-value:hover, .modal-title:hover, .batch-label:hover, .btn:hover, .btn-outline:hover, .action-btn:hover, .badge:hover, .dropdown-content a:hover, .dropdown-content span:hover { - color: #61E8EA !important; - text-shadow: 0 0 8px #61E8EA88, 0 0 2px #61E8EA; - cursor: pointer; - transition: color 0.2s, text-shadow 0.2s; - text-decoration: none; - } - /* 卡片悬停效果 */ - .admin-container:hover, .modal-content:hover, .info-card:hover { - background: rgba(255,255,255,0.12); - box-shadow: 0 8px 32px rgba(97,232,234,0.25), 0 2px 8px rgba(0,0,0,0.18); - border: 1.5px solid #61E8EA; - transform: scale(1.03); - transition: all 0.25s cubic-bezier(.4,2,.3,1); - z-index: 2; - } - /* 新增3D卡片效果 */ - .account-container { - transform-style: preserve-3d; - transform: translateZ(20px); - transition: transform 0.3s ease; - animation: gentleSway 8s infinite ease-in-out; - width: 95%; - max-width: 800px; - background: rgba(255, 255, 255, 0.05); - border-radius: 16px; - backdrop-filter: blur(12px); - padding: 2rem; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.2); - margin: 1rem; - } - .account-container.tilting { - animation: none; - transition: transform 0.1s linear; - } - - .account-header { - display: flex; - align-items: center; - margin-bottom: 2rem; - padding-bottom: 1.5rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - } - .account-avatar { - width: 100px; - height: 100px; - border-radius: 50%; - object-fit: cover; - border: 1px solid rgba(255, 255, 255, 0.2); - margin-right: 1.5rem; - background: rgba(0, 0, 0, 0.3); - } - .account-info { - flex: 1; - } - .account-name { - font-size: 1.8rem; - margin-bottom: 0.5rem; - color: #fff; - } - .account-subtitle { - color: #aaa; - margin-bottom: 0.3rem; - font-size: 1rem; - } - .account-stats { - display: flex; - margin-top: 1rem; - gap: 1.5rem; - } - .stat-item { - text-align: center; - } - .stat-value { - font-size: 1.5rem; - color: #61E8EA; - font-weight: bold; - } - .stat-label { - font-size: 0.9rem; - color: #aaa; - } - .form-section { - margin-bottom: 2rem; - } - .section-title { - font-size: 1.3rem; - margin-bottom: 1.2rem; - color: #61E8EA; - position: relative; - padding-left: 1rem; - } - .section-title::before { - content: ""; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - height: 70%; - width: 4px; - background: #61E8EA; - border-radius: 4px; - } - .form-group { - margin-bottom: 1.5rem; - } - .form-label { - display: block; - margin-bottom: 0.5rem; - color: #ddd; - } - .form-input { - width: 100%; - padding: 0.8rem 1rem; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; - color: #fff; - font-size: 1rem; - outline: none; - transition: all 0.3s ease; - } - .form-input:focus { - border-color: rgba(255, 255, 255, 0.4); - box-shadow: 0 0 10px rgba(255, 255, 255, 0.2); - } - .form-row { - display: flex; - gap: 1.5rem; - margin-bottom: 1.5rem; - } - .form-col { - flex: 1; - } - .badge { - display: inline-block; - padding: 0.3rem 0.8rem; - border-radius: 999px; - font-size: 0.85rem; - margin-right: 0.5rem; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(6px); - border: 1px solid rgba(255, 255, 255, 0.2); - } - .badge-admin { - background: rgba(255, 153, 0, 0.2); - color: #ff9900; - } - .badge-dev { - background: rgba(0, 204, 255, 0.2); - color: #00ccff; - } - .btn { - padding: 0.8rem 1.5rem; - border-radius: 8px; - border: none; - font-size: 1rem; - cursor: pointer; - transition: all 0.3s ease; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(10px); - color: white; - font-weight: 500; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - border: 1px solid rgba(255, 255, 255, 0.2); - } - .btn:hover { - background: rgba(255, 255, 255, 0.15); - transform: translateY(-2px); - } - .btn-outline { - background: transparent; - border: 1px solid rgba(255, 255, 255, 0.2); - color: #ddd; - } - .btn-outline:hover { - background: rgba(255, 255, 255, 0.1); - } - .btn-container { - display: flex; - gap: 1rem; - margin-top: 1rem; - } - .info-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 1.5rem; - margin-top: 1rem; - } - .info-card { - background: rgba(255, 255, 255, 0.05); - border-radius: 12px; - padding: 1.2rem; - border: 1px solid rgba(255, 255, 255, 0.1); - backdrop-filter: blur(6px); - } - .info-title { - color: #aaa; - font-size: 0.9rem; - margin-bottom: 0.5rem; - } - .info-value { - font-size: 1.1rem; - color: #fff; - word-break: break-all; - } - .status-indicator { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - margin-right: 0.5rem; - } - .status-active { - background: #0f0; - box-shadow: 0 0 8px #0f0; - } - .status-inactive { - background: #f33; - } - .message { - padding: 0.8rem; - border-radius: 8px; - margin: 1rem 0; - text-align: center; - display: none; - backdrop-filter: blur(6px); - border: 1px solid rgba(255, 255, 255, 0.1); - } - .message-success { - background: rgba(0, 200, 0, 0.2); - color: #0f0; - } - .message-error { - background: rgba(200, 0, 0, 0.2); - color: #f33; - } - .form-actions { - display: flex; - justify-content: flex-end; - gap: 1rem; - margin-top: 1rem; - } - @media (max-width: 768px) { - header { - padding: 0.5rem 1rem; - flex-wrap: wrap; - } - .account-header { - flex-direction: column; - text-align: center; - } - .account-avatar { - margin-right: 0; - margin-bottom: 1rem; - } - .form-row { - flex-direction: column; - gap: 1rem; - } - .account-stats { - justify-content: center; - } - } - .admin-container { - transform-style: preserve-3d; - transform: translateZ(20px); - transition: transform 0.3s ease; - animation: gentleSway 8s infinite ease-in-out; - width: 95%; - max-width: 1200px; - background: rgba(255, 255, 255, 0.05); - border-radius: 16px; - backdrop-filter: blur(12px); - padding: 2rem; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.2); - margin: 1rem; - } - - .admin-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2rem; - padding-bottom: 1.5rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - } - - .admin-title { - font-size: 2rem; - color: #61E8EA; - } - - .user-count { - background: rgba(255, 215, 0, 0.2); - color: #61E8EA; - padding: 0.5rem 1rem; - border-radius: 999px; - font-weight: bold; - } - - .filters { - display: flex; - gap: 1rem; - margin-bottom: 1.5rem; - flex-wrap: wrap; - } - - .filter-input { - flex: 1; - min-width: 250px; - padding: 0.8rem 1rem; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; - color: #fff; - font-size: 1rem; - outline: none; - } - - .filter-input:focus { - border-color: rgba(255, 255, 255, 0.4); - box-shadow: 0 0 10px rgba(255, 255, 255, 0.2); - } - - .users-table { - width: 100%; - border-collapse: collapse; - margin-bottom: 2rem; - } - - .users-table th { - text-align: left; - padding: 1rem; - background: rgba(255, 215, 0, 0.1); - color: #61E8EA; - font-weight: 500; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - } - - .users-table td { - padding: 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); - } - - .user-row:hover { - background: rgba(255, 255, 255, 0.03); - } - - .user-avatar-small { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; - border: 1px solid rgba(255, 255, 255, 0.2); - } - - .user-select { - width: 20px; - height: 20px; - cursor: pointer; - } - - .badge { - display: inline-block; - padding: 0.3rem 0.8rem; - border-radius: 999px; - font-size: 0.85rem; - margin-right: 0.5rem; - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - } - - .badge-admin { - background: rgba(255, 153, 0, 0.2); - color: #ff9900; - } - - .badge-dev { - background: rgba(0, 204, 255, 0.2); - color: #00ccff; - } - - .actions-cell { - display: flex; - gap: 0.5rem; - } - - .action-btn { - padding: 0.4rem 0.8rem; - border-radius: 6px; - font-size: 0.9rem; - cursor: pointer; - transition: all 0.3s ease; - background: rgba(255, 255, 255, 0.1); - color: white; - border: 1px solid rgba(255, 255, 255, 0.2); - } - - .action-btn:hover { - background: rgba(255, 255, 255, 0.15); - } - - .btn-edit { - background: rgba(0, 150, 255, 0.2); - color: #0096ff; - } - - .batch-actions { - display: flex; - gap: 1rem; - margin-bottom: 2rem; - flex-wrap: wrap; - } - - .batch-form { - display: flex; - gap: 1rem; - align-items: center; - flex-wrap: wrap; - } - - .batch-label { - color: #ddd; - font-size: 0.9rem; - } - - .batch-select { - padding: 0.6rem 1rem; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; - color: #fff; - min-width: 150px; - } - - .message { - padding: 0.8rem; - border-radius: 8px; - margin: 1rem 0; - text-align: center; - display: none; - backdrop-filter: blur(6px); - border: 1px solid rgba(255, 255, 255, 0.1); - } - - .message-success { - background: rgba(0, 200, 0, 0.2); - color: #0f0; - } - - .message-error { - background: rgba(200, 0, 0, 0.2); - color: #f33; - } - - /* 模态框样式 */ - .modal { - position: fixed; - top: 0; left: 0; - width: 100vw; height: 100vh; - display: flex; - justify-content: center; - align-items: center; - background: rgba(0,0,0,0.7); - backdrop-filter: blur(10px); - z-index: 9999; - display: none; - } - - .modal-content { - background: rgba(30, 30, 40, 0.9); - padding: 2rem; - border-radius: 16px; - color: #fff; - backdrop-filter: blur(20px); - max-width: 500px; - width: 90%; - border: 1px solid rgba(255, 255, 255, 0.2); - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); - } - - .modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; - } - - .modal-title { - font-size: 1.5rem; - color: #61E8EA; - margin: 0; - } - - .close-modal { - background: none; - border: none; - color: #aaa; - font-size: 1.5rem; - cursor: pointer; - transition: color 0.3s; - } - - .close-modal:hover { - color: #fff; - } - - .modal-body { - margin-bottom: 1.5rem; - } - - .modal-footer { - display: flex; - justify-content: flex-end; - gap: 1rem; - } - - .password-display { - background: rgba(0, 0, 0, 0.3); - padding: 0.8rem; - border-radius: 8px; - font-family: monospace; - word-break: break-all; - margin: 0.5rem 0; - } - - .reveal-btn { - background: rgba(255, 215, 0, 0.2); - color: #61E8EA; - border: none; - padding: 0.3rem 0.8rem; - border-radius: 4px; - cursor: pointer; - font-size: 0.9rem; - } diff --git a/index.html b/index.html index b827351..b325a42 100644 --- a/index.html +++ b/index.html @@ -1,65 +1,124 @@ - - + + - - - - HyperSynapse Network免费Phira多人游戏服务器 - - - - + + + HSNPhira — Demo + - - - - +
+
+

HSNPhira Demo

+
-
-
操作成功!
+
+
+

HyperSynapse Network Phira多人游戏服务器

+

免费 · 多功能 · 稳定 · 低延迟

+
已访问: --
+
-
-

HyperSynapse Network Phira多人游戏服务器

-

免费 · 稳定 · 低延迟

-

加载中...

-
- -
-

联系我们

- -
-
- QQ群号 - 1049578201 +
+
+

服务器状态

+
加载中...
+
+
+
QQ群: 1049578201
+
-
- 服务器地址 - service.htadiy.cc:7865 +
+
地址: service.htadiy.com:7865
+
-
- -
- - -
-
- - +
+
+
+ +
+

房间列表

+
状态查询组件占位
+ + + + + + + + + + + + + + + + + +
房间名房主人数状态循环锁定谱面曲绘操作
+
+
+ + +
+
+

谱面排行

+
加载中...
+
+
+ +
+
+

用户排行

+
加载中...
+
+
+ +
+
+

账户管理

+
加载中...
+
+
+ +
+
+

谱面下载工具

+
+ + +
+
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + diff --git a/js/account.js b/js/account.js deleted file mode 100644 index d2cccbc..0000000 --- a/js/account.js +++ /dev/null @@ -1,548 +0,0 @@ - // 扩大卡片倾斜范围,越靠边倾斜越小,回正过程平滑 - document.addEventListener('DOMContentLoaded', () => { - const accountContainer = document.querySelector('.account-container'); - if (accountContainer) { - document.addEventListener('mousemove', (e) => { - const winW = window.innerWidth; - const winH = window.innerHeight; - const centerX = winW / 2; - const centerY = winH / 2; - const relX = (e.clientX - centerX) / centerX; - const relY = (e.clientY - centerY) / centerY; - const maxAngle = 6; - const rotateY = relX * maxAngle * (1 - Math.abs(relX) * 0.7); - const rotateX = -relY * maxAngle * (1 - Math.abs(relY) * 0.7); - accountContainer.style.transition = 'transform 0.5s cubic-bezier(.4,2,.3,1)'; - accountContainer.style.transform = `translateZ(20px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; - }); - document.addEventListener('mouseleave', () => { - accountContainer.style.transition = 'transform 1.2s cubic-bezier(.4,2,.3,1)'; - accountContainer.style.transform = 'translateZ(20px) rotateX(0deg) rotateY(0deg)'; - }); - } - }); - // 默认头像URL - const DEFAULT_AVATAR = 'https://phira.moe/assets/user-6212ee95.png'; - let currentUser = null; - let authMode = 'login'; - - // 页面加载时检查登录状态 - document.addEventListener('DOMContentLoaded', async () => { - // 添加3D卡片交互效果 - const accountContainer = document.querySelector('.account-container'); - - if (accountContainer) { - accountContainer.addEventListener('mousemove', (e) => { - if (!accountContainer.classList.contains('tilting')) { - accountContainer.classList.add('tilting'); - } - - const rect = accountContainer.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const centerX = rect.width / 2; - const centerY = rect.height / 2; - - const rotateY = (x - centerX) / centerX * 8; - const rotateX = (centerY - y) / centerY * 8; - - accountContainer.style.transform = `translateZ(20px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; - }); - - accountContainer.addEventListener('mouseleave', () => { - accountContainer.classList.remove('tilting'); - accountContainer.style.transform = 'translateZ(20px) rotateX(0) rotateY(0)'; - - // 重新启用轻微晃动动画 - setTimeout(() => { - accountContainer.style.animation = 'gentleSway 8s infinite ease-in-out'; - }, 300); - }); - } - - // 检查会话状态 - try { - const response = await fetch('/api/auth/me'); - if (response.ok) { - currentUser = await response.json(); - updateUserDisplay(); - await loadAccountDetails(); - // 等待主内容准备并安装 overlay,避免毛玻璃闪烁 - await waitForAppReady(3000); - installAccountTiltOverlay(); - // 窗口滚动时重新计算 overlay 位置 - window.addEventListener('scroll', () => installAccountTiltOverlay()); - } else { - document.getElementById('login-button').style.display = 'flex'; - } - } catch (error) { - console.error('检查会话失败:', error); - document.getElementById('login-button').style.display = 'flex'; - } - // 等待资源渲染并执行一次回流,然后短延迟隐藏 loader,避免毛玻璃闪烁 - const mainContent = document.getElementById('main-content'); - if (mainContent) { - void mainContent.offsetHeight; // 强制回流 - mainContent.style.opacity = 1; - } - const loader = document.getElementById('page-loader'); - if (loader) setTimeout(() => loader.classList.add('hide'), 120); - - // 兼容性修复:如果 .glass-checkbox 无法响应点击,使用事件委托切换 checkbox - document.addEventListener('click', (e) => { - const box = e.target.closest && e.target.closest('.glass-checkbox'); - if (!box) return; - const input = box.querySelector('input[type="checkbox"]'); - if (!input) return; - if (e.target === input) return; - input.checked = !input.checked; - input.dispatchEvent(new Event('change', { bubbles: true })); - }); - - // 视觉同步:当 checkbox 的 checked 状态改变时,切换容器上的 .checked class - document.addEventListener('change', (e) => { - const input = e.target; - if (!input || input.type !== 'checkbox') return; - const box = input.closest && input.closest('.glass-checkbox'); - if (!box) return; - if (input.checked) box.classList.add('checked'); else box.classList.remove('checked'); - }); - }); - - // 加载账户详细信息 - async function loadAccountDetails() { - if (!currentUser) return; - - // 设置用户数据 - document.getElementById('account-name').textContent = currentUser.username; - document.getElementById('username-display').textContent = currentUser.username; - document.getElementById('dropdown-username').textContent = currentUser.username; - document.getElementById('account-id').textContent = currentUser.id; - document.getElementById('phira-id').textContent = currentUser.phira_id; - document.getElementById('phira-name').textContent = currentUser.phira_username; - document.getElementById('info-username').textContent = currentUser.username; - document.getElementById('rks-value').textContent = currentUser.phira_rks ? parseFloat(currentUser.phira_rks).toFixed(2) : '0.00'; - - // 设置头像 - const avatarUrl = currentUser.phira_avatar || DEFAULT_AVATAR; - document.getElementById('user-avatar').src = avatarUrl; - document.getElementById('user-avatar-large').src = avatarUrl; - - // 设置注册日期 - document.getElementById('join-date').textContent = formatDate(currentUser.register_time); - - // 设置最后登录时间 - document.getElementById('last-login').textContent = formatDate(currentUser.last_login_time); - - // 设置权限信息 - document.getElementById('admin-status').textContent = (currentUser.permissions & 0xFFFFFFFF) === 0xFFFFFFFF ? "是" : "否"; - document.getElementById('user-management-status').textContent = (currentUser.permissions & 0x00000002) ? "是" : "否"; - document.getElementById('group-management-status').textContent = (currentUser.permissions & 0x00000004) ? "是" : "否"; - - // 更新权限徽章 - const badgesContainer = document.getElementById('account-badges'); - badgesContainer.innerHTML = ''; - - if ((currentUser.permissions & 0xFFFFFFFF) === 0xFFFFFFFF) { - const adminBadge = document.createElement('span'); - adminBadge.className = 'badge badge-admin'; - adminBadge.textContent = '管理员'; - badgesContainer.appendChild(adminBadge); - } - - if (currentUser.permissions & 0x00000002) { - const userMgmtBadge = document.createElement('span'); - userMgmtBadge.className = 'badge badge-admin'; - userMgmtBadge.textContent = '用户管理'; - badgesContainer.appendChild(userMgmtBadge); - } - - if (currentUser.permissions & 0x00000004) { - const groupMgmtBadge = document.createElement('span'); - groupMgmtBadge.className = 'badge badge-dev'; - groupMgmtBadge.textContent = '用户组管理'; - badgesContainer.appendChild(groupMgmtBadge); - } - } - - // 格式化日期 - function formatDate(dateString) { - if (!dateString) return '-'; - const date = new Date(dateString); - // 转换为北京时间(UTC+8) - const beijingDate = new Date(date.getTime() + 8 * 60 * 60 * 1000); - return beijingDate.toLocaleString('zh-CN', { hour12: false }); - } - - // 等待页面主要内容与图片就绪后再隐藏加载器,避免毛玻璃闪烁 - async function waitForAppReady(maxWait = 3000) { - const start = Date.now(); - const nameEl = document.getElementById('account-name'); - function imagesLoaded() { - const imgs = Array.from(document.images || []); - return imgs.length === 0 || imgs.every(i => i.complete); - } - while (Date.now() - start < maxWait) { - const nameReady = nameEl && nameEl.textContent && !nameEl.textContent.includes('加载中'); - if (nameReady && imagesLoaded()) return; - // eslint-disable-next-line no-await-in-loop - await new Promise(r => setTimeout(r, 80)); - } - } - - // 更新用户显示区域 - function updateUserDisplay() { - const loginButton = document.getElementById('login-button'); - const avatarContainer = document.getElementById('avatar-container'); - - if (currentUser) { - // 隐藏登录按钮 - if (loginButton) loginButton.style.display = 'none'; - - // 显示头像容器 - if (avatarContainer) avatarContainer.style.display = 'flex'; - - // 更新用户名和头像 - if (document.getElementById('username-display')) { - document.getElementById('username-display').textContent = currentUser.username; - } - if (document.getElementById('user-avatar')) { - document.getElementById('user-avatar').src = currentUser.phira_avatar || DEFAULT_AVATAR; - } - if (document.getElementById('dropdown-username')) { - document.getElementById('dropdown-username').textContent = currentUser.username; - } - } else { - // 显示登录按钮 - if (loginButton) loginButton.style.display = 'flex'; - - // 隐藏头像容器 - if (avatarContainer) avatarContainer.style.display = 'none'; - } - } - - // 重置用户名表单 - function resetUsernameForm() { - document.getElementById('new-username').value = ''; - document.getElementById('username-password').value = ''; - hideMessage('username-message'); - } - - // 重置密码表单 - function resetPasswordForm() { - document.getElementById('current-password').value = ''; - document.getElementById('new-password').value = ''; - document.getElementById('confirm-password').value = ''; - hideMessage('password-message'); - } - - // 显示消息 - function showMessage(elementId, message, isSuccess) { - const messageElement = document.getElementById(elementId); - messageElement.textContent = message; - messageElement.className = 'message ' + (isSuccess ? 'message-success' : 'message-error'); - messageElement.style.display = 'block'; - - // 5秒后隐藏消息 - setTimeout(() => { - hideMessage(elementId); - }, 5000); - } - - // 隐藏消息 - function hideMessage(elementId) { - const messageElement = document.getElementById(elementId); - messageElement.style.display = 'none'; - } - - // 更新用户名 - async function updateUsername() { - const newUsername = document.getElementById('new-username').value; - const password = document.getElementById('username-password').value; - - if (!newUsername) { - showMessage('username-message', '请输入新的用户名', false); - return; - } - - if (!password) { - showMessage('username-message', '请输入当前密码', false); - return; - } - - try { - const response = await fetch(`/api/auth/users/${currentUser.id}`, { - method: 'PATCH', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - username: newUsername, - current_password: password - }) - }); - - if (!response.ok) { - const errorData = await response.json(); - showMessage('username-message', errorData.message || '更新失败', false); - return; - } - - // 更新本地显示 - currentUser.username = newUsername; - document.getElementById('account-name').textContent = newUsername; - document.getElementById('info-username').textContent = newUsername; - document.getElementById('username-display').textContent = newUsername; - document.getElementById('dropdown-username').textContent = newUsername; - - showMessage('username-message', '用户名更新成功!', true); - resetUsernameForm(); - } catch (error) { - showMessage('username-message', '网络错误,请重试', false); - } - } - - // 更新密码 - async function updatePassword() { - const currentPassword = document.getElementById('current-password').value; - const newPassword = document.getElementById('new-password').value; - const confirmPassword = document.getElementById('confirm-password').value; - - if (!currentPassword) { - showMessage('password-message', '请输入当前密码', false); - return; - } - - if (!newPassword) { - showMessage('password-message', '请输入新密码', false); - return; - } - - if (newPassword.length < 6) { - showMessage('password-message', '密码长度至少为6个字符', false); - return; - } - - if (newPassword !== confirmPassword) { - showMessage('password-message', '新密码与确认密码不匹配', false); - return; - } - - try { - const response = await fetch(`/api/auth/users/${currentUser.id}`, { - method: 'PATCH', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - current_password: currentPassword, - password: newPassword - }) - }); - - if (!response.ok) { - const errorData = await response.json(); - showMessage('password-message', errorData.message || '更新失败', false); - return; - } - - showMessage('password-message', '密码更新成功!', true); - resetPasswordForm(); - } catch (error) { - showMessage('password-message', '网络错误,请重试', false); - } - } - - // 以下是登录/注册相关功能 - function openAuth() { - document.getElementById('auth-modal').style.display = 'flex'; - document.getElementById('auth-msg').textContent = ''; - updateAuthUI(); - } - - function closeAuth() { - document.getElementById('auth-modal').style.display = 'none'; - } - - function toggleAuthMode() { - authMode = authMode === 'login' ? 'register' : 'login'; - updateAuthUI(); - - // 显示/隐藏用户协议复选框 - const agreementContainer = document.getElementById('agreement-container'); - agreementContainer.style.display = authMode === 'register' ? 'block' : 'none'; - } - - function updateAuthUI() { - var title = document.getElementById('auth-title'); - var phiraid = document.getElementById('auth-phiraid'); - var passwordConfirm = document.getElementById('auth-password-confirm'); - if (authMode === 'login') { - title.textContent = '用户登录'; - phiraid.classList.add('collapsed'); - passwordConfirm.style.display = 'none'; - } - else { - title.textContent = '用户注册'; - phiraid.classList.remove('collapsed'); - passwordConfirm.style.display = 'block'; - } - } - - async function submitAuth() { - const username = document.getElementById('auth-name').value; - const password = document.getElementById('auth-password').value; - const phira_id = document.getElementById('auth-phiraid').value; - const remember = document.getElementById('remember-me').checked; - const agreeTerms = authMode === 'register' ? document.getElementById('agree-terms').checked : true; - const msg = document.getElementById('auth-msg'); - msg.textContent = '处理中...'; - - try { - // 注册时需要确认用户协议 - if (authMode === 'register' && !agreeTerms) { - msg.textContent = '请同意用户协议'; - return; - } - - const endpoint = authMode === 'login' - ? '/api/auth/login' - : '/api/auth/users'; - - const payload = authMode === 'login' - ? { username, password, remember: remember } - : { username, password, phira_id }; - - const res = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(payload) - }); - - if (!res.ok) { - const errorData = await res.json(); - msg.textContent = errorData.message || '操作失败'; - return; - } - - // 登录成功后获取用户信息 - if (authMode === 'login') { - const userRes = await fetch('/api/auth/me'); - if (!userRes.ok) { - msg.textContent = '获取用户信息失败'; - return; - } - currentUser = await userRes.json(); - updateUserDisplay(); - loadAccountDetails(); - } - - msg.textContent = authMode === 'login' ? '登录成功!' : '注册成功!'; - setTimeout(() => { - closeAuth(); - if (authMode === 'register') { - authMode = 'login'; - updateAuthUI(); - } - msg.textContent = ''; - }, 1000); - } catch (e) { - msg.textContent = '网络错误'; - console.error('认证错误:', e); - } - } - - // 退出登录 - async function logout() { - try { - await fetch('/api/auth/logout', { method: 'POST' }); - currentUser = null; - window.location.reload(); - } catch (error) { - console.error('登出失败:', error); - } - } - - // 切换下拉菜单显示状态 - function toggleDropdown() { - const dropdown = document.getElementById('user-dropdown'); - if (dropdown) { - dropdown.classList.toggle('show'); - } - } - - // 点击其他地方关闭下拉菜单 - document.addEventListener('click', (e) => { - const dropdown = document.getElementById('user-dropdown'); - const userInfo = document.querySelector('.user-info'); - if (dropdown && dropdown.classList.contains('show') && - !e.target.closest('.user-info') && - !e.target.closest('.dropdown-content')) { - dropdown.classList.remove('show'); - } - }); - - // 安装 account 的倾斜 overlay:扩大触发区域并降低最大倾斜角度以获得更平滑的过渡 - function installAccountTiltOverlay() { - const container = document.querySelector('.account-container'); - if (!container) return; - const existing = document.getElementById('account-tilt-overlay'); - if (existing) existing.remove(); - const rect = container.getBoundingClientRect(); - const overlay = document.createElement('div'); - overlay.id = 'account-tilt-overlay'; - const scale = 1.6; // 略大于 sqrt(2),扩大触发范围 - const w = rect.width * scale; - const h = rect.height * scale; - overlay.style.width = w + 'px'; - overlay.style.height = h + 'px'; - overlay.style.left = (rect.left + (rect.width - w) / 2) + 'px'; - overlay.style.top = (rect.top + (rect.height - h) / 2) + 'px'; - overlay.style.position = 'fixed'; - overlay.style.background = 'transparent'; - // 允许点击穿透:overlay 不阻塞指针事件,使用全局 mousemove 计算倾斜 - overlay.style.pointerEvents = 'none'; - overlay.style.zIndex = 1002; - document.body.appendChild(overlay); - // 使用 window mousemove 允许点击穿透并在离开 overlay 区域时回正 - if (existing && existing._handler) { - window.removeEventListener('mousemove', existing._handler); - } - let isHovering = false; - function onGlobalMouseMove(e) { - const orect = overlay.getBoundingClientRect(); - const inside = e.clientX >= orect.left && e.clientX <= orect.right && e.clientY >= orect.top && e.clientY <= orect.bottom; - if (!inside) { - if (isHovering) { - isHovering = false; - container.classList.remove('tilting'); - container.style.transition = 'transform 0.6s cubic-bezier(.4,2,.3,1)'; - container.style.transform = 'translateZ(20px) rotateX(0deg) rotateY(0deg)'; - } - return; - } - isHovering = true; - const r = container.getBoundingClientRect(); - const centerX = r.left + r.width / 2; - const centerY = r.top + r.height / 2; - const dx = e.clientX - centerX; - const dy = e.clientY - centerY; - const maxDist = Math.hypot(r.width/2, r.height/2); - const dist = Math.hypot(dx, dy); - const ratio = Math.min(1, dist / maxDist); - const maxAngle = 6; // 更小的最大角度 - const angle = ratio * maxAngle; - const rotateY = (dx / maxDist) * angle; - const rotateX = -(dy / maxDist) * angle; - container.classList.add('tilting'); - container.style.transform = `translateZ(20px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; - } - window.addEventListener('mousemove', onGlobalMouseMove); - overlay._handler = onGlobalMouseMove; - - window.addEventListener('resize', () => { - const r = container.getBoundingClientRect(); - const w2 = r.width * scale; - const h2 = r.height * scale; - overlay.style.width = w2 + 'px'; - overlay.style.height = h2 + 'px'; - overlay.style.left = (r.left + (r.width - w2) / 2) + 'px'; - overlay.style.top = (r.top + (r.height - h2) / 2) + 'px'; - }); - } \ No newline at end of file diff --git a/js/agreement_update.js b/js/agreement_update.js deleted file mode 100644 index 6ec6916..0000000 --- a/js/agreement_update.js +++ /dev/null @@ -1,21 +0,0 @@ -// Agreement update notifier -(function(){ - const KEY = '__hsn_last_agreement_v'; - async function check(){ - try{ - const r = await fetch('/api/agreements/meta', {cache: 'no-store'}); - if (!r.ok) return; - const d = await r.json(); - const v = d.version || d.updated_at || ''; - const prev = localStorage.getItem(KEY); - if (prev && v && prev !== v){ - if (typeof showMessage === 'function'){ - showMessage('服务条款更新', '服务条款已更新,请查看并同意', 'linear-gradient(135deg, rgba(255,192,32,0.08), rgba(255,96,96,0.06))', 0); - } - } - if (v) localStorage.setItem(KEY, v); - }catch(e){} - } - check(); - setInterval(check, 1000*60*30); -})(); diff --git a/js/auth-window.js b/js/auth-window.js deleted file mode 100644 index 97de41b..0000000 --- a/js/auth-window.js +++ /dev/null @@ -1,136 +0,0 @@ -(function(){ - // Centralized auth window using mac-window - const state = { authMode: 'login', win: null }; - - function createWindowIfNeeded(){ - if (state.win && document.body.contains(state.win)) return state.win; - // create a mac-window instance - const win = document.createElement('mac-window'); - win.id = 'auth-mac-window'; - // smaller default - win.style.setProperty('--width','420px'); - win.style.setProperty('--height','380px'); - document.body.appendChild(win); - state.win = win; - - // when closed remove instance so next open creates fresh one - win.addEventListener('mac-window-close', () => { - try { win.remove(); } catch(e){} - state.win = null; - }); - - return win; - } - - function openAuth(){ - const tmpl = document.getElementById('auth-template'); - if (!tmpl) return console.warn('auth template missing'); - const win = createWindowIfNeeded(); - - // clear previous content - const existing = win.querySelector('#auth-box'); - if (existing) existing.remove(); - - const node = tmpl.content.cloneNode(true); - // append nodes into window (slot area) - win.appendChild(node); - - // wire up buttons - const submitBtn = win.querySelector('#auth-submit'); - const toggleBtn = win.querySelector('#auth-toggle'); - const msgElem = win.querySelector('#auth-msg'); - - if (submitBtn) submitBtn.addEventListener('click', submitAuth); - if (toggleBtn) toggleBtn.addEventListener('click', toggleAuthMode); - - // ensure UI initial state - updateAuthUI(); - - win.open(); - } - - function closeAuth(){ - if (state.win) state.win.close(); - } - - function toggleAuthMode(){ - state.authMode = state.authMode === 'login' ? 'register' : 'login'; - updateAuthUI(); - } - - function updateAuthUI(){ - const win = state.win || document.getElementById('auth-mac-window'); - const title = win && win.querySelector('#auth-title'); - const phiraid = win && win.querySelector('#auth-phiraid'); - const agreementContainer = win && win.querySelector('#agreement-container'); - if (!title) return; - if (state.authMode === 'login'){ - title.textContent = '用户登录'; - if (phiraid) phiraid.classList.add('collapsed'); - if (agreementContainer) agreementContainer.style.display = 'none'; - } else { - title.textContent = '用户注册'; - if (phiraid) phiraid.classList.remove('collapsed'); - if (agreementContainer) agreementContainer.style.display = 'flex'; - } - } - - async function submitAuth(){ - const win = state.win || document.getElementById('auth-mac-window'); - if (!win) return; - const username = win.querySelector('#auth-name').value; - const password = win.querySelector('#auth-password').value; - const phira_id = win.querySelector('#auth-phiraid').value; - const remember = !!(win.querySelector('#remember-me') && win.querySelector('#remember-me').checked); - const agreeTerms = state.authMode === 'register' ? !!(win.querySelector('#agree-terms') && win.querySelector('#agree-terms').checked) : true; - const msg = win.querySelector('#auth-msg'); - if (msg) msg.textContent = '处理中...'; - - try { - if (state.authMode === 'register' && !agreeTerms) { - if (msg) msg.textContent = '请同意用户协议'; - return; - } - const endpoint = state.authMode === 'login' ? '/api/auth/login' : '/api/auth/users'; - const payload = state.authMode === 'login' ? { username, password, remember } : { username, password, phira_id }; - const res = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type':'application/json'}, - body: JSON.stringify(payload) - }); - if (!res.ok) { - const err = await res.json().catch(()=>({})); - if (msg) msg.textContent = err.message || '操作失败'; - return; - } - if (state.authMode === 'login'){ - // refresh global currentUser if applicable - try { - const userRes = await fetch('/api/auth/me'); - if (userRes.ok) { - window.currentUser = await userRes.json(); - if (window.updateUserDisplay) try { window.updateUserDisplay(); } catch(e){} - } - } catch(e){} - } - if (msg) msg.textContent = state.authMode === 'login' ? '登录成功!' : '注册成功!'; - setTimeout(()=>{ - try { closeAuth(); } catch(e){} - if (state.authMode === 'register') state.authMode = 'login'; - }, 800); - } catch (e) { - if (msg) msg.textContent = '网络错误'; - console.error('auth error', e); - } - } - - // expose globals used by header and other scripts - window.openAuth = openAuth; - window.closeAuth = closeAuth; - window.toggleAuthMode = toggleAuthMode; - window.submitAuth = submitAuth; - window.updateAuthUI = updateAuthUI; - - // auto-initialize if template not yet loaded: wait for DOMContentLoaded - if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ()=>{}); -})(); diff --git a/js/auto_update.js b/js/auto_update.js deleted file mode 100644 index a8f53ea..0000000 --- a/js/auto_update.js +++ /dev/null @@ -1,22 +0,0 @@ -// Simple auto-update checker -(function(){ - const CHECK_INTERVAL = 1000 * 60 * 5; // 5 minutes - async function check(){ - try{ - const r = await fetch('/api/version', {cache: 'no-store'}); - if (!r.ok) return; - const data = await r.json(); - if (!window.__hsn_last_version) window.__hsn_last_version = data.version; - if (data.version && data.version !== window.__hsn_last_version){ - window.__hsn_last_version = data.version; - if (typeof showMessage === 'function'){ - showMessage('更新可用', '检测到新版本,建议刷新页面以获取最新内容', 'linear-gradient(135deg, rgba(32,160,255,0.12), rgba(0,200,120,0.08))', 0); - } - } - }catch(e){ - // ignore - } - } - check(); - setInterval(check, CHECK_INTERVAL); -})(); diff --git a/js/checkserverstatus.js b/js/checkserverstatus.js deleted file mode 100644 index 15af588..0000000 --- a/js/checkserverstatus.js +++ /dev/null @@ -1,20 +0,0 @@ - // 检查服务器状态 - async function checkServerStatus() { - const status = document.getElementById("status"); - try { - const res = await fetch("/api/rooms/info?_=" + Date.now(), { method: "GET", cache: "no-store" }); - if (res.ok) { - status.textContent = "服务器状态:在线 :)"; - status.classList.add("online"); - status.classList.remove("offline"); - status.classList.remove("cached"); - } else { - throw new Error("无返回"); - } - } catch (err) { - status.textContent = "服务器状态:离线 :("; - status.classList.add("offline"); - status.classList.remove("online"); - status.classList.remove("cached"); - } - } diff --git a/js/contact-window.js b/js/contact-window.js deleted file mode 100644 index e124983..0000000 --- a/js/contact-window.js +++ /dev/null @@ -1,44 +0,0 @@ -// Contact window controller -(function(){ - async function openContact(){ - const tplResp = await fetch('./component/contact.html'); - const text = await tplResp.text(); - const div = document.createElement('div'); - div.innerHTML = text; - const tpl = div.querySelector('#contact-template'); - if (!tpl) return; - - const win = document.createElement('mac-window'); - win.style.setProperty('--width','640px'); - win.style.setProperty('--height','420px'); - document.body.appendChild(win); - - const content = tpl.content.cloneNode(true); - const slotWrap = document.createElement('div'); - slotWrap.appendChild(content); - win.appendChild(slotWrap); - - // simple carousel dummy: try to fetch announcements - try{ - const r = await fetch('/api/announcements?limit=5'); - if (r.ok){ - const arr = await r.json(); - const carousel = win.querySelector('#contact-carousel'); - if (carousel && Array.isArray(arr)){ - carousel.innerHTML = ''; - arr.forEach(a=>{ - const it = document.createElement('div'); - it.className = 'contact-item'; - it.textContent = a.title || a.summary || '公告'; - carousel.appendChild(it); - }); - } - } - }catch(e){} - - // expose for global usage - window.openContact = openContact; - } - - window.openContact = openContact; -})(); diff --git a/js/index.js b/js/index.js deleted file mode 100644 index f831117..0000000 --- a/js/index.js +++ /dev/null @@ -1,92 +0,0 @@ - // 复制QQ群号功能 - function copyQQ() { - // 使用现代Clipboard API [6,7](@ref) - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText('1049578201') - .then(() => showToast('QQ群号已复制!')) - .catch(() => { - // 如果现代API失败,回退到传统方法 [1,4](@ref) - fallbackCopyText('1049578201', 'QQ群号'); - }); - } else { - // 使用传统的execCommand方法作为备选方案 [1,2](@ref) - fallbackCopyText('1049578201', 'QQ群号'); - } - } - - // 复制服务器地址功能 - function copyServerAddress() { - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText('service.htadiy.cc:7865') - .then(() => showToast('服务器地址已复制!')) - .catch(() => { - fallbackCopyText('service.htadiy.cc:7865', '服务器地址'); - }); - } else { - fallbackCopyText('service.htadiy.cc:7865', '服务器地址'); - } - } - - // 传统复制方法作为备选 [1,4,6](@ref) - function fallbackCopyText(text, type) { - // 创建临时的textarea元素 [4,7](@ref) - const textArea = document.createElement('textarea'); - textArea.value = text; - textArea.style.position = 'fixed'; - textArea.style.top = '0'; - textArea.style.left = '0'; - textArea.style.opacity = '0'; - - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - try { - // 执行复制命令 [1,6](@ref) - const successful = document.execCommand('copy'); - document.body.removeChild(textArea); - if (successful) { - showToast(`${type}已复制!`); - } else { - showToast(`复制失败,请手动选择并复制`); - } - } catch (err) { - document.body.removeChild(textArea); - showToast(`复制失败,请手动选择并复制`); - } - } - - // 显示操作提示 - function showToast(message) { - const toast = document.getElementById('toast'); - toast.textContent = message; - toast.classList.add('show'); - - setTimeout(() => { - toast.classList.remove('show'); - }, 3000); - } - - // 页面滚动动画 - const observer = new IntersectionObserver((entries) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - entry.target.classList.add('visible'); - } - }); - }, { threshold: 0.3 }); - - document.querySelectorAll('section').forEach(section => { - observer.observe(section); - }); - - // 获取用户数量 - fetch("/api/auth/visited/count") - .then(res => res.json()) - .then(data => { - const count = data; - document.getElementById("user-count").innerText = `已有 ${count} 位用户使用过我们的服务器`; - }) - .catch(() => { - document.getElementById("user-count").innerText = "无法加载用户数据"; - }); \ No newline at end of file diff --git a/js/login-btn.js b/js/login-btn.js deleted file mode 100644 index c93ff29..0000000 --- a/js/login-btn.js +++ /dev/null @@ -1,63 +0,0 @@ - // 更新用户显示区域 - function updateUserDisplay() { - const loginButton = document.getElementById('login-button'); - const avatarContainer = document.getElementById('avatar-container'); - - if (currentUser) { - // 隐藏登录按钮 - loginButton.style.display = 'none'; - - // 显示头像容器 - avatarContainer.style.display = 'flex'; - - // 更新用户名和头像 - document.getElementById('username-display').textContent = currentUser.username; - document.getElementById('user-avatar').src = currentUser.phira_avatar || DEFAULT_AVATAR; - document.getElementById('dropdown-username').textContent = currentUser.username; - - // 更新Phira账户链接 - const phiraLink = document.getElementById('phira-profile-link'); - if (phiraLink && currentUser.phira_id) { - phiraLink.href = `https://phira.moe/user/${currentUser.phira_id}`; - } - } else { - // 显示登录按钮 - loginButton.style.display = 'flex'; - - // 隐藏头像容器 - avatarContainer.style.display = 'none'; - } - } - - // 切换下拉菜单显示状态 - function toggleDropdown() { - const dropdown = document.getElementById('user-dropdown'); - if (dropdown) { - dropdown.classList.toggle('show'); - } - } - - // 点击其他地方关闭下拉菜单 - document.addEventListener('click', (e) => { - const dropdown = document.getElementById('user-dropdown'); - const userInfo = document.querySelector('.user-info'); - if (dropdown && dropdown.classList.contains('show') && - !e.target.closest('.user-info') && - !e.target.closest('.dropdown-content')) { - dropdown.classList.remove('show'); - } - }); - - // 退出登录 - async function logout() { - try { - await fetch('/api/auth/logout', { method: 'POST' }); - currentUser = null; - updateUserDisplay(); - // 关闭下拉菜单 - const dropdown = document.getElementById('user-dropdown'); - if (dropdown) dropdown.classList.remove('show'); - } catch (err) { - console.error('登出失败:', err); - } - } \ No newline at end of file diff --git a/js/message.js b/js/message.js deleted file mode 100644 index 6cddc4c..0000000 --- a/js/message.js +++ /dev/null @@ -1,70 +0,0 @@ -// Unified message component -(function(){ - const containerId = 'hsn-message-container'; - function ensureContainer(){ - let c = document.getElementById(containerId); - if (!c){ - c = document.createElement('div'); - c.id = containerId; - c.style.position = 'fixed'; - c.style.top = '12px'; - c.style.right = '12px'; - c.style.zIndex = 200000; - c.style.display = 'flex'; - c.style.flexDirection = 'column'; - c.style.gap = '8px'; - document.body.appendChild(c); - } - return c; - } - - function showMessage(title, content, bgColor, timeout=4000){ - const c = ensureContainer(); - const card = document.createElement('div'); - card.style.minWidth = '220px'; - card.style.maxWidth = '420px'; - card.style.background = bgColor || 'rgba(255,255,255,0.06)'; - card.style.backdropFilter = 'blur(12px)'; - card.style.border = '1px solid rgba(255,255,255,0.08)'; - card.style.borderRadius = '12px'; - card.style.padding = '12px'; - card.style.color = '#fff'; - card.style.boxShadow = '0 8px 32px rgba(0,0,0,0.3)'; - card.style.position = 'relative'; - - const close = document.createElement('button'); - close.textContent = '×'; - close.style.position = 'absolute'; - close.style.right = '8px'; - close.style.top = '8px'; - close.style.border = 'none'; - close.style.background = 'rgba(255,0,0,0.7)'; - close.style.color = '#fff'; - close.style.width = '20px'; - close.style.height = '20px'; - close.style.borderRadius = '50%'; - close.style.cursor = 'pointer'; - close.addEventListener('click', ()=>{ if (card.parentNode) card.parentNode.removeChild(card); }); - - const h = document.createElement('div'); - h.textContent = title || ''; - h.style.fontSize = '16px'; - h.style.fontWeight = '700'; - h.style.marginBottom = '6px'; - - const p = document.createElement('div'); - p.innerHTML = content || ''; - p.style.fontSize = '13px'; - - card.appendChild(close); - card.appendChild(h); - card.appendChild(p); - c.appendChild(card); - - if (timeout > 0){ - setTimeout(()=>{ try{ if (card.parentNode) card.parentNode.removeChild(card); }catch(e){} }, timeout); - } - } - - window.showMessage = showMessage; -})(); diff --git a/js/ranks.js b/js/ranks.js deleted file mode 100644 index 4cdc379..0000000 --- a/js/ranks.js +++ /dev/null @@ -1,731 +0,0 @@ - // 默认头像URL - const DEFAULT_AVATAR = 'https://phira.moe/assets/user-6212ee95.png'; - const USER_CACHE = new Map(); - - let currentUser = null; - let authMode = 'login'; - let autoRefreshInterval; - let isHoveringTable = false; - let lastMousePosition = { x: 0, y: 0 }; - - // 排行榜相关变量 - let currentPage = 1; - const pageSize = 10; - let totalUsers = 0; - let totalPlaytime = 0; - let fullLeaderboardData = []; - let filteredLeaderboardData = []; - let isSearching = false; - let lastSearchQuery = ''; // 保存上次搜索条件 - - // 分段加载与并发控制 - let leaderboardChunkSize = 50; // 每次请求多少条排行榜条目 - let leaderboardOffset = 0; // 下一次应该请求的偏移量 - let hasMoreLeaderboardData = true; // 是否还有未加载的数据 - let chunkFetchConcurrency = 3; // 同时发起多少个排行榜段请求 - let userFetchConcurrency = 8; // 获取用户信息时并发数 - let prefetching = false; // 后台预取进行中标志 - - // 页面加载时检查登录状态并初始化 - document.addEventListener('DOMContentLoaded', async () => { - // 扩大表格倾斜范围:全局鼠标不再驱动整个页面,而使用 overlay 驱动表格倾斜 - const leaderboardTable = document.getElementById('leaderboard-table'); - if (leaderboardTable) { - // 保持表格初始微倾斜 - leaderboardTable.style.transform = 'translateZ(20px) rotateX(0deg) rotateY(0deg)'; - } - - // 尝试获取当前会话 - try { - const response = await fetch('/api/auth/me'); - if (response.ok) { - currentUser = await response.json(); - updateUserDisplay(); - } - } catch (error) { - console.error('检查会话失败:', error); - } - - // 首次检查服务器状态并加载排行榜数据 - await checkServerStatus(); - await loadLeaderboard(); - - // 等待资源完全准备好后再隐藏加载器 - await waitForAppReady(); - - // 安装倾斜 overlay(基于表格的实时几何中心,面积为两倍) - installTableTiltOverlay(); - - // 设置每秒自动刷新 - autoRefreshInterval = setInterval(async () => { - // 如果正在搜索,不自动刷新 - if (isSearching) return; - - await checkServerStatus(); - await loadLeaderboard(); - }, 1000); - - // 翻页按钮事件监听 - document.getElementById('prev-page').addEventListener('click', () => { - if (currentPage > 1) { - currentPage--; - renderLeaderboardPage(); - } - }); - - document.getElementById('next-page').addEventListener('click', () => { - const maxPage = Math.ceil(totalUsers / pageSize); - if (currentPage < maxPage) { - currentPage++; - renderLeaderboardPage(); - } - }); - - // 搜索框输入事件监听 - const searchInput = document.getElementById('search-input'); - searchInput.addEventListener('input', debounce(() => { - const query = searchInput.value.trim(); - lastSearchQuery = query; - performSearch(query); - }, 300)); - - // 兼容性:如果自定义的 .glass-checkbox 无法响应 click,使用事件委托做一次切换 - document.addEventListener('click', (e) => { - const box = e.target.closest && e.target.closest('.glass-checkbox'); - if (!box) return; - const input = box.querySelector('input[type="checkbox"]'); - if (!input) return; - // 如果点击目标就是 input ,让浏览器正常处理;否则手动切换并派发 change - if (e.target === input) return; - input.checked = !input.checked; - input.dispatchEvent(new Event('change', { bubbles: true })); - }); - - // 当 checkbox 状态变化时,给容器添加/移除 .checked 以显示视觉打勾 - document.addEventListener('change', (e) => { - const input = e.target; - if (!input || input.type !== 'checkbox') return; - const box = input.closest && input.closest('.glass-checkbox'); - if (!box) return; - if (input.checked) box.classList.add('checked'); else box.classList.remove('checked'); - }); - }); - - // 防抖函数 - function debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; - } - - // 执行搜索 - function performSearch(query) { - if (!query) { - isSearching = false; - currentPage = 1; - renderLeaderboardPage(); - return; - } - - isSearching = true; - - // 同时搜索ID和用户名 - filteredLeaderboardData = fullLeaderboardData.filter(user => { - // ID精确匹配 - if (user.user_id.toString() === query) { - return true; - } - - // 用户名模糊匹配(如果用户名已缓存) - if (USER_CACHE.has(user.user_id)) { - const userInfo = USER_CACHE.get(user.user_id); - if (userInfo && userInfo.name && userInfo.name.toLowerCase().includes(query.toLowerCase())) { - return true; - } - } - - return false; - }); - - currentPage = 1; - renderLeaderboardPage(); - } - - // 更新用户显示区域 - function updateUserDisplay() { - const loginButton = document.getElementById('login-button'); - const avatarContainer = document.getElementById('avatar-container'); - - if (currentUser) { - // 隐藏登录按钮 - loginButton.style.display = 'none'; - - // 显示头像容器 - avatarContainer.style.display = 'flex'; - - // 更新用户名和头像 - document.getElementById('username-display').textContent = currentUser.username; - document.getElementById('user-avatar').src = currentUser.phira_avatar || DEFAULT_AVATAR; - document.getElementById('dropdown-username').textContent = currentUser.username; - - // 更新Phira账户链接 - const phiraLink = document.getElementById('phira-profile-link'); - if (phiraLink && currentUser.phira_id) { - phiraLink.href = `https://phira.moe/user/${currentUser.phira_id}`; - } - } else { - // 显示登录按钮 - loginButton.style.display = 'flex'; - - // 隐藏头像容器 - avatarContainer.style.display = 'none'; - } - } - - // 切换下拉菜单显示状态 - function toggleDropdown() { - const dropdown = document.getElementById('user-dropdown'); - if (dropdown) { - dropdown.classList.toggle('show'); - } - } - - // 点击其他地方关闭下拉菜单 - document.addEventListener('click', (e) => { - const dropdown = document.getElementById('user-dropdown'); - const userInfo = document.querySelector('.user-info'); - if (dropdown && dropdown.classList.contains('show') && - !e.target.closest('.user-info') && - !e.target.closest('.dropdown-content')) { - dropdown.classList.remove('show'); - } - }); - - // 退出登录 - async function logout() { - try { - await fetch('/api/auth/logout', { method: 'POST' }); - currentUser = null; - updateUserDisplay(); - // 关闭下拉菜单 - const dropdown = document.getElementById('user-dropdown'); - if (dropdown) dropdown.classList.remove('show'); - } catch (err) { - console.error('登出失败:', err); - } - } - - // 获取用户信息 - async function getUserInfo(userId) { - if (USER_CACHE.has(userId)) { - return USER_CACHE.get(userId); - } - - try { - const res = await fetch(`https://phira.5wyxi.com/user/${userId}`); - if (!res.ok) return null; - const userData = await res.json(); - USER_CACHE.set(userId, userData); - return userData; - } catch (err) { - console.error('获取用户信息错误:', err); - return null; - } - } - - // 加载排行榜数据(分段:先加载首段并立即呈现,后台并发预取剩余) - async function loadLeaderboard() { - try { - // 首次只请求首段数据以尽快展示界面 - const initialLimit = leaderboardChunkSize; - const { chunk, total } = await fetchLeaderboardChunk(0, initialLimit); - - fullLeaderboardData = Array.isArray(chunk) ? chunk : []; - totalUsers = total || fullLeaderboardData.length; - leaderboardOffset = fullLeaderboardData.length; - hasMoreLeaderboardData = fullLeaderboardData.length < totalUsers; - - // 计算已加载部分的总游玩时间,并展示 - totalPlaytime = fullLeaderboardData.reduce((sum, user) => sum + user.total_playtime, 0); - updateTotalPlaytimeDisplay(); - - // 立即缓存首段的用户名(并发) - cacheAllUsernames(fullLeaderboardData.map(u => u.user_id)).catch(e => console.warn(e)); - - // 如果有搜索条件,先在已加载数据中搜索(后台会继续加载更多) - const searchInput = document.getElementById('search-input'); - if (searchInput.value.trim()) { - performSearch(searchInput.value.trim()); - } else { - renderLeaderboardPage(); - } - - // 更新加载进度显示 - updateLoadProgress(); - - // 异步在后台预取剩余段,不阻塞主线程 - prefetchRemainingLeaderboard().catch(e => console.warn('预取失败', e)); - - // 更新服务器状态 - const status = document.getElementById("status"); - status.textContent = "服务器状态:在线 :) "; - status.classList.add("online"); - status.classList.remove("offline"); - } catch (err) { - console.error('加载排行榜信息错误:', err); - const status = document.getElementById("status"); - status.textContent = "服务器状态:离线 :( "; - status.classList.add("offline"); - status.classList.remove("online"); - - const tbody = document.querySelector("#leaderboard-table tbody"); - tbody.innerHTML = "加载失败,请刷新页面重试"; - } - } - -// 缓存所有用户名(改进:支持传入部分ID并使用并发池) - async function cacheAllUsernames(userIds) { - // 如果没有传入 userIds,默认使用当前已知的排行榜数据 - const idsToCheck = Array.isArray(userIds) - ? userIds - : fullLeaderboardData.map(u => u.user_id); - - const uncachedUserIds = idsToCheck.filter(id => !USER_CACHE.has(id)); - if (uncachedUserIds.length === 0) return; - - console.log(`正在获取 ${uncachedUserIds.length} 个用户信息(并发 ${userFetchConcurrency})...`); - - const concurrency = userFetchConcurrency; - let index = 0; - while (index < uncachedUserIds.length) { - const batch = uncachedUserIds.slice(index, index + concurrency); - await Promise.allSettled(batch.map(uid => getUserInfo(uid))); - index += concurrency; - // 为了不对外部服务造成短时间内太多压力,短延迟一小段(可配置) - await new Promise(resolve => setTimeout(resolve, 30)); - } - } - -// 渲染排行榜当前页(快速首屏:立即渲染占位符,用户名异步填充) - function renderLeaderboardPage() { - const tbody = document.querySelector("#leaderboard-table tbody"); - const displayData = isSearching ? filteredLeaderboardData : fullLeaderboardData; - - if (!Array.isArray(displayData) || displayData.length === 0) { - if (isSearching) { - tbody.innerHTML = "没有找到匹配的用户"; - } else { - tbody.innerHTML = "暂无数据"; - } - updatePaginationControls(0); - return; - } - - // 计算当前页数据范围 - const startIndex = (currentPage - 1) * pageSize; - // 如果用户请求的页数据尚未加载完,则在后台尽量触发更多加载 - const endIndex = Math.min(startIndex + pageSize, displayData.length); - const pageData = displayData.slice(startIndex, endIndex); - - // 清空并渲染占位符(快速呈现) - tbody.innerHTML = ""; - for (let i = 0; i < pageData.length; i++) { - const user = pageData[i]; - const rank = isSearching ? - fullLeaderboardData.findIndex(u => u.user_id === user.user_id) + 1 : - startIndex + i + 1; - - const tr = document.createElement("tr"); - // 先渲染用户名占位符 - tr.innerHTML = ` - ${rank} - - ${formatPlaytime(user.total_playtime)} - `; - tbody.appendChild(tr); - - // 异步获取用户名并替换占位符(不阻塞其它行渲染) - (function(nameCell, uid){ - getUserInfo(uid).then(userInfo => { - const name = userInfo && userInfo.name ? userInfo.name : `用户${uid}`; - nameCell.innerHTML = ``; - }).catch(() => { - nameCell.innerHTML = ``; - }); - })(tr.cells[1], user.user_id); - } - - // 若当前页超出了已加载数据范围,触发后台预取更多 - const neededUntil = endIndex; - if (neededUntil > fullLeaderboardData.length - 3 && hasMoreLeaderboardData) { - // 触发预取以保证用户翻页不会等待太久 - prefetchRemainingLeaderboard().catch(e => console.warn('prefetch during render failed', e)); - } - - // 更新翻页控件状态 - const knownLength = totalUsers || fullLeaderboardData.length; - updatePaginationControls(knownLength); - } - - // 更新翻页控件状态 - function updatePaginationControls(dataLength) { - const maxPage = Math.ceil(dataLength / pageSize); - document.getElementById('page-info').textContent = `第 ${currentPage} 页 / 共 ${maxPage} 页`; - document.getElementById('prev-page').disabled = currentPage <= 1; - document.getElementById('next-page').disabled = currentPage >= maxPage; - } - - // 格式化游玩时间 - function formatPlaytime(seconds) { - const days = Math.floor(seconds / (3600 * 24)); - const hours = Math.floor((seconds % (3600 * 24)) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - let result = ''; - if (days > 0) result += `${days}天 `; - if (hours > 0) result += `${hours}小时 `; - if (minutes > 0) result += `${minutes}分 `; - result += `${secs}秒`; - - return result; - } - - // 等待页面资源加载完成的通用函数 - async function waitForAppReady() { - // 等待排行榜加载完成(确保 tbody 不再显示加载中)并等待文档内图片加载 - const maxWait = 3000; // 最长等待时间 - const start = Date.now(); - const tbody = document.querySelector('#leaderboard-table tbody'); - while (Date.now() - start < maxWait) { - if (tbody && tbody.innerHTML && !tbody.innerHTML.includes('加载中') && !tbody.innerHTML.includes('加')) break; - await new Promise(r => setTimeout(r, 80)); - } - - // 等待页面内图片加载完 - const imgs = Array.from(document.images); - await Promise.all(imgs.map(img => { - if (img.complete) return Promise.resolve(); - return new Promise(res => { img.addEventListener('load', res); img.addEventListener('error', res); }); - }).slice(0)); - - // 隐藏加载器并显示主内容 - const loader = document.getElementById('page-loader'); - const mainContent = document.getElementById('main-content'); - // 强制一次重绘并短延迟,避免毛玻璃效果在样式应用前闪烁 - if (mainContent) { - // 触发回流 - void mainContent.offsetHeight; - mainContent.style.opacity = 1; - } - if (loader) { - // 给主内容一点时间应用 backdrop-filter 等样式 - setTimeout(() => loader.classList.add('hide'), 120); - } - } - - // 安装表格倾斜触发 overlay:面积为表格面积两倍,中心一致 - function installTableTiltOverlay() { - const table = document.getElementById('leaderboard-table'); - if (!table) return; - // remove existing if any - const existing = document.getElementById('table-tilt-overlay'); - if (existing) existing.remove(); - - const rect = table.getBoundingClientRect(); - const overlay = document.createElement('div'); - overlay.id = 'table-tilt-overlay'; - // 计算面积扩大时的边长扩展(保留中心)——稍微放大 overlay 以便更容易触发,scale ~1.6 - const scale = 1.6; - const w = rect.width * scale; - const h = rect.height * scale; - overlay.style.width = w + 'px'; - overlay.style.height = h + 'px'; - overlay.style.left = (rect.left + (rect.width - w) / 2) + 'px'; - overlay.style.top = (rect.top + (rect.height - h) / 2) + 'px'; - overlay.style.position = 'fixed'; - // 不阻塞下面元素的点击:让 overlay 对指针事件透明,使用 window 的 mousemove 代替 - overlay.style.pointerEvents = 'none'; - overlay.style.background = 'transparent'; - document.body.appendChild(overlay); - - // 鼠标在 overlay 上时,表格倾斜,角度与鼠标到几何中心距离成正比 - // 使用 window mousemove 以允许点击穿透 overlay。 - // 若之前已安装 overlay,移除旧的全局监听 - if (existing && existing._handler) { - window.removeEventListener('mousemove', existing._handler); - } - - let isHovering = false; - function onGlobalMouseMove(e) { - // 当前位置是否在 overlay 的几何范围内 - const orect = overlay.getBoundingClientRect(); - const inside = e.clientX >= orect.left && e.clientX <= orect.right && e.clientY >= orect.top && e.clientY <= orect.bottom; - if (!inside) { - if (isHovering) { - isHovering = false; - table.classList.remove('tilting'); - table.style.transition = 'transform 0.6s cubic-bezier(.4,2,.3,1)'; - table.style.transform = 'translateZ(20px) rotateX(0deg) rotateY(0deg)'; - } - return; - } - isHovering = true; - const currentRect = table.getBoundingClientRect(); - const centerX = currentRect.left + currentRect.width / 2; - const centerY = currentRect.top + currentRect.height / 2; - const dx = e.clientX - centerX; - const dy = e.clientY - centerY; - const maxDist = Math.hypot(currentRect.width/2, currentRect.height/2); - const dist = Math.hypot(dx, dy); - const ratio = Math.min(1, dist / maxDist); - const maxAngle = 8; - const angle = ratio * maxAngle; - const rotateY = (dx / maxDist) * angle; - const rotateX = -(dy / maxDist) * angle; - table.classList.add('tilting'); - table.style.transform = `translateZ(20px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; - } - window.addEventListener('mousemove', onGlobalMouseMove); - // 保存 handler 以便重新安装前移除 - overlay._handler = onGlobalMouseMove; - - // 若窗口变化,重新计算 overlay - window.addEventListener('resize', () => { - const r = table.getBoundingClientRect(); - const w2 = r.width * scale; - const h2 = r.height * scale; - overlay.style.width = w2 + 'px'; - overlay.style.height = h2 + 'px'; - overlay.style.left = (r.left + (r.width - w2) / 2) + 'px'; - overlay.style.top = (r.top + (r.height - h2) / 2) + 'px'; - }); - // 页面滚动时也需要重新计算 overlay 位置 - window.addEventListener('scroll', () => { - const r = table.getBoundingClientRect(); - const w2 = r.width * scale; - const h2 = r.height * scale; - overlay.style.width = w2 + 'px'; - overlay.style.height = h2 + 'px'; - overlay.style.left = (r.left + (r.width - w2) / 2) + 'px'; - overlay.style.top = (r.top + (r.height - h2) / 2) + 'px'; - }, { passive: true }); - } - - function showLightbox(src) { - const lb = document.getElementById("lightbox"); - document.getElementById("lightbox-img").src = src; - lb.style.display = "flex"; - requestAnimationFrame(() => lb.classList.add("active")); - } - - function hideLightbox() { - const lb = document.getElementById("lightbox"); - lb.classList.remove("active"); - setTimeout(() => { - lb.style.display = "none"; - document.getElementById("lightbox-img").src = ""; - }, 300); - } - - function closeModal() { - const w = document.getElementById('user-window'); - if (w) w.close(); - } - - function openAuth() { - document.getElementById('auth-modal').style.display = 'flex'; - document.getElementById('auth-msg').textContent = ''; - updateAuthUI(); - } - - function closeAuth() { - document.getElementById('auth-modal').style.display = 'none'; - } - - function toggleAuthMode() { - authMode = authMode === 'login' ? 'register' : 'login'; - updateAuthUI(); - - // 显示/隐藏用户协议复选框 - const agreementContainer = document.getElementById('agreement-container'); - agreementContainer.style.display = authMode === 'register' ? 'block' : 'none'; - } - - function updateAuthUI() { - var title = document.getElementById('auth-title'); - var phiraid = document.getElementById('auth-phiraid'); - if (authMode === 'login') { - title.textContent = '用户登录'; - phiraid.classList.add('collapsed'); - } - else { - title.textContent = '用户注册'; - phiraid.classList.remove('collapsed'); - } - } - - async function submitAuth() { - const username = document.getElementById('auth-name').value; - const password = document.getElementById('auth-password').value; - const phira_id = document.getElementById('auth-phiraid').value; - const remember = document.getElementById('remember-me').checked; - const agreeTerms = authMode === 'register' ? document.getElementById('agree-terms').checked : true; - const msg = document.getElementById('auth-msg'); - msg.textContent = '处理中...'; - - try { - // 注册时需要确认用户协议 - if (authMode === 'register' && !agreeTerms) { - msg.textContent = '请同意用户协议'; - return; - } - - const endpoint = authMode === 'login' - ? '/api/auth/login' - : '/api/auth/users'; - - const payload = authMode === 'login' - ? { username, password, remember: remember } - : { username, password, phira_id }; - - const res = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(payload) - }); - - if (!res.ok) { - const errorData = await res.json(); - msg.textContent = errorData.message || '操作失败'; - return; - } - - // 登录成功后获取用户信息 - if (authMode === 'login') { - const userRes = await fetch('/api/auth/me'); - if (!userRes.ok) { - msg.textContent = '获取用户信息失败'; - return; - } - currentUser = await userRes.json(); - updateUserDisplay(); - } - - msg.textContent = authMode === 'login' ? '登录成功!' : '注册成功!'; - setTimeout(() => { - closeAuth(); - if (authMode === 'register') { - authMode = 'login'; - updateAuthUI(); - } - msg.textContent = ''; - }, 1000); - } catch (e) { - msg.textContent = '网络错误'; - console.error('认证错误:', e); - } - } - - // 尝试按偏移量/长度请求排行榜段(若后端不支持 offset/limit,则会退回到全量返回) - async function fetchLeaderboardChunk(offset, limit) { - try { - const res = await fetch(`/rankapi/playtime_leaderboard?offset=${offset}&limit=${limit}&_=${Date.now()}`, { method: 'GET', cache: 'no-store' }); - if (!res.ok) throw new Error('请求失败'); - const data = await res.json(); - if (!data.success) throw new Error('API返回失败'); - return { chunk: data.data || [], total: data.total_users }; - } catch (err) { - console.warn('分段请求失败,尝试退回全量请求', err); - // 退回到全量请求以保证兼容性 - const res = await fetch('/rankapi/playtime_leaderboard'); - if (!res.ok) throw err; - const data = await res.json(); - if (!data.success) throw new Error('API返回失败'); - return { chunk: data.data || [], total: data.total_users }; - } - } - - // 后台预取剩余的排行榜段,使用并发池 - async function prefetchRemainingLeaderboard() { - if (prefetching) return; - if (!hasMoreLeaderboardData) return; - prefetching = true; - try { - const total = totalUsers || 0; - const limit = leaderboardChunkSize; - let offset = leaderboardOffset; - - const workers = []; - while (offset < total) { - const thisOffset = offset; - offset += limit; - workers.push((async () => { - try { - const { chunk } = await fetchLeaderboardChunk(thisOffset, limit); - if (Array.isArray(chunk) && chunk.length) { - // append - fullLeaderboardData = fullLeaderboardData.concat(chunk); - leaderboardOffset = fullLeaderboardData.length; - // 更新总游玩时间(增量) - totalPlaytime += chunk.reduce((s, u) => s + u.total_playtime, 0); - updateTotalPlaytimeDisplay(); - // 并发缓存用户信息 - cacheAllUsernames(chunk.map(u => u.user_id)).catch(e => console.warn(e)); - // 更新加载进度 - updateLoadProgress(); - // 若用户当前页落在新数据内,尝试渲染最新 - renderLeaderboardPage(); - } - } catch (e) { - console.warn('预取段失败', e); - } - })()); - - // 当积累到并发上限时,等待已发起的请求完成后继续 - if (workers.length >= chunkFetchConcurrency) { - await Promise.allSettled(workers.splice(0)); - } - } - - // 等待剩余的工作完成 - if (workers.length) await Promise.allSettled(workers); - - hasMoreLeaderboardData = fullLeaderboardData.length >= total; - } finally { - prefetching = false; - } - } - - // 更新加载进度 UI(尽量不改动现有 DOM 结构,向 status 添加子项) - function updateLoadProgress() { - const status = document.getElementById('status'); - if (!status) return; - let progress = status.querySelector('.load-progress'); - if (!progress) { - progress = document.createElement('span'); - progress.className = 'load-progress'; - progress.style.marginLeft = '10px'; - progress.style.fontSize = '0.9em'; - progress.style.opacity = '0.9'; - status.appendChild(progress); - } - progress.textContent = `已加载 ${fullLeaderboardData.length}/${totalUsers || '?'} 条`; - } - - function updateTotalPlaytimeDisplay() { - const el = document.getElementById('total-playtime'); - if (el) el.textContent = `总游玩时间:${formatPlaytime(totalPlaytime)}`; - } - - // 简单的 HTML 转义(防止用户名包含特殊字符) - function escapeHtml(s) { - if (!s) return ''; - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } \ No newline at end of file diff --git a/js/room-history.js b/js/room-history.js deleted file mode 100644 index d20206f..0000000 --- a/js/room-history.js +++ /dev/null @@ -1,58 +0,0 @@ -// Room play-history window -(function(){ - async function openRoomHistory(roomId){ - if (!roomId) { showMessage && showMessage('错误','未提供房间标识'); return; } - - // load template - let tplText = ''; - try{ - const r = await fetch('./component/room-history.html'); - tplText = await r.text(); - }catch(e){ tplText = ''; } - const wrapper = document.createElement('div'); - wrapper.innerHTML = tplText; - const tpl = wrapper.querySelector('#room-history-template'); - if (!tpl) { showMessage && showMessage('错误','无法加载历史模板'); return; } - - const win = document.createElement('mac-window'); - win.style.setProperty('--width','680px'); - win.style.setProperty('--height','420px'); - document.body.appendChild(win); - - const content = tpl.content.cloneNode(true); - const container = document.createElement('div'); - container.appendChild(content); - win.appendChild(container); - - const meta = win.querySelector('#history-meta'); - const tbody = win.querySelector('#history-table tbody'); - if (meta) meta.textContent = `房间:${roomId}`; - - // try multiple endpoints - const candidates = [`/api/rooms/history/${encodeURIComponent(roomId)}`, `/api/rooms/history?room=${encodeURIComponent(roomId)}`]; - let data = null; - for (const url of candidates){ - try{ - const r = await fetch(url); - if (!r.ok) continue; - data = await r.json(); - break; - }catch(e){} - } - - if (!Array.isArray(data) || data.length === 0){ - tbody.innerHTML = '暂无历史记录'; - return; - } - - tbody.innerHTML = data.map(item => { - const player = item.player_name || item.player || item.user || '匿名'; - const chart = item.chart_name || item.chart || item.song || '未知'; - const score = item.score != null ? item.score : (item.result || '—'); - const t = item.time || item.ts || item.played_at || ''; - return `${player}${chart}${score}${t}`; - }).join(''); - } - - window.openRoomHistory = openRoomHistory; -})(); diff --git a/js/rooms.js b/js/rooms.js deleted file mode 100644 index bd4fcbc..0000000 --- a/js/rooms.js +++ /dev/null @@ -1,460 +0,0 @@ - // 默认头像URL - const DEFAULT_AVATAR = 'https://phira.moe/assets/user-6212ee95.png'; - const USER_CACHE = new Map(); - const CHART_CACHE = new Map(); - const STATE_MAP = { - 'SELECTING_CHART': { text: '选谱中', class: 'state-selecting' }, - 'WAITING_FOR_READY': { text: '准备中', class: 'state-ready' }, - 'PLAYING': { text: '游戏中', class: 'state-playing' } - }; - - let currentUser = null; - let authMode = 'login'; - let autoRefreshInterval; - let isHoveringTable = false; - let lastMousePosition = { x: 0, y: 0 }; - - // 页面加载时检查登录状态并初始化 - document.addEventListener('DOMContentLoaded', async () => { - // 扩大表格倾斜范围:全局鼠标不再驱动整个页面,而使用 overlay 驱动表格倾斜 - const roomsTable = document.getElementById('rooms-table'); - if (roomsTable) { - // 保持表格初始微倾斜 - roomsTable.style.transform = 'translateZ(20px) rotateX(0deg) rotateY(0deg)'; - } - - // 尝试获取当前会话 - try { - const response = await fetch('/api/auth/me'); - if (response.ok) { - currentUser = await response.json(); - updateUserDisplay(); - } - } catch (error) { - console.error('检查会话失败:', error); - } - - // 首次检查服务器状态并加载房间数据 - await checkServerStatus(); - await loadRooms(); - - // 等待资源完全准备好后再隐藏加载器 - await waitForAppReady(); - - // 安装倾斜 overlay(基于表格的实时几何中心,面积为两倍) - installTableTiltOverlay(); - - // 设置每3秒自动刷新 - autoRefreshInterval = setInterval(async () => { - await checkServerStatus(); - await loadRooms(); - }, 3000); - - // 兼容性:如果自定义的 .glass-checkbox 无法响应 click,使用事件委托做一次切换 - document.addEventListener('click', (e) => { - const box = e.target.closest && e.target.closest('.glass-checkbox'); - if (!box) return; - const input = box.querySelector('input[type="checkbox"]'); - if (!input) return; - // 若 .glass-checkbox 被 label 包裹,则浏览器已处理切换,避免重复切换 - if (box.closest('label')) return; - // 如果点击目标就是 input ,让浏览器正常处理;否则手动切换并派发 change - if (e.target === input) return; - input.checked = !input.checked; - input.dispatchEvent(new Event('change', { bubbles: true })); - }); - - // 当 checkbox 状态变化时,给容器添加/移除 .checked 以显示视觉打勾 - document.addEventListener('change', (e) => { - const input = e.target; - if (!input || input.type !== 'checkbox') return; - const box = input.closest && input.closest('.glass-checkbox'); - if (!box) return; - if (input.checked) box.classList.add('checked'); else box.classList.remove('checked'); - }); - }); - - // 获取用户信息 - async function getUserInfo(userId) { - if (USER_CACHE.has(userId)) { - return USER_CACHE.get(userId); - } - - try { - const res = await fetch(`https://phira.5wyxi.com/user/${userId}`); - if (!res.ok) return null; - const userData = await res.json(); - USER_CACHE.set(userId, userData); - return userData; - } catch (err) { - console.error('获取用户信息错误:', err); - return null; - } - } - - // 获取谱面信息 - async function getChartInfo(chartId) { - if (CHART_CACHE.has(chartId)) { - return CHART_CACHE.get(chartId); - } - - try { - const res = await fetch(`https://phira.5wyxi.com/chart/${chartId}`); - if (!res.ok) return null; - const chartData = await res.json(); - CHART_CACHE.set(chartId, chartData); - return chartData; - } catch (err) { - console.error('获取谱面信息错误:', err); - return null; - } - } - - // 加载房间数据 - async function loadRooms() { - try { - const res = await fetch('/api/rooms/info'); - if (!res.ok) throw new Error('获取房间信息失败'); - const rooms = await res.json(); - console.log('房间数据调试:', rooms); // 调试输出 - - const tbody = document.querySelector("#rooms-table tbody"); - if (!Array.isArray(rooms) || rooms.length === 0) { - tbody.innerHTML = "暂时无房间 加入我们的QQ群:1049578201"; - // 更新状态 - const status = document.getElementById("status"); - status.textContent = "服务器状态:在线 :)"; - status.classList.add("online"); - status.classList.remove("offline"); - return; - } - - // 清除现有内容 - tbody.innerHTML = ""; - - // 处理每个房间 - for (const room of rooms) { - if (!room || !room.host) { - console.warn('跳过无效房间:', room); - continue; - } - // 获取房主信息 - const hostInfo = await getUserInfo(room.host); - const hostName = hostInfo ? hostInfo.name : `用户${room.host}`; - // 获取谱面信息(如果有) - let chartText = "暂未选择"; - let chartImg = "无封面"; - let downloadBtn = "无文件"; - if (room.chart) { - const chartInfo = await getChartInfo(room.chart); - if (chartInfo) { - chartText = chartInfo.name - ? `${chartInfo.name}` - : `ID: ${room.chart}`; - chartImg = chartInfo.illustration - ? `` - : "无封面"; - downloadBtn = chartInfo.file - ? `` - : "无文件"; - } - } - // 状态显示 - const stateInfo = STATE_MAP[room.state] || { text: room.state, class: '' }; - const stateDisplay = `${stateInfo.text}`; - const hostBtn = ``; - const usersBtn = ``; - const roomIdVal = (room.id || room.name || '').toString().replace(/'/g, "\\'"); - const historyBtn = ``; - const tr = document.createElement("tr"); - tr.innerHTML = ` - ${room.name} - ${hostBtn} - ${room.users.length}/100 - ${stateDisplay} - ${room.cycle ? "是" : "否"} - ${chartText} - ${chartImg} - ${downloadBtn} - ${usersBtn} - ${historyBtn} - `; - tbody.appendChild(tr); - } - - // 更新服务器状态 - const status = document.getElementById("status"); - status.textContent = "服务器状态:在线 :)"; - status.classList.add("online"); - status.classList.remove("offline"); - } catch (err) { - console.error('加载房间信息错误:', err); - const status = document.getElementById("status"); - status.textContent = "服务器状态:离线 :("; - status.classList.add("offline"); - status.classList.remove("online"); - } - } - - // 等待页面资源与 rooms 数据加载完成的通用函数 - async function waitForAppReady() { - // 等待 rooms 加载完成(确保 tbody 不再显示加载中)并等待文档内图片加载 - const maxWait = 3000; // 最长等待时间 - const start = Date.now(); - const tbody = document.querySelector('#rooms-table tbody'); - while (Date.now() - start < maxWait) { - if (tbody && tbody.innerHTML && !tbody.innerHTML.includes('加载中') && !tbody.innerHTML.includes('加')) break; - await new Promise(r => setTimeout(r, 80)); - } - - // 等待页面内图片加载完(例如来自 chartInfo 的 illustration) - const imgs = Array.from(document.images); - await Promise.all(imgs.map(img => { - if (img.complete) return Promise.resolve(); - return new Promise(res => { img.addEventListener('load', res); img.addEventListener('error', res); }); - }).slice(0)); - - // 隐藏加载器并显示主内容 - const loader = document.getElementById('page-loader'); - const mainContent = document.getElementById('main-content'); - if (mainContent) { - mainContent.style.opacity = 1; - } - if (loader) { - setTimeout(() => { - loader.classList.add('hide'); - // 确保在淡出时不再阻挡指针事件 - loader.style.pointerEvents = 'none'; - // 在过渡结束后彻底移除(display:none)以避免长期覆盖其它元素 - const onTransitionEnd = () => { - loader.style.display = 'none'; - loader.removeEventListener('transitionend', onTransitionEnd); - }; - loader.addEventListener('transitionend', onTransitionEnd); - }, 120); - } - } - - // 安装表格倾斜触发 overlay:面积为表格面积两倍,中心一致 - function installTableTiltOverlay() { - const table = document.getElementById('rooms-table'); - if (!table) return; - // remove existing if any - const existing = document.getElementById('table-tilt-overlay'); - if (existing) existing.remove(); - - const rect = table.getBoundingClientRect(); - const overlay = document.createElement('div'); - overlay.id = 'table-tilt-overlay'; - // 计算面积扩大时的边长扩展(保留中心)——稍微放大 overlay 以便更容易触发,scale ~1.6 - const scale = 1.6; - const w = rect.width * scale; - const h = rect.height * scale; - overlay.style.width = w + 'px'; - overlay.style.height = h + 'px'; - overlay.style.left = (rect.left + (rect.width - w) / 2) + 'px'; - overlay.style.top = (rect.top + (rect.height - h) / 2) + 'px'; - overlay.style.position = 'fixed'; - // 不阻塞下面元素的点击:让 overlay 对指针事件透明,使用 window 的 mousemove 代替 - overlay.style.pointerEvents = 'none'; - overlay.style.background = 'transparent'; - document.body.appendChild(overlay); - - // 鼠标在 overlay 上时,表格倾斜,角度与鼠标到几何中心距离成正比 - // 使用 window mousemove 以允许点击穿透 overlay。 - // 若之前已安装 overlay,移除旧的全局监听 - if (existing && existing._handler) { - window.removeEventListener('mousemove', existing._handler); - } - - let isHovering = false; - function onGlobalMouseMove(e) { - // 当前位置是否在 overlay 的几何范围内 - const orect = overlay.getBoundingClientRect(); - const inside = e.clientX >= orect.left && e.clientX <= orect.right && e.clientY >= orect.top && e.clientY <= orect.bottom; - if (!inside) { - if (isHovering) { - isHovering = false; - table.classList.remove('tilting'); - table.style.transition = 'transform 0.6s cubic-bezier(.4,2,.3,1)'; - table.style.transform = 'translateZ(20px) rotateX(0deg) rotateY(0deg)'; - } - return; - } - isHovering = true; - const currentRect = table.getBoundingClientRect(); - const centerX = currentRect.left + currentRect.width / 2; - const centerY = currentRect.top + currentRect.height / 2; - const dx = e.clientX - centerX; - const dy = e.clientY - centerY; - const maxDist = Math.hypot(currentRect.width/2, currentRect.height/2); - const dist = Math.hypot(dx, dy); - const ratio = Math.min(1, dist / maxDist); - const maxAngle = 8; - const angle = ratio * maxAngle; - const rotateY = (dx / maxDist) * angle; - const rotateX = -(dy / maxDist) * angle; - table.classList.add('tilting'); - table.style.transform = `translateZ(20px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; - } - window.addEventListener('mousemove', onGlobalMouseMove); - // 保存 handler 以便重新安装前移除 - overlay._handler = onGlobalMouseMove; - - // 若窗口变化,重新计算 overlay - window.addEventListener('resize', () => { - const r = table.getBoundingClientRect(); - const w2 = r.width * scale; - const h2 = r.height * scale; - overlay.style.width = w2 + 'px'; - overlay.style.height = h2 + 'px'; - overlay.style.left = (r.left + (r.width - w2) / 2) + 'px'; - overlay.style.top = (r.top + (r.height - h2) / 2) + 'px'; - }); - // 页面滚动时也需要重新计算 overlay 位置 - window.addEventListener('scroll', () => { - const r = table.getBoundingClientRect(); - const w2 = r.width * scale; - const h2 = r.height * scale; - overlay.style.width - overlay.style.top = (r.top + (r.height - h2) / 2) + 'px'; - }, { passive: true }); - } - - // 显示房间用户 - async function showRoomUsers(userIds, hostId) { - const win = document.getElementById('user-window'); - const list = document.getElementById('user-list'); - if (!win || !list) return; - - // 显示加载中并打开窗口 - list.innerHTML = '
  • 加载中...
  • '; - win.open(); - - // 获取所有用户详细信息 - const users = []; - for (const userId of userIds) { - const userInfo = await getUserInfo(userId); - if (userInfo) { - users.push({ id: userId, name: userInfo.name, isHost: userId === hostId }); - } - } - - // 更新用户列表 - list.innerHTML = users.map(user => `
  • ${user.name || `用户${user.id}`}
  • `).join(""); - } - - function showLightbox(src) { - const lb = document.getElementById("lightbox"); - document.getElementById("lightbox-img").src = src; - lb.style.display = "flex"; - requestAnimationFrame(() => lb.classList.add("active")); - } - - function hideLightbox() { - const lb = document.getElementById("lightbox"); - lb.classList.remove("active"); - setTimeout(() => { - lb.style.display = "none"; - document.getElementById("lightbox-img").src = ""; - }, 300); - } - - function closeModal() { - const w = document.getElementById('user-window'); - if (w) w.close(); - } - - function openAuth() { - document.getElementById('auth-modal').style.display = 'flex'; - document.getElementById('auth-msg').textContent = ''; - updateAuthUI(); - } - - function closeAuth() { - document.getElementById('auth-modal').style.display = 'none'; - } - - function toggleAuthMode() { - authMode = authMode === 'login' ? 'register' : 'login'; - updateAuthUI(); - - // 显示/隐藏用户协议复选框 - const agreementContainer = document.getElementById('agreement-container'); - agreementContainer.style.display = authMode === 'register' ? 'flex' : 'none'; - } - - function updateAuthUI() { - var title = document.getElementById('auth-title'); - var phiraid = document.getElementById('auth-phiraid'); - if (authMode === 'login') { - title.textContent = '用户登录'; - phiraid.classList.add('collapsed'); - } - else { - title.textContent = '用户注册'; - phiraid.classList.remove('collapsed'); - } - } - - async function submitAuth() { - const username = document.getElementById('auth-name').value; - const password = document.getElementById('auth-password').value; - const phira_id = document.getElementById('auth-phiraid').value; - const remember = document.getElementById('remember-me').checked; - const agreeTerms = authMode === 'register' ? document.getElementById('agree-terms').checked : true; - const msg = document.getElementById('auth-msg'); - msg.textContent = '处理中...'; - - try { - // 注册时需要确认用户协议 - if (authMode === 'register' && !agreeTerms) { - msg.textContent = '请同意用户协议'; - return; - } - - const endpoint = authMode === 'login' - ? '/api/auth/login' - : '/api/auth/users'; - - const payload = authMode === 'login' - ? { username, password, remember: remember } - : { username, password, phira_id }; - - const res = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(payload) - }); - - if (!res.ok) { - const errorData = await res.json(); - msg.textContent = errorData.message || '操作失败'; - return; - } - - // 登录成功后获取用户信息 - if (authMode === 'login') { - const userRes = await fetch('/api/auth/me'); - if (!userRes.ok) { - msg.textContent = '获取用户信息失败'; - return; - } - currentUser = await userRes.json(); - updateUserDisplay(); - } - - msg.textContent = authMode === 'login' ? '登录成功!' : '注册成功!'; - setTimeout(() => { - closeAuth(); - if (authMode === 'register') { - authMode = 'login'; - updateAuthUI(); - } - msg.textContent = ''; - }, 1000); - } catch (e) { - msg.textContent = '网络错误'; - console.error('认证错误:', e); - } - } \ No newline at end of file diff --git a/js/table-component.js b/js/table-component.js deleted file mode 100644 index 8f2baf9..0000000 --- a/js/table-component.js +++ /dev/null @@ -1,57 +0,0 @@ -// Shared table helper: adds touch-friendly horizontal scroll behavior -// and optional sticky header handling. Auto-initializes tables with class -// "responsive-table". -(function(){ - function initTable(t){ - if (t.__tableComponentInit) return; t.__tableComponentInit = true; - // make sure the table uses block display for native horizontal scroll - t.style.display = 'block'; - t.style.overflowX = 'auto'; - t.style.webkitOverflowScrolling = 'touch'; - - // add wheel-to-scroll-on-x for desktop mousewheel when shift not held - t.addEventListener('wheel', function(e){ - if (Math.abs(e.deltaX) < Math.abs(e.deltaY)){ - // translate vertical wheel to horizontal scroll for convenience - t.scrollLeft += e.deltaY; - e.preventDefault(); - } - }, { passive: false }); - - // make header visually sticky by cloning header row into a fixed element if desired - // keep minimal: add shadow when scrolled horizontally to hint overflow - function updateShadow(){ - if (t.scrollLeft > 0) t.style.boxShadow = 'inset 8px 0 12px -8px rgba(0,0,0,0.4)'; - else t.style.boxShadow = ''; - } - t.addEventListener('scroll', updateShadow); - updateShadow(); - - // accessibility: expose keyboard horizontal navigation - t.setAttribute('tabindex','0'); - t.addEventListener('keydown', function(e){ - if (e.key === 'ArrowRight') { t.scrollLeft += 60; e.preventDefault(); } - if (e.key === 'ArrowLeft') { t.scrollLeft -= 60; e.preventDefault(); } - if (e.key === 'Home') { t.scrollLeft = 0; } - if (e.key === 'End') { t.scrollLeft = t.scrollWidth; } - }); - } - - function scan(){ - document.querySelectorAll('table.responsive-table').forEach(initTable); - // enable marquee for long scrolling-btn elements - setTimeout(()=>{ - document.querySelectorAll('.scrolling-btn').forEach(el=>{ - try{ - const span = el.querySelector('span'); - if (span && span.scrollWidth > el.clientWidth) el.classList.add('marquee'); - }catch(e){} - }); - }, 120); - } - - if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', scan); else scan(); - - // expose for manual init - window.HSNTable = { init: initTable }; -})(); diff --git a/js/top.js b/js/top.js deleted file mode 100644 index 222f1a3..0000000 --- a/js/top.js +++ /dev/null @@ -1,473 +0,0 @@ - // 默认头像URL - const DEFAULT_AVATAR = 'https://phira.moe/assets/user-6212ee95.png'; - const CHART_CACHE = new Map(); - const API_BASE_URL = 'https://phira.htadiy.cc/topchart/hot_rank'; - - // 排行榜数据缓存 - let chartDataCache = { - hour: null, - day: null, - week: null, - month: null - }; - - let currentUser = null; - let authMode = 'login'; - let autoRefreshInterval; - - // 热门谱面相关状态 - let currentTimeRange = 'hour'; - let currentPage = 1; - let perPage = 10; - let totalResults = 0; - let lastChartListUpdate = null; - let lastRecordUpdate = null; - let usingCachedData = false; - - // 页面加载时检查登录状态并初始化 - document.addEventListener('DOMContentLoaded', async () => { - // 尝试获取当前会话 - try { - const response = await fetch('/api/auth/me'); - if (response.ok) { - currentUser = await response.json(); - updateUserDisplay(); - } - } catch (error) { - console.error('检查会话失败:', error); - } - - // 首次检查服务器状态并加载热门谱面数据 - await checkServerStatus(); - await loadHotCharts(); - - // 等待资源完全准备好后再隐藏加载器 - await waitForAppReady(); - - // 设置每30秒自动刷新 - autoRefreshInterval = setInterval(async () => { - await checkServerStatus(); - await loadHotCharts(); - }, 30000); - - // 添加时间范围按钮事件监听 - document.querySelectorAll('.time-range-btn').forEach(btn => { - btn.addEventListener('click', () => { - const range = btn.dataset.range; - if (range !== currentTimeRange) { - // 更新活动按钮 - document.querySelectorAll('.time-range-btn').forEach(b => { - b.classList.remove('active'); - }); - btn.classList.add('active'); - - // 更新当前时间范围并重新加载数据 - currentTimeRange = range; - currentPage = 1; - loadHotCharts(); - } - }); - }); - - // 添加分页按钮事件监听 - document.getElementById('prev-page').addEventListener('click', () => { - if (currentPage > 1) { - currentPage--; - loadHotCharts(); - } - }); - - document.getElementById('next-page').addEventListener('click', () => { - const totalPages = Math.ceil(totalResults / perPage); - if (currentPage < totalPages) { - currentPage++; - loadHotCharts(); - } - }); - }); - - - // 获取谱面信息 - async function getChartInfo(chartId) { - if (CHART_CACHE.has(chartId)) { - return CHART_CACHE.get(chartId); - } - - try { - const res = await fetch(`https://phira.5wyxi.com/chart/${chartId}`); - if (!res.ok) return null; - const chartData = await res.json(); - CHART_CACHE.set(chartId, chartData); - return chartData; - } catch (err) { - console.error('获取谱面信息错误:', err); - return null; - } - } - - // 加载热门谱面数据 - async function loadHotCharts() { - try { - const url = `${API_BASE_URL}/${currentTimeRange}?page=${currentPage}&per_page=${perPage}`; - const res = await fetch(url); - if (!res.ok) throw new Error('获取热门谱面失败'); - const data = await res.json(); - - // 检查API返回的数据是否为空 - if (data.results && data.results.length > 0) { - // 数据有效,更新全局状态 - lastChartListUpdate = data.last_chart_list_update; - lastRecordUpdate = data.last_record_update; - totalResults = data.total_results; - currentPage = data.page; - perPage = data.per_page; - - // 更新缓存 - chartDataCache[currentTimeRange] = { - data: data, - timestamp: new Date().toISOString() - }; - - // 更新状态显示 - const updateTime = new Date(lastRecordUpdate) - .toLocaleTimeString('zh-CN', { timeZone: 'Asia/Shanghai' }); - status.textContent = `服务器状态:在线 :) 数据更新时间:${updateTime}`; - status.classList.add("online"); - status.classList.remove("offline"); - status.classList.remove("cached"); - - // 隐藏缓存提示 - document.getElementById('cache-info').style.display = 'none'; - usingCachedData = false; - - // 渲染表格 - renderChartsTable(data.results); - } else { - // API返回了空数据,尝试使用缓存数据 - useCachedData(); - } - } catch (err) { - console.error('加载热门谱面错误:', err); - // 尝试使用缓存数据 - useCachedData(); - } - } - - // 使用缓存数据 - function useCachedData() { - if (chartDataCache[currentTimeRange] && chartDataCache[currentTimeRange].data) { - const cachedData = chartDataCache[currentTimeRange].data; - const cacheTime = new Date(chartDataCache[currentTimeRange].timestamp).toLocaleTimeString(); - - // 更新状态显示为缓存数据 - const status = document.getElementById("status"); - status.textContent = "服务器状态:在线 :) 加入我们的QQ群:1049578201"; - status.classList.add("cached"); - status.classList.remove("online"); - status.classList.remove("offline"); - - // 显示缓存提示 - const cacheInfo = document.getElementById('cache-info'); - cacheInfo.textContent = `数据更新时间:${cacheTime}`; - cacheInfo.style.display = 'block'; - - // 渲染缓存数据 - renderChartsTable(cachedData.results); - usingCachedData = true; - } else { - // 没有缓存数据时显示错误 - const status = document.getElementById("status"); - status.textContent = "服务器状态:离线 :("; - status.classList.add("offline"); - status.classList.remove("online"); - status.classList.remove("cached"); - - const tbody = document.querySelector("#charts-table tbody"); - tbody.innerHTML = "加载失败,请稍后重试"; - } - } - - // 渲染谱面表格 - async function renderChartsTable(results) { - const tbody = document.querySelector("#charts-table tbody"); - if (!Array.isArray(results) || results.length === 0) { - tbody.innerHTML = "暂时无数据"; - return; - } - - // 清除现有内容 - tbody.innerHTML = ""; - - // 处理每个谱面 - for (let i = 0; i < results.length; i++) { - const chart = results[i]; - const rank = (currentPage - 1) * perPage + i + 1; - - // 获取谱面信息 - const chartInfo = await getChartInfo(chart.chart_id); - - // 构建表格行 - const tr = document.createElement("tr"); - - // 名次列 - const rankTd = document.createElement("td"); - rankTd.textContent = rank; - tr.appendChild(rankTd); - - // 谱面名称列 - const nameTd = document.createElement("td"); - if (chartInfo && chartInfo.name) { - const nameBtn = document.createElement("a"); - nameBtn.href = `https://phira.moe/chart/${chart.chart_id}`; - nameBtn.target = "_blank"; - nameBtn.className = "chart-name-btn scrolling-btn"; - const innerSpan = document.createElement('span'); - innerSpan.textContent = chartInfo.name; - nameBtn.appendChild(innerSpan); - // if text overflows, add marquee class to animate - setTimeout(()=>{ - try{ - if (innerSpan.scrollWidth > nameBtn.clientWidth) nameBtn.classList.add('marquee'); - }catch(e){} - }, 120); - nameTd.appendChild(nameBtn); - } else { - nameTd.textContent = `ID: ${chart.chart_id}`; - } - tr.appendChild(nameTd); - - // 谱面ID列 - const idTd = document.createElement("td"); - const idSpan = document.createElement("span"); - idSpan.textContent = chart.chart_id; - idSpan.style.marginRight = "0.5rem"; - idSpan.style.fontFamily = "monospace"; - idSpan.style.fontWeight = "bold"; - idSpan.style.color = "#61E8EA"; - - const copyBtn = document.createElement("button"); - copyBtn.className = "chart-btn"; - copyBtn.textContent = "复制"; - copyBtn.onclick = () => copyChartId(chart.chart_id); - - idTd.appendChild(idSpan); - idTd.appendChild(copyBtn); - tr.appendChild(idTd); - - // 游玩人数列 - const playersTd = document.createElement("td"); - playersTd.textContent = chart.increase; - tr.appendChild(playersTd); - - // 曲绘列 - 修复变量名错误 - const coverTd = document.createElement("td"); - if (chartInfo && chartInfo.illustration) { - const coverBtn = document.createElement("button"); // 修复变量名 - coverBtn.className = "chart-btn"; - coverBtn.textContent = "查看"; - coverBtn.onclick = () => showLightbox(chartInfo.illustration); - coverTd.appendChild(coverBtn); - } else { - coverTd.textContent = "无封面"; - } - tr.appendChild(coverTd); - - // 下载列 - const downloadTd = document.createElement("td"); - if (chartInfo && chartInfo.file) { - const downloadLink = document.createElement('a'); - downloadLink.href = chartInfo.file; - // use chart name if available, sanitize filename - const safeName = (chartInfo.name || `chart_${chart.chart_id}`).replace(/[^\w\-\u4e00-\u9fa5\. ]+/g, '').trim(); - downloadLink.download = `${safeName || 'chart_' + chart.chart_id}.zip`; - const downloadBtn = document.createElement('button'); - downloadBtn.className = "dl-btn"; - downloadBtn.textContent = "下载"; - downloadLink.appendChild(downloadBtn); - downloadTd.appendChild(downloadLink); - } else { - downloadTd.textContent = "无文件"; - } - tr.appendChild(downloadTd); - - tbody.appendChild(tr); - } - - // 更新分页控件 - updatePaginationControls(); - } - - // 复制谱面ID(前置#) - function copyChartId(chartId) { - const idText = '#' + String(chartId); - navigator.clipboard.writeText(idText) - .then(() => { - if (window.showMessage) { - window.showMessage('已复制', idText, ''); - } else { - const notification = document.getElementById('copy-notification'); - if (notification) { - notification.textContent = `谱面ID ${idText} 已复制到剪贴板!`; - notification.classList.add('show'); - setTimeout(() => notification.classList.remove('show'), 2000); - } - } - }) - .catch(err => { - console.error('复制失败:', err); - alert('复制失败,请手动复制谱面ID'); - }); - } - - // 更新分页控件状态 - function updatePaginationControls() { - const prevBtn = document.getElementById('prev-page'); - const nextBtn = document.getElementById('next-page'); - const pageInfo = document.getElementById('page-info'); - - const totalPages = Math.ceil(totalResults / perPage); - - // 更新页面信息 - pageInfo.textContent = `第 ${currentPage} 页 / 共 ${totalPages} 页`; - - // 更新按钮状态 - prevBtn.disabled = currentPage <= 1; - nextBtn.disabled = currentPage >= totalPages; - } - - // 等待页面资源加载完成的通用函数 - async function waitForAppReady() { - // 等待表格加载完成 - const maxWait = 3000; // 最长等待时间 - const start = Date.now(); - const tbody = document.querySelector('#charts-table tbody'); - while (Date.now() - start < maxWait) { - if (tbody && tbody.innerHTML && !tbody.innerHTML.includes('加载中') && !tbody.innerHTML.includes('加')) break; - await new Promise(r => setTimeout(r, 80)); - } - - // 隐藏加载器并显示主内容 - const loader = document.getElementById('page-loader'); - const mainContent = document.getElementById('main-content'); - if (mainContent) { - mainContent.style.opacity = 1; - } - if (loader) { - setTimeout(() => loader.classList.add('hide'), 120); - } - } - - function showLightbox(src) { - const lb = document.getElementById("lightbox"); - document.getElementById("lightbox-img").src = src; - lb.style.display = "flex"; - requestAnimationFrame(() => lb.classList.add("active")); - } - - function hideLightbox() { - const lb = document.getElementById("lightbox"); - lb.classList.remove("active"); - setTimeout(() => { - lb.style.display = "none"; - document.getElementById("lightbox-img").src = ""; - }, 300); - } - - function closeModal() { - const w = document.getElementById('user-window'); - if (w) w.close(); - } - - function openAuth() { - document.getElementById('auth-modal').style.display = 'flex'; - document.getElementById('auth-msg').textContent = ''; - updateAuthUI(); - } - - function closeAuth() { - document.getElementById('auth-modal').style.display = 'none'; - } - - function toggleAuthMode() { - authMode = authMode === 'login' ? 'register' : 'login'; - updateAuthUI(); - - // 显示/隐藏用户协议复选框 - const agreementContainer = document.getElementById('agreement-container'); - agreementContainer.style.display = authMode === 'register' ? 'flex' : 'none'; - } - - function updateAuthUI() { - var title = document.getElementById('auth-title'); - var phiraid = document.getElementById('auth-phiraid'); - if (authMode === 'login') { - title.textContent = '用户登录'; - phiraid.classList.add('collapsed'); - } - else { - title.textContent = '用户注册'; - phiraid.classList.remove('collapsed'); - } - } - - async function submitAuth() { - const username = document.getElementById('auth-name').value; - const password = document.getElementById('auth-password').value; - const phira_id = document.getElementById('auth-phiraid').value; - const remember = document.getElementById('remember-me').checked; - const agreeTerms = authMode === 'register' ? document.getElementById('agree-terms').checked : true; - const msg = document.getElementById('auth-msg'); - msg.textContent = '处理中...'; - - try { - // 注册时需要确认用户协议 - if (authMode === 'register' && !agreeTerms) { - msg.textContent = '请同意用户协议'; - return; - } - - const endpoint = authMode === 'login' - ? '/api/auth/login' - : '/api/auth/users'; - - const payload = authMode === 'login' - ? { username, password, remember: remember } - : { username, password, phira_id }; - - const res = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(payload) - }); - - if (!res.ok) { - const errorData = await res.json(); - msg.textContent = errorData.message || '操作失败'; - return; - } - - // 登录成功后获取用户信息 - if (authMode === 'login') { - const userRes = await fetch('/api/auth/me'); - if (!userRes.ok) { - msg.textContent = '获取用户信息失败'; - return; - } - currentUser = await userRes.json(); - updateUserDisplay(); - } - - msg.textContent = authMode === 'login' ? '登录成功!' : '注册成功!'; - setTimeout(() => { - closeAuth(); - if (authMode === 'register') { - authMode = 'login'; - updateAuthUI(); - } - msg.textContent = ''; - }, 1000); - } catch (e) { - msg.textContent = '网络错误'; - console.error('认证错误:', e); - } - } \ No newline at end of file diff --git a/js/users_manage.js b/js/users_manage.js deleted file mode 100644 index 3b1b5db..0000000 --- a/js/users_manage.js +++ /dev/null @@ -1,510 +0,0 @@ - // 全局变量 - let allUsers = []; - let currentUser = null; - // 用户组管理相关 - let allGroups = []; - // 管理员密码(用于批量接口验证),在 verifyAdminPassword() 验证后设置 - let adminPassword = ''; - - // 等待页面关键内容与资源就绪(用户/用户组渲染 & 图片加载) - async function waitForAppReady(maxWait = 5000) { - const start = Date.now(); - const usersBody = () => document.getElementById('users-table-body'); - const groupsList = () => document.getElementById('groups-list'); - - function imagesLoaded() { - const imgs = Array.from(document.images || []); - return imgs.length === 0 || imgs.every(i => i.complete); - } - - while (Date.now() - start < maxWait) { - const usersReady = usersBody() && usersBody().children.length > 0; - const groupsReady = groupsList() && groupsList().children.length > 0; - if ((usersReady || groupsReady) && imagesLoaded()) return; - // small delay and retry - // eslint-disable-next-line no-await-in-loop - await new Promise(r => setTimeout(r, 80)); - } - } - - // 页面初始化:确保数据加载并在资源就绪后再隐藏加载器 - document.addEventListener('DOMContentLoaded', async () => { - const loader = document.getElementById('page-loader'); - loader.classList.remove('hide'); - - // 获取当前用户信息 - try { - const res = await fetch('/api/auth/me'); - if (!res.ok) throw new Error('未登录'); - currentUser = await res.json(); - updateUserDisplay(); - } catch (e) { - alert('请先登录'); - window.location.href = 'index.html'; - return; - } - - // 加载用户列表与用户组列表 - await Promise.all([loadAllUsers(), loadAllGroups()]); - - // 等待页面主要内容与图片渲染完成再淡出加载器 - await waitForAppReady(5000); - // 给 CSS 过渡一点时间 - setTimeout(() => loader.classList.add('hide'), 80); - }); - - // 加载所有用户数据 - async function loadAllUsers() { - try { - const res = await fetch('/api/auth/users'); - if (!res.ok) throw new Error('加载用户失败'); - allUsers = await res.json(); - document.getElementById('total-users').textContent = allUsers.length; - renderUsersTable(allUsers); - } catch (e) { - showAdminMessage('加载用户失败', false); - } - } - - // 渲染用户表格(兼容新API) - function renderUsersTable(users) { - const tableBody = document.getElementById('users-table-body'); - tableBody.innerHTML = ''; - const searchName = document.getElementById('search-name').value.toLowerCase(); - const searchPhira = document.getElementById('search-phira').value.toLowerCase(); - // 过滤用户 - const filteredUsers = users.filter(user => { - const nameMatch = (user.username || '').toLowerCase().includes(searchName); - const phiraMatch = (String(user.phira_id) || '').toLowerCase().includes(searchPhira); - return nameMatch && phiraMatch; - }); - filteredUsers.forEach(user => { - const row = document.createElement('tr'); - row.className = 'user-row'; - row.innerHTML = ` - - ${user.id} - ${user.username} - - ${user.phira_id || '-'} - ${user.phira_username || '-'} - ${user.phira_rks ? parseFloat(user.phira_rks).toFixed(2) : '0.00'} - - ${user.group_id === 1 ? '超级管理员' : ''} - ${user.group_id === 2 ? '管理员' : ''} - ${(user.group_id === 1 || user.group_id === 2) ? '开发者' : ''} - - - - - - `; - tableBody.appendChild(row); - }); - } - - // 打开编辑用户模态框(兼容新API) - function openEditModal(userId) { - const user = allUsers.find(u => u.id === userId); - if (!user) return; - document.getElementById('edit-user-id').value = user.id; - document.getElementById('edit-username').value = user.username; - document.getElementById('edit-phira-id').value = user.phira_id || ''; - document.getElementById('edit-admin').value = (user.group_id === 1 || user.group_id === 2) ? 'yes' : 'no'; - document.getElementById('edit-dev').value = (user.group_id === 1 || user.group_id === 2) ? 'yes' : 'no'; - document.getElementById('edit-message').style.display = 'none'; - document.getElementById('edit-modal').style.display = 'flex'; - } - - // 关闭编辑用户模态框 - function closeEditModal() { - document.getElementById('edit-modal').style.display = 'none'; - } - - // 切换密码显示 - function togglePasswordDisplay() { - const display = document.getElementById('password-display'); - if (display.textContent === '************') { - display.textContent = display.dataset.original; - } else { - display.textContent = '************'; - } - } - - // 保存用户更改(兼容新API,增加current_password) - async function saveUserChanges() { - const userId = parseInt(document.getElementById('edit-user-id').value); - const username = document.getElementById('edit-username').value; - const password = document.getElementById('edit-password').value; - const phiraId = document.getElementById('edit-phira-id').value; - const admin = document.getElementById('edit-admin').value; - const dev = document.getElementById('edit-dev').value; - const current_password = document.getElementById('edit-current-password').value; - let group_id = 3; - if (admin === 'yes') group_id = 2; - if (dev === 'yes') group_id = 2; - if (admin === 'yes' && dev === 'yes') group_id = 1; - const changes = { username, phira_id: phiraId, group_id, current_password }; - if (password) changes.password = password; - try { - const res = await fetch(`/api/auth/users/${userId}`, { - method: 'PATCH', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(changes) - }); - if (!res.ok) throw new Error('更新失败'); - showEditMessage('用户信息更新成功', true); - setTimeout(() => { - closeEditModal(); - loadAllUsers(); - }, 1500); - } catch (e) { - showEditMessage('更新失败', false); - } - } - // 删除用户(新API) - async function deleteUser(userId) { - if (!confirm('确定要删除该用户吗?')) return; - try { - const res = await fetch(`/api/auth/users/${userId}`, { method: 'DELETE' }); - if (!res.ok) throw new Error('删除失败'); - showAdminMessage('用户已删除', true); - loadAllUsers(); - } catch (e) { - showAdminMessage('删除失败', false); - } - } - - // 显示编辑消息 - function showEditMessage(message, isSuccess) { - const messageElement = document.getElementById('edit-message'); - messageElement.textContent = message; - messageElement.className = `message ${isSuccess ? 'message-success' : 'message-error'}`; - messageElement.style.display = 'block'; - } - - // 全选用户 - function selectAllUsers() { - document.querySelectorAll('.user-select').forEach(checkbox => { - checkbox.checked = true; - }); - } - - // 取消全选 - function deselectAllUsers() { - document.querySelectorAll('.user-select').forEach(checkbox => { - checkbox.checked = false; - }); - } - - // 应用批量操作 - function applyBatchAction() { - const action = document.getElementById('batch-action').value; - const selectedUserIds = []; - - document.querySelectorAll('.user-select:checked').forEach(checkbox => { - if (checkbox.id !== 'select-all') { - selectedUserIds.push(parseInt(checkbox.dataset.id)); - } - }); - - if (selectedUserIds.length === 0) { - showAdminMessage('请选择至少一个用户', false); - return; - } - - // 确定要应用的更改 - let changes = {}; - switch (action) { - case 'set_admin': - changes.admin = 'yes'; - break; - case 'remove_admin': - changes.admin = 'no'; - break; - case 'set_dev': - changes.dev = 'yes'; - break; - case 'remove_dev': - changes.dev = 'no'; - break; - } - - // 如果尚未输入管理员密码,弹出管理员密码模态框要求验证 - if (!adminPassword) { - // 打开管理员密码模态框,验证后回调继续操作 - document.getElementById('admin-password-modal').style.display = 'flex'; - document.getElementById('admin-password-message').style.display = 'none'; - // 绑定一次性验证通过后继续提交的回调 - window._afterAdminVerified = () => { - // 重新调用 applyBatchAction 并保留 adminPassword - applyBatchAction(); - delete window._afterAdminVerified; - }; - return; - } - - fetch('/admin/batch-update', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - current_password: adminPassword, - user_ids: selectedUserIds, - changes - }) - }) - .then(response => response.json()) - .then(data => { - if (data.status === 'success') { - // 统计成功和失败的数量 - const successCount = data.results.filter(r => r.status === 'success').length; - const errorCount = data.results.filter(r => r.status === 'error').length; - - if (errorCount === 0) { - showAdminMessage(`成功更新 ${successCount} 个用户`, true); - } else { - showAdminMessage(`成功更新 ${successCount} 个用户,失败 ${errorCount} 个`, false); - } - - // 重新加载用户数据 - loadAllUsers(); - } else { - showAdminMessage(data.message || '批量更新失败', false); - } - }) - .catch(error => { - showAdminMessage('网络错误,请重试', false); - }); - } - - // 显示管理员消息 - function showAdminMessage(message, isSuccess) { - const messageElement = document.getElementById('admin-message'); - messageElement.textContent = message; - messageElement.className = `message ${isSuccess ? 'message-success' : 'message-error'}`; - messageElement.style.display = 'block'; - - // 5秒后隐藏消息 - setTimeout(() => { - messageElement.style.display = 'none'; - }, 5000); - } - - // 加载所有用户组数据 - async function loadAllGroups() { - try { - const res = await fetch('/api/auth/groups'); - if (!res.ok) throw new Error('加载用户组失败'); - allGroups = await res.json(); - renderGroupsList(allGroups); - } catch (e) { - showGroupsMessage('加载用户组失败', false); - } - } - // 渲染用户组列表 - function renderGroupsList(groups) { - const list = document.getElementById('groups-list'); - list.innerHTML = ''; - groups.forEach(group => { - const card = document.createElement('div'); - card.className = 'info-card'; - card.innerHTML = ` -
    ID: ${group.id}
    -
    ${group.name}
    -
    权限: ${group.permissions}
    -
    - - -
    - `; - list.appendChild(card); - }); - } - function showGroupsMessage(msg, isSuccess) { - const el = document.getElementById('groups-message'); - el.textContent = msg; - el.className = 'message ' + (isSuccess ? 'message-success' : 'message-error'); - el.style.display = 'block'; - setTimeout(() => { el.style.display = 'none'; }, 5000); - } - function openCreateGroupModal() { - document.getElementById('group-modal-title').textContent = '创建用户组'; - document.getElementById('group-id').value = ''; - document.getElementById('group-name').value = ''; - document.getElementById('group-permissions').value = ''; - document.getElementById('group-password-area').style.display = 'none'; - document.getElementById('group-modal').style.display = 'flex'; - document.getElementById('group-modal-message').style.display = 'none'; - document.getElementById('group-modal-save-btn').onclick = saveGroupChanges; - } - function openEditGroupModal(id) { - const group = allGroups.find(g => g.id === id); - if (!group) return; - document.getElementById('group-modal-title').textContent = '编辑用户组'; - document.getElementById('group-id').value = group.id; - document.getElementById('group-name').value = group.name; - document.getElementById('group-permissions').value = group.permissions; - document.getElementById('group-password-area').style.display = 'block'; - document.getElementById('group-modal').style.display = 'flex'; - document.getElementById('group-modal-message').style.display = 'none'; - document.getElementById('group-modal-save-btn').onclick = saveGroupChanges; - } - function closeGroupModal() { - document.getElementById('group-modal').style.display = 'none'; - } - async function saveGroupChanges() { - const id = document.getElementById('group-id').value; - const name = document.getElementById('group-name').value; - const permissions = parseInt(document.getElementById('group-permissions').value); - const current_password = document.getElementById('group-current-password').value; - const msgEl = document.getElementById('group-modal-message'); - msgEl.style.display = 'none'; - if (!name || isNaN(permissions)) { - msgEl.textContent = '请填写完整信息'; - msgEl.className = 'message message-error'; - msgEl.style.display = 'block'; - return; - } - try { - let res; - if (id) { - // 编辑 - res = await fetch(`/api/auth/groups/${id}`, { - method: 'PATCH', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ name, permissions, current_password }) - }); - } else { - // 创建 - res = await fetch('/api/auth/groups', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ name, permissions }) - }); - } - if (!res.ok) throw new Error('操作失败'); - msgEl.textContent = '保存成功'; - msgEl.className = 'message message-success'; - msgEl.style.display = 'block'; - setTimeout(() => { - closeGroupModal(); - loadAllGroups(); - }, 1200); - } catch (e) { - msgEl.textContent = '操作失败'; - msgEl.className = 'message message-error'; - msgEl.style.display = 'block'; - } - } - async function deleteGroup(id) { - if (!confirm('确定要删除该用户组吗?')) return; - try { - const res = await fetch(`/api/auth/groups/${id}`, { method: 'DELETE' }); - if (!res.ok) throw new Error('删除失败'); - showGroupsMessage('用户组已删除', true); - loadAllGroups(); - } catch (e) { - showGroupsMessage('删除失败', false); - } - } - // 页面加载时加载用户组 - document.addEventListener('DOMContentLoaded', loadAllGroups); - - // 绑定搜索和过滤事件 - document.getElementById('search-name').addEventListener('input', () => renderUsersTable(allUsers)); - document.getElementById('search-phira').addEventListener('input', () => renderUsersTable(allUsers)); - document.getElementById('filter-admin').addEventListener('change', () => renderUsersTable(allUsers)); - document.getElementById('filter-dev').addEventListener('change', () => renderUsersTable(allUsers)); - - // 全选/取消全选 - document.getElementById('select-all').addEventListener('change', function() { - document.querySelectorAll('.user-select').forEach(checkbox => { - checkbox.checked = this.checked; - }); - }); - - // 复用account.html中的函数 - function updateUserDisplay() { - if (currentUser) { - document.getElementById('username-display').textContent = currentUser.username; - document.getElementById('user-avatar').src = currentUser.phira_avatar || 'https://phira.moe/assets/user-6212ee95.png'; - document.getElementById('dropdown-username').textContent = currentUser.username; - const phiraLink = document.getElementById('phira-profile-link'); - if (phiraLink && currentUser.phira_id) { - phiraLink.href = `https://phira.moe/user/${currentUser.phira_id}`; - } - } - } - - function toggleDropdown() { - const dropdown = document.getElementById('user-dropdown'); - if (dropdown) { - dropdown.classList.toggle('show'); - } - } - - // 关闭管理员密码模态框并清理状态 - function closeAdminPasswordModal() { - const modal = document.getElementById('admin-password-modal'); - const input = document.getElementById('admin-password-input'); - const msg = document.getElementById('admin-password-message'); - if (modal) modal.style.display = 'none'; - if (input) input.value = ''; - if (msg) { - msg.style.display = 'none'; - msg.textContent = ''; - } - } - - // 验证管理员密码:使用当前用户用户名调用登录接口进行密码校验,校验成功后保存 adminPassword 并触发后续回调 - async function verifyAdminPassword() { - const input = document.getElementById('admin-password-input'); - const msg = document.getElementById('admin-password-message'); - if (!input || !msg) return; - msg.style.display = 'none'; - const password = input.value || ''; - if (!password) { - msg.textContent = '请输入管理员密码'; - msg.className = 'message message-error'; - msg.style.display = 'block'; - return; - } - if (!currentUser || !currentUser.username) { - msg.textContent = '未检测到当前用户,请先登录'; - msg.className = 'message message-error'; - msg.style.display = 'block'; - return; - } - try { - const res = await fetch('/api/auth/login', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ username: currentUser.username, password, remember: false }) - }); - if (!res.ok) { - const data = await res.json().catch(() => null); - throw new Error((data && data.message) || '验证失败'); - } - // 验证成功,保存密码并关闭模态框,然后触发一轮回调(如果有) - adminPassword = password; - msg.textContent = '验证成功'; - msg.className = 'message message-success'; - msg.style.display = 'block'; - setTimeout(() => { - closeAdminPasswordModal(); - if (window._afterAdminVerified) { - try { window._afterAdminVerified(); } catch (e) { /* ignore */ } - delete window._afterAdminVerified; - } - }, 600); - } catch (e) { - msg.textContent = e.message || '验证失败'; - msg.className = 'message message-error'; - msg.style.display = 'block'; - } - } - - function logout() { - localStorage.removeItem('user'); - window.location.href = 'index.html'; - } \ No newline at end of file diff --git a/js/window-links.js b/js/window-links.js deleted file mode 100644 index 721ca77..0000000 --- a/js/window-links.js +++ /dev/null @@ -1,166 +0,0 @@ -(function(){ - // Global link handler: open external links (and privacy) inside mac-window component - function isExternalLink(a) { - try { - const href = a.getAttribute('href') || ''; - if (!href) return false; - // ignore javascript: links - if (/^\s*javascript:/i.test(href)) return false; - // treat privacy page specially - if (/privacy\.html(\b|$)/i.test(href)) return true; - // if link points to a downloadable file, do not treat as external page - const downloadExt = ['.zip', '.osz', '.osu', '.png', '.jpg', '.jpeg', '.gif', '.mp3', '.ogg', '.7z']; - for (let ext of downloadExt) { - if (href.toLowerCase().split('?')[0].endsWith(ext)) return false; - } - // protocol-relative or absolute - if (/^\/\//.test(href)) return true; - if (/^https?:\/\//i.test(href)) { - const url = new URL(href, location.href); - return url.host !== location.host; // external host - } - return false; - } catch (e) { - return false; - } - } - - // create a new mac-window instance and wire stacking/cleanup - function createMacWindow() { - window._macWindowCounter = (window._macWindowCounter || 0) + 1; - window._macWindowZ = window._macWindowZ || 100001; - window._macWindowSizeCache = window._macWindowSizeCache || {}; - const id = 'global-mac-window-' + window._macWindowCounter; - const win = document.createElement('mac-window'); - win.id = id; - // bring this window to front by assigning z - win.style.setProperty('--mac-window-z', (window._macWindowZ++).toString()); - // default size - win.style.setProperty('--width','900px'); - win.style.setProperty('--height','640px'); - - // clicking/touching the window brings it to front - win.addEventListener('pointerdown', () => { - win.style.setProperty('--mac-window-z', (window._macWindowZ++).toString()); - }); - - // cleanup on close: remove element entirely - const onClose = () => { - const iframe = win.querySelector('iframe'); - try { if (iframe) iframe.src = 'about:blank'; } catch(e){} - // remove loader if any - const loader = win.querySelector('.window-loader'); - try { if (loader && loader.remove) loader.remove(); } catch(e){} - // finally remove element - try { win.remove(); } catch(e){} - }; - win.addEventListener('mac-window-close', onClose); - - document.body.appendChild(win); - return win; - } - - function openUrlInWindow(url, title, width, height) { - const win = createMacWindow(); - // remove existing iframe to avoid duplicates - let iframe = win.querySelector('iframe'); - if (iframe) iframe.remove(); - - // embedded mode: remove title and let iframe fill the window - win.setAttribute('embedded',''); - - // if no explicit width/height provided, check cached size for this URL - window._macWindowSizeCache = window._macWindowSizeCache || {}; - const key = url; - if (!width && window._macWindowSizeCache[key]) { - const s = window._macWindowSizeCache[key]; - if (s.w) win.style.setProperty('--width', s.w + 'px'); - if (s.h) win.style.setProperty('--height', s.h + 'px'); - } - if (width) win.style.setProperty('--width', width); - if (height) win.style.setProperty('--height', height); - - // add a loading overlay while iframe loads - const loader = document.createElement('div'); - loader.className = 'window-loader'; - loader.innerHTML = '
    '; - win.appendChild(loader); - - iframe = document.createElement('iframe'); - iframe.src = url; - iframe.setAttribute('tabindex','0'); - iframe.setAttribute('loading','lazy'); - iframe.setAttribute('referrerpolicy','no-referrer-when-downgrade'); - iframe.style.width = '100%'; - iframe.style.height = '100%'; - iframe.style.border = '0'; - iframe.style.minHeight = '320px'; - iframe.setAttribute('allow','fullscreen; geolocation; microphone; camera; clipboard-read; clipboard-write'); - - // remove loader once iframe finishes loading; attempt to cache size when same-origin - iframe.addEventListener('load', () => { - try { if (loader && loader.remove) loader.remove(); } catch(e){} - // try measure content size if same-origin - try { - const doc = iframe.contentDocument || iframe.contentWindow.document; - if (doc) { - const fullH = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight); - const fullW = Math.max(doc.documentElement.scrollWidth, doc.body.scrollWidth); - // only cache reasonable sizes - if (fullH && fullH < 3000) { - window._macWindowSizeCache[key] = window._macWindowSizeCache[key] || {}; - window._macWindowSizeCache[key].h = fullH; - window._macWindowSizeCache[key].w = fullW; - // apply cached height to window for better fit (desktop) - win.style.setProperty('--height', Math.max(320, fullH) + 'px'); - } - } - } catch(e) { - // cross-origin — ignore - } - // try inject minimal reset CSS into same-origin iframe to avoid body margin gaps - try { - const doc = iframe.contentDocument || iframe.contentWindow.document; - if (doc) { - const style = doc.createElement('style'); - style.textContent = 'html,body{height:100%;margin:0;padding:0;background:transparent;}body>*{box-sizing:border-box;}'; - doc.head && doc.head.appendChild(style); - } - } catch(e) { - // cross-origin: cannot inject - } - setTimeout(() => { try { iframe.contentWindow && iframe.contentWindow.focus(); } catch(e){} }, 120); - }, { once: true }); - - win.appendChild(iframe); - // let mac-window manage title/navigation if supported - try { if (typeof win.registerIframe === 'function') win.registerIframe(iframe, url); } catch(e){} - - win.open(); - } - - // delegate click handler - document.addEventListener('click', function(e){ - const a = e.target.closest && e.target.closest('a'); - if (!a) return; - const href = a.getAttribute('href'); - if (!href) return; // ignore anchors without href - - if (isExternalLink(a)) { - e.preventDefault(); - const title = a.getAttribute('data-window-title') || a.textContent.trim() || ''; - const width = a.getAttribute('data-window-width'); - const height = a.getAttribute('data-window-height'); - openUrlInWindow(href, title, width, height); - } - }, true); - - // Ensure MacWindow is loaded lazily if not present - if (!window.MacWindow) { - const s = document.createElement('script'); - s.src = './component/window.js'; - s.onload = () => console.debug('mac-window loaded'); - s.onerror = () => console.warn('failed to load mac-window component'); - document.head.appendChild(s); - } -})(); \ No newline at end of file diff --git a/logo.png b/logo.png deleted file mode 100644 index 56524f233ed6a7d2152bc7b222979879bb9c907e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 210262 zcmdqH1y`KQ(l(5f1cwB74Z+>rJwT8N!3pl}8Z7vrgF69&yF+kycX!u8zscUuJ}3LE z_ZNJunZ=r!uIlQms;;W;yF))JN};^Le**;tg(4#@t^x)1rWXndruP*BP0pf%^l;uj=YMw`u$&(m+a?|3AGZw7W84(WCK%osPOfXCCJ? zYl@3x<3ZO$^5rf}Y@h}ja${D9(II)Lp`-cBeJ_lT?yyfDH%4BaUMYDI-NhPwH0j>% z`o5>-lrjr?OvaH@lao1z0zsNy&E+#Ja5zji_^VBcAJ`HZbr~u6TsWP?%OZw;YUo%* zY|l2@@Qd?Zo%=4qSI!hDT|k~44cd$*7w}=9G)n_L47Ux2a%?uZC zi>&Npt!J%c36^`xk1aHwj*AyGS8HtxZf&aS?3fmR6Ua3T>um!N_qew zJUDi7N#p*I{AdxFma8SDQ!PR7jmeBzOA7MjT+ql`+xyG3m)$J&`qN=tC=9nmDysjw zbHa<2$5}XXWaNa=!9lk_u{svN%`56Zjeg`_=aK)I zKq=#XM-KdXZmaoU-{r0w8tjPKZ%q|eoF6rdC)m(5$Dfh6zCcCO;Ao=kraSl>5al$#B{({RTEMW5QY zt#}<#WNqP>x3WM=6m@Zx*Z@?q{|p9FUT5@I5bVY4K4yD;eYn}J#9Hm!qJb+DtwtnD zq?DO3WO_}CKos^lYz?dLf4WBfuU$kO_;+EA#+xJGh>W zsy(Omla&;anBRK}ms z5%*8^FFy&J5{6t49>E2&L~V+pZNhOj&{~Lp52u_gs`z6t3T>>R#^CQz7r>v=4Y(tV zS*3P($o$O%DkSl~_*h1NP$dNT-ctL!tNl)=E{plI9N$ym5x%25Wb8o|rQiQr0a;{6 z1riQwt!yhcyAb6aak5bfs5QP{_3y}a{UMz!f=KqK?~f}K zBE7uL%^y6S1#y;lJlLDz`NKkg;~N!B`6@d-`QlFLzhLK*{Z4#RLhjpMEgWndA*r-g zvJHhbc;d-hQAoY0cuUCvI9^N)K$ZBf4CVE#p$5-QU!khu@m8K?JXfD5lIv?MLx@+1 znLyC`|4#lf!}z!Ek59K!a?{OoA9Y48y@6-&nfQ)hAPU|4$T1H@r2e#@v)wS3@ZmSD zPx00AE1CJg-o5Th++g{c3dz*muWIoDs3L!wvT7JZcrR&uxH(x6#i8Iy(MJ*s#>b!t zh%9cagx&rmu0!BR30cT_Y(I406M6vZS1;B8>TLGwPKD?QX;zXLu0OELm70WT;{$gf zZVuO4wV{Hsx5_YBc=wX-sr}YO)3#Un1X<3R5Q!oomh*c#-dqWhZwT!ugFvL@DShr2 zV=;Lm%{O8`3V*01rVz^@^w=8$om=;l#0={qw7w2U;mv;@nE%$s<+gX$mPF|I{x{Iq zQ=oq1F_xuR(l-or^?#O%Z^RJ;I=B0-JEkf3Tx^@GGOn<5zipyjH&3`g6H4d45JKg} zhJYg_3*@-Y%z?zve*hxTGmSOW?YDFpNqa!edyMNlg<3=;Qq35Ked+H-M0?r|Koy6~ z9usT!Z>N2N^bnTcOpJ}46}7o9KcoA^Z+Wzriu6utfaCxJKHzh|P#@O4kC2&&WxALC z#o*IpYLNmr<5Gx+-Q8tU!@7Zw(7%)HT2+*b;<>;Japcj9JXxfd0>Zn}vaFR`nVo+c z{KWMMk`@^PO*R!~ttzt5)C$k&1}~Y02UWa4pLw$9fV$1;k<5S?hzE?|m1Q*r|5e1; z+2ak-loU^n)xol~o?OYESQ~4!LPF{vuqM#) zzi-XuR8=(^AsOv?G5zDsR#(g|B2hR*mN}H3y6@k?F+WfdJd%pn zR$G1LM&70n%H@aS`t2oM%LpblvgmO*_5tl>J+l)682$&DIMblrIpzPX8Az#pD@_eY zIJm@$f4!Tn*81+u5IA}s^2|{8j^!PS&u&+1sE@#3ZVpn6R!A@&)Bj^Tm;3Cog(({a zsi>xkX<$n+Pqr#>n0PbtCrIv<0q03yRzf?2U!p{UA*G2ujx|Rd>wh}3Rf>4R2ZL6& z3Cq>zFP!J@=t6#Xn4n{k-f0t!oUTztc|tG=0#OKL*<;`P+{ak{oEzo5kFvM*B22fs zEPc_TdF1}O&y$X=4JI6!w^T3%?M_LFkpC(J<#&sgtB(V)|F`tB2RqP^tr!#@?2R_=_# z-v-7o0JS&+vk`CGXT?Kr;}l~YnU@$caTZQYjM?_TvSVyVF>*LOhs4M8r(55IsZuAh4$(Ol{a5duFs{2*T>?b zpGFDoZ(&6iraz6=pT>8DDkc*0a9&lJY{u@mef12}{|q4#lh;3&L%RpVm4wqQZSR(@nm1ysPGVfDpZi$|`{whrS74O6ge_i~m zhsaWhCG>D~c6OQG2m^4fj)ri-MnZMQmq*LPZ=a?nCEd0B6_{6=a+QUX1OFDC$%KXC z_CDW;Jb8Q^L|;{M#kw#HVqoD+Lb*82NaB8ML*sq>8)O}0BeS4GSJgif3OXgz{-_lR zrI$$Ta=b527vM* z9VLby<6Jj`+jWlw<+s6<%cP+b?43@^zk9TQUN8u8gdY5>B*SbJ84Pi=0gqvEuAm`5 z^@`)VZ`R_^Oj;(!FOVJUUxb}Y05>0Zg{$x%VPG!V)fl9}c_JMzy6xb5EA>+t-w<$O9T7 zxNb=-{BS5+ZKZtItK2?kYc+qR1!VUEP(|Z?^WOyktD!%m9mlg4M->!g^e5ZEfhky9 zQ7zp}%U*1afNiD=V69lJ|BG$PWgVk_kzUe2r0-AHKSd7HOXcUMTQ`QaDek18m%$pM zZP$<_41IGgnBFK;5Io=77YT;sWG-Ld|AP)T>3WTORCkpG;}x!J!ViW?SD|XH?+yjO z$ebxt`#g1eNqO_^=|S*z#KFJ=sYm{~^?qe#xji@JdNTx~5P2xZ*p`Z3MR2yK>eMvIw=#TsU$3UZ9oZWkLp!>={9pG0`J_=P0--!;+D zP2o4%ZLx|iNCt>q&IiVb>Viic%NNFqtI5@d=Q1&6O#bzy?!;E>u0 zyO0%zJ|^Yw>lhvG!S`g;PkHD#BHru#E^Z6WtVpM=iv};x%8knm;Zr$|i#hDW@2gZP z=ozi_XA@YkcbzeY8ChMR`OK1u;wp*k`O4!lt$>^u4CJ5L2W2W{BtpMaLjyxl$M&gu zB$;?P_tEKrUt@-pLYn1i6HVqa&3*-POrwpU zeUtkTvc<~9E5*Aox5($|MTf@CwX@6^Czv5;u$h8N!INJW-IE&%i;o`W2_J`8KAHh^ zfrhs&mm{@#-Q9_DTXhs}NO#C$3Ajj8?Jo`J+p9w02|2j{R7r@TXryeF?48cdAATb@ z(HVnxX5F19XGqULl-;dtN<@8$zSXhPHYZgcZ1gSP4ECKk(>g~bxvb5a>(^uza?(T# zLW$@G)^Ery$n(uQ(6s$4X)zhk@+kAs%1kdy@Yu9p$Tl!RyJREL@<1@l<+aop@9@!i z@fD^OmCWs?i4fgmr%(OFY~zaHtP}n1$#NoCkCNM)-|bf0^}IVd#67L7tf~Jew_Wz| zoTkKsY`0~Do7d5b*Da?3QB_@u2}sFMmN$*@;f6u;oj2@uAxmi&_oW=ez|D6}&0b#o zrU&xF&$EYaB~G<9wkc0TxXeCLR^0~7yvg+RFRfIuZhA;950Y$T9CBr?!64kNX%$*zic|Mh~NDp1p6Isr>|Kylom*9IwG1% zb*SpNVVDj2dSIh%d|)%I+|6IIgTIKT8?bu#i{RRjP3-=66qncth!oGu6-xd%jM2jR zynCxu*A(g1tQ6IP1*ydwgo#Srzcv`lrGnUe%{Q z?vIZXAh)IvxPP|g(}@~_vC;4>ud;1^CtoY*l@Ub<&?PV#UGZL)hcfX~Ica`QrY=0( z3P9t1>=97U{Vem=_2KxtSnxvi;=8XmCyRpz&|!-WcBGpZ+j_erT6M*I&c;K(=*P?% z?6>-UU0hhFxJ(q|eoO-46+9ae)~+3UXA|Pv~Hp}-i!*^EWj&69p*4rS0 zfVhscx^-5oS=D5noH;pM=Y<8J=}UNwk{xa=7RhPEX+*6s$t9F z+d$-^UNeoLe)`TJLh-&vw~{37NQPNz-adW^aC6kq9Lf2xo8}_PUGE^Yyd*Y>pT~ZD z7*%6k)v(MRV&^qx@O6HX5Oay!cD(V};E7|8>sbJ(PN$t@0%iX%M(%Z2( zllMnCC$sH4yi6ZjCtZ|)AMs@y#2r^RmyX=3qBPm zeqOydyRR?R&|x!-!Av9_t(Z$)>x&6H^z>FT4wFl)oj5+B;br<*1ZxpoQ(Aw|v*rVT zN96=gQrsOl5OG?T+c#!(m%=Whw(J7Zb~;L_j~%u+N2>b?DetC`go zzH{H(U9`48Y>fq9mWQ`wj##1*bdY4_liVDScfI52p7}lo6iJ| zOp|eC+rW zg5Qo>`oE_;RV8WL)jG7aAUfxQqskAxAgj1C33q-$DZVK~gEaB<4su>MQ(*wLGQ@MN zBG1s35xpcM<5q31$C-T(fdzCVQ?&u09_8Hu{`uT&%J;IXtbdLGQHrpGtHThsI)dI(y!a_Se0)<@; zgDZ|~E$d^Sgf3L0)sojLx-3q7$ml&uO#Tod@ieTQ_jU2+FCO*{!#(5~~RN`Tnc1 zi{pp@mX=kUJw_`kc9gVyGSfXt1&)c2d%!$7Ri>D*F!37U9r8eU?A;yGvWW$6(7Z$9 z-H@M>>+N#!QYsJFH6@ks>(3{N12+@fJE;4NPjdck#6xMzcWL#FyWBR-HrpIpF8J!;>JmPx_L65Tw#@|be#$<} zkh_r7muQt|bl3X+ieNOGuXZOK{u-v+Iw3?qA0vtpKd65p>8iI&sn8ZQNYUr=Ky8Ge!JkHa7pr;sn?hA|=VA@H9opjdiU zOwx-yVM+kCwM_PQ`{d^x?`{a&dK3^pEyO9NnpSx|h}fS4WG@L(GP1gqllhT(6j>3* z^T(*ou&T*E3p+JwulKx3K#Zo-BeZ{OKyyZE6#QN;TCSZi&P+Cy;u1F5H6w6`sPY=a zrr9oVSXa4LLhYCoj6DCC6qNXS;NBV5g}^lY@I@=QCJ_lgQ*P!|IXC$c+S&nR5Hbic%4;SAu?z??66%dwXhYD?Lgy zG>ewYjj^UWqyxJ7)~$}aG(Wp)4w5`d`eqDEyi@c2LS)ulaP|FAwdFs5&X6Oq_8ZqI z98hvsyPk7<2()onTKM@4)EupK^@@RG#NbhwJl3w=ynmZ5%NTr@|9qu86}Ly#14J?!!J6g>aWmh^Y?KWJWP`G_bTdDxhWAo2>NJav zTz?p}#yeJta@u!nq^Eb}@o7Dj825rK{;C7f>Lwz55|3o_B9NsK3k=zP9%k9PO=eE974El7h5O_|&E66SCpwZU%HK5Z_A)Mv%8)hTEKVdvM3F<|06 zFTV$wNUMcKGqv9S9fe9TKgRU6_%8y(hE?cb54*uNap!)`>}vCToK=b_oeryu5eQ2z z-{>!F9~wQ!QCiEk)0ek?@#9dx4e+euc)caT`W%~Fa(Em#> zp~tQ1veuKAH~-8)PV;t^y0t^o{ke`i2_8yI_W~W;nahrTf=7OkdW2fh1%B+53ztwe%mIuuT))u(d8B4I(1H;G~c> z0^Q_$8=K=iVIa~x9sy;_n zxJby0+X!J%$|UwZQ6gr|ONDmANr7yr0o+2}L*OtYHaTaL{aOG=uHeQjUc>DEy#boJcQRbwX>YQxs!)w$sI!53kiNk6@jbKMh*(N83 zZk{!MPtl7x`QuHWBg-?U|MhoEZ<83RJaUpF5?8bW(B{qj0!Z5oS9V+o_)i{hkBBP`1E$a~b z#i&!sG>tON%Z>i&#W6ANyaOkyU3+Z6Y3dLC?dJE1qhaRRj%JnA$%?1kRgR>5Mpe=i zO{V46kb;B}rjgYsTP>(h#xy=O{Lvu!e1?6L1ms@_IS|ycd+~!CGaj82Y%JN$bV#L& z6d|Ox7bLpycW2(m7cKTIj{INn{hI9{2|Xh<(Mon2nzNM1s< z%vy4%TJoZ*Y6Z@>+CD#;6sd!;`?ZWN-(VCUy73(o%f9VVukOU17eKU9w);{nae>;@2AP?&^g)4*cGVZ zgeE}~3Eh?XZ{euouoY>I4Y!v&b*m>ALnnjmo0IMEYj3th^bQ#YQH?A@wq-vyOXEEu z%s;WMt)h|xr6g5}a=K?_bjSyY`>qw&ym1aKjY_-}#l^Cy9f~xdzq{aB3=7&y=T(PR zqEz1EQ4;g>)MhM#KTn%;35I+*v2E37rb?t^W~51i*Jf=LcP4Y6Hj1V4mte)|ODgE+ zS;HT1k)+)v-2Czqcs++Z%^-U*!GKCKitwOi4$E8$Etuez_GnmJmqVl zql2|Awy|;d6o}}y^7e*XwxesbTdiD)JdRg9tKsdO#ebV8SH^kIJ?!OKAoycW-87+x z%$ZT6v-OFywz5on7RtU&Fdfa}p)remSoVxApsNDN-PEjPu}3v@12nM!;%CblA=-!j zNRZy?cTW!)q)dEYVrFlaOuw6CGa}-Ez>PvL$oWtua%k1YASqR+yc-sG0ne&Sx(TS9 zUta9(!PX16lngaJ|8cSg^tFtsfW@V7_1|(!PNf*2XOErQrs%uPye2N0Ve)I>h-F78 z7+mj6Y;bi>e!oj*;^*bEBxRMW<$MesBhv$;;E9)9Out1>-Ma#y&eKS|} z9L7ts*1YD9Rp<2s&Ntuvyy>s{33? zT+0%r+sp({Q&gFt^;5qHo(|4Eebg|Zr?J99>PpN1JTCBFIIOP)E+dc|HC($a_pY6o zef51lEtnmUmb*Wq52@|j7tcHfe0Y2fs+D@Wx6yTGmN`WoE5*&vLNl$#R0&Z52G_Bg zHMU|CDIKH^2=@2`c>Zi~1tE7ydEEU+S!|Aq^sFBCF?kiJ52a4)nu#I9f>j-)@R0jH zXOkJV+}xFckj7SW9@Q>9-z@@D7fuH*5p&WTYc!6J@2<5m zfnABtD*n<0A8I%@MIER6X@5oALsQ9ql}S`$-z=M3>{_un@~ofn>G$<2@k5)mWbV7L zzYnV&X!-<(JzqCCyQ`6$^OqOjYU=8&i5MKhI8e@pJiePEVJ6TZ;m*~m9GY42y|tg{ z4{)EV`PvBF{$(D6KtQ0&pj}K&R94t;pSh&;<;O{Wy zk-lIBW#n|RQA#B;aXvWmo4&8T-cyV9=j^{jdDnp*GA9>{7-H{hnNGRRZeK{(l}Xvx%p4RosOTe@Yu)DI5Vy|(B*l$L!kp= z>>PeA3kxbkGs~7oZJDv^?)(Z2vjik}8J+Nj(TNngPoHVzmwErV2$Fp~QMun3h}U-9 zMVqurhjzvcN4L8*w?k8~rq~3SQqO-LfyO~3igAqc$7Dz*ED(=oL2W?Xzy)diXpm=5 z++&5!!O{GFIVLc8bO%_#8HwQ;-egxDYuWQcs{!$}MCJ3M4p%w428A2KDFNN0Lj(rd zhOTMXeX5X1k@^+MKlBlyj|yMHlT({*XwWC$V?mrZV^h(Gc!iEXDziHdfy&p$55Vj;eiiAeo^vV($dWTl=pbC~gRxz@Cr$u;cy zmQz_W`y_k%`*s+~JX|qD|G_g~d~G$OUC9HIzg2WFsWZl>E$XD5LKoz*b&j0s_%OzWx4$^9ESEWu(_LJ)|W7b-yvB*r*ll@iks-`8HPM(cx9rvV9A$ zMcJ~5eCImcw$uxY3 z-d7ViIsW_CQ;jW3hjF6UP^*QvbOuoU#8H9fV3_>nmhUeOQ9}h}*wWa2&7$jA$@egM zUI@vmMwcw~UT~^3YiaQ912SmjYl-p^v_(=ghxD*rJcO*LShFGU3go> zBH#CN_gBH;&>nm7S6GF+tR5=_lqxB-@V((n&pdnEWycyNYMl=wp+UBiGa{6yO+DXm ziFLw9U`HE*X{3)u)Lzp!2k-WFBka>KeOS&V)8x(hv zU`&61K~-GYS56y=F0O{j>GP=4LAaN-t*+RY#lIhq^@P$#jTV*>4=MS)No0ghBTZKL zNJSPaDpnn<=(09$Y3@W6hdDTzXWf;`k{m{YC2L`}XE4E*v+T%DU=NbFnp$zRUNx)bI#p!D>wH(M|c#}d>l zTK-(bPXl2Zaz8n&5`v^{Y=G>P%g|e*jOf)0cdJlbN2B&f`L{J(xT}Fj=yuS_Rg@&E z12U$lb`^rshYfg)srTOq8$`8r^$iUA%_L`OtC%@BMy{@$Z;q4o%%0x^d}1&&5Yu5J zp>u>iCgs0Aoc*MXkBnhKI3jm5Xvp63)cxQ2MpS!*a(7_;JH_Scl^j~}s>xh1<%I>6T%HM~oIOKig1N$;7WMb5jJ%1tg}V~` z(gs)F;y5wM(2QUVXM9xi5$DPeb@inil}Z@JxyLakq4&8lM5NO3-NBM|_y zDb9Zf|4poL$<~wIH!-T7h2f^+;nxDX@-g|Qn}3c`B&sKgV;F9ZStRHP84TLZp|8mW$57Dia#N29e+o z0za;)dwU>J7iyk+Z_Wu;Zztg2n=(q766{mlYru8`vLdCSLBADenSUUvgdYTnG`JuV}{* z-@ap(-Lb=N)TK!lf?gqT{FEYF=Z!x=&_4MSnGM-{u9P~lK$2nGD)~ODektu(>rl3xH9Pvc(pSlj)GY_BL%iHhe2M)$3V|TCiSpXM!S)lmLSrqXksH zyb+|bb1}6fT+ER)Pyq}2aL0o4mQ_+Zoz#J@0}dsF0z|e!E|=WUXtbmeV50&GcVFWf7%q>hts)Vsku1 zqrE0!)p){dS~!IbkckXT7UY@E#~?ZQ3RZX1F>?>SepQ=bOixgad?(PhUn? zQl4hw(=EIqN4Oqq@^;t~-+}Kd*{5A87YiZ_>|F>VBvBPHNpBQ-i=!D0T6X$IcLgH36wBvfKfRz)=m5dDAnKUgf_15V(7{NfP$&I$a!9L zpQf#N-e2;bclup(n(3ej!HYn<=4bpWANW*wV1h^I=oc%iD9xX;xIO? zn9qJXP+gqmC`<5ss8<+?y!{O_JJiO2w9olg#~9&isKYyFig(*gM3AjQ=Am!xN#l$4ypRE9ygb^j7R;96?CqIS z>~P1dv_Gg$3o;Q%7rQ>Zx$|#sQu26UEytOaS7Oy={RTi)C6Eb>bD_0iP+n8jv-x1j zj(<48Xg+l|iRJX17`p`}+s%(f*qg1^4yhqtSRbt$Y z-^M>pBo*p&kuj=Y8abT-uRnJR<=i|ixew~9ciOvB<>Zu{GMm^IJXi!6#DPczHia>p z9q;$&svl>)5#wQ~)`XTGmW5vYGhX@5cJu43nct%M z1vxj~$mbHs5|K75J$i3TTgB?iit~FY!!)V@vNK1|uc(pKs$m#%TjtZ&nE8V2{8As{ zy1Nyzv1@TjRu%cmKe^qNrx;Wq?z7LyjKx%oxqp8*rnbh)!zEQ&-{8U}OXVy*{V*Dg zxdjj&h_OT`q_HExLul$sKFK^@WS8KAc;vgqta;e-b(P}G8_S^Krl1~V!XO^GJTq&# zEpxGs`vwkt?3hkl?jV*!@l+U(j5+I^UkaQXOmlm8sjlYls)ie}9#0XCAx(KmR+f5g z-D-#3>xQ~>XX0^0a|*GGd|pzRg$_k1DfCUf-ScOMH;-&l)XzglGrXZ84{J#vKu*gO z0=z}fC1XCu5QzvL1|n`rQp5&3WDDxfyXbn_qK5iF{h+7HwiUI0%zmewn?sqsW2r|U zP>N}Xr<+b=m-leAJ5Vjgd`ICdVz?#SeFqM+69xu_1is_8JKAUkBG^H#JE& zECp6U>*4NE6C5h|gi_IYB@uO8BCO?&5}10zy^Te4g4xW(EDxG52pJTIAD?0$ zbZM>9R`^iTwzaDfgNw0+OJtSb%;WDM)PCeOrWN9iiD9QnvcRCO0QU{GTzQS1IkJk_ zU@;$xo`J#s>7IIbl;jJh9%(tj5ja$(OlObT!P@+w9!DrMA%0o&Q}lDIlit}Xm*b8& zgl&G4&f4oBuwEe69Ez3}`7iw)6&E3PSRAU2x%Kt2NZp{Km#l?s0KSMO2X@p0_DBT> z0wPX>-6n?DvKOw9Jg0&e3l`{1QheiQY_KWr-c}#Rs0b2FrDWC^O1xe8WG0qbaIBs$ za>cip5JTG0(}pUYXjL#}Id3V~i>>DpVpwN`K}Jg$`i=pOSht$pYf{Wa zy@fOJ-zIu`QNJXrAU%gfzo2 zA?2|Q&184q`Oe1oGNmZ`fn7wD)pZEeRQmM77|IvZoC&gUeu?DJ7^YBCSdvVw7pNm% zq3=pJorvm#=2z;8X0VJ6%!{PQ-J>7KjD^JAVRt224v`7^JR~vLU(FyBc3*Nby5P<4 zUe$QMCJg81>^N)qn)>o~!#P~=<0^Fe%hy-^(920JQh`#{ySskf)v$8!;+xKHh&Pst zu)e6z!6;B&BD7NKw91`=hFkcWM;n>jqK~2$-$&LJ-brL%?8341Ih+ApBMD1)AG|+i z;q+?Z(sU%V3dL|)h;hnp94sX}bE5>7Q7Hx*?aIeeCT$f>lSul-O51Ea!R&E$od<8F z6~uH^ct+=VHO5;-7~X4@%Vp-MiTK2iS>A@IhpUf4Z7|4l+0Fz2(tk96pXZd}@LMA# z=tCbZBR`Y1vz#qdRnW4q7=C|Dv?kCS@Kj59xjKpxgxvPK;eb$pSRhjDW;w8579BK!y2~Hx zpcF+Sma+nCFN~-{moGSUZ)Xm}QKi$*!ULd8)XFWh@mkF&07BRTWuX;j7WM|aM_HUr z(pz`*>4Q0A_wQaJDbFB-#&A+Mah$C9%=OYZ#?Ozlc2+8SMcA1fgDf|^ya;xebpv5X zuy+7$U#N*IGz$0;a5uy>HcgoA!Mq|mP7w|m+VQp5ri>-4S3NL{gH4)bWB!KLIZhwSIi?rU0?$s-kuDJeW#+B<9k&cUy>Y2BWWx6Z~y7drm03xz|)?ff=4 zBhCGM0G*TUDhJqo+jv>+v=fsY){sdv{$AhOYBBN#B>oj9 zQZN5~L;LtAhkeXnMW`bY)kRZ%+}p90W~ox8?@pzDREM*U*%KLC>(xfYsj&b|zeYq7 zHDbTGrE$yHmFWL7mJ%(Bw%QG+2o0D(6+C({NX{1wj^qUJ8T#32cooYbb`FL zKo9>!ud85I_7jLpdS7GDjmlii8G5eAAz*)>&(f;Ies19~%+xCIxXA2F(e^AG^*|vF z@Ed-NgSIOHB9YjD$@9uv_*_PQ*rdqut^1@?CRh=|CX;Vw&S_-1^nZ=uDKP@pMrdu`2gsyPvlqkP}! z$5TeX)|uqNp6TFl^PG_q;_F7fqUvc&BgA))c4q&(b%gPIi*8?<1#E+P=#L;U_<=Y8 z0O;F=!kY0EFXYl=K)?E8l_f+(**O%u*a@F~a>Ah(FyF5w-jJe4lvEQNF?>SE{K_>~ zU$$6)-VKeA*zPAtGZ~N6-e%n@ysL%r_(vvnWc)D2lb`2gucg(o&1)?+0pdx_ABfJu z%A~X;9tc%z+=>Rgpz}92n-JL9IhkB+ikNbRc~cbv0#RRZhC7&?L$ZS;<`aSGU|)T% zvJ9a|7D^7vPpm>b0EP*{(6ET0P}DqrsKe~D<9ii6P5qk=v5?ZuJ%t@4^Ucsndv|I0 zOoA$XJwhC%n8A48l24nwrj}J$+`)Fx<5AM3St^b4?N1g6-4S z*I^tSHh)|Qe7F(Foy=4ePp3KMveo+mYl*Cim(%_@Wb)|^X!5 zP=fqU+?RZA*XGtTCQzyWZU{-EF@wO|^a%*g9kt@$I_U{UD*F0%BPyh_h}y#E_`Xcn zOyp4x6iZII^L^xOzEi^yO993%sJq`Hb2-N(9sd>>)+6#9l4B7!=jQ#{@7qWvQtS zKJNDVNFhzmvQ!6p7RDKqTeeaw$)3g-h9|X=#RJWOu6o9U{3V@VxJxKJm(K=up!e)mz+N7@*d!FKrtzpwr%UkyhJ zqFb&O#J3qYObM&7GAV>7H^pZg+ZRMOGiBylQREAVlu4sehWrqGt@WT>aY$aIHYVsN zV4}V3j@pG$3sJyTxIT!@NC)KHJ^8_=y;G3@E(5qK?`+e+ot<}>i1Vb8D7k$We7&_W zcK3kXReV^pfsp55M5!om;2$~763e5zySdozXZOrMG$I;aT7I$Cba_BLS5OvO^ zZ>@zz0rVidXsd)X^yCHo_E?qqrCdAES1~BeIV$rWQ^}dgUBXI!H>=GY7$l@(M;9B4 z>9#>HR@DEGrf-akv=7_Pw(ZTDYP0Ry+O)aJwrzWBavL^mwrg|kY~F0!<~z^xKKIx8 za?S7h<2=p-=QUL z-dqM9os6YO`Id?hR|CM2Sb5^JJ-JRJj86lsE~-P5I49iat6BSLYU8i32MOI3j(l-d zH7&G>J3JWin>J&8H$!PiB>N#^q3`mimI5BOA7mPuDyOS}<|kz#*Bz52#vwjXcDALR zoriId*j;Ezi8sp;$tj=bWPQ+Af4+%4Dc(>$?$LiqoCL|=I8|vnquR4iswcq)%|;4- z=)!e~NU0@^i0)yopzfsav?%Ayjg1A+cDD~!EKkI?HzWW)KDfO&&%sqr&s)jF9Zy+AF#Ad?YXDU^`w!O0gQ!Ps!ERv9a zN7ufY^*%T8*Av;{Oy0P0J41oi?j!2w4=XaIr}t&dTp;z`nNkK%Qp$Wcozm4JTFLg* z6-U#Cp-<_IfBN;cs5BsmLKfRYgZ}d+v$BfG8mK33n=DbRj^_?>(SVwzW-3PC-r5OW z#RG{hb3#|%!nD!>CSpr>t%z}nsu9)OZiq*`bOwBS>acmzRJ;N^SRBKf-YIP__WMZK z(xF6@7mco_dmi;&MV(@3WU(p8WN#}I4ZRxXWpCfuw7pAFlP6}?qY#x}?Ra}}q$xNN zws1Gijf+Kq!0HG*;439&ui)#EMeoNX> z59br@#QJ)!A0qy!T}Hb-gyl+bwzAA7T}eQ+D)?VuDT~x9Ed)h*Fd`l$;v3#0hBeZ( z@+Hb7ii$Y4_0TE5Dy;4vHt7vZ)2+6=fjMjl?EdFG;LcwaInDKdUbfNN$!{DF2uW`v zO|<`YXy&k4sIv!s=U6nnnIRP^BbVul&U=c>_2xMSaQnZ^Dw=9?pt-`!8YoXB7+P)0 z!1Hqfxi$0{l%9C$(9q%6CUPkbp+c&fnv~VmfP^lW$6v-VA5TGK2<+6Vv>mfv*YI^j zK-eb*Gedfv*Z7|EgaEPXt`{Y0J@4Cjz~4ekNGi+63`P1LD?7d_8rX{URF)^A8DxZb zPxc7`Bhs|Oolz};Nis;gl?Xc~VbrE14GD3b)=F7zl9N+ia(RR6;bmy!BVcewgLF5_ z*%SNG<=<1Mz}XZ`uwK4&$*MGsx&yp^$sw;^UPsgt&dZi7!%!bRDk}Hj9tYN%NtlAb zw>XWBmoya6U2r5oOx9krs>*(9)D3|f@KJ|)3bQR-gsG`XPr+tJ>+&+A!u&mlwOWnS zvq$u0@U3qY+JR^!dNt?KJEbcFpuelrd;NNy`LY9LNciYZ{)!h~UmKy97!xBbdJfBn zf=1OqHMc>ofl&opfoTs~pD%{!Vz4JRQ3OclNQm^{L@1f5PnUo;HQl_Lx(PwKB+G;R zteg`DtFn}Ll2e1v0;R~B2=jWdTDA(uD)MU}vAES|VUUS;+t<7#I2go1 zJHe50Fjyd|c7;6NqweEuefq~ovM1Glz%1T`-bz%t2v!Jak7j8V(e+1}IAfHU-N~R! zxAvwfjQT#YDegdceKo^$dfh4g(@0WQyBPBH$fyTU5O%-Pk&>y$PgAR81=$nfmvZ4F z&_tjcbdSxAxT}{d8gsP}y;TXt0Ba5JLhe)`AuQwdK;QfP@7-y2xcu3;n(SNQI<`+d z@1WnH>SoYL|Df1b9Lw!p^-t2A+qP4q!jOt@4F|4>VhGGiZPJQaP0dqt$E3(xoWq z!l`h%h*WmZr%mQ-9UErwRZ?#)CvQv8@1b%*r`1-?Q(SUTcUTkpJMYa6=Hp85nqb%V zqKZU~FE%;idVbn&1xZ$W)^R9=PKl5d(iLmKE-Yh)spYe^Gchc5F;jugfpa-+9iY=- zUdCQzK-)@3q=}C!Ok*6c$5+GtUN$--QhW_yen!6J8%+rHu&~Pee2J!ToxExTZlzvO zU7yrAE!PS|+FtB~zJ}nhCJi>5A653S7m;k>_gcR&sSm&9kpFrxu5fds^JI2iM9;r# zokhRkx8*@P#}iTgxH{d8Vy~QQx(%w*4Tn})puf)nck9t^noVt5EyNP{QqF(1yehv| z>sJOfnRr6i_iI04Bz$Rj^x-cI2E;DCh&y@m`Zy4;Y8kjkWb~7%Z)NrhM^7l5#Q523 z2jYh~Md?8^C0*%^PJ}XpdgEHqQ*IuFmIlU6;OXx|EPqU+Bml1u=*kQwW#t)KIMxhCpq9>U=j7Ni=p0eEpvqrt zJD^pC?X$pd6*IJtE5~2c9&TMIydm z{Lgxy%Zmn3xjs=&pvl;nECq) zeV7RQISmwGJFSJ>}s{^Yj*?n>5%!x<2Sz zhn|2kv_Zu8{^V0g&Yy5q%QQsZ_9!+m=s?6?|A0#zb^>@uL@iWvu^rGyEq6$oyz%w6 zgN{s=bvdU~QgW-RolN2X_>6Hf^x#@g6OBZCCn1eHfV-vqS+>IAU+ZJ#@rR~S5K=LEHbXT-3y+NF@`2gQ@fPe68ckDR-XC<|C_+-fOe8$`2gjamDw z4e{&QpKfv$mX?tbgbB9P*OAdk-eWkU*+5!@6TCQt652!yA*KUcQI{(Dw|CT2*!xG? z%>Hj4di_t*RB@s5Ev8s>G-YYtiPUV{u9}1UfE=J0neam@okUqoxj)LP@m4!juA)#? zUt+X4TOX&cTQl#digvM$rK8XNH?o|j9njH{OzsD5N{6@=R)E?dHWN+kztqJ65M{AO zKEk{;o=cS;m#ALuxk7loLEqsgk9xV!DVpEldxl>NzP#HlzgS}KzlySaH}aHo?G=>f zvvh*COW&@|%FEk-e{*0rtW;X4Sg=$oO5T1tBQ=KeR5Taq^Vv-TGOItNF@6T}!jU0v zM2J{NZfQ)#bs+$JSLGH`^I;Pr`)CRg8!dO&Y1#iKUp&Gz+L7G~kAhuxWHBxf0G;7W zIsA8KJe0aFi|1KndZMnig5va;X{6JBK+Px;0( zVg;jDnnUJCHJb+F;gMF%w(#$LWR{gEFfiy+T37bA636hJ@+G}PPDy2+-El)BAr?C> z4cxGc3at7}MOgPW# zU^?kBkKvww%a*qlyL(+2cisdhj0UNI3z7p=wjm+?ZXRg*c*pn$=c^p)FMi8k&Zr@f zh(>fFYDpiI2W`>!PkpTGH%R6l+c}G0l5aCBYka^ay!7NRsJKf^M-&W!9&~A83FFb?Qa5FD2KiW& z5jF~Fl@j`q_!F>2&MkFUb29+ibTQrs@<96hZID6z$_QcX-p6SS?9BJRo*b9inBSNR<=7vg;V0KULEH{f$ zzI=)^A{6%#ykud|oq$uIn2{eEV+EuMrjcbBmdSlvpeyZ~T_WaCNaEX*^0QN>Q=%wc zA1%GKXW9KR-x0dm3!Q=`5YG+zVA_V+YZj$YX(}OKQgXiC&t`JCgC$ z3A6Hu{Y#$>*dRU}IfiSbQBsm~{vu`uSCMnDRP~QM1NK|8Z!k75i&Sz*Ps_T1^4pT> zV05tXA^^NDdoHZRZIzXEBs66wHnTS7|AY=+hf*~=u83uYy$iMd7kG+&NWZOQ5}K=T z=6K`WevHU9!orYlgyWKz(fq%1 zug?oHG?QFK<8IfwfgfqAr8puxNzR^#SJ=ZM@!$Lu2rqJy(ZdpG6K|{=GIRJEPlj7? z!DNk%Ba9ARaZg{AyG@9(rmbNckq338I~3d;Q{j8ROOh${>V3FI_QfeE{*>VRnMUN) z!08tAgvi8C=*W{Ua z2U0s{FxqK)1yjl&Xg%zaaVk}z=+m3Vg5Rt0smv)E4dU~u7C&C0W3P7}*##aj3yr!A zXabGdbDl_%NW?c-JTT#%r^ayA)Fy|4#s-gRef=E9+5(AZibHP*$)p|4A+76iGe;3LXOt%L z)VjRlBOFkBcU4JVDk#VO>A~Xe1T2PxrjBMGCL;YTg8#zs?J@Lzrx6iHlD$>lT!%@g zqt6fqLBaNCiHH>=j0{*dn2&LV6;~LBS2N+nLjlKgaCN|_&MSm3WDuEcZKc`w!UnmK z-kk zgl$tKB*^={#A$h^J3mC#`NLPO-)0k9rbZjLQ1S%{&PbJlHHinszhE&#@7XJ8e?O>m z=<)HrdW{|*h76Va4YV%78#%4*(Nv9yhP}fkyG*Zlz)q`3gkOg;3iJ*ifr?7K+d1}= z!^R5PlkkzIC=S-^InK{X38)}6(^^=>k7wEy9oSB~d`IaM4F9z_Q|_D2O9W)e_A(-^a77g zYW0~#1RgRM=1JW}Jq+^=V9Sj%$(zau$!YZ+T;W@H&vwVs>?joxIUSW7M66{x@5Sy6Iyac+Kj(fy*k}5VaqhdfLO7+ZyyQp=Ie0^J zZYBpoqU=kjEfm5B$(GiW98gM3BX_=_J-zmg$Ug=BZP70AO+#>-zv5~d`5%}PCr9n_ zl`=J$4e>+xwvHuAaJ&OjnUK|`bNh7>Ka#&AwjPz+7iuEzelx)PS|$=B&OBu;AF1c? z>UOEqrY+Nly&6NBxBJb@G*|jcV1Ix8$C4ThBJDSSe9KT$AIl8EnAqYzA4q34unbSp z$OREw?dUqyNjG`>oPf*pM^gb>;wZWeruMty0#s@=yY>j*QYYupY*ld@xEwsR-Qx3x zP6eH_Kw|3dd^R_Bhy*el1~GfUxW`A)h7-^-7DMnoY}}SbwBwZDXhw9AU!U1BbvK2M zE4aX###Qbr7g1J1kMSxmx^W0@My_quYVo)jR>)6>aM6=CVS%O){)yUoBcz{xXdE9S zfuEof_VRW=K-Tq`-~QJbQs&9f3u+&y5SQjJp>F`y;JiMRYC45<(W>Gec0F`oxP+`qJO}S5SV+nViUsopVJ zT`I*xfMOoD;GXzd!TW+Mpb;J>WO)DLLsam2fYNiL9Do<-TGdgO^uu4&vGLm8#qr$HWowI|nQf5XgyksBA_FbS`^v8Q;v;U7-ujjM;T%SRigVxVd!bWzY0ffQ}pxJ*D1^r5+MMLIGn zGMn!?MIXZ|R}yBEhRq|>!T09u=I;BfG<04^ zu4*bvzQFFSL{CuW9}k*Z)#=wjqzVt*kG!YTOqh>H962=fb;?U+9r zvSb$MB}7gZgLE@u)NhGJZz=c1ZipnY?CHTcH))uhUToOKGhurA^np6Ego`WDOa`xN zi%zbW5(Ysh-yssHDxKvhFzx1!!cdpOfC z21+ow85$^0$atg_c=zkm{Rv;UYcG1{$p|-$Ai^*wc?-Rkg$XlfPcpGAS zWZmI*IdAQlrbMf-(Q#0%iQ;fW{Uo){+oexeEEeCEwUn*W@J|@GvCXiUs7nJHWe1Xw=5x{ zP=L)_)zjn4ei7x^O0s|q45rZ-NYfIui~EvnduEW|Wr;JvS-{%l4OUj|m-@H+wn8@0 zYe_Z=_TDD~J#fj5Wdf52#C6B$!F|xL-qB6krF;zpa{PNiZ%%ccu=pGRDF%p1kEg=0x1a1c|(Dd#6pN$+E6o-*Te?deQv z7Gvih@l+vH7^FJ>Wr;{tYowaz3$7WFOLi>z!SpWPSy)oC;?Zz3Y2X>-yLqz45!t?K zh;QYmJKljDVtH*p-IwACbILQgVy~uTB+jN)Y%9>i4Y8r$GD#~~^X96;(8$&D8wiSPs zc_ysnD;WyW!TTwe65R5$qm!w^l=E08-n`g3U+qcDaMnlohmvp9BVEb1@TduY@JF%i z*0n`z;^Jq!@K7&=KAc62DSiKW*V!OVRYsg!6V@Al05t3QX@2yb zB%xU5xwm0uJXL|NL!lIu75fXASvn__TA0ST~!%2r((ESKO=x6KTh5W=&p5u zcI1_920d#vD$0;Z0Lo?L<_ARvYmTPB3)|M#X_tHfbA+vJ>MoFRbKlTmEG5L*Itae~ zR3Rww*V~`37Ix}29MpOKtqRjkx7S6A+K6N=dAs?>|HUVJZ%?Stb@>OprwZ4@?{T8N zkud}{FFv7T-=00%iS$!cT@QA;xKuy61b#}zc-$d{I?Ao)A&$T+{NC5S_uT4T-kVn^=TnA!D!U6jP#pWuSC_A=}qVyMHSBr5{z|%d7J&E@1+`3ly z;<_hK&SJ*Emi4&6_;)f@fuc(1d41IHiYzHYi9onhBFhoZMA#7kODf$#$qlIj9Vvx9 z$|IX9B{h2*i^%5a*f7Nl92Xd(HTWtp@{b_EgoIgHn-!IHHG53|2uis1;!?et<00LA z(i@s2Wm}3v948iz%qJe4dpGjCJECZ>Eb%{P$oGHCj0#!;FXb2^aw#herbuN(s933e;${9VaKE>T)AJdd*GhtJ58?q0$HQMM?W4yh z9Xbk#&rZ|!jfVM2G3a9Mw0XnLy3vh3m%@K|vKiot_M;heF|eRnibl_8pgqE+8p5pFNUWZXu0x;qTe z;yXeabyaY@n*GIv9*Jj~tIXT=#z5D}der#=LBBD4vN3lU6L(>gR<}4ry7Bc0t3z#x zkF+U{>SJp^Yn?KjSdB!yzSu`u{jYr``;cLt)LRTAJ~V-0a2%BRjTMrFO=WY%Ue?; znPrFwJRXf6?_)lD>V0NVNIaY=iWPA;*%h=WAs|ih6^ey!hv@p> zAjlWz$HIR120REZe zk>rT?XD8knwZq#OD3GJz!4e+CQl~DU3U;AEr%5!8H}NDZa|HVadrAxGLZ^!bTe()# zjMADQJff8)IZtb`dKJ_ZFFD7b9f!81CzRz@!7caO!>>wSKM)4p)Fgcf8m>Fe%nJY8 z*ts$DYuWHWx9h~5x-dH$f?od{{K36Q_x+l$iA@L;UL9t4{kTa+iUfy|_#-AJzw0Jb z`MgX%NKSyVCPh+>5lR4IuAR>n%OYK7wsMi5VypZ%4IC{ z`+a-1CU9TCJ3V^bxp43e;}7*7AiI+ENJSh~mt`t;4q{;_m*>rJHBR_(JUj(3Kft1F zbU#44`L1%`$t(k1{&gkWAbawJtq#3}*aQd2ck%L>pijZEb}b+<&v@dbs;PH)vTIik zTphC3FJru|g!lnwq`lVD&6z<^Xdlk;t_i7zuI-Fyiz{#$Lul^`8{OI=n7On984gsba&zzZz=6YpQ_*)n+4k(^2`~Oy|jetEd~!RCDE~|ke?F{2cq4`%}|~M zhOwDoUtz`dzyu+-Blgif+E`~tjFZHq9UFPh5BE!E{rF+AJ+r{iw$q-lqZuD~8MYY- zh!mHC=&{W(Pa^SKPcEhupy4)X1Nwa~_%(~ay!TJ5LceDhbj(aG5nSNaa4+en@;ptT z*tW_lLT#8s5EGA#tIz=8bW7DUQ3;+QA%zk(o7rvOX23)_C7#g&aVdFpC%nI z^;R;cdW_QK+R@zS$>$ZSg1u;c0VlA};%h|YbVZZT{fE%_$q86aXj9Ym!AmpwVt>qp zBufBwKyMaHXUE1o{^Dc$Lm;?@Hm9@*9;ICiWt!whDW~b;4|fkt0L8-{-F&_Bsnb?# zHk0&Op{MdrAHOrg4jKegkG*>xe!82kx>L|3yxWH#ZGTli}sekvbQ2hOZ?= zjb5{3weFc0++as5$q%F$oLq7A4jI!=I6QI_(O&M^&c$BD*vN!tH<)o>4zgPv+@Uk78SCpMr>+DNNOI2oX)sj$^;k{Qww_{r>DF#G4YTx<(}i{SBHYJhljhn zxsF#U*KCWu!rW*U7IJD7=->&>FCd^EQ(kc!O0rLnv@ei`Q3D0W3fZr^dEqkfBJK#a zXxpJ9s-IGJ*n?s6j3Xn$wu`#&Zbu!*LFqY3JN?`B6>yVB)v_bSG>l-v47 z5MzG*#j`Asy!ETIqV&GFO@3IB?vxjg-WdTp9~i$~7vKiGxVnr`YJGGZ2#Yysv3~3O zx4#&D;PoJ|5hg8K))A&Ln%IJo4fUQU{~;}!zy43y@~qQ^7Q)>~TcK7}3kV8Ido&AR zU&|TtpA5T(ZAL{4$VvwpvIdbI4!HNRM|>)bpmTj>9VGnmjkbxOSBDjAg%e^OnwT34 zE@1;xnE6YReQ?7EMDArC9xa)ySXf^~)X&91=k9I8qEzcYHbE6%bI_6_crnqa&s!-_|WA z^d<#_92p-wzHS9Mauk)7$&)>Z-@zSf4I)c+Uctzdf>fY^px*w?!?n6IY9?zDp1HNT zza7cz2(1xU0uU$W?VSTmlmN_OjD4>x=Cm(9XrPXj>9pPZZs!)Z4wo)R)2qDJScZi& zwq0{{`6)AK3}Q}=(kTxl!qz83m0!3z`QU)H`YFYSyyzM2#km45x{#>JS@Z=gUGKuIRMw=8IIrQu^gHBggr6Lwg(91=dTw}$^ zb_WSyzR0bu@uL#(@vpZow$vFZRi?^u;pa?q6ZPvg@1pCW z7{l*4;TKwcVDgj3`}F5f}?m-J$r$p>u9e@^{E)SecN3UaE9gYMsg*t6&s z_3#qvG_p$ry!ZCZE=W}eE#DzSL8Rkhy5T%-{qe}#<4;KrS*~lOb({n-Xww$WqEL@1 zPEuC-btQRKH?ByjNv_B#3`@Yxb?Rp&Bpw>kf|v7gdSV)sx?(ecI*F2XX)?1sd2$cL z{V9$TE9$~%O~ie*omF_|bC&xx@($)`oUJ05cl&q}YjRNE_AmaPPuu>Iy4@?<=ljg# zXYQ5D7K+>VnF>N;$sfbiuNXZgCV=K~g7$lK*(egqUu-e_@Gz&ll_e-uxN@ks7@v(8 z(N=CiL^%_)OIaf;TrxSO8hH}u6#GJT20{d0rpGYo+eCebBZpGAFab!J*Pt&cgQXa_ zOYrHvgjJs`F@EOgxpqG;1Jo&HNpcyXiOula5Is>FCjm8^9%=l&qurhQxA(l3xQ&LO z`*NTCEm+Kh{Sdpss?Eo2X((R;K=xqauN-N{Cb_q1G`z5%oShi{%DbXJ`b8@3r&72~ zRW)aE^z|3UxPzB4gL&13UM{L$Xi!Yn`j7`tz~HammnNyW3?v^E-o&=efw|pLM-aj6 zhbELXj*t@=igNqRjWK+M8zQ*>r2m5SuDV89V!##nifrbG`Na0KtTqiv0fUnHq&*@O z{db=RZ|zTP!n@FJ1i{2;4IPul1#de^!N^$`^S7>mpLv5ZJbw>VkEklk>cdrF6y+zh z(d`t}lD@O*0q-*_{<5q8a!_Xk|K1M}X4}1H<8Q`#aWp|Xr5hI5yM9nfKIQm_EH{!1V?J#?8R2H;0P-0GQ?hGK2~3t&@|*DZqSW!bS^f3#m9FzR6wzXji7F+uemVirzdg**Xl|# zSDI**CV?@+n1`{wpF$V6JS&?X(EaFXX%k2xe(=z5ezl!ppQjcw51h9AjJH)3w(v|D;C-J1{w zZy%!0e|-iMJZ*;IuLphbrlkqEUUBYj{C=U_q65dqbf(^yi;V;4IO(HTdrj`;m<@o= z&iR|TW15G@-w?DxImd|SK;Gy%X=j%z{50?*S?!}~^B{UZC z_#^0#Tqt-Vl0L#`4+h_bT#C_0#Fjb29mkB)VC@iPu`-=oBJB}g{*t}!tP5F{L64m0 zPTb?%NM^a6YCe?(#XjEEk~iYM73!qYfFo;qThpG~gXi`8Fr$+vIg-+}PB+c6v zz7!x1R#SzY?c=hvm_p)&Yof{S8CiD`dIG{vk~P#`zGBBg2%X`hBb45s%0j6l{_*W> zqZ|;9ny5oTE&*~D{=ah;d(O6uA}Tp0#l@PK{kw1R=z7M|2CUc!zY^~yq|&63iS{B+ zIR8YFqfh?J&&n9LG``4`S_mcT2{Ksp1N)ZxqrK#*25h>hVmS#RMUDW~F=l;IVH@$m z%wV;|ke;ee2@gd41>!F>qL(9BPMbVRWZyaU71k_%WSMl08t7wc3q^V35>dV8m!h!( zS7i)7HFUbq@xrtoDTFh@1JxBwC6*0ZTZaV4YRlA)A?DVv3-n|7gfVt*g@_=La!O}P zH8Lt|8h_(gg3#U7EB-^_b*}{(>K6utaz$wj|HC6Gg#9mHf7s_7dbfB9vd-B`epwUX zl{R-KFBT2r54+=Z@J=0J&s#|amBpcFocmPSpg(!UD-06NXvI%=xLFZuSUrZumTlwc zd?A(nLV0sTetT`LlCQVbo|h%00<8oh;?zxj{y_ng*>s3WV1HEs74EP=qx%`wH7WFH-!u zN#h2+F)_K;-_ybH@3!F)N#2_IqEb%c)^7CMGkN$S1S4W*Gkq0`7JwJTBpYFEDy5hI z1A$z8$)siD!cws4`2+cdal6%9vwT|@0Ea#n5P{38`EyX7m771j{KD&FfQ@bFVyczR z{G*kv+LPum-_uo+P?q0fR1vl`$ND#k1l+SY5?Od!N#-d=zB6+}8-BsQZbdS+>6zBy z#_)JzV!yvr>JdU-1c%3!I76t9%O1jIoOy`duwCE0Ku+jub5_NUgTyooN)_oqb2>|o z{T0Szs+xpQ1SF&QJ9O6>#?K(V5>%`fUQpx@w$S+Xir0)1c~ z@(hpBZP^jpaclwN0OzzXw3d`9NupNW&&eSd!wY4o1^GAdV|DJ1@JTQnc>n7OrkvV} zoO7R#kB<*9ZToucF=3ec5EOUoHX2?}Ow3dabuR@)CJjGQzSQi(w1>JLAAev%e;`Q^ zWI?9f720{LKmm^CG@ur`4WdZptbO+JTfE9A^AGp%Qi_(~3&G?R#6FnC-YRw6ROu?{ z|0YUPzI~V#t=Hjvg$$mc$)hDlT=-Q8$gSD7+qqpK>wV#u!C(}PX~%q#J`vTUh{jk) z-pvytB@+`>aO;YnW$Pk^A*F69pjsa}<=05r+<9b*CUZuctnd|HZz-4)y^E~8{BrGv zbS&QT)=t6d;ifnIK%I%$d!|Yz{&*G5@S+=ABx5XnFotiEx3okY8^KZW_1SqI@m@6l z0oy9=F2-Jm(DNk@wj&t$?Ri3eR9p|5Vm8(lhW-9i;OQRn#~92RD^?GcGm47pPrc{7vc z|E&#nq&8$|aCwf}ABc_X^sd50e^OoZI^Nb@={vy%pU*Do?bpQsPtv5BN9Q-x$~HTl zpJu&Dhv#y-c1R>eY=XJ!Lcf+1N`1CA^56AelHej>D0j&j)C26amq@E;oT5WzM!NmW zl&egkGiQVSbiVFTR9uX?;*U@H4-3NuWq|s6G44(ueFS1tmp^?OJ6K8k7@mJnqo{2G z3dU$sCM*3uJySptu>$l+KhtUjNNh}bZ+f1^n;T~Gj~PKW-}^hvgti35V)-QG69Bwa z%UhyWomigeK&5f8~z)Q%G1|`?aH(g(@#QteOI*92`iG5B>@#@DILW zE88@K4DR`na~R{CZ6bzWgBioEJ0y1`_6KElnZQGEvm!-VTp!t(g`v`vc2+ZWUO#`r zHnz47;nQv!{(1nP42>GiSN_Cp%3w&m8-1k~EDqpAd{-)|Rd*ms_zC{Og_G#enh|_< zxw-4Y#BUp4hc_UR^m zMR0}&4e~?B&!wHtxVq8dWX@YUOAR)=l^+n)%}ik%AZK>kcsv>5Cv%gux0?I!I=5hpy)tft&j?BxFG4aHNgDkkse} z=!QO)53iS4=m%JSE)1WI0tJlOdVJ4sYCC_Xv)QgojUG4Lmi=ptQ=#1aEWtdh(Zf;U z3@vIdHrq|de7|!Bp_DDZ?7)Oo!`O$O(>emtpa^Zcl+0O%84vN+zj4m~APw^yD^=p; zBksVDO1ruuaq6m9o=7mA>l?m$1~l*~+>3Yr$ay3$Ap<#+NPC7rhD9Qry+7o;xjhkd zQBvt`luFuHbH<;N-j%t^HY|AQVl2S z3rM@U0o4m*(Z3iU?+)SL{wi^8j}^&Ytao$ew}86hBMN0}^(Q&3rW)c}ws2828&`@agDMeKIezoj z++knO4>bp&fy+zFl+LpA^U>)U?C?;6`bU6_?=0%@J6eT#JZBYY2lGU5TibjZ$B$+U3jdwx zZmDdtFRDqvdORFW!9F7^gRVzqsh&{{95B46rs#JoeONbO(WtV+l!_Bj7SU!L;?<&e zINJQV4U}ITQ-m$?H>n%{34DDaIXE}F$L(qkS;q45^z<_YgQ~~0bOs=CBYTwNU!U4 zAiB2`=#+qHW@FduUE4kc5eaC6>ikP*>m}ZTW7xp(^378e=L7Yan{md^PN$5&=z^G= zy@N;D%}8X~ZC4S0#V_GZoMWvNxcK7l_`ji*yZpUuf?@crYGR8>1KyV6M&QQ45PP#h z*B!D)XRPP<$y_vZ#@5X@KTD<+m{LW>X6#>~+U6Lw%j;f5%xVrsH#w%k|LARjXQr+J zdVU9v3`@3JFW3`8(@!2j&(6B>i@|J(qmvm+|TEtB<<&%$Br8{9J+y?2vU%F zAQrA)zx*hZ8t^RFmM!-A!4HY+kqHd&`2w=?8($$+z1s>>)f29LNBja2L~(~L&B@46 zy!|lg7?L;$9Oh1sy@8o0(~t{L@Q5(Qv4_8l_#?&(SvhkW=OP zp_9GN+P3E8de}QQHz%Cp7$Q1Ug>R3f0KFrcCZ%nUbxU9W7kI^QY4@9h87y5fCtbf7 zSDTC2sp~a?;y+f6eyLfgzv~g^sS~XH^z}c^ z6_}s8AjjbsvY#l4^K_}XEt?lCi=N{+vbgJsVXB(ds?fME*FKfUDT%5m{CmEsffot$ z{6bfvU+2QxFYQoheac1xdrS5cwyq)!@f)==e#Xu*?o<=YK?CuYDn?w|eqf7-Pe<`f ze0Fs5#w{{P%=Um00ZF3bLu;qhgo#FZhwcoJ{vd6#OZ{xMJ>TQ)g=3d^x$GGifXk0L zpJqT6WcBwHdui;C%YR+~0B6JweOprW&LrnrsfL0+Nq?L|Ai?LKWo2VmfT_p_TKQ@d zfqpHKPbVnTa$tSwzzpviUEk!r3*TzZE1`ao$FJ^ zua74ha6><0QOB*rs7#68BAK78@qcu==^!a8$xZtVZo~5J@?kB(>Ikp#G^* zJ~C@07BVD>5_=@a`xlK>Ga4&Is&j&CeJ93 z>0SF$e3oiUi=eV0!-3uCRjx~lLyZ-wV8B= zvr?s2pPraU1f2@~F)q|#f+V)FC?BabM55#5F-LFYOsL^iDH0ivM|37=CZHGq*|05T ziEK0v#D_|}4vA_`U%fvTjsy)~MuNiIkIV}gQ2kYo(&C~uwZqFFNxP_9Ql&-KlU= z4~*L3yM#iQ(7ZzNw_vIb}pwMz4sHH{OZ z4oz1Ie4MS;-c0?4N33-;yGI{br~Xa#TTgPi8q)@$AJk&W!S<09DPD^@fH&1>R@@4W zzy&`Pk%8Zp$^4v=K4jPYQXj5RF5w#+8_SoqIXu~E1W$mUML6WABaV0)D3(`xgF16r zwgWJ&{X__3JK*&HY}Vqo*XpuUCO+KomZ@<-D}llG>TwKXEC@o8rs5n5$7!i_bZ9_W zd*Zgl*^j8!TB1$Sad)WB{pn>!w?yzmr~7&zk>x^d|Bnl+jFlhYE-;5B#0QKU}oJ z|H6H!ncJ&$GR!se+sNnk!3iMJ9lS$vi68T8j&X{l$*{q!fqnt`YkQx4h2n=iG7@QZ z>P-OA1yt=;bsG6V={Ai^R0x+twxtN2L;_Ha#AwAmNw5Y%jvA%;keh#M$SuySQ3%08sDgib)jogfL&>OlgLNU>RM!GUAtaaH@+Iw@-wY({0D zT9G_4r$btQl&GMWlAj7&V*XBmZA5>6jbC$pjJWtEpLud5Cljxl+JAtDcesG;_mM5PH}g4E3QF`yB8}KibIj& zMN4rgZUKV3yHniVxp}`U=jLbr3o$P;9R0~SxPBCudW=mx3*#5+ z6Tqi7r>w?ubpG9GSt>_DYWliD`G`Z=TUr&4Kcvh_-dYj*sJ2xWPi{&fpJ6{^Hz?A# zEtYPtZMU<%ZH23C1P7*^pR;@?jM=3x=7{r=)iI6G8*n142^lz|9a}F5u?XW8{23f# zg@C~SlUc3cP^Y-5?Z(2uy^=W*2#VrEaIUratLl+&)^6yk7cM@cp? zAghoKRO|u%5uoZdKwV7Kg_N&WZ;GsiIVuXH=1Z`f8@YkqlT_p%G1X%uCp>29g%~Y_Zj`o!!jPzAJ^oYZ zpWF)@+uGW{ec+aR=u#&{(jmbW-VX_`Y~OQJ#^9(~&i{dLq9b80Wvo6}9nQ;wJ+AMx z+E?+~w1|m0WryT`dO8iLXhH#HTSyP#U~_I{4Yn$}ZyWX_9uj0z3C~+00|-0cL|qE? zd??=(`yNNQT1pFQ4+DW- zF_zFB;<≦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 z

    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