Files
TurboSu/static/js/pages/market.js
T

380 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const PageMarket = {
_el: null,
_marketToken: null,
_tab: 'dashboards',
init() {
this._el = document.getElementById('page-market');
this._marketToken = localStorage.getItem('turbosu-market-token');
},
async render() {
this._el.innerHTML = this._template();
this._bindEvents();
this._updateUI();
this._loadItems();
},
async _loadItems() {
const grid = document.getElementById('market-grid');
if (!grid) return;
grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>';
if (this._tab === 'mine') {
if (!this._marketToken) {
grid.innerHTML = this._emptyHtml('请先登录');
return;
}
return this._loadMine(grid);
}
const isScene = this._tab === 'scenes';
const endpoint = isScene ? '/api/market/scenes' : '/api/market/dashboards';
try {
const res = await fetch(endpoint);
const data = await res.json();
const items = data.items || [];
let localIds = [];
try {
const lr = await fetch('/api/dashboards?category=all');
const ld = await lr.json();
if (Array.isArray(ld)) localIds = ld.map(d => d.id);
if (isScene) {
const sr = await fetch('/api/scenes');
const sl = await sr.json();
if (Array.isArray(sl)) localIds = localIds.concat(sl.map(s => s.id));
}
} catch(e) { console.error('load local:', e); }
if (!items.length) { grid.innerHTML = this._emptyHtml(isScene ? '暂无场景' : '暂无仪表盘'); return; }
grid.innerHTML = items.map(d => {
const installed = d.manifest_id && localIds.includes(d.manifest_id);
return this._cardHtml(d, isScene, installed);
}).join('');
} catch (e) { console.error(e); grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败: ' + (e.message||'未知') + '</div>'; }
},
async _loadMine(grid) {
try {
const res = await fetch('/api/market/mine?token=' + encodeURIComponent(this._marketToken));
const data = await res.json();
if (!data.dashboards?.length && !data.scenes?.length) {
grid.innerHTML = this._emptyHtml('你还没有上传任何内容');
return;
}
let html = '';
if (data.dashboards?.length) {
html += '<h3 style="margin-bottom:10px;font-size:16px;color:var(--text-secondary)">我的仪表盘</h3>' + data.dashboards.map(d => this._myCardHtml(d, 'dashboards')).join('');
}
if (data.scenes?.length) {
html += '<h3 style="margin:20px 0 10px;font-size:16px;color:var(--text-secondary)">我的场景</h3>' + data.scenes.map(d => this._myCardHtml(d, 'scenes')).join('');
}
grid.innerHTML = html;
} catch (e) { console.error(e); grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败: ' + (e.message||'未知') + '</div>'; }
},
_cardHtml(d, isScene, installed) {
const icon = isScene ? '🎬' : (d.icon || '📦');
const label = installed ? '已安装' : (isScene ? '安装场景' : '安装');
const btnClass = isScene ? 'btn-m-install-scene' : 'btn-m-install';
const uninstallBtn = installed ? `<button class="btn btn-sm btn-danger btn-m-uninstall" data-name="${d.name||d.id}" style="margin-top:4px;width:100%">卸载</button>` : '';
return `<div class="glass-card theme-card">
<div class="theme-card-preview">${icon}</div>
<div class="theme-card-info">
<div class="theme-card-name">${d.name||'Unnamed'}</div>
<div class="theme-card-meta">
<span>${isScene ? (d.game_id||'') : (d.category||'')}</span><span>${isScene ? '' : 'v'+(d.version||'1.0')}</span>
</div>
<div class="theme-card-meta" style="margin-top:4px">
<span>${d.author||''}</span><span style="color:var(--text-tertiary)">${d.downloads||0} 次</span>
</div>
<button class="btn btn-sm btn-primary ${btnClass}" data-id="${d.id}" data-file="${d.file||''}" style="margin-top:8px;width:100%" ${installed?'disabled':''}>${label}</button>
${uninstallBtn}
</div></div>`;
},
_myCardHtml(d, collection) {
const pending = d.dashb_pending ? '<span class="badge" style="background:rgba(255,159,10,.15);color:#FF9F0A">待审核</span>' : '<span class="badge" style="background:rgba(52,199,89,.12);color:#34C759">已通过</span>';
const created = d.created ? new Date(d.created).toLocaleString('zh-CN', {month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '-';
const updated = d.updated ? new Date(d.updated).toLocaleString('zh-CN', {month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}) : created;
return `<div class="glass-card" style="padding:14px 16px;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div style="flex:1;min-width:200px">
<div style="font-weight:600;font-size:14px">${d.name||'Unnamed'} <span style="color:var(--text-tertiary);font-weight:400;font-size:12px">v${d.version||'1.0'}</span></div>
<div style="font-size:12px;color:var(--text-tertiary);margin-top:2px">
${d.author||''} · ${d.downloads||0} 次下载 · ${pending}
</div>
<div style="font-size:11px;color:var(--text-tertiary);margin-top:2px">上传: ${created}${updated!==created?' · 更新: '+updated:''}</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn btn-sm btn-secondary btn-my-update" data-id="${d.id}" data-coll="${collection}" data-file="${d.file||''}" data-type="${collection==='scenes'?'.tss':'.tsd'}">更新</button>
<button class="btn btn-sm btn-danger btn-my-delete" data-id="${d.id}" data-coll="${collection}">删除</button>
</div></div>`;
},
_bindEvents() {
document.getElementById('btn-m-login')?.addEventListener('click', () => this._showLogin());
document.getElementById('btn-m-upload')?.addEventListener('click', () => this._upload());
document.getElementById('market-grid')?.addEventListener('click', async (e) => {
const btnInstall = e.target.closest('.btn-m-install');
const btnScene = e.target.closest('.btn-m-install-scene');
const btnUpdate = e.target.closest('.btn-my-update');
const btnDelete = e.target.closest('.btn-my-delete');
if (e.target.closest('.btn-m-uninstall')) {
const ubtn = e.target.closest('.btn-m-uninstall');
const uname = ubtn.dataset.name||'';
if (!confirm('确定卸载 "' + uname + '"')) return;
try {
const r = await fetch('/api/dashboards?category=all');
const list = (await r.json()) || [];
const dash = list.find(d => d.id === uname || d.name === uname);
if (dash) {
await fetch('/api/dashboards/' + dash.id, { method: 'DELETE' });
Toast.show('已卸载', 'success');
this._loadItems();
} else { Toast.show('未找到本地安装', 'error'); }
} catch (e) { Toast.show('卸载失败', 'error'); }
return;
}
if (btnUpdate) {
const input = document.createElement('input'); input.type = 'file'; input.accept = btnUpdate.dataset.type;
input.onchange = async (ev) => {
const file = ev.target.files[0]; if (!file) return;
const fd = new FormData(); fd.append('file', file); fd.append('token', this._marketToken);
const url = btnUpdate.dataset.coll === 'scenes' ? '/api/market/scenes/upload' : '/api/market/upload';
const res = await fetch(url, { method: 'POST', body: fd });
const data = await res.json();
Toast.show(data.error ? '更新失败: ' + data.error : '上传成功(新记录)', data.error ? 'error' : 'success');
if (!data.error) this._loadItems();
}; input.click();
return;
}
if (btnDelete) {
if (!confirm('确定删除?')) return;
try {
await fetch('/api/market/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: btnDelete.dataset.id, collection: btnDelete.dataset.coll, token: this._marketToken}) });
Toast.show('已删除', 'info');
this._loadItems();
} catch (e) { Toast.show('删除失败', 'error'); }
return;
}
const id = (btnInstall || btnScene)?.dataset.id;
const file = (btnInstall || btnScene)?.dataset.file;
if (!id || !file) { Toast.show('安装失败:数据不完整', 'error'); return; }
const isScene = !!btnScene;
const url = isScene ? `/api/market/scenes/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`
: `/api/market/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`;
try {
const res = await fetch(url);
const data = await res.json();
if (data.ok) {
Toast.show('安装成功!请在仪表盘页面查看', 'success', 4000);
this._loadItems();
} else {
Toast.show('安装失败: ' + (data.error||'请求异常'), 'error');
}
} catch (e) { Toast.show('安装失败:网络错误', 'error'); }
});
document.getElementById('tab-dashboards')?.addEventListener('click', () => {
this._tab = 'dashboards';
document.getElementById('tab-dashboards').style.cssText = 'border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px';
document.getElementById('tab-scenes').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
document.getElementById('tab-mine').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
this._loadItems();
});
document.getElementById('tab-scenes')?.addEventListener('click', () => {
this._tab = 'scenes';
document.getElementById('tab-scenes').style.cssText = 'border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px';
document.getElementById('tab-dashboards').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
document.getElementById('tab-mine').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
this._loadItems();
});
document.getElementById('tab-mine')?.addEventListener('click', () => {
this._tab = 'mine';
document.getElementById('tab-mine').style.cssText = 'border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px';
document.getElementById('tab-dashboards').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
document.getElementById('tab-scenes').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
this._loadItems();
});
},
_updateUI() {
const loginBtn = document.getElementById('btn-m-login');
const uploadBtn = document.getElementById('btn-m-upload');
const statusEl = document.getElementById('m-status');
if (!loginBtn || !uploadBtn) return;
if (this._marketToken) {
loginBtn.textContent = '已登录';
loginBtn.className = 'btn btn-sm btn-secondary';
uploadBtn.style.display = '';
if (statusEl) statusEl.textContent = ' 已登录';
} else {
loginBtn.textContent = '登录';
loginBtn.className = 'btn btn-sm btn-primary';
uploadBtn.style.display = 'none';
if (statusEl) statusEl.textContent = ' 未登录';
}
},
_upload() {
if (!this._marketToken) { Toast.show('请先登录', 'error'); return; }
const overlay = document.createElement('div'); overlay.className = 'modal-overlay';
overlay.innerHTML = '<div class="modal" style="min-width:480px;max-width:520px;padding:24px"><h3 style="font-size:18px;font-weight:600;margin-bottom:4px">发布作品</h3><p style="font-size:12px;color:var(--text-secondary);margin-bottom:18px">选择要发布的仪表盘或场景</p><button id="pub-file-btn" class="btn btn-primary btn-sm" style="width:100%;margin-bottom:10px">📁 选择文件 (.tsd / .tss)</button><div style="font-size:13px;color:var(--text-tertiary);margin:10px 0;text-align:center">— 或从本地选择 —</div><div id="pub-local-list" style="max-height:300px;overflow-y:auto">加载中...</div></div>';
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector('#pub-file-btn').addEventListener('click', () => {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsd,.tss';
input.onchange = async (e) => {
const file = e.target.files[0]; if (!file) return;
const isTss = file.name.endsWith('.tss');
const fd = new FormData(); fd.append('file', file); fd.append('token', this._marketToken);
const res = await fetch(isTss ? '/api/market/scenes/upload' : '/api/market/upload', { method: 'POST', body: fd });
const data = await res.json();
Toast.show(data.error ? '失败: '+data.error : '发布成功', data.error ? 'error' : 'success');
if (!data.error) { overlay.remove(); this._loadItems(); }
}; input.click();
});
this._loadLocalItems(overlay);
},
async _loadLocalItems(overlay) {
const list = overlay.querySelector('#pub-local-list');
try {
const dashRes = await fetch('/api/dashboards?category=all');
const dashes = (await dashRes.json()) || [];
const sceneRes = await fetch('/api/scenes');
const scenes = (await sceneRes.json()) || [];
const upD = dashes.filter(d => !d.is_builtin && !(d.id||'').match(/^[a-f0-9]{6}__/));
const upS = Array.isArray(scenes) ? scenes.filter(s => !s.is_builtin && !(s.id||'').match(/^[a-f0-9]{6}__/)) : [];
let h = '';
if (upD.length) {
h += '<div class="pub-section" style="cursor:pointer;font-weight:600;font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:flex;align-items:center;gap:6px" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==\'none\'?\'\':\'none\';this.querySelector(\'.pub-arrow\').style.transform=this.nextElementSibling.style.display==\'none\'?\'rotate(-90deg)\':\'\'"><span class="pub-arrow" style="display:inline-block;transition:transform .2s">▼</span> 本地仪表盘 ('+upD.length+')</div>';
h += '<div class="pub-group">' + upD.map(d => '<div class="glass-card" style="padding:10px 14px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between;gap:8px"><div><span style="font-weight:500">'+(d.icon||'📊')+' '+d.name+'</span></div><button class="btn btn-sm btn-primary pub-dash-btn" data-id="'+d.id+'">发布</button></div>').join('') + '</div>';
}
if (upS.length) {
h += '<div class="pub-section" style="cursor:pointer;font-weight:600;font-size:13px;color:var(--text-secondary);margin:10px 0 4px;display:flex;align-items:center;gap:6px" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==\'none\'?\'\':\'none\';this.querySelector(\'.pub-arrow\').style.transform=this.nextElementSibling.style.display==\'none\'?\'rotate(-90deg)\':\'\'"><span class="pub-arrow" style="display:inline-block;transition:transform .2s">▼</span> 本地场景 ('+upS.length+')</div>';
h += '<div class="pub-group">' + upS.map(s => '<div class="glass-card" style="padding:10px 14px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between;gap:8px"><div><span style="font-weight:500">🎬 '+s.name+'</span></div><button class="btn btn-sm btn-primary pub-scene-btn" data-id="'+s.id+'">发布</button></div>').join('') + '</div>';
}
list.innerHTML = h || '<p style="color:var(--text-tertiary);font-size:12px;padding:10px">没有可发布的内容</p>';
list.querySelectorAll('.pub-dash-btn').forEach(btn => btn.addEventListener('click', async () => {
const r = await fetch('/api/market/publish-local', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:btn.dataset.id,type:'dashboard',token:this._marketToken})});
const d = await r.json();
Toast.show(d.error?'失败:'+d.error:'发布成功',d.error?'error':'success');
if(!d.error){overlay.remove();this._loadItems();}
}));
list.querySelectorAll('.pub-scene-btn').forEach(btn => btn.addEventListener('click', async () => {
const r = await fetch('/api/market/publish-local', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:btn.dataset.id,type:'scene',token:this._marketToken})});
const d = await r.json();
Toast.show(d.error?'失败:'+d.error:'发布成功',d.error?'error':'success');
if(!d.error){overlay.remove();this._loadItems();}
}));
} catch(e) { list.innerHTML = '<p style="color:var(--text-tertiary);padding:10px">加载失败</p>'; }
},
_showLogin() {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
const isLogin = !this._marketToken;
overlay.innerHTML = `<div class="modal" style="min-width:380px;max-width:420px;padding:24px">
<div style="display:flex;gap:0;margin-bottom:18px;border-bottom:1px solid var(--border-subtle)">
<button id="ml-tab-login" class="btn btn-sm ${isLogin?'':'btn-secondary'}" style="border-radius:0;border-bottom:2px solid ${isLogin?'var(--accent)':'transparent'};color:${isLogin?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px">登录</button>
<button id="ml-tab-register" class="btn btn-sm ${!isLogin?'':'btn-secondary'}" style="border-radius:0;border-bottom:2px solid ${!isLogin?'var(--accent)':'transparent'};color:${!isLogin?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px">注册</button>
</div>
<div class="form-group"><label>邮箱</label><input id="ml-email" type="email" placeholder="your@email.com"></div>
<div class="form-group" id="ml-name-group" style="display:${!isLogin?'':'none'}"><label>用户名</label><input id="ml-name" type="text" placeholder="必填"></div>
<div class="form-group"><label>密码</label><input id="ml-pass" type="password"></div>
<div class="modal-actions">
<button class="btn btn-sm" id="ml-logout" style="display:${this._marketToken?'':'none'};margin-right:auto;background:var(--bg2);color:var(--text2)">退出</button>
<span style="flex:1"></span>
<button class="btn btn-primary btn-sm" id="ml-submit">${isLogin?'登录':'注册'}</button>
</div></div>`;
document.body.appendChild(overlay);
if (this._marketToken) {
overlay.querySelector('#ml-logout').addEventListener('click', () => {
this._marketToken = null;
localStorage.removeItem('turbosu-market-token');
overlay.remove();
this._updateUI();
Toast.show('已退出', 'info');
});
}
let mode = isLogin ? 'login' : 'register';
const tabLogin = overlay.querySelector('#ml-tab-login');
const tabRegister = overlay.querySelector('#ml-tab-register');
const nameGroup = overlay.querySelector('#ml-name-group');
const submitBtn = overlay.querySelector('#ml-submit');
function setTab(t) {
mode = t;
const isL = t === 'login';
tabLogin.style.cssText = `border-radius:0;border-bottom:2px solid ${isL?'var(--accent)':'transparent'};color:${isL?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px`;
tabRegister.style.cssText = `border-radius:0;border-bottom:2px solid ${!isL?'var(--accent)':'transparent'};color:${!isL?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px`;
nameGroup.style.display = isL ? 'none' : '';
submitBtn.textContent = isL ? '登录' : '注册';
}
tabLogin.addEventListener('click', () => setTab('login'));
tabRegister.addEventListener('click', () => setTab('register'));
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
const doAuth = async () => {
const email = document.getElementById('ml-email').value;
const pass = document.getElementById('ml-pass').value;
if (!email || !pass) { Toast.show('请输入邮箱和密码', 'error'); return; }
if (mode === 'login') {
const res = await fetch('/api/market/auth', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass}) });
const data = await res.json();
if (data.token) {
this._marketToken = data.token;
localStorage.setItem('turbosu-market-token', data.token);
Toast.show('登录成功', 'success');
overlay.remove();
this._updateUI();
} else { Toast.show(data.message || '登录失败', 'error'); }
} else {
const name = document.getElementById('ml-name')?.value || '';
const res = await fetch('/api/market/register', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass, passwordConfirm: pass, name}) });
const data = await res.json();
Toast.show(data.error ? '注册失败: ' + data.error : '注册成功,请查收验证邮件(点击关闭)', data.error ? 'error' : 'success', data.error ? 3000 : 0);
if (!data.error) overlay.remove();
}
};
overlay.querySelector('#ml-submit').addEventListener('click', doAuth);
overlay.querySelector('#ml-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') doAuth(); });
},
_emptyHtml(msg) {
return `<div class="empty-state" style="grid-column:1/-1"><h4>${msg||'暂无'}</h4></div>`;
},
_template() {
return `<div class="animated">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;flex-wrap:wrap;gap:10px">
<div>
<h2 style="font-size:24px;font-weight:700">仪表盘市场</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px">浏览并安装社区分享的仪表盘和场景 <span id="m-status" style="color:var(--text-tertiary)"> 未登录</span></p>
</div>
<div style="display:flex;gap:8px">
<button id="btn-m-upload" class="btn btn-sm btn-secondary" style="display:none">发布你的作品</button>
<button id="btn-m-login" class="btn btn-sm btn-primary">登录</button>
</div>
</div>
<div style="display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid var(--border-subtle)">
<button id="tab-dashboards" class="btn btn-sm active" style="border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px">仪表盘</button>
<button id="tab-scenes" class="btn btn-sm" style="border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px">场景</button>
<button id="tab-mine" class="btn btn-sm" style="border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px">我的</button>
</div>
<div id="market-grid" class="theme-grid"></div>
</div>`;
}
};
window.PageMarket = PageMarket;