diff --git a/dev_test_scripts/debug/debug_private_clipboard_share_link.js b/dev_test_scripts/debug/debug_private_clipboard_share_link.js new file mode 100644 index 0000000..aa70af1 --- /dev/null +++ b/dev_test_scripts/debug/debug_private_clipboard_share_link.js @@ -0,0 +1,132 @@ +/** + * 私人剪贴板「限时直通URL」端到端黑盒验证 + * 流程:/go 拿 gate → 登录 → 上传测试图片 → 创建直链 → 无鉴权访问 → 模拟过期 → 删除清理验证 + * 用法:node dev_test_scripts/debug/debug_private_clipboard_share_link.js + */ +const http = require('http') +const fs = require('fs') +const os = require('os') +const path = require('path') + +const BASE = { hostname: 'localhost', port: 8976 } + +// 1x1 透明 PNG +const PNG_1PX = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63fcffff3f0300050201aad0a95e0000000049454e44ae426082', 'hex') + +const loadEnv = () => { + const out = {} + try { + const text = fs.readFileSync(path.join(os.homedir(), 'Toolbox_local_creds.env.local'), 'utf8') + for (const line of text.split(/\r?\n/)) { + const t = line.trim() + if (!t || t.startsWith('#')) continue + const eq = t.indexOf('=') + if (eq <= 0) continue + out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim() + } + } catch {} + return out +} + +const req = (urlPath, opts = {}) => new Promise((resolve, reject) => { + const headers = Object.assign({}, opts.headers || {}) + if (opts.cookie) headers.Cookie = opts.cookie + if (opts.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json' + if (opts.body) headers['Content-Length'] = Buffer.byteLength(opts.body) + const r = http.request(Object.assign({}, BASE, { path: urlPath, method: opts.method || 'GET', headers }), (rp) => { + const chunks = [] + rp.on('data', c => chunks.push(c)) + rp.on('end', () => resolve({ status: rp.statusCode, headers: rp.headers, body: Buffer.concat(chunks) })) + }) + r.on('error', reject) + if (opts.body) r.write(opts.body) + r.end() +}) + +const mergeCookies = (prev, resp) => { + const set = resp.headers['set-cookie'] || [] + const map = {} + String(prev || '').split(/;\s*/).filter(Boolean).forEach(kv => { const i = kv.indexOf('='); if (i > 0) map[kv.slice(0, i)] = kv.slice(i + 1) }) + set.forEach(c => { const kv = c.split(';')[0]; const i = kv.indexOf('='); if (i > 0) map[kv.slice(0, i)] = kv.slice(i + 1) }) + return Object.keys(map).map(k => `${k}=${map[k]}`).join('; ') +} + +let passed = 0 +let failed = 0 +const check = (name, cond, detail) => { + if (cond) { passed++; console.log(` [PASS] ${name}`) } else { failed++; console.log(` [FAIL] ${name} ${detail || ''}`) } +} + +;(async () => { + const env = loadEnv() + const user = env.PRIVATE_CLIPBOARD_USERNAME || '' + const pass = env.PRIVATE_CLIPBOARD_PASSWORD || '' + if (!user || !pass) { console.error('[FAIL] env 缺少 PRIVATE_CLIPBOARD_USERNAME / PASSWORD'); process.exit(1) } + + // 1. /go 拿 gate cookie + let ck = '' + let r = await req('/go?systemId=Tools-private_clipboard') + while (r.status >= 300 && r.status < 400 && r.headers.location) { + ck = mergeCookies(ck, r) + r = await req(r.headers.location, { cookie: ck }) + } + ck = mergeCookies(ck, r) + check('1 /go gate 链路', /private_clipboard_gate=1/.test(ck), `cookie=${ck}`) + + // 2. 登录拿业务 cookie + const login = await req('/api/private_clipboard/auth/login', { method: 'POST', cookie: ck, body: JSON.stringify({ username: user, password: pass }) }) + ck = mergeCookies(ck, login) + const loginJson = JSON.parse(login.body.toString('utf8') || '{}') + check('2 业务登录', login.status === 200 && loginJson.ok === true, `status=${login.status}`) + + // 3. 上传测试图片 + const up = await req('/api/private_clipboard/upload?filename=share_test.png&mime=image/png', { method: 'POST', cookie: ck, body: PNG_1PX, headers: { 'Content-Type': 'image/png' } }) + const upJson = JSON.parse(up.body.toString('utf8') || '{}') + check('3 上传测试图片', up.status === 200 && upJson.ok === true && upJson.id, `status=${up.status} body=${up.body.toString().slice(0, 100)}`) + const itemId = upJson.id + + // 4. 创建限时直链 + const sh = await req('/api/private_clipboard/share/create', { method: 'POST', cookie: ck, body: JSON.stringify({ id: itemId }) }) + const shJson = JSON.parse(sh.body.toString('utf8') || '{}') + check('4 创建限时直链', sh.status === 200 && shJson.ok === true && /^https:\/\/traesite\.umersoft\.com:8975\/share\/clip\/[a-f0-9]{32}$/.test(String(shJson.url || '')), `body=${sh.body.toString().slice(0, 160)}`) + const token = String(shJson.url || '').split('/').pop() + console.log(` 直链过期时间(北京时间): ${shJson.expires_at_text}`) + + // 5. 无鉴权访问(模拟第三方浏览器,不带任何 cookie) + const pub = await req(`/share/clip/${token}`) + check('5 无鉴权访问直链', pub.status === 200 && String(pub.headers['content-type'] || '').includes('image/png'), `status=${pub.status} type=${pub.headers['content-type']}`) + + // 5b. 未登录调用 share/create 必须被拒 + const shNoAuth = await req('/api/private_clipboard/share/create', { method: 'POST', body: JSON.stringify({ id: itemId }) }) + check('5b 未授权创建直链被拒', shNoAuth.status === 401 || shNoAuth.status === 403, `status=${shNoAuth.status}`) + + // 6. 模拟过期:把 expires_at 改成过去 + const Database = require(path.join(process.cwd(), 'node_modules', 'better-sqlite3')) + const db = new Database(path.join(process.cwd(), 'data', 'private_clipboard.db')) + db.prepare('UPDATE clipboard_share_links SET expires_at = ? WHERE token = ?').run(Date.now() - 1000, token) + db.close() + const expired = await req(`/share/clip/${token}`) + const expiredHtml = expired.body.toString('utf8') + check('6 过期链接返回 410 + 提示文案', expired.status === 410 && expiredHtml.includes('你的链接期限已经大于三天') && expiredHtml.includes('无法访问'), `status=${expired.status}`) + + // 7. 重新生成(覆盖旧 token),再删除条目,验证 share 链接被清理 + const sh2 = await req('/api/private_clipboard/share/create', { method: 'POST', cookie: ck, body: JSON.stringify({ id: itemId }) }) + const sh2Json = JSON.parse(sh2.body.toString('utf8') || '{}') + const token2 = String(sh2Json.url || '').split('/').pop() + const del = await req(`/api/private_clipboard/${itemId}`, { method: 'DELETE', cookie: ck }) + const delJson = JSON.parse(del.body.toString('utf8') || '{}') + check('7 删除条目', del.status === 200 && delJson.ok === true, `status=${del.status}`) + const afterDel = await req(`/share/clip/${token2}`) + check('7b 删除后直链失效', afterDel.status === 410, `status=${afterDel.status}`) + const db2 = new Database(path.join(process.cwd(), 'data', 'private_clipboard.db')) + const left = db2.prepare('SELECT COUNT(1) AS c FROM clipboard_share_links WHERE item_id = ?').get(itemId) + db2.close() + check('7c share_links 表已清理', Number(left && left.c) === 0, `remaining=${left && left.c}`) + + // 8. 伪造 token + const fake = await req('/share/clip/00000000000000000000000000000000') + check('8 伪造 token 返回失效页', fake.status === 410, `status=${fake.status}`) + + console.log(`\n结果: ${passed} 通过 / ${failed} 失败`) + process.exit(failed ? 1 : 0) +})().catch(e => { console.error('[ERR]', e); process.exit(1) }) diff --git a/dev_test_scripts/runners/run_oss_browser_zip_transit.js b/dev_test_scripts/runners/run_oss_browser_zip_transit.js new file mode 100644 index 0000000..212312d --- /dev/null +++ b/dev_test_scripts/runners/run_oss_browser_zip_transit.js @@ -0,0 +1,168 @@ +/** + * OSS 中转脚本:把 oss://android-o1-images/trae-files-temp/oss-browser-win32-x64.zip + * 下载到本地 public/download_files/,成功后立即删除 OSS 对象。 + * + * 密钥来源:~/Toolbox_local_creds.env.local(DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID / SECRET) + * 用法:node dev_test_scripts/runners/run_oss_browser_zip_transit.js [--keep-oss] + * --keep-oss 仅下载不删除 OSS 对象(调试用,默认下载成功后删除) + */ +const fs = require('fs') +const os = require('os') +const path = require('path') +const crypto = require('crypto') +const https = require('https') + +const BUCKET = 'android-o1-images' +const ENDPOINT = 'oss-cn-shanghai.aliyuncs.com' +const OSS_PREFIX = 'trae-files-temp/' +const TARGET_NAME = 'oss-browser-win32-x64.zip' +const LOCAL_DIR = path.join(process.cwd(), 'public', 'download_files') +const LOCAL_FILE = path.join(LOCAL_DIR, TARGET_NAME) + +const loadEnv = () => { + const out = {} + try { + const p = path.join(os.homedir(), 'Toolbox_local_creds.env.local') + const text = fs.readFileSync(p, 'utf8') + for (const line of text.split(/\r?\n/)) { + const t = line.trim() + if (!t || t.startsWith('#')) continue + const eq = t.indexOf('=') + if (eq <= 0) continue + out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim() + } + } catch {} + return out +} + +const ENV = loadEnv() +const AK = ENV.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID || '' +const SK = ENV.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_SECRET || '' +if (!AK || !SK) { + console.error('[FAIL] 缺少 DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID / SECRET(~/Toolbox_local_creds.env.local)') + process.exit(1) +} + +const sign = (method, date, resource, contentType = '', canonicalHeaders = '') => { + const stringToSign = `${method}\n\n${contentType}\n${date}\n${canonicalHeaders ? canonicalHeaders + '\n' : ''}${resource}` + return crypto.createHmac('sha1', SK).update(stringToSign).digest('base64') +} + +const ossRequest = (method, urlPath, resource) => new Promise((resolve, reject) => { + const date = new Date().toUTCString() + const req = https.request({ + hostname: `${BUCKET}.${ENDPOINT}`, + path: urlPath, + method, + headers: { + Host: `${BUCKET}.${ENDPOINT}`, + Date: date, + Authorization: `OSS ${AK}:${sign(method, date, resource)}` + } + }, (res) => { + const chunks = [] + res.on('data', c => chunks.push(c)) + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) })) + }) + req.on('error', reject) + req.setTimeout(120000, () => req.destroy(new Error('timeout'))) + req.end() +}) + +const listPrefix = async (prefix) => { + const resp = await ossRequest('GET', `/?prefix=${encodeURIComponent(prefix)}&max-keys=100`, `/${BUCKET}/`) + if (resp.status !== 200) throw new Error(`OSS list 失败: ${resp.status} ${resp.body.toString('utf8').slice(0, 200)}`) + const xml = resp.body.toString('utf8') + const keys = [] + let idx = 0 + while (true) { + const s = xml.indexOf('', idx) + if (s < 0) break + const e = xml.indexOf('', s) + if (e < 0) break + keys.push(xml.slice(s + 5, e)) + idx = e + } + return keys +} + +const downloadToFile = (key, localPath) => new Promise((resolve, reject) => { + const date = new Date().toUTCString() + const resource = `/${BUCKET}/${key}` + const req = https.request({ + hostname: `${BUCKET}.${ENDPOINT}`, + path: `/${key.split('/').map(encodeURIComponent).join('/')}`, + method: 'GET', + headers: { + Host: `${BUCKET}.${ENDPOINT}`, + Date: date, + Authorization: `OSS ${AK}:${sign('GET', date, resource)}` + } + }, (res) => { + if (res.statusCode !== 200) { + const chunks = [] + res.on('data', c => chunks.push(c)) + res.on('end', () => reject(new Error(`OSS 下载失败: ${res.statusCode} ${Buffer.concat(chunks).toString('utf8').slice(0, 200)}`))) + return + } + const ws = fs.createWriteStream(localPath) + let received = 0 + res.on('data', c => { received += c.length }) + res.pipe(ws) + ws.on('finish', () => resolve(received)) + ws.on('error', reject) + }) + req.on('error', reject) + req.setTimeout(600000, () => req.destroy(new Error('下载超时'))) + req.end() +}) + +const deleteObject = async (key) => { + const resource = `/${BUCKET}/${key}` + const urlPath = `/${key.split('/').map(encodeURIComponent).join('/')}` + const resp = await ossRequest('DELETE', urlPath, resource) + if (resp.status !== 204 && resp.status !== 200) { + throw new Error(`OSS 删除失败: ${resp.status} ${resp.body.toString('utf8').slice(0, 200)}`) + } + return true +} + +const bjNow = () => new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }) + +;(async () => { + console.log(`[${bjNow()}] 列举 ${BUCKET}/${OSS_PREFIX} ...`) + const keys = await listPrefix(OSS_PREFIX) + console.log(` 前缀下共 ${keys.length} 个对象`) + const zipKey = keys.find(k => k.endsWith('/' + TARGET_NAME) || k === TARGET_NAME) + if (!zipKey) { + console.error(`[FAIL] 未找到 ${OSS_PREFIX}${TARGET_NAME},现有对象:`) + keys.forEach(k => console.error(' - ' + k)) + process.exit(1) + } + console.log(` 找到对象: ${zipKey}`) + + if (!fs.existsSync(LOCAL_DIR)) fs.mkdirSync(LOCAL_DIR, { recursive: true }) + const tmpFile = LOCAL_FILE + '.part' + console.log(`[${bjNow()}] 开始下载到 ${LOCAL_FILE} ...`) + const received = await downloadToFile(zipKey, tmpFile) + const size = fs.statSync(tmpFile).size + if (size < 1024 * 1024) { + fs.unlinkSync(tmpFile) + throw new Error(`下载文件过小(${size} 字节),疑似异常,已丢弃临时文件`) + } + if (fs.existsSync(LOCAL_FILE)) fs.unlinkSync(LOCAL_FILE) + fs.renameSync(tmpFile, LOCAL_FILE) + console.log(` 下载完成: ${(size / 1024 / 1024).toFixed(2)} MB`) + + if (process.argv.includes('--keep-oss')) { + console.log('[SKIP] --keep-oss:保留 OSS 对象不删除') + } else { + console.log(`[${bjNow()}] 删除 OSS 对象 ${zipKey} ...`) + await deleteObject(zipKey) + console.log(' OSS 对象已删除(中转完成)') + } + console.log(`[${bjNow()}] 中转成功:本地 ${LOCAL_FILE}`) +})().catch(e => { + console.error('[FAIL]', e.message || e) + process.exit(1) +}) diff --git a/dev_test_scripts/tools/tool_extract_snake_frames.py b/dev_test_scripts/tools/tool_extract_snake_frames.py new file mode 100644 index 0000000..ed57dd0 --- /dev/null +++ b/dev_test_scripts/tools/tool_extract_snake_frames.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +""" +tool_extract_snake_frames.py +从「贪蛇出动」游戏录屏视频中抽帧,用于分析玩法与抠素材。 +用法: + python tool_extract_snake_frames.py # 默认每 2 秒抽 1 帧 + python tool_extract_snake_frames.py 0.5 # 每 0.5 秒抽 1 帧 + python tool_extract_snake_frames.py 0.2 60 70 # 每 0.2 秒抽 1 帧,只抽 60~70 秒区间 +""" +import os +import sys +import cv2 + +VIDEO = r"D:\Trae_Files\TRAE-Toolbox\temp\a4ef87cf84a3152cb5800eae5e06e6a8.mp4" +OUT_DIR = r"D:\Trae_Files\TRAE-Toolbox\temp\snake_frames" + + +def main(): + step = float(sys.argv[1]) if len(sys.argv) > 1 else 2.0 + start = float(sys.argv[2]) if len(sys.argv) > 2 else 0.0 + end = float(sys.argv[3]) if len(sys.argv) > 3 else 1e18 + + os.makedirs(OUT_DIR, exist_ok=True) + cap = cv2.VideoCapture(VIDEO) + if not cap.isOpened(): + print("无法打开视频:", VIDEO) + sys.exit(1) + fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 + total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + print(f"fps={fps:.2f} total_frames={total} duration={total / fps:.1f}s step={step}s") + + t = start + saved = 0 + while t <= end: + cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000.0) + ok, frame = cap.read() + if not ok: + break + name = "t%07d.jpg" % int(round(t * 10)) # 文件名单位:0.1 秒 + cv2.imwrite(os.path.join(OUT_DIR, name), frame, [cv2.IMWRITE_JPEG_QUALITY, 88]) + saved += 1 + t += step + cap.release() + print(f"saved={saved} -> {OUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/dev_test_scripts/unit/test_greedy_snake_core.js b/dev_test_scripts/unit/test_greedy_snake_core.js new file mode 100644 index 0000000..50fa16f --- /dev/null +++ b/dev_test_scripts/unit/test_greedy_snake_core.js @@ -0,0 +1,86 @@ +/* 贪蛇出动 - 核心逻辑单元测试(无 DOM,Node 直接运行) + * 运行: node dev_test_scripts/unit/test_greedy_snake_core.js + */ +'use strict'; +const path = require('path'); +const fs = require('fs'); + +global.window = global; // 让模块的 window.GS 挂到全局 +const JS_DIR = path.join(__dirname, '..', '..', 'public', 'tools', 'greedy_snake', 'js'); +for (const f of ['config.js', 'utils.js', 'snake.js', 'ai.js', 'world.js']) { + const src = fs.readFileSync(path.join(JS_DIR, f), 'utf-8'); + new Function(src)(); +} + +const { U, Snake, AI, World, CFG } = global.GS; +let failed = 0; +const assert = (cond, msg) => { + if (cond) console.log(' [PASS] ' + msg); + else { failed++; console.error(' [FAIL] ' + msg); } +}; + +console.log('== 工具函数 =='); +assert(U.fmt(276496) === '276,496', 'fmt 千分位: ' + U.fmt(276496)); +assert(U.fmt(1194000) === '1,194k', 'fmt k 格式: ' + U.fmt(1194000)); +assert(Math.abs(U.normAngle(Math.PI * 2)) < 1e-9, 'normAngle 归一化'); +assert(U.clamp(5, 0, 3) === 3, 'clamp'); + +console.log('== 蛇移动与采样 =='); +const s = new Snake({ id: 'p', name: 't', isPlayer: true, color: { body: '#f00', dark: '#900' }, x: 2000, y: 2000, angle: 0 }); +s.targetAngle = 0; +for (let i = 0; i < 120; i++) s.update(1 / 60, 0); +assert(Math.abs(s.head.x - (2000 + 130 * 2)) < 3, '直线移动距离正确: ' + (s.head.x - 2000).toFixed(1)); +const segs = s.segments(); +assert(segs.length === s.segCount, '身体节数=' + segs.length); +const d01 = U.dist(segs[0].x, segs[0].y, segs[1].x, segs[1].y); +assert(Math.abs(d01 - s.spacing) < 0.5, '节间距=spacing: ' + d01.toFixed(2)); + +console.log('== 世界/金币/碰撞 =='); +const w = new World(); +assert(w.coins.length >= CFG.coinCount, '初始金币数: ' + w.coins.length); +const before = w.coins.length; +w.dropFromSegments(segs); +assert(w.coins.length > before, '尸体掉币: +' + (w.coins.length - before)); + +const s2 = new Snake({ id: 'p2', name: 't2', isPlayer: true, color: { body: '#f00', dark: '#900' }, x: 2000, y: 3000, angle: 0 }); +s2.targetAngle = 0; +for (let i = 0; i < 120; i++) s2.update(1 / 60, 0); // 先走出一条身体 +const bot = new Snake({ id: 'b', name: 'bot', color: { body: '#fff', dark: '#ccc' }, x: s2.head.x + 120, y: s2.head.y, score: 20, angle: Math.PI }); +bot.targetAngle = Math.PI; // 朝玩家身体冲 +let hit = null; +for (let i = 0; i < 60 && !hit; i++) { + s2.update(1 / 60, 0); bot.update(1 / 60, 0); + hit = w.collide(bot, [s2, bot]); +} +assert(hit !== null, '撞身体判定: ' + (hit && hit.type)); + +console.log('== AI + 综合模拟 600 步 =='); +const w2 = new World(); +const player = new Snake({ id: 'p', name: 'me', isPlayer: true, color: { body: '#f00', dark: '#900' }, x: 2000, y: 2000 }); +const bots = []; +const ais = new Map(); +for (let i = 0; i < CFG.botCount; i++) { + const b = new Snake({ id: 'b' + i, name: 'bot' + i, color: { body: '#fff', dark: '#ccc' }, x: U.rand(500, 3500), y: U.rand(500, 3500), score: U.randInt(10, 200) }); + bots.push(b); ais.set(b.id, new AI(b)); +} +let deaths = 0, eaten0 = player.score; +for (let t = 0; t < 600; t++) { + player.targetAngle += 0.02; + for (const b of bots) ais.get(b.id).update(1 / 60, w2, [player, ...bots]); + for (const x of [player, ...bots]) x.update(1 / 60, t); + w2.update(1 / 60, [player, ...bots], t); + for (const x of [player, ...bots]) { + if (x.dead) continue; + const h = w2.collide(x, [player, ...bots]); + if (h) { + if (x.shield) { x.shield = false; continue; } + x.die(); deaths++; + } + } +} +assert(true, '模拟完成,死亡数=' + deaths + ',玩家分数 ' + eaten0 + ' -> ' + Math.floor(player.score)); +assert(w2.coins.length > 0, '模拟后金币存在: ' + w2.coins.length); +assert(isFinite(player.head.x) && isFinite(player.head.y), '玩家坐标有限'); + +console.log(failed === 0 ? '\n全部通过' : `\n${failed} 项失败`); +process.exit(failed === 0 ? 0 : 1); diff --git a/public/tools/doc_cloud_keeper/doc_cloud_keeper.css b/public/tools/doc_cloud_keeper/doc_cloud_keeper.css index 3d30f39..b8bbd94 100644 --- a/public/tools/doc_cloud_keeper/doc_cloud_keeper.css +++ b/public/tools/doc_cloud_keeper/doc_cloud_keeper.css @@ -889,6 +889,24 @@ background: #bfdbfe; } +.dck-copy-title-btn { + display: inline-block; + min-width: 24px; + padding: 1px 5px; + border: none; + border-radius: 999px; + background: #dbeafe; + color: #111827; + font-size: 13px; + line-height: 1.4; + cursor: pointer; + vertical-align: middle; +} + +.dck-copy-title-btn:hover { + background: #bfdbfe; +} + .dck-preview-body { padding: 0; border-radius: 0; diff --git a/public/tools/doc_cloud_keeper/doc_cloud_keeper.js b/public/tools/doc_cloud_keeper/doc_cloud_keeper.js index c2b4752..0a77e9e 100644 --- a/public/tools/doc_cloud_keeper/doc_cloud_keeper.js +++ b/public/tools/doc_cloud_keeper/doc_cloud_keeper.js @@ -733,6 +733,7 @@ const dck = (() => { ${parentFolderName ? `${esc(parentFolderName)}` : ''} + ` tr.innerHTML = ` @@ -797,6 +798,23 @@ const dck = (() => { await openPreview(doc) } }) + Array.from(tb.querySelectorAll('.dck-copy-title-btn')).forEach(btn => { + btn.onclick = async e => { + e.stopPropagation() + const docId = Number(btn.dataset.docId || 0) + if (!docId) return + const doc = docs.find(item => Number(item.id) === docId) + if (!doc) return + try { + await copyText(doc.filename || '') + btn.textContent = '✅' + setTimeout(() => { btn.textContent = '📋' }, 1200) + } catch (err) { + btn.textContent = '⚠️' + setTimeout(() => { btn.textContent = '📋' }, 1200) + } + } + }) renderBanner() updateActionButtonsState() } diff --git a/public/tools/greedy_snake/auth_config.json b/public/tools/greedy_snake/auth_config.json new file mode 100644 index 0000000..8799872 --- /dev/null +++ b/public/tools/greedy_snake/auth_config.json @@ -0,0 +1,15 @@ +{ + "_comment": "贪蛇出动的鉴权配置", + "enable_auth_comment": "true:启用 tool 外层鉴权;false:完全放开访问", + "enable_auth": true, + "max_age_days_comment": "鉴权 cookie 最大有效天数(1~365)", + "max_age_days": 30, + "cookieName_comment": "鉴权 Cookie 名称,默认格式 _gate", + "cookieName": "greedy_snake_gate", + "requireGuid_comment": "true:强制 GUID 参数才能访问;false:允许仅凭 token 或已有会话访问", + "requireGuid": false, + "guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid", + "guidParam": "guid", + "guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换(留空表示不启用 GUID)", + "guidValue": "" +} diff --git a/public/tools/greedy_snake/greedy_snake.css b/public/tools/greedy_snake/greedy_snake.css new file mode 100644 index 0000000..5db04b8 --- /dev/null +++ b/public/tools/greedy_snake/greedy_snake.css @@ -0,0 +1,221 @@ +/* 贪蛇出动 greedy_snake 样式(tool 独立维护,不与其他 tool 共用) */ +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { + width: 100%; height: 100%; + overflow: hidden; + background: #1c2450; + touch-action: none; + overscroll-behavior: none; + -webkit-user-select: none; user-select: none; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; +} +#gs-root { position: fixed; inset: 0; } +#gs-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } +.gs-hidden { display: none !important; } + +/* ---------- 左上 HUD ---------- */ +#gs-hud-stats { + position: absolute; top: calc(10px + env(safe-area-inset-top)); left: 14px; + color: #fff; font-size: 14px; line-height: 1.9; z-index: 5; + text-shadow: 0 1px 3px rgba(0,0,0,.5); +} +.gs-stat-label { color: #e8ecff; } +.gs-stat-value.coins { color: #ffd23e; font-weight: 700; } +.gs-stat-value.lucky { color: #ff5fa2; font-weight: 700; } +.gs-stat-value.kills { color: #ff5fa2; font-weight: 700; } + +/* ---------- 顶部进度 ---------- */ +#gs-hud-progress { + position: absolute; top: calc(8px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); + text-align: center; z-index: 5; +} +#gs-progress-title { color: #ffd23e; font-size: 14px; font-weight: 700; text-shadow: 0 1px 3px rgba(0,0,0,.6); } +#gs-progress-bar-wrap { display: flex; align-items: center; gap: 6px; margin-top: 4px; } +#gs-progress-flame { font-size: 16px; } +#gs-progress-bar { + width: 150px; height: 10px; border-radius: 6px; + background: rgba(255,255,255,.25); overflow: hidden; + border: 1px solid rgba(255,255,255,.35); +} +#gs-progress-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #ffb62e, #ffd23e); border-radius: 6px; transition: width .2s; } +#gs-progress-text { color: #fff; font-size: 12px; text-shadow: 0 1px 3px rgba(0,0,0,.6); min-width: 64px; text-align: left; } + +/* ---------- 右上排行榜 ---------- */ +#gs-hud-board { + position: absolute; top: calc(8px + env(safe-area-inset-top)); right: 8px; width: 168px; + background: rgba(28,36,80,.82); border-radius: 8px; z-index: 5; + padding: 6px 8px 8px; color: #cfd6ff; font-size: 11px; + backdrop-filter: blur(3px); +} +#gs-board-title { text-align: center; color: #fff; font-size: 13px; font-weight: 700; padding-bottom: 4px; border-bottom: 1px solid rgba(255,255,255,.15); } +#gs-board-best { color: #8fa0ff; margin-top: 4px; } +#gs-board-best-row { display: flex; justify-content: space-between; padding: 2px 0 4px; border-bottom: 1px solid rgba(255,255,255,.12); } +#gs-board-best-row .best { color: #ffd23e; font-weight: 700; } +#gs-board-list .gs-board-row { display: flex; align-items: center; gap: 4px; padding: 1.5px 0; } +#gs-board-list .gs-board-rank { width: 16px; text-align: center; color: #9aa6e8; flex: none; } +#gs-board-list .gs-board-rank.r1 { color: #ffd23e; } +#gs-board-list .gs-board-rank.r2 { color: #d7dbe8; } +#gs-board-list .gs-board-rank.r3 { color: #e8a06a; } +#gs-board-list .gs-board-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#gs-board-list .gs-board-score { color: #fff; } +#gs-board-list .gs-board-row.me .gs-board-name, #gs-board-list .gs-board-row.me .gs-board-score, #gs-board-list .gs-board-row.me .gs-board-rank { color: #ff5fa2; font-weight: 700; } + +/* ---------- 摇杆 ---------- */ +#gs-joystick { + position: absolute; left: 26px; bottom: calc(34px + env(safe-area-inset-bottom)); + width: 118px; height: 118px; border-radius: 50%; + background: rgba(10,14,34,.55); z-index: 6; +} +#gs-joystick-knob { + position: absolute; left: 50%; top: 50%; width: 56px; height: 56px; border-radius: 50%; + background: rgba(214,220,235,.92); transform: translate(-50%, -50%); + box-shadow: 0 2px 8px rgba(0,0,0,.4); +} + +/* ---------- 加速按钮 ---------- */ +#gs-boost { + position: absolute; right: 26px; bottom: calc(30px + env(safe-area-inset-bottom)); + width: 96px; height: 96px; border-radius: 50%; border: none; z-index: 6; + background: rgba(226,230,240,.92); + box-shadow: 0 3px 10px rgba(0,0,0,.45); + display: flex; align-items: center; justify-content: center; +} +#gs-boost.active { background: #ffd23e; transform: scale(1.06); } +#gs-boost.active svg path { fill: #7a4b00; } + +/* ---------- 遮罩通用 ---------- */ +.gs-overlay { + position: absolute; inset: 0; z-index: 20; + background: rgba(8,10,26,.62); + display: flex; align-items: center; justify-content: center; +} + +/* ---------- 道具转盘 ---------- */ +#gs-roulette { flex-direction: column; } +#gs-roulette-title { color: #fff; font-size: 20px; font-weight: 800; letter-spacing: 2px; text-shadow: 0 2px 6px rgba(0,0,0,.6); margin-bottom: 18px; } +#gs-roulette-body { display: flex; align-items: center; gap: 14px; } +#gs-slot-machine { + position: relative; padding: 20px 18px 30px; + background: linear-gradient(180deg, #6a4bd8, #4b32b3); + border-radius: 14px; box-shadow: 0 8px 30px rgba(0,0,0,.5); +} +.gs-bulbs { position: absolute; display: flex; } +.gs-bulbs i { width: 8px; height: 8px; border-radius: 50%; background: #ffe9a3; box-shadow: 0 0 6px #ffd23e; margin: 0 9px; } +.gs-bulbs i.off { background: #b9a5ff; box-shadow: none; } +.gs-bulbs-top { top: 5px; left: 10px; right: 10px; justify-content: space-between; } +.gs-bulbs-bottom { bottom: 5px; left: 10px; right: 10px; justify-content: space-between; } +.gs-bulbs-left { left: 5px; top: 12px; bottom: 12px; flex-direction: column; justify-content: space-between; } +.gs-bulbs-right { right: 5px; top: 12px; bottom: 12px; flex-direction: column; justify-content: space-between; } +.gs-bulbs-left i, .gs-bulbs-right i { margin: 7px 0; } +#gs-reels { display: flex; gap: 8px; } +.gs-reel { + width: 92px; height: 148px; border-radius: 8px; cursor: pointer; + background: radial-gradient(circle at 50% 45%, #7fb3ff 0%, #4d7ddb 55%, #3a5cb4 100%); + border: 2px solid rgba(255,255,255,.25); + display: flex; align-items: center; justify-content: center; + transition: transform .15s, border-color .15s, box-shadow .15s; +} +.gs-reel.selected { border-color: #ffd23e; box-shadow: 0 0 14px rgba(255,210,62,.8); transform: scale(1.04); } +.gs-reel-icon { width: 62px; height: 62px; } +.gs-reel-icon svg, .gs-reel-icon canvas { width: 100%; height: 100%; } +#gs-roulette-skip { + position: absolute; bottom: 7px; left: 0; right: 0; text-align: center; + color: #e5dcff; font-size: 13px; cursor: pointer; +} +#gs-reroll { + writing-mode: vertical-rl; letter-spacing: 4px; + background: linear-gradient(180deg, #58c8f2, #2ea7e0); + color: #fff; border: none; border-radius: 16px; + padding: 12px 8px; font-size: 13px; font-weight: 700; cursor: pointer; + box-shadow: 0 4px 12px rgba(0,0,0,.4); +} +#gs-reroll:disabled { filter: grayscale(.6); opacity: .7; } +#gs-reroll .gs-gem { writing-mode: horizontal-tb; margin-bottom: 4px; } + +/* ---------- 复活面板 ---------- */ +#gs-revive-panel { + position: relative; width: min(430px, 92vw); + background: rgba(10,12,24,.92); border-radius: 14px; + padding: 26px 20px 24px; text-align: center; + box-shadow: 0 10px 40px rgba(0,0,0,.6); +} +#gs-revive-close { + position: absolute; top: 8px; right: 12px; background: none; border: none; + color: #fff; font-size: 20px; cursor: pointer; +} +#gs-revive-title { color: #ffd23e; font-size: 16px; font-weight: 700; } +#gs-revive-title span { color: #ff8a3e; } +#gs-revive-count-wrap { position: relative; width: 74px; height: 74px; margin: 18px auto 16px; } +#gs-revive-count-ring { + position: absolute; inset: 0; border-radius: 50%; + background: conic-gradient(#ffd23e var(--p, 100%), rgba(255,255,255,.15) 0); + -webkit-mask: radial-gradient(circle, transparent 55%, #000 56%); + mask: radial-gradient(circle, transparent 55%, #000 56%); +} +#gs-revive-count { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + color: #fff; font-size: 30px; font-weight: 800; +} +#gs-revive-btn { + background: linear-gradient(180deg, #ffb62e, #ff8a1e); + color: #fff; border: none; border-radius: 8px; + padding: 10px 26px; font-size: 15px; font-weight: 700; cursor: pointer; + box-shadow: 0 4px 14px rgba(255,150,30,.45); +} +#gs-revive-btn:disabled { filter: grayscale(.7); opacity: .7; } + +/* ---------- 结算面板 ---------- */ +#gs-result-panel { + width: min(430px, 92vw); background: rgba(10,12,24,.92); border-radius: 14px; + padding: 22px 20px; text-align: center; box-shadow: 0 10px 40px rgba(0,0,0,.6); +} +#gs-result-title { color: #fff; font-size: 18px; font-weight: 800; letter-spacing: 2px; } +.gs-result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 18px 0 10px; } +.gs-result-cell { background: rgba(255,255,255,.06); border-radius: 10px; padding: 10px 0; } +.gs-result-num { font-size: 22px; font-weight: 800; color: #fff; } +.gs-result-num.coins { color: #ffd23e; } +.gs-result-num.kills { color: #ff5fa2; } +.gs-result-num.lucky { color: #ff5fa2; } +.gs-result-label { color: #9aa6e8; font-size: 12px; margin-top: 2px; } +#gs-result-best { color: #8fa0ff; font-size: 13px; margin-bottom: 16px; } +#gs-result-best em { color: #ffd23e; font-style: normal; font-weight: 700; } +#gs-result-btns { display: flex; gap: 12px; justify-content: center; } +.gs-btn-primary { + background: linear-gradient(180deg, #ffb62e, #ff8a1e); color: #fff; border: none; + border-radius: 8px; padding: 10px 30px; font-size: 15px; font-weight: 700; cursor: pointer; +} +.gs-btn-ghost { + background: rgba(255,255,255,.12); color: #cfd6ff; border: 1px solid rgba(255,255,255,.25); + border-radius: 8px; padding: 10px 22px; font-size: 15px; cursor: pointer; +} + +/* ---------- Toast ---------- */ +#gs-toast { + position: absolute; left: 50%; top: 18%; transform: translateX(-50%); + padding: 8px 18px; border-radius: 18px; z-index: 40; + color: #fff; font-size: 13px; pointer-events: none; + background: rgba(46,120,220,.92); + box-shadow: 0 4px 14px rgba(0,0,0,.4); +} +#gs-toast.success { background: rgba(46,180,90,.92); } +#gs-toast.error { background: rgba(220,60,60,.92); } + +/* ---------- 交互详情弹窗 ---------- */ +#gs-dialog { position: absolute; inset: 0; z-index: 50; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; } +#gs-dialog-box { width: min(560px, 94vw); max-height: 70vh; background: #10142c; border: 1px solid #3a4a8c; border-radius: 10px; display: flex; flex-direction: column; } +#gs-dialog-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #2a3568; color: #fff; font-size: 13px; } +#gs-dialog-head #gs-dialog-title { flex: 1; font-weight: 700; } +#gs-dialog-head button { background: #2a3568; color: #cfd6ff; border: none; border-radius: 6px; padding: 3px 10px; font-size: 12px; cursor: pointer; } +#gs-dialog-body { margin: 0; padding: 10px 12px; overflow: auto; color: #9fe8b8; font-size: 11px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; } + +/* 横屏适配:竖屏时提示旋转 */ +#gs-rotate-tip { + position: absolute; inset: 0; z-index: 60; background: #1c2450; color: #cfd6ff; + display: none; align-items: center; justify-content: center; text-align: center; + font-size: 15px; padding: 0 30px; +} +@media (orientation: portrait) and (max-width: 520px) { + #gs-rotate-tip { display: flex; } +} diff --git a/public/tools/greedy_snake/index.html b/public/tools/greedy_snake/index.html new file mode 100644 index 0000000..63b8a19 --- /dev/null +++ b/public/tools/greedy_snake/index.html @@ -0,0 +1,121 @@ + + + + + + + +贪蛇出动 + + + +
+ + + +
+
金币:0
+
幸运星:0
+
击杀:0
+
+ + +
+
长度达到2000进入宝藏屋
+
+ 🔥 +
+ 0/2000 +
+
+ + +
+
排行榜
+
今日最佳
+
🏅 -0
+
+
+ + +
+
+
+ + + + + +
+
请选择1个道具
+
+
+
+
+
+
+
+
+
+
+
+
不使用,直接开始
+
+ +
+
+ + +
+
+ +
复活,给对手一点教训
+
5
+ +
+
+ + +
+
+
本局结算
+
+
0
本局长度
+
0
本局金币
+
0
击杀
+
0
幸运星
+
+
历史最佳长度:0
+
+ + +
+
+
+ + +
请旋转手机至横屏体验「贪蛇出动」
+ + +
+
+
+
交互详情
+

