diff --git a/apps/app_order_box/kits/motion_reminder/api.ts b/apps/app_order_box/kits/motion_reminder/api.ts index 4da44f2..5bf9d45 100644 --- a/apps/app_order_box/kits/motion_reminder/api.ts +++ b/apps/app_order_box/kits/motion_reminder/api.ts @@ -1,7 +1,7 @@ import axios from 'axios'; -import { ApiEnvelope, MotionCard, MotionSession } from './types'; +import { ApiEnvelope, MotionCard, MotionSession, TTSPrepareResult } from './types'; -const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || '/api/app_order_box'; +export 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'; @@ -95,4 +95,17 @@ export const motionReminderApi = { throw new Error(formatError(error)); } }, + + /** 批量预合成整卡「最小播报单元」的云端 mp3,返回文本→URL 映射(服务端 md5 内容寻址,重复文本不会重复合成) */ + async prepareTTS(card: MotionCard): Promise { + try { + const response = await api.post>(`${MOTION_BASE}/tts/prepare`, { card }); + if (!response.data.ok || !response.data.data) { + throw new Error(response.data.error || '语音预合成失败'); + } + return response.data.data; + } catch (error) { + throw new Error(formatError(error)); + } + }, }; diff --git a/apps/app_order_box/kits/motion_reminder/hooks/useRunner.ts b/apps/app_order_box/kits/motion_reminder/hooks/useRunner.ts index 988b5af..bf231a9 100644 --- a/apps/app_order_box/kits/motion_reminder/hooks/useRunner.ts +++ b/apps/app_order_box/kits/motion_reminder/hooks/useRunner.ts @@ -1,7 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import * as Speech from 'expo-speech'; import { motionReminderApi } from '../api'; import { MotionCard, SessionStatus } from '../types'; +import { + countdownAtom, + prepareCardTTS, + speakAtoms, + stepSpeechAtoms, + stopSpeech, +} from '../speech'; export interface RunnerState { sessionId: string; @@ -24,17 +30,6 @@ export interface UseRunnerReturn { syncToServer: () => Promise; } -const formatSpeechDuration = (seconds: number) => { - const safe = Math.max(0, Math.floor(seconds || 0)); - const mm = Math.floor(safe / 60); - const ss = safe % 60; - const parts: string[] = []; - if (mm > 0) parts.push(`${mm}分钟`); - if (ss > 0) parts.push(`${ss}秒`); - if (!parts.length) parts.push('0秒'); - return parts.join(''); -}; - export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => { const [runner, setRunner] = useState(null); const announcementRef = useRef(''); @@ -55,7 +50,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => { const message = error instanceof Error ? error.message : '结束失败'; console.error('[Runner] 同步失败:', message); } finally { - Speech.stop(); + void stopSpeech(); setRunner(null); announcementRef.current = ''; onSessionEnd?.(); @@ -105,10 +100,9 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => { const isAction = step?.type === 'action'; const countdownPitch = isAction ? 1.1 : 0.9; - // 5/3 秒倒计时播报 + // 5/3 秒倒计时播报:文本与系统 TTS / 云端 mp3 查表共用同一原子 if (runner.remainingSec === 5 || runner.remainingSec === 3) { - Speech.stop(); - Speech.speak(`还有 ${runner.remainingSec} 秒`, { language: 'zh-CN', pitch: countdownPitch, rate: 0.95 }); + speakAtoms([countdownAtom(runner.remainingSec)], countdownPitch); } if (runner.remainingSec <= 1) { @@ -138,15 +132,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => { // 动作步骤用较高音调,休息步骤用较低音调 const stepPitch = step.type === 'action' ? 1.1 : 0.9; - - const title = step.voiceText || `${step.name},${formatSpeechDuration(step.durationSec || 0)}`; - const nextText = nextStep ? `。下一步,${nextStep.name}` : ''; - Speech.stop(); - Speech.speak(`第 ${runner.currentLoop} 轮,${title}${nextText}`, { - language: 'zh-CN', - pitch: stepPitch, - rate: 0.95, - }); + speakAtoms(stepSpeechAtoms(runner.currentLoop, step, nextStep, runner.card.loopMode), stepPitch); // 同步到服务端 void motionReminderApi.syncSession({ @@ -162,7 +148,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => { // 清理 useEffect(() => { return () => { - Speech.stop(); + void stopSpeech(); }; }, []); @@ -170,6 +156,8 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => { if (!card.steps.length) { throw new Error('当前卡片没有可执行步骤'); } + // 会话启动时批量预合成整卡云端语音(不阻塞启动,失败时运行期回退系统 TTS) + void prepareCardTTS(card); const session = await motionReminderApi.startSession(card); setRunner({ sessionId: session.id, @@ -238,4 +226,4 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => { finishSession: finishRunner, syncToServer, }; -}; +}; \ No newline at end of file diff --git a/apps/app_order_box/kits/motion_reminder/screens/CardsScreen.tsx b/apps/app_order_box/kits/motion_reminder/screens/CardsScreen.tsx index 00fc049..028df1f 100644 --- a/apps/app_order_box/kits/motion_reminder/screens/CardsScreen.tsx +++ b/apps/app_order_box/kits/motion_reminder/screens/CardsScreen.tsx @@ -13,6 +13,15 @@ import { motionReminderApi } from '../api'; import { useRunner } from '../hooks/useRunner'; import { CardEditor } from '../components/CardEditor'; import { RunnerModal } from '../components/RunnerModal'; +import { + WELCOME_TEXT, + configureSpeechAudioMode, + confirmSpeechHeard, + getSpeechState, + prepareCardTTS, + probeSpeech, + subscribeSpeechState, +} from '../speech'; import { theme } from '../../../theme'; import { MotionCard, MotionSession, OrderBoxState } from '../../../types'; @@ -40,11 +49,59 @@ export const CardsScreen: React.FC = ({ onExit, serverState, o const [loading, setLoading] = useState(true); const [editorVisible, setEditorVisible] = useState(false); const [editingCard, setEditingCard] = useState(null); + const [speechState, setSpeechState] = useState(getSpeechState()); + const [ttsRegenId, setTtsRegenId] = useState(null); const runner = useRunner(() => { onRefresh(); }); + useEffect(() => subscribeSpeechState(setSpeechState), []); + + const askSpeechHeard = () => { + Alert.alert( + '语音检查', + `正在播报欢迎语:${WELCOME_TEXT}\n请确认是否听到声音。`, + [ + { text: '没听到', style: 'destructive', onPress: () => confirmSpeechHeard(false) }, + { text: '听到了', onPress: () => confirmSpeechHeard(true) }, + ] + ); + }; + + const retrySpeechProbe = async () => { + const engineStarted = await probeSpeech(true); + if (engineStarted) askSpeechHeard(); + }; + + /** 手动补救:强制重建该卡片的云端语音缓存(内容寻址,已存在的文本不会重复调用百炼) */ + const regenCardTTS = async (card: MotionCard) => { + if (!card.steps.length) { + Alert.alert('无法生成', '当前卡片没有可执行步骤'); + return; + } + setTtsRegenId(card.id); + try { + const result = await prepareCardTTS(card, true); + if (!result) { + Alert.alert('生成失败', '云端语音生成失败,请检查服务与网络后重试(详情见控制台日志)。'); + return; + } + Alert.alert(`语音已重新生成(${result.success}/${result.total})`, '该卡片的云端语音缓存已强制重建。'); + } finally { + setTtsRegenId(null); + } + }; + + // 进入运动 kit 时:先配置音频会话(iOS 静音键下也要能播 mp3),再播欢迎语探测系统 TTS + useEffect(() => { + void configureSpeechAudioMode(); + void (async () => { + const engineStarted = await probeSpeech(); + if (engineStarted) askSpeechHeard(); + })(); + }, []); + const loadData = useCallback(async () => { try { const nextCards = await motionReminderApi.listCards(); @@ -141,6 +198,16 @@ export const CardsScreen: React.FC = ({ onExit, serverState, o ) : null} + {speechState.warning ? ( + + 语音播报可能不可用 + {speechState.warning} + + 重新检测 + + + ) : null} + {loading ? ( ) : ( @@ -177,6 +244,15 @@ export const CardsScreen: React.FC = ({ onExit, serverState, o deleteCard(item)}> 删除 + regenCardTTS(item)} + disabled={ttsRegenId === item.id} + > + + {ttsRegenId === item.id ? '生成中…' : '重生语音'} + + )} @@ -347,6 +423,12 @@ const styles = StyleSheet.create({ dangerText: { color: theme.colors.error, }, + voiceWarnCard: { + borderColor: theme.colors.error, + }, + buttonDisabled: { + opacity: 0.5, + }, emptyCard: { backgroundColor: theme.colors.surface, borderRadius: theme.borderRadius.lg, diff --git a/apps/app_order_box/kits/motion_reminder/speech.ts b/apps/app_order_box/kits/motion_reminder/speech.ts new file mode 100644 index 0000000..f6674b4 --- /dev/null +++ b/apps/app_order_box/kits/motion_reminder/speech.ts @@ -0,0 +1,349 @@ +// 运动提醒宝(motion_reminder)语音播报:系统 TTS + 云端 mp3 双通道兜底 +// 说明:项目 Rule 6 要求每个 tool 独立维护自己的第三方能力封装, +// 因此本文件不引用其他 kit 的语音实现,只依赖本 kit 的 ./api 与 expo-av / expo-speech。 +// 场景:安卓设备无系统 TTS 引擎、引擎被回收、或系统 TTS 静默失败时,改播服务端预合成的 mp3。 +// 语义与 Web 端 order_box.js 语音区保持一致:探测 → 预合成 → 运行期自动降级 → 不可用预警。 +import { Audio } from 'expo-av'; +import * as Speech from 'expo-speech'; +import { API_BASE_URL, motionReminderApi } from './api'; +import { MotionCard, MotionStep, TTSPrepareResult } from './types'; + +export type SpeechEngineMode = 'unknown' | 'system' | 'cloud' | 'none'; + +export interface SpeechState { + mode: SpeechEngineMode; + /** 非空即在卡片列表展示语音预警横幅 */ + warning: string; +} + +/** 欢迎语,同时承担「系统 TTS 是否真的出声」的探测信号;必须与服务端 tts.js 的 WELCOME_TEXT 逐字一致 */ +export const WELCOME_TEXT = '欢迎回来,每天锻炼身体好!'; + +// 系统语音引擎启动判定超时:超过该时间既没 onStart 也没 onError,视为引擎没起来 +const PROBE_TIMEOUT_MS = 1200; +// 云端 mp3 单条播放的兜底上限,避免回调异常导致播放队列卡死 +const CLOUD_PLAY_TIMEOUT_MS = 20000; + +const formatSpeechDuration = (seconds: number | null | undefined) => { + const safe = Math.max(0, Math.floor(Number(seconds) || 0)); + const mm = Math.floor(safe / 60); + const ss = safe % 60; + const parts: string[] = []; + if (mm > 0) parts.push(`${mm}分钟`); + if (ss > 0) parts.push(`${ss}秒`); + return parts.length ? parts.join('') : '0秒'; +}; + +// ==================== 状态(供页面订阅展示预警) ==================== + +let speechState: SpeechState = { mode: 'unknown', warning: '' }; +const stateListeners = new Set<(state: SpeechState) => void>(); + +const setSpeechState = (next: Partial) => { + const merged = { ...speechState, ...next }; + if (merged.mode === speechState.mode && merged.warning === speechState.warning) return; + speechState = merged; + stateListeners.forEach((listener) => { + try { + listener(speechState); + } catch {} + }); +}; + +export const getSpeechState = (): SpeechState => speechState; + +export const subscribeSpeechState = (listener: (state: SpeechState) => void) => { + stateListeners.add(listener); + return () => { + stateListeners.delete(listener); + }; +}; + +export const setSpeechMode = (mode: SpeechEngineMode, warning = '') => setSpeechState({ mode, warning }); + +export const setSpeechWarning = (warning: string) => setSpeechState({ warning }); + +// ==================== 音频会话与云端播放 ==================== + +let audioModeConfigured = false; + +/** iOS 静音键下也要能播云端 mp3,与系统 TTS 不受静音键影响的语义对齐 */ +export const configureSpeechAudioMode = async () => { + if (audioModeConfigured) return; + audioModeConfigured = true; + try { + await Audio.setAudioModeAsync({ + playsInSilentModeIOS: true, + staysActiveInBackground: false, + shouldDuckAndroid: true, + }); + } catch (error) { + console.warn('[order_box tts] 音频模式设置失败:', error); + } +}; + +let ttsUrlMap: Record = {}; +let activeSound: Audio.Sound | null = null; +let playToken = 0; +let cloudChain: Promise = Promise.resolve(); +let assetOriginWarned = false; + +/** 服务端返回的是 /uploads/... 相对路径,播放器需要绝对 URL(外网走后端注入的 https 基址) */ +const resolveAssetUrl = (url: string): string => { + const raw = String(url || '').trim(); + if (!raw) return ''; + if (/^https?:\/\//i.test(raw)) return raw; + const origin = API_BASE_URL.replace(/\/api\/app_order_box\/?$/i, '').replace(/\/+$/, ''); + if (!/^https?:\/\//i.test(origin)) { + if (!assetOriginWarned) { + assetOriginWarned = true; + console.warn('[order_box tts] EXPO_PUBLIC_API_BASE_URL 不是绝对地址,云端语音无法播放,将回退系统 TTS'); + } + return ''; + } + return origin + (raw.startsWith('/') ? raw : `/${raw}`); +}; + +const enqueueCloud = (task: () => Promise) => { + const run = cloudChain.then(task, task); + cloudChain = run.catch(() => {}); + return run; +}; + +const unloadActiveSound = async () => { + const sound = activeSound; + activeSound = null; + if (!sound) return; + try { + await sound.stopAsync(); + } catch {} + try { + await sound.unloadAsync(); + } catch {} +}; + +/** 云端 mp3 播放/加载失败的统一预警,与 Web 端一致:不静默失败 */ +const notifyCloudPlayFailed = () => + setSpeechWarning('云端语音播放失败,请检查网络后重试,或点「重新检测」。'); + +/** 播放一条 mp3 并等它播完;被 stopSpeech 打断时立即返回,避免队列卡住 */ +const playOneUrl = (url: string, token: number) => + new Promise((resolve) => { + let settled = false; + let timer: ReturnType | null = null; + const finish = () => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(); + }; + timer = setTimeout(finish, CLOUD_PLAY_TIMEOUT_MS); + + Audio.Sound.createAsync( + { uri: url }, + { shouldPlay: true }, + (status) => { + if (token !== playToken) { + finish(); + return; + } + if (!status.isLoaded) { + if (status.error) { + console.warn('[order_box tts] 云端语音播放失败:', status.error); + notifyCloudPlayFailed(); + finish(); + } + return; + } + if (status.didJustFinish) finish(); + } + ) + .then(({ sound }) => { + if (token !== playToken) { + void sound.unloadAsync().catch(() => {}); + finish(); + return; + } + activeSound = sound; + }) + .catch((error) => { + console.warn('[order_box tts] 云端语音加载失败:', error); + notifyCloudPlayFailed(); + finish(); + }); + }); + +/** + * 依次播放一组原子文本对应的 mp3。 + * 全部原子都命中缓存才返回 true;只要有一条查不到就整体回退系统 TTS,避免只念半句。 + */ +const playCloudAtoms = (atoms: string[]): boolean => { + const urls = atoms.map((text) => resolveAssetUrl(ttsUrlMap[text] || '')); + if (!urls.length || urls.some((url) => !url)) return false; + playToken += 1; + const token = playToken; + void unloadActiveSound().then(() => { + urls.forEach((url) => { + void enqueueCloud(() => (token === playToken ? playOneUrl(url, token) : Promise.resolve())); + }); + }); + return true; +}; + +// ==================== 系统 TTS ==================== + +/** 运行期系统 TTS 播报(不关心是否启动,失败由降级链兜底) */ +export const speakText = (text: string, pitch = 1) => { + const content = String(text || '').trim(); + if (!content) return; + try { + void Speech.stop(); + } catch {} + try { + Speech.speak(content, { language: 'zh-CN', pitch, rate: 0.95 }); + } catch (error) { + console.warn('[order_box tts] 系统语音播报异常:', error); + } +}; + +/** 带启动判定的系统 TTS:onStart 视为引擎已启动,onError / 超时视为没起来(探测专用) */ +export const speakSystem = (text: string, pitch = 1): Promise => + new Promise((resolve) => { + const content = String(text || '').trim(); + if (!content) { + resolve(false); + return; + } + let settled = false; + const settle = (ok: boolean) => { + if (settled) return; + settled = true; + resolve(ok); + }; + try { + void Speech.stop(); + } catch {} + try { + Speech.speak(content, { + language: 'zh-CN', + pitch, + rate: 0.95, + onStart: () => settle(true), + onError: () => settle(false), + }); + } catch { + settle(false); + return; + } + setTimeout(() => settle(false), PROBE_TIMEOUT_MS); + }); + +/** 统一播报入口:云端通道优先(命中缓存时),否则回退系统 TTS */ +export const speakAtoms = (atoms: string[], pitch = 1) => { + const list = atoms.map((text) => String(text || '').trim()).filter(Boolean); + if (!list.length) return; + if (speechState.mode === 'cloud') { + if (playCloudAtoms(list)) return; + if (!Object.keys(ttsUrlMap).length) { + setSpeechWarning('云端语音尚未预合成,请回卡片列表点「重生语音」后重试。'); + } + } + // 系统 TTS 用合并句播报更自然;'下一步' 前保留句号,保持原有语气 + const merged = list.map((text, index) => (index > 0 && text.indexOf('下一步') === 0 ? `。${text}` : text)).join(','); + speakText(merged, pitch); +}; + +export const stopSpeech = async () => { + try { + await Speech.stop(); + } catch {} + playToken += 1; + cloudChain = Promise.resolve(); + await unloadActiveSound(); +}; + +// ==================== 播报原子取词 ==================== + +/** 步骤播报的最小原子单元,必须与服务端 tts.js 的 buildCardTTSBatch 逐字一致(云端查表依赖) */ +export const stepSpeechAtoms = ( + loop: number, + step: MotionStep, + nextStep: MotionStep | null, + loopMode: string +): string[] => { + const atoms: string[] = []; + if (loopMode !== 'once') atoms.push(`第${loop}轮`); + const voiceText = String(step?.voiceText || '').trim(); + atoms.push(voiceText || `${String(step?.name || '').trim() || '步骤'},${formatSpeechDuration(step?.durationSec)}`); + if (nextStep) atoms.push(`下一步,${String(nextStep.name || '').trim() || '下一步'}`); + return atoms; +}; + +/** 倒计时原子,必须与服务端 tts.js 的 '还有 5 秒' / '还有 3 秒' 逐字一致 */ +export const countdownAtom = (seconds: number) => `还有 ${seconds} 秒`; + +// ==================== 预合成与探测 ==================== + +let ttsPreparedCardKey = ''; + +/** 卡片内容指纹:步骤/循环任一变化即视为新内容,需重新预合成(与 card.id 无关) */ +export const cardTTSFingerprint = (card: MotionCard): string => { + try { + return JSON.stringify({ + steps: (card.steps || []).map((step) => [ + String(step.name || ''), + Number(step.durationSec) || 0, + String(step.voiceText || ''), + String(step.type || ''), + ]), + loopMode: card.loopMode, + loopCount: card.loopCount, + }); + } catch { + return ''; + } +}; + +/** 批量预合成整卡语音;force=true 用于卡片列表的「重生语音」手动补救 */ +export const prepareCardTTS = async (card: MotionCard, force = false): Promise => { + if (!card?.id) return null; + const key = cardTTSFingerprint(card); + if (!force && key && ttsPreparedCardKey === key) return null; + ttsPreparedCardKey = key; + try { + const result = await motionReminderApi.prepareTTS(card); + ttsUrlMap = { ...ttsUrlMap, ...(result.map || {}) }; + console.log(`[order_box tts] 预合成完成 ${result.success}/${result.total}`); + if (speechState.mode === 'cloud' && Object.keys(ttsUrlMap).length) setSpeechWarning(''); + return result; + } catch (error) { + ttsPreparedCardKey = ''; + console.warn('[order_box tts] 预合成失败:', error); + return null; + } +}; + +let probeDone = false; + +/** + * 系统 TTS 是否真的可用:欢迎语自身当探测信号,onStart/onError + 1200ms 超时判定引擎是否启动; + * 「喇叭有没有真响」由调用方弹窗让用户确认。force=true 忽略「已探测过」强制重测。 + * 返回 true 表示引擎已启动(仍需用户听觉确认),false 表示引擎没起来(已自动切云端兜底)。 + */ +export const probeSpeech = async (force = false): Promise => { + if (probeDone && !force) return speechState.mode === 'system'; + probeDone = true; + const started = await speakSystem(WELCOME_TEXT, 1); + if (started) return true; + setSpeechMode('cloud', '系统语音未能启动,已切换云端语音兜底(需先启动一次流程完成预合成)。'); + return false; +}; + +/** 用户确认「听到了 / 没听到」后落定通道 */ +export const confirmSpeechHeard = (heard: boolean) => { + if (heard) { + setSpeechMode('system', ''); + return; + } + setSpeechMode('cloud', '系统语音未出声,已切换云端语音兜底(需先启动一次流程完成预合成)。'); +}; \ No newline at end of file diff --git a/apps/app_order_box/kits/motion_reminder/types.ts b/apps/app_order_box/kits/motion_reminder/types.ts index 12e0614..9cbc243 100644 --- a/apps/app_order_box/kits/motion_reminder/types.ts +++ b/apps/app_order_box/kits/motion_reminder/types.ts @@ -40,3 +40,12 @@ export interface ApiEnvelope { data?: T; error?: string; } + +/** POST /kits/motion_reminder/tts/prepare 的返回:map 为「播报原子文本 → 云端 mp3 URL」 */ +export interface TTSPrepareResult { + voice: string; + total: number; + success: number; + map: Record; + missing: string[]; +} diff --git a/apps/app_order_box/package-lock.json b/apps/app_order_box/package-lock.json index 20bdd60..284c21a 100644 --- a/apps/app_order_box/package-lock.json +++ b/apps/app_order_box/package-lock.json @@ -14,6 +14,7 @@ "@react-navigation/native": "^6.1.0", "axios": "^1.6.0", "expo": "~54.0.0", + "expo-av": "~16.0.8", "expo-haptics": "^57.0.0", "expo-keep-awake": "^57.0.0", "expo-speech": "~13.0.1", @@ -4452,6 +4453,23 @@ "react-native": "*" } }, + "node_modules/expo-av": { + "version": "16.0.8", + "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz", + "integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", diff --git a/apps/app_order_box/package.json b/apps/app_order_box/package.json index 3fb106d..7febdac 100644 --- a/apps/app_order_box/package.json +++ b/apps/app_order_box/package.json @@ -16,6 +16,7 @@ "@react-navigation/native": "^6.1.0", "axios": "^1.6.0", "expo": "~54.0.0", + "expo-av": "~16.0.8", "expo-haptics": "^57.0.0", "expo-keep-awake": "^57.0.0", "expo-speech": "~13.0.1", diff --git a/dev_test_scripts/debug/debug_pc_attachments_probe.js b/dev_test_scripts/debug/debug_pc_attachments_probe.js new file mode 100644 index 0000000..3a5a43f --- /dev/null +++ b/dev_test_scripts/debug/debug_pc_attachments_probe.js @@ -0,0 +1,11 @@ +// 一次性探针:查看個人收藏附件表里的文件名(排查乱码) +const db = require('better-sqlite3')('data/mp_personal_collect.db') +const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name) +console.log('tables:', JSON.stringify(tables)) +for (const t of tables) { + if (!/attach|upload|file/i.test(t)) continue + const cols = db.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name) + console.log('== table', t, 'cols:', JSON.stringify(cols)) + const rows = db.prepare(`SELECT * FROM ${t} ORDER BY ROWID DESC LIMIT 20`).all() + rows.forEach(r => console.log(' ', JSON.stringify(r))) +} \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_pc_trash_probe.js b/dev_test_scripts/debug/debug_pc_trash_probe.js new file mode 100644 index 0000000..88e6680 --- /dev/null +++ b/dev_test_scripts/debug/debug_pc_trash_probe.js @@ -0,0 +1,10 @@ +// 只读探针:验证回收站 store 函数可运行(不执行任何删除) +const store = require('../../src/server/mp_personal_collect/store.js') + +console.log('[funcs]', ['listTrash', 'restoreItem', 'purgeItem'].map(f => f + ':' + typeof store[f]).join(' ')) + +const trash = store.listTrash() +console.log('[trash] 条数:', trash.length) +trash.slice(0, 3).forEach(i => { + console.log(' id=', i.id, ' title=', JSON.stringify(i.title), ' 附件数=', (i.attachments || []).length) +}) \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..cb91bdb --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,62 @@ +/* ===================================================================== + * ESLint 配置:只启用 no-undef 一条规则 + * + * 目的:抓"引用了未声明的变量"这类运行时才暴露的错误 + * (例如 prepareCardTTS 里用了 ttsPreparedCardKey 但声明丢失)。 + * npm run lint 的 node --check 只做语法检查,看不见这类问题。 + * + * 覆盖范围按需渐进:目前只纳入 3 个高频维护的孪生 web tool, + * 后续某个 tool 清干净了,再往文件列表里加。 + * ===================================================================== */ +const globals = require('globals'); + +/** 三个孪生 web tool 目录(增量扩展时改这里即可) */ +const TWIN_WEB_TOOLS = [ + 'public/tools/web_order_box/**/*.js', + 'public/tools/web_zen_box/**/*.js', + 'public/tools/web_mobile_calendar_reminder/**/*.js' +]; + +/** 非浏览器运行环境的脚本,单独配置 */ +const SERVICE_WORKER_FILES = ['public/tools/web_zen_box/sw.js']; +const NODE_SCRIPT_FILES = ['public/tools/web_zen_box/generate-icons.js']; + +module.exports = [ + // 浏览器端 tool 脚本(IIFE / 全局脚本) + { + files: TWIN_WEB_TOOLS, + ignores: [...SERVICE_WORKER_FILES, ...NODE_SCRIPT_FILES], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'script', + globals: { ...globals.browser } + }, + rules: { + 'no-undef': 'error' + } + }, + // Service Worker(caches / clients / self / skipWaiting 等) + { + files: SERVICE_WORKER_FILES, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'script', + globals: { ...globals.serviceworker } + }, + rules: { + 'no-undef': 'error' + } + }, + // Node 辅助脚本(require / module / process 等) + { + files: NODE_SCRIPT_FILES, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'commonjs', + globals: { ...globals.node } + }, + rules: { + 'no-undef': 'error' + } + } +]; \ No newline at end of file diff --git a/miniprogram/mp_time_assistant/.gitignore b/miniprogram/mp_time_assistant/.gitignore new file mode 100644 index 0000000..14ea590 --- /dev/null +++ b/miniprogram/mp_time_assistant/.gitignore @@ -0,0 +1,14 @@ +# Windows +[Dd]esktop.ini +Thumbs.db +$RECYCLE.BIN/ + +# macOS +.DS_Store +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes + +# Node.js +node_modules/ diff --git a/miniprogram/mp_time_assistant/api.js b/miniprogram/mp_time_assistant/api.js index c7ce533..5391eb9 100644 --- a/miniprogram/mp_time_assistant/api.js +++ b/miniprogram/mp_time_assistant/api.js @@ -112,11 +112,30 @@ const personalCollect = { method: 'DELETE', data: { id }, }), - upload: (filePath, groupId = '') => uploadFile({ + trash: () => request({ + url: '/api/mp_personal_collect/kits/personal_collect/trash', + method: 'GET', + }), + restore: (id) => request({ + url: '/api/mp_personal_collect/kits/personal_collect/trash/restore', + method: 'PUT', + data: { id }, + }), + purge: (id) => request({ + url: '/api/mp_personal_collect/kits/personal_collect/trash', + method: 'DELETE', + data: { id }, + }), + upload: (filePath, groupId = '', originalName = '') => uploadFile({ url: '/api/mp_personal_collect/kits/personal_collect/upload', filePath, name: 'file', - formData: groupId ? { groupId } : {}, + // 原始文件名由小程序随 multipart 透传(wx.chooseMessageFile 的 name 是 UTF-8 完好), + // 避免服务端只能拿到桌面微信传输中已被损坏的多字节文件名 + formData: Object.assign( + groupId ? { groupId } : {}, + originalName ? { originalName } : {} + ), }), } diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.js b/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.js index cd98556..ec2496a 100644 --- a/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.js +++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.js @@ -134,7 +134,7 @@ Page({ const idx = list.length - 1 try { - const res = await api.personalCollect.upload(tempFile.path || tempFile.tempFilePath, this.data.uploadGroupId) + const res = await api.personalCollect.upload(tempFile.path || tempFile.tempFilePath, this.data.uploadGroupId, tempFile.name || '') const data = (res && res.data) || {} const attachment = { id: data.id || tempId, diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.js b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.js index 1fd6019..fa6e6d6 100644 --- a/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.js +++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.js @@ -13,6 +13,8 @@ Page({ loading: true, keyword: '', items: [], + trashItems: [], + trashMode: false, error: '', }, @@ -43,16 +45,28 @@ Page({ async refresh() { this.setData({ loading: true, error: '' }) try { - const res = await api.personalCollect.list(this.data.keyword || '') - const raw = Array.isArray(res.data) ? res.data : [] - const items = raw.map(item => ({ - ...item, - updatedAtText: formatDate(item.updatedAt || item.createdAt || ''), - tagsText: (item.tags || []).join(' · '), - previewImage: (item.attachments || []).find(a => a && a.isImage && a.filePath) || null, - fileCount: Array.isArray(item.attachments) ? item.attachments.length : 0, - })) - this.setData({ items }) + if (this.data.trashMode) { + const res = await api.personalCollect.trash() + const raw = Array.isArray(res.data) ? res.data : [] + const trashItems = raw.map(item => ({ + ...item, + deletedAtText: formatDate(item.updatedAt || item.createdAt || ''), + tagsText: (item.tags || []).join(' · '), + fileCount: Array.isArray(item.attachments) ? item.attachments.length : 0, + })) + this.setData({ trashItems }) + } else { + const res = await api.personalCollect.list(this.data.keyword || '') + const raw = Array.isArray(res.data) ? res.data : [] + const items = raw.map(item => ({ + ...item, + updatedAtText: formatDate(item.updatedAt || item.createdAt || ''), + tagsText: (item.tags || []).join(' · '), + previewImage: (item.attachments || []).find(a => a && a.isImage && a.filePath) || null, + fileCount: Array.isArray(item.attachments) ? item.attachments.length : 0, + })) + this.setData({ items }) + } } catch (e) { this.setData({ error: e && e.message ? e.message : '加载失败' }) } finally { @@ -107,4 +121,65 @@ Page({ url: '/kits/personal_collect/pages/edit/edit', }) }, + + onSwitchToTrash() { + if (this.data.trashMode) return + this.setData({ trashMode: true, keyword: '', error: '' }) + this.refresh() + }, + + onBackToList() { + if (!this.data.trashMode) return + this.setData({ trashMode: false, keyword: '', error: '' }) + this.refresh() + }, + + onRestoreTap(e) { + const id = e.currentTarget.dataset.id + const title = e.currentTarget.dataset.title || '此收藏' + wx.showLoading({ title: '恢复中...', mask: true }) + api.personalCollect.restore(id) + .then(() => { + wx.hideLoading() + wx.showToast({ title: '已恢复', icon: 'success' }) + this.refresh() + }) + .catch((err) => { + wx.hideLoading() + wx.showModal({ + title: '恢复失败', + content: err && err.message ? err.message : '请重试', + showCancel: false, + }) + }) + }, + + onPurgeTap(e) { + const id = e.currentTarget.dataset.id + const title = e.currentTarget.dataset.title || '此收藏' + const count = Number(e.currentTarget.dataset.count || 0) + wx.showModal({ + title: '彻底删除', + content: `「${title}」将从回收站永久删除${count > 0 ? ',同时清除 ' + count + ' 个附件文件' : ''},不可恢复!`, + confirmText: '彻底删除', + confirmColor: '#e05561', + success: async (res) => { + if (!res.confirm) return + try { + wx.showLoading({ title: '彻底删除中...', mask: true }) + await api.personalCollect.purge(id) + wx.hideLoading() + wx.showToast({ title: '已彻底删除', icon: 'success' }) + this.refresh() + } catch (err) { + wx.hideLoading() + wx.showModal({ + title: '删除失败', + content: err && err.message ? err.message : '请重试', + showCancel: false, + }) + } + }, + }) + }, }) diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxml b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxml index 8984437..3481bbc 100644 --- a/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxml +++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxml @@ -4,7 +4,12 @@ 保存聊天里的图片与文件,标签搜索管理。 - + + 收藏 + 回收站 + + + 🔍 加载中... - + 📁 {{keyword ? '没有匹配的收藏' : '还没有收藏,点右下角 + 新建'}} - + + + 🗑️ + 回收站是空的 + + + + @@ -72,6 +84,22 @@ + + + + + {{item.title}} + {{item.remark || '未命名收藏'}} + 删除于 {{item.deletedAtText}} · {{item.fileCount}} 个附件 + + + + 恢复 + + 彻底删除 + + + -+ ++ diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxss b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxss index ac9e9ee..00fb8c3 100644 --- a/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxss +++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxss @@ -1 +1,25 @@ /* 列表页独立样式,大部分复用 app.wxss 全局样式 */ + +/* 收藏 / 回收站 切换 Tab */ +.trash-tabs { + display: flex; + margin: 24rpx 32rpx 0; + background: rgba(255,255,255,0.06); + border-radius: 999rpx; + padding: 6rpx; + overflow: hidden; +} +.trash-tab { + flex: 1; + text-align: center; + padding: 14rpx 0; + font-size: 28rpx; + color: #9a9ab8; + border-radius: 999rpx; + transition: all 0.2s ease; +} +.trash-tab-active { + background: rgba(255,255,255,0.92); + color: #1a1a2e; + font-weight: 600; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0dfee76..984d949 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,8 @@ "devDependencies": { "css-loader": "^7.1.4", "esbuild": "^0.27.4", + "eslint": "^10.10.0", + "globals": "^17.12.0", "style-loader": "^4.0.0", "tinymce": "^6.8.3" }, @@ -108,6 +110,30 @@ "node": ">=6.9.0" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -550,6 +576,177 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@flatten-js/interval-tree": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.3.tgz", @@ -594,6 +791,72 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -666,6 +929,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/canvas": { "version": "0.1.80", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", @@ -1905,21 +2192,26 @@ "@types/estree": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/ms": { "version": "2.1.0", @@ -4303,7 +4595,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4325,6 +4616,16 @@ "acorn": "^8.14.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/adler-32": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", @@ -5012,6 +5313,20 @@ "node": ">= 0.8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -5685,6 +6000,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", @@ -6104,6 +6426,19 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/escodegen/-/escodegen-2.1.0.tgz", @@ -6125,6 +6460,65 @@ "source-map": "~0.6.1" } }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -6151,6 +6545,209 @@ "node": ">=4.0" } }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", @@ -6164,13 +6761,25 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -6335,8 +6944,7 @@ "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -6350,6 +6958,20 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", @@ -6377,6 +6999,16 @@ "pend": "~1.2.0" } }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -6414,6 +7046,25 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -6711,6 +7362,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -6719,6 +7383,19 @@ "license": "BSD-2-Clause", "peer": true }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", @@ -6775,6 +7452,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", @@ -6796,6 +7486,13 @@ "he": "bin/he" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -6997,6 +7694,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/imapflow": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.3.tgz", @@ -7061,6 +7768,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -7123,6 +7840,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -7132,6 +7859,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -7249,6 +7989,13 @@ "license": "MIT", "peer": true }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", @@ -7324,6 +8071,16 @@ "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", "license": "ISC" }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/kind-of": { "version": "3.2.2", "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-3.2.2.tgz", @@ -7402,6 +8159,20 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/libbase64": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", @@ -7909,6 +8680,13 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", @@ -8052,6 +8830,24 @@ "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", "license": "BSD-2-Clause" }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ot-json1": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/ot-json1/-/ot-json1-1.0.2.tgz", @@ -8531,6 +9327,16 @@ "node": ">=10" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -8662,6 +9468,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -9043,6 +9859,26 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -10315,6 +11151,19 @@ "node": "*" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", @@ -10474,6 +11323,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -10685,6 +11544,16 @@ "yauzl": "^2.10.0" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -10962,6 +11831,19 @@ "node": "*" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zip-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", diff --git a/package.json b/package.json index b53c8c1..21747e7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "upgrade:expense": "\"D:\\ProgramData\\nodejs_22\\node.exe\" scripts/upgrade_expense_db.js", "test": "\"D:\\ProgramData\\nodejs_22\\node.exe\" src/server/test_weekly_modular.js", "lint": "powershell -NoProfile -Command \"Get-ChildItem -Recurse -Filter *.js -Path src,public | Where-Object { $_.FullName -notmatch '\\\\node_modules\\\\' -and $_.FullName -notmatch '\\\\public\\\\lib\\\\' -and $_.FullName -notmatch '\\\\public\\\\tools\\\\cloud_notes\\\\tinymce\\\\' -and $_.FullName -notmatch '\\\\public\\\\tools\\\\rebalance\\\\lib\\\\' -and $_.Name -notmatch '\\.min\\.js$' } | ForEach-Object { & 'D:\\\\ProgramData\\\\nodejs_22\\\\node.exe' --check $_.FullName }\"", - "typecheck": "npm run lint" + "typecheck": "npm run lint", + "lint:undef": "eslint public/tools/web_order_box public/tools/web_zen_box public/tools/web_mobile_calendar_reminder" }, "dependencies": { "@alicloud/pop-core": "^1.8.0", @@ -48,6 +49,8 @@ "devDependencies": { "css-loader": "^7.1.4", "esbuild": "^0.27.4", + "eslint": "^10.10.0", + "globals": "^17.12.0", "style-loader": "^4.0.0", "tinymce": "^6.8.3" } diff --git a/public/index.css b/public/index.css index f2ec2ba..46259a0 100644 --- a/public/index.css +++ b/public/index.css @@ -692,13 +692,282 @@ body { align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.65); - z-index: 2000; + z-index: 2200; } .icon-picker.active { display: flex; } +/* ===== 书签后台维护:tab + 表格 + 分页 + 弹窗 ===== */ +.admin-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.admin-tab-btn { + opacity: 0.65; +} + +.admin-tab-btn.active { + opacity: 1; + background: rgba(59, 130, 246, 0.4); + border-color: rgba(99, 102, 241, 0.8); +} + +.admin-tab-page { + display: none; +} + +.admin-tab-page.active { + display: block; +} + +.admin-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 10px; +} + +.admin-toolbar .bookmark-search { + flex: 1 1 200px; + min-width: 160px; + margin: 0; +} + +.bookmark-filter-select { + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(0, 0, 0, 0.35); + color: #ffffff; + padding: 0.5rem 0.6rem; + outline: none; + max-width: 220px; +} + +.admin-count { + margin-left: auto; + font-size: 0.85rem; + opacity: 0.8; + white-space: nowrap; +} + +.admin-table-wrap { + overflow-x: auto; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(0, 0, 0, 0.25); +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; + min-width: 660px; +} + +.admin-table th, +.admin-table td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + vertical-align: middle; +} + +.admin-table th { + background: rgba(255, 255, 255, 0.06); + font-weight: 600; + white-space: nowrap; +} + +.admin-table tbody tr:last-child td { + border-bottom: none; +} + +.admin-table tbody tr:hover { + background: rgba(255, 255, 255, 0.05); +} + +.admin-table .th-actions, +.admin-table .td-actions { + text-align: right; + white-space: nowrap; +} + +.admin-table .td-actions .admin-row-actions { + justify-content: flex-end; +} + +.admin-table .td-icon { + width: 34px; + text-align: center; +} + +.admin-table .td-icon img { + width: 20px; + height: 20px; + object-fit: contain; + vertical-align: middle; +} + +.admin-table .td-main .td-sub { + font-size: 0.8rem; + opacity: 0.75; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-table .td-remark-wrap { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.admin-table .td-remark-wrap.expanded { + align-items: flex-start; +} + +.admin-table .td-remark-wrap.expanded .td-sub { + white-space: normal; + max-width: 420px; + overflow: visible; + text-overflow: clip; +} + +.remark-toggle { + flex: none; + background: none; + border: none; + color: #93c5fd; + cursor: pointer; + font-size: 0.8rem; + line-height: 1; + padding: 0 2px; + opacity: 0.9; +} + +.remark-toggle:hover { + opacity: 1; +} + +.admin-table .td-url { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-table .td-url .url-type-icon { + margin-right: 4px; + font-size: 0.85em; +} + +.admin-table .td-url a { + color: #93c5fd; + text-decoration: none; +} + +.admin-table .td-url a:hover { + text-decoration: underline; +} + +.admin-table .empty-row { + text-align: center; + color: rgba(255, 255, 255, 0.6); + padding: 18px 10px; +} + +.admin-pagination { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + justify-content: flex-end; + margin-top: 10px; +} + +.pagination-btn { + padding: 0.32rem 0.7rem; + font-size: 0.85rem; +} + +.pagination-btn.active { + background: rgba(59, 130, 246, 0.5); + border-color: rgba(99, 102, 241, 0.9); + opacity: 1; +} + +.pagination-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pagination-dots { + color: rgba(255, 255, 255, 0.6); + padding: 0 2px; +} + +/* 后台维护弹窗 */ +.nav-modal-overlay { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + z-index: 2100; + padding: 16px; +} + +.nav-modal-overlay.active { + display: flex; +} + +.nav-modal { + width: 100%; + max-width: 460px; + max-height: 86vh; + overflow-y: auto; + border-radius: 12px; + background: rgba(15, 20, 35, 0.98); + border: 1px solid rgba(255, 255, 255, 0.22); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + padding: 16px; +} + +.nav-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.nav-modal-title { + font-size: 1.05rem; + font-weight: 600; +} + +.nav-modal-close { + padding: 4px 10px; + line-height: 1; +} + +.nav-form .form-actions { + justify-content: flex-end; +} + +.nav-form button[type="submit"]:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .fitness-diary-reminder, .weekly-report-reminder { position: fixed; diff --git a/public/index.js b/public/index.js index 19e620b..fb59a6a 100644 --- a/public/index.js +++ b/public/index.js @@ -85,6 +85,19 @@ class ToolboxHome { this.editingCategoryCode = ''; this.editingBookmarkId = ''; this.iconPickerTarget = null; + // 后台维护状态(tab + 分页 + 筛选) + this.adminTab = 'bookmarks'; + this.adminCatPage = 1; + this.adminCatPageSize = 10; + this.adminBookPage = 1; + this.adminBookPageSize = 50; + this.adminBookCatFilter = ''; + this.adminBookQuery = ''; + this.adminCategories = []; + this.adminBookmarks = []; + this.adminCatTotal = 0; + this.adminBookTotal = 0; + this.adminSearchTimer = null; this.activeReminderKey = ''; this.reminderQueue = []; @@ -205,17 +218,78 @@ class ToolboxHome {
书签后台维护
-
-
-
分类维护
-
+
+ + +
+
+
+ + + + +
+
+
+ + + + + + + + + + + + + +
名称网址分类排序创建时间操作
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + +
编码名称排序书签数操作
+
+
+
+ `; + + // 后台维护弹窗(新增/编辑分类、书签共用) + const adminModal = document.createElement('div'); + adminModal.className = 'nav-modal-overlay'; + adminModal.id = 'nav-admin-modal'; + adminModal.innerHTML = ` +
`; @@ -334,6 +403,7 @@ class ToolboxHome {
`; document.body.appendChild(iconPicker); + document.body.appendChild(adminModal); const fitnessDiaryReminder = document.createElement('div'); fitnessDiaryReminder.className = 'fitness-diary-reminder'; @@ -383,13 +453,22 @@ class ToolboxHome { this.bookmarkContainer = bookmarkSections; this.bookmarkEmpty = bookmarkEmpty; this.bookmarkAdmin = bookmarkAdmin; - this.categoryForm = bookmarkAdmin.querySelector('#category-form'); - this.bookmarkForm = bookmarkAdmin.querySelector('#bookmark-form'); - this.categoryListEl = bookmarkAdmin.querySelector('#category-list'); - this.bookmarkListEl = bookmarkAdmin.querySelector('#bookmark-list'); + this.categoryForm = adminModal.querySelector('#admin-category-form'); + this.bookmarkForm = adminModal.querySelector('#admin-bookmark-form'); + this.categoryListEl = bookmarkAdmin.querySelector('#category-table-body'); + this.bookmarkListEl = bookmarkAdmin.querySelector('#bookmark-table-body'); this.bookmarkSearchInput = bookmarkToolbar.querySelector('#bookmark-search'); - this.bookmarkCreatedAtEl = bookmarkAdmin.querySelector('[data-field="created_at"]'); - this.iconPreviewEl = bookmarkAdmin.querySelector('[data-field="icon-preview"]'); + this.bookmarkCreatedAtEl = adminModal.querySelector('[data-field="created_at"]'); + this.iconPreviewEl = adminModal.querySelector('[data-field="icon-preview"]'); + this.navAdminModal = adminModal; + this.navAdminModalTitle = adminModal.querySelector('#nav-admin-modal-title'); + this.adminTabsEl = bookmarkAdmin.querySelector('#admin-tabs'); + this.categoryPaginationEl = bookmarkAdmin.querySelector('#category-pagination'); + this.bookmarkPaginationEl = bookmarkAdmin.querySelector('#bookmark-pagination'); + this.bookmarkAdminCatFilter = bookmarkAdmin.querySelector('#bookmark-admin-category-filter'); + this.bookmarkAdminSearchInput = bookmarkAdmin.querySelector('#bookmark-admin-search'); + this.categoryCountEl = bookmarkAdmin.querySelector('#category-count'); + this.bookmarkCountEl = bookmarkAdmin.querySelector('#bookmark-count'); this.iconPicker = iconPicker; this.iconPickerGrid = iconPicker.querySelector('.icon-picker-grid'); this.fitnessDiaryReminder = fitnessDiaryReminder; @@ -525,6 +604,7 @@ class ToolboxHome { this.adminVisible = false; if (this.bookmarkAdmin) this.bookmarkAdmin.classList.remove('active'); if (this.bookmarksPage) this.bookmarksPage.classList.remove('admin-visible'); + this.closeAdminModal(); } getIconUrl(icon) { @@ -631,58 +711,269 @@ class ToolboxHome { renderAdmin() { if (!this.bookmarkAdmin) return; - const categorySelect = this.bookmarkForm ? this.bookmarkForm.querySelector('select[name="category_code"]') : null; - if (categorySelect) { - categorySelect.innerHTML = ''; - const emptyOpt = document.createElement('option'); - emptyOpt.value = ''; - emptyOpt.textContent = '未分类'; - categorySelect.appendChild(emptyOpt); - this.categories.forEach(cat => { - const opt = document.createElement('option'); - opt.value = cat.code; - opt.textContent = `${cat.code} - ${cat.name}`; - categorySelect.appendChild(opt); - }); + if (!this.adminVisible) return; + this.switchAdminTab(this.adminTab); + } + + // 后台维护 tab 切换(分类 / 书签) + switchAdminTab(tab) { + if (!this.bookmarkAdmin) return; + this.adminTab = tab; + this.bookmarkAdmin.querySelectorAll('.admin-tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.admTab === tab); + }); + this.bookmarkAdmin.querySelectorAll('.admin-tab-page').forEach(p => { + p.classList.toggle('active', p.dataset.admPage === tab); + }); + if (tab === 'bookmarks') { + this.renderAdminCategoryFilter(); + this.loadAdminBookmarks(); + } else { + this.loadAdminCategories(); } - if (this.categoryListEl) { - this.categoryListEl.innerHTML = ''; - this.categories.forEach(cat => { - const row = document.createElement('div'); - row.className = 'admin-row'; - row.innerHTML = ` -
-
${cat.code} - ${cat.name}
-
排序 ${cat.sort_order || 0}
-
+ } + + esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + + // 判断网址是否为文件下载链接(按扩展名 + 下载关键字) + isFileUrl(url) { + const raw = String(url || ''); + const path = raw.split(/[?#]/)[0] || ''; + if (/\.(exe|msi|zip|rar|7z|tar|gz|bz2|pdf|doc|docx|xls|xlsx|ppt|pptx|csv|json|apk|ipa|dmg|pkg|iso|jar|torrent|xmind|dwg)(\/|$)/i.test(path)) return true; + if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|mp3|wav|flac|ogg|aac|mp4|mkv|mov|avi|m4a|wmv|flv|torrent)(\/|$)/i.test(path)) return true; + if (/\/(download|downloads|dl|get|file|files)\//i.test(path)) return true; + if (/[?&](download|dl|file|action)=/i.test(raw)) return true; + return false; + } + + // 统一按北京时间展示创建时间 + fmtNavTime(iso) { + if (!iso) return '-'; + try { + const parts = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false + }).formatToParts(new Date(iso)); + const get = t => { const p = parts.find(x => x.type === t); return p ? p.value : ''; }; + return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}`; + } catch { + return String(iso || '').slice(0, 16).replace('T', ' '); + } + } + + getCategoryName(code) { + const cat = (this.categories || []).find(c => String(c.code) === String(code == null ? '' : code)); + return cat ? cat.name : '未分类'; + } + + async loadAdminCategories() { + if (!this.bookmarkAdmin || !this.categoryListEl) return; + try { + const res = await fetch(`/api/nav_bookmarks/categories?page=${this.adminCatPage}&pageSize=${this.adminCatPageSize}&t=${Date.now()}`); + if (!res.ok) return; + const data = await res.json(); + if (!data || !data.ok) return; + this.adminCategories = Array.isArray(data.rows) ? data.rows : []; + this.adminCatTotal = Number(data.total || 0); + const totalPages = Math.max(1, Number(data.total_pages || 1)); + if (this.adminCatPage > totalPages) { + this.adminCatPage = totalPages; + this.loadAdminCategories(); + return; + } + this.renderAdminCategoryTable(); + if (this.categoryCountEl) this.categoryCountEl.textContent = `共 ${this.adminCatTotal} 个分类`; + this.renderAdminPagination(this.categoryPaginationEl, this.adminCatPage, totalPages, p => { this.adminCatPage = p; this.loadAdminCategories(); }); + } catch {} + } + + renderAdminCategoryTable() { + if (!this.categoryListEl) return; + if (!this.adminCategories.length) { + this.categoryListEl.innerHTML = '暂无分类'; + return; + } + this.categoryListEl.innerHTML = this.adminCategories.map(cat => ` + + ${this.esc(cat.code)} + ${this.esc(cat.name)} + ${Number(cat.sort_order || 0)} + ${Number(cat.bookmark_count || 0)} +
- - + +
- `; - this.categoryListEl.appendChild(row); - }); + + + `).join(''); + } + + async loadAdminBookmarks() { + if (!this.bookmarkAdmin || !this.bookmarkListEl) return; + try { + const p = new URLSearchParams({ page: String(this.adminBookPage), pageSize: String(this.adminBookPageSize), t: String(Date.now()) }); + if (this.adminBookCatFilter) p.set('category_code', this.adminBookCatFilter); + if (this.adminBookQuery) p.set('q', this.adminBookQuery); + const res = await fetch(`/api/nav_bookmarks/bookmarks?${p.toString()}`); + if (!res.ok) return; + const data = await res.json(); + if (!data || !data.ok) return; + this.adminBookmarks = Array.isArray(data.rows) ? data.rows : []; + this.adminBookTotal = Number(data.total || 0); + const totalPages = Math.max(1, Number(data.total_pages || 1)); + if (this.adminBookPage > totalPages) { + this.adminBookPage = totalPages; + this.loadAdminBookmarks(); + return; + } + this.renderAdminBookmarkTable(); + if (this.bookmarkCountEl) this.bookmarkCountEl.textContent = `共 ${this.adminBookTotal} 条`; + this.renderAdminPagination(this.bookmarkPaginationEl, this.adminBookPage, totalPages, p => { this.adminBookPage = p; this.loadAdminBookmarks(); }); + } catch {} + } + + renderAdminBookmarkTable() { + if (!this.bookmarkListEl) return; + if (!this.adminBookmarks.length) { + const hasFilter = this.adminBookCatFilter || String(this.adminBookQuery || '').trim(); + this.bookmarkListEl.innerHTML = `暂无书签${hasFilter ? '(筛选条件无结果)' : ''}`; + return; } - if (this.bookmarkListEl) { - this.bookmarkListEl.innerHTML = ''; - this.bookmarks.forEach(item => { - const cat = this.categories.find(c => String(c.code) === String(item.category_code)) || { name: '未分类' }; - const row = document.createElement('div'); - row.className = 'admin-row'; - row.innerHTML = ` -
-
${item.name || '未命名'}
-
${cat.name} · 排序 ${item.sort_order || 0}
-
-
- - -
- `; - this.bookmarkListEl.appendChild(row); - }); + this.bookmarkListEl.innerHTML = this.adminBookmarks.map(item => { + const iconUrl = this.getIconUrl(item.icon); + const iconHtml = iconUrl ? `` : ''; + const isFile = this.isFileUrl(item.url); + const urlIcon = `${isFile ? '📄' : '🔗'}`; + const remarkHtml = item.remark ? ` +
+ ${this.esc(item.remark)} + +
` : ''; + return ` + + ${iconHtml} + +
${this.esc(item.name || '未命名')}
+ ${remarkHtml} + + ${urlIcon}${this.esc(item.url || '-')} + ${this.esc(this.getCategoryName(item.category_code))} + ${Number(item.sort_order || 0)} + ${this.fmtNavTime(item.created_at)} + +
+ + +
+ + + `; + }).join(''); + // 备注未超长时隐藏展开按钮,保证行高低稳定 + this.bookmarkListEl.querySelectorAll('.td-remark-wrap').forEach(wrap => { + const sub = wrap.querySelector('.td-sub'); + const btn = wrap.querySelector('.remark-toggle'); + if (sub && btn && sub.scrollWidth <= sub.clientWidth + 1) btn.style.visibility = 'hidden'; + }); + } + + // 书签管理 tab 顶部的分类筛选下拉(用全量分类填充) + renderAdminCategoryFilter() { + if (!this.bookmarkAdminCatFilter) return; + const current = this.bookmarkAdminCatFilter.value || this.adminBookCatFilter; + this.bookmarkAdminCatFilter.innerHTML = ''; + const all = document.createElement('option'); + all.value = ''; + all.textContent = '全部分类'; + this.bookmarkAdminCatFilter.appendChild(all); + (this.categories || []).forEach(cat => { + const opt = document.createElement('option'); + opt.value = cat.code; + opt.textContent = `${cat.code} - ${cat.name}`; + this.bookmarkAdminCatFilter.appendChild(opt); + }); + if (current) this.bookmarkAdminCatFilter.value = current; + } + + renderAdminPagination(container, page, totalPages, onChange) { + if (!container) return; + container.innerHTML = ''; + if (totalPages <= 1) return; + const mkBtn = (text, p, disabled, active) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'bookmark-btn pagination-btn' + (active ? ' active' : ''); + b.textContent = text; + b.disabled = !!disabled; + if (!disabled) b.addEventListener('click', () => onChange(p)); + return b; + }; + const mkDots = () => { + const s = document.createElement('span'); + s.className = 'pagination-dots'; + s.textContent = '…'; + return s; + }; + container.appendChild(mkBtn('上一页', page - 1, page <= 1, false)); + const start = Math.max(1, page - 2); + const end = Math.min(totalPages, page + 2); + if (start > 1) { + container.appendChild(mkBtn('1', 1, false, false)); + if (start > 2) container.appendChild(mkDots()); } - this.updateIconPreview(); + for (let p = start; p <= end; p++) container.appendChild(mkBtn(String(p), p, false, p === page)); + if (end < totalPages) { + if (end < totalPages - 1) container.appendChild(mkDots()); + container.appendChild(mkBtn(String(totalPages), totalPages, false, false)); + } + container.appendChild(mkBtn('下一页', page + 1, page >= totalPages, false)); + } + + openAdminModal(type, title) { + if (!this.navAdminModal) return; + const isCat = type === 'category'; + if (this.navAdminModalTitle) this.navAdminModalTitle.textContent = title || (isCat ? '新增分类' : '新增书签'); + if (this.categoryForm) this.categoryForm.style.display = isCat ? '' : 'none'; + if (this.bookmarkForm) this.bookmarkForm.style.display = isCat ? 'none' : ''; + if (!isCat) this.prefillBookmarkSelect(); + if (this.iconPicker) this.iconPicker.classList.remove('active'); + this.navAdminModal.classList.add('active'); + } + + closeAdminModal() { + if (this.navAdminModal) this.navAdminModal.classList.remove('active'); + } + + openCategoryModal(data) { + this.openAdminModal('category', data ? '编辑分类' : '新增分类'); + if (data) this.fillCategoryForm(data); else this.resetCategoryForm(); + } + + openBookmarkModal(data) { + this.openAdminModal('bookmark', data ? '编辑书签' : '新增书签'); + if (data) this.fillBookmarkForm(data); else this.resetBookmarkForm(); + } + + // 书签表单里的分类下拉(用全量分类填充) + prefillBookmarkSelect() { + const sel = this.bookmarkForm ? this.bookmarkForm.querySelector('select[name="category_code"]') : null; + if (!sel) return; + const current = sel.value; + sel.innerHTML = ''; + const emptyOpt = document.createElement('option'); + emptyOpt.value = ''; + emptyOpt.textContent = '未分类'; + sel.appendChild(emptyOpt); + (this.categories || []).forEach(cat => { + const opt = document.createElement('option'); + opt.value = cat.code; + opt.textContent = `${cat.code} - ${cat.name}`; + sel.appendChild(opt); + }); + if (current) sel.value = current; } setupEventListeners() { @@ -774,6 +1065,70 @@ class ToolboxHome { }); } + // 后台维护:管理员 tab 切换(分类 / 书签) + if (this.adminTabsEl) { + this.adminTabsEl.addEventListener('click', (e) => { + const btn = e.target.closest('.admin-tab-btn'); + if (!btn) return; + this.switchAdminTab(btn.dataset.admTab); + }); + } + + // 新增分类 / 新增书签 + const categoryAddBtn = document.getElementById('category-add-btn'); + if (categoryAddBtn) { + categoryAddBtn.addEventListener('click', () => { + this.openCategoryModal(null); + }); + } + const bookmarkAddBtn = document.getElementById('bookmark-add-btn'); + if (bookmarkAddBtn) { + bookmarkAddBtn.addEventListener('click', () => { + this.openBookmarkModal(null); + }); + } + + // 书签管理:分类筛选 + 关键词搜索(300ms 防抖) + if (this.bookmarkAdminCatFilter) { + this.bookmarkAdminCatFilter.addEventListener('change', () => { + this.adminBookCatFilter = this.bookmarkAdminCatFilter.value; + this.adminBookPage = 1; + this.loadAdminBookmarks(); + }); + } + if (this.bookmarkAdminSearchInput) { + this.bookmarkAdminSearchInput.addEventListener('input', () => { + clearTimeout(this.adminSearchTimer); + this.adminSearchTimer = setTimeout(() => { + this.adminBookQuery = this.bookmarkAdminSearchInput.value.trim(); + this.adminBookPage = 1; + this.loadAdminBookmarks(); + }, 300); + }); + } + const adminSearchClear = document.getElementById('bookmark-admin-search-clear'); + if (adminSearchClear) { + adminSearchClear.addEventListener('click', () => { + if (this.bookmarkAdminSearchInput) this.bookmarkAdminSearchInput.value = ''; + this.adminBookQuery = ''; + this.adminBookPage = 1; + this.loadAdminBookmarks(); + }); + } + + // 后台维护弹窗:关闭按钮 / 点击遮罩关闭 + const navModalClose = document.getElementById('nav-admin-modal-close'); + if (navModalClose) { + navModalClose.addEventListener('click', () => { + this.closeAdminModal(); + }); + } + if (this.navAdminModal) { + this.navAdminModal.addEventListener('click', (e) => { + if (e.target === this.navAdminModal) this.closeAdminModal(); + }); + } + if (this.bookmarkContainer) { this.bookmarkContainer.addEventListener('click', (e) => { const toggle = e.target.closest('.category-toggle'); @@ -785,6 +1140,7 @@ class ToolboxHome { } if (this.categoryForm) { + const categorySubmitBtn = this.categoryForm.querySelector('button[type="submit"]'); this.categoryForm.addEventListener('submit', async (e) => { e.preventDefault(); const data = new FormData(this.categoryForm); @@ -793,6 +1149,8 @@ class ToolboxHome { name: String(data.get('name') || '').trim(), sort_order: Number(data.get('sort_order') || 0) }; + const wasEditing = !!this.editingCategoryCode; + if (categorySubmitBtn) { categorySubmitBtn.disabled = true; categorySubmitBtn.textContent = '保存中…'; } try { if (this.editingCategoryCode) { await this.apiRequest(`/api/nav_bookmarks/categories/${encodeURIComponent(this.editingCategoryCode)}`, { @@ -808,20 +1166,28 @@ class ToolboxHome { }); } this.resetCategoryForm(); + this.closeAdminModal(); + if (!wasEditing) this.adminCatPage = 1; await this.loadBookmarks(); + await this.loadAdminCategories(); + this.renderAdminCategoryFilter(); } catch (err) { alert(err.message || '保存失败'); + } finally { + if (categorySubmitBtn) { categorySubmitBtn.disabled = false; categorySubmitBtn.textContent = '保存'; } } }); const cancelBtn = this.categoryForm.querySelector('[data-action="cancel-category"]'); if (cancelBtn) { cancelBtn.addEventListener('click', () => { this.resetCategoryForm(); + this.closeAdminModal(); }); } } if (this.bookmarkForm) { + const bookmarkSubmitBtn = this.bookmarkForm.querySelector('button[type="submit"]'); this.bookmarkForm.addEventListener('submit', async (e) => { e.preventDefault(); const data = new FormData(this.bookmarkForm); @@ -833,6 +1199,8 @@ class ToolboxHome { remark: String(data.get('remark') || '').trim(), sort_order: Number(data.get('sort_order') || 0) }; + const wasEditing = !!this.editingBookmarkId; + if (bookmarkSubmitBtn) { bookmarkSubmitBtn.disabled = true; bookmarkSubmitBtn.textContent = '保存中…'; } try { if (this.editingBookmarkId) { await this.apiRequest(`/api/nav_bookmarks/bookmarks/${encodeURIComponent(this.editingBookmarkId)}`, { @@ -848,15 +1216,21 @@ class ToolboxHome { }); } this.resetBookmarkForm(); + this.closeAdminModal(); + if (!wasEditing) this.adminBookPage = 1; await this.loadBookmarks(); + await this.loadAdminBookmarks(); } catch (err) { alert(err.message || '保存失败'); + } finally { + if (bookmarkSubmitBtn) { bookmarkSubmitBtn.disabled = false; bookmarkSubmitBtn.textContent = '保存'; } } }); const cancelBtn = this.bookmarkForm.querySelector('[data-action="cancel-bookmark"]'); if (cancelBtn) { cancelBtn.addEventListener('click', () => { this.resetBookmarkForm(); + this.closeAdminModal(); }); } this.bookmarkForm.addEventListener('input', (e) => { @@ -886,14 +1260,16 @@ class ToolboxHome { const action = btn.dataset.action; const code = btn.dataset.code; if (action === 'edit-category') { - const cat = this.categories.find(c => String(c.code) === String(code)); - if (cat) this.fillCategoryForm(cat); + const cat = this.adminCategories.find(c => String(c.code) === String(code)) || this.categories.find(c => String(c.code) === String(code)); + if (cat) this.openCategoryModal(cat); } if (action === 'delete-category') { if (!confirm('确定删除该分类吗?')) return; try { await this.apiRequest(`/api/nav_bookmarks/categories/${encodeURIComponent(code)}`, { method: 'DELETE' }); await this.loadBookmarks(); + await this.loadAdminCategories(); + this.renderAdminCategoryFilter(); } catch (err) { alert(err.message || '删除失败'); } @@ -902,23 +1278,29 @@ class ToolboxHome { } if (this.bookmarkListEl) { - this.bookmarkListEl.addEventListener('click', async (e) => { + this.bookmarkListEl.addEventListener('click', (e) => { const btn = e.target.closest('button[data-action]'); if (!btn) return; const action = btn.dataset.action; const id = btn.dataset.id; + if (action === 'toggle-remark') { + const wrap = btn.closest('.td-remark-wrap'); + if (!wrap) return; + const expanded = wrap.classList.toggle('expanded'); + btn.textContent = expanded ? '👆' : '👇'; + btn.title = expanded ? '收起备注' : '展开/收起备注'; + return; + } if (action === 'edit-bookmark') { - const item = this.bookmarks.find(b => String(b.id) === String(id)); - if (item) this.fillBookmarkForm(item); + const item = this.adminBookmarks.find(b => String(b.id) === String(id)) || this.bookmarks.find(b => String(b.id) === String(id)); + if (item) this.openBookmarkModal(item); } if (action === 'delete-bookmark') { if (!confirm('确定删除该书签吗?')) return; - try { - await this.apiRequest(`/api/nav_bookmarks/bookmarks/${encodeURIComponent(id)}`, { method: 'DELETE' }); + this.apiRequest(`/api/nav_bookmarks/bookmarks/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(async () => { await this.loadBookmarks(); - } catch (err) { - alert(err.message || '删除失败'); - } + await this.loadAdminBookmarks(); + }).catch(err => alert(err.message || '删除失败')); } }); } @@ -1165,7 +1547,7 @@ class ToolboxHome { if (!this.bookmarkForm) return; this.editingBookmarkId = String(item.id); const nameInput = this.bookmarkForm.querySelector('input[name="name"]'); - const urlInput = this.bookmarkForm.querySelector('input[name="url"]'); + const urlInput = this.bookmarkForm.querySelector('textarea[name="url"]'); const catSelect = this.bookmarkForm.querySelector('select[name="category_code"]'); const iconInput = this.bookmarkForm.querySelector('input[name="icon"]'); const remarkInput = this.bookmarkForm.querySelector('textarea[name="remark"]'); diff --git a/public/tools/web_order_box/index.html b/public/tools/web_order_box/index.html index 95d0364..3cc3e34 100644 --- a/public/tools/web_order_box/index.html +++ b/public/tools/web_order_box/index.html @@ -51,6 +51,12 @@
如果这是上次异常退出遗留,可以先清理占用再重新启动。
+ +
' + '' + '' + + '' + '
' + ''; }).join(''); @@ -600,6 +606,23 @@ 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); + else if (btn.classList.contains('btn-regen-tts')) handleRegenCardTTS(card, btn); + } + + /** 手动补救:强制重建该卡片的云端语音缓存;平时无需点击,编辑后遇到没声音时用 */ + async function handleRegenCardTTS(card, btn) { + if (!Array.isArray(card.steps) || !card.steps.length) { + showAlert('无法生成', '当前卡片没有可执行步骤'); + return; + } + setButtonLoading(btn, '生成中…'); + const res = await runTrackedInteraction('重生卡片语音', function () { + return prepareCardTTS(card, true); + }); + resetButton(btn); + if (!res || !res.ok) return; + const d = res.data || {}; + showToast('语音已重新生成(' + (Number(d.success) || 0) + '/' + (Number(d.total) || 0) + ')', 'success'); } function handleDeleteCard(card) { @@ -630,6 +653,8 @@ // 必须在 await 之前(用户手势上下文中)解锁音频和语音 unlockAudio(); unlockSpeech(); + // 会话启动时批量预合成整卡云端语音(不阻塞启动,失败时运行期回退系统 TTS) + prepareCardTTS(card); setButtonLoading(btn, '启动中…'); const res = await runTrackedInteraction('启动执行会话', function () { return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/start', { card: card }); @@ -1344,19 +1369,173 @@ } // ==================== 语音播报 ==================== - // Web Speech API:动作 pitch=1.1,休息 pitch=0.9,rate=0.95,lang=zh-CN - // 去重:同一步骤(sessionId:loop:stepIndex)不重复播报 + // 双通道策略(解决"有时能读、有时不能读、安卓平板完全不能读"): + // 通道1 系统 TTS(Web Speech API):音质自然,iPhone 静音键下仍能发声,优先生效; + // 通道2 云端 mp3(服务端 DashScope 预合成):系统 TTS 无引擎 / 静默失败时兜底。 + // 判定链路:进入 kit 播欢迎语探测 → 用户确认 → 失败切云端 → 两者皆不可用则显式预警。 + // 去重:同一步骤(sessionId:loop:stepIndex)不重复播报。 + const WELCOME_TEXT = '欢迎回来,每天锻炼身体好!'; + // 部分安卓 Chrome 无语音引擎时既不触发 onstart 也不触发 onerror,只能靠超时兜底 + const SPEECH_PROBE_TIMEOUT_MS = 1200; + + // 'unknown' 未判定 | 'system' 系统 TTS | 'cloud' 云端 mp3 | 'none' 两者皆不可用 + let speechEngineMode = 'unknown'; + let speechProbeDone = false; + let voiceWarned = false; + + // 文本 → 云端 mp3 URL 映射,由 prepareCardTTS 预合成填充 + let ttsUrlMap = {}; + // 已预合成卡片的内容指纹(不是 card.id):编辑流程后内容变化即视为新内容,需重新预合成 + let ttsPreparedCardKey = ''; + + // 云端音频播放器(单实例串行队列,避免多条播报叠在一起) + let speechAudioEl = null; + let cloudQueue = []; + let cloudQueueBusy = false; + + function showVoiceWarning(msg) { + const el = document.getElementById('voiceWarning'); + if (!el) return; + const descEl = document.getElementById('voiceWarningDesc'); + if (descEl && msg) descEl.textContent = msg; + el.style.display = ''; + } + + function hideVoiceWarning() { + const el = document.getElementById('voiceWarning'); + if (el) el.style.display = 'none'; + } + + /** 语音彻底不可用:显式预警,避免用户误以为正在静默播报 */ + function markVoiceUnavailable(reason) { + speechEngineMode = 'none'; + showVoiceWarning(reason || '本机无法播放语音,请检查系统语音引擎与音量设置。'); + if (!voiceWarned) { + voiceWarned = true; + showToast('本机无法播放语音,请检查系统语音引擎或音量', 'error', 4000); + } + } + + /** 系统 TTS 单句播报;用 onstart / onerror / 超时判断引擎是否真的启动 */ + function speakSystem(text, pitch) { + return new Promise(function (resolve) { + let settled = false; + let timer = null; + const finish = function (ok, error) { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve({ ok: ok, error: error || '' }); + }; + try { + if (!('speechSynthesis' in window) || typeof SpeechSynthesisUtterance === 'undefined') { + finish(false, 'unsupported'); + return; + } + const synth = window.speechSynthesis; + try { synth.cancel(); } catch (e) { /* 静默 */ } + const utterance = new SpeechSynthesisUtterance(String(text || '')); + utterance.lang = 'zh-CN'; + utterance.pitch = Number(pitch) || 1; + utterance.rate = 0.95; + timer = setTimeout(function () { finish(false, 'start-timeout'); }, SPEECH_PROBE_TIMEOUT_MS); + utterance.onstart = function () { finish(true, ''); }; + utterance.onerror = function (ev) { finish(false, (ev && ev.error) || 'utterance-error'); }; + // cancel() 紧跟 speak() 在 Chrome 上存在竞态会丢句,延后一拍再播 + setTimeout(function () { + try { synth.speak(utterance); } catch (e) { finish(false, String((e && e.message) || e)); } + }, 60); + } catch (e) { + finish(false, String((e && e.message) || e)); + } + }); + } + + /** 云端 mp3 单条播放,Promise */ + function playCloudUrl(url) { + return new Promise(function (resolve) { + let settled = false; + const finish = function (ok) { + if (settled) return; + settled = true; + resolve(ok); + }; + try { + if (speechAudioEl) { try { speechAudioEl.pause(); } catch (e) { /* 静默 */ } } + const audio = new Audio(url); + audio.preload = 'auto'; + speechAudioEl = audio; + audio.onended = function () { finish(true); }; + audio.onerror = function () { finish(false); }; + const playPromise = audio.play(); + // 自动播放策略拦截 / 静音键 / 解码失败:play() 会 reject + if (playPromise && typeof playPromise.catch === 'function') { + playPromise.catch(function () { finish(false); }); + } + } catch (e) { + finish(false); + } + }); + } + + function stopCloudSpeech() { + cloudQueue = []; + cloudQueueBusy = false; + if (speechAudioEl) { + try { speechAudioEl.pause(); } catch (e) { /* 静默 */ } + speechAudioEl = null; + } + } + + function runCloudQueue() { + if (!cloudQueueBusy) return; + const url = cloudQueue.shift(); + if (!url) { + cloudQueueBusy = false; + return; + } + playCloudUrl(url).then(function (ok) { + if (!ok) { + cloudQueueBusy = false; + markVoiceUnavailable('云端语音播放失败,请检查音量与静音键。'); + return; + } + runCloudQueue(); + }); + } + + /** 顺序播放一批云端 mp3;返回是否存在可播的缓存音频 */ + function playCloudAtoms(atoms) { + const list = (Array.isArray(atoms) ? atoms : [atoms]) + .map(function (t) { return String(t || '').trim(); }) + .filter(Boolean); + const urls = list.map(function (t) { return ttsUrlMap[t]; }).filter(Boolean); + if (!urls.length) return false; + stopCloudSpeech(); + cloudQueue = urls.slice(); + cloudQueueBusy = true; + runCloudQueue(); + return true; + } + + /** 统一播报入口:优先系统 TTS,静默失败时自动降级到云端 mp3 */ 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) { /* 不支持时静默 */ } + const t = String(text || '').trim(); + if (!t) return; + if (speechEngineMode === 'cloud' && playCloudAtoms(t)) return; + speakSystem(t, pitch).then(function (r) { + if (r.ok) return; + // 系统 TTS 本次失败:有云端音频就切过去,否则显式预警 + if (playCloudAtoms(t)) { + if (speechEngineMode !== 'cloud') { + speechEngineMode = 'cloud'; + showToast('已自动切换到云端语音播报', 'info', 2000); + } + return; + } + markVoiceUnavailable('系统语音与云端语音均不可用,请检查系统语音引擎或音量设置。'); + }); } /** 在用户手势中调用,解锁 iOS Safari 的语音合成限制 */ @@ -1374,6 +1553,112 @@ try { if ('speechSynthesis' in window) window.speechSynthesis.cancel(); } catch (e) { /* 静默 */ } + stopCloudSpeech(); + } + + /** + * 进入运动 kit 时的语音探测:用欢迎语本身当探测信号(无需另造探测音), + * 引擎是否启动由 onstart/onerror 判定,「喇叭有没有真响」由用户点按钮确认。 + * 必须在用户手势上下文中调用;force=true 时忽略"已探测过"强制重测。 + */ + function probeSpeech(force) { + if (speechProbeDone && !force) return; + speechProbeDone = true; + let answered = false; + const settle = function (mode, tip) { + if (answered) return; + answered = true; + speechEngineMode = mode; + // 必须收起探测弹窗:否则模态遮罩会一直盖住页面,导致后面所有按钮都点不动 + closeDialog(); + if (tip) showVoiceWarning(tip); else hideVoiceWarning(); + }; + speakSystem(WELCOME_TEXT, 1).then(function (r) { + if (answered) return; + if (r.ok) return; // 引擎已启动,等用户确认是否真的听到 + settle('cloud', '系统语音未能启动,已切换云端语音兜底(需先启动一次流程完成预合成)。'); + }); + showDialog('语音检查', + '正在播报欢迎语:
' + escHtml(WELCOME_TEXT) + '
请确认是否听到声音。', + [ + { + text: '没听到', + style: 'default', + onPress: function () { settle('cloud', '已切换到云端语音兜底(需先启动一次流程完成预合成)。'); } + }, + { + text: '听到了', + style: 'primary', + onPress: function () { settle('system', ''); } + } + ]); + // 兜底:用户一直不点确认时自动收起弹窗,避免模态遮罩长期挡住页面 + setTimeout(function () { + if (answered) return; + answered = true; + closeDialog(); + }, 8000); + } + + /** 卡片内容指纹:步骤/循环任一变化即视为新内容;与 card.id 无关 */ + function cardTTSFingerprint(card) { + try { + return JSON.stringify({ + steps: (card.steps || []).map(function (s) { + return [ + String(s.name || ''), + Number(s.durationSec) || 0, + String(s.voiceText || ''), + String(s.type || '') + ]; + }), + loopMode: card.loopMode, + loopCount: card.loopCount + }); + } catch (e) { return ''; } + } + + /** 会话启动时批量预合成整卡语音;失败不阻塞流程,运行期回退系统 TTS。force=true 用于手动重生 */ + function prepareCardTTS(card, force) { + if (!card || !card.id) return Promise.resolve({ ok: false, error: 'no card' }); + const key = cardTTSFingerprint(card); + if (!force && key && ttsPreparedCardKey === key) return Promise.resolve({ ok: true, skipped: true }); + ttsPreparedCardKey = key; + const payload = { + card: { + id: card.id, + title: card.title, + steps: card.steps, + loopMode: card.loopMode, + loopCount: card.loopCount + } + }; + return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/tts/prepare', payload).then(function (res) { + if (res.ok && res.data && res.data.map) { + Object.keys(res.data.map).forEach(function (text) { ttsUrlMap[text] = res.data.map[text]; }); + console.log('[order_box tts] 预合成完成 ' + res.data.success + '/' + res.data.total); + if (speechEngineMode === 'cloud' && Object.keys(ttsUrlMap).length) hideVoiceWarning(); + } else { + ttsPreparedCardKey = ''; + console.warn('[order_box tts] 预合成失败:' + (res.error || 'unknown')); + } + return res; + }).catch(function (e) { + ttsPreparedCardKey = ''; + console.warn('[order_box tts] 预合成异常:' + String((e && e.message) || e)); + return { ok: false, error: String((e && e.message) || e) }; + }); + } + + /** 步骤播报的最小原子单元,必须与后端 buildCardTTSBatch 的取词逐字一致(云端缓存查表依赖) */ + function stepSpeechAtoms(loop, step, nextStep, loopMode) { + const atoms = []; + if (loopMode !== 'once') atoms.push('第' + loop + '轮'); + atoms.push((step.voiceText && String(step.voiceText).trim()) + ? String(step.voiceText).trim() + : (step.name + ',' + formatSpeechDuration(step.durationSec))); + if (nextStep) atoms.push('下一步,' + (String(nextStep.name || '').trim() || '下一步')); + return atoms; } /** 步骤切换播报:"第N轮,{名称},{时长}。下一步,{nextName}" */ @@ -1387,11 +1672,13 @@ 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); + const atoms = stepSpeechAtoms(runnerState.currentLoop, step, nextStep, runnerState.card.loopMode); + if (speechEngineMode === 'cloud' && playCloudAtoms(atoms)) return; + // 系统 TTS 用合并句播报更自然;'下一步' 前保留句号,保持原有语气 + const merged = atoms.map(function (t, i) { + return (i > 0 && t.indexOf('下一步') === 0) ? '。' + t : t; + }).join(','); + speakText(merged, pitch); } // ==================== 提示音 ==================== @@ -1536,6 +1823,9 @@ document.getElementById('btnBackHub').addEventListener('click', handleBackHub); document.getElementById('btnNewCard').addEventListener('click', function () { openEditor(null); }); document.getElementById('btnCleanStale').addEventListener('click', handleCleanStale); + // 逐元素判空绑定:任何一个元素缺失都不应该中断后续绑定(否则整页按钮会全部失效) + const retryVoiceEl = document.getElementById('btnRetryVoice'); + if (retryVoiceEl) retryVoiceEl.addEventListener('click', function () { probeSpeech(true); }); document.getElementById('btnEditorClose').addEventListener('click', closeEditor); document.getElementById('btnInteractionCopy').addEventListener('click', handleCopyInteraction); document.getElementById('cardsList').addEventListener('click', handleCardsListClick); diff --git a/src/server/app_order_box/kits/motion_reminder/index.js b/src/server/app_order_box/kits/motion_reminder/index.js index 0594a1e..8a46fda 100644 --- a/src/server/app_order_box/kits/motion_reminder/index.js +++ b/src/server/app_order_box/kits/motion_reminder/index.js @@ -1,5 +1,6 @@ const crypto = require('crypto') const store = require('./store') +const tts = require('./tts') const createId = () => { try { return crypto.randomUUID() } catch { return `${Date.now()}_${Math.random().toString(36).slice(2)}` } @@ -81,6 +82,36 @@ const bindRoutes = (app, helpers) => { } }) + // 语音兜底预合成:把整卡会用到的「最小播报单元」批量合成为 mp3 并返回 文本→URL 映射。 + // 系统 TTS 不可用(安卓无引擎 / 引擎被回收 / 静默失败)时,播放端改播这些 mp3。 + app.post('/api/app_order_box/kits/motion_reminder/tts/prepare', async (req, res) => { + try { + const raw = req.body && req.body.card ? req.body.card : {} + const sourceCardId = String(raw.id || '').trim() + const existingCard = sourceCardId ? store.getCardById(sourceCardId) : null + const card = existingCard || sanitizeCard(raw) + if (!card.title || !Array.isArray(card.steps) || !card.steps.length) { + return res.status(400).json({ ok: false, error: 'missing card title or steps' }) + } + const voice = String(req.body && req.body.voice || '').trim() || tts.VOICE_ID_DEFAULT + const result = await tts.synthesizeBatch(card, voice) + const map = {} + result.items.forEach(item => { if (item.ok && item.url) map[item.text] = item.url }) + return res.json({ + ok: true, + data: { + voice, + total: result.total, + success: result.success, + map, + missing: result.missing, + }, + }) + } catch (e) { + return res.status(500).json({ ok: false, error: String(e.message || e) }) + } + }) + app.post('/api/app_order_box/kits/motion_reminder/sessions/start', (req, res) => { try { const raw = req.body && req.body.card ? req.body.card : {} diff --git a/src/server/app_order_box/kits/motion_reminder/store.js b/src/server/app_order_box/kits/motion_reminder/store.js index 9d0816a..2ad6142 100644 --- a/src/server/app_order_box/kits/motion_reminder/store.js +++ b/src/server/app_order_box/kits/motion_reminder/store.js @@ -34,6 +34,15 @@ const getDb = () => { manual_stopped INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL );`) + db.exec(`CREATE TABLE IF NOT EXISTS motion_tts_cache ( + md5 TEXT PRIMARY KEY, + text TEXT NOT NULL, + voice TEXT NOT NULL, + url TEXT NOT NULL, + duration_ms INTEGER, + created_at TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0 + );`) db.exec(`CREATE INDEX IF NOT EXISTS idx_motion_cards_updated ON cards(updated_at);`) db.exec(`CREATE INDEX IF NOT EXISTS idx_motion_sessions_started ON sessions(started_at);`) return db @@ -219,7 +228,43 @@ const listHistory = () => { return rows.map(mapSession) } +// ==================== TTS 缓存 ==================== +const getTTSCacheRow = (md5Key) => { + const row = getDb().prepare(`SELECT * FROM motion_tts_cache WHERE md5 = ? AND deleted = 0 LIMIT 1`).get(md5Key) + return row || null +} + +const insertTTSCache = ({ md5: md5Key, text, voice, url, durationMs, createdAt }) => { + // UPSERT:md5 即使已存在(含 deleted=1 的历史行)也覆盖恢复为最新音频, + // 避免 INSERT OR IGNORE 导致缓存永远无法更新、重复合成文件堆积 + getDb().prepare(` + INSERT INTO motion_tts_cache (md5, text, voice, url, duration_ms, created_at, deleted) + VALUES (?, ?, ?, ?, ?, ?, 0) + ON CONFLICT(md5) DO UPDATE SET + text = excluded.text, + voice = excluded.voice, + url = excluded.url, + duration_ms = excluded.duration_ms, + deleted = 0, + created_at = excluded.created_at + `).run( + String(md5Key || ''), + String(text || ''), + String(voice || ''), + String(url || ''), + durationMs ? Math.max(0, Number(durationMs)) : null, + String(createdAt || nowIso()) + ) + return { ok: true, md5: md5Key, url } +} + +const updateTTSCacheUrl = (md5Key, url) => { + getDb().prepare(`UPDATE motion_tts_cache SET url = ? WHERE md5 = ?`).run(String(url || ''), String(md5Key || '')) +} + module.exports = { + getDb, + nowIso, listCards, getCardById, insertCard, @@ -230,4 +275,7 @@ module.exports = { syncSession, finishSession, listHistory, + getTTSCacheRow, + insertTTSCache, + updateTTSCacheUrl, } diff --git a/src/server/app_order_box/kits/motion_reminder/tts.js b/src/server/app_order_box/kits/motion_reminder/tts.js new file mode 100644 index 0000000..1811d72 --- /dev/null +++ b/src/server/app_order_box/kits/motion_reminder/tts.js @@ -0,0 +1,305 @@ +// 运动提醒宝(motion_reminder)独立 TTS 合成与缓存实现 +// 说明:本项目 Rule 6 要求每个 tool 独立维护自己的第三方调用封装, +// 因此本文件不 require 其他 tool 的 tts 模块,只依赖本 kit 的 ./store。 +// 用途:为运动执行器的语音播报提供「云端 mp3 兜底通道」—— +// 设备系统 TTS 不可用(安卓无引擎 / 引擎被回收 / 静默失败)时改播本地缓存的 mp3。 +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') +const store = require('./store') + +const TTS_ROOT = path.join(process.cwd(), 'uploads', 'app_order_box', 'tts_cache') +const TTS_URL_PREFIX = '/uploads/app_order_box/tts_cache/' +const VOICE_ID_DEFAULT = 'female_01' +// 进入运动执行器时的欢迎语,同时承担「系统 TTS 是否真的出声」的探测信号 +const WELCOME_TEXT = '欢迎回来,每天锻炼身体好!' + +// 单张卡片最多预合成的原子文本数,避免大卡片把 DashScope QPS 打满 +const MAX_BATCH_ATOMS = 40 +// 循环轮次数词最大预合成数目,超出部分运行期回退系统 TTS +const MAX_LOOP_ATOMS = 20 + +const ensureDir = dir => { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) } +ensureDir(TTS_ROOT) + +const log = (...args) => { try { console.log('[motion_reminder tts]', ...args) } catch (_) {} } + +/** 内容优先嗅探音频格式,避免 Content-Type 谎报导致扩展名错配、iOS 按错误解码器解析而无声 */ +const detectAudioExt = (buf, contentType = '') => { + try { + if (buf && buf.length > 0) { + const headAscii = Buffer.from(buf.subarray(0, Math.min(buf.length, 16))).toString('ascii') + if (headAscii.startsWith('RIFF') && headAscii.indexOf('WAVE') >= 0) return '.wav' + if (headAscii.startsWith('ID3')) return '.mp3' + if (buf.length >= 2 && buf[0] === 0xFF && (buf[1] & 0xE0) === 0xE0) return '.mp3' + } + const ct = String(contentType || '').toLowerCase() + if (ct.includes('wav')) return '.wav' + if (ct.includes('mpeg') || ct.includes('mp3')) return '.mp3' + } catch {} + return '.mp3' +} + +const getAbsPathFromUrl = url => { + const clean = String(url || '').trim().replace(/^\/+/, '') + if (!clean) return '' + return path.join(process.cwd(), clean.replace(/\//g, path.sep)) +} + +/** 历史缓存若扩展名与实际内容不符,就地改名并回写数据库(一次性自愈) */ +const normalizeCachedUrl = row => { + try { + if (!row || !row.url) return row + const currentUrl = String(row.url || '').trim() + if (!currentUrl) return row + const abs = getAbsPathFromUrl(currentUrl) + if (!abs || !fs.existsSync(abs)) return row + const buf = fs.readFileSync(abs) + const realExt = detectAudioExt(buf) + const currentExt = path.extname(abs || '').toLowerCase() + if (!realExt || realExt === currentExt) return row + const nextAbs = abs.slice(0, -currentExt.length) + realExt + const nextUrl = currentUrl.slice(0, -currentExt.length) + realExt + if (!fs.existsSync(nextAbs)) fs.renameSync(abs, nextAbs) + store.updateTTSCacheUrl(row.md5, nextUrl) + return Object.assign({}, row, { url: nextUrl }) + } catch {} + return row +} + +/** 内容寻址缓存键:md5(voice|text),保证同一句话永远命中同一份音频 */ +const md5 = (text, voice = VOICE_ID_DEFAULT) => { + return crypto.createHash('md5').update(`${voice}|${String(text || '')}`).digest('hex') +} + +const getTTSCacheRow = (text, voice = VOICE_ID_DEFAULT) => { + const row = store.getTTSCacheRow(md5(text, voice)) + return normalizeCachedUrl(row) +} + +const getSafeFetch = () => { + try { + if (globalThis && typeof globalThis.fetch === 'function') return globalThis.fetch + } catch (_) {} + if (global && typeof global.fetch === 'function') return global.fetch + try { return require('node-fetch') } catch (_) {} + return null +} + +const fetchWithTimeout = async (url, options = {}, timeoutMs = 20000) => { + const fn = getSafeFetch() + if (!fn) return null + if (typeof AbortController === 'undefined') return fn(url, options) + const controller = new AbortController() + const timer = setTimeout(() => { try { controller.abort() } catch (_) {} }, timeoutMs) + try { + return await fn(url, Object.assign({}, options, { signal: controller.signal })) + } finally { + clearTimeout(timer) + } +} + +// 全局 TTS 串行队列:DashScope 有 QPS 限流, +// 并发合成会触发 HTTP 429 Throttling.RateQuota 导致随机文本合成失败,必须逐条排队 +let ttsChain = Promise.resolve() +const ttsQueue = fn => { + const run = ttsChain.then(fn, fn) + ttsChain = run.catch(() => {}) + return run +} + +const callCosyVoice = async (text, voice = VOICE_ID_DEFAULT) => { + const apiKey = process.env.DASHSCOPE_API_KEY || '' + if (!apiKey) { + log('❌ DASHSCOPE_API_KEY 未配置,无法合成:', JSON.stringify(String(text || ''))) + return { ok: false, error: 'DASHSCOPE_API_KEY 未配置' } + } + const t = String(text || '').trim() + if (!t) return { ok: false, error: 'empty text' } + const isMale = String(voice || '').indexOf('male') >= 0 + const headers = { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + } + const fallbacks = [] + fallbacks.push({ + name: 'Fallback-A:Qwen3-TTS', + url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation', + body: { + model: 'qwen3-tts-flash', + input: { + text: t, + voice: 'Cherry', + language_type: 'Chinese', + }, + }, + }) + fallbacks.push({ + name: 'Fallback-B:CosyVoice-v3', + url: 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer', + body: { + model: 'cosyvoice-v3-flash', + input: { + text: t, + voice: isMale ? 'longxiaocheng' : 'longanyang', + format: 'mp3', + sample_rate: 24000, + }, + }, + }) + for (const fb of fallbacks) { + const t0 = Date.now() + try { + let resp = null + try { + resp = await fetchWithTimeout(fb.url, { method: 'POST', headers, body: JSON.stringify(fb.body) }, 20000) + } catch (fe) { + log(' ↳', fb.name, 'fetch 抛异常:', String(fe && fe.message || fe), '耗时', (Date.now() - t0) + 'ms') + continue + } + if (!resp || !resp.ok) { + const txt = resp ? await resp.text().catch(() => '') : '' + log(' ', fb.name, 'HTTP', resp ? resp.status : 'no-fetch', '耗时', (Date.now() - t0) + 'ms', '响应=', String(txt || '').replace(/\s+/g, ' ').slice(0, 400)) + continue + } + let j = null + try { j = await resp.json() } catch (je) { log(' ↳', fb.name, '响应非 JSON:', String(je && je.message || je)); continue } + let audioUrl = '' + try { + const output = (j && j.output) || null + if (output) { + if (output.audio && typeof output.audio.url === 'string') audioUrl = output.audio.url + if (!audioUrl && typeof output.audio_url === 'string') audioUrl = output.audio_url + if (!audioUrl && typeof output.audioUrl === 'string') audioUrl = output.audioUrl + if (!audioUrl && Array.isArray(output.results) && output.results[0]) { + const first = output.results[0] || {} + if (first.audio && typeof first.audio.url === 'string') audioUrl = first.audio.url + } + } + } catch {} + if (!audioUrl) { log(' ↳', fb.name, '响应里未找到 audio url,跳过该 fallback'); continue } + const download = await fetchWithTimeout(audioUrl, {}, 20000) + if (!download || !download.ok) { log(' ↳', fb.name, '下载音频失败 status=', download && download.status); continue } + const buf = Buffer.from(await download.arrayBuffer()) + const ext = detectAudioExt(buf, download.headers && download.headers.get ? download.headers.get('content-type') : '') + // 文件名保持纯 ASCII,避免中文名经反代 + URL 编码后取不到 + const storedName = `mr_${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}` + const absPath = path.join(TTS_ROOT, storedName) + fs.writeFileSync(absPath, buf) + const stat = fs.statSync(absPath) + const durationMs = Math.max(500, Math.round(stat.size / (12 * 1024 / 8))) + log(' ✔ 合成成功 text=', JSON.stringify(t), 'file=', storedName, '耗时', (Date.now() - t0) + 'ms') + return { ok: true, url: `${TTS_URL_PREFIX}${storedName}`, durationMs } + } catch (e) { + log(' ↳', fb.name, '处理异常:', String(e && e.message || e)) + } + } + log('✘ 所有 TTS fallback 均失败 text=', JSON.stringify(t), 'voice=', voice) + return { ok: false, error: 'All TTS fallbacks failed' } +} + +/** 合成单条文本:先查 md5 缓存,未命中才真实请求 */ +const synthesize = async (text, voice = VOICE_ID_DEFAULT) => { + const t = String(text || '').trim() + if (!t) return { ok: false, error: 'empty text' } + const cached = getTTSCacheRow(t, voice) + if (cached) { + return { ok: true, url: cached.url, cached: true, durationMs: cached.duration_ms } + } + const r = await ttsQueue(() => callCosyVoice(t, voice)) + if (!r || !r.ok) return { ok: false, error: (r && r.error) || 'unknown tts error' } + store.insertTTSCache({ + md5: md5(t, voice), + text: t, + voice, + url: r.url, + durationMs: r.durationMs, + createdAt: store.nowIso(), + }) + return { ok: true, url: r.url, cached: false, durationMs: r.durationMs } +} + +/** 语音播报专用时长文案,必须与 Web/Runner 端 formatSpeechDuration 保持一致 */ +const 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秒' +} + +/** 卡片的步骤播报标题,必须与 Web/Runner 端 announceStep 的取词逻辑一致 */ +const stepSpeechTitle = step => { + const voiceText = String(step && step.voiceText || '').trim() + if (voiceText) return voiceText + const name = String(step && step.name || '').trim() || '步骤' + return name + ',' + formatSpeechDuration(step && step.durationSec) +} + +/** + * 枚举一张卡片运行期会用到的全部「最小播报单元」。 + * 采用最小单元而非整句:整句预合成会导致 轮数 × 步骤数 组合爆炸。 + * 注意:loopMode 判据必须与 sanitizeCard 的产出对齐(repeat_count / repeat_forever / once)。 + * 文本必须与 Web 端 order_box.js 的原子取词逐字一致,否则缓存查表命中不了。 + */ +const buildCardTTSBatch = card => { + const lines = [WELCOME_TEXT] + const c = card || {} + const steps = Array.isArray(c.steps) ? c.steps : [] + const loopMode = String(c.loopMode || 'once') + const loopCount = Math.max(0, Number(c.loopCount) || 0) + if (loopMode === 'repeat_count' && loopCount > 1) { + for (let i = 1; i <= Math.min(loopCount, MAX_LOOP_ATOMS); i++) lines.push(`第${i}轮`) + } else if (loopMode === 'repeat_forever') { + for (let i = 1; i <= MAX_LOOP_ATOMS; i++) lines.push(`第${i}轮`) + } + for (let i = 0; i < steps.length; i++) { + const s = steps[i] || {} + lines.push(stepSpeechTitle(s)) + const next = steps[i + 1] + if (next) lines.push(`下一步,${String(next.name || '').trim() || '下一步'}`) + } + lines.push('还有 5 秒') + lines.push('还有 3 秒') + return Array.from(new Set(lines.filter(Boolean))).slice(0, MAX_BATCH_ATOMS) +} + +/** + * 批量预合成给定文本,返回 { items: [{text, url, ok, cached, error}], missing } + * 单条失败不影响整体,播放端按 text 查表,查不到就回退系统 TTS。 + */ +const synthesizeTexts = async (lines, voice = VOICE_ID_DEFAULT) => { + const list = Array.from(new Set((Array.isArray(lines) ? lines : []).map(t => String(t || '').trim()).filter(Boolean))).slice(0, MAX_BATCH_ATOMS) + const st = Date.now() + log('▶ prepare 开始 voice=', voice, '原子数=', list.length) + const results = await Promise.all(list.map(line => synthesize(line, voice).then(r => ({ + text: line, + url: r.url || '', + ok: !!r.ok, + cached: !!r.cached, + error: r.error || null, + })))) + const success = results.filter(r => r.ok).length + const missing = results.filter(r => !r.ok).map(r => r.text) + log('✔ prepare 完成 success=', success + '/' + list.length, '耗时', (Date.now() - st) + 'ms') + return { ok: success > 0, total: list.length, success, items: results, missing } +} + +const synthesizeBatch = async (card, voice = VOICE_ID_DEFAULT) => synthesizeTexts(buildCardTTSBatch(card), voice) + +module.exports = { + synthesize, + synthesizeBatch, + synthesizeTexts, + buildCardTTSBatch, + stepSpeechTitle, + callCosyVoice, + TTS_ROOT, + TTS_URL_PREFIX, + VOICE_ID_DEFAULT, + WELCOME_TEXT, + MAX_BATCH_ATOMS, + getTTSCacheRow, +} \ No newline at end of file diff --git a/src/server/index.js b/src/server/index.js index 74c1154..85c174f 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1044,6 +1044,20 @@ app.use('/uploads/mp_rep_clock', (req, res, next) => { if (hasWebMpTimeAssistantAuth(req)) return next() return res.status(401).send('未授权') }) +app.use('/uploads/app_order_box', (req, res, next) => { + // 运动提醒宝的 TTS 兜底音频由