refactor: 重构禅音工具路径与配置,新增私人剪贴板技能

这是一次大型重构和功能新增:
1. 将原 zen_box 工具重命名为 web_zen_box,调整所有相关路径与配置
2. 新增 web_order_box 原生Web应用的完整资源与部署脚本
3. 新增私人剪贴板后端技能与配套测试脚本
4. 修复订单盒子APP的API地址配置,避免路径重复拼接
5. 新增本地开发跨域白名单支持localhost
6. 清理旧版zen_box的冗余文件
This commit is contained in:
yangxiangyuan
2026-07-26 21:03:28 +08:00
parent a062a7e2c0
commit 212dbc4e1d
47 changed files with 6446 additions and 755 deletions
+4 -2
View File
@@ -8,8 +8,10 @@ import {
OrderBoxState,
} from './types';
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_order_box';
const MOTION_BASE = `${API_BASE_URL}/kits/motion_reminder`;
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || '/api/app_order_box';
// 注意:MOTION_BASE 是相对于 axios baseURL 的路径,不要包含 API_BASE_URL 前缀
// 否则 axios 会双重拼接导致 404(如 /api/app_order_box/api/app_order_box/kits/...)
const MOTION_BASE = '/kits/motion_reminder';
const api = axios.create({
baseURL: API_BASE_URL,
+64
View File
@@ -0,0 +1,64 @@
// 自动化部署脚本:构建后自动修正路径并复制到 public
const fs = require('fs');
const path = require('path');
const SOURCE_DIR = path.join(__dirname, 'dist');
const TARGET_DIR = path.join(__dirname, '..', '..', 'public', 'tools', 'web_order_box');
// 确保目标目录存在
if (!fs.existsSync(TARGET_DIR)) {
fs.mkdirSync(TARGET_DIR, { recursive: true });
}
// 复制所有文件
function copyDir(src, dest) {
fs.readdirSync(src).forEach(file => {
const srcPath = path.join(src, file);
const destPath = path.join(dest, file);
if (fs.statSync(srcPath).isDirectory()) {
if (!fs.existsSync(destPath)) fs.mkdirSync(destPath, { recursive: true });
copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
});
}
copyDir(SOURCE_DIR, TARGET_DIR);
// 复制字体文件到 public/assets/fonts
const FONTS_SOURCE = path.join(__dirname, 'node_modules', '@expo', 'vector-icons', 'build', 'vendor', 'react-native-vector-icons', 'Fonts');
const FONTS_TARGET = path.join(TARGET_DIR, 'assets', 'fonts');
if (fs.existsSync(FONTS_SOURCE)) {
if (!fs.existsSync(FONTS_TARGET)) fs.mkdirSync(FONTS_TARGET, { recursive: true });
fs.readdirSync(FONTS_SOURCE).forEach(file => {
if (file.endsWith('.ttf')) {
fs.copyFileSync(path.join(FONTS_SOURCE, file), path.join(FONTS_TARGET, file));
}
});
console.log('✅ 字体文件已复制到 assets/fonts/');
}
// 修正 index.html:添加 base 标签并修改路径为相对路径
const indexPath = path.join(TARGET_DIR, 'index.html');
let indexContent = fs.readFileSync(indexPath, 'utf-8');
// 在 head 中添加 base 标签
const baseTag = '<base href="/tools/web_order_box/" />';
if (!indexContent.includes('<base')) {
indexContent = indexContent.replace('</head>', ` ${baseTag}\n</head>`);
}
// 将绝对路径改为相对路径(使 base 标签生效)
indexContent = indexContent.replace(/src="\/_expo\//g, 'src="_expo/');
indexContent = indexContent.replace(/href="\/favicon\.ico"/g, 'href="favicon.ico"');
indexContent = indexContent.replace(/href="\/assets\//g, 'href="assets/');
// 修正字体路径
indexContent = indexContent.replace(/\/assets\/node_modules\/@expo\/vector-icons\/build\/vendor\/react-native-vector-icons\/Fonts\//g, 'assets/fonts/');
fs.writeFileSync(indexPath, indexContent, 'utf-8');
console.log('✅ 部署完成!');
console.log(`📁 目标目录:${TARGET_DIR}`);
console.log('🌐 访问地址:https://traesite.umersoft.com:8975/tools/web_order_box');
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>框子</title>
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
<style id="expo-reset">
/* These styles make the body full-height */
html,
body {
height: 100%;
}
/* These styles disable body scrolling if you are using <ScrollView> */
body {
overflow: hidden;
}
/* These styles make the root element full-height */
#root {
display: flex;
height: 100%;
flex: 1;
}
</style>
<link rel="icon" href="/favicon.ico" /></head>
<body>
<!-- Use static rendering with Expo Router to support running without JavaScript. -->
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<!-- The root element for your Expo app. -->
<div id="root"></div>
<script src="/_expo/static/js/web/AppEntry-7b6ff344f2982c1198d247406bc96731.js" defer></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{"version":0,"bundler":"metro","fileMetadata":{}}
@@ -1,8 +1,10 @@
import axios from 'axios';
import { ApiEnvelope, MotionCard, MotionSession } from './types';
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_order_box';
const MOTION_BASE = `${API_BASE_URL}/kits/motion_reminder`;
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || '/api/app_order_box';
// 注意:MOTION_BASE 是相对于 axios baseURL 的路径,不要包含 API_BASE_URL 前缀
// 否则 axios 会双重拼接导致 404(如 /api/app_order_box/api/app_order_box/kits/...)
const MOTION_BASE = '/kits/motion_reminder';
const api = axios.create({
baseURL: API_BASE_URL,
+195
View File
@@ -20,11 +20,13 @@
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "^19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.2",
"react-native-worklets": "^0.10.0",
"react-native-worklets-core": "^1.6.3"
},
@@ -4053,6 +4055,15 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4067,6 +4078,15 @@
"node": ">= 8"
}
},
"node_modules/css-in-js-utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
"license": "MIT",
"dependencies": {
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4598,6 +4618,36 @@
"bser": "2.1.1"
}
},
"node_modules/fbjs": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
"license": "MIT",
"dependencies": {
"cross-fetch": "^3.1.5",
"fbjs-css-vars": "^1.0.0",
"loose-envify": "^1.0.0",
"object-assign": "^4.1.0",
"promise": "^7.1.1",
"setimmediate": "^1.0.5",
"ua-parser-js": "^1.0.35"
}
},
"node_modules/fbjs-css-vars": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
"license": "MIT"
},
"node_modules/fbjs/node_modules/promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
"license": "MIT",
"dependencies": {
"asap": "~2.0.3"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -5043,6 +5093,12 @@
"node": ">= 6"
}
},
"node_modules/hyphenate-style-name": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5119,6 +5175,15 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/inline-style-prefixer": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
"integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
"license": "MIT",
"dependencies": {
"css-in-js-utils": "^3.1.0"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -6482,6 +6547,26 @@
"integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
@@ -6932,6 +7017,12 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -7133,6 +7224,18 @@
}
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"node_modules/react-freeze": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
@@ -7285,6 +7388,38 @@
"react-native": "*"
}
},
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1",
"fbjs": "^3.0.4",
"inline-style-prefixer": "^7.0.1",
"memoize-one": "^6.0.0",
"nullthrows": "^1.1.1",
"postcss-value-parser": "^4.2.0",
"styleq": "^0.1.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-native-web/node_modules/@react-native/normalize-colors": {
"version": "0.74.89",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz",
"integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==",
"license": "MIT"
},
"node_modules/react-native-web/node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/react-native-worklets": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
@@ -7694,6 +7829,12 @@
"node": ">= 0.8"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -7981,6 +8122,12 @@
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
"license": "MIT"
},
"node_modules/styleq": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
"integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -8220,6 +8367,12 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -8258,6 +8411,32 @@
"node": ">=14.17"
}
},
"node_modules/ua-parser-js": {
"version": "1.0.41",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"license": "MIT",
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/undici": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
@@ -8443,6 +8622,16 @@
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/whatwg-url-without-unicode": {
"version": "8.0.0-3",
"resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz",
@@ -8457,6 +8646,12 @@
"node": ">=10"
}
},
"node_modules/whatwg-url/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2
View File
@@ -21,11 +21,13 @@
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "^19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.2",
"react-native-worklets": "^0.10.0",
"react-native-worklets-core": "^1.6.3"
},
+1 -1
View File
@@ -49,7 +49,7 @@ const normalizePlaylistFile = (fileName?: string): string => {
};
const buildPlaylistPath = (fileName?: string): string => {
return `/tools/zen_box/${encodeURIComponent(normalizePlaylistFile(fileName))}`;
return `/tools/web_zen_box/${encodeURIComponent(normalizePlaylistFile(fileName))}`;
};
const getPlaylistSnapshotKey = (fileName?: string): string => {
@@ -0,0 +1,79 @@
// 重置 rateLimitMap(通过重启服务或等待)
// 这里直接测试单个请求
const http = require('http')
const BASE = 'http://localhost:8976'
const API = `${BASE}/api/v1/ingest/private_clipboard`
function post(path, body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body)
const req = http.request(`${API}${path}`, {
method: 'POST',
headers: {
'X-API-Id': 'private_clipboard',
'X-API-Key': 'pcb_nuS0qf-W63qcvAPsrG5ePA',
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(data)
}
}, (res) => {
let b = ''
res.on('data', d => b += d)
res.on('end', () => resolve({ status: res.statusCode, body: b }))
})
req.on('error', reject)
req.write(data)
req.end()
})
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
async function run() {
console.log('等待 32 秒让速率限制重置...')
await sleep(32000)
// 1. 英文
console.log('\n--- Test 1: 英文 ---')
const r1 = await post('/text', { text: 'hello world' })
console.log(`Status: ${r1.status}, Body: ${r1.body}`)
// 2. 中文
console.log('\n--- Test 2: 中文 ---')
const r2 = await post('/text', { text: '你好世界' })
console.log(`Status: ${r2.status}, Body: ${r2.body}`)
// 3. XSS
console.log('\n--- Test 3: XSS ---')
const r3 = await post('/text', { text: '<script>alert(1)</script>' })
console.log(`Status: ${r3.status}, Body: ${r3.body}`)
// 4. 特殊字符
console.log('\n--- Test 4: 特殊字符 & < > ---')
const r4 = await post('/text', { text: 'Tom & Jerry <best> "movie"' })
console.log(`Status: ${r4.status}, Body: ${r4.body}`)
// 5. 查询验证
console.log('\n--- Test 5: 查询最近 4 条 ---')
await new Promise((resolve, reject) => {
http.get(`${API}/latest?limit=4`, {
headers: {
'X-API-Id': 'private_clipboard',
'X-API-Key': 'pcb_nuS0qf-W63qcvAPsrG5ePA'
}
}, (res) => {
let b = ''
res.on('data', d => b += d)
res.on('end', () => {
try {
const data = JSON.parse(b)
data.items.forEach((item, i) => {
console.log(` [${i}] text_content: "${item.text_content}"`)
})
} catch { console.log(`Body: ${b}`) }
resolve()
})
}).on('error', reject)
})
}
run().catch(console.error)
@@ -0,0 +1,78 @@
// 快速测试:写入 + 查询
const http = require('http')
const API = 'http://localhost:8976/api/v1/ingest/private_clipboard'
function post(path, body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body)
const req = http.request(`${API}${path}`, {
method: 'POST',
headers: {
'X-API-Id': 'private_clipboard',
'X-API-Key': 'pcb_nuS0qf-W63qcvAPsrG5ePA',
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(data)
}
}, (res) => {
let b = ''
res.on('data', d => b += d)
res.on('end', () => resolve({ status: res.statusCode, body: b }))
})
req.on('error', reject)
req.write(data)
req.end()
})
}
async function run() {
let pass = 0, fail = 0
// 1. 中文
const r1 = await post('/text', { text: '你好世界' })
if (r1.status === 200) { console.log('✅ 中文写入成功'); pass++ }
else { console.log(`❌ 中文写入失败: ${r1.body}`); fail++ }
// 2. XSS
const r2 = await post('/text', { text: '<script>alert(1)</script>' })
if (r2.status === 200) { console.log('✅ XSS 写入成功'); pass++ }
else { console.log(`❌ XSS 写入失败: ${r2.body}`); fail++ }
// 3. 特殊字符
const r3 = await post('/text', { text: 'Tom & Jerry <best> "movie"' })
if (r3.status === 200) { console.log('✅ 特殊字符写入成功'); pass++ }
else { console.log(`❌ 特殊字符写入失败: ${r3.body}`); fail++ }
// 4. 查询验证
await new Promise((resolve) => {
http.get(`${API}/latest?limit=3`, {
headers: { 'X-API-Id': 'private_clipboard', 'X-API-Key': 'pcb_nuS0qf-W63qcvAPsrG5ePA' }
}, (res) => {
let b = ''
res.on('data', d => b += d)
res.on('end', () => {
const data = JSON.parse(b)
console.log('\n--- 查询结果 ---')
data.items.forEach((item, i) => {
console.log(`[${i}] "${item.text_content}"`)
})
// 验证转义
const xss = data.items.find(i => i.text_content.includes('&lt;'))
if (xss) { console.log('\n✅ XSS 已转义'); pass++ }
else { console.log('\n❌ XSS 未转义'); fail++ }
const cn = data.items.find(i => i.text_content === '你好世界')
if (cn) { console.log('✅ 中文存储正确'); pass++ }
else { console.log('❌ 中文存储错误'); fail++ }
const sp = data.items.find(i => i.text_content.includes('&amp;') && i.text_content.includes('&lt;'))
if (sp) { console.log('✅ 特殊字符转义正确'); pass++ }
else { console.log('❌ 特殊字符转义错误'); fail++ }
console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`)
resolve()
})
})
})
}
run()
+43
View File
@@ -0,0 +1,43 @@
// 测试速率限制
const http = require('http')
const API = 'http://localhost:8976/api/v1/ingest/private_clipboard'
function post(path, body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body)
const req = http.request(`${API}${path}`, {
method: 'POST',
headers: {
'X-API-Id': 'private_clipboard',
'X-API-Key': 'pcb_nuS0qf-W63qcvAPsrG5ePA',
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(data)
}
}, (res) => {
let b = ''
res.on('data', d => b += d)
res.on('end', () => resolve({ status: res.statusCode, body: b }))
})
req.on('error', reject)
req.write(data)
req.end()
})
}
async function run() {
console.log('开始发送 65 次请求...')
let limited = false
for (let i = 1; i <= 65; i++) {
const r = await post('/text', { text: `ratelimit-${i}` })
if (r.status === 429) {
console.log(`第 ${i} 次请求被限制 (429)`)
console.log(`响应: ${r.body}`)
limited = true
break
}
}
if (!limited) console.log('❌ 65 次请求全部通过,速率限制未生效')
else console.log('✅ 速率限制生效')
}
run()
@@ -0,0 +1,155 @@
/**
* 邮件 SMTP 连接测试脚本
* 测试 Gmail SMTP 465 端口连接和认证
*/
const nodemailer = require('nodemailer')
const path = require('path')
// 加载环境变量
const fs = require('fs')
const localCredsPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local')
if (fs.existsSync(localCredsPath)) {
fs.readFileSync(localCredsPath, 'utf-8')
.split('\n')
.filter(l => l.trim() && !l.startsWith('#'))
.forEach(line => {
const [key, ...valParts] = line.split('=')
const val = valParts.join('=').trim().replace(/^["']|["']$/g, '')
if (key && val) process.env[key.trim()] = val
})
}
// 也加载项目根目录的 .env(如果存在)
const projectEnvPath = path.join(__dirname, '..', '..', '..', '..', '.env')
if (fs.existsSync(projectEnvPath)) {
fs.readFileSync(projectEnvPath, 'utf-8')
.split('\n')
.filter(l => l.trim() && !l.startsWith('#'))
.forEach(line => {
const [key, ...valParts] = line.split('=')
const val = valParts.join('=').trim().replace(/^["']|["']$/g, '')
if (key && val) process.env[key.trim()] = val
})
}
const smtpHost = process.env.EMAIL_SENDER_SMTP_HOST || 'smtp.gmail.com'
const smtpUser = process.env.EMAIL_SENDER_SMTP_USER || ''
const smtpPass = process.env.EMAIL_SENDER_SMTP_PASS || ''
console.log('=== Email Sender SMTP 连接测试 ===\n')
console.log(`SMTP Host: ${smtpHost}`)
console.log(`SMTP User: ${smtpUser || '(未配置)'}`)
console.log(`SMTP Pass: ${smtpPass ? smtpPass.substring(0, 4) + '****' + smtpPass.substring(smtpPass.length - 2) : '(未配置)'}`)
console.log(`\n`)
if (!smtpUser || !smtpPass) {
console.error('❌ 错误: EMAIL_SENDER_SMTP_USER 或 EMAIL_SENDER_SMTP_PASS 未配置')
console.error('请在 Toolbox_local_creds.env.local 中配置这两个环境变量')
process.exit(1)
}
async function testPort(port, secure) {
const mode = secure ? 'SSL (465)' : 'STARTTLS (587)'
console.log(`\n--- 测试端口 ${port} (${mode}) ---\n`)
// DNS
console.log('[1/3] 测试 DNS 解析...')
try {
const dns = require('dns')
const addresses = await dns.promises.resolve(smtpHost)
console.log(`✅ ${smtpHost} -> ${addresses.join(', ')}\n`)
} catch (e) {
console.error(`❌ DNS 解析失败: ${e.message}\n`)
return false
}
// TCP
console.log(`[2/3] 测试 TCP 连接到 ${smtpHost}:${port}...`)
const net = require('net')
const tcpTimeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error('TCP 连接超时 (10s)')), 10000)
})
const tcpConnect = new Promise((resolve, reject) => {
const socket = net.createConnection({ host: smtpHost, port })
socket.on('connect', () => {
socket.end()
resolve()
})
socket.on('error', reject)
})
try {
await Promise.race([tcpConnect, tcpTimeout])
console.log(`✅ TCP 连接成功\n`)
} catch (e) {
console.error(`❌ TCP 连接失败: ${e.message}\n`)
return false
}
// SMTP
console.log('[3/3] 测试 SMTP 认证和发信...')
const transporter = nodemailer.createTransport({
host: smtpHost,
port,
secure,
auth: { user: smtpUser, pass: smtpPass },
connectionTimeout: 15000,
socketTimeout: 30000,
greetingTimeout: 15000
})
try {
console.log(' → 验证 SMTP 连接...')
await transporter.verify()
console.log(' ✅ SMTP 验证成功\n')
console.log(' → 尝试发送测试邮件...')
const info = await transporter.sendMail({
from: smtpUser,
to: smtpUser,
subject: 'TRAE-Toolbox SMTP 测试',
text: `这是一封自动测试邮件\n发送时间: ${new Date().toISOString()}\n如果收到此邮件,说明 SMTP 配置正常。`
})
console.log(` ✅ 邮件发送成功!`)
console.log(` Message ID: ${info.messageId}`)
console.log(` Response: ${info.response}`)
} catch (e) {
console.error(`❌ SMTP 操作失败: ${e.message}`)
return false
} finally {
transporter.close()
}
return true
}
async function test() {
// 先试 465 SSL
const ok465 = await testPort(465, true)
if (ok465) {
console.log('\n=== 465 SSL 测试通过 ✅ ===')
return
}
console.log('\n465 SSL 失败,尝试 587 STARTTLS...')
// 再试 587 STARTTLS
const ok587 = await testPort(587, false)
if (ok587) {
console.log('\n=== 587 STARTTLS 测试通过 ✅ ===')
console.log('\n建议: 将 EMAIL_SENDER_SMTP_PORT 改为 587,EMAIL_SENDER_SMTP_SECURE 设为 false')
return
}
console.log('\n=== 两个端口都失败了 ❌ ===')
console.log('\n可能原因:')
console.log('1. Gmail 限制了当前 IP 的 SMTP 访问')
console.log('2. 企业防火墙/安全组阻断了 Gmail SMTP')
console.log('3. 需要检查 Gmail 账号安全设置')
process.exit(1)
}
test().catch(e => {
console.error('未预期错误:', e)
process.exit(1)
})
@@ -0,0 +1,108 @@
// 私人剪贴板 API 验证测试
const http = require('http')
const BASE = 'http://localhost:8976'
const API = `${BASE}/api/v1/ingest/private_clipboard`
const HEADERS = {
'X-API-Id': 'private_clipboard',
'X-API-Key': 'pcb_nuS0qf-W63qcvAPsrG5ePA',
'Content-Type': 'application/json; charset=utf-8'
}
function post(path, body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body)
const req = http.request(`${API}${path}`, {
method: 'POST',
headers: { ...HEADERS, 'Content-Length': Buffer.byteLength(data) }
}, (res) => {
let body = ''
res.on('data', d => body += d)
res.on('end', () => {
try { resolve({ status: res.statusCode, body: JSON.parse(body) }) }
catch { resolve({ status: res.statusCode, body }) }
})
})
req.on('error', reject)
req.write(data)
req.end()
})
}
function get(path) {
return new Promise((resolve, reject) => {
http.get(`${API}${path}`, { headers: HEADERS }, (res) => {
let body = ''
res.on('data', d => body += d)
res.on('end', () => {
try { resolve({ status: res.statusCode, body: JSON.parse(body) }) }
catch { resolve({ status: res.statusCode, body }) }
})
}).on('error', reject)
})
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
async function runTests() {
let pass = 0, fail = 0
console.log('=== 1. 中文编码测试 ===')
const r1 = await post('/text', { text: '你好世界' })
if (r1.status === 200 && r1.body.ok) {
// 查询最新一条
const r1q = await get('/latest?limit=1')
const text = r1q.body.items[0].text_content
if (text === '你好世界') {
console.log(' ✅ 中文存储和查询正常')
pass++
} else {
console.log(` ❌ 查询返回: "${text}" (期望: "你好世界")`)
fail++
}
} else {
console.log(` ❌ 写入失败: ${JSON.stringify(r1)}`)
fail++
}
console.log('\n=== 2. XSS 转义测试 ===')
const xssPayload = '<script>alert(1)</script>'
const r2 = await post('/text', { text: xssPayload })
if (r2.status === 200 && r2.body.ok) {
const r2q = await get('/latest?limit=1')
const text = r2q.body.items[0].text_content
if (text.includes('&lt;') && text.includes('&gt;') && !text.includes('<script>')) {
console.log(` ✅ XSS 已转义: "${text}"`)
pass++
} else {
console.log(` ❌ XSS 未转义: "${text}"`)
fail++
}
} else {
console.log(` ❌ 写入失败: ${JSON.stringify(r2)}`)
fail++
}
console.log('\n=== 3. 速率限制测试 ===')
let rateLimited = false
for (let i = 0; i < 62; i++) {
const r = await post('/text', { text: `ratelimit-test-${i}` })
if (r.status === 429) {
console.log(` ✅ 第 ${i + 1} 次请求被限制 (429)`)
rateLimited = true
break
}
// 快速请求
}
if (!rateLimited) {
console.log(' ❌ 62 次请求全部通过,未触发速率限制')
fail++
} else {
pass++
}
console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`)
process.exit(fail > 0 ? 1 : 0)
}
runTests().catch(e => { console.error('Error:', e); process.exit(1) })
@@ -0,0 +1,7 @@
{
"_comment": "框子工具的前端鉴权配置",
"enable_auth_comment": "true:启用鉴权(需要从导航登录);false:完全放开访问",
"enable_auth": false,
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365)",
"max_age_days": 30
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+146
View File
@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#080b12">
<title>框子 - 运动提醒宝</title>
<link rel="icon" href="/tools/web_order_box/favicon.ico">
<link rel="stylesheet" href="/tools/web_order_box/order_box.css">
</head>
<body>
<!-- ===== Hub 首页 ===== -->
<div id="viewHub" class="view">
<div class="hub-header">
<h1 class="hub-title">框子</h1>
<p class="hub-subtitle">kit 聚合壳。当前阶段先落地第一个工具:运动提醒宝。</p>
</div>
<div class="hub-kit-list">
<button class="kit-card" id="btnEnterMotion" disabled>
<div class="kit-icon-wrap">
<svg class="kit-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 6.5h11M6.5 17.5h11M3 10v4M21 10v4M6.5 6.5v11M17.5 6.5v11"/></svg>
</div>
<div class="kit-info">
<span class="kit-name">运动提醒宝</span>
<span class="kit-desc">流程卡片、自动切步、语音播报、大字倒计时、下一个动作常驻显示。</span>
</div>
<svg class="kit-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
<!-- ===== 运动提醒宝 主容器 ===== -->
<div id="viewMotion" class="view" style="display:none">
<!-- 顶部栏 -->
<div class="motion-header">
<button class="btn-back" id="btnBackHub">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
<span>返回框子</span>
</button>
</div>
<!-- 页面内容区 -->
<div class="motion-body">
<!-- 卡片页 -->
<div id="pageCards" class="page">
<!-- 残留 session 警告 -->
<div id="staleWarning" class="stale-warning" style="display:none">
<div class="stale-title">检测到服务端仍保留一个运行中的 session</div>
<div class="stale-desc">如果这是上次异常退出遗留,可以先清理占用再重新启动。</div>
<button class="btn-stale-clean" id="btnCleanStale">清理占用</button>
</div>
<!-- 新建按钮 -->
<div class="cards-toolbar">
<button class="btn-primary" id="btnNewCard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span>新建卡片</span>
</button>
</div>
<!-- 卡片列表 -->
<div id="cardsList" class="cards-list"></div>
<!-- 空状态 -->
<div id="cardsEmpty" class="empty-state" style="display:none">
<div class="empty-title">还没有流程卡片</div>
<div class="empty-desc">先建一个训练流程,再从卡片直接启动执行界面。</div>
</div>
<!-- 加载 -->
<div id="cardsLoading" class="loading-wrap"><div class="spinner"></div></div>
</div>
<!-- 历史页 -->
<div id="pageHistory" class="page" style="display:none">
<div id="historyList" class="history-list"></div>
<div id="historyEmpty" class="empty-state" style="display:none">
<div class="empty-title">还没有执行记录</div>
<div class="empty-desc">从卡片页启动一次流程后,这里会出现历史记录。</div>
</div>
<div id="historyLoading" class="loading-wrap"><div class="spinner"></div></div>
</div>
</div>
<!-- 底部 Tab -->
<div class="tab-bar">
<button class="tab-item active" data-tab="cards">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
<span>卡片</span>
</button>
<button class="tab-item" data-tab="history">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/></svg>
<span>历史</span>
</button>
</div>
</div>
<!-- ===== 卡片编辑器 Modal ===== -->
<div id="editorModal" class="modal-overlay" style="display:none">
<div class="modal-sheet">
<div class="sheet-header">
<span class="sheet-title" id="editorTitle">新建流程卡片</span>
<button class="btn-icon" id="btnEditorClose">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="sheet-body" id="editorBody">
<!-- 动态渲染 -->
</div>
</div>
</div>
<!-- ===== 执行器 Modal ===== -->
<div id="runnerModal" class="modal-overlay modal-fullscreen" style="display:none">
<div class="runner-container" id="runnerContainer">
<!-- 动态渲染 -->
</div>
</div>
<!-- ===== 通用弹窗 ===== -->
<div id="dialogOverlay" class="dialog-overlay" style="display:none">
<div class="dialog-box" id="dialogBox">
<div class="dialog-title" id="dialogTitle"></div>
<div class="dialog-body" id="dialogBody"></div>
<div class="dialog-actions" id="dialogActions"></div>
</div>
</div>
<!-- ===== 交互详情弹窗 ===== -->
<div id="interactionOverlay" class="dialog-overlay" style="display:none">
<div class="dialog-box dialog-interaction" id="interactionBox">
<div class="dialog-header-row">
<span class="dialog-title" id="interactionTitle">交互详情</span>
<button class="btn-icon btn-copy" id="btnInteractionCopy" title="复制">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
<pre class="interaction-content" id="interactionContent"></pre>
<div class="dialog-actions" id="interactionActions"></div>
</div>
</div>
<!-- ===== Toast ===== -->
<div id="toast" class="toast"></div>
<script src="/tools/web_order_box/order_box.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{"version":1,"type":"native_web","description":"框子 - 运动提醒宝 Web 版(原生 HTML/CSS/JS)"}
+439
View File
@@ -0,0 +1,439 @@
/* ============================================================
框子 · 运动提醒宝 — order_box.css
深色主题 / 移动端优先 / 所有色值统一走 CSS 变量
============================================================ */
/* ---------- 主题变量 ---------- */
:root {
--bg: #080b12;
--surface: #121826;
--surface-soft: #1a2234;
--surface-strong: #202b42;
--primary: #f0b35f;
--primary-soft: rgba(240, 179, 95, 0.16);
--text: #f6f7fb;
--text-secondary: #9ba7bc;
--text-disabled: #5a6578;
--border: #2a3449;
--border-hover: #3b4866;
--success: #4ade80;
--warning: #fbbf24;
--error: #ef4444;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-xl: 24px;
}
/* ---------- Reset ---------- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body {
background-color: var(--bg);
background-image:
radial-gradient(1100px 560px at 88% -12%, rgba(240,179,95,0.07), transparent 60%),
radial-gradient(900px 520px at -12% 32%, rgba(72,104,180,0.10), transparent 55%);
background-attachment: fixed;
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
-webkit-user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent;
overflow-x: hidden;
}
input, textarea { -webkit-user-select: text; user-select: text; font-family: inherit; }
button { border: none; background: none; cursor: pointer; font: inherit; color: inherit; -webkit-tap-highlight-color: transparent; }
button:disabled { cursor: not-allowed; }
svg { display: block; flex-shrink: 0; }
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
/* ---------- 滚动条 ---------- */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
::-webkit-scrollbar-track { background: transparent; }
/* ---------- 动画 ---------- */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes scaleIn { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes pausedBlink { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
/* ============================================================
视图骨架
============================================================ */
.view { min-height: 100dvh; display: flex; flex-direction: column; }
.page { animation: fadeUp 0.25s ease both; }
/* ============================================================
Hub 首页
============================================================ */
#viewHub { justify-content: center; padding: 24px; }
.hub-header { margin-bottom: 28px; animation: fadeUp 0.35s ease both; }
.hub-title { font-size: 28px; font-weight: 700; letter-spacing: 1px; line-height: 1.2; }
.hub-subtitle { font-size: 16px; color: var(--text-secondary); line-height: 1.5; margin-top: 8px; }
.hub-kit-list { display: flex; flex-direction: column; gap: 12px; }
.kit-card {
display: flex; flex-direction: row; align-items: center; gap: 16px; width: 100%;
padding: 16px; background: var(--surface); border-radius: var(--radius-md);
border: 1px solid var(--border); text-align: left;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
animation: fadeUp 0.4s 0.08s ease both;
}
.kit-card:not(:disabled):hover { border-color: var(--border-hover); transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.35); }
.kit-card:not(:disabled):active { transform: translateY(0) scale(0.98); }
.kit-card:disabled { opacity: 0.5; }
.kit-icon-wrap { width: 48px; height: 48px; border-radius: 50%; background: var(--primary-soft); display: flex; align-items: center; justify-content: center; }
.kit-icon { width: 28px; height: 28px; color: var(--primary); }
.kit-info { display: flex; flex-direction: column; flex: 1; gap: 2px; }
.kit-name { font-size: 20px; font-weight: 600; }
.kit-desc { font-size: 14px; color: var(--text-secondary); line-height: 1.45; }
.kit-arrow { width: 24px; height: 24px; color: var(--text-secondary); transition: transform 0.2s; }
.kit-card:not(:disabled):hover .kit-arrow { transform: translateX(3px); color: var(--primary); }
/* ============================================================
运动提醒宝 · 顶部栏 / 主体 / Tab
============================================================ */
.motion-header { padding: 12px 16px; padding-top: calc(12px + env(safe-area-inset-top)); }
.btn-back { display: flex; flex-direction: row; align-items: center; gap: 4px; color: var(--text-secondary); font-size: 14px; transition: color 0.2s, transform 0.15s; }
.btn-back svg { width: 18px; height: 18px; }
.btn-back:hover { color: var(--text); }
.btn-back:active { transform: scale(0.96); }
.motion-body { flex: 1; overflow-y: auto; padding: 0 16px 80px; }
.tab-bar { position: fixed; bottom: 0; left: 0; right: 0; background: var(--surface); border-top: 1px solid var(--border); display: flex; flex-direction: row; padding-bottom: env(safe-area-inset-bottom); z-index: 50; }
.tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 0; color: var(--text-secondary); font-size: 12px; transition: color 0.2s; }
.tab-item svg { width: 22px; height: 22px; transition: transform 0.2s; }
.tab-item:hover { color: var(--text); }
.tab-item.active { color: var(--primary); }
.tab-item.active svg { transform: translateY(-1px); }
/* ============================================================
残留 session 警告
============================================================ */
.stale-warning { padding: 16px; border: 1px solid var(--warning); border-radius: var(--radius-md); margin-bottom: 16px; background: rgba(251,191,36,0.07); animation: fadeUp 0.3s ease both; }
.stale-title { font-size: 14px; font-weight: 600; color: var(--warning); }
.stale-desc { font-size: 13px; color: var(--text-secondary); margin-top: 4px; line-height: 1.5; }
.btn-stale-clean { margin-top: 12px; padding: 8px 16px; background: var(--warning); color: #000; border-radius: var(--radius-sm); font-weight: 600; font-size: 14px; transition: transform 0.15s, filter 0.2s; }
.btn-stale-clean:hover { filter: brightness(1.08); }
.btn-stale-clean:active { transform: scale(0.96); }
/* ============================================================
卡片页
============================================================ */
.cards-toolbar { padding: 8px 0; display: flex; justify-content: flex-end; }
.btn-primary {
display: flex; flex-direction: row; align-items: center; gap: 6px;
padding: 10px 20px; background: var(--primary); color: #081018;
border-radius: var(--radius-md); font-weight: 700; font-size: 14px;
box-shadow: 0 4px 16px rgba(240,179,95,0.25);
transition: transform 0.15s, filter 0.2s, box-shadow 0.2s;
}
.btn-primary svg { width: 18px; height: 18px; }
.btn-primary:hover:not(:disabled) { filter: brightness(1.06); box-shadow: 0 6px 20px rgba(240,179,95,0.35); }
.btn-primary:active:not(:disabled) { transform: scale(0.96); }
.btn-primary:disabled { opacity: 0.5; box-shadow: none; }
.cards-list { display: flex; flex-direction: column; }
.card-item {
background: var(--surface); border-radius: var(--radius-md); padding: 16px;
margin-bottom: 12px; border: 1px solid var(--border);
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
animation: fadeUp 0.3s ease both;
}
.card-item:hover { border-color: var(--border-hover); transform: translateY(-1px); box-shadow: 0 6px 20px rgba(0,0,0,0.3); }
.card-item-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; gap: 8px; }
.card-title { font-size: 20px; font-weight: 600; }
.card-meta { font-size: 14px; color: var(--text-secondary); margin-top: 4px; }
.card-sub { font-size: 13px; color: var(--text-disabled); margin-top: 2px; }
/* 步骤预览 — JS 用 .step-preview */
.step-preview { margin-top: 8px; }
.step-preview-line { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
.step-preview-more { font-size: 13px; color: var(--text-disabled); font-style: italic; line-height: 1.6; }
.card-actions { display: flex; flex-direction: row; gap: 8px; margin-top: 12px; }
/* 卡片按钮 — JS 用 .btn-card-primary / .btn-card-ghost */
.btn-card-primary {
display: flex; flex-direction: row; align-items: center; gap: 4px;
padding: 8px 16px; background: var(--primary); color: #081018;
border-radius: var(--radius-sm); font-weight: 700; font-size: 13px;
transition: transform 0.15s, filter 0.2s;
}
.btn-card-primary svg { width: 16px; height: 16px; }
.btn-card-primary:hover { filter: brightness(1.08); }
.btn-card-primary:active { transform: scale(0.95); }
.btn-card-ghost {
padding: 8px 16px; border: 1px solid var(--border); border-radius: var(--radius-sm);
color: var(--text-secondary); font-size: 13px;
transition: border-color 0.2s, color 0.2s, transform 0.15s;
}
.btn-card-ghost:hover { border-color: var(--border-hover); color: var(--text); }
.btn-card-ghost:active { transform: scale(0.95); }
.btn-text-danger { color: var(--error); }
/* 通用 btn-text span */
.btn-text { display: inline; }
/* 对话框里用的实心危险按钮 */
.btn-danger { padding: 8px 16px; background: var(--error); color: #fff; border-radius: var(--radius-sm); font-size: 14px; font-weight: 600; transition: transform 0.15s, filter 0.2s; }
.btn-danger:hover { filter: brightness(1.1); }
.btn-danger:active { transform: scale(0.95); }
/* ============================================================
空状态 / 加载
============================================================ */
.empty-state { text-align: center; padding: 48px 24px; animation: fadeIn 0.3s ease both; }
.empty-title { font-size: 18px; font-weight: 600; color: var(--text-secondary); }
.empty-desc { font-size: 14px; color: var(--text-disabled); margin-top: 8px; line-height: 1.5; }
.loading-wrap { display: flex; align-items: center; justify-content: center; padding: 48px; }
.spinner { width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
/* ============================================================
历史页 — JS 用 .history-line / .history-badge
============================================================ */
.history-list { display: flex; flex-direction: column; padding-top: 8px; }
.history-item {
background: var(--surface); border-radius: var(--radius-md); padding: 16px;
margin-bottom: 12px; border: 1px solid var(--border);
transition: border-color 0.2s; animation: fadeUp 0.3s ease both;
}
.history-item:hover { border-color: var(--border-hover); }
.history-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
.history-line { font-size: 14px; color: var(--text-secondary); line-height: 1.8; }
.history-badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.history-badge.badge-warning { background: rgba(251,191,36,0.15); color: var(--warning); }
.history-badge.badge-success { background: rgba(74,222,128,0.15); color: var(--success); }
.history-badge.badge-muted { background: var(--surface-strong); color: var(--text-disabled); }
/* ============================================================
Modal(底部抽屉 / 全屏)
============================================================ */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.58); z-index: 100; display: flex; flex-direction: column; justify-content: flex-end; animation: fadeIn 0.2s ease both; }
.modal-fullscreen { justify-content: stretch; align-items: stretch; }
.modal-sheet { width: 100%; background: var(--bg); border-radius: var(--radius-xl) var(--radius-xl) 0 0; max-height: 92dvh; display: flex; flex-direction: column; animation: slideUp 0.3s ease-out both; border-top: 1px solid var(--border); }
.sheet-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.sheet-title { font-size: 18px; font-weight: 600; }
.sheet-body { flex: 1; overflow-y: auto; padding: 20px; }
.btn-icon { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; color: var(--text); border-radius: var(--radius-sm); transition: color 0.2s, background 0.2s, transform 0.15s; }
.btn-icon svg { width: 24px; height: 24px; }
.btn-icon:hover { background: var(--surface-soft); }
.btn-icon:active { transform: scale(0.9); }
/* ============================================================
通用弹窗 / 交互详情弹窗
============================================================ */
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; display: flex; align-items: center; justify-content: center; animation: fadeIn 0.15s ease both; }
.dialog-box { background: var(--surface); border-radius: var(--radius-lg); padding: 24px; max-width: 340px; width: calc(100% - 48px); animation: scaleIn 0.2s ease both; border: 1px solid var(--border); box-shadow: 0 16px 48px rgba(0,0,0,0.5); }
.dialog-title { font-size: 18px; font-weight: 600; margin-bottom: 12px; }
.dialog-body { font-size: 14px; color: var(--text-secondary); line-height: 1.6; margin-bottom: 20px; white-space: pre-wrap; word-break: break-word; }
.dialog-actions { display: flex; flex-direction: row; gap: 8px; justify-content: flex-end; }
.dialog-header-row { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.dialog-header-row .dialog-title { margin-bottom: 0; }
.dialog-interaction { max-width: 420px; max-height: 80dvh; display: flex; flex-direction: column; }
.interaction-content { flex: 1; overflow-y: auto; background: var(--bg); border-radius: var(--radius-sm); border: 1px solid var(--border); padding: 12px; font-size: 12px; line-height: 1.5; color: var(--text-secondary); white-space: pre-wrap; word-break: break-all; margin-bottom: 16px; font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace; }
.btn-copy { color: var(--text-secondary); }
.btn-copy:hover { color: var(--primary); }
/* ============================================================
Toast
============================================================ */
.toast { position: fixed; top: 60px; left: 50%; transform: translateX(-50%) translateY(-8px); padding: 10px 24px; border-radius: 999px; font-size: 14px; color: #fff; z-index: 300; opacity: 0; transition: opacity 0.3s, transform 0.3s; pointer-events: none; box-shadow: 0 8px 24px rgba(0,0,0,0.4); max-width: calc(100vw - 48px); text-align: center; }
.toast-show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast-info { background: #3b82f6; }
.toast-success { background: #16a34a; }
.toast-error { background: #ef4444; }
/* ============================================================
编辑器 — 匹配 JS 实际 class 名
============================================================ */
.editor-field { margin-bottom: 16px; }
.editor-label { display: block; font-size: 14px; color: var(--text-secondary); margin-bottom: 6px; }
.editor-input {
width: 100%; padding: 12px 16px; background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius-sm);
color: var(--text); font-size: 16px; outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.editor-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(240,179,95,0.12); }
.editor-input::placeholder { color: var(--text-disabled); }
/* 循环方式分段按钮 — JS 用 .segment-row / .segment-btn */
.segment-row { display: flex; flex-direction: row; gap: 8px; }
.segment-btn {
flex: 1; padding: 10px; text-align: center; background: var(--surface-strong);
border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 14px;
transition: background 0.2s, color 0.2s, transform 0.15s;
}
.segment-btn:hover { color: var(--text); }
.segment-btn:active { transform: scale(0.96); }
.segment-btn.active { background: var(--primary); color: #081018; font-weight: 600; }
/* 编辑器步骤区头部 — JS 用 .editor-steps-header */
.editor-steps-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin: 20px 0 12px; }
.editor-section-title { font-size: 20px; font-weight: 600; margin: 0; }
.editor-steps-buttons { display: flex; flex-direction: row; gap: 8px; }
.btn-small { padding: 8px 14px; background: var(--surface-strong); border-radius: var(--radius-sm); color: var(--text); font-size: 13px; transition: filter 0.2s, transform 0.15s; }
.btn-small:hover { filter: brightness(1.2); }
.btn-small:active { transform: scale(0.95); }
/* 总时长预览 — JS 用 .editor-total-duration */
.editor-total-duration {
display: flex; flex-direction: row; align-items: center; gap: 8px;
padding: 12px 16px; background: var(--surface-soft); border-radius: var(--radius-sm);
margin-bottom: 16px; font-size: 14px; color: var(--text-secondary);
}
.editor-total-duration svg { width: 18px; height: 18px; color: var(--primary); }
/* 步骤列表容器 — JS 用 .editor-steps-list */
.editor-steps-list { display: flex; flex-direction: column; }
/* --- 步骤卡片 — JS 用 .step-editor --- */
.step-editor {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 14px; margin-bottom: 10px;
position: relative;
transition: border-color 0.2s, opacity 0.2s, transform 0.2s;
animation: fadeUp 0.25s ease both;
}
.step-editor.step-action { border-color: var(--primary-soft); border-left: 3px solid var(--primary); }
.step-editor.step-rest { border-color: rgba(74,222,128,0.16); border-left: 3px solid var(--success); }
/* 步骤头部 — JS 用 .step-editor-header */
.step-editor-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.step-title-row { display: flex; flex-direction: row; align-items: center; gap: 8px; }
.step-badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; font-weight: 600; }
.step-editor.step-action .step-badge { background: var(--primary); color: #081018; }
.step-editor.step-rest .step-badge { background: var(--success); color: #081018; }
.step-index { font-size: 14px; color: var(--text-secondary); }
/* 步骤操作按钮 — JS 用 .step-actions / .btn-step-icon */
.step-actions { display: flex; flex-direction: row; align-items: center; gap: 4px; }
.btn-step-icon {
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
color: var(--text-secondary); border-radius: var(--radius-sm);
transition: color 0.2s, background 0.2s, transform 0.15s;
}
.btn-step-icon svg { width: 18px; height: 18px; }
.btn-step-icon:hover:not(:disabled) { background: var(--surface-soft); }
.btn-step-icon:active:not(:disabled) { transform: scale(0.9); }
.btn-step-icon:disabled, .btn-step-icon.disabled { color: var(--text-disabled); pointer-events: none; }
.btn-step-remove:not(:disabled):not(.disabled) { color: var(--error); }
/* 拖拽手柄 — JS 用 .step-drag-handle */
.step-drag-handle {
display: flex; align-items: center; justify-content: center;
color: var(--text-disabled); cursor: grab; padding: 4px;
-webkit-user-select: none; user-select: none; transition: color 0.2s;
}
.step-drag-handle svg { width: 18px; height: 18px; }
.step-drag-handle:hover { color: var(--text-secondary); }
.step-drag-handle:active { cursor: grabbing; }
.step-editor.dragging { opacity: 0.4; }
.step-editor.drag-over { border-top: 2px solid var(--primary); }
/* 步骤内输入框 — JS 用 .editor-input.step-name-input / .step-duration-input */
.step-name-input { margin-bottom: 8px; font-size: 14px; padding: 10px 12px; background: var(--bg); }
.step-duration-input { width: 100px; font-size: 14px; padding: 10px 12px; background: var(--bg); margin-bottom: 4px; }
.step-duration-hint { font-size: 13px; color: var(--text-secondary); white-space: nowrap; margin-top: 4px; }
/* 编辑器底部统计 — JS 用 .editor-duration-info / .duration-info-row */
.editor-duration-info { margin-top: 16px; padding: 14px 16px; background: var(--surface-soft); border-radius: var(--radius-sm); }
.duration-info-row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; font-size: 14px; color: var(--text-secondary); line-height: 2; }
.duration-info-label { color: var(--text-secondary); }
.duration-info-value { color: var(--text); font-weight: 600; }
.duration-info-highlight { color: var(--primary); }
/* 编辑器保存栏 — JS 用 .editor-footer */
.editor-footer { padding: 16px 0; padding-bottom: calc(16px + env(safe-area-inset-bottom)); }
.editor-footer .btn-primary { width: 100%; justify-content: center; padding: 14px; font-size: 16px; }
/* ============================================================
执行器(全屏)— 匹配 JS 实际 class 名
============================================================ */
.runner-container {
width: 100%; height: 100dvh; background: var(--bg);
display: flex; flex-direction: column; padding: 20px;
padding-top: calc(20px + env(safe-area-inset-top));
overflow-y: auto; animation: fadeIn 0.25s ease both;
}
/* 执行器头部 — JS 用 .runner-header */
.runner-header { text-align: center; margin-bottom: 16px; }
.runner-badge {
display: inline-block; padding: 4px 16px; border-radius: 999px;
font-size: 13px; font-weight: 600; margin-bottom: 8px;
background: transparent; transition: color 0.3s, border-color 0.3s;
}
.runner-badge.badge-action { border: 1px solid var(--primary); color: var(--primary); background: var(--primary-soft); }
.runner-badge.badge-rest { border: 1px solid var(--success); color: var(--success); background: rgba(74,222,128,0.12); }
/* 卡片标题 — JS 用 .runner-title */
.runner-title { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
/* 轮次信息 — JS 用 .runner-loop-text */
.runner-loop-text { font-size: 14px; color: var(--text-secondary); margin-bottom: 16px; }
/* 步骤进度条 — JS 用 .runner-progress / .runner-progress-bar */
.runner-progress { height: 10px; background: var(--surface); border-radius: 5px; overflow: hidden; margin-bottom: 20px; }
.runner-progress-bar { height: 100%; background: var(--primary); border-radius: 5px; transition: width 0.3s; box-shadow: 0 0 12px rgba(240,179,95,0.4); }
/* 主体区 — JS 用 .runner-main */
.runner-main { flex: 1; display: flex; flex-direction: column; }
.runner-label { font-size: 16px; color: var(--text-secondary); margin-bottom: 4px; }
/* 当前动作名 — JS 用 .runner-current */
.runner-current { font-size: 34px; font-weight: 700; margin-bottom: 8px; line-height: 1.2; word-break: break-word; }
.runner-current.current-action { color: var(--primary); }
.runner-current.current-rest { color: var(--success); }
/* 倒计时 — JS 用 .runner-timer */
.runner-timer {
font-size: 90px; font-weight: 700; color: var(--primary);
text-align: center; margin: 16px 0;
font-variant-numeric: tabular-nums; line-height: 1; letter-spacing: 2px;
text-shadow: 0 0 36px rgba(240,179,95,0.35);
}
.runner-container.is-paused .runner-timer { animation: pausedBlink 1.2s ease-in-out infinite; }
/* 下一个动作卡片 */
.runner-next-card { padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-xl); background: var(--surface); margin-bottom: 16px; transition: border-color 0.3s; }
/* 下一个动作名 — JS 用 .runner-next */
.runner-next { font-size: 26px; font-weight: 700; word-break: break-word; }
/* 底部区 — JS 用 .runner-footer */
.runner-footer { margin-top: auto; }
/* 执行信息卡片 */
.runner-info-card { padding: 14px 16px; background: var(--surface-soft); border-radius: var(--radius-sm); margin-bottom: 16px; }
.runner-info-row { display: flex; flex-direction: row; justify-content: space-between; font-size: 14px; color: var(--text-secondary); line-height: 2; font-variant-numeric: tabular-nums; }
.runner-info-label { color: var(--text-secondary); }
.runner-info-value { color: var(--text); }
.runner-info-highlight { color: var(--primary); font-weight: 600; }
/* 控制按钮 — JS 用 .runner-buttons / .runner-btn */
.runner-buttons { display: flex; flex-direction: row; gap: 10px; margin-bottom: 16px; }
.runner-btn {
flex: 1; padding: 14px; background: var(--surface-strong);
border-radius: var(--radius-md); color: var(--text);
font-size: 15px; font-weight: 600;
transition: filter 0.2s, transform 0.15s;
}
.runner-btn:hover { filter: brightness(1.25); }
.runner-btn:active { transform: scale(0.96); }
.runner-btn.runner-btn-danger { background: var(--error); color: #fff; }
.runner-btn.runner-btn-danger:hover { filter: brightness(1.1); }
/* 全局进度 — JS 用 .runner-global */
.runner-global { margin-top: 16px; }
.runner-global-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 18px; }
.runner-global-label { color: var(--text); }
.runner-global-percent { font-size: 22px; font-weight: 700; color: var(--primary); font-variant-numeric: tabular-nums; }
.runner-global-bar-wrap { height: 24px; background: var(--surface-soft); border-radius: 12px; overflow: hidden; margin-bottom: 8px; }
.runner-global-bar { height: 100%; background: var(--primary); border-radius: 12px; transition: width 0.3s; box-shadow: 0 0 14px rgba(240,179,95,0.35); }
.runner-global-stats { font-size: 13px; color: var(--text-secondary); text-align: center; font-variant-numeric: tabular-nums; display: flex; flex-direction: row; justify-content: center; gap: 4px; flex-wrap: wrap; }
.runner-global-divider { color: var(--text-disabled); }
.runner-global-remaining { color: var(--text-secondary); }
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -5,15 +5,36 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="八音盒">
<meta name="apple-mobile-web-app-title" content="观空禅音">
<meta name="theme-color" content="#060612">
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="apple-touch-icon.svg">
<title>八音盒</title>
<title>观空禅音</title>
<link rel="stylesheet" href="zen_box.css">
</head>
<body>
<!-- Splash Screen -->
<div class="splash" id="splash">
<svg class="splash-icon" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="44" r="32" stroke="#e8b86d" stroke-width="1.2" opacity="0.25"/>
<ellipse cx="50" cy="84" rx="22" ry="4" fill="rgba(232,184,109,0.12)"/>
<path d="M33 66 C24 65 16 70 14 78 C12 84 16 88 24 87 C32 86 42 79 44 72 L44 67 Z" fill="#e8e6f0"/>
<path d="M67 66 C76 65 84 70 86 78 C88 84 84 88 76 87 C68 86 58 79 56 72 L56 67 Z" fill="#e8e6f0"/>
<path d="M44 67 C44 67 40 58 40 50 C40 40 44 34 48 30 L50 29 L52 30 C56 34 60 40 60 50 C60 58 56 67 56 67 C53 74 47 74 44 67 Z" fill="#e8e6f0"/>
<ellipse cx="50" cy="72" rx="10" ry="4" fill="#e8e6f0"/>
<rect x="46" y="25" width="8" height="7" rx="2" fill="#e8e6f0"/>
<ellipse cx="50" cy="17" rx="11" ry="12" fill="#e8e6f0"/>
<ellipse cx="50" cy="6" rx="5" ry="4" fill="#e8e6f0"/>
<path d="M34 36 Q28 33 28 29 Q28 25 34 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.7"/>
<path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.7"/>
<circle cx="50" cy="7" r="1.5" fill="#e8b86d" opacity="0.8"/>
</svg>
<div class="splash-title">观空禅音</div>
<div class="splash-sub">正在加载歌单与缓存...</div>
<div class="splash-spinner"></div>
</div>
<!-- Starfield Background -->
<div class="starfield">
<div class="stars-sm"></div>
@@ -40,7 +61,7 @@
<path d="M66 36 Q72 33 72 29 Q72 25 66 22" stroke="#e8b86d" stroke-width="1.2" stroke-linecap="round" fill="none" opacity="0.7"/>
<circle cx="50" cy="7" r="1.5" fill="#e8b86d" opacity="0.8"/>
</svg>
八<span>音</span>盒
观<span>空</span>禅音
</div>
<div class="header-count" id="trackCount">0 首</div>
</div>
@@ -161,9 +182,9 @@
<div class="track-info">
<div class="track-title marquee-host" id="trackTitleHost">
<div class="marquee-track" id="trackTitleTrack">
<span class="marquee-copy marquee-copy-primary" id="trackTitle">尚未选择曲目</span>
<span class="marquee-copy marquee-copy-primary" id="trackTitle">未选择歌曲</span>
<span class="marquee-gap" aria-hidden="true"></span>
<span class="marquee-copy marquee-copy-duplicate" id="trackTitleDuplicate" aria-hidden="true">尚未选择曲目</span>
<span class="marquee-copy marquee-copy-duplicate" id="trackTitleDuplicate" aria-hidden="true">未选择歌曲</span>
</div>
</div>
<div class="track-artist" id="trackArtist">—</div>
@@ -218,7 +239,7 @@
</button>
</div>
<!-- Secondary row: playlist toggle + volume -->
<!-- Secondary row: playlist + settings -->
<div class="secondary-controls">
<button class="sec-btn" onclick="openSheet()" title="播放列表">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
@@ -230,11 +251,6 @@
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</button>
<button class="sec-btn" id="btnVolToggle" onclick="toggleVolume()" title="音量">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" id="volIcon">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02z"></path>
</svg>
</button>
<button class="sec-btn" onclick="openSettings()" title="设置">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
@@ -259,23 +275,52 @@
</button>
</div>
<div class="settings-body">
<div class="setting-group">
<div class="setting-label">账号</div>
<div id="accountSection"></div>
</div>
<div class="setting-group">
<div class="setting-label">当前歌单</div>
<select id="playlistSelect" class="setting-select"></select>
<div class="setting-note">自动读取当前目录下所有以 playlist.json 结尾的歌单文件,并记住你上次选择。</div>
<div class="setting-note">自动读取所有以 playlist.json 结尾的歌单,并记住你上次选择。</div>
</div>
<div class="setting-group">
<div class="setting-label">缓存对照</div>
<div class="setting-summary" id="cacheSummary">正在检查缓存...</div>
<div class="setting-note">按歌曲地址对照当前歌单与浏览器缓存,标记哪些已缓存、哪些未缓存。</div>
</div>
<div class="setting-group">
<div class="setting-label">缓存模式</div>
<label class="setting-switch">
<input type="checkbox" id="cacheOnlyToggle">
<span>只用缓存听歌曲</span>
</label>
<div class="setting-note">开启后,切歌时只播放已下载的歌曲,未缓存的歌曲需先在播放列表下载。</div>
</div>
<div class="setting-group">
<div class="setting-label">缓存管理</div>
<div class="cache-size-row">
<span>已缓存大小</span>
<span class="cache-size-value" id="cacheSizeText">正在统计...</span>
</div>
<button class="clear-cache-btn" id="btnClearCache" onclick="clearAllCache()">清除所有缓存</button>
</div>
<div class="setting-group">
<div class="setting-label">播放记忆</div>
<label class="setting-switch">
<input type="checkbox" id="resumePlaybackToggle">
<span>记住上次位置继续听</span>
</label>
<div class="setting-note">默认开启。下次打开时,会从上次暂停的位置继续播放当前音频。</div>
<div class="setting-note">默认开启,下次打开时从上次暂停的位置继续播放</div>
</div>
<div class="about-card">
<div class="about-head">
<div class="about-name">观空禅音</div>
<div class="about-sub">ZenVoid</div>
</div>
<div class="about-desc">这不是一款为了喧闹和效率而设计的播放器。它更像一处留白,让人愿意慢下来,听见一点安静。</div>
<div class="about-desc">"ZenVoid"和"观空"都不是标准答案,只是我们尽力靠近这种感受的一种表达。希望它陪你在日常里,留一点空,听一点静。</div>
<div class="about-version">v1.0.0</div>
</div>
</div>
</div>
@@ -285,7 +330,10 @@
<div class="sheet" id="sheet">
<div class="sheet-handle"></div>
<div class="sheet-header">
<div class="sheet-title">播放列表</div>
<div class="sheet-title">
播放列表
<span class="offline-badge" id="offlineBadge" style="display:none">离线模式</span>
</div>
<button class="sheet-close" onclick="closeSheet()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
@@ -298,6 +346,48 @@
<span style="font-size:12px;opacity:0.6">请将音乐文件添加至播放列表</span>
</div>
</div>
<div class="sheet-footer">
<div class="sort-row">
<button class="sort-btn" id="sortOriginal" onclick="applySort('original')">原始顺序</button>
<button class="sort-btn" id="sortAsc" onclick="applySort('asc')">名称正序</button>
<button class="sort-btn" id="sortDesc" onclick="applySort('desc')">名称倒序</button>
</div>
<button class="download-all-btn" id="btnDownloadAll" onclick="downloadAllTracks()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span id="btnDownloadAllText">一键下载所有</span>
</button>
<div class="batch-progress" id="batchProgress" style="display:none">
<div class="batch-progress-title" id="batchProgressTitle">—</div>
<div class="batch-progress-bar">
<div class="batch-progress-fill" id="batchProgressFill"></div>
</div>
<div class="batch-progress-meta">
<span id="batchProgressPct">0%</span>
<span id="batchProgressCount">0/0</span>
</div>
</div>
</div>
</div>
<!-- Login Overlay -->
<div class="login-overlay" id="loginOverlay">
<div class="login-card">
<button class="sheet-close login-close" onclick="closeLoginOverlay()" title="关闭">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div class="login-title">登录</div>
<div class="login-sub">登录后可跨设备同步你的配置</div>
<input class="login-input" id="loginUsername" type="text" placeholder="账号" autocomplete="username">
<input class="login-input" id="loginPassword" type="password" placeholder="密码" autocomplete="current-password">
<div class="login-error" id="loginError"></div>
<button class="login-btn" id="btnLogin" onclick="doLogin()">登 录</button>
</div>
</div>
<!-- Toast -->
@@ -1,6 +1,6 @@
{
"name": "八音盒",
"short_name": "八音盒",
"name": "观空禅音",
"short_name": "观空禅音",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#060612",
@@ -0,0 +1,366 @@
[
{
"rank": 1,
"title": "Flowers",
"artist": "Miley Cyrus",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/01.%20Miley%20Cyrus%20-%20Flowers%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 2,
"title": "Anti-Hero",
"artist": "Taylor Swift",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/02.%20Taylor%20Swift%20-%20Anti-Hero%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 3,
"title": "As It Was",
"artist": "Harry Styles",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/03.%20Harry%20Styles%20-%20As%20It%20Was%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 4,
"title": "Unholy",
"artist": "Sam Smith & Kim Petras",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/unholy.mp3",
"cover": ""
},
{
"rank": 5,
"title": "Kill Bill",
"artist": "SZA",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/05.%20SZA%20-%20Kill%20Bill%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 6,
"title": "Cruel Summer",
"artist": "Taylor Swift",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/06.%20Taylor%20Swift%20-%20Cruel%20Summer%20(Official%20Audio).mp3",
"cover": ""
},
{
"rank": 7,
"title": "vampire",
"artist": "Olivia Rodrigo",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/07.%20Olivia%20Rodrigo%20-%20vampire%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 8,
"title": "Paint The Town Red",
"artist": "Doja Cat",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/08.%20Doja%20Cat%20-%20Paint%20The%20Town%20Red%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 9,
"title": "greedy",
"artist": "Tate McRae",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/09.%20Tate%20McRae%20-%20greedy%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 10,
"title": "Snooze",
"artist": "SZA",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/10.%20SZA%20-%20Snooze%20(Audio).mp3",
"cover": ""
},
{
"rank": 11,
"title": "Water",
"artist": "Tyla",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/11.%20Tyla%20-%20Water%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 12,
"title": "Lovin On Me",
"artist": "Jack Harlow",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/12.%20Jack%20Harlow%20-%20Lovin%20On%20Me%20%5BOfficial%20Music%20Video%5D.mp3",
"cover": ""
},
{
"rank": 13,
"title": "yes, and?",
"artist": "Ariana Grande",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/yes_and.mp3",
"cover": ""
},
{
"rank": 14,
"title": "Houdini",
"artist": "Dua Lipa",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/14.%20Dua%20Lipa%20-%20Houdini%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 15,
"title": "Espresso",
"artist": "Sabrina Carpenter",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/15.%20Sabrina%20Carpenter%20-%20Espresso.mp3",
"cover": ""
},
{
"rank": 16,
"title": "Please Please Please",
"artist": "Sabrina Carpenter",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/16.%20Sabrina%20Carpenter%20-%20Please%20Please%20Please.mp3",
"cover": ""
},
{
"rank": 17,
"title": "BIRDS OF A FEATHER",
"artist": "Billie Eilish",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/17.%20Billie%20Eilish%20-%20BIRDS%20OF%20A%20FEATHER%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 18,
"title": "Die With A Smile",
"artist": "Lady Gaga & Bruno Mars",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/die_with_smile.mp3",
"cover": ""
},
{
"rank": 19,
"title": "APT.",
"artist": "ROSÉ & Bruno Mars",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/19.%20ROSE%CC%81%20%26%20Bruno%20Mars%20-%20APT.%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 20,
"title": "Good Luck, Babe!",
"artist": "Chappell Roan",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/good_luck_babe.mp3",
"cover": ""
},
{
"rank": 21,
"title": "Fortnight",
"artist": "Taylor Swift ft. Post Malone",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/21.%20Taylor%20Swift%20-%20Fortnight%20(feat.%20Post%20Malone)%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 22,
"title": "I Had Some Help",
"artist": "Post Malone ft. Morgan Wallen",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/22.%20Post%20Malone%20-%20I%20Had%20Some%20Help%20(feat.%20Morgan%20Wallen)%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 23,
"title": "Lose Control",
"artist": "Teddy Swims",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/23.%20Teddy%20Swims%20-%20Lose%20Control%20(The%20Village%20Sessions).mp3",
"cover": ""
},
{
"rank": 24,
"title": "Beautiful Things",
"artist": "Benson Boone",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/24.%20Benson%20Boone%20-%20Beautiful%20Things%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 25,
"title": "Stick Season",
"artist": "Noah Kahan",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/25.%20Noah%20Kahan%20-%20Stick%20Season.mp3",
"cover": ""
},
{
"rank": 26,
"title": "Too Sweet",
"artist": "Hozier",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/26.%20Hozier%20-%20Too%20Sweet%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 27,
"title": "A Bar Song (Tipsy)",
"artist": "Shaboozey",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/27.%20Shaboozey%20-%20A%20Bar%20Song%20(Tipsy)%20%5BOfficial%20Visualizer%5D.mp3",
"cover": ""
},
{
"rank": 28,
"title": "Not Like Us",
"artist": "Kendrick Lamar",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/28.%20Kendrick%20Lamar%20-%20Not%20Like%20Us.mp3",
"cover": ""
},
{
"rank": 29,
"title": "TEXAS HOLD 'EM",
"artist": "Beyoncé",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/29.%20Beyonc%C3%A9%20-%20TEXAS%20HOLD%20'EM%20(Official%20Visualizer).mp3",
"cover": ""
},
{
"rank": 30,
"title": "we can't be friends",
"artist": "Ariana Grande",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/30.%20Ariana%20Grande%20-%20we%20can't%20be%20friends%20(wait%20for%20your%20love)%20(official%20music%20video).mp3",
"cover": ""
},
{
"rank": 31,
"title": "Illusion",
"artist": "Dua Lipa",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/31.%20Dua%20Lipa%20-%20Illusion%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 32,
"title": "Training Season",
"artist": "Dua Lipa",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/32.%20Dua%20Lipa%20-%20Training%20Season%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 33,
"title": "Gata Only",
"artist": "FloyyMenor & Cris MJ",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/33.%20The%20Tale%20Of%20The%20Princess%20Kaguya-The%20Procession%20of%20Celestial%20Beings.mp3",
"cover": ""
},
{
"rank": 34,
"title": "Taste",
"artist": "Sabrina Carpenter",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/36.%20Sabrina%20Carpenter%20-%20Taste%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 35,
"title": "Bed Chem",
"artist": "Sabrina Carpenter",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/35.%20Rauw%20Alejandro%20x%20Chris%20Brown%20x%20Rvssian%20-%20Nost%C3%A1lgico%20(Letra%E2%A7%B8Lyrics).mp3",
"cover": ""
},
{
"rank": 36,
"title": "Juno",
"artist": "Sabrina Carpenter",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/36.%20Sabrina%20Carpenter%20-%20Taste%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 37,
"title": "Sailor Song",
"artist": "Gigi Perez",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/37.%20Sabrina%20Carpenter%20-%20Bed%20Chem%20(Official%20Lyric%20Video).mp3",
"cover": ""
},
{
"rank": 38,
"title": "Timeless",
"artist": "The Weeknd & Playboi Carti",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/38.%20Sabrina%20Carpenter%20-%20Juno%20(Official%20Lyric%20Video).mp3",
"cover": ""
},
{
"rank": 39,
"title": "Starboy",
"artist": "The Weeknd ft. Daft Punk",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/39.%20Gigi%20Perez%20-%20Sailor%20Song%20(Official%20Music%20Video).mp3",
"cover": ""
},
{
"rank": 40,
"title": "Blinding Lights",
"artist": "The Weeknd",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/42.%20The%20Weeknd%20-%20Blinding%20Lights%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 41,
"title": "Save Your Tears",
"artist": "The Weeknd",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/41.%20The%20Weeknd%20-%20Starboy%20ft.%20Daft%20Punk%20(Official%20Video)%20ft.%20Daft%20Punk.mp3",
"cover": ""
},
{
"rank": 42,
"title": "Levitating",
"artist": "Dua Lipa",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/42.%20The%20Weeknd%20-%20Blinding%20Lights%20(Official%20Video).mp3",
"cover": ""
},
{
"rank": 43,
"title": "Don't Start Now",
"artist": "Dua Lipa",
"src": "",
"cover": ""
},
{
"rank": 44,
"title": "Physical",
"artist": "Dua Lipa",
"src": "",
"cover": ""
},
{
"rank": 45,
"title": "Used To Be Young",
"artist": "Miley Cyrus",
"src": "",
"cover": ""
},
{
"rank": 46,
"title": "Karma",
"artist": "Taylor Swift ft. Ice Spice",
"src": "",
"cover": ""
},
{
"rank": 47,
"title": "Calm Down",
"artist": "Rema & Selena Gomez",
"src": "",
"cover": ""
},
{
"rank": 48,
"title": "Vampire",
"artist": "Olivia Rodrigo",
"src": "",
"cover": ""
},
{
"rank": 49,
"title": "Seven",
"artist": "Jung Kook ft. Latto",
"src": "",
"cover": ""
},
{
"rank": 50,
"title": "What Was I Made For?",
"artist": "Billie Eilish",
"src": "",
"cover": ""
},
{
"rank": 33,
"title": "The Tale Of The Princess Kaguya-The Procession of Celestial Beings",
"artist": "",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/33.%20The%20Tale%20Of%20The%20Princess%20Kaguya-The%20Procession%20of%20Celestial%20Beings.mp3",
"cover": ""
},
{
"rank": 35,
"title": "Rauw Alejandro x Chris Brown x Rvssian - Nostálgico (Letra⧸Lyrics)",
"artist": "",
"src": "https://android-o1-images.oss-accelerate.aliyuncs.com/550e8400-e29b-41d4-a716-446655442082/external_storage/pop_music_2026/35.%20Rauw%20Alejandro%20x%20Chris%20Brown%20x%20Rvssian%20-%20Nost%C3%A1lgico%20(Letra%E2%A7%B8Lyrics).mp3",
"cover": ""
}
]
@@ -1,4 +1,5 @@
const CACHE = 'music-box-v3';
const CACHE = 'music-box-v4';
const AUDIO_CACHE = 'zen-audio-cache';
self.addEventListener('install', e => {
self.skipWaiting();
@@ -7,20 +8,32 @@ self.addEventListener('install', e => {
'./',
'./index.html',
'./zen_box.js',
'./zen_box.css',
'./playlist.json'
'./zen_box.css'
]))
);
});
self.addEventListener('activate', e => {
e.waitUntil(clients.claim());
e.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(k => k !== CACHE && k !== AUDIO_CACHE).map(k => caches.delete(k))
)
).then(() => clients.claim())
);
});
self.addEventListener('fetch', e => {
// 音频文件直接使用 Network Only
// 音频文件:优先命中 zen-audio-cache(离线播放必需),否则走网络
if (e.request.url.includes('.mp3') || e.request.url.includes('oss')) {
e.respondWith(fetch(e.request));
e.respondWith(
caches.open(AUDIO_CACHE).then(audioCache =>
audioCache.match(e.request).then(cached => {
if (cached) return cached;
return fetch(e.request);
})
).catch(() => fetch(e.request))
);
return;
}
@@ -519,6 +519,9 @@ html, body {
font-size: 16px;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.sheet-close {
border: none;
@@ -582,24 +585,47 @@ html, body {
text-overflow: ellipsis;
}
.item-cache {
.dl-btn {
flex-shrink: 0;
font-size: 11px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.08);
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(255,255,255,0.04);
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, transform 0.1s;
-webkit-tap-highlight-color: transparent;
}
.item-cache.cached {
.dl-btn:not(:disabled):active {
transform: scale(0.88);
color: var(--gold);
}
.dl-btn.cached {
color: var(--gold);
border-color: rgba(232,184,109,0.32);
background: rgba(232,184,109,0.08);
cursor: default;
}
.dl-btn.downloading {
color: var(--gold);
border-color: rgba(232,184,109,0.32);
cursor: default;
}
.dl-btn svg { display: block; }
.item-cache.pending {
background: rgba(255,255,255,0.04);
.dl-spinner {
width: 13px;
height: 13px;
border-radius: 50%;
border: 2px solid rgba(232,184,109,0.25);
border-top-color: var(--gold);
animation: zen-spin 0.8s linear infinite;
}
@keyframes zen-spin { to { transform: rotate(360deg); } }
.settings-overlay {
position: fixed;
@@ -621,6 +647,8 @@ html, body {
left: 50%;
top: calc(18px + var(--safe-top));
width: min(420px, calc(100vw - 24px));
max-height: calc(100dvh - 36px - var(--safe-top) - var(--safe-bottom));
overflow-y: auto;
transform: translateX(-50%) translateY(-12px);
opacity: 0;
pointer-events: none;
@@ -633,6 +661,7 @@ html, body {
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 25;
}
.settings-panel::-webkit-scrollbar { display: none; }
.settings-panel.open {
opacity: 1;
@@ -806,3 +835,431 @@ html, body {
font-size: 14px;
line-height: 1.7;
}
/* ========== Sheet Footer: Sort + Batch Download ========== */
.sheet-footer {
flex-shrink: 0;
padding: 10px 20px;
padding-bottom: calc(12px + var(--safe-bottom));
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 10px;
}
.sort-row {
display: flex;
gap: 8px;
}
.sort-btn {
flex: 1;
padding: 7px 0;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(255,255,255,0.04);
color: var(--text-dim);
font-size: 12px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.sort-btn.active {
color: var(--gold);
border-color: rgba(232,184,109,0.38);
background: rgba(232,184,109,0.10);
}
.sort-btn:not(.active):active {
background: rgba(255,255,255,0.08);
}
.download-all-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
width: 100%;
padding: 10px 0;
border-radius: 12px;
border: 1px solid rgba(232,184,109,0.28);
background: rgba(232,184,109,0.08);
color: var(--gold);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
-webkit-tap-highlight-color: transparent;
}
.download-all-btn:not(:disabled):active {
background: rgba(232,184,109,0.16);
}
.download-all-btn:disabled {
opacity: 0.6;
cursor: default;
}
.batch-progress {
display: flex;
flex-direction: column;
gap: 6px;
}
.batch-progress-title {
font-size: 12px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.batch-progress-bar {
width: 100%;
height: 4px;
border-radius: 2px;
background: rgba(255,255,255,0.08);
overflow: hidden;
}
.batch-progress-fill {
height: 100%;
width: 0;
border-radius: 2px;
background: var(--gold);
transition: width 0.3s ease;
}
.batch-progress-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
/* ========== Offline Badge ========== */
.offline-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
background: rgba(232,184,109,0.12);
border: 1px solid rgba(232,184,109,0.30);
color: var(--gold);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
white-space: nowrap;
}
/* ========== Splash Screen ========== */
.splash {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-deep);
transition: opacity 0.7s ease, visibility 0.7s;
}
.splash.hide {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.splash-icon {
width: 80px;
height: 80px;
margin-bottom: 16px;
}
.splash-title {
font-size: 24px;
font-weight: 600;
color: var(--text);
margin-bottom: 8px;
letter-spacing: 0.08em;
}
.splash-sub {
font-size: 14px;
color: var(--text-dim);
margin-bottom: 24px;
}
.splash-spinner {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid rgba(232,184,109,0.25);
border-top-color: var(--gold);
animation: zen-spin 1s linear infinite;
}
/* ========== Login Overlay ========== */
.login-overlay {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(0,0,0,0.6);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-overlay.open {
display: flex;
}
.login-card {
width: 100%;
max-width: 320px;
background: rgba(17,17,30,0.94);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid var(--border);
border-radius: 20px;
padding: 24px;
position: relative;
}
.login-close {
position: absolute;
top: 14px;
right: 14px;
}
.login-title {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
}
.login-sub {
font-size: 13px;
color: var(--text-dim);
margin-bottom: 20px;
}
.login-input {
width: 100%;
padding: 12px 14px;
margin-bottom: 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.06);
color: var(--text);
font-size: 15px;
outline: none;
transition: border-color 0.15s;
}
.login-input:focus {
border-color: rgba(232,184,109,0.38);
}
.login-input::placeholder {
color: var(--text-dim);
}
.login-error {
min-height: 20px;
font-size: 13px;
color: #ff4d4f;
margin-bottom: 12px;
}
.login-btn {
width: 100%;
padding: 12px;
border-radius: 12px;
border: none;
background: var(--gold);
color: var(--bg-deep);
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
-webkit-tap-highlight-color: transparent;
}
.login-btn:not(:disabled):active {
opacity: 0.85;
}
.login-btn:disabled {
opacity: 0.7;
cursor: default;
}
/* ========== Account Section ========== */
.account-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.account-name {
font-size: 14px;
color: var(--gold);
font-weight: 500;
}
.logout-btn {
padding: 6px 14px;
border-radius: 10px;
border: 1px solid rgba(255,77,79,0.35);
background: rgba(255,77,79,0.08);
color: #ff4d4f;
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.logout-btn:active {
background: rgba(255,77,79,0.16);
}
.login-entry-btn {
width: 100%;
padding: 10px;
border-radius: 12px;
border: 1px solid rgba(232,184,109,0.28);
background: rgba(232,184,109,0.08);
color: var(--gold);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.login-entry-btn:active {
background: rgba(232,184,109,0.16);
}
/* ========== Cache Management ========== */
.cache-size-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.06);
font-size: 13px;
color: var(--text);
}
.cache-size-value {
color: var(--gold);
font-variant-numeric: tabular-nums;
}
.clear-cache-btn {
width: 100%;
padding: 10px;
border-radius: 12px;
border: 1px solid rgba(255,77,79,0.30);
background: rgba(255,77,79,0.06);
color: #ff4d4f;
font-size: 13px;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
-webkit-tap-highlight-color: transparent;
}
.clear-cache-btn:not(:disabled):active {
background: rgba(255,77,79,0.14);
}
.clear-cache-btn:disabled {
opacity: 0.6;
cursor: default;
}
/* ========== About Card ========== */
.about-card {
padding: 16px;
border-radius: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
display: flex;
flex-direction: column;
gap: 8px;
}
.about-head {
display: flex;
align-items: baseline;
gap: 8px;
}
.about-name {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.about-sub {
font-size: 12px;
color: var(--gold-dim);
letter-spacing: 0.06em;
}
.about-desc {
font-size: 12px;
line-height: 1.7;
color: var(--text-dim);
}
.about-version {
font-size: 11px;
color: var(--text-dim);
opacity: 0.6;
margin-top: 2px;
}
/* ========== Playing Indicator: Three-Dot Animation ========== */
.eq-dots {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 14px;
}
.eq-dots i {
display: block;
width: 3px;
border-radius: 1.5px;
background: var(--gold);
animation: eq-bounce 1s ease-in-out infinite;
}
.eq-dots i:nth-child(1) { height: 6px; animation-delay: 0s; }
.eq-dots i:nth-child(2) { height: 10px; animation-delay: 0.2s; }
.eq-dots i:nth-child(3) { height: 7px; animation-delay: 0.4s; }
@keyframes eq-bounce {
0%, 100% { transform: scaleY(0.5); opacity: 0.6; }
50% { transform: scaleY(1); opacity: 1; }
}
File diff suppressed because it is too large Load Diff
-717
View File
@@ -1,717 +0,0 @@
const PLAYLIST_LIST_API = '/api/zen_box/playlists';
const PLAYLIST_STORAGE_KEY = 'zen_box_selected_playlist';
const RESUME_PLAYBACK_STORAGE_KEY = 'zen_box_resume_playback_enabled';
const PLAYBACK_POSITION_KEY_PREFIX = 'zen_box_playback_position';
let PLAYLIST = [];
let playlistFiles = [];
let currentPlaylistFile = 'playlist.json';
let cachedTrackUrls = new Set();
let currentIdx = 0;
let isPlaying = false;
let isSeeking = false;
let repeatMode = 0;
let shuffleOn = false;
let shuffleOrder = [];
let sheetOpen = false;
let settingsOpen = false;
let volumeVisible = false;
let resumePlaybackEnabled = true;
let lastSavedPositionSeconds = 0;
const audio = document.getElementById('audio');
async function init() {
resumePlaybackEnabled = getResumePlaybackEnabled();
bindSettingsEvents();
registerSW();
await loadPlaylistFiles();
await restorePlaylistSelection();
setupMediaSession();
window.addEventListener('resize', refreshMarqueeState);
}
async function loadPlaylistFiles() {
try {
const response = await fetch(PLAYLIST_LIST_API, { cache: 'no-store' });
const data = await response.json();
if (data && data.ok && Array.isArray(data.playlists) && data.playlists.length) {
playlistFiles = data.playlists.map(item => String(item || '')).filter(Boolean);
} else {
playlistFiles = ['playlist.json'];
}
} catch (error) {
console.warn('Failed to load playlist files:', error);
playlistFiles = ['playlist.json'];
}
if (!playlistFiles.includes('playlist.json')) playlistFiles.unshift('playlist.json');
}
async function restorePlaylistSelection() {
const saved = getSavedPlaylistFile();
const target = playlistFiles.includes(saved) ? saved : 'playlist.json';
await switchPlaylist(target, { silent: true });
}
function getSavedPlaylistFile() {
try {
return String(localStorage.getItem(PLAYLIST_STORAGE_KEY) || 'playlist.json') || 'playlist.json';
} catch {
return 'playlist.json';
}
}
function savePlaylistFile(fileName) {
try {
localStorage.setItem(PLAYLIST_STORAGE_KEY, fileName);
} catch {}
}
function getResumePlaybackEnabled() {
try {
const value = localStorage.getItem(RESUME_PLAYBACK_STORAGE_KEY);
if (value === null) return true;
return value === '1';
} catch {
return true;
}
}
function saveResumePlaybackEnabled(enabled) {
resumePlaybackEnabled = !!enabled;
try {
localStorage.setItem(RESUME_PLAYBACK_STORAGE_KEY, enabled ? '1' : '0');
} catch {}
}
function getPlaybackPositionKey(trackSrc) {
return `${PLAYBACK_POSITION_KEY_PREFIX}:${currentPlaylistFile}::${normalizeTrackSrc(trackSrc)}`;
}
function getSavedPlaybackPosition(trackSrc) {
try {
const value = localStorage.getItem(getPlaybackPositionKey(trackSrc));
const seconds = Number(value || 0);
return Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
} catch {
return 0;
}
}
function clearSavedPlaybackPosition(trackSrc) {
try {
localStorage.removeItem(getPlaybackPositionKey(trackSrc));
} catch {}
}
function saveCurrentPlaybackPosition({ force = false, explicitPosition = null, explicitDuration = null } = {}) {
if (!resumePlaybackEnabled) return;
const track = PLAYLIST[currentIdx];
if (!track || !track.src) return;
const currentTime = explicitPosition === null ? audio.currentTime : explicitPosition;
const duration = explicitDuration === null ? audio.duration : explicitDuration;
const safeSeconds = Math.max(0, Math.floor(Number(currentTime) || 0));
const safeDuration = Math.max(0, Math.floor(Number(duration) || 0));
const nearEnd = safeDuration > 0 && safeDuration - safeSeconds <= 30;
if (safeSeconds < 5 || nearEnd) {
clearSavedPlaybackPosition(track.src);
lastSavedPositionSeconds = 0;
return;
}
if (!force && Math.abs(safeSeconds - lastSavedPositionSeconds) < 15) {
return;
}
try {
localStorage.setItem(getPlaybackPositionKey(track.src), String(safeSeconds));
lastSavedPositionSeconds = safeSeconds;
} catch {}
}
function getPlaylistRequestPath(fileName) {
return encodeURIComponent(String(fileName || 'playlist.json'));
}
async function fetchPlaylistData(fileName) {
const response = await fetch(getPlaylistRequestPath(fileName), { cache: 'no-store' });
if (!response.ok) throw new Error(`playlist_http_${response.status}`);
const data = await response.json();
return Array.isArray(data) ? data : [];
}
async function switchPlaylist(fileName, { silent = false } = {}) {
const target = String(fileName || 'playlist.json');
const fallback = playlistFiles.includes(target) ? target : 'playlist.json';
const previousFile = currentPlaylistFile;
const previousSrc = PLAYLIST[currentIdx] ? normalizeTrackSrc(PLAYLIST[currentIdx].src) : '';
const wasPlaying = isPlaying;
const playlistSelect = document.getElementById('playlistSelect');
if (playlistSelect) playlistSelect.disabled = true;
saveCurrentPlaybackPosition({ force: true });
pauseAudio();
try {
const nextPlaylist = await fetchPlaylistData(fallback);
currentPlaylistFile = fallback;
savePlaylistFile(fallback);
PLAYLIST = nextPlaylist.map(track => ({ ...track, isCached: false }));
await refreshCacheState();
const matchedIdx = previousSrc ? PLAYLIST.findIndex(track => normalizeTrackSrc(track.src) === previousSrc) : -1;
currentIdx = matchedIdx >= 0 ? matchedIdx : 0;
updateTrackCount();
buildShuffleOrder();
renderSettings();
renderPlaylist();
if (PLAYLIST.length > 0) {
loadTrack(currentIdx, false);
if (wasPlaying && matchedIdx >= 0) playAudio();
} else {
resetTrackInfo();
}
closeSheet();
if (!silent) showToast(`已切换歌单:${fallback}`);
} catch (error) {
currentPlaylistFile = previousFile || 'playlist.json';
renderSettings();
console.warn('Failed to switch playlist:', error);
if (!silent) showToast('歌单切换失败');
} finally {
if (playlistSelect) playlistSelect.disabled = false;
}
}
async function refreshCacheState() {
cachedTrackUrls = await collectCachedTrackUrls();
PLAYLIST = PLAYLIST.map(track => ({
...track,
isCached: cachedTrackUrls.has(normalizeTrackSrc(track.src))
}));
updateCacheSummary();
}
async function collectCachedTrackUrls() {
const result = new Set();
if (!('caches' in window)) return result;
try {
const cacheNames = await caches.keys();
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
requests.forEach(request => {
const url = normalizeTrackSrc(request && request.url ? request.url : '');
if (isAudioCacheUrl(url)) result.add(url);
});
}
} catch (error) {
console.warn('Failed to inspect cache storage:', error);
}
return result;
}
function isAudioCacheUrl(url) {
return !!url && (url.includes('.mp3') || url.includes('oss'));
}
function normalizeTrackSrc(src) {
try {
const url = new URL(String(src || ''), window.location.href);
url.hash = '';
return url.href;
} catch {
return String(src || '').trim();
}
}
function updateTrackCount() {
const countText = `${PLAYLIST.length} 首`;
document.getElementById('trackCount').textContent = countText;
}
function setMarqueeText(host, text) {
if (!host) return;
const content = String(text || '');
const primary = host.querySelector('.marquee-copy-primary');
const duplicate = host.querySelector('.marquee-copy-duplicate');
if (primary) primary.textContent = content;
if (duplicate) duplicate.textContent = content;
}
function refreshMarqueeHost(host, enabled) {
if (!host) return;
const primary = host.querySelector('.marquee-copy-primary');
const duplicate = host.querySelector('.marquee-copy-duplicate');
if (!primary || !duplicate) return;
const overflow = primary.scrollWidth > host.clientWidth + 6;
if (!enabled || !overflow) {
host.classList.remove('marquee-active');
host.style.removeProperty('--marquee-distance');
host.style.removeProperty('--marquee-duration');
return;
}
const distance = Math.ceil(primary.scrollWidth + 42);
const duration = Math.max(10, Math.round(distance / 24));
host.style.setProperty('--marquee-distance', `${distance}px`);
host.style.setProperty('--marquee-duration', `${duration}s`);
host.classList.add('marquee-active');
}
function refreshCurrentTrackMarquee() {
const host = document.getElementById('trackTitleHost');
refreshMarqueeHost(host, !!PLAYLIST[currentIdx]);
}
function refreshPlaylistMarquee() {
document.querySelectorAll('.playlist-item .marquee-host').forEach(host => {
const item = host.closest('.playlist-item');
refreshMarqueeHost(host, !!(item && item.classList.contains('active')));
});
}
function refreshMarqueeState() {
refreshCurrentTrackMarquee();
refreshPlaylistMarquee();
}
function resetTrackInfo() {
audio.removeAttribute('src');
audio.load();
setMarqueeText(document.getElementById('trackTitleHost'), '尚未选择曲目');
document.getElementById('trackArtist').textContent = currentPlaylistFile || '—';
document.getElementById('timeCurrent').textContent = '0:00';
document.getElementById('timeTotal').textContent = '0:00';
document.getElementById('progressBar').value = 0;
updateSliderFill(document.getElementById('progressBar'), 0);
updatePlaylistHighlight();
refreshCurrentTrackMarquee();
}
function renderSettings() {
const select = document.getElementById('playlistSelect');
if (select) {
select.innerHTML = '';
playlistFiles.forEach(file => {
const option = document.createElement('option');
option.value = file;
option.textContent = file;
if (file === currentPlaylistFile) option.selected = true;
select.appendChild(option);
});
select.value = currentPlaylistFile;
}
const resumeToggle = document.getElementById('resumePlaybackToggle');
if (resumeToggle) {
resumeToggle.checked = !!resumePlaybackEnabled;
}
updateCacheSummary();
}
function updateCacheSummary() {
const summary = document.getElementById('cacheSummary');
if (!summary) return;
const cachedCount = PLAYLIST.filter(track => !!track.isCached).length;
summary.textContent = PLAYLIST.length
? `已缓存 ${cachedCount} / ${PLAYLIST.length} 首`
: '当前歌单暂无曲目';
}
function bindSettingsEvents() {
const panel = document.getElementById('settingsPanel');
if (panel) {
panel.addEventListener('click', event => event.stopPropagation());
}
const select = document.getElementById('playlistSelect');
if (select) {
select.addEventListener('change', async event => {
const nextFile = String(event.target && event.target.value || 'playlist.json');
await switchPlaylist(nextFile);
});
}
const resumeToggle = document.getElementById('resumePlaybackToggle');
if (resumeToggle) {
resumeToggle.addEventListener('change', event => {
const enabled = !!(event.target && event.target.checked);
saveResumePlaybackEnabled(enabled);
renderSettings();
showToast(enabled ? '已开启断点续播' : '已关闭断点续播');
});
}
}
function openSettings() {
settingsOpen = true;
renderSettings();
document.getElementById('settingsOverlay').classList.add('open');
document.getElementById('settingsPanel').classList.add('open');
}
function closeSettings() {
settingsOpen = false;
document.getElementById('settingsOverlay').classList.remove('open');
document.getElementById('settingsPanel').classList.remove('open');
}
function loadTrack(idx, autoplay) {
if (PLAYLIST[currentIdx] && audio.src) {
saveCurrentPlaybackPosition({ force: true });
}
currentIdx = idx;
const track = PLAYLIST[idx];
if (!track) return;
const savedPosition = resumePlaybackEnabled ? getSavedPlaybackPosition(track.src) : 0;
audio.src = track.src;
audio.dataset.resumePosition = savedPosition > 5 ? String(savedPosition) : '0';
audio.load();
setMarqueeText(document.getElementById('trackTitleHost'), track.title || '未知曲目');
document.getElementById('trackArtist').textContent = track.artist || '未知歌手';
document.getElementById('timeCurrent').textContent = '0:00';
document.getElementById('timeTotal').textContent = '0:00';
document.getElementById('progressBar').value = 0;
updateSliderFill(document.getElementById('progressBar'), 0);
updatePlaylistHighlight();
updateMediaSessionMetadata();
refreshCurrentTrackMarquee();
lastSavedPositionSeconds = savedPosition > 5 ? Math.floor(savedPosition) : 0;
if (autoplay) playAudio();
}
function togglePlay() {
if (!PLAYLIST.length) {
showToast('播放列表为空');
return;
}
if (!audio.src || audio.src === location.href) {
loadTrack(0, true);
return;
}
isPlaying ? pauseAudio() : playAudio();
}
function playAudio() {
audio.play().then(() => {
isPlaying = true;
updatePlayUI(true);
}).catch(error => {
console.warn('Play failed', error);
showToast('播放失败,请检查音频地址');
});
}
function pauseAudio() {
audio.pause();
isPlaying = false;
updatePlayUI(false);
}
function updatePlayUI(playing) {
document.getElementById('iconPlay').style.display = playing ? 'none' : 'block';
document.getElementById('iconPause').style.display = playing ? 'block' : 'none';
const glow = document.getElementById('zenGlow');
glow.classList.toggle('active', playing);
for (let i = 0; i < 3; i += 1) {
document.getElementById(`zenWave${i}`).classList.toggle('active', playing);
}
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
}
updatePlaylistHighlight();
}
function nextTrack() {
if (!PLAYLIST.length) return;
if (repeatMode === 1) {
audio.currentTime = 0;
if (isPlaying) playAudio();
return;
}
let next;
if (shuffleOn) {
const pos = shuffleOrder.indexOf(currentIdx);
next = shuffleOrder[(pos + 1) % shuffleOrder.length];
} else {
next = (currentIdx + 1) % PLAYLIST.length;
}
loadTrack(next, isPlaying);
}
function prevTrack() {
if (!PLAYLIST.length) return;
if (audio.currentTime > 3) {
audio.currentTime = 0;
return;
}
let prev;
if (shuffleOn) {
const pos = shuffleOrder.indexOf(currentIdx);
prev = shuffleOrder[(pos - 1 + shuffleOrder.length) % shuffleOrder.length];
} else {
prev = (currentIdx - 1 + PLAYLIST.length) % PLAYLIST.length;
}
loadTrack(prev, isPlaying);
}
function buildShuffleOrder() {
shuffleOrder = [...Array(PLAYLIST.length).keys()];
for (let i = shuffleOrder.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]];
}
}
function toggleShuffle() {
shuffleOn = !shuffleOn;
if (shuffleOn) buildShuffleOrder();
document.getElementById('btnShuffle').classList.toggle('active-mode', shuffleOn);
showToast(shuffleOn ? '随机播放 开' : '随机播放 关');
}
const repeatLabels = ['顺序播放', '单曲循环', '列表循环'];
function cycleRepeat() {
repeatMode = (repeatMode + 1) % 3;
const btn = document.getElementById('btnRepeat');
btn.classList.toggle('active-mode', repeatMode > 0);
btn.innerHTML = repeatMode === 1
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/><text x="11" y="14" font-size="6" fill="currentColor" stroke="none" text-anchor="middle">1</text></svg>'
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>';
showToast(repeatLabels[repeatMode]);
}
function toggleVolume() {
volumeVisible = !volumeVisible;
showToast(volumeVisible ? '请用 iPhone 侧面音量键调节' : '');
}
function onProgressInput(val) {
isSeeking = true;
updateSliderFill(document.getElementById('progressBar'), val);
}
function onProgressChange(val) {
if (audio.duration) audio.currentTime = (val / 100) * audio.duration;
isSeeking = false;
saveCurrentPlaybackPosition({ force: true });
}
function updateSliderFill(el, pct) {
el.style.background = `linear-gradient(to right, #e8b86d 0%, #e8b86d ${pct}%, rgba(255,255,255,0.10) ${pct}%, rgba(255,255,255,0.10) 100%)`;
}
function fmtTime(seconds) {
if (isNaN(seconds) || seconds < 0) return '0:00';
const minutes = Math.floor(seconds / 60);
const remain = Math.floor(seconds % 60);
return `${minutes}:${String(remain).padStart(2, '0')}`;
}
audio.addEventListener('timeupdate', () => {
if (isSeeking) return;
const pct = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
document.getElementById('progressBar').value = pct;
updateSliderFill(document.getElementById('progressBar'), pct);
document.getElementById('timeCurrent').textContent = fmtTime(audio.currentTime);
if ('mediaSession' in navigator && audio.duration) {
try {
navigator.mediaSession.setPositionState({
duration: audio.duration,
playbackRate: audio.playbackRate,
position: audio.currentTime
});
} catch {}
}
if (isPlaying) {
saveCurrentPlaybackPosition();
}
});
audio.addEventListener('loadedmetadata', () => {
const resumeSeconds = Number(audio.dataset.resumePosition || '0');
if (resumePlaybackEnabled && resumeSeconds > 5 && audio.duration && resumeSeconds < Math.max(audio.duration - 30, 5)) {
audio.currentTime = resumeSeconds;
lastSavedPositionSeconds = Math.floor(resumeSeconds);
} else {
lastSavedPositionSeconds = 0;
}
audio.dataset.resumePosition = '0';
document.getElementById('timeTotal').textContent = fmtTime(audio.duration);
});
audio.addEventListener('ended', () => {
const track = PLAYLIST[currentIdx];
if (track && track.src) {
clearSavedPlaybackPosition(track.src);
}
lastSavedPositionSeconds = 0;
isPlaying = false;
updatePlayUI(false);
if (repeatMode === 1) {
audio.currentTime = 0;
playAudio();
} else {
const isLast = !shuffleOn && currentIdx === PLAYLIST.length - 1;
if (isLast && repeatMode === 0) return;
nextTrack();
playAudio();
}
});
audio.addEventListener('play', () => {
isPlaying = true;
updatePlayUI(true);
});
audio.addEventListener('pause', () => {
isPlaying = false;
updatePlayUI(false);
saveCurrentPlaybackPosition({ force: true });
});
audio.addEventListener('error', () => {
isPlaying = false;
updatePlayUI(false);
const err = audio.error;
let msg = '音频加载失败';
if (err) {
switch (err.code) {
case MediaError.MEDIA_ERR_ABORTED: msg = '播放被中止'; break;
case MediaError.MEDIA_ERR_NETWORK: msg = '网络错误,无法加载音频'; break;
case MediaError.MEDIA_ERR_DECODE: msg = '音频解码失败'; break;
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: msg = '音频地址无效或格式不支持'; break;
default: break;
}
}
console.warn('Audio error:', err, audio.src);
showToast(msg);
});
function renderPlaylist() {
const wrap = document.getElementById('sheetList');
const empty = document.getElementById('emptyState');
if (!PLAYLIST.length) {
empty.style.display = 'block';
wrap.querySelectorAll('.playlist-item').forEach(el => el.remove());
return;
}
empty.style.display = 'none';
wrap.querySelectorAll('.playlist-item').forEach(el => el.remove());
PLAYLIST.forEach((track, index) => {
const div = document.createElement('div');
div.className = `playlist-item${index === currentIdx ? ' active' : ''}`;
div.dataset.idx = index;
div.innerHTML = `
<div class="item-num" id="itemNum${index}">${index + 1}</div>
<div class="item-info">
<div class="item-title marquee-host">
<div class="marquee-track">
<span class="marquee-copy marquee-copy-primary">${escHtml(track.title || '未知曲目')}</span>
<span class="marquee-gap" aria-hidden="true"></span>
<span class="marquee-copy marquee-copy-duplicate" aria-hidden="true">${escHtml(track.title || '未知曲目')}</span>
</div>
</div>
<div class="item-sub">${escHtml(track.artist || '未知歌手')}</div>
</div>
<div class="item-cache ${track.isCached ? 'cached' : 'pending'}">${track.isCached ? '已缓存' : '未缓存'}</div>
`;
div.addEventListener('click', () => {
if (index === currentIdx) {
togglePlay();
} else {
loadTrack(index, true);
closeSheet();
}
});
wrap.appendChild(div);
});
refreshPlaylistMarquee();
}
function updatePlaylistHighlight() {
document.querySelectorAll('.playlist-item').forEach((el, index) => {
el.classList.toggle('active', index === currentIdx);
const num = document.getElementById(`itemNum${index}`);
if (num) {
num.textContent = (index === currentIdx && isPlaying) ? '▶' : (index + 1);
num.className = `item-num${index === currentIdx ? ' playing-icon' : ''}`;
}
});
refreshPlaylistMarquee();
}
function openSheet() {
sheetOpen = true;
document.getElementById('sheetOverlay').classList.add('open');
const sheet = document.getElementById('sheet');
sheet.classList.add('open');
sheet.onclick = event => event.stopPropagation();
updatePlaylistHighlight();
}
function closeSheet() {
sheetOpen = false;
document.getElementById('sheetOverlay').classList.remove('open');
document.getElementById('sheet').classList.remove('open');
}
function setupMediaSession() {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => playAudio());
navigator.mediaSession.setActionHandler('pause', () => pauseAudio());
navigator.mediaSession.setActionHandler('nexttrack', () => nextTrack());
navigator.mediaSession.setActionHandler('previoustrack', () => prevTrack());
navigator.mediaSession.setActionHandler('seekto', event => {
if (event.seekTime !== undefined && audio.duration) {
audio.currentTime = event.seekTime;
}
});
}
function updateMediaSessionMetadata() {
if (!('mediaSession' in navigator)) return;
const track = PLAYLIST[currentIdx];
if (!track) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title || '未知曲目',
artist: track.artist || '未知歌手',
album: '八音盒',
artwork: track.cover ? [{ src: track.cover, sizes: '512x512', type: 'image/jpeg' }] : []
});
}
function registerSW() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(() => {});
}
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
saveCurrentPlaybackPosition({ force: true });
}
});
window.addEventListener('beforeunload', () => {
saveCurrentPlaybackPosition({ force: true });
});
function escHtml(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
let toastTimer;
function showToast(msg) {
const el = document.getElementById('toast');
if (!msg) {
el.classList.remove('show');
el.textContent = '';
return;
}
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 1800);
}
init();
setTimeout(() => {
updateSliderFill(document.getElementById('progressBar'), 0);
}, 50);
+11
View File
@@ -10,6 +10,7 @@ const { insert, upsertLatest, queryLatest, queryList, queryByTimeRange, count }
const { loadSkillConfig } = require('./auth')
const { bindRoutes: bindExternalStorageRoutes } = require('./skills/external_storage')
const { bindRoutes: bindEmailSenderRoutes } = require('./skills/email_sender')
const { bindRoutes: bindPrivateClipboardRoutes } = require('./skills/private_clipboard')
const setNoCache = (res) => {
try {
@@ -94,6 +95,16 @@ const bindRoutes = (app) => {
console.error(`[data_gateway] Failed to bind email_sender routes: ${e.message}`)
}
// ============================================================
// 私人剪贴板 skill(对外写入剪贴板)
// 路由挂载在 /api/v1/ingest/private_clipboard/*
// ============================================================
try {
bindPrivateClipboardRoutes(app)
} catch (e) {
console.error(`[data_gateway] Failed to bind private_clipboard routes: ${e.message}`)
}
// ============================================================
// 外部写入接口(公网暴露)
// POST /api/v1/ingest/:skillId
@@ -0,0 +1,214 @@
// ============================================================
// clipboard_bridge.js - 桥接模块,复用私人剪贴板的数据库和文件存储逻辑
// 供 data_gateway 的 private_clipboard skill 调用
// ============================================================
const Database = require('better-sqlite3')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
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')
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true })
let db = null
const ensureSchema = () => {
if (!db) return
db.exec(`
CREATE TABLE IF NOT EXISTS clipboard_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
text_content TEXT,
file_name TEXT,
file_mime TEXT,
file_size INTEGER,
file_path TEXT,
group_id INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_clipboard_items_created_at ON clipboard_items(created_at);
`)
const cols = db.prepare('PRAGMA table_info(clipboard_items)').all()
if (!cols.some(c => c.name === 'group_id')) db.exec('ALTER TABLE clipboard_items ADD COLUMN group_id INTEGER')
}
const getDb = () => {
if (db) {
ensureSchema()
return db
}
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
db.pragma('busy_timeout = 4000')
ensureSchema()
return db
}
const normalizeName = (name) => {
let s = String(name || '').replace(/\0/g, '')
// 移除路径穿越模式
s = s.replace(/\.\./g, '')
const base = path.basename(s)
return base || 'file'
}
const extFromMime = (mime) => {
const m = String(mime || '').toLowerCase()
if (m === 'image/png') return '.png'
if (m === 'image/jpeg' || m === 'image/jpg') return '.jpg'
if (m === 'image/webp') return '.webp'
if (m === 'image/gif') return '.gif'
if (m === 'image/bmp') return '.bmp'
if (m === 'image/svg+xml') return '.svg'
return ''
}
const saveFile = (rawBuffer, name, mime) => {
const safe = normalizeName(name)
let ext = path.extname(safe)
if (!ext) ext = extFromMime(mime)
const stamp = Date.now()
const rand = crypto.randomBytes(6).toString('hex')
const fileName = `${stamp}-${rand}${ext}`
const rel = path.join('uploads', 'private_clipboard', fileName)
const abs = path.join(process.cwd(), rel)
fs.writeFileSync(abs, rawBuffer)
const size = rawBuffer.length
return { rel, fileName: safe, size }
}
const formatItem = (row) => {
const filePath = row.file_path ? String(row.file_path) : ''
const url = filePath ? '/' + filePath.replace(/^\/+/, '') : ''
return {
id: row.id,
type: row.type,
text_content: row.text_content || '',
file_name: row.file_name || '',
file_mime: row.file_mime || '',
file_size: row.file_size || 0,
file_url: url,
created_at: row.created_at
}
}
// ============================================================
// 公开 API
// ============================================================
/**
* 插入一条文本记录
*/
const insertText = (text, groupId) => {
const database = getDb()
const now = Date.now()
const info = database.prepare(
'INSERT INTO clipboard_items (type, text_content, group_id, created_at) VALUES (?, ?, ?, ?)'
).run('text', String(text || ''), groupId !== null && groupId !== undefined ? groupId : null, now)
return { id: info.lastInsertRowid, type: 'text', created_at: now }
}
/**
* 插入多个文件(支持一次上传多个)
* 返回 { group_id, items: [...] }
*/
const insertFiles = (files, groupId) => {
const database = getDb()
const now = Date.now()
// 如果没有提供 groupId,自动生成一个
const actualGroupId = (groupId !== null && groupId !== undefined) ? groupId : -now
const tx = database.transaction(() => {
const items = []
for (const f of files) {
const { rel, fileName, size } = saveFile(f.buffer, f.name, f.mime)
const type = String(f.mime || '').toLowerCase().startsWith('image/') ? 'image' : 'file'
const info = database.prepare(
'INSERT INTO clipboard_items (type, file_name, file_mime, file_size, file_path, group_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(
type, fileName, f.mime || '', size, rel, actualGroupId, now
)
items.push({
id: info.lastInsertRowid,
type,
file_name: fileName,
file_mime: f.mime || '',
file_size: size,
file_url: '/' + rel.replace(/^\/+/, ''),
created_at: now
})
}
return items
})
const items = tx()
return { group_id: actualGroupId, items }
}
/**
* 查询最近 N 条记录(按 group 分组)
*/
const queryLatest = (limit = 50) => {
const database = getDb()
const safeLimit = Math.min(Math.max(1, Number(limit) || 50), 500)
const rows = database.prepare(`
SELECT * FROM clipboard_items
ORDER BY created_at DESC
LIMIT ?
`).all(safeLimit)
// 按 group_id 分组
const groups = new Map()
for (const row of rows) {
const gid = row.group_id
const key = gid !== null && gid !== undefined ? `g:${gid}` : `r:${row.id}`
if (!groups.has(key)) {
groups.set(key, {
id: gid || row.id,
type: row.type,
text_content: row.text_content || '',
created_at: row.created_at,
items: [],
group_types: []
})
}
const group = groups.get(key)
group.group_types.push(row.type)
if (row.type === 'text') {
if (!group.text_content) group.text_content = row.text_content || ''
} else {
group.items.push(formatItem(row))
}
}
const result = Array.from(groups.values()).map(group => {
const unique = Array.from(new Set(group.group_types.filter(Boolean)))
let type = group.type
if (group.items.length) {
type = unique.length === 1 ? unique[0] : 'mixed'
} else {
type = 'text'
}
return {
id: group.id,
type,
text_content: group.text_content,
created_at: group.created_at,
items: group.items,
group_types: unique
}
})
// 按 created_at 降序
result.sort((a, b) => b.created_at - a.created_at)
return { items: result, total: result.length }
}
module.exports = { getDb, insertText, insertFiles, queryLatest }
@@ -0,0 +1,20 @@
{
"_comment": "私人剪贴板 skill - 供第三方程序向剪贴板写入数据",
"id": "private_clipboard",
"name": "私人剪贴板",
"enabled": true,
"api_key_comment": "API Key(明文),用于写入剪贴板数据的鉴权",
"api_key": "pcb_nuS0qf-W63qcvAPsrG5ePA",
"api_key_hash_comment": "API Key 的 SHA256 哈希,用于服务端校验",
"api_key_hash": "sha256:d28127ad7b0b84563891fbc2621f0623915055e12c7097cd31191014ef2b1121",
"rate_limit_comment": "每分钟最多 60 次请求",
"rate_limit_per_minute": 60,
"max_files_per_request_comment": "单次上传最多文件数",
"max_files_per_request": 10,
"max_file_size_comment": "单文件最大 200MB(与现有剪贴板逻辑一致)",
"max_file_size": 209715200,
"latest_limit_comment": "latest 接口默认返回最近 50 条",
"latest_limit": 50,
"db_path_comment": "剪贴板数据库路径(读取环境变量 PRIVATE_CLIPBOARD_DB_PATH,默认 data/private_clipboard.db)",
"upload_dir_comment": "剪贴板上传目录(复用现有 uploads/private_clipboard/)"
}
@@ -0,0 +1,231 @@
// ============================================================
// private_clipboard/index.js - 私人剪贴板对外写入 API
// 路由挂载在 /api/v1/ingest/private_clipboard/*
// ============================================================
const express = require('express')
const multer = require('multer')
const fs = require('fs')
const os = require('os')
const path = require('path')
const { verifyApiKey } = require('../../auth')
const { logJSON } = require('../../../logger')
const { insertText, insertFiles, queryLatest } = require('./clipboard_bridge')
const SKILL_ID = 'private_clipboard'
const RATE_LIMIT_MAX = 60
const RATE_LIMIT_WINDOW_MS = 60 * 1000
const MAX_FILES = 10
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
const MAX_TEXT_LENGTH = 50000
// 临时目录
const TEMP_DIR = path.join(os.tmpdir(), 'private_clipboard_ingest')
if (!fs.existsSync(TEMP_DIR)) {
try { fs.mkdirSync(TEMP_DIR, { recursive: true }) } catch {}
}
const upload = multer({
dest: TEMP_DIR,
limits: { fileSize: MAX_FILE_SIZE, files: MAX_FILES }
})
// 速率限制
const rateLimitMap = new Map()
const checkRate = (ip) => {
const rec = rateLimitMap.get(ip)
if (!rec || Date.now() - rec.start > RATE_LIMIT_WINDOW_MS) {
rateLimitMap.set(ip, { start: Date.now(), count: 1 })
return { ok: true }
}
rec.count++
if (rec.count > RATE_LIMIT_MAX) {
const waitSec = Math.ceil((rec.start + RATE_LIMIT_WINDOW_MS - Date.now()) / 1000)
return { ok: false, retryAfter: Math.max(1, waitSec) }
}
return { ok: true }
}
setInterval(() => {
const now = Date.now()
for (const [ip, rec] of rateLimitMap.entries()) {
if (now - rec.start > RATE_LIMIT_WINDOW_MS + 10000) rateLimitMap.delete(ip)
}
}, 60000)
// 安全文件名清理
const safeName = (name) => {
let s = String(name || 'file')
try {
const recovered = Buffer.from(s, 'latin1').toString('utf8')
if (recovered !== s && !/\ufffd/.test(recovered)) s = recovered
} catch {}
// 移除路径穿越模式
s = s.replace(/\.\./g, '')
return s.replace(/[<>\":/\\|?*\x00-\x1F]/g, '_').trim() || 'file'
}
// HTML 转义(防御存储型 XSS)
const escapeHtml = (str) => {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
}
const setNoCache = (res) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
res.set('Pragma', 'no-cache')
res.set('Expires', '0')
} catch {}
}
const bindRoutes = (app) => {
const router = express.Router()
// API Key 鉴权中间件
router.use((req, res, next) => {
const auth = verifyApiKey(req)
if (!auth.ok) {
try { logJSON('private_clipboard.auth.fail', { error: auth.error, ip: req.ip }, 'private_clipboard') } catch {}
return res.status(401).json({ ok: false, error: auth.error })
}
const apiId = String(req.headers['x-api-id'] || '').trim()
if (apiId !== SKILL_ID) {
return res.status(403).json({ ok: false, error: 'skill id mismatch' })
}
next()
})
// 无缓存
router.use((req, res, next) => {
setNoCache(res)
next()
})
// ============================================================
// 写入文本
// POST /api/v1/ingest/private_clipboard/text
// Body: { "text": "...", "group_id": -123 }(group_id 可选)
// ============================================================
router.post('/text', express.json({
type: 'application/json',
limit: '1mb',
verify: (req, res, buf) => {
// 强制按 UTF-8 解码原始字节,绕过全局 express.json() 的可能编码错误
req._rawBody = buf.toString('utf8')
}
}), (req, res) => {
try {
// 速率限制
const clientIp = req.ip || req.connection.remoteAddress || 'unknown'
const rateCheck = checkRate(clientIp)
if (!rateCheck.ok) {
return res.status(429).json({ ok: false, error: '请求过于频繁,请在 ' + rateCheck.retryAfter + ' 秒后重试' })
}
// 优先使用 UTF-8 原始 buffer 解析,绕过全局 express.json() 的可能编码问题
let body = {}
let text = ''
if (req._rawBody) {
try {
body = JSON.parse(req._rawBody)
text = String(body.text || '').trim()
} catch { body = req.body || {} }
}
// 如果没有 rawBody 或解析失败,回退到 req.body
if (!text) {
body = req.body || {}
text = String(body.text || '').trim()
}
if (!text) return res.status(400).json({ ok: false, error: '文本内容不能为空' })
if (text.length > MAX_TEXT_LENGTH) {
return res.status(400).json({ ok: false, error: `文本超过 ${MAX_TEXT_LENGTH} 字符限制` })
}
// HTML 转义后存库,防御存储型 XSS
const safeText = escapeHtml(text)
const groupId = body.group_id !== undefined ? Number(body.group_id) : null
const result = insertText(safeText, groupId)
try { logJSON('private_clipboard.text.ok', { id: result.id, groupId }, 'private_clipboard') } catch {}
res.json({ ok: true, id: result.id, type: result.type, created_at: result.created_at })
} catch (e) {
try { logJSON('private_clipboard.text.error', { error: String(e.message || e) }, 'private_clipboard') } catch {}
res.status(500).json({ ok: false, error: '操作失败' })
}
})
// ============================================================
// 上传文件/图片(支持多文件)
// POST /api/v1/ingest/private_clipboard/upload
// multipart/form-data, field: "files", 可选 field: "group_id"
// ============================================================
router.post('/upload', upload.array('files', MAX_FILES), async (req, res) => {
try {
// 速率限制
const clientIp = req.ip || req.connection.remoteAddress || 'unknown'
const rateCheck = checkRate(clientIp)
if (!rateCheck.ok) {
return res.status(429).json({ ok: false, error: '请求过于频繁,请在 ' + rateCheck.retryAfter + ' 秒后重试' })
}
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
return res.status(400).json({ ok: false, error: '未收到文件' })
}
if (req.files.length > MAX_FILES) {
return res.status(400).json({ ok: false, error: `文件数量不能超过 ${MAX_FILES} 个` })
}
const groupId = req.body.group_id !== undefined ? Number(req.body.group_id) : null
const files = req.files.map(f => ({
buffer: fs.readFileSync(f.path),
name: safeName(f.originalname || 'file'),
mime: f.mimetype || 'application/octet-stream'
}))
const result = insertFiles(files, groupId)
// 清理临时文件
for (const f of req.files) {
if (f.path) try { fs.unlinkSync(f.path) } catch {}
}
try { logJSON('private_clipboard.upload.ok', { count: result.items.length, groupId: result.group_id }, 'private_clipboard') } catch {}
res.json({ ok: true, group_id: result.group_id, items: result.items })
} catch (e) {
// 清理临时文件
if (req.files && Array.isArray(req.files)) {
for (const f of req.files) {
if (f.path) try { fs.unlinkSync(f.path) } catch {}
}
}
try { logJSON('private_clipboard.upload.error', { error: String(e.message || e) }, 'private_clipboard') } catch {}
res.status(500).json({ ok: false, error: '上传失败' })
}
})
// ============================================================
// 查询最近 N 条
// GET /api/v1/ingest/private_clipboard/latest?limit=50
// ============================================================
router.get('/latest', (req, res) => {
try {
const limit = parseInt(req.query.limit, 10) || 50
const result = queryLatest(limit)
res.json({ ok: true, ...result })
} catch (e) {
try { logJSON('private_clipboard.latest.error', { error: String(e.message || e) }, 'private_clipboard') } catch {}
res.status(500).json({ ok: false, error: '查询失败' })
}
})
// 挂载路由
app.use('/api/v1/ingest/private_clipboard', router)
console.log('[private_clipboard] Routes mounted at /api/v1/ingest/private_clipboard')
}
module.exports = { bindRoutes }
+25 -2
View File
@@ -100,7 +100,9 @@ const allowedOrigins = [
'https://traesite.umersoft.com',
'http://traesite.umersoft.com',
'https://traesite.umersoft.com:8975',
'http://traesite.umersoft.com:8975'
'http://traesite.umersoft.com:8975',
'http://localhost:8081',
'http://127.0.0.1:8081'
]
app.use(cors({
origin: function (origin, callback) {
@@ -5906,9 +5908,30 @@ app.get('/api/box_backup/download/lowfreq_decrypted', async (req, res) => {
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
})
// web_order_box routes - 原生 HTML/CSS/JS Web 应用
app.get('/tools/web_order_box', (req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'tools', 'web_order_box', 'index.html'))
})
app.use('/tools/web_order_box', (req, res, next) => {
try {
const authConfigPath = path.join(process.cwd(), 'public', 'tools', 'web_order_box', 'auth_config.json')
if (fs.existsSync(authConfigPath)) {
const authConfig = JSON.parse(fs.readFileSync(authConfigPath, 'utf-8'))
if (authConfig.enable_auth === false) return next()
}
if (hasNavAuthRoot(req)) return next()
return res.status(401).send('未授权')
} catch { return res.status(401).send('未授权') }
})
app.use('/tools/web_order_box', (req, res, next) => {
res.set('X-Frame-Options', 'SAMEORIGIN')
next()
})
app.use('/tools/web_order_box', express.static(path.join(process.cwd(), 'public', 'tools', 'web_order_box')))
app.get('/api/zen_box/playlists', (req, res) => {
try {
const zenDir = path.join(process.cwd(), 'public', 'tools', 'zen_box')
const zenDir = path.join(process.cwd(), 'public', 'tools', 'web_zen_box')
const files = fs.existsSync(zenDir) ? fs.readdirSync(zenDir, { withFileTypes: true }) : []
const playlists = files
.filter(entry => entry && entry.isFile && entry.isFile())