+    
+
+
+ + + + + + + + + + + + diff --git a/public/tools/greedy_snake/js/ai.js b/public/tools/greedy_snake/js/ai.js new file mode 100644 index 0000000..8db9258 --- /dev/null +++ b/public/tools/greedy_snake/js/ai.js @@ -0,0 +1,101 @@ +/* 贪蛇出动 - AI 蛇控制器:觅食/避障/边界回避/随机加速 */ +(function () { + 'use strict'; + window.GS = window.GS || {}; + const U = GS.U; + + class AI { + constructor(snake) { + this.s = snake; + this.decideTimer = U.rand(0, 0.3); + this.wanderAngle = snake.angle; + this.boostTimer = 0; + } + + update(dt, world, snakes) { + const s = this.s; + if (s.dead) return; + this.decideTimer -= dt; + this.boostTimer -= dt; + if (this.decideTimer > 0) { + s.boosting = this.boostTimer > 0; + return; + } + this.decideTimer = 0.15; + + const h = s.head; + const look = 90 + s.radius * 3; + const aheadX = h.x + Math.cos(s.angle) * look; + const aheadY = h.y + Math.sin(s.angle) * look; + + // 1) 边界回避 + const margin = 220; + let avoid = null; + if (aheadX < margin || aheadY < margin || aheadX > world.size - margin || aheadY > world.size - margin) { + avoid = Math.atan2(world.size / 2 - h.y, world.size / 2 - h.x); + } + + // 2) 蛇身回避:检查前方采样点是否撞上其他蛇 + if (avoid == null) { + for (const o of snakes) { + if (o === s || o.dead) continue; + const segs = o.segments(); + const rr = (s.radius + o.radius + 26); + for (let i = 0; i < segs.length; i += 2) { + if (U.dist2(aheadX, aheadY, segs[i].x, segs[i].y) < rr * rr) { + // 向左或向右转,选更开阔的一侧 + const left = s.angle - Math.PI / 2, right = s.angle + Math.PI / 2; + avoid = this._clearer(left, right, h, snakes, s); + break; + } + } + if (avoid != null) break; + } + } + + if (avoid != null) { + s.targetAngle = avoid; + s.boosting = false; + return; + } + + // 3) 觅食:找最近金币 + let best = null, bestD = 520 * 520; + const near = world.grid.query(h.x, h.y, 520); + for (const c of near) { + const d2 = U.dist2(h.x, h.y, c.x, c.y); + if (d2 < bestD) { bestD = d2; best = c; } + } + if (best) { + s.targetAngle = Math.atan2(best.y - h.y, best.x - h.x); + } else { + // 漫游:小幅随机偏转 + this.wanderAngle += U.rand(-0.6, 0.6); + s.targetAngle = this.wanderAngle; + } + + // 4) 随机加速:追食或逃跑时小概率冲刺 + if (Math.random() < 0.06 && s.score > 30) this.boostTimer = U.rand(0.4, 1.0); + s.boosting = this.boostTimer > 0; + } + + _clearer(a1, a2, h, snakes, s) { + const score = a => { + const px = h.x + Math.cos(a) * 160, py = h.y + Math.sin(a) * 160; + let d = 1e9; + for (const o of snakes) { + if (o === s || o.dead) continue; + const segs = o.segments(); + for (let i = 0; i < segs.length; i += 3) { + const dd = U.dist2(px, py, segs[i].x, segs[i].y); + if (dd < d) d = dd; + } + } + return d; + }; + return score(a1) >= score(a2) ? a1 : a2; + } + } + + GS.AI = AI; +})(); diff --git a/public/tools/greedy_snake/js/config.js b/public/tools/greedy_snake/js/config.js new file mode 100644 index 0000000..b4fa277 --- /dev/null +++ b/public/tools/greedy_snake/js/config.js @@ -0,0 +1,49 @@ +/* 贪蛇出动 - 全局常量配置(前端默认值,服务端 config/greedy_snake.json 可覆盖) */ +(function () { + 'use strict'; + window.GS = window.GS || {}; + + GS.CFG = { + worldSize: 4000, // 世界边长 + botCount: 9, // AI 蛇数量 + coinCount: 320, // 场内常驻金币数 + baseSpeed: 130, // 基础移速 px/s + boostSpeed: 250, // 加速移速 px/s + turnRate: 4.6, // 玩家转向速度 rad/s + botTurnRate: 3.2, // AI 转向速度 rad/s + startScore: 10, // 初始长度分 + treasureTarget: 2000, // 宝藏屋目标 + rerollCost: 20, // 换一换钻石 + initialGems: 100, // 初始钻石 + reviveCountdown: 5, // 复活倒计时 + playerName: '贪蛇小将', + uiMode: 'debug' // debug / normal,服务端配置优先 + }; + + // 道具定义 + GS.ITEMS = { + helmet: { key: 'helmet', name: '头盔护盾', desc: '开局获得护盾,可抵挡一次致命碰撞' }, + rocket: { key: 'rocket', name: '火箭冲刺', desc: '开局自动冲刺 4 秒,冲刺期间无敌' }, + magnet: { key: 'magnet', name: '吸铁石', desc: '开局 20 秒内吸附附近金币' }, + expcard: { key: 'expcard', name: '体验卡', desc: '开局直接 +200 长度' } + }; + GS.ITEM_KEYS = ['helmet', 'rocket', 'magnet', 'expcard']; + + // 蛇配色(玩家固定红色,AI 随机) + GS.SNAKE_COLORS = [ + { body: '#f2f2f7', dark: '#c9c9d8' }, // 白 + { body: '#f0c070', dark: '#c8934a' }, // 面包黄 + { body: '#8fd0ff', dark: '#5a9cd0' }, // 浅蓝 + { body: '#a0e8a0', dark: '#6ab86a' }, // 绿 + { body: '#e0a0e8', dark: '#b06ab8' }, // 粉紫 + { body: '#ffb060', dark: '#d08030' }, // 橙 + { body: '#b8b8c8', dark: '#8a8a9c' } // 灰 + ]; + GS.PLAYER_COLOR = { body: '#e8394a', dark: '#b81f33' }; + + GS.BOT_NAMES = [ + 'Wanderer', 'MILu', '夜行者', '黑白的熊猫', '木头宇', '星空', + '态度', '林小八', 'Angel', '疾风少年', '小旋风', '贪吃鬼', + '史蒂文', '米奇的k夏', '石家庄一起快乐', '不记得了' + ]; +})(); diff --git a/public/tools/greedy_snake/js/input.js b/public/tools/greedy_snake/js/input.js new file mode 100644 index 0000000..254f1c6 --- /dev/null +++ b/public/tools/greedy_snake/js/input.js @@ -0,0 +1,109 @@ +/* 贪蛇出动 - 输入控制:左摇杆 + 右加速按钮 + 桌面键盘 */ +(function () { + 'use strict'; + window.GS = window.GS || {}; + + class Input { + constructor() { + this.angle = -Math.PI / 2; // 目标方向 + this.boost = false; + this.enabled = false; + this._joyId = null; + this._joyBase = { x: 0, y: 0 }; + this._keys = new Set(); + this._bind(); + } + + _bind() { + const joy = document.getElementById('gs-joystick'); + const knob = document.getElementById('gs-joystick-knob'); + const boostBtn = document.getElementById('gs-boost'); + this.joyEl = joy; this.knobEl = knob; this.boostEl = boostBtn; + + // 摇杆:触摸左下区域 + joy.addEventListener('touchstart', e => { + e.preventDefault(); + const t = e.changedTouches[0]; + this._joyId = t.identifier; + const r = joy.getBoundingClientRect(); + this._joyBase = { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + this._moveKnob(t.clientX, t.clientY); + }, { passive: false }); + joy.addEventListener('touchmove', e => { + e.preventDefault(); + for (const t of e.changedTouches) if (t.identifier === this._joyId) this._moveKnob(t.clientX, t.clientY); + }, { passive: false }); + const endJoy = e => { + for (const t of e.changedTouches) if (t.identifier === this._joyId) { + this._joyId = null; + this.knobEl.style.transform = 'translate(-50%, -50%)'; + } + }; + joy.addEventListener('touchend', endJoy); + joy.addEventListener('touchcancel', endJoy); + + // 桌面鼠标拖拽摇杆 + let mouseDown = false; + joy.addEventListener('mousedown', e => { + mouseDown = true; + const r = joy.getBoundingClientRect(); + this._joyBase = { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + this._moveKnob(e.clientX, e.clientY); + }); + window.addEventListener('mousemove', e => { if (mouseDown) this._moveKnob(e.clientX, e.clientY); }); + window.addEventListener('mouseup', () => { mouseDown = false; this.knobEl.style.transform = 'translate(-50%, -50%)'; }); + + // 加速按钮:按住加速 + const boostOn = e => { e.preventDefault(); this.boost = true; boostBtn.classList.add('active'); }; + const boostOff = () => { this.boost = false; boostBtn.classList.remove('active'); }; + boostBtn.addEventListener('touchstart', boostOn, { passive: false }); + boostBtn.addEventListener('touchend', boostOff); + boostBtn.addEventListener('touchcancel', boostOff); + boostBtn.addEventListener('mousedown', boostOn); + boostBtn.addEventListener('mouseup', boostOff); + boostBtn.addEventListener('mouseleave', boostOff); + + // 键盘(桌面调试):方向键/WSAD 转向,空格加速 + window.addEventListener('keydown', e => { + this._keys.add(e.code); + if (e.code === 'Space') { this.boost = true; boostBtn.classList.add('active'); } + this._applyKeys(); + }); + window.addEventListener('keyup', e => { + this._keys.delete(e.code); + if (e.code === 'Space') { this.boost = false; boostBtn.classList.remove('active'); } + this._applyKeys(); + }); + + // 阻止页面滚动/双击缩放 + document.addEventListener('touchmove', e => { e.preventDefault(); }, { passive: false }); + document.addEventListener('gesturestart', e => e.preventDefault()); + } + + _applyKeys() { + let dx = 0, dy = 0; + if (this._keys.has('ArrowLeft') || this._keys.has('KeyA')) dx -= 1; + if (this._keys.has('ArrowRight') || this._keys.has('KeyD')) dx += 1; + if (this._keys.has('ArrowUp') || this._keys.has('KeyW')) dy -= 1; + if (this._keys.has('ArrowDown') || this._keys.has('KeyS')) dy += 1; + if (dx || dy) this.angle = Math.atan2(dy, dx); + } + + _moveKnob(cx, cy) { + const maxR = 40; + let dx = cx - this._joyBase.x, dy = cy - this._joyBase.y; + const d = Math.sqrt(dx * dx + dy * dy); + if (d > maxR) { dx = dx / d * maxR; dy = dy / d * maxR; } + this.knobEl.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`; + if (d > 8) this.angle = Math.atan2(dy, dx); + } + + setVisible(v) { + this.enabled = v; + this.joyEl.classList.toggle('gs-hidden', !v); + this.boostEl.classList.toggle('gs-hidden', !v); + } + } + + GS.Input = Input; +})(); diff --git a/public/tools/greedy_snake/js/main.js b/public/tools/greedy_snake/js/main.js new file mode 100644 index 0000000..339e706 --- /dev/null +++ b/public/tools/greedy_snake/js/main.js @@ -0,0 +1,337 @@ +/* 贪蛇出动 - 主控:状态机 / 游戏循环 / 击杀与死亡 / 复活 / 服务端数据同步 */ +(function () { + 'use strict'; + window.GS = window.GS || {}; + const U = GS.U; + + const API = '/api/greedy_snake'; + + class Game { + constructor() { + this.ui = new GS.UI(); + this.render = new GS.Render(document.getElementById('gs-canvas')); + this.input = new GS.Input(); + this.state = 'idle'; // idle / roulette / playing / dead / result + this.stats = { gems: GS.CFG.initialGems, totalCoins: 0, best: 0, todayBest: null }; + this.player = null; + this.bots = []; + this.ais = new Map(); + this.world = null; + this.selectedItem = -1; + this.reelKeys = []; + this.freeReviveUsed = false; + this.treasureDone = false; + this._last = 0; + this._boardTimer = 0; + this._respawnQueue = []; + this._killerName = ''; + this._reviveTimer = null; + this._bindUI(); + } + + async init() { + // 拉取服务端配置与存档 + const cfg = await this.ui.trackFetch('加载游戏配置', API + '/config'); + if (cfg && cfg.ok && cfg.data) { + Object.assign(GS.CFG, { + worldSize: cfg.data.world_size || GS.CFG.worldSize, + botCount: cfg.data.bot_count || GS.CFG.botCount, + coinCount: cfg.data.coin_count || GS.CFG.coinCount, + baseSpeed: cfg.data.base_speed || GS.CFG.baseSpeed, + boostSpeed: cfg.data.boost_speed || GS.CFG.boostSpeed, + treasureTarget: cfg.data.treasure_target || GS.CFG.treasureTarget, + rerollCost: cfg.data.reroll_cost || GS.CFG.rerollCost, + initialGems: cfg.data.initial_gems || GS.CFG.initialGems, + reviveCountdown: cfg.data.revive_countdown || GS.CFG.reviveCountdown, + playerName: cfg.data.player_name || GS.CFG.playerName + }); + this.ui.setUiMode(cfg.data.ui_mode || 'debug'); + } + const st = await this.ui.trackFetch('加载存档', API + '/stats'); + if (st && st.ok && st.data) this.stats = Object.assign(this.stats, st.data); + document.getElementById('gs-progress-target').textContent = GS.CFG.treasureTarget; + this._openRoulette(); + requestAnimationFrame(t => this._loop(t)); + } + + /* ---------------- 转盘阶段 ---------------- */ + _openRoulette() { + this.state = 'roulette'; + this.input.setVisible(false); + this.ui.showHud(false); + this.ui.hideResult(); + this.ui.hideRevive(); + this.selectedItem = -1; + this._rollReels(); + this.ui.showRoulette(this.stats.gems); + } + + _rollReels() { + const pool = GS.ITEM_KEYS.slice(); + this.reelKeys = [U.pick(pool), U.pick(pool), U.pick(pool)]; + this.ui.spinReels(this.reelKeys); + setTimeout(() => this.ui.setReels(this.reelKeys, this.selectedItem), 900); + } + + _bindUI() { + // 转盘:选择道具 / 换一换 / 直接开始 + document.querySelectorAll('.gs-reel').forEach(reel => { + reel.addEventListener('click', () => { + const idx = Number(reel.dataset.idx); + this.selectedItem = this.selectedItem === idx ? -1 : idx; + this.ui.setReels(this.reelKeys, this.selectedItem); + }); + }); + document.getElementById('gs-reroll').addEventListener('click', async () => { + if (this.stats.gems < GS.CFG.rerollCost) { this.ui.toast('钻石不足', 'error'); return; } + const btn = document.getElementById('gs-reroll'); + btn.disabled = true; + this.stats.gems -= GS.CFG.rerollCost; + this.ui.updateGems(this.stats.gems); + this._saveStats({ gems: this.stats.gems }); + this.selectedItem = -1; + this._rollReels(); + }); + document.getElementById('gs-roulette-skip').addEventListener('click', () => this._startGame(null)); + document.getElementById('gs-reels').addEventListener('dblclick', () => { + if (this.selectedItem >= 0) this._startGame(this.reelKeys[this.selectedItem]); + }); + + // 复活 + document.getElementById('gs-revive-btn').addEventListener('click', () => this._revive()); + document.getElementById('gs-revive-close').addEventListener('click', () => this._toResult()); + + // 结算 + document.getElementById('gs-result-again').addEventListener('click', () => this._openRoulette()); + document.getElementById('gs-result-home').addEventListener('click', () => this._openRoulette()); + + // 弹窗 + document.getElementById('gs-dialog-close').addEventListener('click', () => this.ui.hideDialog()); + document.getElementById('gs-dialog-copy').addEventListener('click', () => { + const txt = document.getElementById('gs-dialog-body').textContent; + if (navigator.clipboard) navigator.clipboard.writeText(txt).then(() => this.ui.toast('已复制', 'success')); + }); + } + + /* ---------------- 开局 ---------------- */ + _startGame(itemKey) { + this.ui.hideRoulette(); + this.world = new GS.World(); + this.bots = []; + this.ais.clear(); + this._respawnQueue = []; + this.freeReviveUsed = false; + this.treasureDone = false; + + // 玩家 + const px = U.rand(800, GS.CFG.worldSize - 800); + const py = U.rand(800, GS.CFG.worldSize - 800); + this.player = new GS.Snake({ id: 'p', name: GS.CFG.playerName, isPlayer: true, color: GS.PLAYER_COLOR, x: px, y: py }); + this._applyItem(this.player, itemKey); + + // AI + for (let i = 0; i < GS.CFG.botCount; i++) this._spawnBot(); + + this.state = 'playing'; + this.input.setVisible(true); + this.ui.showHud(true); + if (itemKey) this.ui.toast('已装备:' + GS.ITEMS[itemKey].name, 'success'); + } + + _applyItem(s, key) { + if (!key) return; + const now = performance.now(); + if (key === 'helmet') s.shield = true; + if (key === 'rocket') s.invincibleUntil = now + 4000; + if (key === 'magnet') s.magnetUntil = now + 20000; + if (key === 'expcard') s.grow(200); + } + + _spawnBot() { + const b = new GS.Snake({ + id: 'b' + Math.random().toString(36).slice(2, 7), + name: U.pick(GS.BOT_NAMES), + color: U.pick(GS.SNAKE_COLORS), + skin: Math.random() < 0.5 ? 1 : 0, + x: U.rand(300, GS.CFG.worldSize - 300), + y: U.rand(300, GS.CFG.worldSize - 300), + score: U.randInt(10, 400) + }); + this.bots.push(b); + this.ais.set(b.id, new GS.AI(b)); + } + + /* ---------------- 主循环 ---------------- */ + _loop(t) { + const dt = Math.min(0.05, (t - this._last) / 1000 || 0.016); + this._last = t; + if (this.state === 'playing' || this.state === 'dead') this._step(dt, t); + if (this.world) this.render.draw(this.world, this._allSnakes(), this.player, t); + requestAnimationFrame(tt => this._loop(tt)); + } + + _allSnakes() { return this.player ? [this.player, ...this.bots] : this.bots; } + + _step(dt, now) { + const p = this.player; + // 玩家输入 + if (this.state === 'playing' && !p.dead) { + p.targetAngle = this.input.angle; + const wantBoost = this.input.boost; + p.boosting = wantBoost && p.score > 15; + if (p.boosting) { + p.score = Math.max(10, p.score - 6 * dt); // 加速消耗长度 + if (Math.random() < dt * 2) this.world.addCoin( + p.head.x - Math.cos(p.angle) * p.radius * 2, + p.head.y - Math.sin(p.angle) * p.radius * 2, 0); + } + } + + // AI 决策 + for (const b of this.bots) this.ais.get(b.id).update(dt, this.world, this._allSnakes()); + + // 移动 + for (const s of this._allSnakes()) s.update(dt, now); + + // 金币/磁铁 + this.world.update(dt, this._allSnakes(), now); + + // 相机 + if (!p.dead) this.world.updateCam(p.head, this.render.w, this.render.h, dt); + + // 碰撞与死亡 + const snakes = this._allSnakes(); + for (const s of snakes) { + if (s.dead) continue; + const hit = this.world.collide(s, snakes); + if (!hit) continue; + if (s.shield) { s.shield = false; s.invincibleUntil = now + 1500; continue; } + if (s.invincible(now)) continue; + this._onDeath(s, hit, now); + } + + // 复活队列 + for (let i = this._respawnQueue.length - 1; i >= 0; i--) { + const r = this._respawnQueue[i]; + if (now >= r.at) { this._respawnQueue.splice(i, 1); this._spawnBot(); } + } + + // 宝藏屋进度 + if (!this.treasureDone && p && !p.dead && p.score >= GS.CFG.treasureTarget) { + this.treasureDone = true; + this.world.treasureBurst(p.head.x, p.head.y); + p.lucky += 5; + this.ui.toast('长度达标!宝藏屋开启,幸运星 +5', 'success'); + } + + // HUD 刷新 + this._boardTimer -= dt; + if (this._boardTimer <= 0) { + this._boardTimer = 0.5; + this._refreshHud(); + } + } + + _onDeath(s, hit, now) { + const segs = s.die(); + this.world.dropFromSegments(segs); + if (hit.type === 'body' && hit.snake) hit.snake.kills += 1; + if (s.isPlayer) { + this._killerName = hit.type === 'wall' ? '' : (hit.snake ? hit.snake.name : '对手'); + this.state = 'dead'; + this.input.setVisible(false); + this.input.boost = false; + this._startReviveCountdown(); + } else { + // 玩家击杀判定:撞到玩家身体 + if (hit.type === 'body' && hit.snake === this.player) this.ui.toast('击杀 ' + s.name + '!', 'success'); + this.bots = this.bots.filter(b => b !== s); + this.ais.delete(s.id); + this._respawnQueue.push({ at: now + 3000 }); + } + } + + /* ---------------- 复活 / 结算 ---------------- */ + _startReviveCountdown() { + const total = GS.CFG.reviveCountdown; + let left = total; + this.ui.showRevive(this._killerName, this.freeReviveUsed); + this.ui.updateReviveCount(left, 1); + clearInterval(this._reviveTimer); + this._reviveTimer = setInterval(() => { + left -= 0.1; + if (left <= 0) { clearInterval(this._reviveTimer); this._toResult(); return; } + this.ui.updateReviveCount(Math.ceil(left), left / total); + }, 100); + } + + _revive() { + if (this.freeReviveUsed) return; + this.freeReviveUsed = true; + clearInterval(this._reviveTimer); + const p = this.player; + p.dead = false; + // 安全点重生:世界中心附近 + const x = U.clamp(p.head.x + U.rand(-600, 600), 400, GS.CFG.worldSize - 400); + const y = U.clamp(p.head.y + U.rand(-600, 600), 400, GS.CFG.worldSize - 400); + p.path = [{ x, y }]; + p.pathLen = 0; + p.shield = true; + p.invincibleUntil = performance.now() + 3000; + this.ui.hideRevive(); + this.input.setVisible(true); + this.state = 'playing'; + this.ui.toast('复活成功,护盾保护中', 'success'); + } + + _toResult() { + clearInterval(this._reviveTimer); + this.ui.hideRevive(); + this.state = 'result'; + const p = this.player; + const score = Math.floor(p.score); + if (score > this.stats.best) this.stats.best = score; + this.stats.totalCoins += p.coins; + this.stats.gems += p.kills * 5 + p.lucky; + this.ui.showResult({ score, coins: p.coins, kills: p.kills, lucky: p.lucky, best: this.stats.best }); + this._saveStats({ score, coins: p.coins, kills: p.kills, lucky: p.lucky, gems: this.stats.gems, totalCoins: this.stats.totalCoins }); + } + + _refreshHud() { + const p = this.player; + if (!p) return; + this.ui.updateStats(this.stats.totalCoins + p.coins, p.lucky, p.kills); + this.ui.updateProgress(p.score, GS.CFG.treasureTarget, p.score >= 1000 && !this.treasureDone); + // 排行榜 + const rows = this._allSnakes().filter(s => !s.dead) + .map(s => ({ name: s.name, score: Math.floor(s.score), me: s.isPlayer })) + .sort((a, b) => b.score - a.score); + const top = rows.slice(0, 9); + const meRow = rows.find(r => r.me); + if (meRow && !top.includes(meRow)) top.push(meRow); + this.ui.updateBoard(top, meRow, this.stats.todayBest && this.stats.todayBest.score >= 0 ? this.stats.todayBest : { name: '-', score: this.stats.best }); + } + + /* ---------------- 服务端同步 ---------------- */ + async _saveStats(extra) { + const body = Object.assign({}, extra); + const res = await this.ui.trackFetch('保存战绩', API + '/stats', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (res && res.ok && res.data) { + this.stats.best = res.data.best != null ? res.data.best : this.stats.best; + this.stats.gems = res.data.gems != null ? res.data.gems : this.stats.gems; + this.stats.totalCoins = res.data.totalCoins != null ? res.data.totalCoins : this.stats.totalCoins; + this.stats.todayBest = res.data.todayBest || this.stats.todayBest; + } + } + } + + window.addEventListener('DOMContentLoaded', () => { + const g = new Game(); + GS.game = g; + g.init(); + }); +})(); diff --git a/public/tools/greedy_snake/js/render.js b/public/tools/greedy_snake/js/render.js new file mode 100644 index 0000000..907356b --- /dev/null +++ b/public/tools/greedy_snake/js/render.js @@ -0,0 +1,198 @@ +/* 贪蛇出动 - 渲染:Canvas 程序化绘制(背景网格/边界/金币/蛇/特效) */ +(function () { + 'use strict'; + window.GS = window.GS || {}; + const U = GS.U; + + class Render { + constructor(canvas) { + this.cv = canvas; + this.ctx = canvas.getContext('2d'); + this.w = 0; this.h = 0; this.dpr = 1; + this.resize(); + window.addEventListener('resize', () => this.resize()); + } + + resize() { + this.dpr = Math.min(window.devicePixelRatio || 1, 2); + this.w = window.innerWidth; + this.h = window.innerHeight; + this.cv.width = Math.floor(this.w * this.dpr); + this.cv.height = Math.floor(this.h * this.dpr); + } + + draw(world, snakes, player, now) { + const ctx = this.ctx; + const cam = world.cam; + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + + // 背景 + ctx.fillStyle = '#222b57'; + ctx.fillRect(0, 0, this.w, this.h); + + const sc = cam.scale; + const ox = this.w / 2 - cam.x * sc; + const oy = this.h / 2 - cam.y * sc; + const toX = x => x * sc + ox; + const toY = y => y * sc + oy; + + // 网格 + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; + ctx.lineWidth = 1; + const gridStep = 100 * sc; + const gx0 = ((ox % gridStep) + gridStep) % gridStep; + const gy0 = ((oy % gridStep) + gridStep) % gridStep; + ctx.beginPath(); + for (let x = gx0; x < this.w; x += gridStep) { ctx.moveTo(x, 0); ctx.lineTo(x, this.h); } + for (let y = gy0; y < this.h; y += gridStep) { ctx.moveTo(0, y); ctx.lineTo(this.w, y); } + ctx.stroke(); + + // 边界(红粉色发光线)+ 界外压暗 + const bx0 = toX(0), by0 = toY(0), bx1 = toX(world.size), by1 = toY(world.size); + ctx.fillStyle = 'rgba(8,10,26,0.55)'; + if (bx0 > 0) ctx.fillRect(0, 0, bx0, this.h); + if (bx1 < this.w) ctx.fillRect(bx1, 0, this.w - bx1, this.h); + if (by0 > 0) ctx.fillRect(0, 0, this.w, by0); + if (by1 < this.h) ctx.fillRect(0, by1, this.w, this.h - by1); + ctx.strokeStyle = '#ff4d6d'; + ctx.lineWidth = 3; + ctx.shadowColor = '#ff4d6d'; + ctx.shadowBlur = 12; + ctx.strokeRect(bx0, by0, bx1 - bx0, by1 - by0); + ctx.shadowBlur = 0; + + // 金币(视口内) + for (const c of world.coins) { + const x = toX(c.x), y = toY(c.y); + if (x < -40 || y < -40 || x > this.w + 40 || y > this.h + 40) continue; + const pulse = 1 + Math.sin(now / 300 + c.phase) * 0.12; + const r = c.r * sc * pulse; + if (c.kind === 1) this._starCoin(ctx, x, y, r); + else this._coin(ctx, x, y, r, c.kind === 2); + } + + // 蛇:先画 AI 再画玩家 + for (const s of snakes) if (!s.isPlayer && !s.dead) this._snake(ctx, s, toX, toY, sc, now); + if (player && !player.dead) this._snake(ctx, player, toX, toY, sc, now); + } + + _coin(ctx, x, y, r, big) { + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = big ? '#ffca28' : '#f5b921'; + ctx.fill(); + ctx.lineWidth = Math.max(1, r * 0.28); + ctx.strokeStyle = big ? '#c98f00' : '#b8860b'; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(x - r * 0.25, y - r * 0.25, r * 0.4, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.fill(); + } + + _starCoin(ctx, x, y, r) { + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + ctx.arc(0, 0, r, 0, Math.PI * 2); + ctx.fillStyle = '#ffc233'; + ctx.fill(); + ctx.lineWidth = Math.max(1, r * 0.22); + ctx.strokeStyle = '#c98f00'; + ctx.stroke(); + // 五角星 + ctx.beginPath(); + for (let i = 0; i < 10; i++) { + const rr = i % 2 === 0 ? r * 0.62 : r * 0.28; + const a = -Math.PI / 2 + i * Math.PI / 5; + const px = Math.cos(a) * rr, py = Math.sin(a) * rr; + if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + } + ctx.closePath(); + ctx.fillStyle = '#fff3c4'; + ctx.fill(); + ctx.restore(); + } + + _snake(ctx, s, toX, toY, sc, now) { + const segs = s.segments(); + const r = s.radius * sc; + const wob = Math.sin(s.wobble) * 0.06; + + // 磁铁光环 + if (now < s.magnetUntil) { + const h = segs[0]; + ctx.beginPath(); + ctx.arc(toX(h.x), toY(h.y), 260 * sc, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(90,200,255,0.25)'; + ctx.lineWidth = 2; + ctx.stroke(); + } + // 护盾圈 + if (s.shield || s.invincible(now)) { + const h = segs[0]; + const sr = r * 3.2; + ctx.beginPath(); + ctx.arc(toX(h.x), toY(h.y), sr, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(80,160,255,0.10)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(120,200,255,0.8)'; + ctx.lineWidth = 2.5; + ctx.stroke(); + } + // 加速尾焰 + if (s.boosting && segs.length > 2) { + const t = segs[segs.length - 1]; + ctx.beginPath(); + ctx.arc(toX(t.x), toY(t.y), r * 0.8, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,220,120,0.35)'; + ctx.fill(); + } + + // 身体:从尾到头 + for (let i = segs.length - 1; i >= 1; i--) { + const g = segs[i]; + const rr = r * (1 - (i / segs.length) * 0.25) * (1 + wob * Math.sin(i * 0.6)); + ctx.beginPath(); + ctx.arc(toX(g.x), toY(g.y), rr, 0, Math.PI * 2); + ctx.fillStyle = (s.skin === 1 && i % 4 < 2) ? s.color.dark : s.color.body; + ctx.fill(); + } + + // 头 + const h = segs[0]; + const hx = toX(h.x), hy = toY(h.y); + ctx.beginPath(); + ctx.arc(hx, hy, r * 1.08, 0, Math.PI * 2); + ctx.fillStyle = s.color.body; + ctx.fill(); + + // 眼睛:朝向前方 + const a = s.angle; + const ex = Math.cos(a), ey = Math.sin(a); + const px = -ey, py = ex; + for (const side of [1, -1]) { + const cx = hx + (ex * 0.45 + px * 0.5 * side) * r; + const cy = hy + (ey * 0.45 + py * 0.5 * side) * r; + ctx.beginPath(); + ctx.arc(cx, cy, r * 0.34, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); + ctx.beginPath(); + ctx.arc(cx + ex * r * 0.12, cy + ey * r * 0.12, r * 0.15, 0, Math.PI * 2); + ctx.fillStyle = '#111'; + ctx.fill(); + } + + // 名字 + 分数 + ctx.font = `${Math.max(11, 13 * sc)}px "PingFang SC", sans-serif`; + ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(255,255,255,0.92)'; + ctx.fillText(String(Math.floor(s.score)), hx, hy - r * 2.4); + ctx.fillStyle = s.isPlayer ? 'rgba(255,255,255,0.75)' : 'rgba(200,210,255,0.7)'; + ctx.fillText(s.name, hx, hy - r * 2.4 + 14 * sc); + } + } + + GS.Render = Render; +})(); diff --git a/public/tools/greedy_snake/js/snake.js b/public/tools/greedy_snake/js/snake.js new file mode 100644 index 0000000..e8394c3 --- /dev/null +++ b/public/tools/greedy_snake/js/snake.js @@ -0,0 +1,112 @@ +/* 贪蛇出动 - 蛇实体:移动/转向/生长/路径采样 */ +(function () { + 'use strict'; + window.GS = window.GS || {}; + const U = GS.U; + + class Snake { + constructor(opts) { + this.id = opts.id; + this.name = opts.name; + this.isPlayer = !!opts.isPlayer; + this.color = opts.color; + this.skin = opts.skin || 0; // 0 纯色 1 条纹 + this.angle = opts.angle != null ? opts.angle : U.rand(0, Math.PI * 2); + this.targetAngle = this.angle; + this.turnRate = this.isPlayer ? GS.CFG.turnRate : GS.CFG.botTurnRate; + this.score = opts.score || GS.CFG.startScore; + this.dead = false; + this.boosting = false; + this.shield = false; // 护盾:抵挡一次致命碰撞 + this.invincibleUntil = 0; // 无敌时间戳(火箭/复活保护) + this.magnetUntil = 0; // 磁铁截止 + this.kills = 0; + this.coins = 0; // 本局吃到的金币数 + this.lucky = 0; // 本局幸运星 + // 路径:密集头轨迹点 + this.path = [{ x: opts.x, y: opts.y }]; + this.pathLen = 0; + this._segs = []; + this._segsDirty = true; + this.wobble = U.rand(0, Math.PI * 2); // 身体摆动相位 + } + + get head() { return this.path[this.path.length - 1]; } + + // 半径随分数增长 + get radius() { return U.clamp(7 + Math.sqrt(Math.max(0, this.score)) * 0.32, 7, 30); } + + // 身体节数 + get segCount() { return Math.floor(10 + this.score / 6); } + + get spacing() { return Math.max(5, this.radius * 0.5); } + + get speed() { return this.boosting ? GS.CFG.boostSpeed : GS.CFG.baseSpeed; } + + invincible(now) { return now < this.invincibleUntil; } + + update(dt, now) { + if (this.dead) return; + // 转向限速 + const da = U.normAngle(this.targetAngle - this.angle); + const maxTurn = this.turnRate * dt; + this.angle += U.clamp(da, -maxTurn, maxTurn); + + const sp = this.speed; + const h = this.head; + const nx = h.x + Math.cos(this.angle) * sp * dt; + const ny = h.y + Math.sin(this.angle) * sp * dt; + this.path.push({ x: nx, y: ny }); + this.pathLen += sp * dt; + + // 裁剪路径:保留足够长度 + const need = this.segCount * this.spacing + this.radius * 4; + while (this.path.length > 2 && this.pathLen > need) { + const d = U.dist(this.path[0].x, this.path[0].y, this.path[1].x, this.path[1].y); + if (this.pathLen - d < need) break; + this.pathLen -= d; + this.path.shift(); + } + this._segsDirty = true; + this.wobble += dt * (this.boosting ? 14 : 9); + } + + // 从头部沿路径每隔 spacing 采样一节(不修改原路径) + segments() { + if (!this._segsDirty && this._segs.length) return this._segs; + const segs = [{ x: this.head.x, y: this.head.y }]; + const n = this.segCount; + const spacing = this.spacing; + let acc = 0; // 自上一个采样点起已走过但不足 spacing 的距离 + for (let i = this.path.length - 1; i > 0 && segs.length < n; i--) { + let ax = this.path[i].x, ay = this.path[i].y; + const b = this.path[i - 1]; + let d = U.dist(ax, ay, b.x, b.y); + if (d < 1e-9) continue; + while (acc + d >= spacing && segs.length < n) { + const t = (spacing - acc) / d; + const px = ax + (b.x - ax) * t; + const py = ay + (b.y - ay) * t; + segs.push({ x: px, y: py }); + d -= (spacing - acc); + ax = px; ay = py; + acc = 0; + } + acc += d; + } + this._segs = segs; + this._segsDirty = false; + return segs; + } + + grow(v) { this.score += v; } + + // 死亡:返回尸体坐标(用于掉币) + die() { + this.dead = true; + return this.segments().slice(); + } + } + + GS.Snake = Snake; +})(); diff --git a/public/tools/greedy_snake/js/ui.js b/public/tools/greedy_snake/js/ui.js new file mode 100644 index 0000000..b3b7e85 --- /dev/null +++ b/public/tools/greedy_snake/js/ui.js @@ -0,0 +1,213 @@ +/* 贪蛇出动 - UI:HUD 刷新 / 道具转盘 / 复活 / 结算 / Toast 与交互详情弹窗(双模式) */ +(function () { + 'use strict'; + window.GS = window.GS || {}; + const U = GS.U; + + /* ---------- 道具图标:程序化 SVG ---------- */ + function itemSvg(key) { + switch (key) { + case 'helmet': + return ''; + case 'rocket': + return ''; + case 'magnet': + return ''; + case 'expcard': + return ''; + default: return ''; + } + } + + class UI { + constructor() { + this.el = id => document.getElementById(id); + this.statsEl = this.el('gs-hud-stats'); + this.coinsEl = this.el('gs-stat-coins'); + this.luckyEl = this.el('gs-stat-lucky'); + this.killsEl = this.el('gs-stat-kills'); + this.progEl = this.el('gs-hud-progress'); + this.progFill = this.el('gs-progress-fill'); + this.progText = this.el('gs-progress-text'); + this.boardEl = this.el('gs-hud-board'); + this.boardList = this.el('gs-board-list'); + this.bestName = this.el('gs-best-name'); + this.bestScore = this.el('gs-best-score'); + this.rouletteEl = this.el('gs-roulette'); + this.reviveEl = this.el('gs-revive'); + this.resultEl = this.el('gs-result'); + this.toastEl = this.el('gs-toast'); + this.dlgEl = this.el('gs-dialog'); + this._bulbs(); + this._toastTimer = null; + this._dlgTimer = null; + } + + /* 转盘灯泡装饰 */ + _bulbs() { + document.querySelectorAll('.gs-bulbs').forEach(box => { + const vert = box.classList.contains('gs-bulbs-left') || box.classList.contains('gs-bulbs-right'); + const n = vert ? 7 : 12; + for (let i = 0; i < n; i++) box.appendChild(document.createElement('i')); + }); + setInterval(() => { + document.querySelectorAll('.gs-bulbs i').forEach((b, i) => b.classList.toggle('off', (i + Math.floor(Date.now() / 400)) % 2 === 0)); + }, 400); + } + + setUiMode(mode) { GS.CFG.uiMode = mode; } + + /* ---------- HUD ---------- */ + showHud(v) { + this.statsEl.classList.toggle('gs-hidden', !v); + this.boardEl.classList.toggle('gs-hidden', !v); + } + + updateStats(totalCoins, lucky, kills) { + this.coinsEl.textContent = U.fmt(totalCoins); + this.luckyEl.textContent = U.fmt(lucky); + this.killsEl.textContent = U.fmt(kills); + } + + updateProgress(score, target, visible) { + this.progEl.classList.toggle('gs-hidden', !visible); + if (!visible) return; + this.progFill.style.width = U.clamp(score / target * 100, 0, 100) + '%'; + this.progText.textContent = Math.floor(Math.min(score, target)) + '/' + target; + } + + updateBoard(rows, me, best) { + if (best) { + this.bestName.textContent = best.name || '-'; + this.bestScore.textContent = U.fmt(best.score || 0); + } + let html = ''; + rows.forEach((r, i) => { + const rank = i + 1; + const medal = rank === 1 ? '👑' : (rank <= 3 ? ['①', '②', '③'][rank - 1] : rank); + html += `
+ ${medal} + ${this._esc(r.name)} + ${U.fmt(r.score)}
`; + }); + this.boardList.innerHTML = html; + } + + _esc(s) { return String(s).replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c])); } + + /* ---------- 转盘 ---------- */ + showRoulette(gems) { + this.rouletteEl.classList.remove('gs-hidden'); + this.el('gs-reroll-text').textContent = GS.CFG.rerollCost + ' 换一换'; + this.updateGems(gems); + } + hideRoulette() { this.rouletteEl.classList.add('gs-hidden'); } + updateGems(gems) { + const btn = this.el('gs-reroll'); + btn.disabled = gems < GS.CFG.rerollCost; + } + setReels(keys, selected) { + document.querySelectorAll('.gs-reel').forEach((reel, i) => { + reel.classList.toggle('selected', selected === i); + reel.querySelector('.gs-reel-icon').innerHTML = itemSvg(keys[i]); + reel.title = GS.ITEMS[keys[i]].name + ':' + GS.ITEMS[keys[i]].desc; + }); + } + spinReels(keys) { + // 滚动动画:快速切换图标后落定 + const reels = document.querySelectorAll('.gs-reel'); + reels.forEach((reel, i) => { + const icon = reel.querySelector('.gs-reel-icon'); + let n = 0; + const timer = setInterval(() => { + icon.innerHTML = itemSvg(U.pick(GS.ITEM_KEYS)); + if (++n > 8 + i * 4) { clearInterval(timer); icon.innerHTML = itemSvg(keys[i]); } + }, 70); + }); + } + + /* ---------- 复活 ---------- */ + showRevive(killerName, usedFree) { + this.reviveEl.classList.remove('gs-hidden'); + this.el('gs-revive-killer').textContent = killerName || '对手'; + const btn = this.el('gs-revive-btn'); + btn.disabled = usedFree; + btn.textContent = usedFree ? '已复活过' : '▶ 免费复活'; + } + hideRevive() { this.reviveEl.classList.add('gs-hidden'); } + updateReviveCount(n, pct) { + this.el('gs-revive-count').textContent = n; + this.el('gs-revive-count-ring').style.setProperty('--p', (pct * 100) + '%'); + } + + /* ---------- 结算 ---------- */ + showResult(data) { + this.resultEl.classList.remove('gs-hidden'); + this.el('gs-result-score').textContent = U.fmt(data.score); + this.el('gs-result-coins').textContent = U.fmt(data.coins); + this.el('gs-result-kills').textContent = U.fmt(data.kills); + this.el('gs-result-lucky').textContent = U.fmt(data.lucky); + this.el('gs-result-best-num').textContent = U.fmt(data.best); + } + hideResult() { this.resultEl.classList.add('gs-hidden'); } + + /* ---------- Toast(normal 模式 / 强提示) ---------- */ + toast(msg, type) { + const el = this.toastEl; + el.textContent = msg; + el.className = type || ''; + el.classList.remove('gs-hidden'); + clearTimeout(this._toastTimer); + this._toastTimer = setTimeout(() => el.classList.add('gs-hidden'), 2000); + } + + /* ---------- 交互详情弹窗(debug 模式) ---------- */ + showDialog(info, mode) { + // mode: view=查看类(normal 降级 toast) / error=异常(双模式保留) + const isDebug = GS.CFG.uiMode === 'debug'; + if (!isDebug && mode === 'view') { this.toast(info.action + ' 成功', 'success'); return; } + if (!isDebug && mode !== 'error') { this.toast(info.action + (info.ok ? ' 成功' : ' 失败'), info.ok ? 'success' : 'error'); return; } + const body = this.el('gs-dialog-body'); + body.textContent = + '触发动作:' + info.action + '\n' + + '时间:' + U.nowStr() + '\n' + + '接口地址:' + (info.url || '-') + '\n' + + 'HTTP 方法:' + (info.method || '-') + '\n' + + '请求参数:' + (info.req || '-') + '\n' + + '返回结果:' + (info.res || '-') + '\n' + + '错误信息:' + (info.err || '-') + '\n' + + '耗时:' + (info.ms != null ? info.ms + 'ms' : '-') + '\n' + + '当前状态:' + (info.ok ? '成功' : '失败'); + this.dlgEl.classList.remove('gs-hidden'); + this._dlgInfo = info; + clearTimeout(this._dlgTimer); + if (mode !== 'error') this._dlgTimer = setTimeout(() => this.hideDialog(), 3000); + } + hideDialog() { this.dlgEl.classList.add('gs-hidden'); } + + /* 带交互记录的 fetch 包装(双模式) */ + async trackFetch(action, url, opts) { + const t0 = performance.now(); + let info = { action, url, method: (opts && opts.method) || 'GET', req: opts && opts.body ? opts.body : '-' }; + try { + const res = await fetch(url, opts); + const text = await res.text(); + info.ms = Math.round(performance.now() - t0); + info.res = text.slice(0, 400); + info.ok = res.ok; + if (!res.ok) info.err = 'HTTP ' + res.status; + this.showDialog(info, res.ok ? 'view' : 'error'); + return res.ok ? JSON.parse(text) : null; + } catch (e) { + info.ms = Math.round(performance.now() - t0); + info.ok = false; + info.err = String(e && e.message || e); + this.showDialog(info, 'error'); + return null; + } + } + } + + GS.UI = UI; + GS.itemSvg = itemSvg; +})(); diff --git a/public/tools/greedy_snake/js/utils.js b/public/tools/greedy_snake/js/utils.js new file mode 100644 index 0000000..3e1022c --- /dev/null +++ b/public/tools/greedy_snake/js/utils.js @@ -0,0 +1,69 @@ +/* 贪蛇出动 - 通用工具函数 */ +(function () { + 'use strict'; + window.GS = window.GS || {}; + + const U = {}; + GS.U = U; + + U.rand = (a, b) => a + Math.random() * (b - a); + U.randInt = (a, b) => Math.floor(U.rand(a, b + 1)); + U.pick = arr => arr[Math.floor(Math.random() * arr.length)]; + U.clamp = (v, a, b) => Math.max(a, Math.min(b, v)); + U.dist2 = (x1, y1, x2, y2) => { const dx = x2 - x1, dy = y2 - y1; return dx * dx + dy * dy; }; + U.dist = (x1, y1, x2, y2) => Math.sqrt(U.dist2(x1, y1, x2, y2)); + + // 角度归一化到 [-PI, PI] + U.normAngle = a => { + while (a > Math.PI) a -= Math.PI * 2; + while (a < -Math.PI) a += Math.PI * 2; + return a; + }; + + // 数字格式化:>=1e6 显示为 "1,194k",否则千分位 + U.fmt = n => { + n = Math.floor(n); + if (n >= 1e6) return Math.floor(n / 1000).toLocaleString('en-US') + 'k'; + return n.toLocaleString('en-US'); + }; + + // 北京时间日期串 YYYY-MM-DD(今日最佳用) + U.todayBJ = () => { + const d = new Date(Date.now() + (8 * 60 + new Date().getTimezoneOffset()) * 60000); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return d.getFullYear() + '-' + m + '-' + day; + }; + + U.nowStr = () => { + const d = new Date(); + const p = x => String(x).padStart(2, '0'); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); + }; + + // 简单空间哈希网格(金币拾取/碰撞加速) + class Grid { + constructor(cell) { this.cell = cell; this.m = new Map(); } + clear() { this.m.clear(); } + _key(cx, cy) { return cx + ',' + cy; } + insert(obj) { + const cx = Math.floor(obj.x / this.cell), cy = Math.floor(obj.y / this.cell); + const k = this._key(cx, cy); + let arr = this.m.get(k); + if (!arr) { arr = []; this.m.set(k, arr); } + arr.push(obj); + } + query(x, y, r) { + const out = []; + const c0 = Math.floor((x - r) / this.cell), c1 = Math.floor((x + r) / this.cell); + const r0 = Math.floor((y - r) / this.cell), r1 = Math.floor((y + r) / this.cell); + for (let cx = c0; cx <= c1; cx++) for (let cy = r0; cy <= r1; cy++) { + const arr = this.m.get(this._key(cx, cy)); + if (arr) for (const o of arr) out.push(o); + } + return out; + } + } + U.Grid = Grid; +})(); diff --git a/public/tools/greedy_snake/js/world.js b/public/tools/greedy_snake/js/world.js new file mode 100644 index 0000000..9cfeefa --- /dev/null +++ b/public/tools/greedy_snake/js/world.js @@ -0,0 +1,146 @@ +/* 贪蛇出动 - 世界:金币生成/拾取/磁铁/边界/相机/死亡掉币 */ +(function () { + 'use strict'; + window.GS = window.GS || {}; + const U = GS.U; + + class World { + constructor() { + this.size = GS.CFG.worldSize; + this.coins = []; // {x,y,r,value,kind:0普通 1星 2大, phase} + this.grid = new U.Grid(220); + this.cam = { x: this.size / 2, y: this.size / 2, scale: 1 }; + this.seed(); + } + + seed() { + this.coins.length = 0; + for (let i = 0; i < GS.CFG.coinCount; i++) this.coins.push(this._makeCoin()); + this._rebuild(); + } + + _makeCoin(x, y, kind) { + const k = kind != null ? kind : (Math.random() < 0.08 ? 1 : 0); + const base = { + 0: { r: 7, value: 2 }, + 1: { r: 12, value: 10 }, // 星币:+幸运星 + 2: { r: 14, value: 20 } // 大金币:尸体掉落 + }[k]; + return { + x: x != null ? x : U.rand(60, this.size - 60), + y: y != null ? y : U.rand(60, this.size - 60), + r: base.r, value: base.value, kind: k, + phase: U.rand(0, Math.PI * 2), + vx: 0, vy: 0 + }; + } + + _rebuild() { + this.grid.clear(); + for (const c of this.coins) this.grid.insert(c); + } + + // 尸体掉币:沿身体间隔掉落 + dropFromSegments(segs) { + for (let i = 0; i < segs.length; i += 2) { + const s = segs[i]; + this.coins.push(this._makeCoin( + U.clamp(s.x + U.rand(-14, 14), 40, this.size - 40), + U.clamp(s.y + U.rand(-14, 14), 40, this.size - 40), 2)); + } + this._rebuild(); + } + + // 外部新增单枚金币(加速掉落等),并重建网格索引 + addCoin(x, y, kind) { + const c = this._makeCoin( + U.clamp(x, 40, this.size - 40), + U.clamp(y, 40, this.size - 40), kind || 0); + c.r = kind === 2 ? 14 : (kind === 1 ? 12 : 5); + c.value = kind === 2 ? 20 : (kind === 1 ? 10 : 1); + this.coins.push(c); + this.grid.insert(c); + return c; + } + + // 宝藏屋奖励:玩家附近撒一大簇金币 + treasureBurst(x, y) { + for (let i = 0; i < 70; i++) { + const a = U.rand(0, Math.PI * 2), d = U.rand(30, 320); + this.coins.push(this._makeCoin( + U.clamp(x + Math.cos(a) * d, 40, this.size - 40), + U.clamp(y + Math.sin(a) * d, 40, this.size - 40), + Math.random() < 0.25 ? 1 : (Math.random() < 0.4 ? 2 : 0))); + } + this._rebuild(); + } + + update(dt, snakes, now) { + let moved = false; + for (const s of snakes) { + if (s.dead) continue; + const h = s.head; + const magnet = now < s.magnetUntil; + const eatR = s.radius + 16; + const pullR = magnet ? 260 : eatR; + const near = this.grid.query(h.x, h.y, Math.max(pullR, 60)); + for (const c of near) { + if (c._eaten) continue; + const d2 = U.dist2(h.x, h.y, c.x, c.y); + if (magnet && d2 < pullR * pullR) { + const d = Math.sqrt(d2) || 1; + const pull = 420 * dt; + c.x += (h.x - c.x) / d * pull; + c.y += (h.y - c.y) / d * pull; + moved = true; + } + if (d2 < (eatR + c.r) * (eatR + c.r)) { + c._eaten = true; + s.grow(c.value); + s.coins += c.value; + if (c.kind === 1) s.lucky += 1; + } + } + } + if (this.coins.some(c => c._eaten)) { + this.coins = this.coins.filter(c => !c._eaten); + // 补充普通金币保持总量 + while (this.coins.length < GS.CFG.coinCount) this.coins.push(this._makeCoin()); + moved = true; + } + if (moved) this._rebuild(); + } + + // 相机跟随 + updateCam(target, viewW, viewH, dt) { + const k = 1 - Math.pow(0.001, dt); // 平滑跟随 + this.cam.x += (target.x - this.cam.x) * k; + this.cam.y += (target.y - this.cam.y) * k; + const base = Math.min(viewW, viewH) / 720; + this.cam.scale = U.clamp(base, 0.7, 1.6); + } + + inBounds(x, y, margin) { + return x > margin && y > margin && x < this.size - margin && y < this.size - margin; + } + + // 碰撞判定:返回 {type:'body', snake} 或 {type:'wall'} 或 null + collide(s, snakes) { + const h = s.head; + if (!this.inBounds(h.x, h.y, s.radius * 0.5)) return { type: 'wall' }; + for (const o of snakes) { + if (o === s || o.dead) continue; + const segs = o.segments(); + const rr = (s.radius * 0.8 + o.radius * 0.9); + // 跳过对方头部附近(允许并排) + for (let i = 2; i < segs.length; i++) { + const g = segs[i]; + if (U.dist2(h.x, h.y, g.x, g.y) < rr * rr) return { type: 'body', snake: o }; + } + } + return null; + } + } + + GS.World = World; +})(); diff --git a/public/tools/oss_browser_link/assets/register_ossbrowser_launch.bat b/public/tools/oss_browser_link/assets/register_ossbrowser_launch.bat new file mode 100644 index 0000000..6fa63dc --- /dev/null +++ b/public/tools/oss_browser_link/assets/register_ossbrowser_launch.bat @@ -0,0 +1,31 @@ +@echo off +chcp 65001 >nul +setlocal +rem ========================================== +rem oss-browser 一键拉起协议注册脚本 +rem 用法:把本文件放到与 oss-browser.exe 同一目录,双击运行 +rem 作用:在当前用户注册表(HKCU)注册 ossbrowser:// 协议,无需管理员权限 +rem ========================================== +set "EXE=%~dp0oss-browser.exe" +if not exist "%EXE%" ( + echo. + echo [错误] 未在当前目录找到 oss-browser.exe + echo 请把本脚本放入解压后的 oss-browser-win32-x64 文件夹内(与 oss-browser.exe 同级)再运行。 + echo. + pause + exit /b 1 +) +reg add "HKCU\Software\Classes\ossbrowser" /ve /d "URL:ossbrowser Protocol" /f >nul +reg add "HKCU\Software\Classes\ossbrowser" /v "URL Protocol" /t REG_SZ /d "" /f >nul +reg add "HKCU\Software\Classes\ossbrowser\shell\open\command" /ve /d "\"%EXE%\" \"%%1\"" /f >nul +if errorlevel 1 ( + echo [失败] 注册表写入出错。 + pause + exit /b 1 +) +echo. +echo [完成] 已注册 ossbrowser:// 协议,指向: +echo %EXE% +echo 现在可回到网页点击「打开 oss-browser」。首次拉起时浏览器会弹确认框,勾选“始终允许”后即为无感打开。 +echo. +pause diff --git a/public/tools/oss_browser_link/auth_config.json b/public/tools/oss_browser_link/auth_config.json new file mode 100644 index 0000000..2e64cc7 --- /dev/null +++ b/public/tools/oss_browser_link/auth_config.json @@ -0,0 +1,15 @@ +{ + "_comment": "OSS中转直链(oss_browser_link)的鉴权配置", + "enable_auth_comment": "true:启用 tool 外层鉴权;false:完全放开访问", + "enable_auth": true, + "max_age_days_comment": "鉴权 cookie 最大有效天数(1~365)", + "max_age_days": 30, + "cookieName_comment": "鉴权 Cookie 名称,默认格式 _gate", + "cookieName": "oss_browser_link_gate", + "requireGuid_comment": "true:强制 GUID 参数才能访问;false:允许仅凭 token 或已有会话访问", + "requireGuid": false, + "guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid", + "guidParam": "guid", + "guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换(留空表示不启用 GUID)", + "guidValue": "" +} diff --git a/public/tools/oss_browser_link/index.html b/public/tools/oss_browser_link/index.html new file mode 100644 index 0000000..f029808 --- /dev/null +++ b/public/tools/oss_browser_link/index.html @@ -0,0 +1,67 @@ + + + + + +OSS中转直链 + + + +
+
+

☁️ OSS中转直链

+

oss-browser 安装包 · 鉴权下载 · 一键拉起

+
+ +
+
📦
+
+
oss-browser-win32-x64.zip
+
加载中…
+
+ 检测中 +
+ + + +
+ + + +
+ +
+

使用说明

+
    +
  1. 下载:点击上方「下载」获取安装包(直链本身受 Toolbox 鉴权保护,未授权无法下载)。
  2. +
  3. 解压:将 zip 解压到一个固定目录(如 D:\oss-browser)。
  4. +
  5. 注册协议(仅首次):下载「一键注册脚本」,放入解压目录(与 oss-browser.exe 同级),双击运行。脚本会在当前用户注册表注册 ossbrowser:// 协议,无需管理员权限。
  6. +
  7. 一键打开:以后点击「打开 oss-browser」即可直接拉起本地程序(浏览器首次会弹确认框,勾选"始终允许"后不再打扰)。
  8. +
  9. 若检测到未安装,会自动回退为下载安装包。
  10. + +
  11. 阿里OSS直链中转。DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID是LTAI5t9fraTXxm-dafe-TwE31Ph2ne
  12. +
  13. DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_SECRET是LBN3ntJfdwlnmCiA9QCed-dafe-Y7XlhxpvX
  14. +
  15. 路径是oss://android-o1-images/trae-dafe-files-temp/
  16. +
+
+ +
+ +
+
+
+ 交互详情 + + +
+

+    
+
+
+ + + + diff --git a/public/tools/oss_browser_link/oss_browser_link.css b/public/tools/oss_browser_link/oss_browser_link.css new file mode 100644 index 0000000..ea6b038 --- /dev/null +++ b/public/tools/oss_browser_link/oss_browser_link.css @@ -0,0 +1,122 @@ +/* OSS中转直链 oss_browser_link 样式 */ +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { height: 100%; } +body { + font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + background: linear-gradient(160deg, #10162e 0%, #1a2450 60%, #10162e 100%); + color: #e8ecff; + min-height: 100vh; + display: flex; + justify-content: center; + padding: 24px 16px calc(24px + env(safe-area-inset-bottom)); +} +#obl-root { width: 100%; max-width: 640px; } + +#obl-header h1 { font-size: 24px; letter-spacing: 1px; } +#obl-sub { color: #8f9ac8; font-size: 13px; margin-top: 6px; } + +#obl-file-card { + display: flex; align-items: center; gap: 14px; + background: rgba(255,255,255,.06); + border: 1px solid rgba(255,255,255,.1); + border-radius: 14px; + padding: 18px; + margin-top: 20px; +} +#obl-file-icon { font-size: 40px; } +#obl-file-info { flex: 1; min-width: 0; } +#obl-file-name { font-weight: 600; font-size: 15px; word-break: break-all; } +#obl-file-meta { color: #8f9ac8; font-size: 12px; margin-top: 5px; } +.obl-status { + font-size: 12px; padding: 4px 10px; border-radius: 999px; white-space: nowrap; + background: rgba(255,255,255,.1); color: #aab4e0; +} +.obl-status.ok { background: rgba(46,204,113,.16); color: #4be08a; } +.obl-status.miss { background: rgba(255,120,120,.16); color: #ff8f8f; } + +#obl-link-row { display: flex; gap: 8px; margin-top: 14px; } +#obl-link-input { + flex: 1; min-width: 0; + background: rgba(0,0,0,.3); + border: 1px solid rgba(255,255,255,.14); + border-radius: 10px; + color: #b9c3ef; + font-size: 12px; + padding: 10px 12px; + outline: none; +} + +.obl-btn { + border: none; border-radius: 10px; cursor: pointer; + font-size: 14px; padding: 12px 16px; color: #fff; + transition: opacity .15s, transform .05s; + -webkit-tap-highlight-color: transparent; +} +.obl-btn:active { transform: scale(.97); } +.obl-btn:disabled { opacity: .5; cursor: not-allowed; } +.obl-btn.primary { background: linear-gradient(135deg, #3d7bff, #5a4dff); } +.obl-btn.green { background: linear-gradient(135deg, #20b26a, #17a05e); } +.obl-btn.ghost { + background: transparent; color: #aab4e0; + border: 1px solid rgba(255,255,255,.22); +} + +#obl-actions { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; } +#obl-link-row .obl-btn { white-space: nowrap; } + +#obl-steps { + margin-top: 22px; + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.08); + border-radius: 14px; + padding: 16px 18px; +} +#obl-steps h2 { font-size: 15px; margin-bottom: 10px; color: #cfd7ff; } +#obl-steps ol { padding-left: 20px; } +#obl-steps li { font-size: 13px; color: #9aa5d2; line-height: 1.9; } +#obl-steps code { + background: rgba(0,0,0,.35); padding: 1px 6px; border-radius: 5px; + font-size: 12px; color: #7fd0ff; +} + +/* Toast */ +#obl-toast { + position: fixed; left: 50%; top: 18%; transform: translateX(-50%); + background: #2d3a6e; color: #fff; font-size: 14px; + padding: 10px 18px; border-radius: 10px; z-index: 60; + box-shadow: 0 6px 24px rgba(0,0,0,.4); + max-width: 86vw; +} +#obl-toast.success { background: #1d9e58; } +#obl-toast.error { background: #d84a4a; } + +/* 交互详情弹窗(双模式) */ +#obl-dialog { + position: fixed; inset: 0; background: rgba(0,0,0,.55); + display: flex; align-items: center; justify-content: center; + z-index: 100; padding: 20px; +} +#obl-dialog-box { + width: 100%; max-width: 560px; max-height: 78vh; + background: #182047; border: 1px solid rgba(255,255,255,.14); + border-radius: 14px; display: flex; flex-direction: column; +} +#obl-dialog-head { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,.1); +} +#obl-dialog-title { flex: 1; font-size: 14px; font-weight: 600; } +#obl-dialog-head button { + background: rgba(255,255,255,.1); border: none; color: #dfe5ff; + border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 13px; +} +#obl-dialog-body { + padding: 12px 14px; overflow: auto; flex: 1; + font-size: 12px; line-height: 1.7; color: #b9c3ef; white-space: pre-wrap; word-break: break-all; +} +.obl-hidden { display: none !important; } + +@media (min-width: 720px) { + #obl-actions { flex-direction: row; flex-wrap: wrap; } + #obl-actions .obl-btn { flex: 1; } +} diff --git a/public/tools/oss_browser_link/oss_browser_link.js b/public/tools/oss_browser_link/oss_browser_link.js new file mode 100644 index 0000000..5909bcc --- /dev/null +++ b/public/tools/oss_browser_link/oss_browser_link.js @@ -0,0 +1,236 @@ +/* OSS中转直链 oss_browser_link 前端逻辑(双模式弹窗 / 交互详情 / 协议拉起) */ +;(function () { + 'use strict' + var CFG = { ui_mode: 'debug', public_url: '', launch_protocol: 'ossbrowser' } + + function $ (id) { return document.getElementById(id) } + + function bjNow () { + try { return new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }) } catch (e) { return new Date().toLocaleString() } + } + + /* ---------- Toast ---------- */ + var toastTimer = null + function showToast (msg, kind) { + var el = $('obl-toast') + el.textContent = msg + el.className = kind || '' + clearTimeout(toastTimer) + toastTimer = setTimeout(function () { el.className = 'obl-hidden' }, 2000) + } + + /* ---------- 交互详情弹窗(双模式) ---------- */ + var dialogTimer = null + function showDialog (title, text, opts) { + opts = opts || {} + $('obl-dialog-title').textContent = title + $('obl-dialog-body').textContent = text + $('obl-dialog').classList.remove('obl-hidden') + clearTimeout(dialogTimer) + if (opts.error) return // 异常:停留窗口,不自动关闭 + var delay = CFG.ui_mode === 'debug' ? 3000 : 2000 + dialogTimer = setTimeout(closeDialog, delay) + } + function closeDialog () { + clearTimeout(dialogTimer) + $('obl-dialog').classList.add('obl-hidden') + } + + function buildLog (rec) { + return [ + '触发动作: ' + rec.action, + '时间: ' + rec.time, + '接口: ' + rec.method + ' ' + rec.url, + '请求参数: ' + JSON.stringify(rec.params || {}), + '状态: ' + rec.status, + '返回结果: ' + rec.result, + rec.error ? '错误信息: ' + rec.error : '', + '耗时: ' + rec.cost + 'ms' + ].filter(Boolean).join('\n') + } + + /* 包装一次与后台/系统交互的操作,按 ui_mode 分支提示 */ + function runTracked (action, url, method, params, fn, opts) { + opts = opts || {} + var t0 = Date.now() + var rec = { action: action, time: bjNow(), url: url, method: method, params: params, status: '进行中', result: '', error: '', cost: 0 } + return Promise.resolve() + .then(fn) + .then(function (result) { + rec.cost = Date.now() - t0 + rec.status = '成功' + rec.result = typeof result === 'string' ? result : JSON.stringify(result) + if (CFG.ui_mode === 'debug') showDialog('交互详情 · ' + action, buildLog(rec)) + else if (!opts.silent) showToast(action + ' 成功', 'success') + return result + }) + .catch(function (err) { + rec.cost = Date.now() - t0 + rec.status = '失败' + rec.error = String((err && err.message) || err) + showDialog('交互详情 · ' + action, buildLog(rec), { error: true }) // 异常双模式均强提示 + throw err + }) + } + + /* ---------- 初始化:拉配置 + 文件状态 ---------- */ + function init () { + fetch('/api/oss_browser_link/config').then(function (r) { return r.json() }).then(function (j) { + if (j && j.ok && j.data) { + CFG.ui_mode = j.data.ui_mode || 'debug' + CFG.public_url = j.data.public_url || '' + CFG.launch_protocol = j.data.launch_protocol || 'ossbrowser' + } + }).catch(function () {}) + $('obl-link-input').value = CFG.public_url || location.origin + '/download_files/oss-browser-win32-x64.zip' + refreshFileInfo() + bindEvents() + } + + function fmtSize (n) { + if (!Number.isFinite(n)) return '' + if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(2) + ' MB' + if (n >= 1024) return (n / 1024).toFixed(1) + ' KB' + return n + ' B' + } + + function refreshFileInfo () { + var t0 = Date.now() + fetch('/api/oss_browser_link/info').then(function (r) { return r.json() }).then(function (j) { + var meta = $('obl-file-meta') + var st = $('obl-file-status') + var cost = Date.now() - t0 + if (j && j.ok && j.data && j.data.available) { + meta.textContent = '大小 ' + fmtSize(j.data.size) + ' · 已中转至本站 · 鉴权保护' + st.textContent = '可下载' + st.className = 'obl-status ok' + $('obl-download-btn').disabled = false + } else { + meta.textContent = '尚未检测到中转文件 · 请等待管理员中转后刷新' + st.textContent = '未就绪' + st.className = 'obl-status miss' + $('obl-download-btn').disabled = true + } + if (CFG.ui_mode === 'debug') { + meta.textContent += ' · 检测耗时 ' + cost + 'ms' + } + }).catch(function () { + $('obl-file-meta').textContent = '文件状态检测失败' + $('obl-file-status').textContent = '异常' + $('obl-file-status').className = 'obl-status miss' + }) + } + + /* ---------- 按钮事件 ---------- */ + function bindEvents () { + $('obl-copy-btn').addEventListener('click', function () { + var url = $('obl-link-input').value + var btn = this + btn.disabled = true; btn.textContent = '复制中…' + runTracked('复制直链', location.href, 'CLIPBOARD', { url: url }, function () { + if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(url) + $('obl-link-input').select() + document.execCommand('copy') + return 'fallback copy ok' + }).then(function () { + showToast('直链已复制(对方需鉴权才能下载)', 'success') + }).catch(function () {}) + btn.disabled = false; btn.textContent = '复制直链' + }) + + $('obl-download-btn').addEventListener('click', function () { + var btn = this + btn.disabled = true; btn.textContent = '准备下载…' + runTracked('下载安装包', '/download_files/oss-browser-win32-x64.zip', 'GET', {}, function () { + return new Promise(function (resolve, reject) { + var xhr = new XMLHttpRequest() + xhr.open('GET', '/download_files/oss-browser-win32-x64.zip') + xhr.responseType = 'blob' + xhr.onload = function () { + if (xhr.status !== 200) return reject(new Error('HTTP ' + xhr.status)) + var a = document.createElement('a') + a.href = URL.createObjectURL(xhr.response) + a.download = 'oss-browser-win32-x64.zip' + document.body.appendChild(a) + a.click() + setTimeout(function () { URL.revokeObjectURL(a.href); a.remove() }, 4000) + resolve('下载已触发,大小 ' + (xhr.response ? xhr.response.size : 0) + ' 字节') + } + xhr.onerror = function () { reject(new Error('网络错误')) } + xhr.send() + }) + }).catch(function () {}) + btn.disabled = false; btn.textContent = '⬇ 下载 oss-browser 安装包' + }) + + $('obl-launch-btn').addEventListener('click', function () { + launchOssBrowser() + }) + + $('obl-reg-btn').addEventListener('click', function () { + var btn = this + btn.disabled = true; btn.textContent = '下载中…' + runTracked('下载注册脚本', '/tools/oss_browser_link/assets/register_ossbrowser_launch.bat', 'GET', {}, function () { + var a = document.createElement('a') + a.href = '/tools/oss_browser_link/assets/register_ossbrowser_launch.bat' + a.download = 'register_ossbrowser_launch.bat' + document.body.appendChild(a) + a.click() + setTimeout(function () { a.remove() }, 2000) + return '脚本下载已触发(放入解压目录后双击运行)' + }).catch(function () {}) + btn.disabled = false; btn.textContent = '📄 下载一键注册脚本(首次)' + }) + + $('obl-dialog-close').addEventListener('click', closeDialog) + $('obl-dialog-copy').addEventListener('click', function () { + var text = $('obl-dialog-body').textContent + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text) + showToast('交互日志已复制', 'success') + }) + } + + /* ---------- 协议拉起:未装则回退下载 ---------- */ + function launchOssBrowser () { + var proto = CFG.launch_protocol || 'ossbrowser' + var url = proto + '://launch?t=' + Date.now() + var btn = $('obl-launch-btn') + btn.disabled = true; btn.textContent = '正在打开…' + runTracked('打开 oss-browser', url, 'PROTOCOL', { protocol: proto }, function () { + return new Promise(function (resolve, reject) { + var done = false + var started = false + /* 拉起成功时浏览器通常会模糊/隐藏页面 */ + window.addEventListener('blur', onBlur) + document.addEventListener('visibilitychange', onVis) + function onBlur () { started = true } + function onVis () { if (document.hidden) started = true } + try { + var f = document.createElement('iframe') + f.style.display = 'none' + f.src = url + document.body.appendChild(f) + setTimeout(function () { try { f.remove() } catch (e) {} }, 3000) + } catch (e) { location.href = url } + setTimeout(function () { + window.removeEventListener('blur', onBlur) + document.removeEventListener('visibilitychange', onVis) + if (done) return + done = true + if (started) resolve('已触发协议拉起') + else reject(new Error('未检测到已安装的 oss-browser(协议未注册)')) + }, 2500) + }) + }).then(function () { + showToast('已尝试打开 oss-browser;若浏览器弹出确认框请勾选"始终允许"', 'success') + }).catch(function () { + /* 未安装:自动回退下载安装包 */ + showToast('未检测到 oss-browser,已自动开始下载安装包', 'error') + setTimeout(function () { $('obl-download-btn').click() }, 600) + }) + setTimeout(function () { btn.disabled = false; btn.textContent = '🚀 打开 oss-browser' }, 3200) + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init) + else init() +})() diff --git a/public/tools/private_clipboard/clipboard.css b/public/tools/private_clipboard/clipboard.css index b07cd92..b64f811 100644 --- a/public/tools/private_clipboard/clipboard.css +++ b/public/tools/private_clipboard/clipboard.css @@ -640,21 +640,35 @@ body { .pc-image-thumb { width: 100%; - height: 180px; border-radius: 10px; border: 1px solid rgba(63, 70, 89, 0.4); background: rgba(18, 20, 28, 0.7); overflow: hidden; cursor: zoom-in; + display: flex; + flex-direction: column; } .pc-image-thumb img { width: 100%; - height: 100%; + height: 180px; object-fit: cover; display: block; } +.pc-image-thumb-actions { + display: flex; + gap: 8px; + padding: 8px; + border-top: 1px solid rgba(63, 70, 89, 0.35); + background: rgba(14, 16, 22, 0.7); +} + +.pc-image-thumb-actions .btn { + font-size: 12px; + padding: 5px 10px; +} + .pc-image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); diff --git a/public/tools/private_clipboard/clipboard.js b/public/tools/private_clipboard/clipboard.js index fddf4f6..53f629a 100644 --- a/public/tools/private_clipboard/clipboard.js +++ b/public/tools/private_clipboard/clipboard.js @@ -171,6 +171,37 @@ const copyImageToClipboard = async (url) => { } } +// 限时直通URL:生成 3 天有效的无鉴权直链并复制(按钮带 loading 防重复点击) +const copyShareLink = async (itemId, btn) => { + if (!itemId) return + const originText = btn ? btn.textContent : '' + try { + if (btn) { + btn.disabled = true + btn.textContent = '生成中…' + } + const data = await apiJson(`${apiBase}/share/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: itemId }) + }) + if (!data || !data.url) throw new Error('empty_share_url') + try { + await navigator.clipboard.writeText(data.url) + } catch (e) { + window.prompt('自动复制失败,请手动复制以下直链:', data.url) + } + setStatus(`已复制限时直通URL(有效期至 ${data.expires_at_text || '3天后'},过期后重新点击按钮生成)`, 'ok') + } catch (e) { + setStatus(`生成限时直通URL失败: ${String(e.message || e)}`, 'error') + } finally { + if (btn) { + btn.disabled = false + btn.textContent = originText + } + } +} + const detectMobile = () => { if (window.matchMedia) { return window.matchMedia('(max-width: 900px), (pointer: coarse)').matches @@ -580,7 +611,7 @@ const buildItemCard = (item) => { if (images.length) { const grid = document.createElement('div') grid.className = 'pc-image-grid' - const viewImages = images.map(i => ({ url: i.file_url, name: i.file_name || '图片' })) + const viewImages = images.map(i => ({ url: i.file_url, name: i.file_name || '图片', id: i.id })) images.forEach((imgItem, idx) => { const wrap = document.createElement('div') wrap.className = 'pc-image-thumb' @@ -588,6 +619,19 @@ const buildItemCard = (item) => { img.src = imgItem.file_url img.alt = imgItem.file_name || 'image' wrap.appendChild(img) + const thumbActions = document.createElement('div') + thumbActions.className = 'pc-image-thumb-actions' + const shareBtn = document.createElement('button') + shareBtn.className = 'btn' + shareBtn.type = 'button' + shareBtn.textContent = '限时直通URL' + shareBtn.title = '生成3天有效的无鉴权直链,可在第三方浏览器/程序直接打开' + shareBtn.addEventListener('click', (e) => { + e.stopPropagation() + copyShareLink(imgItem.id, shareBtn) + }) + thumbActions.appendChild(shareBtn) + wrap.appendChild(thumbActions) wrap.addEventListener('click', () => openViewerAt(viewImages, idx)) grid.appendChild(wrap) }) @@ -630,10 +674,16 @@ const buildItemCard = (item) => { setStatus(`下载失败: ${String(e.message || e)}`, 'error') } }) + const shareFileBtn = document.createElement('button') + shareFileBtn.className = 'btn' + shareFileBtn.textContent = '限时直通URL' + shareFileBtn.title = '生成3天有效的无鉴权直链,可在第三方浏览器/程序直接打开' + shareFileBtn.addEventListener('click', () => copyShareLink(fileItem.id, shareFileBtn)) row.appendChild(name) row.appendChild(metaText) row.appendChild(copyUrlBtn) row.appendChild(downloadBtn) + row.appendChild(shareFileBtn) card.appendChild(row) }) } @@ -1366,6 +1416,14 @@ const initApp = async () => { }) } + const viewerShareUrl = $('pc-viewer-share-url') + if (viewerShareUrl) { + viewerShareUrl.addEventListener('click', () => { + const current = state.viewer.images[state.viewer.index] + if (current && current.id) copyShareLink(current.id, viewerShareUrl) + }) + } + const viewerCopyImg = $('pc-viewer-copy-img') if (viewerCopyImg) { viewerCopyImg.addEventListener('click', () => { diff --git a/public/tools/private_clipboard/index.html b/public/tools/private_clipboard/index.html index 83874d2..22375b6 100644 --- a/public/tools/private_clipboard/index.html +++ b/public/tools/private_clipboard/index.html @@ -209,6 +209,7 @@
+
diff --git a/src/server/index.js b/src/server/index.js index a7b2015..7b516b7 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -473,6 +473,7 @@ const ensurePscLoaded = () => { ensurePscLoaded() let privateClipboardRouter = null +let privateClipboardShareRouter = null const ensurePrivateClipboardLoaded = () => { let mod = null try { mod = require('./private_clipboard') } catch (e) { try { logJSON('private_clipboard.require.error', { error: String(e.message || e) }, 'private_clipboard') } catch {}; setTimeout(ensurePrivateClipboardLoaded, 2000); return } @@ -484,6 +485,10 @@ const ensurePrivateClipboardLoaded = () => { mod.bindRoutes(app) try { logJSON('private_clipboard.bind_routes.done', { ok: true }, 'private_clipboard') } catch {} } + if (mod && mod.createPublicShareRouter) { + privateClipboardShareRouter = mod.createPublicShareRouter() + try { logJSON('private_clipboard.create_share_router.done', { ok: true }, 'private_clipboard') } catch {} + } } catch (e) { try { logJSON('private_clipboard.create_router.error', { error: String(e.message || e) }, 'private_clipboard') } catch {} } @@ -1636,7 +1641,9 @@ const mapSystemIdToUrl = (systemId) => { 'Tools-private_clipboard': '/tools/private_clipboard', 'Tools-style_check': '/tools/style_check/index.html', 'Tools-short_link': '/tools/short_link', - 'Tools-web_mobile_calendar_reminder': '/tools/web_mobile_calendar_reminder' + 'Tools-web_mobile_calendar_reminder': '/tools/web_mobile_calendar_reminder', + 'Tools-greedy_snake': '/tools/greedy_snake', + 'Tools-oss_browser_link': '/tools/oss_browser_link' } if (m[systemId]) return m[systemId] if (systemId.startsWith('Tools-')) { @@ -2585,6 +2592,12 @@ app.use('/uploads/private_clipboard', (req, res, next) => { if (hasPrivateClipboardAuth(req)) return next() return res.status(401).send('未授权') }) +// 私人剪贴板「限时直通URL」公开访问入口:无任何鉴权,供第三方浏览器/程序直连; +// 安全性由 token 随机性(32位hex)与 3 天有效期控制,过期返回失效提示页。 +app.use('/share', (req, res, next) => { + if (privateClipboardShareRouter) return privateClipboardShareRouter(req, res, next) + return res.status(503).json({ ok: false, error: 'service_not_ready' }) +}) app.use('/api/private_clipboard', (req, res, next) => { // 白名单:登录和登出接口不需要鉴权 if (req.path === '/auth/login' || req.path === '/auth/logout') return next() @@ -6585,6 +6598,351 @@ app.post('/api/zen_box/config', (req, res) => { } }) +// ============================================================ +// 贪蛇出动 greedy_snake:鉴权基础函数 + 路由守卫 + 轻量战绩存储 +// 必须注册在下方全局 express.static 之前,否则静态资源会绕过鉴权中间件 +// ============================================================ +const GREEDY_SNAKE_AUTH_COOKIE = 'greedy_snake_gate' +const readGreedySnakeAuthConfig = () => { + try { + const p = path.join(process.cwd(), 'public', 'tools', 'greedy_snake', 'auth_config.json') + if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8')) + } catch {} + return {} +} +const readGreedySnakeJwk = () => { + try { + const p = path.join(process.cwd(), 'config', 'greedy_snake.jwk.json') + if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8')) + } catch {} + return { kty: 'RSA', n: '', e: '' } +} +const getGreedySnakeCookieName = () => { + try { const n = String(readGreedySnakeAuthConfig().cookieName || ''); if (n) return n } catch {} + return GREEDY_SNAKE_AUTH_COOKIE +} +const getGreedySnakeMaxAge = () => { + try { const days = Number(readGreedySnakeAuthConfig().max_age_days || 0); if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000 } catch {} + return 30 * 24 * 3600 * 1000 +} +const getGreedySnakeGuidConfig = () => { + try { + const cfg = readGreedySnakeAuthConfig() + const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim() + const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim() + return { key: keyRaw || 'guid', value: valueRaw } + } catch { return { key: 'guid', value: '' } } +} +const hasValidGreedySnakeGuid = req => { + try { + const cfg = readGreedySnakeAuthConfig() + if (cfg.enable_auth === false) return false + const { key, value } = getGreedySnakeGuidConfig() + if (!value) return false + const q = req && req.query ? req.query : {} + const raw = q[key] + if (raw === undefined || raw === null) return false + const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw) + return incoming === value + } catch { return false } +} +const isGreedySnakeForceGuid = () => { + try { + const cfg = readGreedySnakeAuthConfig() + if (cfg.enable_auth === false) return false + if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid + if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid + return false + } catch { return false } +} +const hasGreedySnakeAuth = req => { + try { + const config = readGreedySnakeAuthConfig() + if (config.enable_auth === false) return true + const cookies = parseCookie(req.headers.cookie || '') + return cookies[getGreedySnakeCookieName()] === '1' + } catch { return false } +} +app.get('/tools/greedy_snake', (req, res, next) => { + setNoCache(res) + try { + const forceGuid = isGreedySnakeForceGuid() + if (hasGreedySnakeAuth(req)) return next() + const guidKey = getGreedySnakeGuidConfig().key + const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey) + if (hasValidGreedySnakeGuid(req)) { + const maxAge = getGreedySnakeMaxAge() + const cookieName = getGreedySnakeCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' }) + return res.redirect('/tools/greedy_snake/index.html') + } + if (hasGuidInput) return res.status(401).send('未授权') + const token = String(req.query.token || '') + if (!token) { + if (forceGuid) return res.status(401).send('未授权') + return next() + } + const payload = verifyJwtWithKeys(token, jwkKeys(readGreedySnakeJwk())) + if (!payload) return res.status(401).send('未授权') + { + const iss = String(payload.iss || '') + const navIss = getNavIssFromFlags() + if (iss === navIss && !audMatch(payload, 'Tools-greedy_snake')) return res.status(401).send('未授权') + } + const maxAge = getGreedySnakeMaxAge() + const cookieName = getGreedySnakeCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' }) + return res.redirect('/tools/greedy_snake/index.html') + } catch (e) { return res.status(401).send('未授权') } +}) +app.use('/tools/greedy_snake', (req, res, next) => { + const forceGuid = isGreedySnakeForceGuid() + if (hasGreedySnakeAuth(req)) return next() + const guidKey = getGreedySnakeGuidConfig().key + const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html') + if (isHandshake && hasValidGreedySnakeGuid(req)) { + const maxAge = getGreedySnakeMaxAge() + const cookieName = getGreedySnakeCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' }) + return res.redirect('/tools/greedy_snake/index.html') + } + if (isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)) return res.status(401).send('未授权') + if (isHandshake && String(req.query.token || '')) { + const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readGreedySnakeJwk())) + if (payload) { + const iss = String(payload.iss || '') + const navIss = getNavIssFromFlags() + if (iss === navIss && !audMatch(payload, 'Tools-greedy_snake')) return res.status(401).send('未授权') + const maxAge = getGreedySnakeMaxAge() + const cookieName = getGreedySnakeCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' }) + return res.redirect('/tools/greedy_snake/index.html') + } + } + if (isHandshake && forceGuid) return res.status(401).send('未授权') + return res.status(401).send('未授权') +}) +// 贪蛇出动:战绩/配置 API(默认受 gate 保护) +const GREEDY_SNAKE_DATA_FILE = path.join(process.cwd(), 'data', 'greedy_snake.json') +const readGreedySnakeData = () => { + try { + if (fs.existsSync(GREEDY_SNAKE_DATA_FILE)) return JSON.parse(fs.readFileSync(GREEDY_SNAKE_DATA_FILE, 'utf-8')) + } catch {} + return { gems: 0, totalCoins: 0, best: 0, todayBest: null, _init: false } +} +const writeGreedySnakeData = data => { + try { + const dir = path.dirname(GREEDY_SNAKE_DATA_FILE) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(GREEDY_SNAKE_DATA_FILE, JSON.stringify(data, null, 2), 'utf-8') + return true + } catch { return false } +} +const greedySnakeTodayBJ = () => { + try { return new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }) } catch { return new Date().toISOString().slice(0, 10) } +} +app.use('/api/greedy_snake', (req, res, next) => { + if (hasGreedySnakeAuth(req)) return next() + return res.status(401).json({ ok: false, error: 'unauthorized' }) +}) +app.get('/api/greedy_snake/config', (req, res) => { + try { + const p = path.join(process.cwd(), 'config', 'greedy_snake.json') + const cfg = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : {} + return res.json({ ok: true, data: cfg }) + } catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }) } +}) +app.get('/api/greedy_snake/stats', (req, res) => { + try { + const d = readGreedySnakeData() + if (!d._init) { + // 首次访问:写入初始钻石 + d.gems = d.gems || 100 + d._init = true + writeGreedySnakeData(d) + } + const today = greedySnakeTodayBJ() + const todayBest = d.todayBest && d.todayBest.date === today ? d.todayBest : null + return res.json({ ok: true, data: { gems: d.gems || 0, totalCoins: d.totalCoins || 0, best: d.best || 0, todayBest } }) + } catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }) } +}) +app.post('/api/greedy_snake/stats', (req, res) => { + try { + const b = req.body || {} + const d = readGreedySnakeData() + if (typeof b.gems === 'number' && Number.isFinite(b.gems)) d.gems = Math.max(0, Math.floor(b.gems)) + if (typeof b.totalCoins === 'number' && Number.isFinite(b.totalCoins)) d.totalCoins = Math.max(d.totalCoins || 0, Math.floor(b.totalCoins)) + const score = Number(b.score) + if (Number.isFinite(score) && score > 0) { + d.best = Math.max(d.best || 0, Math.floor(score)) + const today = greedySnakeTodayBJ() + if (!d.todayBest || d.todayBest.date !== today || (d.todayBest.score || 0) < score) { + d.todayBest = { date: today, score: Math.floor(score), name: String(b.name || '').slice(0, 20) || '贪蛇小将' } + } + } + writeGreedySnakeData(d) + const today = greedySnakeTodayBJ() + return res.json({ ok: true, data: { gems: d.gems || 0, totalCoins: d.totalCoins || 0, best: d.best || 0, todayBest: d.todayBest && d.todayBest.date === today ? d.todayBest : null } }) + } catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }) } +}) + +// ============================================================ +// OSS中转直链 oss_browser_link:鉴权基础函数 + 路由守卫 + 受保护下载 +// 必须注册在下方全局 express.static 之前,否则静态资源会绕过鉴权中间件 +// ============================================================ +const OSS_BROWSER_LINK_AUTH_COOKIE = 'oss_browser_link_gate' +const readOssBrowserLinkAuthConfig = () => { + try { + const p = path.join(process.cwd(), 'public', 'tools', 'oss_browser_link', 'auth_config.json') + if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8')) + } catch {} + return {} +} +const readOssBrowserLinkJwk = () => { + try { + const p = path.join(process.cwd(), 'config', 'oss_browser_link.jwk.json') + if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8')) + } catch {} + return { kty: 'RSA', n: '', e: '' } +} +const getOssBrowserLinkCookieName = () => { + try { const n = String(readOssBrowserLinkAuthConfig().cookieName || ''); if (n) return n } catch {} + return OSS_BROWSER_LINK_AUTH_COOKIE +} +const getOssBrowserLinkMaxAge = () => { + try { const days = Number(readOssBrowserLinkAuthConfig().max_age_days || 0); if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000 } catch {} + return 30 * 24 * 3600 * 1000 +} +const getOssBrowserLinkGuidConfig = () => { + try { + const cfg = readOssBrowserLinkAuthConfig() + const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim() + const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim() + return { key: keyRaw || 'guid', value: valueRaw } + } catch { return { key: 'guid', value: '' } } +} +const hasValidOssBrowserLinkGuid = req => { + try { + const cfg = readOssBrowserLinkAuthConfig() + if (cfg.enable_auth === false) return false + const { key, value } = getOssBrowserLinkGuidConfig() + if (!value) return false + const q = req && req.query ? req.query : {} + const raw = q[key] + if (raw === undefined || raw === null) return false + const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw) + return incoming === value + } catch { return false } +} +const isOssBrowserLinkForceGuid = () => { + try { + const cfg = readOssBrowserLinkAuthConfig() + if (cfg.enable_auth === false) return false + if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid + if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid + return false + } catch { return false } +} +const hasOssBrowserLinkAuth = req => { + try { + const config = readOssBrowserLinkAuthConfig() + if (config.enable_auth === false) return true + const cookies = parseCookie(req.headers.cookie || '') + return cookies[getOssBrowserLinkCookieName()] === '1' + } catch { return false } +} +app.get('/tools/oss_browser_link', (req, res, next) => { + setNoCache(res) + try { + const forceGuid = isOssBrowserLinkForceGuid() + if (hasOssBrowserLinkAuth(req)) return next() + const guidKey = getOssBrowserLinkGuidConfig().key + const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey) + if (hasValidOssBrowserLinkGuid(req)) { + const maxAge = getOssBrowserLinkMaxAge() + const cookieName = getOssBrowserLinkCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' }) + return res.redirect('/tools/oss_browser_link/index.html') + } + if (hasGuidInput) return res.status(401).send('未授权') + const token = String(req.query.token || '') + if (!token) { + if (forceGuid) return res.status(401).send('未授权') + return next() + } + const payload = verifyJwtWithKeys(token, jwkKeys(readOssBrowserLinkJwk())) + if (!payload) return res.status(401).send('未授权') + { + const iss = String(payload.iss || '') + const navIss = getNavIssFromFlags() + if (iss === navIss && !audMatch(payload, 'Tools-oss_browser_link')) return res.status(401).send('未授权') + } + const maxAge = getOssBrowserLinkMaxAge() + const cookieName = getOssBrowserLinkCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' }) + return res.redirect('/tools/oss_browser_link/index.html') + } catch (e) { return res.status(401).send('未授权') } +}) +app.use('/tools/oss_browser_link', (req, res, next) => { + const forceGuid = isOssBrowserLinkForceGuid() + if (hasOssBrowserLinkAuth(req)) return next() + const guidKey = getOssBrowserLinkGuidConfig().key + const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html') + if (isHandshake && hasValidOssBrowserLinkGuid(req)) { + const maxAge = getOssBrowserLinkMaxAge() + const cookieName = getOssBrowserLinkCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' }) + return res.redirect('/tools/oss_browser_link/index.html') + } + if (isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)) return res.status(401).send('未授权') + if (isHandshake && String(req.query.token || '')) { + const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readOssBrowserLinkJwk())) + if (payload) { + const iss = String(payload.iss || '') + const navIss = getNavIssFromFlags() + if (iss === navIss && !audMatch(payload, 'Tools-oss_browser_link')) return res.status(401).send('未授权') + const maxAge = getOssBrowserLinkMaxAge() + const cookieName = getOssBrowserLinkCookieName() + res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' }) + return res.redirect('/tools/oss_browser_link/index.html') + } + } + if (isHandshake && forceGuid) return res.status(401).send('未授权') + return res.status(401).send('未授权') +}) +// OSS中转直链:受保护的 zip 下载(必须在全局 express.static 之前注册) +const OSS_BROWSER_LINK_ZIP_NAME = 'oss-browser-win32-x64.zip' +app.get(`/download_files/${OSS_BROWSER_LINK_ZIP_NAME}`, (req, res) => { + if (!hasOssBrowserLinkAuth(req)) return res.status(401).json({ ok: false, error: 'unauthorized' }) + const p = path.join(process.cwd(), 'public', 'download_files', OSS_BROWSER_LINK_ZIP_NAME) + if (!fs.existsSync(p)) return res.status(404).json({ ok: false, error: '文件尚未中转,暂不可下载' }) + res.download(p, OSS_BROWSER_LINK_ZIP_NAME) +}) +// OSS中转直链:API(默认受 gate 保护,auth/hint 白名单放行) +app.get('/api/oss_browser_link/auth/hint', (req, res) => { + res.json({ ok: true, message: '请通过首页九宫格「OSS中转直链」进入,或使用正确的访问链接' }) +}) +app.use('/api/oss_browser_link', (req, res, next) => { + if (hasOssBrowserLinkAuth(req)) return next() + return res.status(401).json({ ok: false, error: 'unauthorized' }) +}) +app.get('/api/oss_browser_link/config', (req, res) => { + try { + const p = path.join(process.cwd(), 'config', 'oss_browser_link.json') + const cfg = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : {} + return res.json({ ok: true, data: { ui_mode: cfg.ui_mode || 'debug', public_url: cfg.public_url || '', launch_protocol: cfg.launch_protocol || 'ossbrowser' } }) + } catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }) } +}) +app.get('/api/oss_browser_link/info', (req, res) => { + try { + const p = path.join(process.cwd(), 'public', 'download_files', OSS_BROWSER_LINK_ZIP_NAME) + if (!fs.existsSync(p)) return res.json({ ok: true, data: { available: false, name: OSS_BROWSER_LINK_ZIP_NAME, size: 0 } }) + const st = fs.statSync(p) + const mtime = new Date(st.mtime).toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }) + return res.json({ ok: true, data: { available: true, name: OSS_BROWSER_LINK_ZIP_NAME, size: st.size, updated_at: mtime } }) + } catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e) }) } +}) + // Static: keep weekly tool assets always fresh app.use(express.static(path.join(process.cwd(), 'public'), { setHeaders: (res, filePath) => { diff --git a/src/server/private_clipboard.js b/src/server/private_clipboard.js index aec77d3..07de437 100644 --- a/src/server/private_clipboard.js +++ b/src/server/private_clipboard.js @@ -8,6 +8,33 @@ const DATA_DIR = path.join(process.cwd(), 'data') const UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'private_clipboard') const DB_PATH = process.env.PRIVATE_CLIPBOARD_DB_PATH || path.join(DATA_DIR, 'private_clipboard.db') +// 限时直通URL:有效期 3 天;分享链接面向第三方访问,默认走公网 HTTPS 入口 +const SHARE_TTL_MS = 3 * 24 * 3600 * 1000 +const getShareBaseUrl = () => String(process.env.PUBLIC_BASE_URL || '').trim() || 'https://traesite.umersoft.com:8975' +const SHARE_EXPIRED_HTML = ` + + + + +链接已失效 + + +
+
⏳
+
你的链接期限已经大于三天。无法访问。
+
请联系分享者重新生成限时直通URL。
+
+ +` +const renderShareExpired = (res) => { + try { + res.status(410) + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.send(SHARE_EXPIRED_HTML) + } catch {} +} + if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }) @@ -40,8 +67,15 @@ const ensureSchema = () => { sort_order INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS clipboard_share_links ( + token TEXT PRIMARY KEY, + item_id INTEGER NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); CREATE INDEX IF NOT EXISTS idx_clipboard_items_created_at ON clipboard_items(created_at); CREATE INDEX IF NOT EXISTS idx_clipboard_folders_sort_order ON clipboard_folders(sort_order, id); + CREATE INDEX IF NOT EXISTS idx_clipboard_share_links_item_id ON clipboard_share_links(item_id); `) const cols = db.prepare('PRAGMA table_info(clipboard_items)').all() const hasGroup = cols.some(c => c.name === 'group_id') @@ -426,6 +460,33 @@ const createRouter = () => { } }) + // ===== 限时直通URL:生成 3 天有效的无鉴权直链 ===== + router.post('/share/create', (req, res) => { + try { + const id = Number.parseInt(String((req.body && req.body.id) || ''), 10) + if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' }) + const database = getDb() + // 顺手清理已过期的旧链接,控制表体积 + database.prepare('DELETE FROM clipboard_share_links WHERE expires_at < ?').run(Date.now()) + const row = database.prepare('SELECT id, file_name, file_path FROM clipboard_items WHERE id = ?').get(id) + if (!row || !row.file_path) return res.status(404).json({ ok: false, error: 'item_not_found' }) + const abs = path.join(process.cwd(), String(row.file_path || '').replace(/^\/+/, '')) + if (!fs.existsSync(abs)) return res.status(404).json({ ok: false, error: 'file_not_found' }) + // 同一内容只保留一条有效直链:重新生成即作废旧链接,防止链接被反复外传 + database.prepare('DELETE FROM clipboard_share_links WHERE item_id = ?').run(id) + const token = crypto.randomBytes(16).toString('hex') + const now = Date.now() + const expiresAt = now + SHARE_TTL_MS + database.prepare('INSERT INTO clipboard_share_links (token, item_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, id, now, expiresAt) + const url = `${getShareBaseUrl()}/share/clip/${token}` + let expiresText = '' + try { expiresText = new Date(expiresAt).toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }) } catch { expiresText = new Date(expiresAt).toLocaleString() } + return res.json({ ok: true, url, expires_at: expiresAt, expires_at_text: expiresText, ttl_days: 3 }) + } catch (e) { + return res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + router.get('/list', (req, res) => { try { const pageSizeRaw = String((req.query && req.query.page_size) || '50').trim().toLowerCase() @@ -680,10 +741,14 @@ const createRouter = () => { const id = parseInt(req.params.id, 10) if (!id) return res.status(400).json({ ok: false, error: 'invalid_id' }) if (id < 0) { - const rows = getDb().prepare('SELECT file_path, favorite_folder_code FROM clipboard_items WHERE group_id = ?').all(id) + const rows = getDb().prepare('SELECT id, file_path, favorite_folder_code FROM clipboard_items WHERE group_id = ?').all(id) if (!rows.length) return res.status(404).json({ ok: false, error: 'item_not_found' }) const hasFavorite = rows.some(row => row && row.favorite_folder_code) if (hasFavorite) return res.status(409).json({ ok: false, error: 'item_favorited' }) + const ids = rows.map(row => row && row.id).filter(v => Number.isFinite(v)) + if (ids.length) { + getDb().prepare(`DELETE FROM clipboard_share_links WHERE item_id IN (${ids.map(() => '?').join(',')})`).run(...ids) + } getDb().prepare('DELETE FROM clipboard_items WHERE group_id = ?').run(id) rows.forEach(row => { if (row && row.file_path) { @@ -696,6 +761,7 @@ const createRouter = () => { const row = getDb().prepare('SELECT file_path, favorite_folder_code FROM clipboard_items WHERE id = ?').get(id) if (!row) return res.status(404).json({ ok: false, error: 'item_not_found' }) if (row.favorite_folder_code) return res.status(409).json({ ok: false, error: 'item_favorited' }) + getDb().prepare('DELETE FROM clipboard_share_links WHERE item_id = ?').run(id) getDb().prepare('DELETE FROM clipboard_items WHERE id = ?').run(id) if (row && row.file_path) { const rel = String(row.file_path || '').replace(/^\/+/, '') @@ -711,4 +777,38 @@ const createRouter = () => { return router } -module.exports = { createRouter, hasBusinessAuth: hasAuth } +// ===== 限时直通URL 的公开访问路由(无任何鉴权,仅供第三方浏览器/程序直连) ===== +// 在 index.js 中必须挂载在 /api/private_clipboard 鉴权守卫之外的独立公开路径(/share) +const createPublicShareRouter = () => { + const router = express.Router() + router.get('/clip/:token', (req, res) => { + try { + const token = String(req.params.token || '') + if (!/^[a-f0-9]{32}$/.test(token)) return renderShareExpired(res) + const row = getDb().prepare(` + SELECT s.expires_at AS expires_at, i.type AS type, i.file_name AS file_name, i.file_mime AS file_mime, i.file_path AS file_path + FROM clipboard_share_links s + JOIN clipboard_items i ON i.id = s.item_id + WHERE s.token = ? + `).get(token) + if (!row || !row.file_path) return renderShareExpired(res) + if (Number(row.expires_at || 0) < Date.now()) return renderShareExpired(res) + const abs = path.join(process.cwd(), String(row.file_path || '').replace(/^\/+/, '')) + if (!fs.existsSync(abs)) return renderShareExpired(res) + const mime = String(row.file_mime || '').trim() || 'application/octet-stream' + res.setHeader('Content-Type', mime) + res.setHeader('Cache-Control', 'private, no-store, max-age=0') + const safeName = normalizeName(row.file_name || 'file') + const isImage = String(row.type || '').toLowerCase() === 'image' || mime.toLowerCase().startsWith('image/') + const dispositionType = isImage ? 'inline' : 'attachment' + const fallbackName = safeName.replace(/[^\x20-\x7E]/g, '_') + res.setHeader('Content-Disposition', `${dispositionType}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(safeName)}`) + return res.sendFile(abs) + } catch (e) { + return renderShareExpired(res) + } + }) + return router +} + +module.exports = { createRouter, createPublicShareRouter, hasBusinessAuth: hasAuth }