/* ===================================================================== * * SGLV 暂存箱库 (sglv-shelf) * * 定位:Steam 商店全域可达的「暂存箱」——把暂时不想买/想买但现在不买的 * 商品、愿望单项、外部链接与备忘统一暂存,随时查看与恢复。 * * 与 Steam-Cart-Enhanced.js(购物车增强)的关系 * --------------------------------------------------------------------- * 共享 localStorage['sce_cart_shelf_v1'],即「购物类」记录(kind = * app/bundle/package)双向互通:SCE 暂存的项在 SGLV 浮窗可见,SGLV 从 * 购物车暂存的项在 SCE 暂存架可见。用户未安装 SCE 时该 key 由本库独享, * 行为完全一致,无需任何配置。 * * 为什么 wishlist/note 不写进同一个 items 数组 * --------------------------------------------------------------------- * SCE 的 loadShelf() 过滤条件为 * it.id && (it.appid || it.bundleid || it.packageid || appids.length) * 且 saveShelf() 用过滤后的 shelf.items **整体重写** items 数组。 * 若把备忘录/外部链接塞进同一数组,用户一旦在购物车页使用 SCE 暂存, * 这些记录会被 SCE 静默丢弃(无 appid/bundleid/packageid)。 * 故采用双桶: * INTEROP_KEY = 'sce_cart_shelf_v1' 购物类,与 SCE 互通(有损风险 0) * EXTRA_KEY = 'sglv_shelf_extra_v1' 愿望单/备忘,本库独享 * 读取时合并为统一视图,写入时按 kind 路由,用户感知不到分桶。 * * 扩展字段安全性 * --------------------------------------------------------------------- * 顶层未知字段(如自定义 meta)会被 SCE 的 saveShelf 丢弃,故所有扩展 * 一律挂在「记录级」字段上——SCE 只改写它认识的字段,记录级扩展可无损 * 往返。写入互通桶时保留 days/tab 等 SCE 需要的顶层键(Object.assign 合并)。 * * 能力边界(诚实声明) * ------------------------------------------------------------------- * · 恢复(加购)走 AddItemsToCart/v1,全站可用,但**依赖当前登录账号**: * 记录写入时记 steamid,恢复前校验,不匹配直接拒绝(参考脚本无此保护, * 跨账号会静默加到错误账号的购物车)。 * · 暂存(移出购物车)**没有官方 API**,只能点 Steam 自己的「移除」按钮, * 因此仅在 /cart 页可用(与 SCE 同一约束)。其他页面按钮置灰并给跳转。 * · 愿望单不提供移除 API,故 wishlist 记录只做「打开/定位 + 记录管理」。 * · 折扣到期时间 endTs 仅当记录带有该字段时显示倒计时(由 SCE 写入); * 本库从购物车暂存时不主动拉折扣数据。 * * 桥接契约(宿主注入,见 init(ShelfHost)) * --------------------------------------------------------------------- * apiVersion: 1 * isZh: boolean * getSteamId(): string 当前账号 steamid64(可为空) * toast: { success, error, warning, info } * resolveName(appid): Promise 名称补全(可选,缺失时降级) * openUrl(url), isCartPage(): boolean (可选,缺失时降级) * * 暴露:window.SGLVShelf * ===================================================================== */ window.SGLVShelf = (function () { 'use strict'; const apiVersion = 1; // ==================== 常量 ==================== const CFG = { INTEROP_KEY: 'sce_cart_shelf_v1', // 与 Steam-Cart-Enhanced.js 共用 EXTRA_KEY: 'sglv_shelf_extra_v1', // 本库独享(wishlist / note) INTEROP_KINDS: ['app', 'bundle', 'package'], RESTORABLE: ['app', 'bundle', 'package'], CART_API: 'https://api.steampowered.com/IAccountCartService/GetCart/v1/', CART_ADD_API: 'https://api.steampowered.com/IAccountCartService/AddItemsToCart/v1/', ITEMS_API: 'https://api.steampowered.com/IStoreBrowseService/GetItems/v1/', SHELVE_STEP_MS: 700, RESTORE_STEP_MS: 250, CANDIDATE_STEP_MS: 200, MAX_ITEMS: 300, STALE_DAYS: 180, TICK_MS: 1000, }; const KIND_META = { app: { label: '单品', tag: 'cart' }, bundle: { label: '捆绑包', tag: 'cart' }, package: { label: '购买包', tag: 'cart' }, wishlist: { label: '愿望单', tag: 'wishlist' }, note: { label: '备忘', tag: 'note' }, }; const FILTER_META = [ { key: 'all', label: '全部' }, { key: 'cart', label: '购物' }, { key: 'wishlist', label: '愿望单' }, { key: 'note', label: '备忘' }, ]; // ==================== 宿主 ==================== let Host = null; let _hostReady = false; const _changeCbs = []; function zh(s, en) { return Host && Host.isZh === false ? en : s; } function toast(msg, type) { try { if (Host && Host.toast && typeof Host.toast[type] === 'function') Host.toast[type](msg); else if (typeof console !== 'undefined') console.log('[SGLVShelf]', type || 'info', msg); } catch (e) { /* ignore */ } } function debug() { if (typeof console === 'undefined') return; console.log.apply(console, ['[SGLVShelf]'].concat([].slice.call(arguments))); } // ==================== 基础工具 ==================== const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const now = () => Date.now(); const num = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; }; function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); } function uid(prefix) { return (prefix || 'sh') + '_' + now().toString(36) + '_' + Math.random().toString(36).slice(2, 7); } function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); } function absTime(ts) { if (!ts) return ''; try { const d = new Date(ts); const p = (x) => String(x).padStart(2, '0'); return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); } catch (e) { return ''; } } /** 剩余时间文案;ts<=0 返回空串 */ function remainText(ts) { const t = num(ts); if (t <= 0) return ''; let s = Math.floor((t - now()) / 1000); if (s <= 0) return '已到期'; const d = Math.floor(s / 86400); s -= d * 86400; const h = Math.floor(s / 3600); s -= h * 3600; const m = Math.floor(s / 60); if (d > 0) return '剩 ' + d + ' 天 ' + h + ' 小时'; if (h > 0) return '剩 ' + h + ' 小时 ' + m + ' 分'; return '剩 ' + Math.max(1, m) + ' 分'; } /** 紧迫度:0 已过期/无,1 紧张(<24h),2 临近(<72h),3 宽松 */ function severity(ts) { const t = num(ts); if (t <= 0) return 0; const h = (t - now()) / 3600000; if (h <= 0) return 1; if (h < 24) return 1; if (h < 72) return 2; return 3; } function isVisible(el) { if (!el) return false; try { const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') return false; } catch (e) { /* ignore */ } try { if (el.getClientRects && document.body && document.body.getClientRects) { if (document.body.getClientRects().length && !el.getClientRects().length) return false; } } catch (e) { /* ignore */ } return true; } // ==================== 存储层 ==================== function lsGet(key) { try { return window.localStorage.getItem(key); } catch (e) { return null; } } function lsSet(key, val) { try { window.localStorage.setItem(key, val); return true; } catch (e) { return false; } } function readRaw(key) { const s = lsGet(key); if (!s) return null; try { const v = JSON.parse(s); return (v && typeof v === 'object') ? v : null; } catch (e) { return null; } } function isInteropKind(kind) { return CFG.INTEROP_KINDS.indexOf(kind) !== -1; } function bucketOf(kind) { return isInteropKind(kind) ? 'interop' : 'extra'; } /** * kind 推断的唯一权威入口(导入 / 归一化共用)。 * 顺序:显式合法 kind → 购买项 id → 未知 kind 按有无 appid 归一 → 有 url 判备忘 → 默认。 * 「先看 kind 再看 id」:kind='weird' + appid 应归一为 app,而 kind 缺失但带 bundleid * 才按 bundle 处理(与 SCE 的 legacy 推断兼容)。 */ function resolveKind(rec) { const r = rec || {}; const k = r.kind; if (k && KIND_META[k]) return k; if (r.bundleid) return 'bundle'; if (r.packageid) return 'package'; const hasApp = !!r.appid || (Array.isArray(r.appids) && r.appids.length); if (k) return hasApp ? 'app' : 'note'; if (r.url) return 'note'; return hasApp ? 'app' : 'note'; } function currentSteamId() { try { return (Host && typeof Host.getSteamId === 'function' && String(Host.getSteamId() || '')) || ''; } catch (e) { return ''; } } /** * 记录规范化。 * - 互通桶强制 kind ∈ app/bundle/package(与 SCE 的理解对齐) * - 缺失 kind 时按 bundleid/packageid 推断并打 legacy(与 SCE 2216-2230 同规则) * - 补齐 appids/names/displayName/count 等展示字段 * 不丢弃任何未知字段(往返无损) */ function normalizeRecord(rec, bucket) { const r = Object.assign({}, rec || {}); r.id = r.id || uid('sh'); if (!Array.isArray(r.appids)) r.appids = r.appids != null ? [r.appids] : []; r.appids = r.appids.map(num).filter((n) => n > 0); if (!r.names || typeof r.names !== 'object') r.names = {}; let kind = r.kind; if (!kind) { r.legacy = true; if (r.bundleid) kind = 'bundle'; else if (r.packageid) kind = 'package'; else kind = 'app'; if (r.appid == null && kind === 'app' && r.appids.length === 1) r.appid = r.appids[0]; } if (!KIND_META[kind]) kind = resolveKind(r); // 互通桶只允许购物类(SCE 不认其它 kind) if (bucket === 'interop' && !isInteropKind(kind)) kind = 'app'; r.kind = kind; // 三 id 字段互斥化(与 SCE 结构一致:冗余三字段,方便按类型直读) if (kind === 'bundle') { r.packageid = null; r.appid = null; } else if (kind === 'package') { r.bundleid = null; r.appid = null; } else { r.bundleid = null; r.packageid = null; } if (kind === 'bundle') r.bundleid = num(r.bundleid) || null; if (kind === 'package') r.packageid = num(r.packageid) || null; if ((kind === 'app' || kind === 'wishlist') && !r.appid && r.appids.length === 1) r.appid = r.appids[0]; if (kind === 'app' || kind === 'wishlist') r.appid = num(r.appid) || null; if (kind === 'note') { r.appid = null; r.bundleid = null; r.packageid = null; r.appids = []; } r.endTs = num(r.endTs); r.pct = num(r.pct); r.count = num(r.count) || r.appids.length || (kind === 'note' ? 1 : 0); r.removedAt = num(r.removedAt); r.addedAt = num(r.addedAt) || r.removedAt || 0; r.tags = Array.isArray(r.tags) ? r.tags.filter((x) => x != null && x !== '').slice(0, 12) : []; if (r.url == null) r.url = ''; if (r.note == null) r.note = ''; if (r.origin != 'sce') r.origin = 'sglv'; if (!r.source) r.source = bucket === 'interop' ? 'cart' : 'manual'; if (r.lastError === undefined) r.lastError = null; if (r.displayName == null) r.displayName = ''; // SCE 记录常只有 targetName 而 displayName 为空 —— 回填一份, // 使记录自描述(两个脚本都能直接读 displayName) if (!r.displayName && r.targetName) r.displayName = r.targetName; return r; } /** SCE 认可的记录形状(有 id 且至少一个可定位 id) */ function sceAcceptable(rec) { return !!(rec && rec.id && (rec.appid || rec.bundleid || rec.packageid || (Array.isArray(rec.appids) && rec.appids.length))); } function readBucket(bucket) { const key = bucket === 'interop' ? CFG.INTEROP_KEY : CFG.EXTRA_KEY; const raw = readRaw(key); const items = []; if (raw && Array.isArray(raw.items)) { raw.items.forEach((it) => { if (!it || typeof it !== 'object') return; if (bucket === 'interop' && !sceAcceptable(it)) return; items.push(normalizeRecord(it, bucket)); }); } return { raw: raw || {}, items: items }; } /** * 写桶。互通桶用 Object.assign 合并而非整体覆盖, * 保留 SCE 需要的顶层键(days/tab)与任何未来新增键。 */ function writeBucket(bucket, items) { const key = bucket === 'interop' ? CFG.INTEROP_KEY : CFG.EXTRA_KEY; const cur = readRaw(key) || {}; const payload = Object.assign({}, cur); payload.items = items; if (bucket === 'extra' && !payload.version) payload.version = 1; const ok = lsSet(key, JSON.stringify(payload)); if (!ok) toast(zh('暂存箱保存失败:浏览器存储不可用或已满', 'Shelf save failed: storage unavailable or full'), 'error'); return ok; } /** 统一视图:互通桶 + 独享桶 */ function allItems() { return readBucket('interop').items.concat(readBucket('extra').items); } function getItem(id) { const list = allItems(); for (let i = 0; i < list.length; i++) if (list[i].id === id) return list[i]; return null; } function countAll() { return allItems().length; } /** * 容量上限:**跨两桶的全局上限**。 * 淘汰顺序:先独享桶(备忘/愿望单,最不贵)→ 再互通桶(购物类,最贵)。 * 同桶内按 addedAt 升序(最旧先走)。 * 返回被淘汰条数。 */ function enforceCap() { const ex = readBucket('extra').items; const it = readBucket('interop').items; let over = ex.length + it.length - CFG.MAX_ITEMS; if (over <= 0) return 0; let dropped = 0; const drain = (arr) => { const order = arr.slice().sort((a, b) => (num(a.addedAt) || num(a.removedAt)) - (num(b.addedAt) || num(b.removedAt))); for (const r of order) { if (over <= 0) break; const i = arr.indexOf(r); if (i >= 0) { arr.splice(i, 1); over--; dropped++; } } }; drain(ex); if (over > 0) drain(it); if (dropped) { writeBucket('extra', ex); writeBucket('interop', it); } return dropped; } /** * 写入/更新一条记录(按 kind 路由桶)。 * opts.silent 内部批处理时静默(不逐条 toast、不逐条广播) */ function putItem(rec, opts) { const o = opts || {}; const inp = Object.assign({}, rec || {}); // 别名归一:允许调用方直接传 title/name(其他 SGLV 模块的常见用法) if (!inp.displayName && !inp.targetName) { const t = inp.title || inp.name; if (t) { inp.displayName = t; inp.targetName = t; } } // 时间戳补齐:无任何时间戳的记录(外部模块直接 putItem)盖章 now, // 否则 addedAt=0 会让「按加入时间排序」与陈旧清理全部失效 if (!num(inp.addedAt) && !num(inp.removedAt)) { inp.addedAt = now(); inp.removedAt = inp.addedAt; } const r = normalizeRecord(inp, bucketOf(inp.kind)); if (!sceAcceptable(r) && isInteropKind(r.kind)) { toast(zh('这条记录缺少可定位的商品 ID,未保存', 'Record lacks a resolvable id, skipped'), 'warning'); return null; } const bucket = bucketOf(r.kind); const cur = readBucket(bucket).items; const idx = cur.findIndex((x) => x.id === r.id); if (idx >= 0) cur[idx] = r; else cur.unshift(r); writeBucket(bucket, cur); const dropped = enforceCap(); if (dropped && !o.silent) { toast(zh('暂存箱已满(上限 ' + CFG.MAX_ITEMS + ' 条),已淘汰最旧的 ' + dropped + ' 条', 'Shelf full (' + CFG.MAX_ITEMS + '), pruned ' + dropped + ' oldest'), 'warning'); } if (!o.silent) emitChange(); return r; } function deleteItem(id, opts) { const o = opts || {}; const rec = getItem(id); if (!rec) return false; const bucket = bucketOf(rec.kind); const cur = readBucket(bucket).items.filter((x) => x.id !== id); writeBucket(bucket, cur); if (!o.silent) emitChange(); return true; } /** 清理长期未处理的记录(用户手动触发,绝不自动删) */ function pruneStale(days) { const limit = now() - (num(days) || CFG.STALE_DAYS) * 86400000; let n = 0; ['interop', 'extra'].forEach((b) => { const cur = readBucket(b).items; const keep = cur.filter((it) => (num(it.addedAt) || num(it.removedAt)) > limit); if (keep.length !== cur.length) { n += cur.length - keep.length; writeBucket(b, keep); } }); if (n) emitChange(); return n; } // ---- 偏好(存独享桶,SGLV 私有) ---- function getPrefs() { const raw = readRaw(CFG.EXTRA_KEY) || {}; const p = raw.prefs && typeof raw.prefs === 'object' ? raw.prefs : {}; return { filter: p.filter || 'all', sort: p.sort || 'end', compatOnly: !!p.compatOnly, }; } function setPrefs(patch) { const raw = readRaw(CFG.EXTRA_KEY) || {}; const next = Object.assign({}, raw.prefs || {}, patch || {}); const payload = Object.assign({}, raw); payload.prefs = next; if (!payload.version) payload.version = 1; lsSet(CFG.EXTRA_KEY, JSON.stringify(payload)); } // ---- 展示派生 ---- function displayName(rec) { if (!rec) return ''; if (rec.displayName) return rec.displayName; if (rec.targetName) return rec.targetName; if (rec.kind === 'note') return rec.url || zh('(未命名备忘)', '(untitled note)'); const n = rec.names && Object.keys(rec.names)[0]; if (n) return rec.names[n]; if (rec.appid) return 'App ' + rec.appid; if (rec.bundleid) return 'Bundle ' + rec.bundleid; if (rec.packageid) return 'Package ' + rec.packageid; return zh('(未命名)', '(untitled)'); } function linkOf(rec) { if (!rec) return ''; if (rec.kind === 'bundle' && rec.bundleid) return 'https://store.steampowered.com/bundle/' + rec.bundleid; if (rec.kind === 'package' && rec.packageid) return 'https://store.steampowered.com/sub/' + rec.packageid; if (rec.appid) return 'https://store.steampowered.com/app/' + rec.appid; if (rec.url) return rec.url; return ''; } /** 该记录是否属于其他账号(写入时账号 ≠ 当前账号) */ function foreignAccount(rec) { const mine = currentSteamId(); if (!mine || !rec || !rec.steamid) return false; return String(rec.steamid) !== mine; } function canRestore(rec) { return !!rec && CFG.RESTORABLE.indexOf(rec.kind) !== -1; } // ==================== 变更广播 ==================== function emitChange() { const n = countAll(); try { setBadge(n); } catch (e) { /* ignore */ } // 浮窗开着就同步重绘:删除/清空/导入/恢复都走 emitChange, // 否则弹窗确认后列表会停留在旧快照(UI 与数据不一致) if (isOpenFlag) { try { renderList(); } catch (e) { console.warn('[SGLVShelf] 重绘失败', e); } } _changeCbs.forEach((cb) => { try { cb(n); } catch (e) { console.warn('[SGLVShelf] onChange 回调异常', e); } }); } function onChange(cb) { if (typeof cb === 'function') _changeCbs.push(cb); } // ==================== Steam 接口层 ==================== function appConfigEl() { return document.querySelector('#application_config'); } function readJsonAttr(el, name) { if (!el) return null; try { return JSON.parse(el.getAttribute(name) || 'null'); } catch (e) { return null; } } function getAccessToken() { const uc = readJsonAttr(appConfigEl(), 'data-store_user_config'); return (uc && uc.webapi_token) || null; } function isLoggedIn() { const ui = readJsonAttr(appConfigEl(), 'data-userinfo'); return !!(ui && ui.logged_in); } function getContext() { const cfg = readJsonAttr(appConfigEl(), 'data-config') || {}; const lang = cfg.LANGUAGE || (window.g_Languages && window.g_Languages[0]) || 'schinese'; let cc = cfg.COUNTRY; if (!cc) { try { cc = new URLSearchParams(location.search).get('cc'); } catch (e) { /* ignore */ } } return { language: String(lang).toLowerCase(), country_code: String(cc || 'CN').toUpperCase(), steam_realm: 1 }; } function gmJson(url) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { fetch(url, { credentials: 'include' }).then((r) => r.json()).then(resolve).catch(reject); return; } GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 15000, onload: (res) => { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error('JSON 解析失败')); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } function gmFormPost(url, body) { return new Promise((resolve, reject) => { const headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }; if (typeof GM_xmlhttpRequest !== 'function') { fetch(url, { method: 'POST', credentials: 'include', headers: headers, body: body }) .then((r) => r.json()).then(resolve).catch(reject); return; } GM_xmlhttpRequest({ method: 'POST', url: url, headers: headers, data: body, timeout: 20000, onload: (res) => { try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error('JSON 解析失败')); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')), }); }); } async function fetchLineItems(token) { const data = await gmJson(CFG.CART_API + '?access_token=' + encodeURIComponent(token)); const cart = data && data.response && data.response.cart; return ((cart && cart.line_items) || []).map((it) => ({ lineItemId: it.line_item_id != null ? it.line_item_id : null, type: it.type != null ? it.type : null, packageid: it.packageid ? Number(it.packageid) : null, bundleid: it.bundleid ? Number(it.bundleid) : null, })); } async function fetchStoreItems(ids) { if (!ids || !ids.length) return []; const payload = { ids: ids, context: getContext(), data_request: { include_basic_info: true, include_all_purchase_options: true }, }; const data = await gmJson(CFG.ITEMS_API + '?input_json=' + encodeURIComponent(JSON.stringify(payload))); return (data && data.response && data.response.store_items) || []; } function kindQuery(t) { return t.kind === 'bundle' ? { bundleid: t.id } : { packageid: t.id }; } function cartNavData(cc) { return { domain: 'store.steampowered.com', controller: 'default', method: 'default', submethod: '', feature: 'store_home', depth: 1, countrycode: cc, webkey: 0, is_client: false, curator_data: { clanid: null, listid: null }, is_likely_bot: false, is_utm: false, }; } function describeCartError(data) { const r = data && data.response; if (!r) return zh('接口没有返回内容,可能是网络或登录态问题', 'No response from API'); const e = r.error; if (e) { const bits = []; if (e.message) bits.push(String(e.message)); if (e.error_description && e.error_description !== e.message) bits.push(String(e.error_description)); if (e.eresult != null) bits.push('eresult ' + e.eresult); if (!bits.length) bits.push(JSON.stringify(e).slice(0, 200)); return bits.join(' / '); } return zh('Steam 没有把它加入购物车', 'Steam refused to add it'); } async function addOneToCart(token, cc, cand) { const item = { gift_info: null, flags: { is_gift: false, is_private: false } }; if (cand.kind === 'bundle') item.bundleid = cand.id; else item.packageid = cand.id; const payload = { user_country: cc, items: [item], navdata: cartNavData(cc) }; const data = await gmFormPost( CFG.CART_ADD_API + '?access_token=' + encodeURIComponent(token), 'input_json=' + encodeURIComponent(JSON.stringify(payload)) ); if (data && data.response && data.response.cart) return { ok: true, data: data }; return { ok: false, data: data, err: describeCartError(data) }; } function verifyRestored(data, cand) { const resp = (data && data.response) || {}; const lis = (resp.cart && resp.cart.line_items) || []; const ids = (resp.line_item_ids || []).map(String); const mine = ids.length ? lis.filter((li) => ids.indexOf(String(li.line_item_id)) !== -1) : []; const field = cand.kind === 'bundle' ? 'bundleid' : 'packageid'; if (mine.length > 1) return zh('Steam 一次加回了 ' + mine.length + ' 条(预期 1 条)', 'Steam added ' + mine.length + ' lines'); const pool = mine.length ? mine : lis; if (pool.some((li) => String(li[field] || '') === String(cand.id))) return ''; return zh('加回的购买项对不上(Steam 返回了别的项)', 'Restored item mismatch'); } async function bestOptionOfApp(appid) { try { const items = await fetchStoreItems([{ appid: Number(appid) }]); const si = items[0]; const opt = (si && si.best_purchase_option) || null; if (!opt) return null; if (opt.bundleid) return { kind: 'bundle', id: Number(opt.bundleid), name: si.name || '' }; if (opt.packageid) return { kind: 'package', id: Number(opt.packageid), name: si.name || '' }; } catch (e) { debug('bestOptionOfApp 失败', appid, e); } return null; } /** 旧记录(无购买项 id)按内容重新识别购买项 */ async function resolveTargetByContent(appids) { const idsWant = (appids || []).map(Number).filter((n) => n > 0); if (!idsWant.length) return null; try { const items = await fetchStoreItems(idsWant.map((a) => ({ appid: a }))); const cand = []; const seen = new Set(); const push = (kind, id) => { if (!kind || !id) return; const k = kind + ':' + id; if (seen.has(k)) return; seen.add(k); cand.push({ kind: kind, id: Number(id) }); }; items.forEach((si) => { (si.purchase_options || []).forEach((o) => { if (o.bundleid) push('bundle', o.bundleid); else if (o.packageid) push('package', o.packageid); }); const b = si.best_purchase_option; if (b) { if (b.bundleid) push('bundle', b.bundleid); else if (b.packageid) push('package', b.packageid); } }); if (!cand.length) return null; const detail = await fetchStoreItems(cand.map(kindQuery)); const byId = new Map(); detail.forEach((si) => { if (si && si.id != null) byId.set(String(si.id), si); }); let best = null; cand.forEach((c) => { const si = byId.get(String(c.id)); if (!si || !Array.isArray(si.included_appids)) return; const inc = si.included_appids.map(String); if (!idsWant.every((a) => inc.indexOf(String(a)) !== -1)) return; if (!best || inc.length < best.incLength) best = { kind: c.kind, id: c.id, name: si.name || '', incLength: inc.length, how: zh('按商品内容重新识别', 'resolved by content') }; }); return best; } catch (e) { debug('按内容解析购买项失败', e); return null; } } async function resolveRestorePlan(rec) { if (rec.legacy && rec.appids && rec.appids.length) { const byContent = await resolveTargetByContent(rec.appids); if (byContent) return [byContent]; } if (rec.kind === 'bundle' && rec.bundleid) return [{ kind: 'bundle', id: Number(rec.bundleid) }]; if (rec.kind === 'package' && rec.packageid) return [{ kind: 'package', id: Number(rec.packageid) }]; const appid = num(rec.appid || ((rec.appids && rec.appids.length === 1) ? rec.appids[0] : 0)); if (appid > 0 && rec.kind === 'app') { const one = await bestOptionOfApp(appid); if (one) return [one]; } return []; } function candidateLabel(c) { return c.kind === 'bundle' ? 'bundle ' + c.id : 'package ' + c.id; } // ==================== 恢复(加购) ==================== let busy = false; let busyText = ''; function isBusy() { return busy; } async function executeRestore(ids) { if (busy) { toast(zh('暂存箱正忙,稍后再试', 'Shelf is busy, try later'), 'warning'); return; } const targets = allItems().filter((it) => ids.indexOf(it.id) !== -1 && canRestore(it)); if (!targets.length) { toast(zh('选中的记录里没有可恢复到购物车的项', 'Nothing restorable selected'), 'warning'); return; } // 账号归属优先于登录态:「来自其他账号」比「未登录」更具体, // 若先判登录态会把它掩盖成一句误导性的登录提示 const blocked = targets.filter(foreignAccount); if (blocked.length === targets.length) { toast(zh('这些记录来自其他 Steam 账号,已阻止恢复', 'Records belong to another account — restore blocked'), 'error'); return; } if (!isLoggedIn() && !getAccessToken()) { toast(zh('未登录 Steam,请先登录后再恢复', 'Not logged in — log in first'), 'warning'); return; } const token = getAccessToken(); const cc = getContext().country_code; const added = []; const failed = []; const warns = []; busy = true; setBusy(zh('准备恢复 ' + targets.length + ' 项…', 'Preparing ' + targets.length + ' items…')); try { for (let i = 0; i < targets.length; i++) { const rec = targets[i]; if (foreignAccount(rec)) { failed.push({ name: displayName(rec), why: zh('来自其他 Steam 账号', 'from another account') }); continue; } setBusy(zh('正在恢复 ' + (i + 1) + '/' + targets.length + ':', 'Restoring ') + displayName(rec)); const plan = await resolveRestorePlan(rec); if (!plan.length) { rec.lastError = zh('记录里没有可用的购买项', 'no purchasable option'); putItem(rec, { silent: true }); failed.push({ name: displayName(rec), why: rec.lastError }); continue; } const errs = []; let ok = false; for (const cand of plan) { try { const r = await addOneToCart(token, cc, cand); if (r.ok) { const bad = verifyRestored(r.data, cand); rec.kind = cand.kind; rec.bundleid = cand.kind === 'bundle' ? cand.id : null; rec.packageid = cand.kind === 'package' ? cand.id : null; rec.appid = null; if (cand.name) rec.targetName = cand.name; rec.displayName = rec.targetName || displayName(rec); rec.legacy = false; rec.lastError = null; rec.restoredAt = now(); putItem(rec, { silent: true }); if (bad) warns.push({ name: displayName(rec), why: bad }); added.push(rec.id); ok = true; break; } errs.push(candidateLabel(cand) + ' → ' + r.err); } catch (e) { errs.push(candidateLabel(cand) + ' → ' + ((e && e.message) || zh('请求失败', 'request failed'))); } await sleep(CFG.CANDIDATE_STEP_MS); } if (!ok) { rec.lastError = errs.join(zh(';', '; ')) || zh('未知原因', 'unknown'); putItem(rec, { silent: true }); failed.push({ name: displayName(rec), why: rec.lastError }); } await sleep(CFG.RESTORE_STEP_MS); } } finally { busy = false; setBusy(''); emitChange(); } const parts = []; if (added.length) parts.push(zh('已恢复 ', 'Restored ') + added.length + ' ' + zh('项到购物车', 'items to cart')); if (failed.length) parts.push(zh('失败 ', 'Failed ') + failed.length + ' ' + zh('项', 'items')); if (warns.length) parts.push(zh('有 ', '') + warns.length + ' ' + zh('项 Steam 返回与预期不符', 'items returned unexpected cart content')); toast(parts.join(' · ') || zh('没有可恢复的项目', 'Nothing restored'), failed.length ? 'warning' : 'success'); if (failed.length || warns.length) { const rows = failed.concat(warns).map((f) => '
' + esc(f.name) + '' + esc(f.why) + '
').join(''); showModal({ title: zh('恢复结果', 'Restore result'), body: '
' + (parts.join(' · ') || '') + '
' + rows, buttons: [{ label: zh('知道了', 'OK'), primary: true }, { label: zh('刷新购物车', 'Refresh cart'), act: () => { location.href = 'https://store.steampowered.com/cart/'; } }], }); } return { added: added.length, failed: failed.length, warns: warns.length }; } // ==================== 购物车页:行内暂存 ==================== const CART_REMOVE_RE = /(remove|移除|删除|entfernen|supprimer|eliminar|rimuovi)/i; function cartRoot() { return document.querySelector('[data-featuretarget="react-root"]') || document.querySelector('.responsive_page_template_content') || document.body; } function collectAnchors(root) { const out = { apps: [], bundles: [], subs: [], all: [] }; const seen = new Set(); (root.querySelectorAll('a[href*="/app/"], a[href*="/bundle/"], a[href*="/sub/"]') || []).forEach((a) => { const href = a.getAttribute('href') || ''; const ma = href.match(/\/app\/(\d+)/); const mb = href.match(/\/bundle\/(\d+)/); const ms = href.match(/\/sub\/(\d+)/); if (ma && out.apps.indexOf(Number(ma[1])) === -1) out.apps.push(Number(ma[1])); if (mb && out.bundles.indexOf(Number(mb[1])) === -1) out.bundles.push(Number(mb[1])); if (ms && out.subs.indexOf(Number(ms[1])) === -1) out.subs.push(Number(ms[1])); const key = href; if (!seen.has(key)) { seen.add(key); out.all.push({ href: href, el: a }); } }); return out; } function nearestRowWith(el, root) { let n = el; for (let d = 0; n && d < 14; d++) { const anchors = collectAnchors(n); if (anchors.apps.length || anchors.bundles.length || anchors.subs.length) { if (n !== root && n.querySelector && n.querySelector('button, a')) return n; } n = n.parentElement; } return null; } function removeActionEl(rowEl) { let found = null; (rowEl.querySelectorAll('button, a') || []).forEach((el) => { if (found) return; const t = ((el.textContent || '') + ' ' + (el.getAttribute('aria-label') || '') + ' ' + (el.title || '')).trim(); if (t && CART_REMOVE_RE.test(t)) found = el; }); return found; } function rowKey(row) { return [row.appids.join('.'), row.bundleIds.join('.'), row.subIds.join('.')].join('|'); } function scanCartRows() { const root = cartRoot(); if (!root) return []; const rows = []; const seen = new Set(); collectAnchors(root).all.forEach((a) => { const rowEl = nearestRowWith(a.el, root); if (!rowEl) return; const key = rowEl.__sgshRowKey || (rowEl.__sgshRowKey = uid('row')); if (seen.has(key)) return; const an = collectAnchors(rowEl); if (!an.apps.length && !an.bundles.length && !an.subs.length) return; seen.add(key); rows.push({ rowEl: rowEl, key: key, appids: an.apps, bundleIds: an.bundles, subIds: an.subs, removeBtn: removeActionEl(rowEl), }); }); return rows; } /** 行内注入「暂存」按钮;检测到 SCE 已注入 .sce-shelve-btn 时跳过,避免双按钮 */ function injectCartButtons() { if (!onCartPage()) return 0; let n = 0; scanCartRows().forEach((row) => { if (row.rowEl.querySelector('.sgsh-shelve-btn') || row.rowEl.querySelector('.sce-shelve-btn')) return; const btn = document.createElement('button'); btn.className = 'sgsh-shelve-btn'; btn.type = 'button'; btn.textContent = zh('暂存', 'Stash'); btn.title = zh('加入暂存箱(会从购物车移除)', 'Move to shelf (removes from cart)'); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); shelveOneRow(row); }); const act = removeActionEl(row.rowEl); if (act && act.parentElement) act.parentElement.insertBefore(btn, act); else row.rowEl.appendChild(btn); n++; }); return n; } function onCartPage() { try { return /^\/cart\/?(\?|$)/i.test(location.pathname); } catch (e) { return false; } } async function buildCartIndex() { const token = getAccessToken(); if (!token) return { entries: [], ok: false, error: zh('未登录 Steam,读不到登录令牌', 'Not logged in: no access token') }; let lineItems; try { lineItems = await fetchLineItems(token); } catch (e) { return { entries: [], ok: false, error: zh('读取购物车接口失败:', 'GetCart failed: ') + ((e && e.message) || e) }; } const targets = lineItems.map((li) => ( li.bundleid ? { kind: 'bundle', id: Number(li.bundleid) } : li.packageid ? { kind: 'package', id: Number(li.packageid) } : null )).filter(Boolean); let storeItems = []; if (targets.length) { try { storeItems = await fetchStoreItems(targets.map(kindQuery)); } catch (e) { debug('读取购买项内容失败', e); } } const byKey = new Map(); storeItems.forEach((si) => { if (!si || si.id == null) return; const t = targets.find((x) => String(x.id) === String(si.id)); const kind = (t && t.kind) || (si.item_type === 2 ? 'bundle' : 'package'); byKey.set(kind + ':' + si.id, si); }); return { ok: true, error: '', entries: lineItems.map((li, i) => { const t = targets[i]; if (!t) return null; const si = byKey.get(t.kind + ':' + t.id) || null; return { lineItemId: li.lineItemId, kind: t.kind, id: t.id, name: (si && si.name) || '', included: (si && Array.isArray(si.included_appids)) ? si.included_appids.map(String) : [], }; }).filter(Boolean), }; } function classifyRow(row, index) { const ents = ((index && index.entries) || []).filter((e) => e && e.kind); if (!ents.length) return null; const apps = (row.appids || []).map(String); if (apps.length) { const cover = ents.filter((e) => e.included.length && apps.every((a) => e.included.indexOf(a) !== -1)); if (cover.length) { const exact = cover.filter((e) => e.included.length === apps.length); const pool = exact.length ? exact : cover; pool.sort((a, b) => a.included.length - b.included.length); return { kind: pool[0].kind, id: pool[0].id, name: pool[0].name, lineItemId: pool[0].lineItemId, how: exact.length ? zh('内容与购物车里那一项一致', 'content matches cart line') : zh('内容都属于购物车里那一项', 'content belongs to cart line'), }; } } const links = []; (row.bundleIds || []).forEach((id) => links.push({ kind: 'bundle', id: num(id), how: '/bundle/' })); (row.subIds || []).forEach((id) => links.push({ kind: 'package', id: num(id), how: '/sub/' })); for (const l of links) { const hit = ents.find((e) => e.kind === l.kind && String(e.id) === String(l.id)); if (hit) return { kind: hit.kind, id: hit.id, name: hit.name, lineItemId: hit.lineItemId, how: l.how }; } return null; } function buildRecord(row, target) { const rec = normalizeRecord({ id: uid('sh'), kind: target.kind, bundleid: target.kind === 'bundle' ? target.id : null, packageid: target.kind === 'package' ? target.id : null, appid: null, targetName: target.name || '', targetHow: target.how || '', lineItemId: target.lineItemId != null ? target.lineItemId : null, appids: row.appids.slice(), names: {}, endTsMap: {}, endTs: 0, pct: 0, count: row.appids.length, removedAt: now(), addedAt: now(), source: 'cart', steamid: currentSteamId(), }, 'interop'); return rec; } /** 单行暂存(无确认弹窗,与 SCE 行内行为一致) */ async function shelveOneRow(row) { if (busy) { toast(zh('暂存箱正忙,稍后再试', 'Shelf is busy, try later'), 'warning'); return; } const index = await buildCartIndex(); if (!index.ok) { toast(index.error, 'error'); return; } let target = classifyRow(row, index); if (!target && row.appids.length === 1) { target = { kind: 'app', id: num(row.appids[0]), name: '', lineItemId: null, how: zh('单个商品,恢复时按商店当前购买项', 'single app; resolved at restore time') }; } if (!target) { showModal({ title: zh('无法暂存这一项', 'Cannot stash this line'), body: '
' + zh('这一行认不出对应的购买项(bundle/sub)。为避免暂存错东西,已中止。', 'Cannot resolve the purchase option for this line; aborted to avoid a wrong record.') + '
', buttons: [{ label: zh('知道了', 'OK'), primary: true }] }); return; } const rec = buildRecord(row, target); const ok = doShelveRows([{ row: row, rec: rec }]); if (ok) toast(zh('已暂存:', 'Stashed: ') + displayName(rec), 'success'); } /** 批量暂存(确认弹窗 → 执行) */ function shelveAllRows() { if (busy) { toast(zh('暂存箱正忙,稍后再试', 'Shelf is busy, try later'), 'warning'); return; } const rows = scanCartRows(); if (!rows.length) { toast(zh('购物车里没有可暂存的行', 'No cart rows found'), 'warning'); return; } showModal({ title: zh('暂存购物车', 'Stash whole cart'), body: '
' + zh('将把购物车里的 ', 'Moves ') + rows.length + ' ' + zh('项移出购物车并存入暂存箱:', 'lines out of the cart into the shelf:') + '
' + '
    ' + rows.map((r) => '
  • ' + esc((r.appids[0] ? 'App ' + r.appids[0] : (r.bundleIds[0] ? 'Bundle ' + r.bundleIds[0] : 'Package ' + (r.subIds[0] || '?')))) + '
  • ').join('') + '
' + '
' + zh('整包内只要有一项认不出购买项,该行会被跳过。', 'Lines whose purchase option cannot be resolved will be skipped.') + '
', buttons: [ { label: zh('取消', 'Cancel') }, { label: zh('确认暂存 ' + rows.length + ' 项', 'Stash ' + rows.length), primary: true, act: async () => { await doShelveRows(rows.map((r) => ({ row: r, rec: null }))); } }, ], }); } async function doShelveRows(items) { if (busy) return false; busy = true; let done = 0; let fail = 0; try { const index = await buildCartIndex(); if (!index.ok) { toast(index.error, 'error'); return false; } for (let i = 0; i < items.length; i++) { const it = items[i]; let rec = it.rec; if (!rec) { const t = classifyRow(it.row, index) || (it.row.appids.length === 1 ? { kind: 'app', id: num(it.row.appids[0]), name: '', lineItemId: null, how: zh('单个商品', 'single app') } : null); if (!t) { fail++; continue; } rec = buildRecord(it.row, t); } setBusy(zh('正在暂存 ' + (done + fail + 1) + '/' + items.length + ':', 'Stashing ') + displayName(rec)); let btn = it.row.removeBtn; if (!btn || !btn.isConnected) btn = removeActionEl(it.row.rowEl); if (!btn || !btn.isConnected) { const again = scanCartRows().find((r) => rowKey(r) === rowKey(it.row)); btn = again && (again.removeBtn || removeActionEl(again.rowEl)); } if (!btn || !btn.isConnected) { fail++; continue; } try { btn.click(); } catch (e) { fail++; continue; } putItem(rec, { silent: true }); done++; await sleep(CFG.SHELVE_STEP_MS); } } finally { busy = false; setBusy(''); emitChange(); } if (done) toast(zh('已暂存 ', 'Stashed ') + done + ' ' + zh('项', 'items'), 'success'); if (fail) toast(zh('有 ', '') + fail + ' ' + zh('项未暂存(找不到移除按钮或认不出购买项)', 'items not stashed (no remove button / unresolvable)'), 'warning'); if (done) setTimeout(() => { try { location.reload(); } catch (e) { /* ignore */ } }, 700); return done > 0; } // ==================== 公开:新增记录 ==================== async function resolveName(appid) { if (Host && typeof Host.resolveName === 'function') { try { const n = await Host.resolveName(appid); if (n) return n; } catch (e) { /* ignore */ } } try { const items = await fetchStoreItems([{ appid: num(appid) }]); if (items[0] && items[0].name) return items[0].name; } catch (e) { /* ignore */ } return ''; } /** 统一入口:{kind, appid/bundleid/packageid, url, title, note, tags, endTs, pct} */ async function addItem(input) { const inp = Object.assign({}, input || {}); let kind = inp.kind; if (!kind) { if (inp.url) kind = 'note'; else if (inp.bundleid) kind = 'bundle'; else if (inp.packageid) kind = 'package'; else kind = inp.appid ? 'app' : 'note'; } const rec = normalizeRecord({ id: inp.id || uid('sh'), kind: kind, appid: inp.appid != null ? num(inp.appid) : null, bundleid: inp.bundleid != null ? num(inp.bundleid) : null, packageid: inp.packageid != null ? num(inp.packageid) : null, appids: Array.isArray(inp.appids) ? inp.appids : (inp.appid ? [num(inp.appid)] : []), names: inp.names || {}, targetName: inp.title || inp.name || '', displayName: inp.title || inp.name || '', note: inp.note || '', url: inp.url || '', tags: inp.tags || [], endTs: num(inp.endTs), pct: num(inp.pct), price: num(inp.price), currency: inp.currency || '', source: inp.source || 'manual', steamid: currentSteamId(), addedAt: now(), removedAt: now(), }, bucketOf(kind)); if (isInteropKind(kind) && !rec.appid && !rec.bundleid && !rec.packageid) { toast(zh('缺少商品 ID,未保存', 'Missing product id'), 'warning'); return null; } // 名称补全(app/wishlist 且未命名) if (!rec.displayName && (kind === 'app' || kind === 'wishlist') && rec.appid) { rec.displayName = await resolveName(rec.appid); if (rec.displayName) rec.targetName = rec.displayName; } const saved = putItem(rec); if (saved) toast(zh('已加入暂存箱:', 'Added to shelf: ') + displayName(saved), 'success'); return saved; } function addWishlist(appid, name, note) { return addItem({ kind: 'wishlist', appid: appid, title: name, note: note, source: 'wishlist' }); } function addNote(url, title, note, tags) { return addItem({ kind: 'note', url: url, title: title, note: note, tags: tags, source: 'manual' }); } // ==================== 导入 / 导出 ==================== function buildExport(opts) { const o = opts || {}; let items = allItems(); if (o.compatOnly) items = items.filter((r) => isInteropKind(r.kind)); const interopRaw = readRaw(CFG.INTEROP_KEY) || {}; return { format: 'sglv-shelf', version: 2, exportedAt: now(), source: 'SGLV 暂存箱', compatOnly: !!o.compatOnly, counts: { cart: items.filter((r) => isInteropKind(r.kind)).length, wishlist: items.filter((r) => r.kind === 'wishlist').length, note: items.filter((r) => r.kind === 'note').length, }, days: typeof interopRaw.days === 'number' ? interopRaw.days : 3, items: items, }; } /** * 导入:兼容三种形态 * a) 本库导出体 {format:'sglv-shelf', items:[...]} * b) SCE 原样体 {items:[...], days, tab} * c) 裸数组 [...] * 返回 {added, updated, skipped} */ function parseImport(text) { let data = null; try { data = JSON.parse(String(text || '').trim()); } catch (e) { return { ok: false, error: zh('JSON 解析失败:', 'JSON parse failed: ') + (e.message || e) }; } let list = null; if (Array.isArray(data)) list = data; else if (data && Array.isArray(data.items)) list = data.items; if (!list) return { ok: false, error: zh('没有找到 items 数组', 'No items array found') }; let added = 0; let updated = 0; let skipped = 0; const interopNew = []; const extraNew = []; list.forEach((raw) => { if (!raw || typeof raw !== 'object') { skipped++; return; } const kind = resolveKind(raw); const bucket = bucketOf(kind); // 互通桶必须自带 id:SCE 读取时会丢弃无 id 的记录, // 放进去等于「导入成功但下次 SCE 保存时静默消失」 if (bucket === 'interop' && !raw.id) { skipped++; return; } const rec = normalizeRecord(raw, bucket); if (bucket === 'interop' && !sceAcceptable(raw)) { skipped++; return; } if (bucket === 'interop') interopNew.push(rec); else extraNew.push(rec); }); if (interopNew.length) { const cur = readBucket('interop').items; interopNew.forEach((rec) => { const idx = cur.findIndex((x) => sameTarget(x, rec)); if (idx >= 0) { cur[idx] = Object.assign({}, cur[idx], rec, { id: cur[idx].id }); updated++; } else { cur.unshift(rec); added++; } }); writeBucket('interop', cur); } if (extraNew.length) { const cur = readBucket('extra').items; extraNew.forEach((rec) => { const idx = cur.findIndex((x) => sameTarget(x, rec)); if (idx >= 0) { cur[idx] = Object.assign({}, cur[idx], rec, { id: cur[idx].id }); updated++; } else { cur.unshift(rec); added++; } }); writeBucket('extra', cur); } emitChange(); return { ok: true, added: added, updated: updated, skipped: skipped }; } /** 去重比较:同 kind 同 id 视为同一条(id 不同也不重复入库) */ function sameTarget(a, b) { if (!a || !b) return false; if (a.id && b.id && a.id === b.id) return true; if (a.kind !== b.kind) return false; if (a.kind === 'bundle') return !!a.bundleid && String(a.bundleid) === String(b.bundleid); if (a.kind === 'package') return !!a.packageid && String(a.packageid) === String(b.packageid); if (a.kind === 'note') return !!a.url && a.url === b.url; return !!a.appid && String(a.appid) === String(b.appid); } // ==================== UI:浮窗 ==================== let overlayEl = null; let panelEl = null; let modalEl = null; let modalOnClose = null; let tickTimer = null; let isOpenFlag = false; let uiState = { filter: 'all', sort: 'end', q: '', compatOnly: false }; /* ================================================================= * CSS —— steam-industrial token 体系 * 参考 steam-industrial-theme.css(oil-ui 方法论): * 高信息密度 · 小圆角(工业风) · 左侧色条强调 · 斑马纹列表 · 大写小标签 * 表面/文字用 Steam 原生 #171a21 色阶(dark/purple 主题下均稳定); * 强调色走 var(--sglv-accent),跟随主脚本主题切换(color-mix 派生阶)。 * ============================================================== */ const CSS = ` /* ---------- 0. Design Tokens(作用域:浮窗 / 弹窗 / 购物车行内按钮) ---------- */ .sgsh-panel,.sgsh-modal,.sgsh-shelve-btn{ /* 表面 */ --sgsh-bg:#171a21; --sgsh-bg-2:#1b2838; --sgsh-bg-3:#2a3040; --sgsh-bg-4:#333c4e; --sgsh-border:#3d4450; --sgsh-border-2:#2a3040; /* 文字 */ --sgsh-text:#c6d4df; --sgsh-text-2:#8f98a0; --sgsh-text-3:#5c6b7a; --sgsh-heading:#e5e9ed; /* 强调(跟随主题) */ --sgsh-accent:var(--sglv-accent,#66c0f4); --sgsh-accent-wash:color-mix(in srgb,var(--sgsh-accent) 8%,transparent); --sgsh-accent-soft:color-mix(in srgb,var(--sgsh-accent) 16%,transparent); --sgsh-accent-line:color-mix(in srgb,var(--sgsh-accent) 42%,transparent); /* 语义色 */ --sgsh-gold:#ffb74c; --sgsh-gold-text:#d4a853; --sgsh-red:#d94a4a; --sgsh-red-text:#e07070; --sgsh-red-wash:color-mix(in srgb,var(--sgsh-red) 14%,transparent); --sgsh-red-line:color-mix(in srgb,var(--sgsh-red) 42%,transparent); --sgsh-green:#7ba82a; --sgsh-sky:#7dd3fc; /* 刻度 */ --sgsh-r:3px; --sgsh-ease:150ms ease; } /* ---------- 1. 窗体 ---------- */ .sgsh-overlay { background: rgba(8,11,17,0.72) !important; } .sgsh-panel { width: 920px !important; height: 82vh !important; background: var(--sgsh-bg) !important; border: 1px solid var(--sgsh-border) !important; border-radius: 4px !important; box-shadow: 0 18px 60px rgba(0,0,0,.55) !important; } .sgsh-panel .sglv-header { background: var(--sgsh-bg-2) !important; border-bottom: 1px solid var(--sgsh-border); } /* ---------- 2. 头部 ---------- */ .sgsh-head { display:flex; align-items:center; gap:10px; } .sgsh-head-icon { width:24px; height:24px; display:flex; align-items:center; justify-content:center; background: var(--sgsh-accent-wash); border:1px solid var(--sgsh-accent-line); border-radius: var(--sgsh-r); color: var(--sgsh-accent); font-size:13px; } .sgsh-head-title { font-size:14px; font-weight:600; color:var(--sgsh-heading); letter-spacing:.3px; } .sgsh-count { min-width:22px; height:18px; padding:0 7px; border-radius:2px; font-size:11px; font-weight:700; display:inline-flex; align-items:center; justify-content:center; color:var(--sgsh-gold-text); background:rgba(255,183,76,.12); border:1px solid rgba(255,183,76,.35); font-variant-numeric:tabular-nums; } /* ---------- 3. 按钮(扁平 + 描边) ---------- */ .sgsh-btn { border:1px solid var(--sgsh-border-2); border-radius:var(--sgsh-r); padding:5px 11px; font-size:11px; font-weight:600; letter-spacing:.3px; cursor:pointer; background:var(--sgsh-bg-3); color:var(--sgsh-text); display:inline-flex; align-items:center; gap:5px; transition:color var(--sgsh-ease), background var(--sgsh-ease), border-color var(--sgsh-ease); } .sgsh-btn:hover { background:var(--sgsh-bg-4); color:var(--sgsh-heading); border-color:var(--sgsh-border); } .sgsh-btn.primary { background:var(--sgsh-accent-soft); border-color:var(--sgsh-accent-line); color:var(--sgsh-accent); } .sgsh-btn.primary:hover { background:color-mix(in srgb,var(--sgsh-accent) 26%,transparent); border-color:var(--sgsh-accent); } .sgsh-btn.danger { background:transparent; border-color:var(--sgsh-red-line); color:var(--sgsh-red-text); } .sgsh-btn.danger:hover { background:var(--sgsh-red-wash); color:#fff; } .sgsh-btn:disabled { opacity:.4; cursor:not-allowed; } /* ---------- 4. 工具栏(搜索 / 筛选 / 排序) ---------- */ .sgsh-toolbar { display:flex; align-items:center; gap:10px; padding:10px 14px; flex-shrink:0; border-bottom:1px solid var(--sgsh-border-2); background:var(--sgsh-bg); flex-wrap:wrap; } .sgsh-search { flex:1; min-width:160px; padding:6px 10px; border-radius:var(--sgsh-r); font-size:12px; background:var(--sgsh-bg-2); border:1px solid var(--sgsh-border-2); color:var(--sgsh-text); outline:none; transition:border-color var(--sgsh-ease), box-shadow var(--sgsh-ease); } .sgsh-search::placeholder { color:var(--sgsh-text-3); } .sgsh-search:focus { border-color:var(--sgsh-accent-line); box-shadow:0 0 0 1px var(--sgsh-accent-wash); } .sgsh-chips { display:flex; gap:4px; } .sgsh-chip { border:1px solid var(--sgsh-border-2); background:transparent; color:var(--sgsh-text-2); border-radius:var(--sgsh-r); padding:4px 11px; font-size:11px; font-weight:600; cursor:pointer; transition:color var(--sgsh-ease), background var(--sgsh-ease), border-color var(--sgsh-ease); } .sgsh-chip:hover { color:var(--sgsh-text); border-color:var(--sgsh-border); } .sgsh-chip.active { background:var(--sgsh-accent-soft); color:var(--sgsh-accent); border-color:var(--sgsh-accent-line); } .sgsh-sort { padding:6px 9px; border-radius:var(--sgsh-r); font-size:11px; background:var(--sgsh-bg-2); border:1px solid var(--sgsh-border-2); color:var(--sgsh-text); outline:none; cursor:pointer; transition:border-color var(--sgsh-ease); } .sgsh-sort:focus { border-color:var(--sgsh-accent-line); } /* ---------- 5. 摘要条 ---------- */ .sgsh-summary { padding:7px 14px; font-size:11px; color:var(--sgsh-text-2); border-bottom:1px solid var(--sgsh-border-2); display:flex; align-items:center; gap:12px; flex-shrink:0; font-variant-numeric:tabular-nums; } .sgsh-summary b { color:var(--sgsh-accent); } .sgsh-busy { color:var(--sgsh-gold-text); font-weight:600; } /* ---------- 6. 列表(斑马纹 + 左侧色条强调,替代卡片流) ---------- */ .sgsh-body { flex:1; overflow-y:auto; padding:8px 14px 12px; } .sgsh-body::-webkit-scrollbar { width:6px; } .sgsh-body::-webkit-scrollbar-track { background:transparent; } .sgsh-body::-webkit-scrollbar-thumb { background:var(--sgsh-border-2); border-radius:3px; } .sgsh-body::-webkit-scrollbar-thumb:hover { background:var(--sgsh-border); } .sgsh-row { display:flex; align-items:flex-start; gap:10px; padding:9px 12px 9px 9px; border-left:3px solid transparent; border-bottom:1px solid var(--sgsh-border-2); transition:background var(--sgsh-ease), border-color var(--sgsh-ease); } .sgsh-row:first-child { border-top:1px solid var(--sgsh-border-2); } .sgsh-row:nth-child(odd) { background:rgba(255,255,255,.02); } .sgsh-row:hover { background:var(--sgsh-accent-wash); border-left-color:var(--sgsh-accent); } .sgsh-row.is-foreign { border-left-color:var(--sgsh-red); } .sgsh-kindtag { flex-shrink:0; font-size:9px; font-weight:700; padding:2px 6px; border-radius:2px; letter-spacing:.8px; text-transform:uppercase; background:var(--sgsh-accent-wash); color:var(--sgsh-accent); border:1px solid var(--sgsh-accent-line); margin-top:1px; } .sgsh-kindtag.wishlist { background:rgba(125,211,252,.1); color:var(--sgsh-sky); border-color:rgba(125,211,252,.32); } .sgsh-kindtag.note { background:rgba(255,183,76,.1); color:var(--sgsh-gold-text); border-color:rgba(255,183,76,.32); } .sgsh-main { flex:1; min-width:0; } .sgsh-name { font-size:13px; font-weight:600; color:var(--sgsh-heading); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .sgsh-meta { display:flex; flex-wrap:wrap; gap:8px; margin-top:3px; font-size:10.5px; color:var(--sgsh-text-2); font-variant-numeric:tabular-nums; } .sgsh-meta .sev1 { color:var(--sgsh-red-text); font-weight:600; } .sgsh-meta .sev2 { color:var(--sgsh-gold-text); font-weight:600; } .sgsh-meta .sev3 { color:var(--sgsh-green); } .sgsh-meta .sgsh-warn { color:var(--sgsh-gold-text); } .sgsh-note { margin-top:4px; font-size:11px; color:var(--sgsh-text-2); background:rgba(255,255,255,.03); border-left:2px solid var(--sgsh-accent-line); padding:4px 8px; border-radius:0 var(--sgsh-r) var(--sgsh-r) 0; word-break:break-all; } .sgsh-acts { display:flex; gap:4px; flex-shrink:0; } .sgsh-act { width:26px; height:26px; border-radius:var(--sgsh-r); border:1px solid var(--sgsh-border-2); background:transparent; color:var(--sgsh-text-2); cursor:pointer; font-size:12px; line-height:1; display:flex; align-items:center; justify-content:center; transition:color var(--sgsh-ease), background var(--sgsh-ease), border-color var(--sgsh-ease); } .sgsh-act:hover { background:var(--sgsh-accent-soft); color:var(--sgsh-accent); border-color:var(--sgsh-accent-line); } .sgsh-act.del:hover { background:var(--sgsh-red-wash); color:var(--sgsh-red-text); border-color:var(--sgsh-red-line); } .sgsh-act:disabled { opacity:.35; cursor:not-allowed; } .sgsh-empty { text-align:center; padding:50px 20px; color:var(--sgsh-text-3); font-size:12px; line-height:1.9; } .sgsh-foot { display:flex; align-items:center; gap:8px; padding:10px 14px; border-top:1px solid var(--sgsh-border); background:var(--sgsh-bg-2); flex-shrink:0; } .sgsh-foot .sp { flex:1; } /* ---------- 7. 购物车页行内暂存按钮 ---------- */ .sgsh-shelve-btn { margin:0 6px; padding:2px 9px; font-size:10px; font-weight:700; letter-spacing:.5px; cursor:pointer; border-radius:2px; border:1px solid var(--sgsh-accent-line); background:var(--sgsh-accent-wash); color:var(--sgsh-accent); transition:background var(--sgsh-ease), color var(--sgsh-ease), border-color var(--sgsh-ease); } .sgsh-shelve-btn:hover { background:var(--sgsh-accent-soft); border-color:var(--sgsh-accent); color:var(--sgsh-accent); } /* ---------- 8. 弹窗 ---------- */ .sgsh-modal-mask { position:fixed; inset:0; z-index:1000001; background:rgba(8,11,17,.7); display:flex; align-items:center; justify-content:center; } .sgsh-modal { width:520px; max-width:calc(100vw - 40px); max-height:82vh; display:flex; flex-direction:column; background:var(--sgsh-bg); border:1px solid var(--sgsh-border); border-radius:4px; box-shadow:0 18px 60px rgba(0,0,0,.6); overflow:hidden; } .sgsh-modal-head { padding:12px 16px; font-size:13px; font-weight:600; color:var(--sgsh-heading); background:var(--sgsh-bg-2); border-bottom:1px solid var(--sgsh-border); display:flex; align-items:center; letter-spacing:.3px; } .sgsh-modal-body { padding:14px 16px; overflow-y:auto; font-size:12px; color:var(--sgsh-text); line-height:1.7; } .sgsh-modal-foot { padding:11px 16px; border-top:1px solid var(--sgsh-border-2); display:flex; gap:8px; justify-content:flex-end; } .sgsh-m-lead { margin-bottom:9px; color:var(--sgsh-text); } .sgsh-m-lead b { color:var(--sgsh-accent); font-variant-numeric:tabular-nums; } .sgsh-m-warn { margin-top:9px; padding:7px 10px; border-radius:0 var(--sgsh-r) var(--sgsh-r) 0; font-size:11px; background:rgba(255,183,76,.07); border-left:2px solid var(--sgsh-gold); color:var(--sgsh-gold-text); line-height:1.5; } .sgsh-m-list { margin:6px 0 0; padding-left:18px; max-height:220px; overflow-y:auto; } .sgsh-m-line { display:flex; flex-direction:column; padding:5px 0; border-bottom:1px dashed var(--sgsh-border-2); } .sgsh-m-line span { color:var(--sgsh-text-2); font-size:11px; word-break:break-all; } /* ---------- 9. 表单 ---------- */ .sgsh-field { margin-bottom:11px; } .sgsh-field label { display:block; font-size:10px; color:var(--sgsh-text-2); margin-bottom:5px; font-weight:700; text-transform:uppercase; letter-spacing:.6px; } .sgsh-field input, .sgsh-field textarea, .sgsh-field select { width:100%; box-sizing:border-box; padding:7px 10px; border-radius:var(--sgsh-r); font-size:12px; background:var(--sgsh-bg-2); border:1px solid var(--sgsh-border-2); color:var(--sgsh-text); outline:none; font-family:inherit; transition:border-color var(--sgsh-ease); } .sgsh-field textarea { min-height:110px; resize:vertical; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:11px; } .sgsh-field input:focus, .sgsh-field textarea:focus, .sgsh-field select:focus { border-color:var(--sgsh-accent-line); } .sgsh-field .hint { font-size:10.5px; color:var(--sgsh-text-3); margin-top:4px; } .sgsh-check { display:flex; align-items:center; gap:7px; font-size:11.5px; color:var(--sgsh-text-2); margin-bottom:10px; cursor:pointer; } .sgsh-check input { width:auto; } /* ---------- 10. 侧栏角标 ---------- */ .sgsh-badge { position:absolute; top:-3px; right:-3px; min-width:16px; height:15px; padding:0 4px; border-radius:8px; font-size:10px; font-weight:700; color:#fff; background:var(--sgsh-red); display:flex; align-items:center; justify-content:center; pointer-events:none; border:1.5px solid var(--sgsh-bg); } `; function ensureCss() { if (document.getElementById('sgsh-css')) return; const st = document.createElement('style'); st.id = 'sgsh-css'; st.textContent = CSS; (document.head || document.documentElement).appendChild(st); } function ensureUI() { if (panelEl) return; ensureCss(); overlayEl = document.createElement('div'); overlayEl.className = 'sglv-overlay sgsh-overlay'; overlayEl.addEventListener('click', closeShelf); document.body.appendChild(overlayEl); panelEl = document.createElement('div'); panelEl.className = 'sglv-panel sgsh-panel'; panelEl.innerHTML = `
📦
${zh('暂存箱', 'Shelf')}
0
`; document.body.appendChild(panelEl); panelEl.addEventListener('click', onPanelClick); panelEl.querySelector('#sgsh-q').addEventListener('input', (e) => { uiState.q = e.target.value; renderList(); }); panelEl.querySelector('#sgsh-sort').addEventListener('change', (e) => { uiState.sort = e.target.value; setPrefs({ sort: uiState.sort }); renderList(); }); document.addEventListener('keydown', onKeydown); window.addEventListener('storage', onStorage); renderChips(); } function onKeydown(e) { if (e.key === 'Escape') { if (modalEl) { closeModal(); return; } if (isOpenFlag) closeShelf(); } } /** 跨脚本热更新:SCE 改了互通桶 → 同步角标与列表 */ function onStorage(e) { if (!e || (e.key && e.key !== CFG.INTEROP_KEY && e.key !== CFG.EXTRA_KEY)) return; emitChange(); if (isOpenFlag) render(); } function setBadge(n) { try { document.querySelectorAll('[data-sgsh-badge]').forEach((el) => { const v = num(n); el.textContent = v > 99 ? '99+' : String(v); el.style.display = v > 0 ? '' : 'none'; }); } catch (e) { /* ignore */ } } function renderChips() { const box = panelEl && panelEl.querySelector('#sgsh-chips'); if (!box) return; box.innerHTML = FILTER_META.map((f) => ( `` )).join(''); } function visibleItems() { let list = allItems(); if (uiState.filter !== 'all') { list = list.filter((r) => (KIND_META[r.kind] || {}).tag === uiState.filter); } const q = uiState.q.trim().toLowerCase(); if (q) { list = list.filter((r) => { const hay = [displayName(r), r.note, (r.tags || []).join(' '), r.appid, r.bundleid, r.packageid, r.url] .filter(Boolean).join(' ').toLowerCase(); return hay.indexOf(q) !== -1; }); } const sort = uiState.sort; list.sort((a, b) => { if (sort === 'name') return displayName(a).localeCompare(displayName(b)); if (sort === 'added') return (num(b.addedAt) || num(b.removedAt)) - (num(a.addedAt) || num(a.removedAt)); const ae = num(a.endTs) || Infinity; const be = num(b.endTs) || Infinity; if (ae !== be) return ae - be; return (num(a.addedAt) || num(a.removedAt)) - (num(b.addedAt) || num(b.removedAt)); }); return list; } function rowHtml(rec) { const name = displayName(rec); const km = KIND_META[rec.kind] || { label: rec.kind, tag: 'cart' }; const meta = []; if (rec.count > 1) meta.push(zh('含 ', 'incl. ') + rec.count + ' ' + zh('项', 'items')); if (rec.pct) meta.push('-' + rec.pct + '%'); if (num(rec.endTs)) { const s = severity(rec.endTs); meta.push('' + esc(absTime(rec.endTs)) + ' · ' + esc(remainText(rec.endTs)) + ''); } if (rec.price) meta.push((rec.currency || '') + ' ' + rec.price); meta.push(rec.source === 'cart' ? zh('来自购物车', 'from cart') : rec.source === 'wishlist' ? zh('来自愿望单', 'from wishlist') : zh('手动', 'manual')); if (foreignAccount(rec)) meta.push('' + zh('⚠ 其他账号写入', '⚠ other account') + ''); if (rec.lastError) meta.push('' + esc(rec.lastError) + ''); if ((rec.tags || []).length) meta.push('#' + rec.tags.map(esc).join(' #')); const acts = []; if (canRestore(rec)) acts.push(''); const url = linkOf(rec); if (url) acts.push(''); if (rec.url) acts.push(''); acts.push(''); acts.push(''); return `
${esc(km.label)}
${esc(name)}
${meta.join('·')}
${rec.note ? '
' + esc(rec.note) + '
' : ''}
${acts.join('')}
`; } function renderList() { const body = panelEl && panelEl.querySelector('#sgsh-body'); if (!body) return; const list = visibleItems(); const total = countAll(); const cnt = panelEl.querySelector('#sgsh-count'); if (cnt) cnt.textContent = String(total); const share = panelEl.querySelector('#sgsh-share'); if (share) { const nInterop = readBucket('interop').items.length; share.textContent = zh('与购物车增强互通 ', 'shared with cart script: ') + nInterop + ' · ' + zh('独享 ', 'private: ') + (total - nInterop); } const restoreable = list.filter(canRestore).length; const soonest = list.filter((r) => num(r.endTs)).sort((a, b) => a.endTs - b.endTs)[0]; const sum = panelEl.querySelector('#sgsh-summary'); if (sum) { sum.innerHTML = `${zh('共 ', 'Total ') + '' + list.length + '' + (list.length !== total ? zh('(全部 ' + total + ')', ' of ' + total) : '')}` + `${zh('可恢复 ', 'Restorable ') + '' + restoreable + ''}` + (soonest ? `${zh('最近到期 ', 'Next expiry ') + '' + esc(remainText(soonest.endTs)) + ''}` : '') + (busy ? `${esc(busyText)}` : ''); } const cartBtn = panelEl.querySelector('#sgsh-shelve-cart'); if (cartBtn) { cartBtn.style.display = onCartPage() ? '' : 'none'; cartBtn.disabled = busy; } const restoreAllBtn = panelEl.querySelector('[data-act="restore-all"]'); if (restoreAllBtn) restoreAllBtn.disabled = busy || restoreable === 0; if (!list.length) { body.innerHTML = `
📦
${total ? zh('没有符合当前筛选的记录', 'No record matches the filter') : zh('暂存箱是空的。
在购物车页点行内「暂存」、或用右上角「+ 新建」记录备忘与外部链接。', 'Shelf is empty.
Use the row-level “Stash” on /cart, or “+ New” to save notes and links.')}
`; return; } body.innerHTML = list.map(rowHtml).join(''); } function render() { renderList(); } function setBusy(text) { busyText = text || ''; if (isOpenFlag) renderList(); } function openShelf() { if (!_hostReady) { toast(zh('暂存箱模块未就绪', 'Shelf module not ready'), 'warning'); return; } ensureUI(); const p = getPrefs(); uiState.filter = p.filter; uiState.sort = p.sort; uiState.compatOnly = p.compatOnly; isOpenFlag = true; overlayEl.classList.add('sglv-show'); panelEl.classList.add('sglv-show'); const q = panelEl.querySelector('#sgsh-q'); if (q) q.value = uiState.q; const s = panelEl.querySelector('#sgsh-sort'); if (s) s.value = uiState.sort; renderChips(); renderList(); startTick(); injectCartButtons(); } function closeShelf() { if (!panelEl) return; isOpenFlag = false; stopTick(); closeModal(); overlayEl.classList.remove('sglv-show'); panelEl.classList.remove('sglv-show'); } function isOpen() { return isOpenFlag; } function toggleShelf() { if (isOpenFlag) closeShelf(); else openShelf(); } function startTick() { stopTick(); tickTimer = setInterval(() => { if (!isOpenFlag) { stopTick(); return; } const nodes = document.querySelectorAll('#sgsh-body [data-end]'); for (let i = 0; i < nodes.length; i++) { const el = nodes[i]; const ts = num(el.getAttribute('data-end')); const cls = 'sev' + severity(ts); if (el.className !== cls) el.className = cls; const txt = absTime(ts) + ' · ' + remainText(ts); if (el.textContent !== txt) el.textContent = txt; } }, CFG.TICK_MS); } function stopTick() { if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } } // ==================== 弹窗 ==================== function showModal(opts) { const o = opts || {}; closeModal(); modalOnClose = o.onClose || null; const mask = document.createElement('div'); mask.className = 'sgsh-modal-mask'; const buttons = (o.buttons || [{ label: zh('关闭', 'Close'), primary: true }]).map((b, i) => ( `` )).join(''); mask.innerHTML = `
${esc(o.title || '')}
${o.body || ''}
${buttons}
`; mask.addEventListener('click', (e) => { if (e.target === mask && o.maskClose !== false) closeModal(); }); mask.querySelector('.sgsh-modal-foot').addEventListener('click', async (e) => { const b = e.target.closest('[data-mi]'); if (!b) return; const def = (o.buttons || [])[Number(b.getAttribute('data-mi'))]; if (def && typeof def.act === 'function') { b.disabled = true; try { await def.act(); } catch (err) { console.warn('[SGLVShelf] 弹窗动作失败', err); } b.disabled = false; } if (!def || def.keep !== true) closeModal(); }); document.body.appendChild(mask); modalEl = mask; return mask; } function closeModal() { if (!modalEl) return; modalEl.remove(); modalEl = null; const cb = modalOnClose; modalOnClose = null; if (cb) { try { cb(); } catch (e) { /* ignore */ } } } function showConfirm(title, lead, okLabel, act) { showModal({ title: title, body: '
' + lead + '
', buttons: [ { label: zh('取消', 'Cancel') }, { label: okLabel, primary: true, act: act }, ], }); } function copyText(txt) { try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(txt); return true; } } catch (e) { /* ignore */ } try { const ta = document.createElement('textarea'); ta.value = txt; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); return true; } catch (e) { return false; } } // ==================== 交互分发 ==================== function onPanelClick(e) { const chip = e.target.closest('[data-filter]'); if (chip) { uiState.filter = chip.getAttribute('data-filter'); setPrefs({ filter: uiState.filter }); renderChips(); renderList(); return; } const actBtn = e.target.closest('[data-act]'); if (!actBtn) return; const act = actBtn.getAttribute('data-act'); const rowEl = actBtn.closest('.sgsh-row'); const id = rowEl ? rowEl.getAttribute('data-id') : null; const rec = id ? getItem(id) : null; switch (act) { case 'close': closeShelf(); break; case 'add': showEditModal(null); break; case 'edit': if (rec) showEditModal(rec); break; case 'restore': if (rec) executeRestore([rec.id]); break; case 'restore-all': { const ids = visibleItems().filter(canRestore).map((r) => r.id); if (!ids.length) { toast(zh('没有可恢复的记录', 'Nothing to restore'), 'warning'); break; } showConfirm(zh('全部恢复', 'Restore all'), zh('将把 ', 'Restores ') + ids.length + ' ' + zh('项恢复到购物车。', 'items back into the cart.'), zh('确认恢复', 'Restore'), () => executeRestore(ids)); break; } case 'open': { const u = rec ? linkOf(rec) : ''; if (!u) break; if (Host && typeof Host.openUrl === 'function') { try { Host.openUrl(u); break; } catch (err) { /* fallthrough */ } } window.open(u, '_blank', 'noopener'); break; } case 'copy': if (rec && rec.url) toast(copyText(rec.url) ? zh('链接已复制', 'Link copied') : zh('复制失败', 'Copy failed'), copyText(rec.url) ? 'success' : 'error'); break; case 'del': if (rec) showConfirm(zh('删除这条暂存?', 'Delete this record?'), zh('将从暂存箱移除 ', 'Removes ') + esc(displayName(rec)) + '。', zh('删除', 'Delete'), () => { deleteItem(rec.id); toast(zh('已删除', 'Deleted'), 'success'); }); break; case 'clear': { const n = countAll(); if (!n) { toast(zh('暂存箱已经是空的', 'Shelf is already empty'), 'warning'); break; } showConfirm(zh('清空暂存箱', 'Clear shelf'), zh('将删除全部 ', 'Deletes all ') + n + ' ' + zh('条记录(含与购物车增强互通的)。此操作不可撤销,建议先导出。', 'records (including shared ones). This cannot be undone — export first.'), zh('确认清空', 'Clear all'), () => { writeBucket('interop', []); writeBucket('extra', []); emitChange(); toast(zh('暂存箱已清空', 'Shelf cleared'), 'success'); }); break; } case 'prune': { const n = pruneStale(CFG.STALE_DAYS); toast(n ? zh('已清理 ', 'Pruned ') + n + zh(' 条陈旧记录', ' stale records') : zh('没有超过 ' + CFG.STALE_DAYS + ' 天的记录', 'Nothing older than ' + CFG.STALE_DAYS + ' days'), n ? 'success' : 'info'); break; } case 'export': showExportModal(); break; case 'import': showImportModal(); break; case 'shelve-cart': shelveAllRows(); break; default: break; } } // ---- 新建 / 编辑 ---- function showEditModal(rec) { const isNew = !rec; const kind = rec ? rec.kind : 'note'; const kinds = ['note', 'app', 'wishlist', 'bundle', 'package']; const opt = (k) => ``; showModal({ title: isNew ? zh('新建暂存', 'New shelf entry') : zh('编辑暂存', 'Edit shelf entry'), body: `
${zh('单品/捆绑包/购买包会写入与「购物车增强」互通的存储;愿望单与备忘由本库独享。', 'app/bundle/package go to the bucket shared with the cart script; wishlist/note are private.')}
${zh('填了就会在列表里倒计时并按到期排序', 'Enables countdown and expiry sorting')}
`, buttons: [ { label: zh('取消', 'Cancel') }, { label: isNew ? zh('加入暂存箱', 'Add') : zh('保存', 'Save'), primary: true, keep: true, act: async () => { const g = (id) => { const el = document.getElementById(id); return el ? el.value.trim() : ''; }; const k = g('f-kind'); const payload = { kind: k, title: g('f-title'), appid: num(g('f-appid')) || null, bundleid: num(g('f-bundleid')) || null, packageid: num(g('f-packageid')) || null, url: g('f-url'), note: g('f-note'), tags: g('f-tags').split(/\s+/).filter(Boolean), endTs: num(g('f-endts')), source: k === 'wishlist' ? 'wishlist' : 'manual', }; if (k !== 'note' && !payload.appid && !payload.bundleid && !payload.packageid) { toast(zh('该类型必须填写对应 ID', 'This kind requires its id'), 'warning'); return; } if (rec) { const merged = Object.assign({}, rec, payload); putItem(merged); toast(zh('已保存', 'Saved'), 'success'); } else { await addItem(payload); } } }, ], onClose: () => { if (isOpenFlag) renderList(); }, }); } // ---- 导出 ---- function showExportModal() { const full = buildExport({ compatOnly: false }); const compat = buildExport({ compatOnly: true }); showModal({ title: zh('导出暂存箱', 'Export shelf'), body: `
${zh('共 ' + full.items.length + ' 条(购物类 ' + compat.items.length + ' 条)。Ctrl/⌘+C 或点复制。', full.items.length + ' records (' + compat.items.length + ' cart). Ctrl/⌘+C or use Copy.')}
`, buttons: [ { label: zh('取消', 'Cancel') }, { label: zh('复制', 'Copy'), act: () => { const ta = document.getElementById('f-json'); const txt = ta ? ta.value : ''; toast(copyText(txt) ? zh('已复制到剪贴板', 'Copied to clipboard') : zh('复制失败,请手动全选', 'Copy failed — select manually'), copyText(txt) ? 'success' : 'warning'); } }, ], }); const chk = document.getElementById('f-compat'); const ta = document.getElementById('f-json'); if (chk && ta) chk.addEventListener('change', () => { ta.value = JSON.stringify(chk.checked ? compat : full, null, 2); }); } // ---- 导入 ---- function showImportModal() { showModal({ title: zh('导入暂存箱', 'Import shelf'), body: `
${zh('粘贴 JSON。支持本库导出体、购物车增强的原样导出、以及裸数组。相同商品自动合并,不产生重复。', 'Paste JSON. Accepts this shelf export, the cart script raw export, or a bare array. Identical products merge instead of duplicating.')}
`, buttons: [ { label: zh('取消', 'Cancel') }, { label: zh('导入', 'Import'), primary: true, act: () => { const ta = document.getElementById('f-json'); const r = parseImport(ta ? ta.value : ''); if (!r.ok) { toast(r.error, 'error'); return; } toast(zh('导入完成:新增 ', 'Imported: ') + r.added + zh(' · 更新 ', ' · updated ') + r.updated + (r.skipped ? zh(' · 跳过 ', ' · skipped ') + r.skipped : ''), r.added || r.updated ? 'success' : 'info'); } }, ], onClose: () => { if (isOpenFlag) renderList(); }, }); } // ==================== 购物车页监听 ==================== let cartObserver = null; let cartDebounce = 0; function watchCartPage() { if (cartObserver) return; const boot = () => { if (cartObserver || !onCartPage()) return; injectCartButtons(); cartObserver = new MutationObserver(() => { clearTimeout(cartDebounce); cartDebounce = setTimeout(() => { if (onCartPage()) injectCartButtons(); }, 600); }); cartObserver.observe(document.body, { childList: true, subtree: true }); }; boot(); // 单页应用路由(/cart 内部不会切页,但保险起见监听一次 popstate) window.addEventListener('popstate', () => setTimeout(boot, 300)); } function refreshCartButtons() { if (!onCartPage()) return; injectCartButtons(); if (isOpenFlag) renderList(); } // ==================== 对外 ==================== function init(host) { Host = host || null; if (!Host || Host.apiVersion !== apiVersion) { console.warn('[SGLVShelf] 握手失败:Host.apiVersion 与库版本不匹配'); _hostReady = false; return false; } _hostReady = true; try { // 样式必须此刻注入,不能等到 ensureUI():侧栏角标在浮窗首次打开前 // 就可能显示(init 的 emitChange 会点亮它),懒加载会让角标裸奔 ensureCss(); // 初始化即广播一次,让侧栏按钮拿到初始角标 emitChange(); watchCartPage(); } catch (e) { console.warn('[SGLVShelf] 初始化异常(数据层仍可用)', e); } debug('init ok · interop=%d extra=%d', readBucket('interop').items.length, readBucket('extra').items.length); return true; } return { init: init, apiVersion: apiVersion, // 浮窗 openShelf: openShelf, closeShelf: closeShelf, toggleShelf: toggleShelf, isOpen: isOpen, // 数据 getCount: countAll, allItems: allItems, getItem: getItem, addItem: addItem, addWishlist: addWishlist, addNote: addNote, deleteItem: deleteItem, putItem: putItem, executeRestore: executeRestore, pruneStale: pruneStale, buildExport: buildExport, parseImport: parseImport, onChange: onChange, setBadge: setBadge, // 购物车页 injectCartButtons: refreshCartButtons, onCartPage: onCartPage, shelveAllRows: shelveAllRows, // 测试/诊断用内部接口 _internals: { normalizeRecord: normalizeRecord, isInteropKind: isInteropKind, bucketOf: bucketOf, resolveKind: resolveKind, sceAcceptable: sceAcceptable, readBucket: readBucket, writeBucket: writeBucket, getPrefs: getPrefs, setPrefs: setPrefs, sameTarget: sameTarget, resolveRestorePlan: resolveRestorePlan, displayName: displayName, linkOf: linkOf, remainText: remainText, severity: severity, CFG: CFG, }, }; })();