diff --git a/apps/app_order_box/api.ts b/apps/app_order_box/api.ts index 678e450..cf745a5 100644 --- a/apps/app_order_box/api.ts +++ b/apps/app_order_box/api.ts @@ -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, diff --git a/apps/app_order_box/deploy-web.js b/apps/app_order_box/deploy-web.js new file mode 100644 index 0000000..faad758 --- /dev/null +++ b/apps/app_order_box/deploy-web.js @@ -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 = ''; +if (!indexContent.includes('', ` ${baseTag}\n`); +} + +// 将绝对路径改为相对路径(使 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'); diff --git a/apps/app_order_box/dist/_expo/static/js/web/AppEntry-7b6ff344f2982c1198d247406bc96731.js b/apps/app_order_box/dist/_expo/static/js/web/AppEntry-7b6ff344f2982c1198d247406bc96731.js new file mode 100644 index 0000000..dc93c27 --- /dev/null +++ b/apps/app_order_box/dist/_expo/static/js/web/AppEntry-7b6ff344f2982c1198d247406bc96731.js @@ -0,0 +1,696 @@ +var __BUNDLE_START_TIME__=globalThis.nativePerformanceNow?nativePerformanceNow():Date.now(),__DEV__=false,process=globalThis.process||{},__METRO_GLOBAL_PREFIX__='';process.env=process.env||{};process.env.NODE_ENV=process.env.NODE_ENV||"production"; +!(function(e){'use strict';e[`${__METRO_GLOBAL_PREFIX__}__d`]||(e.__r=i,e[`${__METRO_GLOBAL_PREFIX__}__d`]=function(e,n,o){if(t.has(n))return;const i={dependencyMap:o,factory:e,hasError:!1,importedAll:r,importedDefault:r,isInitialized:!1,publicModule:{exports:{}}};t.set(n,i)},e.__c=o,e.__registerSegment=function(e,r,n){c[e]=r,n&&n.forEach(r=>{t.has(r)||s.has(r)||s.set(r,e)})});var t=o();const r={},{hasOwnProperty:n}={};function o(){return t=new Map}function i(e,r){if(null===e)throw new Error('Cannot find module');const n=t.get(e);return n&&n.isInitialized?n.publicModule.exports:a(e,n,r)}function l(e){var n;if(t.has(e)&&(null==(n=t.get(e))?void 0:n.importedDefault)!==r)return t.get(e).importedDefault;const o=i(e),l=o&&o.__esModule?o.default:o;return t.get(e).importedDefault=l}function u(e){var o;if(t.has(e)&&(null==(o=t.get(e))?void 0:o.importedAll)!==r)return t.get(e).importedAll;const l=i(e);let u;if(l&&l.__esModule)u=l;else{if(u={},l)for(const e in l)n.call(l,e)&&(u[e]=l[e]);u.default=l}return t.get(e).importedAll=u}i.importDefault=l,i[Symbol.for('expo.require')]=!0,i.importAll=u,i.context=function(){throw new Error('The experimental Metro feature `require.context` is not enabled in your project.')},i.resolveWeak=function(){throw new Error('require.resolveWeak cannot be called dynamically.')},i.unguarded=function(e,r){const n=t.get(e);return n&&n.isInitialized?n.publicModule.exports:f(e,n,r)};let d=!1;function a(t,r,n){if(!d&&e.ErrorUtils){let o;d=!0;try{o=f(t,r,n)}catch(t){e.ErrorUtils.reportFatalError(t)}return d=!1,o}return f(t,r,n)}i.unpackModuleId=function(e){if('number'!=typeof e)throw new Error('Module ID must be a number in unpackModuleId.');return{segmentId:e>>>16,localId:65535&e}},i.packModuleId=function(e){return(e.segmentId<<16)+e.localId};const c=[],s=new Map;function f(r,n,o){if(!n&&c.length>0){const e=s.get(r)??0,o=c[e];null!=o&&(o(r),n=t.get(r),s.delete(r))}if(!n)throw p(r,o);if(n.hasError)throw n.error;n.isInitialized=!0;const{factory:d,dependencyMap:a}=n;try{const t=n.publicModule;return t.id=r,null==d||d(e,i,l,u,t,t.exports,a),n.factory=void 0,n.dependencyMap=void 0,t.exports}catch(e){throw n.hasError=!0,n.error=e,n.isInitialized=!1,n.publicModule.exports=void 0,e}}function p(e,t){return Error('Requiring unknown module "'+(e??t??"[unknown optional import]")+'".')}})('undefined'!=typeof globalThis?globalThis:'undefined'!=typeof global?global:'undefined'!=typeof window?window:this); +!(function(e){e.$$require_external="undefined"!=typeof require?e=>require(e):()=>null})('undefined'!=typeof globalThis?globalThis:'undefined'!=typeof global?global:'undefined'!=typeof window?window:this); +'undefined'!=typeof globalThis?globalThis:'undefined'!=typeof global?global:'undefined'!=typeof window&&window; +!(function(n){const e=(function(){function n(n,e){return n}function e(n){var e={};return n.forEach(function(n,t){e[n]=!0}),e}function t(n,t,u){if(n.formatValueCalls++,n.formatValueCalls>200)return`[TOO BIG formatValueCalls ${n.formatValueCalls} exceeded limit of 200]`;var c=r(n,t);if(c)return c;var s=Object.keys(t),f=e(s);if(d(t)&&(s.indexOf('message')>=0||s.indexOf('description')>=0))return o(t);if(0===s.length){if(h(t)){var g=t.name?': '+t.name:'';return n.stylize('[Function'+g+']','special')}if(p(t))return n.stylize(RegExp.prototype.toString.call(t),'regexp');if(y(t))return n.stylize(Date.prototype.toString.call(t),'date');if(d(t))return o(t)}var m,v,b='',j=!1,O=['{','}'];(m=t,Array.isArray(m)&&(j=!0,O=['[',']']),h(t))&&(b=' [Function'+(t.name?': '+t.name:'')+']');return p(t)&&(b=' '+RegExp.prototype.toString.call(t)),y(t)&&(b=' '+Date.prototype.toUTCString.call(t)),d(t)&&(b=' '+o(t)),0!==s.length||j&&0!=t.length?u<0?p(t)?n.stylize(RegExp.prototype.toString.call(t),'regexp'):n.stylize('[Object]','special'):(n.seen.push(t),v=j?i(n,t,u,f,s):s.map(function(e){return l(n,t,u,f,e,j)}),n.seen.pop(),a(v,b,O)):O[0]+b+O[1]}function r(n,e){if(f(e))return n.stylize('undefined','undefined');if('string'==typeof e){var t="'"+JSON.stringify(e).replace(/^"|"$/g,'').replace(/'/g,"\\'").replace(/\\"/g,'"')+"'";return n.stylize(t,'string')}return s(e)?n.stylize(''+e,'number'):u(e)?n.stylize(''+e,'boolean'):c(e)?n.stylize('null','null'):void 0}function o(n){return'['+Error.prototype.toString.call(n)+']'}function i(n,e,t,r,o){for(var i=[],a=0,u=e.length;a-1&&(u=l?u.split('\n').map(function(n){return' '+n}).join('\n').slice(2):'\n'+u.split('\n').map(function(n){return' '+n}).join('\n')):u=n.stylize('[Circular]','special')),f(a)){if(l&&i.match(/^\d+$/))return u;(a=JSON.stringify(''+i)).match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)?(a=a.slice(1,a.length-1),a=n.stylize(a,'name')):(a=a.replace(/'/g,"\\'").replace(/\\"/g,'"').replace(/(^"|"$)/g,"'"),a=n.stylize(a,'string'))}return a+': '+u}function a(n,e,t){return n.reduce(function(n,e){return e.indexOf('\n')>=0&&0,n+e.replace(/\u001b\[\d\d?m/g,'').length+1},0)>60?t[0]+(''===e?'':e+'\n ')+' '+n.join(',\n ')+' '+t[1]:t[0]+e+' '+n.join(', ')+' '+t[1]}function u(n){return'boolean'==typeof n}function c(n){return null===n}function s(n){return'number'==typeof n}function f(n){return void 0===n}function p(n){return g(n)&&'[object RegExp]'===m(n)}function g(n){return'object'==typeof n&&null!==n}function y(n){return g(n)&&'[object Date]'===m(n)}function d(n){return g(n)&&('[object Error]'===m(n)||n instanceof Error)}function h(n){return'function'==typeof n}function m(n){return Object.prototype.toString.call(n)}function v(n,e){return Object.prototype.hasOwnProperty.call(n,e)}return function(e,r){return t({seen:[],formatValueCalls:0,stylize:n},e,r.depth)}})(),t='(index)',r=0,o=1,i=2,l=3;function a(t){return function(){let r;r=1===arguments.length&&'string'==typeof arguments[0]?arguments[0]:Array.prototype.map.call(arguments,function(n){return e(n,{depth:10})}).join(', ');const o=arguments[0];let a=t;'string'==typeof o&&'Warning: '===o.slice(0,9)&&a>=l&&(a=i),s.length&&(r=f('',r)),n.nativeLoggingHook(r,a)}}function u(n,e){return Array.apply(null,Array(e)).map(function(){return n})}function c(n,e){if(e===t)return n[e];if(n.hasOwnProperty(e)){var r=n[e];switch(typeof r){case'function':return'\u0192';case'string':return"'"+r+"'";case'object':return null==r?'null':'{\u2026}'}return String(r)}return''}const s=[];function f(n,e){return s.join('')+n+' '+(e||'')}if(n.nativeLoggingHook){const y=n.console;if(n.console=Object.assign({timeStamp:function(){}},y??{},{error:a(l),info:a(o),log:a(o),warn:a(i),trace:a(r),debug:a(r),table:function(e,r){var i;if(Array.isArray(e))i=e.map((n,e)=>{var r={};return r[t]=String(e),Object.assign(r,n),r});else for(var l in i=[],e)if(e.hasOwnProperty(l)){var a={};a[t]=l,Object.assign(a,e[l]),i.push(a)}if(0!==i.length){r=Array.isArray(r)?[t].concat(r):Array.from(i.reduce((n,e)=>(Object.keys(e).forEach(e=>n.add(e)),n),new Set));var s=[],f=[];r.forEach(function(n,e){f[e]=n.length;for(var t=0;t'string'==typeof n?n:p(n)).join(' ');t=new Error(n),t.name='console.error'}n.RN$handleException(t,!1,!1)}}Object.defineProperty(console,'_isPolyfilled',{value:!0,enumerable:!1})}else if(!n.console){function g(){}const h=n.print||g;n.console={debug:h,error:h,info:h,log:h,trace:h,warn:h,assert(n,e){n||h('Assertion failed: '+e)},clear:g,dir:g,dirxml:g,group:g,groupCollapsed:g,groupEnd:g,profile:g,profileEnd:g,table:g,timeStamp:g},Object.defineProperty(console,'_isPolyfilled',{value:!0,enumerable:!1})}})('undefined'!=typeof globalThis?globalThis:'undefined'!=typeof global?global:'undefined'!=typeof window?window:this); +!(function(r){let l=0,n=!0===r.RN$useAlwaysAvailableJSErrorHandling?r.RN$handleException:(r,l)=>{throw r};const t={setGlobalHandler(r){n=r},getGlobalHandler:()=>n,reportError(r){n&&n(r,!1)},reportFatalError(r){n&&n(r,!0)},applyWithGuard(r,n,e,a,o){try{return l++,r.apply(n,e)}catch(r){t.reportError(r)}finally{l--}return null},applyWithGuardIfNeeded:(r,l,n)=>t.inGuard()?r.apply(l,n):(t.applyWithGuard(r,l,n),null),inGuard:()=>!!l,guard(r,l,n){if('function'!=typeof r)return console.warn('A function must be passed to ErrorUtils.guard, got ',r),null;const e=l??r.name??'';return function(...l){return t.applyWithGuard(r,n??this,l,null,e)}}};r.ErrorUtils=t})('undefined'!=typeof globalThis?globalThis:'undefined'!=typeof global?global:'undefined'!=typeof window?window:this); +__d(function(g,r,i,a,m,_e,d){"use strict";function t(t){return t&&t.__esModule?t:{default:t}}var u=t(r(d[0])),e=t(r(d[1]));(0,u.default)(e.default)},0,[1,96]); +__d(function(g,r,i,a,m,_e,d){"use strict";Object.defineProperty(_e,'__esModule',{value:!0}),Object.defineProperty(_e,"default",{enumerable:!0,get:function(){return o}}),r(d[0]);var e,t=r(d[1]),n=(e=t)&&e.__esModule?e:{default:e};function o(e){let t=e;if(n.default.registerComponent('main',()=>t),'undefined'!=typeof window){const e=document.getElementById('root');n.default.runApplication('main',{rootTag:e,hydrate:globalThis.__EXPO_ROUTER_HYDRATE__})}}r(d[2])},1,[2,18,95]); +__d(function(g,r,i,a,m,e,d){"use strict";r(d[0]),r(d[1]),r(d[2])},2,[3,8,17]); +__d(function(g,r,i,a,m,e,d){"use strict";r(d[0])},3,[4]); +__d(function(g,r,i,a,m,e,d){"use strict";r(d[0]),Object.defineProperty(globalThis,'__ExpoImportMetaRegistry',{value:r(d[1]).ImportMetaRegistry,enumerable:!1,writable:!0})},4,[5,6]); +__d(function(g,r,i,a,m,e,d){},5,[]); +__d(function(g,r,i,a,m,e,d){"use strict";Object.defineProperty(e,'__esModule',{value:!0}),Object.defineProperty(e,"ImportMetaRegistry",{enumerable:!0,get:function(){return n}});var t=r(d[0]);const n={get url(){return(0,t.getBundleUrl)()}}},6,[7]); +__d(function(g,r,i,a,m,e,d){"use strict";Object.defineProperty(e,'__esModule',{value:!0}),e.getBundleUrl=function(){let n=null;n='undefined'==typeof window?'file://'+__filename:document.currentScript?.src;if(null==n)return null;const t=new URL(n);return`${t.protocol}//${t.host}${t.pathname}`}},7,[]); +__d(function(g,r,i,a,m,e,d){"use strict";var _=r(d[0]);g[`${g.__METRO_GLOBAL_PREFIX__??''}__loadBundleAsync`]=(0,_.buildAsyncRequire)()},8,[9]); +__d(function(g,r,i,a,m,e,d){"use strict";Object.defineProperty(e,'__esModule',{value:!0}),e.buildAsyncRequire=function(){const t=new Map;return async function(c){if(t.has(c))return t.get(c);const u=(0,n.loadBundleAsync)(c).catch(n=>{throw t.delete(c),n});return t.set(c,u),u}};var n=r(d[0])},9,[10]); +__d(function(g,r,i,a,m,e,d){"use strict";Object.defineProperty(e,'__esModule',{value:!0}),e.loadBundleAsync=async function(t){const u=(0,n.buildUrlForBundle)(t);return(0,c.fetchThenEvalAsync)(u)};var n=r(d[0]),c=r(d[1])},10,[11,13]); +__d(function(g,r,i,a,m,_e,d){"use strict";Object.defineProperty(_e,'__esModule',{value:!0}),_e.buildUrlForBundle=function(e){if(/^https?:\/\//.test(e))return e;const{url:t}=(0,u.default)();return t?new URL(e,t).toString():`//${e.replace(/^\/+/,'')}`};var e,t=r(d[0]),u=(e=t)&&e.__esModule?e:{default:e}},11,[12]); +__d(function(g,r,i,a,m,e,d){"use strict";Object.defineProperty(e,'__esModule',{value:!0}),Object.defineProperty(e,"default",{enumerable:!0,get:function(){return t}});var t=()=>'undefined'==typeof window?{bundleLoadedFromServer:!0,fullBundleUrl:'',url:''}:{bundleLoadedFromServer:!0,get fullBundleUrl(){if(document?.currentScript&&'src'in document.currentScript)return document.currentScript.src;const t=new URL(location.href);return t.searchParams.set('platform','web'),t.toString()},url:location.origin+location.pathname}},12,[]); +__d(function(g,r,i,a,m,e,d){"use strict";Object.defineProperty(e,'__esModule',{value:!0}),e.fetchThenEvalAsync=function(o,{scriptType:c,nonce:s,crossOrigin:u}={}){if('undefined'==typeof window)return r(d[0]).fetchThenEvalAsync(o);return new Promise((p,l)=>{const f=document.createElement('script');c&&(f.type=c),s&&f.setAttribute('nonce',s),f.src=o,u&&0!==f.src.indexOf(window.location.origin+'/')&&(f.crossOrigin=u),f.onload=()=>{f.parentNode&&f.parentNode.removeChild(f),p()};const y=new t;f.onerror=n=>{let t;t='string'==typeof n?{type:'error',target:{src:t}}:n;const c=t&&('load'===t.type?'missing':t.type),s=t?.target?.src;y.message='Loading module '+o+' failed.\n('+c+': '+s+')',y.type=c,y.request=s,f.parentNode&&f.parentNode.removeChild(f),l(y)},f.src===n?p():document.head.appendChild(f)})};const n='undefined'!=typeof document&&document.currentScript&&'src'in document.currentScript&&document.currentScript.src||null;class t extends Error{name='AsyncRequireError'}},13,[14]); +__d(function(g,r,i,a,m,e,d){"use strict";Object.defineProperty(e,'__esModule',{value:!0}),e.fetchThenEvalAsync=fetchThenEvalAsync;var _errors=r(d[0]),_fetchAsync=r(d[1]);function fetchThenEvalAsync(url){return(0,_fetchAsync.fetchAsync)(url).then(({body:body,status:status,headers:headers})=>{if(null!=headers?.has?.('Content-Type')&&headers.get('Content-Type').includes('application/json'))throw new Error(JSON.parse(body).message||`Unknown error fetching '${url}'`);if(200===status)return eval(body);throw new Error(`Failed to load split bundle from URL: ${url}\n${body}`)})}},14,[15,16]); +__d(function(g,r,i,a,m,e,d){"use strict";Object.defineProperty(e,'__esModule',{value:!0}),Object.defineProperty(e,"MetroServerError",{enumerable:!0,get:function(){return t}});class t extends Error{code='METRO_SERVER_ERROR';constructor(t,o){super(t.message),this.name='MetroServerError',this.url=o;for(const o in t)this[o]=t[o]}}},15,[]); +__d(function(g,r,i,a,m,e,d){"use strict";Object.defineProperty(e,'__esModule',{value:!0}),e.fetchAsync=async function(t){const s=await fetch(t,{method:'GET',headers:{'expo-platform':'web'}});return{body:await s.text(),status:s.status,headers:s.headers}}},16,[]); +__d(function(g,r,i,a,m,e,d){},17,[]); +__d(function(g,r,i,a,m,_e,d){"use strict";function t(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(_e,'__esModule',{value:!0}),Object.defineProperty(_e,"default",{enumerable:!0,get:function(){return f}});t(r(d[0]));var e,n=t(r(d[1])),o=t(r(d[2])),p=r(d[3]),s=t(p),u={},l={},c=t=>t();class f{static getAppKeys(){return Object.keys(l)}static getApplication(t,e){return(0,n.default)(l[t]&&l[t].getApplication,"Application "+t+" has not been registered. This is either due to an import error during initialization or failure to call AppRegistry.registerComponent."),l[t].getApplication(e)}static registerComponent(t,n){return l[t]={getApplication:t=>(0,p.getApplication)(c(n),t?t.initialProps:u,e&&e(t)),run:t=>(0,s.default)(c(n),e&&e(t),t.callback,{hydrate:t.hydrate||!1,initialProps:t.initialProps||u,mode:t.mode||'concurrent',rootTag:t.rootTag})},t}static registerConfig(t){t.forEach(t=>{var e=t.appKey,o=t.component,p=t.run;p?f.registerRunnable(e,p):((0,n.default)(o,'No component provider passed in'),f.registerComponent(e,o))})}static registerRunnable(t,e){return l[t]={run:e},t}static runApplication(t,e){return(0,n.default)(l[t]&&l[t].run,"Application \""+t+"\" has not been registered. This is either due to an import error during initialization or failure to call AppRegistry.registerComponent."),l[t].run(e)}static setComponentProviderInstrumentationHook(t){c=t}static setWrapperComponentProvider(t){e=t}static unmountApplicationComponentAtRootTag(t){(0,o.default)(t)}}},18,[19,24,25,26]); +__d(function(g,_r,i,a,m,_e,d){"use strict";Object.defineProperty(_e,'__esModule',{value:!0}),Object.defineProperty(_e,"default",{enumerable:!0,get:function(){return o}});var e,t=_r(d[0]),r=(e=t)&&e.__esModule?e:{default:e};function n(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),r.push.apply(r,n)}return r}function o(e){for(var t=1;t2?t-2:0),s=2;s{var n=f.default.getSheet();return c.default.createElement("style",(0,t.default)({},e,{dangerouslySetInnerHTML:{__html:n.textContent},id:n.id}))}}};var t=e(r(d[0])),n=e(r(d[1])),l=e(r(d[2])),u=r(d[3]),o=e(u),f=e(r(d[4])),c=e(r(d[5]));function p(e,t,f,p){var s=p.hydrate,_=p.initialProps,y=p.rootTag,E=s?u.hydrate:o.default;return(0,l.default)(y,'Expect to have a valid rootTag, instead got ',y),E(c.default.createElement(n.default,{WrapperComponent:t,ref:f,rootTag:y},c.default.createElement(e,_)),y)}},26,[27,28,24,29,42,34]); +__d(function(g,_r,i,a,m,_e,d){"use strict";function e(){return e=Object.assign?Object.assign.bind():function(e){for(var n=1;nQ||(e.current=H[Q],H[Q]=null,Q--)}function W(e,n){Q++,H[Q]=e.current,e.current=n}var q=$(null),K=$(null),Y=$(null),X=$(null);function G(e,n){switch(W(Y,n),W(K,e),W(q,null),n.nodeType){case 9:case 11:e=(e=n.documentElement)&&(e=e.namespaceURI)?xf(e):0;break;default:if(e=n.tagName,n=n.namespaceURI)e=Cf(n=xf(n),e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}B(q),W(q,e)}function Z(){B(q),B(K),B(Y)}function J(e){null!==e.memoizedState&&W(X,e);var n=q.current,t=Cf(n,e.type);n!==t&&(W(K,e),W(q,t))}function ee(e){K.current===e&&(B(q),B(K)),X.current===e&&(B(X),wd._currentValue=V)}var ne=Object.prototype.hasOwnProperty,te=u.unstable_scheduleCallback,re=u.unstable_cancelCallback,le=u.unstable_shouldYield,ae=u.unstable_requestPaint,oe=u.unstable_now,ue=u.unstable_getCurrentPriorityLevel,ie=u.unstable_ImmediatePriority,se=u.unstable_UserBlockingPriority,ce=u.unstable_NormalPriority,fe=u.unstable_LowPriority,de=u.unstable_IdlePriority,pe=u.log,me=u.unstable_setDisableYieldValue,he=null,ge=null;function ve(e){if("function"==typeof pe&&me(e),ge&&"function"==typeof ge.setStrictMode)try{ge.setStrictMode(he,e)}catch(e){}}var ye=Math.clz32?Math.clz32:function(e){return 0===(e>>>=0)?32:31-(be(e)/ke|0)|0},be=Math.log,ke=Math.LN2;var we=256,Se=4194304;function Ee(e){var n=42&e;if(0!==n)return n;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return 4194048&e;case 4194304:case 8388608:case 16777216:case 33554432:return 62914560&e;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function xe(e,n,t){var r=e.pendingLanes;if(0===r)return 0;var l=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var u=134217727&r;return 0!==u?0!==(r=u&~a)?l=Ee(r):0!==(o&=u)?l=Ee(o):t||0!==(t=u&~e)&&(l=Ee(t)):0!==(u=r&~a)?l=Ee(u):0!==o?l=Ee(o):t||0!==(t=r&~e)&&(l=Ee(t)),0===l?0:0!==n&&n!==l&&0===(n&a)&&((a=l&-l)>=(t=n&-n)||32===a&&4194048&t)?n:l}function Ce(e,n){return 0===(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&n)}function ze(e,n){switch(e){case 1:case 2:case 4:case 8:case 64:return n+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return n+5e3;default:return-1}}function Pe(){var e=we;return!(4194048&(we<<=1))&&(we=256),e}function Ne(){var e=Se;return!(62914560&(Se<<=1))&&(Se=4194304),e}function Le(e){for(var n=[],t=0;31>t;t++)n.push(e);return n}function Te(e,n){e.pendingLanes|=n,268435456!==n&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function _e(e,n,t,r,l,a){var o=e.pendingLanes;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=t,e.entangledLanes&=t,e.errorRecoveryDisabledLanes&=t,e.shellSuspendCounter=0;var u=e.entanglements,i=e.expirationTimes,s=e.hiddenUpdates;for(t=o&~t;0)":-1--l||i[r]!==s[l]){var c="\n"+i[r].replace(" at new "," at ");return e.displayName&&c.includes("")&&(c=c.replace("",e.displayName)),c}}while(1<=r&&0<=l);break}}}finally{pn=!1,Error.prepareStackTrace=t}return(t=e?e.displayName||e.name:"")?dn(t):""}function hn(e){switch(e.tag){case 26:case 27:case 5:return dn(e.type);case 16:return dn("Lazy");case 13:return dn("Suspense");case 19:return dn("SuspenseList");case 0:case 15:return mn(e.type,!1);case 11:return mn(e.type.render,!1);case 1:return mn(e.type,!0);case 31:return dn("Activity");default:return""}}function gn(e){try{var n="";do{n+=hn(e),e=e.return}while(e);return n}catch(e){return"\nError generating stack: "+e.message+"\n"+e.stack}}function vn(e){switch(typeof e){case"bigint":case"boolean":case"number":case"string":case"undefined":case"object":return e;default:return""}}function yn(e){var n=e.type;return(e=e.nodeName)&&"input"===e.toLowerCase()&&("checkbox"===n||"radio"===n)}function bn(e){var n=yn(e)?"checked":"value",t=Object.getOwnPropertyDescriptor(e.constructor.prototype,n),r=""+e[n];if(!e.hasOwnProperty(n)&&void 0!==t&&"function"==typeof t.get&&"function"==typeof t.set){var l=t.get,a=t.set;return Object.defineProperty(e,n,{configurable:!0,get:function(){return l.call(this)},set:function(e){r=""+e,a.call(this,e)}}),Object.defineProperty(e,n,{enumerable:t.enumerable}),{getValue:function(){return r},setValue:function(e){r=""+e},stopTracking:function(){e._valueTracker=null,delete e[n]}}}}function kn(e){e._valueTracker||(e._valueTracker=bn(e))}function wn(e){if(!e)return!1;var n=e._valueTracker;if(!n)return!0;var t=n.getValue(),r="";return e&&(r=yn(e)?e.checked?"true":"false":e.value),(e=r)!==t&&(n.setValue(e),!0)}function Sn(e){if(void 0===(e=e||("undefined"!=typeof document?document:void 0)))return null;try{return e.activeElement||e.body}catch(n){return e.body}}var En=/[\n"\\]/g;function xn(e){return e.replace(En,function(e){return"\\"+e.charCodeAt(0).toString(16)+" "})}function Cn(e,n,t,r,l,a,o,u){e.name="",null!=o&&"function"!=typeof o&&"symbol"!=typeof o&&"boolean"!=typeof o?e.type=o:e.removeAttribute("type"),null!=n?"number"===o?(0===n&&""===e.value||e.value!=n)&&(e.value=""+vn(n)):e.value!==""+vn(n)&&(e.value=""+vn(n)):"submit"!==o&&"reset"!==o||e.removeAttribute("value"),null!=n?Pn(e,o,vn(n)):null!=t?Pn(e,o,vn(t)):null!=r&&e.removeAttribute("value"),null==l&&null!=a&&(e.defaultChecked=!!a),null!=l&&(e.checked=l&&"function"!=typeof l&&"symbol"!=typeof l),null!=u&&"function"!=typeof u&&"symbol"!=typeof u&&"boolean"!=typeof u?e.name=""+vn(u):e.removeAttribute("name")}function zn(e,n,t,r,l,a,o,u){if(null!=a&&"function"!=typeof a&&"symbol"!=typeof a&&"boolean"!=typeof a&&(e.type=a),null!=n||null!=t){if(("submit"===a||"reset"===a)&&null==n)return;t=null!=t?""+vn(t):"",n=null!=n?""+vn(n):t,u||n===e.value||(e.value=n),e.defaultValue=n}r="function"!=typeof(r=null!=r?r:l)&&"symbol"!=typeof r&&!!r,e.checked=u?e.checked:!!r,e.defaultChecked=!!r,null!=o&&"function"!=typeof o&&"symbol"!=typeof o&&"boolean"!=typeof o&&(e.name=o)}function Pn(e,n,t){"number"===n&&Sn(e.ownerDocument)===e||e.defaultValue===""+t||(e.defaultValue=""+t)}function Nn(e,n,t,r){if(e=e.options,n){n={};for(var l=0;l=_t),Mt=String.fromCharCode(32),Ot=!1;function At(e,n){switch(e){case"keyup":return-1!==Lt.indexOf(n.keyCode);case"keydown":return 229!==n.keyCode;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Rt(e){return"object"==typeof(e=e.detail)&&"data"in e?e.data:null}var It=!1;function Ut(e,n){switch(e){case"compositionend":return Rt(n);case"keypress":return 32!==n.which?null:(Ot=!0,Mt);case"textInput":return(e=n.data)===Mt&&Ot?null:e;default:return null}}function jt(e,n){if(It)return"compositionend"===e||!Tt&&At(e,n)?(e=Jn(),Zn=Gn=Xn=null,It=!1,e):null;switch(e){case"paste":default:return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:r,offset:n-e};e=t}e:{for(;r;){if(r.nextSibling){r=r.nextSibling;break e}r=r.parentNode}r=void 0}r=ur(r)}}function sr(e,n){return!(!e||!n)&&(e===n||(!e||3!==e.nodeType)&&(n&&3===n.nodeType?sr(e,n.parentNode):"contains"in e?e.contains(n):!!e.compareDocumentPosition&&!!(16&e.compareDocumentPosition(n))))}function cr(e){for(var n=Sn((e=null!=e&&null!=e.ownerDocument&&null!=e.ownerDocument.defaultView?e.ownerDocument.defaultView:window).document);n instanceof e.HTMLIFrameElement;){try{var t="string"==typeof n.contentWindow.location.href}catch(e){t=!1}if(!t)break;n=Sn((e=n.contentWindow).document)}return n}function fr(e){var n=e&&e.nodeName&&e.nodeName.toLowerCase();return n&&("input"===n&&("text"===e.type||"search"===e.type||"tel"===e.type||"url"===e.type||"password"===e.type)||"textarea"===n||"true"===e.contentEditable)}var dr=qn&&"documentMode"in document&&11>=document.documentMode,pr=null,mr=null,hr=null,gr=!1;function vr(e,n,t){var r=t.window===t?t.document:9===t.nodeType?t:t.ownerDocument;gr||null==pr||pr!==Sn(r)||("selectionStart"in(r=pr)&&fr(r)?r={start:r.selectionStart,end:r.selectionEnd}:r={anchorNode:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection()).anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset},hr&&or(hr,r)||(hr=r,0<(r=sf(mr,"onSelect")).length&&(n=new it("onSelect","select",null,n,t),e.push({event:n,listeners:r}),n.target=pr)))}function yr(e,n){var t={};return t[e.toLowerCase()]=n.toLowerCase(),t["Webkit"+e]="webkit"+n,t["Moz"+e]="moz"+n,t}var br={animationend:yr("Animation","AnimationEnd"),animationiteration:yr("Animation","AnimationIteration"),animationstart:yr("Animation","AnimationStart"),transitionrun:yr("Transition","TransitionRun"),transitionstart:yr("Transition","TransitionStart"),transitioncancel:yr("Transition","TransitionCancel"),transitionend:yr("Transition","TransitionEnd")},kr={},wr={};function Sr(e){if(kr[e])return kr[e];if(!br[e])return e;var n,t=br[e];for(n in t)if(t.hasOwnProperty(n)&&n in wr)return kr[e]=t[n];return e}qn&&(wr=document.createElement("div").style,"AnimationEvent"in window||(delete br.animationend.animation,delete br.animationiteration.animation,delete br.animationstart.animation),"TransitionEvent"in window||delete br.transitionend.transition);var Er=Sr("animationend"),xr=Sr("animationiteration"),Cr=Sr("animationstart"),zr=Sr("transitionrun"),Pr=Sr("transitionstart"),Nr=Sr("transitioncancel"),Lr=Sr("transitionend"),Tr=new Map,_r="abort auxClick beforeToggle cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");function Fr(e,n){Tr.set(e,n),nn(n,[e])}_r.push("scrollEnd");var Dr=new WeakMap;function Mr(e,n){if("object"==typeof e&&null!==e){var t=Dr.get(e);return void 0!==t?t:(n={value:e,source:n,stack:gn(n)},Dr.set(e,n),n)}return{value:e,source:n,stack:gn(n)}}var Or=[],Ar=0,Rr=0;function Ir(){for(var e=Ar,n=Rr=Ar=0;n>=o,l-=o,ul=1<<32-ye(n)+l|t<a?a:8;var o,u,i,s=U.T,c={};U.T=c,Jo(e,!1,n,t);try{var f=l(),d=U.S;if(null!==d&&d(c,f),null!==f&&"object"==typeof f&&"function"==typeof f.then)Zo(e,n,(o=r,u=[],i={status:"pending",value:null,reason:null,then:function(e){u.push(e)}},f.then(function(){i.status="fulfilled",i.value=o;for(var e=0;eh?(g=f,f=null):g=f.sibling;var v=p(l,f,u[h],i);if(null===v){null===f&&(f=g);break}e&&f&&null===v.alternate&&n(l,f),o=a(v,o,h),null===c?s=v:c.sibling=v,c=v,f=g}if(h===u.length)return t(l,f),hl&&sl(l,h),s;if(null===f){for(;hg?(v=h,h=null):v=h.sibling;var b=p(l,h,y.value,i);if(null===b){null===h&&(h=v);break}e&&h&&null===b.alternate&&n(l,h),o=a(b,o,g),null===f?s=b:f.sibling=b,f=b,h=v}if(y.done)return t(l,h),hl&&sl(l,g),s;if(null===h){for(;!y.done;g++,y=u.next())null!==(y=d(l,y.value,i))&&(o=a(y,o,g),null===f?s=y:f.sibling=y,f=y);return hl&&sl(l,g),s}for(h=r(h);!y.done;g++,y=u.next())null!==(y=m(h,l,g,y.value,i))&&(e&&null!==y.alternate&&h.delete(null===y.key?g:y.key),o=a(y,o,g),null===f?s=y:f.sibling=y,f=y);return e&&h.forEach(function(e){return n(l,e)}),hl&&sl(l,g),s}function v(e,r,a,u){if("object"==typeof a&&null!==a&&a.type===w&&null===a.key&&(a=a.props.children),"object"==typeof a&&null!==a){switch(a.$$typeof){case b:e:{for(var i=a.key;null!==r;){if(r.key===i){if((i=a.type)===w){if(7===r.tag){t(e,r.sibling),(u=l(r,a.props.children)).return=e,e=u;break e}}else if(r.elementType===i||"object"==typeof i&&null!==i&&i.$$typeof===_&&du(i)===r.type){t(e,r.sibling),cu(u=l(r,a.props),a),u.return=e,e=u;break e}t(e,r);break}n(e,r),r=r.sibling}a.type===w?((u=Gr(a.props.children,e.mode,u,a.key)).return=e,e=u):(cu(u=Xr(a.type,a.key,a.props,null,e.mode,u),a),u.return=e,e=u)}return o(e);case k:e:{for(i=a.key;null!==r;){if(r.key===i){if(4===r.tag&&r.stateNode.containerInfo===a.containerInfo&&r.stateNode.implementation===a.implementation){t(e,r.sibling),(u=l(r,a.children||[])).return=e,e=u;break e}t(e,r);break}n(e,r),r=r.sibling}(u=Jr(a,e.mode,u)).return=e,e=u}return o(e);case _:return v(e,r,a=(i=a._init)(a._payload),u)}if(I(a))return h(e,r,a,u);if(O(a)){if("function"!=typeof(i=O(a)))throw Error(c(150));return g(e,r,a=i.call(a),u)}if("function"==typeof a.then)return v(e,r,su(a),u);if(a.$$typeof===z)return v(e,r,Rl(e,a),u);fu(e,a)}return"string"==typeof a&&""!==a||"number"==typeof a||"bigint"==typeof a?(a=""+a,null!==r&&6===r.tag?(t(e,r.sibling),(u=l(r,a)).return=e,e=u):(t(e,r),(u=Zr(a,e.mode,u)).return=e,e=u),o(e)):t(e,r)}return function(e,n,t,r){try{iu=0;var l=v(e,n,t,r);return uu=null,l}catch(n){if(n===ta||n===la)throw n;var a=Wr(29,n,null,e.mode);return a.lanes=r,a.return=e,a}}}var mu=pu(!0),hu=pu(!1),gu=$(null),vu=null;function yu(e){var n=e.alternate;W(Su,1&Su.current),W(gu,e),null===vu&&(null===n||null!==xa.current||null!==n.memoizedState)&&(vu=e)}function bu(e){if(22===e.tag){if(W(Su,Su.current),W(gu,e),null===vu){var n=e.alternate;null!==n&&null!==n.memoizedState&&(vu=e)}}else ku()}function ku(){W(Su,Su.current),W(gu,gu.current)}function wu(e){B(gu),vu===e&&(vu=null),B(Su)}var Su=$(0);function Eu(e){for(var n=e;null!==n;){if(13===n.tag){var t=n.memoizedState;if(null!==t&&(null===(t=t.dehydrated)||"$?"===t.data||Uf(t)))return n}else if(19===n.tag&&void 0!==n.memoizedProps.revealOrder){if(128&n.flags)return n}else if(null!==n.child){n.child.return=n,n=n.child;continue}if(n===e)break;for(;null===n.sibling;){if(null===n.return||n.return===e)return null;n=n.return}n.sibling.return=n.return,n=n.sibling}return null}function xu(e,n,t,r){t=null==(t=t(r,n=e.memoizedState))?n:v({},n,t),e.memoizedState=t,0===e.lanes&&(e.updateQueue.baseState=t)}var Cu={enqueueSetState:function(e,n,t){e=e._reactInternals;var r=Xs(),l=ha(r);l.payload=n,null!=t&&(l.callback=t),null!==(n=ga(e,l,r))&&(Zs(n,e,r),va(n,e,r))},enqueueReplaceState:function(e,n,t){e=e._reactInternals;var r=Xs(),l=ha(r);l.tag=1,l.payload=n,null!=t&&(l.callback=t),null!==(n=ga(e,l,r))&&(Zs(n,e,r),va(n,e,r))},enqueueForceUpdate:function(e,n){e=e._reactInternals;var t=Xs(),r=ha(t);r.tag=2,null!=n&&(r.callback=n),null!==(n=ga(e,r,t))&&(Zs(n,e,t),va(n,e,t))}};function zu(e,n,t,r,l,a,o){return"function"==typeof(e=e.stateNode).shouldComponentUpdate?e.shouldComponentUpdate(r,a,o):!n.prototype||!n.prototype.isPureReactComponent||(!or(t,r)||!or(l,a))}function Pu(e,n,t,r){e=n.state,"function"==typeof n.componentWillReceiveProps&&n.componentWillReceiveProps(t,r),"function"==typeof n.UNSAFE_componentWillReceiveProps&&n.UNSAFE_componentWillReceiveProps(t,r),n.state!==e&&Cu.enqueueReplaceState(n,n.state,null)}function Nu(e,n){var t=n;if("ref"in n)for(var r in t={},n)"ref"!==r&&(t[r]=n[r]);if(e=e.defaultProps)for(var l in t===n&&(t=v({},t)),e)void 0===t[l]&&(t[l]=e[l]);return t}var Lu="function"==typeof reportError?reportError:function(e){if("object"==typeof window&&"function"==typeof window.ErrorEvent){var n=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:"object"==typeof e&&null!==e&&"string"==typeof e.message?String(e.message):String(e),error:e});if(!window.dispatchEvent(n))return}else if("object"==typeof process&&"function"==typeof process.emit)return void process.emit("uncaughtException",e);console.error(e)};function Tu(e){Lu(e)}function _u(e){console.error(e)}function Fu(e){Lu(e)}function Du(e,n){try{(0,e.onUncaughtError)(n.value,{componentStack:n.stack})}catch(e){setTimeout(function(){throw e})}}function Mu(e,n,t){try{(0,e.onCaughtError)(t.value,{componentStack:t.stack,errorBoundary:1===n.tag?n.stateNode:null})}catch(e){setTimeout(function(){throw e})}}function Ou(e,n,t){return(t=ha(t)).tag=3,t.payload={element:null},t.callback=function(){Du(e,n)},t}function Au(e){return(e=ha(e)).tag=3,e}function Ru(e,n,t,r){var l=t.type.getDerivedStateFromError;if("function"==typeof l){var a=r.value;e.payload=function(){return l(a)},e.callback=function(){Mu(n,t,r)}}var o=t.stateNode;null!==o&&"function"==typeof o.componentDidCatch&&(e.callback=function(){Mu(n,t,r),"function"!=typeof l&&(null===js?js=new Set([this]):js.add(this));var e=r.stack;this.componentDidCatch(r.value,{componentStack:null!==e?e:""})})}function Iu(e,n,t,r,l){if(t.flags|=32768,null!==r&&"object"==typeof r&&"function"==typeof r.then){if(null!==(n=t.alternate)&&Dl(n,t,l,!0),null!==(t=gu.current)){switch(t.tag){case 13:return null===vu?sc():null===t.alternate&&0===Ns&&(Ns=3),t.flags&=-257,t.flags|=65536,t.lanes=l,r===aa?t.flags|=16384:(null===(n=t.updateQueue)?t.updateQueue=new Set([r]):n.add(r),Nc(e,r,l)),!1;case 22:return t.flags|=65536,r===aa?t.flags|=16384:(null===(n=t.updateQueue)?(n={transitions:null,markerInstances:null,retryQueue:new Set([r])},t.updateQueue=n):null===(t=n.retryQueue)?n.retryQueue=new Set([r]):t.add(r),Nc(e,r,l)),!1}throw Error(c(435,t.tag))}return Nc(e,r,l),sc(),!1}if(hl)return null!==(n=gu.current)?(!(65536&n.flags)&&(n.flags|=256),n.flags|=65536,n.lanes=l,r!==yl&&Cl(Mr(e=Error(c(422),{cause:r}),t))):(r!==yl&&Cl(Mr(n=Error(c(423),{cause:r}),t)),(e=e.current.alternate).flags|=65536,l&=-l,e.lanes|=l,r=Mr(r,t),ya(e,l=Ou(e.stateNode,r,l)),4!==Ns&&(Ns=2)),!1;var a=Error(c(520),{cause:r});if(a=Mr(a,t),null===Ms?Ms=[a]:Ms.push(a),4!==Ns&&(Ns=2),null===n)return!0;r=Mr(r,t),t=n;do{switch(t.tag){case 3:return t.flags|=65536,e=l&-l,t.lanes|=e,ya(t,e=Ou(t.stateNode,r,e)),!1;case 1:if(n=t.type,a=t.stateNode,!(128&t.flags||"function"!=typeof n.getDerivedStateFromError&&(null===a||"function"!=typeof a.componentDidCatch||null!==js&&js.has(a))))return t.flags|=65536,l&=-l,t.lanes|=l,Ru(l=Au(l),e,t,r),ya(t,l),!1}t=t.return}while(null!==t);return!1}var Uu=Error(c(461)),ju=!1;function Vu(e,n,t,r){n.child=null===e?hu(n,null,t,r):mu(n,e.child,t,r)}function Hu(e,n,t,r,l){t=t.render;var a=n.ref;if("ref"in r){var o={};for(var u in r)"ref"!==u&&(o[u]=r[u])}else o=r;return Ol(n),r=Ha(e,n,t,o,a,l),u=Wa(),null===e||ju?(hl&&u&&fl(n),n.flags|=1,Vu(e,n,r,l),n.child):(qa(e,n,l),ii(e,n,l))}function Qu(e,n,t,r,l){if(null===e){var a=t.type;return"function"!=typeof a||qr(a)||void 0!==a.defaultProps||null!==t.compare?((e=Xr(t.type,null,r,n,n.mode,l)).ref=n.ref,e.return=n,n.child=e):(n.tag=15,n.type=a,$u(e,n,a,r,l))}if(a=e.child,!si(e,l)){var o=a.memoizedProps;if((t=null!==(t=t.compare)?t:or)(o,r)&&e.ref===n.ref)return ii(e,n,l)}return n.flags|=1,(e=Kr(a,r)).ref=n.ref,e.return=n,n.child=e}function $u(e,n,t,r,l){if(null!==e){var a=e.memoizedProps;if(or(a,r)&&e.ref===n.ref){if(ju=!1,n.pendingProps=r=a,!si(e,l))return n.lanes=e.lanes,ii(e,n,l);131072&e.flags&&(ju=!0)}}return Ku(e,n,t,r,l)}function Bu(e,n,t){var r=n.pendingProps,l=r.children,a=null!==e?e.memoizedState:null;if("hidden"===r.mode){if(128&n.flags){if(r=null!==a?a.baseLanes|t:t,null!==e){for(l=n.child=e.child,a=0;null!==l;)a=a|l.lanes|l.childLanes,l=l.sibling;n.childLanes=a&~r}else n.childLanes=0,n.child=null;return Wu(e,n,r,t)}if(!(536870912&t))return n.lanes=n.childLanes=536870912,Wu(e,n,null!==a?a.baseLanes|t:t,t);n.memoizedState={baseLanes:0,cachePool:null},null!==e&&ea(0,null!==a?a.cachePool:null),null!==a?za(n,a):Pa(),bu(n)}else null!==a?(ea(0,a.cachePool),za(n,a),ku(),n.memoizedState=null):(null!==e&&ea(0,null),Pa(),ku());return Vu(e,n,l,t),n.child}function Wu(e,n,t,r){var l=Jl();return l=null===l?null:{parent:Hl._currentValue,pool:l},n.memoizedState={baseLanes:t,cachePool:l},null!==e&&ea(0,null),Pa(),bu(n),null!==e&&Dl(e,n,r,!0),null}function qu(e,n){var t=n.ref;if(null===t)null!==e&&null!==e.ref&&(n.flags|=4194816);else{if("function"!=typeof t&&"object"!=typeof t)throw Error(c(284));null!==e&&e.ref===t||(n.flags|=4194816)}}function Ku(e,n,t,r,l){return Ol(n),t=Ha(e,n,t,r,void 0,l),r=Wa(),null===e||ju?(hl&&r&&fl(n),n.flags|=1,Vu(e,n,t,l),n.child):(qa(e,n,l),ii(e,n,l))}function Yu(e,n,t,r,l,a){return Ol(n),n.updateQueue=null,t=$a(n,r,t,l),Qa(e),r=Wa(),null===e||ju?(hl&&r&&fl(n),n.flags|=1,Vu(e,n,t,a),n.child):(qa(e,n,a),ii(e,n,a))}function Xu(e,n,t,r,l){if(Ol(n),null===n.stateNode){var a=$r,o=t.contextType;"object"==typeof o&&null!==o&&(a=Al(o)),a=new t(r,a),n.memoizedState=null!==a.state&&void 0!==a.state?a.state:null,a.updater=Cu,n.stateNode=a,a._reactInternals=n,(a=n.stateNode).props=r,a.state=n.memoizedState,a.refs={},pa(n),o=t.contextType,a.context="object"==typeof o&&null!==o?Al(o):$r,a.state=n.memoizedState,"function"==typeof(o=t.getDerivedStateFromProps)&&(xu(n,t,o,r),a.state=n.memoizedState),"function"==typeof t.getDerivedStateFromProps||"function"==typeof a.getSnapshotBeforeUpdate||"function"!=typeof a.UNSAFE_componentWillMount&&"function"!=typeof a.componentWillMount||(o=a.state,"function"==typeof a.componentWillMount&&a.componentWillMount(),"function"==typeof a.UNSAFE_componentWillMount&&a.UNSAFE_componentWillMount(),o!==a.state&&Cu.enqueueReplaceState(a,a.state,null),wa(n,r,a,l),ka(),a.state=n.memoizedState),"function"==typeof a.componentDidMount&&(n.flags|=4194308),r=!0}else if(null===e){a=n.stateNode;var u=n.memoizedProps,i=Nu(t,u);a.props=i;var s=a.context,c=t.contextType;o=$r,"object"==typeof c&&null!==c&&(o=Al(c));var f=t.getDerivedStateFromProps;c="function"==typeof f||"function"==typeof a.getSnapshotBeforeUpdate,u=n.pendingProps!==u,c||"function"!=typeof a.UNSAFE_componentWillReceiveProps&&"function"!=typeof a.componentWillReceiveProps||(u||s!==o)&&Pu(n,a,r,o),da=!1;var d=n.memoizedState;a.state=d,wa(n,r,a,l),ka(),s=n.memoizedState,u||d!==s||da?("function"==typeof f&&(xu(n,t,f,r),s=n.memoizedState),(i=da||zu(n,t,i,r,d,s,o))?(c||"function"!=typeof a.UNSAFE_componentWillMount&&"function"!=typeof a.componentWillMount||("function"==typeof a.componentWillMount&&a.componentWillMount(),"function"==typeof a.UNSAFE_componentWillMount&&a.UNSAFE_componentWillMount()),"function"==typeof a.componentDidMount&&(n.flags|=4194308)):("function"==typeof a.componentDidMount&&(n.flags|=4194308),n.memoizedProps=r,n.memoizedState=s),a.props=r,a.state=s,a.context=o,r=i):("function"==typeof a.componentDidMount&&(n.flags|=4194308),r=!1)}else{a=n.stateNode,ma(e,n),c=Nu(t,o=n.memoizedProps),a.props=c,f=n.pendingProps,d=a.context,s=t.contextType,i=$r,"object"==typeof s&&null!==s&&(i=Al(s)),(s="function"==typeof(u=t.getDerivedStateFromProps)||"function"==typeof a.getSnapshotBeforeUpdate)||"function"!=typeof a.UNSAFE_componentWillReceiveProps&&"function"!=typeof a.componentWillReceiveProps||(o!==f||d!==i)&&Pu(n,a,r,i),da=!1,d=n.memoizedState,a.state=d,wa(n,r,a,l),ka();var p=n.memoizedState;o!==f||d!==p||da||null!==e&&null!==e.dependencies&&Ml(e.dependencies)?("function"==typeof u&&(xu(n,t,u,r),p=n.memoizedState),(c=da||zu(n,t,c,r,d,p,i)||null!==e&&null!==e.dependencies&&Ml(e.dependencies))?(s||"function"!=typeof a.UNSAFE_componentWillUpdate&&"function"!=typeof a.componentWillUpdate||("function"==typeof a.componentWillUpdate&&a.componentWillUpdate(r,p,i),"function"==typeof a.UNSAFE_componentWillUpdate&&a.UNSAFE_componentWillUpdate(r,p,i)),"function"==typeof a.componentDidUpdate&&(n.flags|=4),"function"==typeof a.getSnapshotBeforeUpdate&&(n.flags|=1024)):("function"!=typeof a.componentDidUpdate||o===e.memoizedProps&&d===e.memoizedState||(n.flags|=4),"function"!=typeof a.getSnapshotBeforeUpdate||o===e.memoizedProps&&d===e.memoizedState||(n.flags|=1024),n.memoizedProps=r,n.memoizedState=p),a.props=r,a.state=p,a.context=i,r=c):("function"!=typeof a.componentDidUpdate||o===e.memoizedProps&&d===e.memoizedState||(n.flags|=4),"function"!=typeof a.getSnapshotBeforeUpdate||o===e.memoizedProps&&d===e.memoizedState||(n.flags|=1024),r=!1)}return a=r,qu(e,n),r=!!(128&n.flags),a||r?(a=n.stateNode,t=r&&"function"!=typeof t.getDerivedStateFromError?null:a.render(),n.flags|=1,null!==e&&r?(n.child=mu(n,e.child,null,l),n.child=mu(n,null,t,l)):Vu(e,n,t,l),n.memoizedState=a.state,e=n.child):e=ii(e,n,l),e}function Gu(e,n,t,r){return El(),n.flags|=256,Vu(e,n,t,r),n.child}var Zu={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Ju(e){return{baseLanes:e,cachePool:na()}}function ei(e,n,t){return e=null!==e?e.childLanes&~t:0,n&&(e|=Fs),e}function ni(e,n,t){var r,l=n.pendingProps,a=!1,o=!!(128&n.flags);if((r=o)||(r=(null===e||null!==e.memoizedState)&&!!(2&Su.current)),r&&(a=!0,n.flags&=-129),r=!!(32&n.flags),n.flags&=-33,null===e){if(hl){if(a?yu(n):ku(),hl){var u,i=ml;if(u=i){e:{for(u=i,i=vl;8!==u.nodeType;){if(!i){i=null;break e}if(null===(u=Vf(u.nextSibling))){i=null;break e}}i=u}null!==i?(n.memoizedState={dehydrated:i,treeContext:null!==ol?{id:ul,overflow:il}:null,retryLane:536870912,hydrationErrors:null},(u=Wr(18,null,null,0)).stateNode=i,u.return=n,n.child=u,pl=n,ml=null,u=!0):u=!1}u||bl(n)}if(null!==(i=n.memoizedState)&&null!==(i=i.dehydrated))return Uf(i)?n.lanes=32:n.lanes=536870912,null;wu(n)}return i=l.children,l=l.fallback,a?(ku(),i=ri({mode:"hidden",children:i},a=n.mode),l=Gr(l,a,t,null),i.return=n,l.return=n,i.sibling=l,n.child=i,(a=n.child).memoizedState=Ju(t),a.childLanes=ei(e,r,t),n.memoizedState=Zu,l):(yu(n),ti(n,i))}if(null!==(u=e.memoizedState)&&null!==(i=u.dehydrated)){if(o)256&n.flags?(yu(n),n.flags&=-257,n=li(e,n,t)):null!==n.memoizedState?(ku(),n.child=e.child,n.flags|=128,n=null):(ku(),a=l.fallback,i=n.mode,l=ri({mode:"visible",children:l.children},i),(a=Gr(a,i,t,null)).flags|=2,l.return=n,a.return=n,l.sibling=a,n.child=l,mu(n,e.child,null,t),(l=n.child).memoizedState=Ju(t),l.childLanes=ei(e,r,t),n.memoizedState=Zu,n=a);else if(yu(n),Uf(i)){if(r=i.nextSibling&&i.nextSibling.dataset)var s=r.dgst;r=s,(l=Error(c(419))).stack="",l.digest=r,Cl({value:l,source:null,stack:null}),n=li(e,n,t)}else if(ju||Dl(e,n,t,!1),r=0!==(t&e.childLanes),ju||r){if(null!==(r=bs)&&(0!==(l=0!==((l=42&(l=t&-t)?1:Me(l))&(r.suspendedLanes|t))?0:l)&&l!==u.retryLane))throw u.retryLane=l,Vr(e,l),Zs(r,e,l),Uu;"$?"===i.data||sc(),n=li(e,n,t)}else"$?"===i.data?(n.flags|=192,n.child=e.child,n=null):(e=u.treeContext,ml=Vf(i.nextSibling),pl=n,hl=!0,gl=null,vl=!1,null!==e&&(ll[al++]=ul,ll[al++]=il,ll[al++]=ol,ul=e.id,il=e.overflow,ol=n),(n=ti(n,l.children)).flags|=4096);return n}return a?(ku(),a=l.fallback,i=n.mode,s=(u=e.child).sibling,(l=Kr(u,{mode:"hidden",children:l.children})).subtreeFlags=65011712&u.subtreeFlags,null!==s?a=Kr(s,a):(a=Gr(a,i,t,null)).flags|=2,a.return=n,l.return=n,l.sibling=a,n.child=l,l=a,a=n.child,null===(i=e.child.memoizedState)?i=Ju(t):(null!==(u=i.cachePool)?(s=Hl._currentValue,u=u.parent!==s?{parent:s,pool:s}:u):u=na(),i={baseLanes:i.baseLanes|t,cachePool:u}),a.memoizedState=i,a.childLanes=ei(e,r,t),n.memoizedState=Zu,l):(yu(n),e=(t=e.child).sibling,(t=Kr(t,{mode:"visible",children:l.children})).return=n,t.sibling=null,null!==e&&(null===(r=n.deletions)?(n.deletions=[e],n.flags|=16):r.push(e)),n.child=t,n.memoizedState=null,t)}function ti(e,n){return(n=ri({mode:"visible",children:n},e.mode)).return=e,e.child=n}function ri(e,n){return(e=Wr(22,e,null,n)).lanes=0,e.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},e}function li(e,n,t){return mu(n,e.child,null,t),(e=ti(n,n.pendingProps.children)).flags|=2,n.memoizedState=null,e}function ai(e,n,t){e.lanes|=n;var r=e.alternate;null!==r&&(r.lanes|=n),_l(e.return,n,t)}function oi(e,n,t,r,l){var a=e.memoizedState;null===a?e.memoizedState={isBackwards:n,rendering:null,renderingStartTime:0,last:r,tail:t,tailMode:l}:(a.isBackwards=n,a.rendering=null,a.renderingStartTime=0,a.last=r,a.tail=t,a.tailMode=l)}function ui(e,n,t){var r=n.pendingProps,l=r.revealOrder,a=r.tail;if(Vu(e,n,r.children,t),2&(r=Su.current))r=1&r|2,n.flags|=128;else{if(null!==e&&128&e.flags)e:for(e=n.child;null!==e;){if(13===e.tag)null!==e.memoizedState&&ai(e,t,n);else if(19===e.tag)ai(e,t,n);else if(null!==e.child){e.child.return=e,e=e.child;continue}if(e===n)break e;for(;null===e.sibling;){if(null===e.return||e.return===n)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}r&=1}switch(W(Su,r),l){case"forwards":for(t=n.child,l=null;null!==t;)null!==(e=t.alternate)&&null===Eu(e)&&(l=t),t=t.sibling;null===(t=l)?(l=n.child,n.child=null):(l=t.sibling,t.sibling=null),oi(n,!1,l,t,a);break;case"backwards":for(t=null,l=n.child,n.child=null;null!==l;){if(null!==(e=l.alternate)&&null===Eu(e)){n.child=l;break}e=l.sibling,l.sibling=t,t=l,l=e}oi(n,!0,t,null,a);break;case"together":oi(n,!1,null,null,void 0);break;default:n.memoizedState=null}return n.child}function ii(e,n,t){if(null!==e&&(n.dependencies=e.dependencies),Ls|=n.lanes,0===(t&n.childLanes)){if(null===e)return null;if(Dl(e,n,t,!1),0===(t&n.childLanes))return null}if(null!==e&&n.child!==e.child)throw Error(c(153));if(null!==n.child){for(t=Kr(e=n.child,e.pendingProps),n.child=t,t.return=n;null!==e.sibling;)e=e.sibling,(t=t.sibling=Kr(e,e.pendingProps)).return=n;t.sibling=null}return n.child}function si(e,n){return 0!==(e.lanes&n)||!(null===(e=e.dependencies)||!Ml(e))}function ci(e,n,t){switch(n.tag){case 3:G(n,n.stateNode.containerInfo),Ll(0,Hl,e.memoizedState.cache),El();break;case 27:case 5:J(n);break;case 4:G(n,n.stateNode.containerInfo);break;case 10:Ll(0,n.type,n.memoizedProps.value);break;case 13:var r=n.memoizedState;if(null!==r)return null!==r.dehydrated?(yu(n),n.flags|=128,null):0!==(t&n.child.childLanes)?ni(e,n,t):(yu(n),null!==(e=ii(e,n,t))?e.sibling:null);yu(n);break;case 19:var l=!!(128&e.flags);if((r=0!==(t&n.childLanes))||(Dl(e,n,t,!1),r=0!==(t&n.childLanes)),l){if(r)return ui(e,n,t);n.flags|=128}if(null!==(l=n.memoizedState)&&(l.rendering=null,l.tail=null,l.lastEffect=null),W(Su,Su.current),r)break;return null;case 22:case 23:return n.lanes=0,Bu(e,n,t);case 24:Ll(0,Hl,e.memoizedState.cache)}return ii(e,n,t)}function fi(e,n,t){if(null!==e)if(e.memoizedProps!==n.pendingProps)ju=!0;else{if(!(si(e,t)||128&n.flags))return ju=!1,ci(e,n,t);ju=!!(131072&e.flags)}else ju=!1,hl&&1048576&n.flags&&cl(n,rl,n.index);switch(n.lanes=0,n.tag){case 16:e:{e=n.pendingProps;var r=n.elementType,l=r._init;if(r=l(r._payload),n.type=r,"function"!=typeof r){if(null!=r){if((l=r.$$typeof)===P){n.tag=11,n=Hu(null,n,r,e,t);break e}if(l===T){n.tag=14,n=Qu(null,n,r,e,t);break e}}throw n=R(r)||r,Error(c(306,n,""))}qr(r)?(e=Nu(r,e),n.tag=1,n=Xu(null,n,r,e,t)):(n.tag=0,n=Ku(null,n,r,e,t))}return n;case 0:return Ku(e,n,n.type,n.pendingProps,t);case 1:return Xu(e,n,r=n.type,l=Nu(r,n.pendingProps),t);case 3:e:{if(G(n,n.stateNode.containerInfo),null===e)throw Error(c(387));r=n.pendingProps;var a=n.memoizedState;l=a.element,ma(e,n),wa(n,r,null,t);var o=n.memoizedState;if(r=o.cache,Ll(0,Hl,r),r!==a.cache&&Fl(n,[Hl],t,!0),ka(),r=o.element,a.isDehydrated){if(a={element:r,isDehydrated:!1,cache:o.cache},n.updateQueue.baseState=a,n.memoizedState=a,256&n.flags){n=Gu(e,n,r,t);break e}if(r!==l){Cl(l=Mr(Error(c(424)),n)),n=Gu(e,n,r,t);break e}if(9===(e=n.stateNode.containerInfo).nodeType)e=e.body;else e="HTML"===e.nodeName?e.ownerDocument.body:e;for(ml=Vf(e.firstChild),pl=n,hl=!0,gl=null,vl=!0,t=hu(n,null,r,t),n.child=t;t;)t.flags=-3&t.flags|4096,t=t.sibling}else{if(El(),r===l){n=ii(e,n,t);break e}Vu(e,n,r,t)}n=n.child}return n;case 26:return qu(e,n),null===e?(t=Zf(n.type,null,n.pendingProps,null))?n.memoizedState=t:hl||(t=n.type,e=n.pendingProps,(r=Ef(Y.current).createElement(t))[Ue]=n,r[je]=e,bf(r,t,e),Ze(r),n.stateNode=r):n.memoizedState=Zf(n.type,e.memoizedProps,n.pendingProps,e.memoizedState),null;case 27:return J(n),null===e&&hl&&(r=n.stateNode=$f(n.type,n.pendingProps,Y.current),pl=n,vl=!0,l=ml,Mf(n.type)?(Hf=l,ml=Vf(r.firstChild)):ml=l),Vu(e,n,n.pendingProps.children,t),qu(e,n),null===e&&(n.flags|=4194304),n.child;case 5:return null===e&&hl&&((l=r=ml)&&(null!==(r=Rf(r,n.type,n.pendingProps,vl))?(n.stateNode=r,pl=n,ml=Vf(r.firstChild),vl=!1,l=!0):l=!1),l||bl(n)),J(n),l=n.type,a=n.pendingProps,o=null!==e?e.memoizedProps:null,r=a.children,zf(l,a)?r=null:null!==o&&zf(l,o)&&(n.flags|=32),null!==n.memoizedState&&(l=Ha(e,n,Ba,null,null,t),wd._currentValue=l),qu(e,n),Vu(e,n,r,t),n.child;case 6:return null===e&&hl&&((e=t=ml)&&(null!==(t=If(t,n.pendingProps,vl))?(n.stateNode=t,pl=n,ml=null,e=!0):e=!1),e||bl(n)),null;case 13:return ni(e,n,t);case 4:return G(n,n.stateNode.containerInfo),r=n.pendingProps,null===e?n.child=mu(n,null,r,t):Vu(e,n,r,t),n.child;case 11:return Hu(e,n,n.type,n.pendingProps,t);case 7:return Vu(e,n,n.pendingProps,t),n.child;case 8:case 12:return Vu(e,n,n.pendingProps.children,t),n.child;case 10:return r=n.pendingProps,Ll(0,n.type,r.value),Vu(e,n,r.children,t),n.child;case 9:return l=n.type._context,r=n.pendingProps.children,Ol(n),r=r(l=Al(l)),n.flags|=1,Vu(e,n,r,t),n.child;case 14:return Qu(e,n,n.type,n.pendingProps,t);case 15:return $u(e,n,n.type,n.pendingProps,t);case 19:return ui(e,n,t);case 31:return r=n.pendingProps,t=n.mode,r={mode:r.mode,children:r.children},null===e?((t=ri(r,t)).ref=n.ref,n.child=t,t.return=n,n=t):((t=Kr(e.child,r)).ref=n.ref,n.child=t,t.return=n,n=t),n;case 22:return Bu(e,n,t);case 24:return Ol(n),r=Al(Hl),null===e?(null===(l=Jl())&&(l=bs,a=Ql(),l.pooledCache=a,a.refCount++,null!==a&&(l.pooledCacheLanes|=t),l=a),n.memoizedState={parent:r,cache:l},pa(n),Ll(0,Hl,l)):(0!==(e.lanes&t)&&(ma(e,n),wa(n,null,null,t),ka()),l=e.memoizedState,a=n.memoizedState,l.parent!==r?(l={parent:r,cache:r},n.memoizedState=l,0===n.lanes&&(n.memoizedState=n.updateQueue.baseState=l),Ll(0,Hl,r)):(r=a.cache,Ll(0,Hl,r),r!==l.cache&&Fl(n,[Hl],t,!0))),Vu(e,n,n.pendingProps.children,t),n.child;case 29:throw n.pendingProps}throw Error(c(156,n.tag))}function di(e){e.flags|=4}function pi(e,n){if("stylesheet"!==n.type||4&n.state.loading)e.flags&=-16777217;else if(e.flags|=16777216,!dd(n)){if(null!==(n=gu.current)&&((4194048&ws)===ws?null!==vu:(62914560&ws)!==ws&&!(536870912&ws)||n!==vu))throw sa=aa,ra;e.flags|=8192}}function mi(e,n){null!==n&&(e.flags|=4),16384&e.flags&&(n=22!==e.tag?Ne():536870912,e.lanes|=n,Ds|=n)}function hi(e,n){if(!hl)switch(e.tailMode){case"hidden":n=e.tail;for(var t=null;null!==n;)null!==n.alternate&&(t=n),n=n.sibling;null===t?e.tail=null:t.sibling=null;break;case"collapsed":t=e.tail;for(var r=null;null!==t;)null!==t.alternate&&(r=t),t=t.sibling;null===r?n||null===e.tail?e.tail=null:e.tail.sibling=null:r.sibling=null}}function gi(e){var n=null!==e.alternate&&e.alternate.child===e.child,t=0,r=0;if(n)for(var l=e.child;null!==l;)t|=l.lanes|l.childLanes,r|=65011712&l.subtreeFlags,r|=65011712&l.flags,l.return=e,l=l.sibling;else for(l=e.child;null!==l;)t|=l.lanes|l.childLanes,r|=l.subtreeFlags,r|=l.flags,l.return=e,l=l.sibling;return e.subtreeFlags|=r,e.childLanes=t,n}function vi(e,n,t){var r=n.pendingProps;switch(dl(n),n.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:case 1:return gi(n),null;case 3:return t=n.stateNode,r=null,null!==e&&(r=e.memoizedState.cache),n.memoizedState.cache!==r&&(n.flags|=2048),Tl(Hl),Z(),t.pendingContext&&(t.context=t.pendingContext,t.pendingContext=null),null!==e&&null!==e.child||(Sl(n)?di(n):null===e||e.memoizedState.isDehydrated&&!(256&n.flags)||(n.flags|=1024,xl())),gi(n),null;case 26:return t=n.memoizedState,null===e?(di(n),null!==t?(gi(n),pi(n,t)):(gi(n),n.flags&=-16777217)):t?t!==e.memoizedState?(di(n),gi(n),pi(n,t)):(gi(n),n.flags&=-16777217):(e.memoizedProps!==r&&di(n),gi(n),n.flags&=-16777217),null;case 27:ee(n),t=Y.current;var l=n.type;if(null!==e&&null!=n.stateNode)e.memoizedProps!==r&&di(n);else{if(!r){if(null===n.stateNode)throw Error(c(166));return gi(n),null}e=q.current,Sl(n)?kl(n):(e=$f(l,r,t),n.stateNode=e,di(n))}return gi(n),null;case 5:if(ee(n),t=n.type,null!==e&&null!=n.stateNode)e.memoizedProps!==r&&di(n);else{if(!r){if(null===n.stateNode)throw Error(c(166));return gi(n),null}if(e=q.current,Sl(n))kl(n);else{switch(l=Ef(Y.current),e){case 1:e=l.createElementNS("http://www.w3.org/2000/svg",t);break;case 2:e=l.createElementNS("http://www.w3.org/1998/Math/MathML",t);break;default:switch(t){case"svg":e=l.createElementNS("http://www.w3.org/2000/svg",t);break;case"math":e=l.createElementNS("http://www.w3.org/1998/Math/MathML",t);break;case"script":(e=l.createElement("div")).innerHTML=" + + diff --git a/apps/app_order_box/dist/metadata.json b/apps/app_order_box/dist/metadata.json new file mode 100644 index 0000000..8a878a6 --- /dev/null +++ b/apps/app_order_box/dist/metadata.json @@ -0,0 +1 @@ +{"version":0,"bundler":"metro","fileMetadata":{}} \ No newline at end of file diff --git a/apps/app_order_box/kits/motion_reminder/api.ts b/apps/app_order_box/kits/motion_reminder/api.ts index c721c55..4da44f2 100644 --- a/apps/app_order_box/kits/motion_reminder/api.ts +++ b/apps/app_order_box/kits/motion_reminder/api.ts @@ -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, diff --git a/apps/app_order_box/package-lock.json b/apps/app_order_box/package-lock.json index bc70db3..20bdd60 100644 --- a/apps/app_order_box/package-lock.json +++ b/apps/app_order_box/package-lock.json @@ -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", diff --git a/apps/app_order_box/package.json b/apps/app_order_box/package.json index 27fde15..b7f3ccd 100644 --- a/apps/app_order_box/package.json +++ b/apps/app_order_box/package.json @@ -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" }, diff --git a/apps/app_zen_box/api.ts b/apps/app_zen_box/api.ts index 78efe4f..234d4ee 100644 --- a/apps/app_zen_box/api.ts +++ b/apps/app_zen_box/api.ts @@ -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 => { diff --git a/dev_test_scripts/debug/debug_private_clipboard_text.js b/dev_test_scripts/debug/debug_private_clipboard_text.js new file mode 100644 index 0000000..32fab85 --- /dev/null +++ b/dev_test_scripts/debug/debug_private_clipboard_text.js @@ -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: '' }) + console.log(`Status: ${r3.status}, Body: ${r3.body}`) + + // 4. 特殊字符 + console.log('\n--- Test 4: 特殊字符 & < > ---') + const r4 = await post('/text', { text: 'Tom & Jerry "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) diff --git a/dev_test_scripts/debug/quick_test_clipboard.js b/dev_test_scripts/debug/quick_test_clipboard.js new file mode 100644 index 0000000..9e8780f --- /dev/null +++ b/dev_test_scripts/debug/quick_test_clipboard.js @@ -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: '' }) + if (r2.status === 200) { console.log('✅ XSS 写入成功'); pass++ } + else { console.log(`❌ XSS 写入失败: ${r2.body}`); fail++ } + + // 3. 特殊字符 + const r3 = await post('/text', { text: 'Tom & Jerry "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('<')) + 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('&') && i.text_content.includes('<')) + if (sp) { console.log('✅ 特殊字符转义正确'); pass++ } + else { console.log('❌ 特殊字符转义错误'); fail++ } + + console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`) + resolve() + }) + }) + }) +} + +run() diff --git a/dev_test_scripts/debug/test_rate_limit.js b/dev_test_scripts/debug/test_rate_limit.js new file mode 100644 index 0000000..9ca2a8c --- /dev/null +++ b/dev_test_scripts/debug/test_rate_limit.js @@ -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() diff --git a/dev_test_scripts/integration/test_email_smtp.js b/dev_test_scripts/integration/test_email_smtp.js new file mode 100644 index 0000000..5515153 --- /dev/null +++ b/dev_test_scripts/integration/test_email_smtp.js @@ -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) +}) diff --git a/dev_test_scripts/integration/test_private_clipboard_api.js b/dev_test_scripts/integration/test_private_clipboard_api.js new file mode 100644 index 0000000..e72df61 --- /dev/null +++ b/dev_test_scripts/integration/test_private_clipboard_api.js @@ -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 = '' + 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('<') && text.includes('>') && !text.includes(' + + diff --git a/public/tools/web_order_box/metadata.json b/public/tools/web_order_box/metadata.json new file mode 100644 index 0000000..244b42c --- /dev/null +++ b/public/tools/web_order_box/metadata.json @@ -0,0 +1 @@ +{"version":1,"type":"native_web","description":"框子 - 运动提醒宝 Web 版(原生 HTML/CSS/JS)"} \ No newline at end of file diff --git a/public/tools/web_order_box/order_box.css b/public/tools/web_order_box/order_box.css new file mode 100644 index 0000000..0255ab2 --- /dev/null +++ b/public/tools/web_order_box/order_box.css @@ -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); } diff --git a/public/tools/web_order_box/order_box.js b/public/tools/web_order_box/order_box.js new file mode 100644 index 0000000..c1b9d7d --- /dev/null +++ b/public/tools/web_order_box/order_box.js @@ -0,0 +1,1533 @@ +/* ===================================================================== + * 框子(OrderBox)- 运动提醒宝 Web 端核心业务逻辑 + * 原生 JS 实现,无框架、无构建工具 + * 模块分区: + * 主题常量 / 双模式控制 / 工具函数 / API 封装层 / Toast 系统 / + * 弹窗系统 / 交互追踪 / 按钮 Loading / 页面路由 / Hub 逻辑 / + * Tab 切换 / 卡片列表 / 卡片编辑器 / 执行器 / 语音播报 / + * 提示音 / 屏幕常亮 / 历史列表 / 初始化 + * ===================================================================== */ +(function () { + 'use strict'; + + // ==================== 主题常量 ==================== + const THEME = { + bg: '#080b12', surface: '#121826', surfaceSoft: '#1a2234', surfaceStrong: '#202b42', + primary: '#f0b35f', primarySoft: 'rgba(240,179,95,0.16)', + text: '#f6f7fb', textSecondary: '#9ba7bc', textDisabled: '#5a6578', + border: '#2a3449', success: '#4ade80', warning: '#fbbf24', error: '#ef4444' + }; + + // ==================== 双模式控制 ==================== + // debug:所有交互弹窗完整显示,3s 自动关闭 + // normal:查看类操作降级为 Toast,普通弹窗 2s 关闭 + const UI_MODE_KEY = 'order_box_ui_mode'; + + function getUiMode() { + try { + const mode = localStorage.getItem(UI_MODE_KEY); + if (mode === 'normal' || mode === 'debug') return mode; + } catch (e) { /* localStorage 不可用时按 debug 处理 */ } + return 'debug'; + } + + function setUiMode(mode) { + try { + localStorage.setItem(UI_MODE_KEY, mode === 'normal' ? 'normal' : 'debug'); + } catch (e) { /* 静默失败 */ } + } + + // ==================== 工具函数 ==================== + + /** mm:ss 格式,如 "01:30" */ + function formatDuration(seconds) { + const safe = Math.max(0, Math.floor(Number(seconds) || 0)); + const mm = Math.floor(safe / 60); + const ss = safe % 60; + return String(mm).padStart(2, '0') + ':' + String(ss).padStart(2, '0'); + } + + /** "N分N秒" 或 "N秒" */ + function formatDurationLong(seconds) { + const safe = Math.max(0, Math.floor(Number(seconds) || 0)); + const mm = Math.floor(safe / 60); + const ss = safe % 60; + if (mm > 0) return mm + '分' + ss + '秒'; + return ss + '秒'; + } + + /** 语音播报专用时长文案:"N分钟N秒" */ + function formatSpeechDuration(seconds) { + const safe = Math.max(0, Math.floor(Number(seconds) || 0)); + const mm = Math.floor(safe / 60); + const ss = safe % 60; + const parts = []; + if (mm > 0) parts.push(mm + '分钟'); + if (ss > 0) parts.push(ss + '秒'); + return parts.length ? parts.join('') : '0秒'; + } + + /** 北京时间格式 "yyyy/M/d HH:mm:ss"(项目规则:所有时间展示一律 Asia/Shanghai) */ + function formatDateTime(isoString) { + if (!isoString) return '—'; + const date = new Date(isoString); + if (Number.isNaN(date.getTime())) return String(isoString); + return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }); + } + + /** 循环模式文案 */ + function formatLoopText(loopMode, loopCount) { + if (loopMode === 'once') return '单次执行'; + if (loopMode === 'repeat_forever') return '无限循环'; + return '循环 ' + Math.max(1, Number(loopCount || 1)) + ' 次'; + } + + /** UUID 生成(crypto.randomUUID 优先,降级手写 v4) */ + function generateId() { + try { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return window.crypto.randomUUID(); + } + } catch (e) { /* 降级 */ } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } + + /** HTML 转义 */ + function escHtml(s) { + return String(s === null || s === undefined ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** JSON 安全序列化(超长截断),用于交互详情展示 */ + function safeJsonStringify(value) { + try { + let text = JSON.stringify(value, null, 2); + if (text === undefined) return String(value); + if (text.length > 4000) text = text.slice(0, 4000) + '\n...(内容过长已截断)'; + return text; + } catch (e) { + return String(value); + } + } + + // ==================== API 封装层 ==================== + const API_TIMEOUT_MS = 15000; + + function formatApiError(json, status, rawText) { + if (json && json.error) return String(json.error); + if (rawText) return 'HTTP ' + status + ':' + String(rawText).slice(0, 200); + return 'HTTP ' + status; + } + + /** + * 通用请求封装 + * 返回统一结构:{ ok, data, error, status, duration, url, method, requestBody, responseBody } + */ + async function apiFetch(method, path, body) { + const startedAt = Date.now(); + const result = { + ok: false, + data: null, + error: '', + status: 0, + duration: 0, + url: path, + method: method, + requestBody: body === undefined ? null : body, + responseBody: null + }; + const controller = new AbortController(); + const timeoutId = setTimeout(function () { controller.abort(); }, API_TIMEOUT_MS); + try { + const options = { method: method, headers: {}, signal: controller.signal }; + if (body !== undefined && body !== null) { + options.headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify(body); + } + const resp = await fetch(path, options); + result.status = resp.status; + const text = await resp.text(); + let json = null; + try { json = text ? JSON.parse(text) : null; } catch (e) { json = null; } + result.responseBody = json !== null ? json : (text || null); + if (resp.ok && json && json.ok !== false) { + result.ok = true; + result.data = (json && json.data !== undefined) ? json.data : json; + } else { + result.error = formatApiError(json, resp.status, text); + } + } catch (e) { + if (e && e.name === 'AbortError') { + result.error = '请求超时(' + API_TIMEOUT_MS / 1000 + ' 秒)'; + } else { + result.error = '网络请求失败:' + String((e && e.message) || e); + } + } finally { + clearTimeout(timeoutId); + result.duration = Date.now() - startedAt; + } + return result; + } + + // ==================== Toast 系统 ==================== + // info=蓝 / success=绿 / error=红,默认 2s 自动消失,不可点击关闭 + let toastTimer = null; + + function showToast(msg, type, duration) { + const el = document.getElementById('toast'); + if (!el) return; + const toastType = (type === 'success' || type === 'error') ? type : 'info'; + el.textContent = msg; + el.className = 'toast toast-show toast-' + toastType; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(function () { + el.className = 'toast'; + }, Number(duration) > 0 ? Number(duration) : 2000); + } + + // ==================== 弹窗系统 ==================== + + /** 通用弹窗。actions: [{ text, style: 'default'|'primary'|'danger', closeOnClick, onPress(btn) }] */ + function showDialog(title, bodyHtml, actions) { + document.getElementById('dialogTitle').textContent = title; + document.getElementById('dialogBody').innerHTML = bodyHtml; + const actionsEl = document.getElementById('dialogActions'); + actionsEl.innerHTML = ''; + const list = (Array.isArray(actions) && actions.length) ? actions : [{ text: '知道了', style: 'primary' }]; + list.forEach(function (action) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'dialog-btn' + (action.style ? ' dialog-btn-' + action.style : ''); + btn.textContent = action.text || '确定'; + btn.addEventListener('click', function () { + if (action.closeOnClick !== false) closeDialog(); + if (typeof action.onPress === 'function') action.onPress(btn); + }); + actionsEl.appendChild(btn); + }); + document.getElementById('dialogOverlay').style.display = 'flex'; + } + + function closeDialog() { + document.getElementById('dialogOverlay').style.display = 'none'; + } + + /** 确认弹窗:双模式均保留,永不降级。onConfirm(btn) 需自行调用 closeDialog() */ + function showConfirm(title, bodyHtml, onConfirm) { + showDialog(title, bodyHtml, [ + { text: '取消', style: 'default' }, + { + text: '确认', + style: 'danger', + closeOnClick: false, + onPress: function (btn) { if (typeof onConfirm === 'function') onConfirm(btn); } + } + ]); + } + + function showAlert(title, bodyHtml) { + showDialog(title, bodyHtml, [{ text: '知道了', style: 'primary' }]); + } + + // ---- 交互详情弹窗(带复制按钮与自动关闭倒计时) ---- + let interactionCloseTimer = null; + let interactionCountdown = 0; + + function buildInteractionText(d) { + const reqText = (d.requestBody === null || d.requestBody === undefined) ? '—' : safeJsonStringify(d.requestBody); + let respText = '—'; + if (d.responseBody !== null && d.responseBody !== undefined) { + respText = typeof d.responseBody === 'string' ? d.responseBody : safeJsonStringify(d.responseBody); + } + return [ + '操作:' + d.label, + '时间:' + d.time + '(北京时间)', + '接口:' + d.method + ' ' + d.url, + 'HTTP 状态:' + (d.status || '—'), + '耗时:' + d.duration + ' ms', + '当前状态:' + (d.success ? '成功' : '失败'), + '', + '请求参数:', + reqText, + '', + '返回结果:', + respText, + '', + '错误信息:' + (d.error || '—') + ].join('\n'); + } + + function clearInteractionTimer() { + if (interactionCloseTimer) { + clearInterval(interactionCloseTimer); + interactionCloseTimer = null; + } + } + + function hideInteractionDialog() { + clearInteractionTimer(); + const overlay = document.getElementById('interactionOverlay'); + if (overlay) overlay.style.display = 'none'; + } + + /** + * 交互详情弹窗 + * autoCloseMs > 0:显示倒计时并自动关闭;为 0/null:停留(异常排障场景) + */ + function showInteractionDialog(detail, autoCloseMs) { + clearInteractionTimer(); + const box = document.getElementById('interactionBox'); + document.getElementById('interactionTitle').textContent = detail.success ? '交互详情' : '交互异常'; + document.getElementById('interactionContent').textContent = buildInteractionText(detail); + box.classList.toggle('dialog-error', !detail.success); + + const actionsEl = document.getElementById('interactionActions'); + actionsEl.innerHTML = ''; + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'dialog-btn dialog-btn-primary'; + closeBtn.textContent = '关闭'; + closeBtn.addEventListener('click', hideInteractionDialog); + actionsEl.appendChild(closeBtn); + + document.getElementById('interactionOverlay').style.display = 'flex'; + + if (Number(autoCloseMs) > 0) { + interactionCountdown = Math.ceil(Number(autoCloseMs) / 1000); + closeBtn.textContent = '关闭(' + interactionCountdown + 's)'; + interactionCloseTimer = setInterval(function () { + interactionCountdown -= 1; + if (interactionCountdown <= 0) { + hideInteractionDialog(); + } else { + closeBtn.textContent = '关闭(' + interactionCountdown + 's)'; + } + }, 1000); + } + } + + /** 复制交互日志到剪贴板 */ + async function handleCopyInteraction() { + const contentEl = document.getElementById('interactionContent'); + const text = contentEl ? (contentEl.textContent || '') : ''; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + showToast('已复制交互日志', 'success'); + } catch (e) { + showToast('复制失败', 'error'); + } + } + + // ==================== 交互追踪 ==================== + // 所有 API 调用统一经由此函数包装: + // debug 模式:成功 → 交互详情弹窗 3s 自动关闭 + // normal 模式:options.mode === 'view' → Toast;其余 → 弹窗 2s 关闭 + // 失败/异常:弹窗停留(中断自动关闭),便于排障 + // options.silent:高频后台同步(执行器逐步 sync)静默记录,不打扰前台 + let lastInteractionLog = null; + + async function runTrackedInteraction(label, fn, options) { + const opts = options || {}; + const startedAt = Date.now(); + let res = null; + let thrown = null; + try { + res = await fn(); + } catch (e) { + thrown = e; + } + const detail = { + label: label, + time: formatDateTime(new Date(startedAt).toISOString()), + method: (res && res.method) || '—', + url: (res && res.url) || '—', + requestBody: res ? res.requestBody : null, + responseBody: res ? res.responseBody : null, + duration: (res && typeof res.duration === 'number') ? res.duration : (Date.now() - startedAt), + status: (res && res.status) || 0, + error: thrown ? String((thrown && thrown.message) || thrown) : (res && res.ok ? '' : ((res && res.error) || '未知错误')), + success: !thrown && !!(res && res.ok) + }; + lastInteractionLog = detail; + + if (opts.silent) { + // 高频后台同步:仅记录日志,不弹窗、不 Toast + console.log('[interaction:silent]', detail); + } else if (!detail.success) { + showInteractionDialog(detail, 0); // 异常:停留弹窗,中断自动关闭 + } else if (getUiMode() === 'debug') { + showInteractionDialog(detail, 3000); + } else if (opts.mode === 'view') { + showToast(label + '成功', 'success', 2000); + } else { + showInteractionDialog(detail, 2000); + } + + if (thrown) { + return { + ok: false, data: null, error: detail.error, status: 0, + duration: detail.duration, url: detail.url, method: detail.method, + requestBody: null, responseBody: null + }; + } + return res; + } + + // ==================== 按钮 Loading ==================== + // 异步操作期间:disabled + 文字切换(如"保存中…"),完成后恢复 + + function getButtonTextEl(btn) { + if (!btn) return null; + return btn.querySelector('.btn-text') || btn.querySelector('span') || btn; + } + + function setButtonLoading(btn, loadingText) { + if (!btn) return; + const textEl = getButtonTextEl(btn); + if (textEl.dataset.originalText === undefined) { + textEl.dataset.originalText = textEl.textContent; + } + btn.disabled = true; + btn.classList.add('btn-loading'); + if (loadingText) textEl.textContent = loadingText; + } + + function resetButton(btn, originalText) { + if (!btn) return; + const textEl = getButtonTextEl(btn); + btn.disabled = false; + btn.classList.remove('btn-loading'); + const restore = (originalText !== undefined) ? originalText : textEl.dataset.originalText; + if (restore !== undefined) textEl.textContent = restore; + delete textEl.dataset.originalText; + } + + // ==================== 页面路由 ==================== + let currentView = 'hub'; // 'hub' | 'motion' + let currentTab = 'cards'; // 'cards' | 'history' + + function showView(viewName) { + currentView = viewName; + document.getElementById('viewHub').style.display = (viewName === 'hub') ? '' : 'none'; + document.getElementById('viewMotion').style.display = (viewName === 'motion') ? '' : 'none'; + } + + function switchTab(tabName) { + currentTab = tabName; + document.querySelectorAll('.tab-item').forEach(function (el) { + el.classList.toggle('active', el.dataset.tab === tabName); + }); + document.getElementById('pageCards').style.display = (tabName === 'cards') ? '' : 'none'; + document.getElementById('pageHistory').style.display = (tabName === 'history') ? '' : 'none'; + if (tabName === 'cards') { + loadCards(); + } else { + loadHistory(); + } + } + + // ==================== Hub 逻辑 ==================== + let appMeta = null; + let appState = null; + let cardsCache = []; + + /** 静默刷新服务端全局状态(activeKit / runningSession) */ + async function refreshState(silent) { + const res = await runTrackedInteraction('刷新全局状态', function () { + return apiFetch('GET', '/api/app_order_box/state'); + }, { mode: 'view', silent: silent !== false }); + if (res && res.ok) appState = res.data; + return appState; + } + + async function initHub() { + const metaRes = await runTrackedInteraction('获取应用元信息', function () { + return apiFetch('GET', '/api/app_order_box/meta'); + }, { mode: 'view' }); + if (!metaRes.ok) { + showAlert('初始化失败', escHtml(metaRes.error || '无法获取应用元信息,请刷新重试')); + return; + } + appMeta = metaRes.data; + + const stateRes = await runTrackedInteraction('获取全局状态', function () { + return apiFetch('GET', '/api/app_order_box/state'); + }, { mode: 'view' }); + if (!stateRes.ok) { + showAlert('初始化失败', escHtml(stateRes.error || '无法获取全局状态,请刷新重试')); + return; + } + appState = stateRes.data; + + const enterBtn = document.getElementById('btnEnterMotion'); + enterBtn.disabled = false; + + // activeKit 已指向运动提醒宝:跳过 Hub 直接进入 + if (appState && appState.activeKit === 'motion_reminder') { + showView('motion'); + switchTab('cards'); + } + } + + async function handleEnterMotion() { + const btn = document.getElementById('btnEnterMotion'); + setButtonLoading(btn, '进入中…'); + const res = await runTrackedInteraction('设置激活 kit(motion_reminder)', function () { + return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: 'motion_reminder' }); + }); + if (!res.ok) { + resetButton(btn); + showAlert('进入失败', escHtml(res.error || '请稍后重试')); + return; + } + resetButton(btn); + await refreshState(true); + showView('motion'); + switchTab('cards'); + } + + async function handleBackHub() { + await runTrackedInteraction('返回框子(清除激活 kit)', function () { + return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: '' }); + }, { mode: 'view' }); + showView('hub'); + await refreshState(true); + } + + // ==================== Tab 切换 ==================== + // (switchTab 已在"页面路由"分区实现,此处负责静态事件绑定的入口见 init) + + // ==================== 卡片列表 ==================== + + function updateStaleWarning() { + const el = document.getElementById('staleWarning'); + if (!el) return; + const show = !!(appState && appState.runningSession && !runnerState); + el.style.display = show ? '' : 'none'; + } + + function renderCards() { + const listEl = document.getElementById('cardsList'); + const emptyEl = document.getElementById('cardsEmpty'); + if (!cardsCache.length) { + listEl.innerHTML = ''; + listEl.style.display = 'none'; + emptyEl.style.display = ''; + return; + } + emptyEl.style.display = 'none'; + listEl.style.display = ''; + listEl.innerHTML = cardsCache.map(function (card) { + const steps = Array.isArray(card.steps) ? card.steps : []; + const previews = steps.slice(0, 4).map(function (s, i) { + return '
' + (i + 1) + '. ' + escHtml(s.name) + ' · ' + formatDuration(Number(s.durationSec) || 0) + '
'; + }).join(''); + const more = steps.length > 4 + ? '
还有 ' + (steps.length - 4) + ' 个步骤…
' + : ''; + const perRound = steps.reduce(function (sum, s) { return sum + Math.max(1, Number(s.durationSec) || 0); }, 0); + return '' + + '
' + + '
' + + '
' + escHtml(card.title) + '
' + + '
' + escHtml(formatLoopText(card.loopMode, card.loopCount)) + '
' + + '
' + + '
' + steps.length + ' 个步骤 · 单轮 ' + formatDurationLong(perRound) + '
' + + '
' + previews + more + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
'; + }).join(''); + } + + async function loadCards() { + const listEl = document.getElementById('cardsList'); + const emptyEl = document.getElementById('cardsEmpty'); + const loadingEl = document.getElementById('cardsLoading'); + loadingEl.style.display = ''; + listEl.style.display = 'none'; + listEl.innerHTML = ''; + emptyEl.style.display = 'none'; + + const res = await runTrackedInteraction('加载卡片列表', function () { + return apiFetch('GET', '/api/app_order_box/kits/motion_reminder/cards'); + }, { mode: 'view' }); + + loadingEl.style.display = 'none'; + if (!res.ok) { + showAlert('加载失败', escHtml(res.error || '卡片列表加载失败')); + return; + } + cardsCache = Array.isArray(res.data) ? res.data : []; + renderCards(); + updateStaleWarning(); + } + + // ---- 卡片操作(事件委托) ---- + + function handleCardsListClick(e) { + const btn = e.target.closest ? e.target.closest('button') : null; + if (!btn) return; + const item = btn.closest('.card-item'); + if (!item) return; + const card = cardsCache.find(function (c) { return c.id === item.dataset.id; }); + if (!card) return; + if (btn.classList.contains('btn-start-card')) handleStartCard(card, btn); + else if (btn.classList.contains('btn-edit-card')) openEditor(card); + else if (btn.classList.contains('btn-delete-card')) handleDeleteCard(card); + } + + function handleDeleteCard(card) { + showConfirm('确认删除', '确定删除「' + escHtml(card.title) + '」吗?
删除后不可恢复。', async function (confirmBtn) { + setButtonLoading(confirmBtn, '删除中…'); + const res = await runTrackedInteraction('删除卡片', function () { + return apiFetch('DELETE', '/api/app_order_box/kits/motion_reminder/cards?id=' + encodeURIComponent(card.id)); + }); + closeDialog(); + if (!res.ok) { + resetButton(confirmBtn); + return; // 失败详情已由交互弹窗停留展示 + } + await loadCards(); + }); + } + + async function handleStartCard(card, btn) { + if (!Array.isArray(card.steps) || !card.steps.length) { + showAlert('无法启动', '当前卡片没有可执行步骤'); + return; + } + if (appState && appState.runningSession) { + showAlert('无法启动', '服务端存在运行中的 session,请先清理占用或等待其结束。'); + updateStaleWarning(); + return; + } + setButtonLoading(btn, '启动中…'); + const res = await runTrackedInteraction('启动执行会话', function () { + return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/start', { card: card }); + }); + if (!res.ok) { + resetButton(btn); + if (res.status === 409) { + showToast('已有运行中的会话,请先清理占用', 'error'); + await refreshState(true); + updateStaleWarning(); + } + return; + } + resetButton(btn); + const session = res.data; + await refreshState(true); + updateStaleWarning(); + openRunner(session, (session && session.cardSnapshot) || card); + } + + async function handleCleanStale() { + const btn = document.getElementById('btnCleanStale'); + setButtonLoading(btn, '清理中…'); + const res = await runTrackedInteraction('清理残留 session', function () { + return apiFetch('POST', '/api/app_order_box/state/reset-running'); + }); + resetButton(btn); + if (!res.ok) return; + await refreshState(true); + updateStaleWarning(); + } + + // ==================== 卡片编辑器 ==================== + let editorOpen = false; + let editorDraft = null; + let editorDragIndex = null; + + function createDefaultStep(type) { + const isRest = type === 'rest'; + return { + id: generateId(), + type: isRest ? 'rest' : 'action', + name: isRest ? '休息' : '', + durationSec: isRest ? 30 : 60, + voiceText: '' + }; + } + + /** 默认草稿:1 个动作(60s) + 1 个休息(30s),循环 3 次 */ + function createDraftCard() { + return { + id: '', + title: '', + steps: [createDefaultStep('action'), createDefaultStep('rest')], + loopMode: 'repeat_count', + loopCount: 3 + }; + } + + function openEditor(card) { + if (card) { + editorDraft = { + id: card.id || '', + title: card.title || '', + steps: (Array.isArray(card.steps) ? card.steps : []).map(function (s) { + return { + id: s.id || generateId(), + type: s.type === 'rest' ? 'rest' : 'action', + name: s.name || '', + durationSec: Number(s.durationSec) || 0, + voiceText: s.voiceText || '' + }; + }), + loopMode: ['once', 'repeat_count', 'repeat_forever'].indexOf(card.loopMode) >= 0 ? card.loopMode : 'once', + loopCount: card.loopCount === null || card.loopCount === undefined ? 3 : Number(card.loopCount) + }; + if (!editorDraft.steps.length) editorDraft.steps = [createDefaultStep('action')]; + } else { + editorDraft = createDraftCard(); + } + document.getElementById('editorTitle').textContent = card ? '编辑流程卡片' : '新建流程卡片'; + renderEditor(); + document.getElementById('editorModal').style.display = 'flex'; + editorOpen = true; + } + + function closeEditor() { + document.getElementById('editorModal').style.display = 'none'; + editorOpen = false; + editorDraft = null; + editorDragIndex = null; + } + + function editorTotalDuration() { + return editorDraft.steps.reduce(function (sum, s) { return sum + Math.max(1, Number(s.durationSec) || 0); }, 0); + } + + function updateEditorStats() { + if (!editorDraft) return; + const total = editorTotalDuration(); + const previewEl = document.getElementById('editorTotalPreview'); + if (previewEl) { + previewEl.innerHTML = + '' + + '总时长:' + formatDurationLong(total) + ''; + } + const perRoundEl = document.getElementById('editorPerRound'); + if (perRoundEl) perRoundEl.textContent = formatDurationLong(total); + const globalEl = document.getElementById('editorGlobal'); + if (globalEl) { + if (editorDraft.loopMode === 'repeat_forever') { + globalEl.textContent = '无限循环(单轮 ' + formatDurationLong(total) + ')'; + } else if (editorDraft.loopMode === 'repeat_count') { + const loops = Math.max(1, Number(editorDraft.loopCount || 1)); + globalEl.textContent = formatDurationLong(total * loops); + } else { + globalEl.textContent = formatDurationLong(total); + } + } + } + + function renderStepEditor(step, index, total) { + const isAction = step.type !== 'rest'; + const canRemove = total > 1; + return '' + + '
' + + '
' + + '
' + + '' + (isAction ? '动作' : '休息') + '' + + '步骤 ' + (index + 1) + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
时长:' + formatDurationLong(Number(step.durationSec) || 0) + '
' + + '
'; + } + + function renderEditor() { + if (!editorDraft) return; + const d = editorDraft; + const body = document.getElementById('editorBody'); + const segments = [ + { label: '单次', value: 'once' }, + { label: 'N 次', value: 'repeat_count' }, + { label: '无限', value: 'repeat_forever' } + ]; + const stepsHtml = d.steps.map(function (step, index) { + return renderStepEditor(step, index, d.steps.length); + }).join(''); + + body.innerHTML = '' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + segments.map(function (s) { + return ''; + }).join('') + + '
' + + '
' + + (d.loopMode === 'repeat_count' ? ( + '
' + + '' + + '' + + '
' + ) : '') + + '
' + + '流程步骤' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + stepsHtml + '
' + + '
' + + '
单轮时长
' + + '
全局总时长
' + + '
' + + ''; + + bindEditorEvents(); + updateEditorStats(); + } + + function bindEditorEvents() { + const body = document.getElementById('editorBody'); + + const titleInput = document.getElementById('editorCardTitle'); + titleInput.addEventListener('input', function () { editorDraft.title = titleInput.value; }); + + body.querySelectorAll('.segment-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + if (editorDraft.loopMode === btn.dataset.loop) return; + editorDraft.loopMode = btn.dataset.loop; + renderEditor(); + }); + }); + + const loopCountInput = document.getElementById('editorLoopCount'); + if (loopCountInput) { + loopCountInput.addEventListener('input', function () { + const digits = loopCountInput.value.replace(/[^0-9]/g, ''); + if (digits !== loopCountInput.value) loopCountInput.value = digits; + editorDraft.loopCount = digits === '' ? 0 : parseInt(digits, 10); + updateEditorStats(); + }); + } + + document.getElementById('btnAddAction').addEventListener('click', function () { + editorDraft.steps.push(createDefaultStep('action')); + renderEditor(); + }); + document.getElementById('btnAddRest').addEventListener('click', function () { + editorDraft.steps.push(createDefaultStep('rest')); + renderEditor(); + }); + document.getElementById('btnEditorSave').addEventListener('click', handleEditorSave); + + body.querySelectorAll('.step-editor').forEach(function (stepEl) { + const stepId = stepEl.dataset.stepId; + const step = editorDraft.steps.find(function (s) { return s.id === stepId; }); + if (!step) return; + const index = editorDraft.steps.indexOf(step); + + const nameInput = stepEl.querySelector('.step-name-input'); + nameInput.addEventListener('input', function () { step.name = nameInput.value; }); + + const durationInput = stepEl.querySelector('.step-duration-input'); + durationInput.addEventListener('input', function () { + const digits = durationInput.value.replace(/[^0-9]/g, ''); + if (digits !== durationInput.value) durationInput.value = digits; + step.durationSec = digits === '' ? 0 : parseInt(digits, 10); + const hintEl = stepEl.querySelector('.step-duration-hint'); + if (hintEl) hintEl.textContent = '时长:' + formatDurationLong(step.durationSec); + updateEditorStats(); + }); + + stepEl.querySelector('.btn-step-copy').addEventListener('click', function () { + const idx = editorDraft.steps.findIndex(function (s) { return s.id === stepId; }); + if (idx < 0) return; + const src = editorDraft.steps[idx]; + editorDraft.steps.splice(idx + 1, 0, { + id: generateId(), + type: src.type, + name: (src.name || '') + ' (副本)', + durationSec: src.durationSec, + voiceText: src.voiceText || '' + }); + renderEditor(); + }); + + stepEl.querySelector('.btn-step-remove').addEventListener('click', function () { + if (editorDraft.steps.length <= 1) return; + editorDraft.steps = editorDraft.steps.filter(function (s) { return s.id !== stepId; }); + renderEditor(); + }); + + bindStepDrag(stepEl, index); + }); + } + + // ---- HTML5 拖拽排序:仅拖拽手柄可发起拖放 ---- + function bindStepDrag(stepEl, index) { + const handle = stepEl.querySelector('.step-drag-handle'); + + handle.addEventListener('mousedown', function () { stepEl.draggable = true; }); + handle.addEventListener('mouseup', function () { stepEl.draggable = false; }); + + stepEl.addEventListener('dragstart', function (e) { + editorDragIndex = index; + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', String(index)); } catch (err) { /* 兼容旧内核 */ } + requestAnimationFrame(function () { stepEl.classList.add('step-dragging'); }); + }); + + stepEl.addEventListener('dragend', function () { + stepEl.draggable = false; + editorDragIndex = null; + document.querySelectorAll('.step-editor').forEach(function (el) { + el.classList.remove('step-dragging', 'step-drag-over', 'drag-over-before', 'drag-over-after'); + }); + }); + + stepEl.addEventListener('dragover', function (e) { + if (editorDragIndex === null || editorDragIndex === index) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = stepEl.getBoundingClientRect(); + const before = (e.clientY - rect.top) < rect.height / 2; + stepEl.classList.add('step-drag-over'); + stepEl.classList.toggle('drag-over-before', before); + stepEl.classList.toggle('drag-over-after', !before); + }); + + stepEl.addEventListener('dragleave', function () { + stepEl.classList.remove('step-drag-over', 'drag-over-before', 'drag-over-after'); + }); + + stepEl.addEventListener('drop', function (e) { + e.preventDefault(); + const from = editorDragIndex; + stepEl.classList.remove('step-drag-over', 'drag-over-before', 'drag-over-after'); + if (from === null || from === index) return; + const rect = stepEl.getBoundingClientRect(); + const before = (e.clientY - rect.top) < rect.height / 2; + let to = before ? index : index + 1; + if (from < to) to -= 1; + if (to === from) return; + const moved = editorDraft.steps.splice(from, 1)[0]; + editorDraft.steps.splice(to, 0, moved); + editorDragIndex = null; + renderEditor(); + }); + } + + async function handleEditorSave() { + if (!editorDraft) return; + const saveBtn = document.getElementById('btnEditorSave'); + + // 保存校验 + const title = String(editorDraft.title || '').trim(); + if (!title) { + showAlert('无法保存', '请输入卡片名称'); + return; + } + const invalidSteps = editorDraft.steps.filter(function (s) { + return !(Number(s.durationSec) > 0); + }); + if (invalidSteps.length) { + showAlert('无法保存', invalidSteps.length + ' 个步骤的时长无效,请输入大于 0 的数字'); + return; + } + // 保存前清洗:trim 名称、durationSec 取 floor 最小 1、过滤空名称步骤 + const cleanSteps = editorDraft.steps + .map(function (s) { + return { + id: s.id, + type: s.type === 'rest' ? 'rest' : 'action', + name: String(s.name || '').trim(), + durationSec: Math.max(1, Math.floor(Number(s.durationSec) || 0)), + voiceText: String(s.voiceText || '').trim() + }; + }) + .filter(function (s) { return s.name !== ''; }); + if (!cleanSteps.length) { + showAlert('无法保存', '至少保留一个有效步骤(步骤名称不能为空)'); + return; + } + let loopCount = null; + if (editorDraft.loopMode === 'repeat_count') { + loopCount = Math.floor(Number(editorDraft.loopCount || 0)); + if (loopCount < 1) { + showAlert('无法保存', '循环次数至少为 1'); + return; + } + } + + const isEdit = !!editorDraft.id; + const payload = { + title: title, + steps: cleanSteps, + loopMode: editorDraft.loopMode, + loopCount: loopCount + }; + if (isEdit) payload.id = editorDraft.id; + + setButtonLoading(saveBtn, '保存中…'); + const res = await runTrackedInteraction(isEdit ? '更新流程卡片' : '新建流程卡片', function () { + return apiFetch(isEdit ? 'PUT' : 'POST', '/api/app_order_box/kits/motion_reminder/cards', payload); + }); + if (!res.ok) { + resetButton(saveBtn); + return; // 失败详情已由交互弹窗停留展示 + } + resetButton(saveBtn); + closeEditor(); + await loadCards(); + } + + // ==================== 执行器 ==================== + let runnerState = null; + let runnerTimerId = null; + let runnerAnnounceKey = ''; + let runnerFinishing = false; + + function perRoundSec(card) { + return ((card && card.steps) || []).reduce(function (sum, s) { + return sum + Math.max(1, Number(s.durationSec) || 0); + }, 0); + } + + function runnerTotalLoops() { + if (!runnerState) return null; + return runnerState.card.loopMode === 'repeat_count' + ? Math.max(1, Number(runnerState.card.loopCount || 1)) + : null; + } + + /** 预计剩余总秒数:当前步骤剩余 + 本轮剩余步骤 + 剩余整轮 */ + function computeRemainingSec(rs) { + const steps = rs.card.steps; + let total = Math.max(0, Number(rs.remainingSec) || 0); + for (let i = rs.currentStepIndex + 1; i < steps.length; i++) { + total += Math.max(1, Number(steps[i].durationSec) || 0); + } + const totalLoops = rs.card.loopMode === 'repeat_count' + ? Math.max(1, Number(rs.card.loopCount || 1)) + : null; + if (totalLoops !== null) { + const roundsLeft = Math.max(0, totalLoops - rs.currentLoop); + total += roundsLeft * perRoundSec(rs.card); + } + return total; + } + + function openRunner(session, card) { + const steps = (card && Array.isArray(card.steps)) ? card.steps : []; + if (!steps.length) { + showAlert('无法启动', '当前卡片没有可执行步骤'); + return; + } + const startIndex = Math.min(Math.max(0, Number(session.currentStepIndex) || 0), steps.length - 1); + runnerState = { + sessionId: session.id, + card: card, + status: 'running', + currentLoop: Math.max(1, Number(session.currentLoop) || 1), + completedLoops: Math.max(0, Number(session.completedLoops) || 0), + currentStepIndex: startIndex, + remainingSec: Math.max(1, Number(steps[startIndex].durationSec) || 1), + manualStopped: false + }; + runnerAnnounceKey = ''; + runnerFinishing = false; + document.getElementById('runnerModal').style.display = 'flex'; + requestWakeLock(); + onRunnerStepChanged(true); + startRunnerTimer(); + } + + function closeRunner() { + stopRunnerTimer(); + runnerState = null; + runnerAnnounceKey = ''; + stopSpeech(); + releaseWakeLock(); + document.getElementById('runnerModal').style.display = 'none'; + } + + function startRunnerTimer() { + stopRunnerTimer(); + runnerTimerId = setInterval(runnerTick, 1000); + } + + function stopRunnerTimer() { + if (runnerTimerId) { + clearInterval(runnerTimerId); + runnerTimerId = null; + } + } + + /** 每秒递减;5/3 秒播报;到 0 自动切下一步 */ + function runnerTick() { + if (!runnerState || runnerState.status !== 'running' || runnerFinishing) return; + const step = runnerState.card.steps[runnerState.currentStepIndex]; + const pitch = (step && step.type === 'action') ? 1.1 : 0.9; + if (runnerState.remainingSec === 5 || runnerState.remainingSec === 3) { + speakText('还有 ' + runnerState.remainingSec + ' 秒', pitch); + } + if (runnerState.remainingSec <= 1) { + advanceStep(); + return; + } + runnerState.remainingSec -= 1; + updateRunnerDynamic(); + } + + /** 步骤推进:非末步→下一步;末步→下一轮或完成 */ + function advanceStep() { + if (!runnerState || runnerFinishing) return; + const steps = runnerState.card.steps; + const isLastStep = runnerState.currentStepIndex >= steps.length - 1; + + if (!isLastStep) { + runnerState.currentStepIndex += 1; + runnerState.remainingSec = Math.max(1, Number(steps[runnerState.currentStepIndex].durationSec) || 1); + onRunnerStepChanged(false); + return; + } + + const nextCompletedLoops = runnerState.completedLoops + 1; + const totalLoops = runnerTotalLoops(); + if (runnerState.card.loopMode === 'once' || (totalLoops !== null && nextCompletedLoops >= totalLoops)) { + void finishRunner('completed', false); + return; + } + + runnerState.completedLoops = nextCompletedLoops; + runnerState.currentLoop += 1; + runnerState.currentStepIndex = 0; + runnerState.remainingSec = Math.max(1, Number(steps[0].durationSec) || 1); + onRunnerStepChanged(false); + } + + /** 步骤切换统一入口:渲染 + 播报 + 提示音 + 振动 + 服务端同步 */ + function onRunnerStepChanged(isFirst) { + renderRunner(); + updateRunnerDynamic(); + announceStep(); + playBeep(); + vibrate([50]); + if (!isFirst) syncRunnerSilent(); + } + + /** 静默同步进度到服务端(高频调用,不弹窗) */ + function syncRunnerSilent() { + if (!runnerState) return Promise.resolve(null); + const rs = runnerState; + return runTrackedInteraction('同步执行进度', function () { + return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/sync', { + id: rs.sessionId, + status: rs.status, + currentLoop: rs.currentLoop, + completedLoops: rs.completedLoops, + currentStepIndex: rs.currentStepIndex, + manualStopped: rs.manualStopped + }); + }, { silent: true }); + } + + async function finishRunner(status, manualStopped) { + if (!runnerState || runnerFinishing) return; + runnerFinishing = true; + const rs = runnerState; + stopRunnerTimer(); + const body = { + id: rs.sessionId, + status: status, + currentLoop: rs.currentLoop, + completedLoops: rs.completedLoops, + currentStepIndex: rs.currentStepIndex, + manualStopped: !!manualStopped + }; + const res = await runTrackedInteraction( + status === 'completed' ? '会话正常完成' : '手动结束会话', + function () { + return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/finish', body); + } + ); + closeRunner(); + runnerFinishing = false; + if (res.ok) { + showToast(status === 'completed' ? '流程已完成' : '已提前结束', 'success'); + } + await refreshState(true); + updateStaleWarning(); + if (currentView === 'motion' && currentTab === 'cards') { + await loadCards(); + } + } + + // ---- 执行器控制按钮 ---- + + async function handleRunnerPause() { + if (!runnerState || runnerFinishing) return; + vibrate([50]); + const pausing = runnerState.status === 'running'; + runnerState.status = pausing ? 'paused' : 'running'; + const container = document.getElementById('runnerContainer'); + container.classList.toggle('is-paused', runnerState.status === 'paused'); + const btn = document.getElementById('btnRunnerPause'); + if (btn) { + const textEl = getButtonTextEl(btn); + textEl.textContent = runnerState.status === 'paused' ? '继续' : '暂停'; + } + if (runnerState.status === 'paused') { + stopSpeech(); + } + await syncRunnerSilent(); + } + + function handleRunnerSkip() { + if (!runnerState || runnerFinishing) return; + vibrate([50]); + advanceStep(); + } + + function handleRunnerStop() { + if (!runnerState || runnerFinishing) return; + vibrate([30]); + showConfirm('结束执行', '确定提前结束本次执行吗?这会记录为手动提前结束。', async function () { + closeDialog(); + await finishRunner('stopped', true); + }); + } + + // ---- 执行器渲染 ---- + + function renderRunner() { + const rs = runnerState; + if (!rs) return; + const container = document.getElementById('runnerContainer'); + const step = rs.card.steps[rs.currentStepIndex] || {}; + const nextStep = rs.card.steps[rs.currentStepIndex + 1] || null; + const isAction = step.type !== 'rest'; + const totalSteps = rs.card.steps.length; + const totalLoops = runnerTotalLoops(); + const globalDurationSec = totalLoops !== null ? perRoundSec(rs.card) * totalLoops : null; + + container.innerHTML = '' + + '
' + + '' + (isAction ? '动作' : '休息') + '' + + '
' + escHtml(rs.card.title) + '
' + + '
第 ' + rs.currentLoop + ' 轮 ' + (totalLoops ? '/ 共 ' + totalLoops + ' 轮' : '/ 无限循环') + ' · 步骤 ' + (rs.currentStepIndex + 1) + ' / ' + totalSteps + '
' + + '
' + + '
' + + '
' + + '
当前动作
' + + '
' + escHtml(step.name || '—') + '
' + + '
--:--
' + + '
' + + '
下一个动作
' + + '
' + (nextStep ? escHtml(nextStep.name) : '本轮结束') + '
' + + '
' + + '
' + + ''; + + container.classList.toggle('is-paused', rs.status === 'paused'); + document.getElementById('btnRunnerPause').addEventListener('click', handleRunnerPause); + document.getElementById('btnRunnerSkip').addEventListener('click', handleRunnerSkip); + document.getElementById('btnRunnerStop').addEventListener('click', handleRunnerStop); + } + + /** 每 tick 局部刷新:倒计时、进度条、预计剩余、全局进度 */ + function updateRunnerDynamic() { + const rs = runnerState; + if (!rs) return; + const step = rs.card.steps[rs.currentStepIndex]; + const stepDuration = Math.max(1, Number((step && step.durationSec) || 1)); + const stepProgress = Math.max(0, Math.min(1, 1 - (rs.remainingSec / stepDuration))); + + const timerEl = document.getElementById('runnerTimer'); + if (timerEl) timerEl.textContent = formatDuration(rs.remainingSec); + const barEl = document.getElementById('runnerStepBar'); + if (barEl) barEl.style.width = (stepProgress * 100).toFixed(2) + '%'; + + const remainingSec = computeRemainingSec(rs); + const remEl = document.getElementById('runnerInfoRemaining'); + if (remEl) remEl.textContent = '约 ' + formatDuration(remainingSec); + + const totalLoops = runnerTotalLoops(); + const globalDurationSec = totalLoops !== null ? perRoundSec(rs.card) * totalLoops : null; + if (globalDurationSec !== null && globalDurationSec > 0) { + const executedSec = Math.max(0, globalDurationSec - remainingSec); + const progress = Math.max(0, Math.min(1, executedSec / globalDurationSec)); + const pctEl = document.getElementById('runnerGlobalPercent'); + if (pctEl) pctEl.textContent = Math.round(progress * 100) + '%'; + const gBarEl = document.getElementById('runnerGlobalBar'); + if (gBarEl) gBarEl.style.width = (progress * 100).toFixed(2) + '%'; + const exEl = document.getElementById('runnerGlobalExecuted'); + if (exEl) exEl.textContent = '已执行 ' + formatDuration(executedSec); + const reEl = document.getElementById('runnerGlobalRemaining'); + if (reEl) reEl.textContent = '剩余 ' + formatDuration(remainingSec); + } + } + + // ==================== 语音播报 ==================== + // Web Speech API:动作 pitch=1.1,休息 pitch=0.9,rate=0.95,lang=zh-CN + // 去重:同一步骤(sessionId:loop:stepIndex)不重复播报 + + function speakText(text, pitch) { + try { + if (!('speechSynthesis' in window)) return; + window.speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = 'zh-CN'; + utterance.pitch = Number(pitch) || 1; + utterance.rate = 0.95; + window.speechSynthesis.speak(utterance); + } catch (e) { /* 不支持时静默 */ } + } + + function stopSpeech() { + try { + if ('speechSynthesis' in window) window.speechSynthesis.cancel(); + } catch (e) { /* 静默 */ } + } + + /** 步骤切换播报:"第N轮,{名称},{时长}。下一步,{nextName}" */ + function announceStep() { + if (!runnerState) return; + const key = runnerState.sessionId + ':' + runnerState.currentLoop + ':' + runnerState.currentStepIndex; + if (runnerAnnounceKey === key) return; // 去重 + runnerAnnounceKey = key; + + const step = runnerState.card.steps[runnerState.currentStepIndex]; + if (!step) return; + const nextStep = runnerState.card.steps[runnerState.currentStepIndex + 1] || null; + const pitch = step.type === 'action' ? 1.1 : 0.9; + const title = (step.voiceText && String(step.voiceText).trim()) + ? String(step.voiceText).trim() + : (step.name + ',' + formatSpeechDuration(step.durationSec)); + const nextText = nextStep ? '。下一步,' + nextStep.name : ''; + speakText('第 ' + runnerState.currentLoop + ' 轮,' + title + nextText, pitch); + } + + // ==================== 提示音 ==================== + // Web Audio API:步骤切换时播放短促蜂鸣(200ms, 880Hz) + + let audioCtx = null; + + function playBeep() { + try { + const AudioContextCtor = window.AudioContext || window.webkitAudioContext; + if (!AudioContextCtor) return; + if (!audioCtx) audioCtx = new AudioContextCtor(); + if (audioCtx.state === 'suspended') { void audioCtx.resume(); } + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.type = 'sine'; + osc.frequency.value = 880; + const now = audioCtx.currentTime; + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(0.22, now + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(now); + osc.stop(now + 0.2); + } catch (e) { /* 不支持时静默 */ } + } + + /** 振动反馈(不支持时静默) */ + function vibrate(pattern) { + try { + if (navigator.vibrate) navigator.vibrate(pattern); + } catch (e) { /* 静默 */ } + } + + // ==================== 屏幕常亮 ==================== + // Wake Lock API:执行期间保持屏幕不灭;页面恢复可见时自动重新申请 + + let wakeLockSentinel = null; + + async function requestWakeLock() { + try { + if ('wakeLock' in navigator) { + wakeLockSentinel = await navigator.wakeLock.request('screen'); + wakeLockSentinel.addEventListener('release', function () { wakeLockSentinel = null; }); + } + } catch (e) { /* 不支持或权限受限时静默 */ } + } + + function releaseWakeLock() { + try { + if (wakeLockSentinel) { void wakeLockSentinel.release(); } + } catch (e) { /* 静默 */ } + wakeLockSentinel = null; + } + + function handleVisibilityChange() { + if (document.visibilityState === 'visible' && runnerState) { + requestWakeLock(); + } + } + + // ==================== 历史列表 ==================== + + function renderHistory(list) { + const listEl = document.getElementById('historyList'); + const emptyEl = document.getElementById('historyEmpty'); + if (!list.length) { + listEl.innerHTML = ''; + listEl.style.display = 'none'; + emptyEl.style.display = ''; + return; + } + emptyEl.style.display = 'none'; + listEl.style.display = ''; + listEl.innerHTML = list.map(function (item) { + const title = (item.cardSnapshot && item.cardSnapshot.title) || '未知流程'; + const endText = item.manualStopped ? '手动提前结束' : (item.status === 'completed' ? '正常完成' : '停止'); + const badgeClass = item.manualStopped ? 'badge-warning' : (item.status === 'completed' ? 'badge-success' : 'badge-muted'); + return '' + + '
' + + '
' + escHtml(title) + '
' + + '
开始:' + formatDateTime(item.startedAt) + '
' + + '
结束:' + formatDateTime(item.endedAt) + '
' + + '
完成轮次:' + Math.max(0, Number(item.completedLoops) || 0) + '
' + + '
结束方式:' + endText + '
' + + '
'; + }).join(''); + } + + async function loadHistory() { + const listEl = document.getElementById('historyList'); + const emptyEl = document.getElementById('historyEmpty'); + const loadingEl = document.getElementById('historyLoading'); + loadingEl.style.display = ''; + listEl.style.display = 'none'; + listEl.innerHTML = ''; + emptyEl.style.display = 'none'; + + const res = await runTrackedInteraction('加载执行历史', function () { + return apiFetch('GET', '/api/app_order_box/kits/motion_reminder/history'); + }, { mode: 'view' }); + + loadingEl.style.display = 'none'; + if (!res.ok) { + showAlert('加载失败', escHtml(res.error || '执行历史加载失败')); + return; + } + renderHistory(Array.isArray(res.data) ? res.data : []); + } + + // ==================== 初始化 ==================== + + /** 键盘快捷键:执行器内 空格=暂停/继续,Esc=结束 */ + function handleGlobalKeydown(e) { + if (!runnerState) return; + const tag = String((e.target && e.target.tagName) || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea') return; + if (e.code === 'Space') { + e.preventDefault(); + handleRunnerPause(); + } else if (e.key === 'Escape') { + handleRunnerStop(); + } + } + + function bindStaticEvents() { + document.getElementById('btnEnterMotion').addEventListener('click', handleEnterMotion); + document.getElementById('btnBackHub').addEventListener('click', handleBackHub); + document.getElementById('btnNewCard').addEventListener('click', function () { openEditor(null); }); + document.getElementById('btnCleanStale').addEventListener('click', handleCleanStale); + document.getElementById('btnEditorClose').addEventListener('click', closeEditor); + document.getElementById('btnInteractionCopy').addEventListener('click', handleCopyInteraction); + document.getElementById('cardsList').addEventListener('click', handleCardsListClick); + + document.querySelectorAll('.tab-item').forEach(function (el) { + el.addEventListener('click', function () { switchTab(el.dataset.tab); }); + }); + + document.addEventListener('keydown', handleGlobalKeydown); + document.addEventListener('visibilitychange', handleVisibilityChange); + } + + async function init() { + bindStaticEvents(); + await initHub(); + } + + document.addEventListener('DOMContentLoaded', init); + + // 调试辅助:控制台查看当前 UI 模式与最近一次交互日志 + window.__orderBox = { + getUiMode: getUiMode, + setUiMode: setUiMode, + get lastInteraction() { return lastInteractionLog; }, + THEME: THEME + }; +})(); \ No newline at end of file diff --git a/public/tools/zen_box/2026_jan_mp3_playlist.json b/public/tools/web_zen_box/2026_jan_mp3_playlist.json similarity index 100% rename from public/tools/zen_box/2026_jan_mp3_playlist.json rename to public/tools/web_zen_box/2026_jan_mp3_playlist.json diff --git a/public/tools/zen_box/apple-touch-icon.svg b/public/tools/web_zen_box/apple-touch-icon.svg similarity index 100% rename from public/tools/zen_box/apple-touch-icon.svg rename to public/tools/web_zen_box/apple-touch-icon.svg diff --git a/public/tools/zen_box/country_top50_playlist.json b/public/tools/web_zen_box/country_top50_playlist.json similarity index 100% rename from public/tools/zen_box/country_top50_playlist.json rename to public/tools/web_zen_box/country_top50_playlist.json diff --git a/public/tools/zen_box/douyin_100_playlist.json b/public/tools/web_zen_box/douyin_100_playlist.json similarity index 100% rename from public/tools/zen_box/douyin_100_playlist.json rename to public/tools/web_zen_box/douyin_100_playlist.json diff --git a/public/tools/zen_box/folk_mingyao_34_playlist.json b/public/tools/web_zen_box/folk_mingyao_34_playlist.json similarity index 100% rename from public/tools/zen_box/folk_mingyao_34_playlist.json rename to public/tools/web_zen_box/folk_mingyao_34_playlist.json diff --git a/public/tools/zen_box/generate-icons.html b/public/tools/web_zen_box/generate-icons.html similarity index 100% rename from public/tools/zen_box/generate-icons.html rename to public/tools/web_zen_box/generate-icons.html diff --git a/public/tools/zen_box/generate-icons.js b/public/tools/web_zen_box/generate-icons.js similarity index 100% rename from public/tools/zen_box/generate-icons.js rename to public/tools/web_zen_box/generate-icons.js diff --git a/public/tools/zen_box/icon-192.svg b/public/tools/web_zen_box/icon-192.svg similarity index 100% rename from public/tools/zen_box/icon-192.svg rename to public/tools/web_zen_box/icon-192.svg diff --git a/public/tools/zen_box/icon-512.svg b/public/tools/web_zen_box/icon-512.svg similarity index 100% rename from public/tools/zen_box/icon-512.svg rename to public/tools/web_zen_box/icon-512.svg diff --git a/public/tools/zen_box/index.html b/public/tools/web_zen_box/index.html similarity index 72% rename from public/tools/zen_box/index.html rename to public/tools/web_zen_box/index.html index 5e13be0..86658f8 100644 --- a/public/tools/zen_box/index.html +++ b/public/tools/web_zen_box/index.html @@ -5,15 +5,36 @@ - + -八音盒 +观空禅音 + +
+ + + + + + + + + + + + + + +
观空禅音
+
正在加载歌单与缓存...
+
+
+
@@ -40,7 +61,7 @@ - 八音盒 + 观空禅音
0 首
@@ -161,9 +182,9 @@
- 尚未选择曲目 + 未选择歌曲 - +
—
@@ -218,7 +239,7 @@
- +
-
+
+
账号
+
+
当前歌单
-
自动读取当前目录下所有以 playlist.json 结尾的歌单文件,并记住你上次选择。
+
自动读取所有以 playlist.json 结尾的歌单,并记住你上次选择。
缓存对照
正在检查缓存...
按歌曲地址对照当前歌单与浏览器缓存,标记哪些已缓存、哪些未缓存。
+
+
缓存模式
+ +
开启后,切歌时只播放已下载的歌曲,未缓存的歌曲需先在播放列表下载。
+
+
+
缓存管理
+
+ 已缓存大小 + 正在统计... +
+ +
播放记忆
-
默认开启。下次打开时,会从上次暂停的位置继续播放当前音频。
+
默认开启,下次打开时从上次暂停的位置继续播放
+
+
+
+
观空禅音
+
ZenVoid
+
+
这不是一款为了喧闹和效率而设计的播放器。它更像一处留白,让人愿意慢下来,听见一点安静。
+
"ZenVoid"和"观空"都不是标准答案,只是我们尽力靠近这种感受的一种表达。希望它陪你在日常里,留一点空,听一点静。
+
v1.0.0
@@ -285,7 +330,10 @@
-
播放列表
+
+ 播放列表 + +
+ + + + + diff --git a/public/tools/zen_box/manifest.json b/public/tools/web_zen_box/manifest.json similarity index 85% rename from public/tools/zen_box/manifest.json rename to public/tools/web_zen_box/manifest.json index 6f74189..929e295 100644 --- a/public/tools/zen_box/manifest.json +++ b/public/tools/web_zen_box/manifest.json @@ -1,6 +1,6 @@ { - "name": "八音盒", - "short_name": "八音盒", + "name": "观空禅音", + "short_name": "观空禅音", "start_url": "./index.html", "display": "standalone", "background_color": "#060612", diff --git a/public/tools/zen_box/mingchao_audiobook_playlist.json b/public/tools/web_zen_box/mingchao_audiobook_playlist.json similarity index 100% rename from public/tools/zen_box/mingchao_audiobook_playlist.json rename to public/tools/web_zen_box/mingchao_audiobook_playlist.json diff --git a/public/tools/zen_box/playlist.json b/public/tools/web_zen_box/playlist.json similarity index 100% rename from public/tools/zen_box/playlist.json rename to public/tools/web_zen_box/playlist.json diff --git a/public/tools/web_zen_box/pop_2026_top50_playlist.json b/public/tools/web_zen_box/pop_2026_top50_playlist.json new file mode 100644 index 0000000..ace6cde --- /dev/null +++ b/public/tools/web_zen_box/pop_2026_top50_playlist.json @@ -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": "" + } +] \ No newline at end of file diff --git a/public/tools/zen_box/sw.js b/public/tools/web_zen_box/sw.js similarity index 60% rename from public/tools/zen_box/sw.js rename to public/tools/web_zen_box/sw.js index 4fa17b8..84f1c84 100644 --- a/public/tools/zen_box/sw.js +++ b/public/tools/web_zen_box/sw.js @@ -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; } diff --git a/public/tools/zen_box/taiwan_top50_2026.playlist.json b/public/tools/web_zen_box/taiwan_top50_2026.playlist.json similarity index 100% rename from public/tools/zen_box/taiwan_top50_2026.playlist.json rename to public/tools/web_zen_box/taiwan_top50_2026.playlist.json diff --git a/public/tools/zen_box/zen_box.css b/public/tools/web_zen_box/zen_box.css similarity index 68% rename from public/tools/zen_box/zen_box.css rename to public/tools/web_zen_box/zen_box.css index 85b3c9f..daea855 100644 --- a/public/tools/zen_box/zen_box.css +++ b/public/tools/web_zen_box/zen_box.css @@ -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; } +} diff --git a/public/tools/web_zen_box/zen_box.js b/public/tools/web_zen_box/zen_box.js new file mode 100644 index 0000000..26fd83b --- /dev/null +++ b/public/tools/web_zen_box/zen_box.js @@ -0,0 +1,1395 @@ +const PLAYLIST_LIST_API = '/api/zen_box/playlists'; +const LOGIN_API = '/api/zen_box/auth/login'; +const CONFIG_API = '/api/zen_box/config'; +const AUDIO_CACHE_NAME = 'zen-audio-cache'; +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'; +const SORT_PREF_STORAGE_KEY = 'zen_box_sort_pref'; +const CACHE_ONLY_STORAGE_KEY = 'zen_box_cache_only'; +const LOGGED_IN_USER_KEY = 'zen_box_logged_in_user'; +const PENDING_CONFIG_KEY = 'zen_box_pending_config'; +const SNAPSHOT_KEY_PREFIX = 'zen_box_playlist_snapshot'; +const APP_NAME = '观空禅音'; +const INITIAL_TITLE = '未选择歌曲'; + +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; +/* volumeVisible removed — RN 端无此功能 */ +let resumePlaybackEnabled = true; +let lastSavedPositionSeconds = 0; +let sortMode = 'original'; +let displayOrder = []; +let cacheOnly = true; +let loggedInUser = ''; +let isOnline = true; +let downloadingUrls = new Set(); +let batchDownloadActive = false; +let offlineAvailableFiles = null; +let lastCloudPushAt = 0; +let cloudSyncTimer = null; + +const audio = document.getElementById('audio'); + +async function init() { + resumePlaybackEnabled = getResumePlaybackEnabled(); + cacheOnly = getCacheOnlyEnabled(); + sortMode = getSavedSortMode(); + loggedInUser = getLoggedInUser(); + isOnline = navigator.onLine !== false; + bindSettingsEvents(); + bindOnlineEvents(); + registerSW(); + await loadPlaylistFiles(); + await restorePlaylistSelection(); + setupMediaSession(); + renderAccountSection(); + updateOfflineBadge(); + updateSortButtons(); + 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 getCacheOnlyEnabled() { + try { + const value = localStorage.getItem(CACHE_ONLY_STORAGE_KEY); + if (value === null) return true; + return value === '1'; + } catch { + return true; + } +} + +function saveCacheOnlyEnabled(enabled) { + cacheOnly = !!enabled; + try { + localStorage.setItem(CACHE_ONLY_STORAGE_KEY, enabled ? '1' : '0'); + } catch {} +} + +function getSavedSortMode() { + try { + const value = String(localStorage.getItem(SORT_PREF_STORAGE_KEY) || 'original'); + return ['original', 'asc', 'desc'].includes(value) ? value : 'original'; + } catch { + return 'original'; + } +} + +function saveSortMode(mode) { + try { + localStorage.setItem(SORT_PREF_STORAGE_KEY, mode); + } catch {} +} + +function getLoggedInUser() { + try { + return String(localStorage.getItem(LOGGED_IN_USER_KEY) || ''); + } catch { + return ''; + } +} + +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; + scheduleCloudSync(); + } 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 : []; +} + +function savePlaylistSnapshot(fileName, tracks) { + try { + localStorage.setItem(`${SNAPSHOT_KEY_PREFIX}:${fileName}`, JSON.stringify(tracks)); + } catch {} +} + +function readPlaylistSnapshot(fileName) { + try { + const raw = localStorage.getItem(`${SNAPSHOT_KEY_PREFIX}:${fileName}`); + if (!raw) return null; + const data = JSON.parse(raw); + return Array.isArray(data) && data.length ? data : null; + } catch { + return null; + } +} + +async function loadPlaylistWithFallback(fileName) { + try { + const tracks = await fetchPlaylistData(fileName); + savePlaylistSnapshot(fileName, tracks); + return { tracks, fromSnapshot: false }; + } catch (error) { + const snapshot = readPlaylistSnapshot(fileName); + if (snapshot) return { tracks: snapshot, fromSnapshot: true }; + throw error; + } +} + +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 { tracks: nextPlaylistRaw, fromSnapshot } = await loadPlaylistWithFallback(fallback); + currentPlaylistFile = fallback; + savePlaylistFile(fallback); + PLAYLIST = nextPlaylistRaw.map(track => ({ ...track, isCached: false })); + await refreshCacheState(); + const matchedIdx = previousSrc ? PLAYLIST.findIndex(track => normalizeTrackSrc(track.src) === previousSrc) : -1; + currentIdx = matchedIdx >= 0 ? matchedIdx : 0; + updateTrackCount(); + rebuildDisplayOrder(); + buildShuffleOrder(); + renderSettings(); + renderPlaylist(); + if (PLAYLIST.length > 0) { + const loaded = loadTrack(currentIdx, false); + if (loaded !== false && wasPlaying && matchedIdx >= 0) playAudio(); + } else { + resetTrackInfo(); + } + closeSheet(); + if (fromSnapshot) { + showToast('远程歌单加载失败,已切换到本地快照'); + } else if (!silent) { + showToast(`已切换歌单:${fallback}`); + } + scheduleCloudSync(); + } 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'), INITIAL_TITLE); + 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 = ''; + let files = playlistFiles; + if (!isOnline && Array.isArray(offlineAvailableFiles)) { + files = playlistFiles.filter(file => offlineAvailableFiles.includes(file)); + if (!files.includes(currentPlaylistFile)) files = [currentPlaylistFile, ...files]; + } + files.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; + } + const cacheOnlyToggle = document.getElementById('cacheOnlyToggle'); + if (cacheOnlyToggle) { + cacheOnlyToggle.checked = !!cacheOnly; + } + renderAccountSection(); + updateCacheSummary(); + refreshCacheSize(); +} + +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 ? '已开启断点续播' : '已关闭断点续播'); + scheduleCloudSync(); + }); + } + const cacheOnlyToggle = document.getElementById('cacheOnlyToggle'); + if (cacheOnlyToggle) { + cacheOnlyToggle.addEventListener('change', event => { + const enabled = !!(event.target && event.target.checked); + saveCacheOnlyEnabled(enabled); + showToast(enabled ? '已开启只用缓存听歌' : '已关闭只用缓存听歌'); + scheduleCloudSync(); + }); + } + const loginOverlay = document.getElementById('loginOverlay'); + if (loginOverlay) { + loginOverlay.addEventListener('click', event => { + if (event.target === loginOverlay) closeLoginOverlay(); + }); + } + const loginPassword = document.getElementById('loginPassword'); + if (loginPassword) { + loginPassword.addEventListener('keydown', event => { + if (event.key === 'Enter') doLogin(); + }); + } +} + +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) { + const track = PLAYLIST[idx]; + if (!track) return false; + if (cacheOnly && !track.isCached) { + showToast('歌曲未缓存,且当前设置为只用缓存听歌曲。请先在播放列表页面下载此歌曲。'); + return false; + } + if (PLAYLIST[currentIdx] && audio.src) { + saveCurrentPlaybackPosition({ force: true }); + } + currentIdx = idx; + 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(); + return true; +} + +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 findCachedTrackFrom(startIdx, dir) { + const total = PLAYLIST.length; + if (!total) return -1; + for (let step = 0; step < total; step += 1) { + const idx = dir >= 0 + ? (startIdx + step) % total + : (startIdx - step + total) % total; + if (PLAYLIST[idx] && PLAYLIST[idx].isCached) return idx; + } + return -1; +} + +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; + } + if (cacheOnly) { + const cachedNext = findCachedTrackFrom(next, 1); + if (cachedNext < 0) { + showToast('没有已缓存的歌曲,请先在播放列表下载'); + return; + } + next = cachedNext; + } + 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; + } + if (cacheOnly) { + const cachedPrev = findCachedTrackFrom(prev, -1); + if (cachedPrev < 0) { + showToast('没有已缓存的歌曲,请先在播放列表下载'); + return; + } + prev = cachedPrev; + } + 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 + ? '1' + : ''; + showToast(repeatLabels[repeatMode]); +} + +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 rebuildDisplayOrder() { + const indices = PLAYLIST.map((_, idx) => idx); + if (sortMode === 'asc') { + indices.sort((a, b) => String(PLAYLIST[a].title || '').localeCompare(String(PLAYLIST[b].title || ''), 'zh-CN')); + } else if (sortMode === 'desc') { + indices.sort((a, b) => String(PLAYLIST[b].title || '').localeCompare(String(PLAYLIST[a].title || ''), 'zh-CN')); + } + displayOrder = indices; +} + +function applySort(mode, { fromCloud = false } = {}) { + const nextMode = ['original', 'asc', 'desc'].includes(mode) ? mode : 'original'; + const changed = nextMode !== sortMode; + sortMode = nextMode; + saveSortMode(sortMode); + rebuildDisplayOrder(); + buildShuffleOrder(); + updateSortButtons(); + renderPlaylist(); + if (changed && !fromCloud) { + const labels = { original: '原始顺序', asc: '名称正序', desc: '名称倒序' }; + showToast(`排序:${labels[sortMode]}`); + scheduleCloudSync(); + } +} + +function updateSortButtons() { + ['original', 'asc', 'desc'].forEach(mode => { + const btn = document.getElementById(`sort${mode.charAt(0).toUpperCase()}${mode.slice(1)}`); + if (btn) btn.classList.toggle('active', sortMode === mode); + }); +} + +/* ========== 下载与缓存 ========== */ + +function dlButtonHtml(track) { + const key = normalizeTrackSrc(track.src); + if (downloadingUrls.has(key)) { + return ``; + } + if (track.isCached) { + return ``; + } + return ``; +} + +function updateDownloadButtons(key) { + document.querySelectorAll('.playlist-item').forEach(el => { + const idx = Number(el.dataset.idx); + const track = PLAYLIST[idx]; + if (!track || normalizeTrackSrc(track.src) !== key) return; + const btn = el.querySelector('.dl-btn'); + if (!btn) return; + const wrapper = document.createElement('span'); + wrapper.innerHTML = dlButtonHtml(track); + btn.replaceWith(wrapper.firstChild); + }); +} + +function markTrackCached(src) { + const key = normalizeTrackSrc(src); + PLAYLIST = PLAYLIST.map(track => (normalizeTrackSrc(track.src) === key ? { ...track, isCached: true } : track)); + updateDownloadButtons(key); +} + +async function downloadTrack(src, { silent = false } = {}) { + const key = normalizeTrackSrc(src); + if (cachedTrackUrls.has(key)) return true; + if (downloadingUrls.has(key)) return false; + if (!('caches' in window)) { + if (!silent) showToast('当前浏览器不支持缓存下载'); + return false; + } + downloadingUrls.add(key); + updateDownloadButtons(key); + try { + const response = await fetch(src, { cache: 'no-store' }); + if (!response.ok) throw new Error(`http_${response.status}`); + const blob = await response.blob(); + if (!blob || !blob.size) throw new Error('empty_blob'); + const cache = await caches.open(AUDIO_CACHE_NAME); + await cache.put(new Request(key), new Response(blob)); + cachedTrackUrls.add(key); + markTrackCached(src); + updateCacheSummary(); + refreshCacheSize(); + if (!isOnline) refreshOfflineAvailability().then(() => { if (settingsOpen) renderSettings(); }); + if (!silent) showToast('下载完成'); + return true; + } catch (error) { + console.warn('Download failed:', src, error); + if (!silent) showToast('下载失败,请稍后重试'); + return false; + } finally { + downloadingUrls.delete(key); + updateDownloadButtons(key); + } +} + +async function downloadAllTracks() { + if (!PLAYLIST.length) { + showToast('播放列表为空'); + return; + } + if (batchDownloadActive) return; + const pending = PLAYLIST.filter(track => !cachedTrackUrls.has(normalizeTrackSrc(track.src))); + if (!pending.length) { + showToast('所有歌曲均已缓存'); + return; + } + batchDownloadActive = true; + const btn = document.getElementById('btnDownloadAll'); + const btnText = document.getElementById('btnDownloadAllText'); + const progressWrap = document.getElementById('batchProgress'); + const progressTitle = document.getElementById('batchProgressTitle'); + const progressFill = document.getElementById('batchProgressFill'); + const progressPct = document.getElementById('batchProgressPct'); + const progressCount = document.getElementById('batchProgressCount'); + if (btn) btn.disabled = true; + if (btnText) btnText.textContent = '下载中…'; + if (progressWrap) progressWrap.style.display = 'block'; + const total = pending.length; + let done = 0; + let okCount = 0; + let failCount = 0; + for (const track of pending) { + if (progressTitle) progressTitle.textContent = track.title || '未知曲目'; + const success = await downloadTrack(track.src, { silent: true }); + if (success) okCount += 1; else failCount += 1; + done += 1; + const pct = Math.round((done / total) * 100); + if (progressFill) progressFill.style.width = `${pct}%`; + if (progressPct) progressPct.textContent = `${pct}%`; + if (progressCount) progressCount.textContent = `${done}/${total}`; + } + batchDownloadActive = false; + if (btn) btn.disabled = false; + if (btnText) btnText.textContent = '一键下载所有'; + setTimeout(() => { + if (!batchDownloadActive && progressWrap) progressWrap.style.display = 'none'; + if (progressFill) progressFill.style.width = '0'; + }, 1600); + showToast(`批量下载完成:成功 ${okCount} 首${failCount ? `,失败 ${failCount} 首` : ''}`); +} + +async function computeCacheSize() { + if (!('caches' in window)) return 0; + try { + const cache = await caches.open(AUDIO_CACHE_NAME); + const requests = await cache.keys(); + let total = 0; + for (const request of requests) { + const response = await cache.match(request); + if (!response) continue; + const declared = Number(response.headers.get('content-length') || 0); + if (Number.isFinite(declared) && declared > 0) { + total += declared; + continue; + } + try { + const blob = await response.blob(); + total += blob ? blob.size : 0; + } catch {} + } + return total; + } catch (error) { + console.warn('Failed to compute cache size:', error); + return 0; + } +} + +function formatSize(bytes) { + const safe = Number(bytes) || 0; + if (safe <= 0) return '0 MB'; + const mb = safe / 1024 / 1024; + if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`; + if (mb >= 1) return `${mb.toFixed(1)} MB`; + return `${Math.max(1, Math.round(safe / 1024))} KB`; +} + +async function refreshCacheSize() { + const el = document.getElementById('cacheSizeText'); + if (!el) return; + el.textContent = '正在统计...'; + const size = await computeCacheSize(); + el.textContent = formatSize(size); +} + +async function clearAllCache() { + if (!window.confirm('确定清除所有已缓存的歌曲?清除后离线将无可播放内容。')) return; + const btn = document.getElementById('btnClearCache'); + const oldText = btn ? btn.textContent : ''; + if (btn) { + btn.disabled = true; + btn.textContent = '清除中…'; + } + try { + if ('caches' in window) await caches.delete(AUDIO_CACHE_NAME); + cachedTrackUrls = new Set(); + PLAYLIST = PLAYLIST.map(track => ({ ...track, isCached: false })); + renderPlaylist(); + updateCacheSummary(); + refreshCacheSize(); + if (!isOnline) refreshOfflineAvailability().then(() => { if (settingsOpen) renderSettings(); }); + showToast('已清除全部缓存'); + } catch (error) { + console.warn('Failed to clear cache:', error); + showToast('清除失败,请稍后重试'); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = oldText; + } + } +} + +/* ========== 登录与云端同步 ========== */ + +function openLoginOverlay() { + const overlay = document.getElementById('loginOverlay'); + if (!overlay) return; + document.getElementById('loginError').textContent = ''; + overlay.classList.add('open'); + setTimeout(() => { + const input = document.getElementById('loginUsername'); + if (input) input.focus(); + }, 250); +} + +function closeLoginOverlay() { + const overlay = document.getElementById('loginOverlay'); + if (overlay) overlay.classList.remove('open'); +} + +async function doLogin() { + const usernameInput = document.getElementById('loginUsername'); + const passwordInput = document.getElementById('loginPassword'); + const errorEl = document.getElementById('loginError'); + const btn = document.getElementById('btnLogin'); + const username = String(usernameInput.value || '').trim(); + const password = String(passwordInput.value || ''); + if (errorEl) errorEl.textContent = ''; + if (!username || !password) { + if (errorEl) errorEl.textContent = '请输入账号和密码'; + return; + } + const oldText = btn.textContent; + btn.disabled = true; + btn.textContent = '登录中…'; + try { + const response = await fetch(LOGIN_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data.ok) throw new Error(data.error || `登录失败(${response.status})`); + loggedInUser = username; + try { + localStorage.setItem(LOGGED_IN_USER_KEY, username); + } catch {} + if (passwordInput) passwordInput.value = ''; + closeLoginOverlay(); + renderAccountSection(); + showToast('登录成功'); + await pullCloudConfig(); + } catch (error) { + console.warn('Login failed:', error); + if (errorEl) errorEl.textContent = error.message || '登录失败'; + } finally { + btn.disabled = false; + btn.textContent = oldText; + } +} + +function doLogout() { + if (!window.confirm('确定退出登录?退出后将不再同步云端配置。')) return; + loggedInUser = ''; + try { + localStorage.removeItem(LOGGED_IN_USER_KEY); + localStorage.removeItem(PENDING_CONFIG_KEY); + } catch {} + renderAccountSection(); + showToast('已退出登录'); +} + +function renderAccountSection() { + const wrap = document.getElementById('accountSection'); + if (!wrap) return; + if (loggedInUser) { + wrap.innerHTML = ` + +
已登录,歌单、排序、续播进度等配置将自动同步到云端。
+ `; + } else { + wrap.innerHTML = ` + +
登录后可跨设备同步歌单选择、排序偏好、缓存模式与续播进度。
+ `; + } +} + +function collectPlaybackPositions() { + const result = {}; + try { + const prefix = `${PLAYBACK_POSITION_KEY_PREFIX}:`; + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (!key || !key.startsWith(prefix)) continue; + const suffix = key.slice(prefix.length); + if (!suffix.includes('::')) continue; + const seconds = Number(localStorage.getItem(key) || 0); + if (Number.isFinite(seconds) && seconds > 0) result[suffix] = seconds; + } + } catch {} + return result; +} + +function buildUserConfig() { + return { + selected_playlist: currentPlaylistFile, + resume_enabled: !!resumePlaybackEnabled, + cache_only: !!cacheOnly, + sort_mode: sortMode, + playback_positions: collectPlaybackPositions() + }; +} + +function scheduleCloudSync() { + if (!loggedInUser) return; + clearTimeout(cloudSyncTimer); + cloudSyncTimer = setTimeout(() => pushCloudConfig(), 1500); +} + +async function pushCloudConfig({ force = false } = {}) { + if (!loggedInUser) return; + const config = buildUserConfig(); + if (!isOnline) { + try { + localStorage.setItem(PENDING_CONFIG_KEY, JSON.stringify(config)); + } catch {} + return; + } + if (!force && Date.now() - lastCloudPushAt < 15000) { + clearTimeout(cloudSyncTimer); + cloudSyncTimer = setTimeout(() => pushCloudConfig({ force: true }), 15000); + return; + } + try { + const response = await fetch(CONFIG_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: loggedInUser, config }) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data.ok) throw new Error(data.error || `sync_http_${response.status}`); + lastCloudPushAt = Date.now(); + try { + localStorage.removeItem(PENDING_CONFIG_KEY); + } catch {} + } catch (error) { + console.warn('Cloud config push failed:', error); + try { + localStorage.setItem(PENDING_CONFIG_KEY, JSON.stringify(config)); + } catch {} + } +} + +async function flushPendingConfig() { + if (!loggedInUser || !isOnline) return; + try { + const pending = localStorage.getItem(PENDING_CONFIG_KEY); + if (pending) await pushCloudConfig({ force: true }); + } catch {} +} + +async function pullCloudConfig() { + if (!loggedInUser) return; + try { + const response = await fetch(`${CONFIG_API}?username=${encodeURIComponent(loggedInUser)}`, { cache: 'no-store' }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data.ok) throw new Error(data.error || `pull_http_${response.status}`); + if (!data.config) { + showToast('云端暂无配置,将以本机配置为准'); + await pushCloudConfig({ force: true }); + return; + } + await applyCloudConfig(data.config); + showToast('已同步云端配置'); + } catch (error) { + console.warn('Cloud config pull failed:', error); + showToast('云端配置拉取失败'); + } +} + +async function applyCloudConfig(config) { + try { + if (config.resume_enabled !== undefined) { + saveResumePlaybackEnabled(!!config.resume_enabled); + } + if (config.cache_only !== undefined) { + saveCacheOnlyEnabled(!!config.cache_only); + } + if (config.sort_mode) { + applySort(String(config.sort_mode), { fromCloud: true }); + } + if (config.playback_positions && typeof config.playback_positions === 'object') { + for (const [key, seconds] of Object.entries(config.playback_positions)) { + const parts = String(key).split('::'); + const value = Number(seconds); + if (parts.length >= 2 && Number.isFinite(value) && value > 0) { + try { + localStorage.setItem(`${PLAYBACK_POSITION_KEY_PREFIX}:${key}`, String(Math.floor(value))); + } catch {} + } + } + } + renderSettings(); + if (config.selected_playlist + && playlistFiles.includes(config.selected_playlist) + && config.selected_playlist !== currentPlaylistFile) { + await switchPlaylist(config.selected_playlist, { silent: true }); + } + } catch (error) { + console.warn('Apply cloud config failed:', error); + } +} + +/* ========== 离线模式 ========== */ + +function bindOnlineEvents() { + window.addEventListener('online', () => { + isOnline = true; + offlineAvailableFiles = null; + updateOfflineBadge(); + renderSettings(); + flushPendingConfig(); + showToast('网络已恢复'); + }); + window.addEventListener('offline', () => { + isOnline = false; + updateOfflineBadge(); + refreshOfflineAvailability().then(() => renderSettings()); + showToast('网络已断开,进入离线模式'); + }); +} + +function updateOfflineBadge() { + const badge = document.getElementById('offlineBadge'); + if (badge) badge.style.display = isOnline ? 'none' : 'inline-flex'; +} + +async function refreshOfflineAvailability() { + if (isOnline) { + offlineAvailableFiles = null; + return; + } + const available = []; + for (const file of playlistFiles) { + try { + const tracks = await fetchPlaylistData(file); + const hasCached = tracks.some(track => cachedTrackUrls.has(normalizeTrackSrc(track.src))); + if (hasCached) available.push(file); + } catch { + // 离线且无 SW 缓存的歌单,直接跳过 + } + } + offlineAvailableFiles = available; +} + +/* ========== 播放列表渲染 ========== */ + +function renderPlaylist() { + const wrap = document.getElementById('sheetList'); + const empty = document.getElementById('emptyState'); + wrap.querySelectorAll('.playlist-item').forEach(el => el.remove()); + if (!PLAYLIST.length) { + empty.style.display = 'block'; + return; + } + empty.style.display = 'none'; + displayOrder.forEach((originalIdx, displayPos) => { + const track = PLAYLIST[originalIdx]; + const div = document.createElement('div'); + div.className = `playlist-item${originalIdx === currentIdx ? ' active' : ''}`; + div.dataset.idx = originalIdx; + div.dataset.pos = displayPos; + div.innerHTML = ` +
${displayPos + 1}
+
+
+
+ ${escHtml(track.title || '未知曲目')} + + +
+
+
${escHtml(track.artist || '未知歌手')}
+
+ ${dlButtonHtml(track)} + `; + div.addEventListener('click', event => { + const btn = event.target.closest('.dl-btn'); + if (btn) { + if (!btn.disabled && !track.isCached) downloadTrack(track.src); + return; + } + if (originalIdx === currentIdx) { + togglePlay(); + } else { + const loaded = loadTrack(originalIdx, true); + if (loaded !== false) closeSheet(); + } + }); + wrap.appendChild(div); + }); + updatePlaylistHighlight(); + refreshPlaylistMarquee(); +} + +function updatePlaylistHighlight() { + document.querySelectorAll('.playlist-item').forEach(el => { + const idx = Number(el.dataset.idx); + const pos = Number(el.dataset.pos); + const isActive = idx === currentIdx; + el.classList.toggle('active', isActive); + const num = el.querySelector('.item-num'); + if (!num) return; + if (isActive && isPlaying) { + num.innerHTML = ''; + num.classList.add('playing-icon'); + } else if (isActive) { + num.textContent = '▶'; + num.classList.add('playing-icon'); + } else { + num.textContent = String(pos + 1); + num.classList.remove('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: APP_NAME, + artwork: track.cover ? [{ src: track.cover, sizes: '512x512', type: 'image/jpeg' }] : [] + }); +} + +function registerSW() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('sw.js').catch(() => {}); + } +} + +/* ========== 启动屏 ========== */ + +function hideSplash() { + const splash = document.getElementById('splash'); + if (!splash) return; + splash.classList.add('hide'); + setTimeout(() => { + splash.style.display = 'none'; + }, 700); +} + +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + saveCurrentPlaybackPosition({ force: true }); + pushCloudConfig({ force: true }); + } +}); + +window.addEventListener('beforeunload', () => { + saveCurrentPlaybackPosition({ force: true }); + pushCloudConfig({ force: true }); +}); + +function escHtml(s) { + return String(s || '').replace(/&/g, '&').replace(//g, '>'); +} + +function escAttr(s) { + return escHtml(s).replace(/"/g, '"'); +} + +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'), 2200); +} + +init() + .catch(error => console.warn('Init failed:', error)) + .finally(() => hideSplash()); +setTimeout(() => { + updateSliderFill(document.getElementById('progressBar'), 0); +}, 50); diff --git a/public/tools/zen_box/zen_box.js b/public/tools/zen_box/zen_box.js deleted file mode 100644 index 4441e16..0000000 --- a/public/tools/zen_box/zen_box.js +++ /dev/null @@ -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 - ? '1' - : ''; - 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 = ` -
${index + 1}
-
-
-
- ${escHtml(track.title || '未知曲目')} - - -
-
-
${escHtml(track.artist || '未知歌手')}
-
-
${track.isCached ? '已缓存' : '未缓存'}
- `; - 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, '&').replace(//g, '>'); -} - -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); diff --git a/src/server/data_gateway/index.js b/src/server/data_gateway/index.js index de2f6ee..0b64073 100644 --- a/src/server/data_gateway/index.js +++ b/src/server/data_gateway/index.js @@ -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 diff --git a/src/server/data_gateway/skills/email_sender/email.db-shm b/src/server/data_gateway/skills/email_sender/email.db-shm index 97010bf..d231638 100644 Binary files a/src/server/data_gateway/skills/email_sender/email.db-shm and b/src/server/data_gateway/skills/email_sender/email.db-shm differ diff --git a/src/server/data_gateway/skills/email_sender/email.db-wal b/src/server/data_gateway/skills/email_sender/email.db-wal index b891fc5..acc10a9 100644 Binary files a/src/server/data_gateway/skills/email_sender/email.db-wal and b/src/server/data_gateway/skills/email_sender/email.db-wal differ diff --git a/src/server/data_gateway/skills/private_clipboard/clipboard_bridge.js b/src/server/data_gateway/skills/private_clipboard/clipboard_bridge.js new file mode 100644 index 0000000..8eff7e7 --- /dev/null +++ b/src/server/data_gateway/skills/private_clipboard/clipboard_bridge.js @@ -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 } diff --git a/src/server/data_gateway/skills/private_clipboard/config.json b/src/server/data_gateway/skills/private_clipboard/config.json new file mode 100644 index 0000000..3b7f150 --- /dev/null +++ b/src/server/data_gateway/skills/private_clipboard/config.json @@ -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/)" +} diff --git a/src/server/data_gateway/skills/private_clipboard/index.js b/src/server/data_gateway/skills/private_clipboard/index.js new file mode 100644 index 0000000..59ec9a6 --- /dev/null +++ b/src/server/data_gateway/skills/private_clipboard/index.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +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 } diff --git a/src/server/index.js b/src/server/index.js index 086e394..8ebb36d 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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())