feat(personal_collect,motion_reminder): 新增回收站与云端TTS语音功能

- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作
- 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数
- 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放
- 新增语音引擎探测与异常预警UI,覆盖web端与移动端
- 优化web端订单盒页面布局,修复卡片操作按钮换行问题
- 新增调试探针脚本、ESLint配置与相关lint脚本
- 完善后端接口与路由,更新项目依赖与配置文件
This commit is contained in:
yangxiangyuan
2026-09-17 16:26:27 +08:00
parent d4bf3627fd
commit bbc7ca6523
29 changed files with 3228 additions and 163 deletions
+15 -2
View File
@@ -1,7 +1,7 @@
import axios from 'axios'; 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 前缀 // 注意:MOTION_BASE 是相对于 axios baseURL 的路径,不要包含 API_BASE_URL 前缀
// 否则 axios 会双重拼接导致 404(如 /api/app_order_box/api/app_order_box/kits/...) // 否则 axios 会双重拼接导致 404(如 /api/app_order_box/api/app_order_box/kits/...)
const MOTION_BASE = '/kits/motion_reminder'; const MOTION_BASE = '/kits/motion_reminder';
@@ -95,4 +95,17 @@ export const motionReminderApi = {
throw new Error(formatError(error)); throw new Error(formatError(error));
} }
}, },
/** 批量预合成整卡「最小播报单元」的云端 mp3,返回文本→URL 映射(服务端 md5 内容寻址,重复文本不会重复合成) */
async prepareTTS(card: MotionCard): Promise<TTSPrepareResult> {
try {
const response = await api.post<ApiEnvelope<TTSPrepareResult>>(`${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));
}
},
}; };
@@ -1,7 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import * as Speech from 'expo-speech';
import { motionReminderApi } from '../api'; import { motionReminderApi } from '../api';
import { MotionCard, SessionStatus } from '../types'; import { MotionCard, SessionStatus } from '../types';
import {
countdownAtom,
prepareCardTTS,
speakAtoms,
stepSpeechAtoms,
stopSpeech,
} from '../speech';
export interface RunnerState { export interface RunnerState {
sessionId: string; sessionId: string;
@@ -24,17 +30,6 @@ export interface UseRunnerReturn {
syncToServer: () => Promise<void>; syncToServer: () => Promise<void>;
} }
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 => { export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
const [runner, setRunner] = useState<RunnerState | null>(null); const [runner, setRunner] = useState<RunnerState | null>(null);
const announcementRef = useRef(''); const announcementRef = useRef('');
@@ -55,7 +50,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
const message = error instanceof Error ? error.message : '结束失败'; const message = error instanceof Error ? error.message : '结束失败';
console.error('[Runner] 同步失败:', message); console.error('[Runner] 同步失败:', message);
} finally { } finally {
Speech.stop(); void stopSpeech();
setRunner(null); setRunner(null);
announcementRef.current = ''; announcementRef.current = '';
onSessionEnd?.(); onSessionEnd?.();
@@ -105,10 +100,9 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
const isAction = step?.type === 'action'; const isAction = step?.type === 'action';
const countdownPitch = isAction ? 1.1 : 0.9; const countdownPitch = isAction ? 1.1 : 0.9;
// 5/3 秒倒计时播报 // 5/3 秒倒计时播报:文本与系统 TTS / 云端 mp3 查表共用同一原子
if (runner.remainingSec === 5 || runner.remainingSec === 3) { if (runner.remainingSec === 5 || runner.remainingSec === 3) {
Speech.stop(); speakAtoms([countdownAtom(runner.remainingSec)], countdownPitch);
Speech.speak(`还有 ${runner.remainingSec} 秒`, { language: 'zh-CN', pitch: countdownPitch, rate: 0.95 });
} }
if (runner.remainingSec <= 1) { if (runner.remainingSec <= 1) {
@@ -138,15 +132,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
// 动作步骤用较高音调,休息步骤用较低音调 // 动作步骤用较高音调,休息步骤用较低音调
const stepPitch = step.type === 'action' ? 1.1 : 0.9; const stepPitch = step.type === 'action' ? 1.1 : 0.9;
speakAtoms(stepSpeechAtoms(runner.currentLoop, step, nextStep, runner.card.loopMode), stepPitch);
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,
});
// 同步到服务端 // 同步到服务端
void motionReminderApi.syncSession({ void motionReminderApi.syncSession({
@@ -162,7 +148,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
// 清理 // 清理
useEffect(() => { useEffect(() => {
return () => { return () => {
Speech.stop(); void stopSpeech();
}; };
}, []); }, []);
@@ -170,6 +156,8 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
if (!card.steps.length) { if (!card.steps.length) {
throw new Error('当前卡片没有可执行步骤'); throw new Error('当前卡片没有可执行步骤');
} }
// 会话启动时批量预合成整卡云端语音(不阻塞启动,失败时运行期回退系统 TTS)
void prepareCardTTS(card);
const session = await motionReminderApi.startSession(card); const session = await motionReminderApi.startSession(card);
setRunner({ setRunner({
sessionId: session.id, sessionId: session.id,
@@ -13,6 +13,15 @@ import { motionReminderApi } from '../api';
import { useRunner } from '../hooks/useRunner'; import { useRunner } from '../hooks/useRunner';
import { CardEditor } from '../components/CardEditor'; import { CardEditor } from '../components/CardEditor';
import { RunnerModal } from '../components/RunnerModal'; import { RunnerModal } from '../components/RunnerModal';
import {
WELCOME_TEXT,
configureSpeechAudioMode,
confirmSpeechHeard,
getSpeechState,
prepareCardTTS,
probeSpeech,
subscribeSpeechState,
} from '../speech';
import { theme } from '../../../theme'; import { theme } from '../../../theme';
import { MotionCard, MotionSession, OrderBoxState } from '../../../types'; import { MotionCard, MotionSession, OrderBoxState } from '../../../types';
@@ -40,11 +49,59 @@ export const CardsScreen: React.FC<CardsScreenProps> = ({ onExit, serverState, o
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editorVisible, setEditorVisible] = useState(false); const [editorVisible, setEditorVisible] = useState(false);
const [editingCard, setEditingCard] = useState<MotionCard | null>(null); const [editingCard, setEditingCard] = useState<MotionCard | null>(null);
const [speechState, setSpeechState] = useState(getSpeechState());
const [ttsRegenId, setTtsRegenId] = useState<string | null>(null);
const runner = useRunner(() => { const runner = useRunner(() => {
onRefresh(); 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 () => { const loadData = useCallback(async () => {
try { try {
const nextCards = await motionReminderApi.listCards(); const nextCards = await motionReminderApi.listCards();
@@ -141,6 +198,16 @@ export const CardsScreen: React.FC<CardsScreenProps> = ({ onExit, serverState, o
</View> </View>
) : null} ) : null}
{speechState.warning ? (
<View style={[styles.warnCard, styles.voiceWarnCard]}>
<Text style={styles.warnCardTitle}>语音播报可能不可用</Text>
<Text style={styles.warnCardText}>{speechState.warning}</Text>
<TouchableOpacity style={styles.secondaryButton} onPress={retrySpeechProbe}>
<Text style={styles.secondaryButtonText}>重新检测</Text>
</TouchableOpacity>
</View>
) : null}
{loading ? ( {loading ? (
<ActivityIndicator style={styles.loader} color={theme.colors.primary} /> <ActivityIndicator style={styles.loader} color={theme.colors.primary} />
) : ( ) : (
@@ -177,6 +244,15 @@ export const CardsScreen: React.FC<CardsScreenProps> = ({ onExit, serverState, o
<TouchableOpacity style={styles.ghostActionButton} onPress={() => deleteCard(item)}> <TouchableOpacity style={styles.ghostActionButton} onPress={() => deleteCard(item)}>
<Text style={[styles.ghostActionButtonText, styles.dangerText]}>删除</Text> <Text style={[styles.ghostActionButtonText, styles.dangerText]}>删除</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
style={[styles.ghostActionButton, ttsRegenId === item.id && styles.buttonDisabled]}
onPress={() => regenCardTTS(item)}
disabled={ttsRegenId === item.id}
>
<Text style={styles.ghostActionButtonText}>
{ttsRegenId === item.id ? '生成中…' : '重生语音'}
</Text>
</TouchableOpacity>
</View> </View>
</View> </View>
)} )}
@@ -347,6 +423,12 @@ const styles = StyleSheet.create({
dangerText: { dangerText: {
color: theme.colors.error, color: theme.colors.error,
}, },
voiceWarnCard: {
borderColor: theme.colors.error,
},
buttonDisabled: {
opacity: 0.5,
},
emptyCard: { emptyCard: {
backgroundColor: theme.colors.surface, backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
@@ -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<SpeechState>) => {
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<string, string> = {};
let activeSound: Audio.Sound | null = null;
let playToken = 0;
let cloudChain: Promise<void> = 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<void>) => {
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<void>((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | 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<boolean> =>
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<TTSPrepareResult | null> => {
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<boolean> => {
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', '系统语音未出声,已切换云端语音兜底(需先启动一次流程完成预合成)。');
};
@@ -40,3 +40,12 @@ export interface ApiEnvelope<T> {
data?: T; data?: T;
error?: string; error?: string;
} }
/** POST /kits/motion_reminder/tts/prepare 的返回:map 为「播报原子文本 → 云端 mp3 URL」 */
export interface TTSPrepareResult {
voice: string;
total: number;
success: number;
map: Record<string, string>;
missing: string[];
}
+18
View File
@@ -14,6 +14,7 @@
"@react-navigation/native": "^6.1.0", "@react-navigation/native": "^6.1.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"expo": "~54.0.0", "expo": "~54.0.0",
"expo-av": "~16.0.8",
"expo-haptics": "^57.0.0", "expo-haptics": "^57.0.0",
"expo-keep-awake": "^57.0.0", "expo-keep-awake": "^57.0.0",
"expo-speech": "~13.0.1", "expo-speech": "~13.0.1",
@@ -4452,6 +4453,23 @@
"react-native": "*" "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": { "node_modules/expo-constants": {
"version": "18.0.13", "version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
+1
View File
@@ -16,6 +16,7 @@
"@react-navigation/native": "^6.1.0", "@react-navigation/native": "^6.1.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"expo": "~54.0.0", "expo": "~54.0.0",
"expo-av": "~16.0.8",
"expo-haptics": "^57.0.0", "expo-haptics": "^57.0.0",
"expo-keep-awake": "^57.0.0", "expo-keep-awake": "^57.0.0",
"expo-speech": "~13.0.1", "expo-speech": "~13.0.1",
@@ -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)))
}
@@ -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)
})
+62
View File
@@ -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'
}
}
];
+14
View File
@@ -0,0 +1,14 @@
# Windows
[Dd]esktop.ini
Thumbs.db
$RECYCLE.BIN/
# macOS
.DS_Store
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
# Node.js
node_modules/
+21 -2
View File
@@ -112,11 +112,30 @@ const personalCollect = {
method: 'DELETE', method: 'DELETE',
data: { id }, 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', url: '/api/mp_personal_collect/kits/personal_collect/upload',
filePath, filePath,
name: 'file', name: 'file',
formData: groupId ? { groupId } : {}, // 原始文件名由小程序随 multipart 透传(wx.chooseMessageFile 的 name 是 UTF-8 完好),
// 避免服务端只能拿到桌面微信传输中已被损坏的多字节文件名
formData: Object.assign(
groupId ? { groupId } : {},
originalName ? { originalName } : {}
),
}), }),
} }
@@ -134,7 +134,7 @@ Page({
const idx = list.length - 1 const idx = list.length - 1
try { 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 data = (res && res.data) || {}
const attachment = { const attachment = {
id: data.id || tempId, id: data.id || tempId,
@@ -13,6 +13,8 @@ Page({
loading: true, loading: true,
keyword: '', keyword: '',
items: [], items: [],
trashItems: [],
trashMode: false,
error: '', error: '',
}, },
@@ -43,16 +45,28 @@ Page({
async refresh() { async refresh() {
this.setData({ loading: true, error: '' }) this.setData({ loading: true, error: '' })
try { try {
const res = await api.personalCollect.list(this.data.keyword || '') if (this.data.trashMode) {
const raw = Array.isArray(res.data) ? res.data : [] const res = await api.personalCollect.trash()
const items = raw.map(item => ({ const raw = Array.isArray(res.data) ? res.data : []
...item, const trashItems = raw.map(item => ({
updatedAtText: formatDate(item.updatedAt || item.createdAt || ''), ...item,
tagsText: (item.tags || []).join(' · '), deletedAtText: formatDate(item.updatedAt || item.createdAt || ''),
previewImage: (item.attachments || []).find(a => a && a.isImage && a.filePath) || null, tagsText: (item.tags || []).join(' · '),
fileCount: Array.isArray(item.attachments) ? item.attachments.length : 0, fileCount: Array.isArray(item.attachments) ? item.attachments.length : 0,
})) }))
this.setData({ items }) 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) { } catch (e) {
this.setData({ error: e && e.message ? e.message : '加载失败' }) this.setData({ error: e && e.message ? e.message : '加载失败' })
} finally { } finally {
@@ -107,4 +121,65 @@ Page({
url: '/kits/personal_collect/pages/edit/edit', 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,
})
}
},
})
},
}) })
@@ -4,7 +4,12 @@
<view class="page-subtitle">保存聊天里的图片与文件,标签搜索管理。</view> <view class="page-subtitle">保存聊天里的图片与文件,标签搜索管理。</view>
</view> </view>
<view class="search-box"> <view class="trash-tabs">
<view class="trash-tab {{!trashMode ? 'trash-tab-active' : ''}}" bindtap="onBackToList">收藏</view>
<view class="trash-tab {{trashMode ? 'trash-tab-active' : ''}}" bindtap="onSwitchToTrash">回收站</view>
</view>
<view wx:if="{{!trashMode}}" class="search-box">
<text class="search-icon">🔍</text> <text class="search-icon">🔍</text>
<input <input
class="search-input" class="search-input"
@@ -26,14 +31,21 @@
<view wx:if="{{loading}}" class="empty">加载中...</view> <view wx:if="{{loading}}" class="empty">加载中...</view>
<block wx:if="{{!loading && items.length === 0}}"> <block wx:if="{{!loading && !trashMode && items.length === 0}}">
<view class="empty"> <view class="empty">
<view style="font-size: 80rpx; margin-bottom: 24rpx;">📁</view> <view style="font-size: 80rpx; margin-bottom: 24rpx;">📁</view>
<view>{{keyword ? '没有匹配的收藏' : '还没有收藏,点右下角 + 新建'}}</view> <view>{{keyword ? '没有匹配的收藏' : '还没有收藏,点右下角 + 新建'}}</view>
</view> </view>
</block> </block>
<block wx:for="{{items}}" wx:key="id"> <block wx:if="{{!loading && trashMode && trashItems.length === 0}}">
<view class="empty">
<view style="font-size: 80rpx; margin-bottom: 24rpx;">🗑️</view>
<view>回收站是空的</view>
</view>
</block>
<block wx:for="{{items}}" wx:key="id" wx:if="{{!trashMode}}">
<view class="card" bindtap="onViewTap" data-id="{{item.id}}"> <view class="card" bindtap="onViewTap" data-id="{{item.id}}">
<view class="row row-between"> <view class="row row-between">
<view style="flex:1; min-width:0;"> <view style="flex:1; min-width:0;">
@@ -72,6 +84,22 @@
</view> </view>
</view> </view>
</block> </block>
<block wx:for="{{trashItems}}" wx:key="id" wx:if="{{trashMode}}">
<view class="card">
<view class="row row-between">
<view style="flex:1; min-width:0;">
<view wx:if="{{item.title}}" style="font-size: 32rpx; font-weight: 600; color:#ffffff; margin-bottom: 12rpx;">{{item.title}}</view>
<view wx:if="{{!item.title}}" style="font-size: 32rpx; font-weight: 600; color:#ffffff; margin-bottom: 12rpx; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;">{{item.remark || '未命名收藏'}}</view>
<view class="muted small" style="margin-top: 8rpx;">删除于 {{item.deletedAtText}} · {{item.fileCount}} 个附件</view>
</view>
</view>
<view class="item-actions" style="margin-top: 20rpx;">
<view class="btn btn-ghost mini-btn" catchtap="onRestoreTap" data-id="{{item.id}}" data-title="{{item.title || item.remark || '此收藏'}}">恢复</view>
<view class="spacer"></view>
<view class="btn btn-danger mini-btn" catchtap="onPurgeTap" data-id="{{item.id}}" data-title="{{item.title || item.remark || '此收藏'}}" data-count="{{item.fileCount}}">彻底删除</view>
</view>
</view>
</block>
</view> </view>
<view class="fab" bindtap="onAddTap">+</view> <view class="fab" wx:if="{{!trashMode}}" catchtap="onAddTap">+</view>
@@ -1 +1,25 @@
/* 列表页独立样式,大部分复用 app.wxss 全局样式 */ /* 列表页独立样式,大部分复用 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;
}
+890 -8
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -15,7 +15,8 @@
"upgrade:expense": "\"D:\\ProgramData\\nodejs_22\\node.exe\" scripts/upgrade_expense_db.js", "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", "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 }\"", "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": { "dependencies": {
"@alicloud/pop-core": "^1.8.0", "@alicloud/pop-core": "^1.8.0",
@@ -48,6 +49,8 @@
"devDependencies": { "devDependencies": {
"css-loader": "^7.1.4", "css-loader": "^7.1.4",
"esbuild": "^0.27.4", "esbuild": "^0.27.4",
"eslint": "^10.10.0",
"globals": "^17.12.0",
"style-loader": "^4.0.0", "style-loader": "^4.0.0",
"tinymce": "^6.8.3" "tinymce": "^6.8.3"
} }
+270 -1
View File
@@ -692,13 +692,282 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(0, 0, 0, 0.65); background: rgba(0, 0, 0, 0.65);
z-index: 2000; z-index: 2200;
} }
.icon-picker.active { .icon-picker.active {
display: flex; 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, .fitness-diary-reminder,
.weekly-report-reminder { .weekly-report-reminder {
position: fixed; position: fixed;
+460 -78
View File
@@ -85,6 +85,19 @@ class ToolboxHome {
this.editingCategoryCode = ''; this.editingCategoryCode = '';
this.editingBookmarkId = ''; this.editingBookmarkId = '';
this.iconPickerTarget = null; 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.activeReminderKey = '';
this.reminderQueue = []; this.reminderQueue = [];
@@ -205,17 +218,78 @@ class ToolboxHome {
<div class="admin-title">书签后台维护</div> <div class="admin-title">书签后台维护</div>
<button class="bookmark-btn" id="bookmark-admin-close" type="button">返回书签</button> <button class="bookmark-btn" id="bookmark-admin-close" type="button">返回书签</button>
</div> </div>
<div class="admin-columns"> <div class="admin-tabs" id="admin-tabs">
<div class="admin-panel"> <button class="bookmark-btn admin-tab-btn active" data-adm-tab="bookmarks" type="button">🔖 书签管理</button>
<div class="admin-panel-title">分类维护</div> <button class="bookmark-btn admin-tab-btn" data-adm-tab="categories" type="button">📂 分类管理</button>
<form class="admin-form" id="category-form"> </div>
<div class="admin-tab-page active" data-adm-page="bookmarks">
<div class="admin-toolbar">
<button class="bookmark-btn primary" id="bookmark-add-btn" type="button">新增书签</button>
<select id="bookmark-admin-category-filter" class="bookmark-filter-select" title="按分类筛选"></select>
<input id="bookmark-admin-search" class="bookmark-search admin-search" placeholder="搜索名称/网址/备注..." autocomplete="off" />
<button class="bookmark-btn" id="bookmark-admin-search-clear" type="button">清空</button>
<div class="admin-count" id="bookmark-count"></div>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th class="th-icon"></th>
<th>名称</th>
<th>网址</th>
<th>分类</th>
<th>排序</th>
<th>创建时间</th>
<th class="th-actions">操作</th>
</tr>
</thead>
<tbody id="bookmark-table-body"></tbody>
</table>
</div>
<div class="admin-pagination" id="bookmark-pagination"></div>
</div>
<div class="admin-tab-page" data-adm-page="categories">
<div class="admin-toolbar">
<button class="bookmark-btn primary" id="category-add-btn" type="button">新增分类</button>
<div class="admin-count" id="category-count"></div>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>编码</th>
<th>名称</th>
<th>排序</th>
<th>书签数</th>
<th class="th-actions">操作</th>
</tr>
</thead>
<tbody id="category-table-body"></tbody>
</table>
</div>
<div class="admin-pagination" id="category-pagination"></div>
</div>
`;
// 后台维护弹窗(新增/编辑分类、书签共用)
const adminModal = document.createElement('div');
adminModal.className = 'nav-modal-overlay';
adminModal.id = 'nav-admin-modal';
adminModal.innerHTML = `
<div class="nav-modal" role="dialog" aria-modal="true" aria-labelledby="nav-admin-modal-title">
<div class="nav-modal-header">
<div class="nav-modal-title" id="nav-admin-modal-title">新增分类</div>
<button class="bookmark-btn nav-modal-close" id="nav-admin-modal-close" type="button">✕</button>
</div>
<div class="nav-modal-body">
<form class="admin-form nav-form" id="admin-category-form">
<div class="form-row"> <div class="form-row">
<label>编码</label> <label>编码</label>
<input name="code" required /> <input name="code" required placeholder="如 12 / dev / life" />
</div> </div>
<div class="form-row"> <div class="form-row">
<label>名称</label> <label>名称</label>
<input name="name" required /> <input name="name" required placeholder="分类名称" />
</div> </div>
<div class="form-row"> <div class="form-row">
<label>排序</label> <label>排序</label>
@@ -226,18 +300,14 @@ class ToolboxHome {
<button class="bookmark-btn" type="button" data-action="cancel-category">取消</button> <button class="bookmark-btn" type="button" data-action="cancel-category">取消</button>
</div> </div>
</form> </form>
<div class="admin-list" id="category-list"></div> <form class="admin-form nav-form" id="admin-bookmark-form" style="display:none">
</div>
<div class="admin-panel">
<div class="admin-panel-title">书签维护</div>
<form class="admin-form" id="bookmark-form">
<div class="form-row"> <div class="form-row">
<label>名称</label> <label>名称</label>
<input name="name" required /> <input name="name" required placeholder="书签名称" />
</div> </div>
<div class="form-row"> <div class="form-row">
<label>网址</label> <label>网址</label>
<input name="url" required /> <textarea name="url" rows="3" required placeholder="https://..."></textarea>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>分类</label> <label>分类</label>
@@ -269,7 +339,6 @@ class ToolboxHome {
<button class="bookmark-btn" type="button" data-action="cancel-bookmark">取消</button> <button class="bookmark-btn" type="button" data-action="cancel-bookmark">取消</button>
</div> </div>
</form> </form>
<div class="admin-list" id="bookmark-list"></div>
</div> </div>
</div> </div>
`; `;
@@ -334,6 +403,7 @@ class ToolboxHome {
</div> </div>
`; `;
document.body.appendChild(iconPicker); document.body.appendChild(iconPicker);
document.body.appendChild(adminModal);
const fitnessDiaryReminder = document.createElement('div'); const fitnessDiaryReminder = document.createElement('div');
fitnessDiaryReminder.className = 'fitness-diary-reminder'; fitnessDiaryReminder.className = 'fitness-diary-reminder';
@@ -383,13 +453,22 @@ class ToolboxHome {
this.bookmarkContainer = bookmarkSections; this.bookmarkContainer = bookmarkSections;
this.bookmarkEmpty = bookmarkEmpty; this.bookmarkEmpty = bookmarkEmpty;
this.bookmarkAdmin = bookmarkAdmin; this.bookmarkAdmin = bookmarkAdmin;
this.categoryForm = bookmarkAdmin.querySelector('#category-form'); this.categoryForm = adminModal.querySelector('#admin-category-form');
this.bookmarkForm = bookmarkAdmin.querySelector('#bookmark-form'); this.bookmarkForm = adminModal.querySelector('#admin-bookmark-form');
this.categoryListEl = bookmarkAdmin.querySelector('#category-list'); this.categoryListEl = bookmarkAdmin.querySelector('#category-table-body');
this.bookmarkListEl = bookmarkAdmin.querySelector('#bookmark-list'); this.bookmarkListEl = bookmarkAdmin.querySelector('#bookmark-table-body');
this.bookmarkSearchInput = bookmarkToolbar.querySelector('#bookmark-search'); this.bookmarkSearchInput = bookmarkToolbar.querySelector('#bookmark-search');
this.bookmarkCreatedAtEl = bookmarkAdmin.querySelector('[data-field="created_at"]'); this.bookmarkCreatedAtEl = adminModal.querySelector('[data-field="created_at"]');
this.iconPreviewEl = bookmarkAdmin.querySelector('[data-field="icon-preview"]'); 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.iconPicker = iconPicker;
this.iconPickerGrid = iconPicker.querySelector('.icon-picker-grid'); this.iconPickerGrid = iconPicker.querySelector('.icon-picker-grid');
this.fitnessDiaryReminder = fitnessDiaryReminder; this.fitnessDiaryReminder = fitnessDiaryReminder;
@@ -525,6 +604,7 @@ class ToolboxHome {
this.adminVisible = false; this.adminVisible = false;
if (this.bookmarkAdmin) this.bookmarkAdmin.classList.remove('active'); if (this.bookmarkAdmin) this.bookmarkAdmin.classList.remove('active');
if (this.bookmarksPage) this.bookmarksPage.classList.remove('admin-visible'); if (this.bookmarksPage) this.bookmarksPage.classList.remove('admin-visible');
this.closeAdminModal();
} }
getIconUrl(icon) { getIconUrl(icon) {
@@ -631,58 +711,269 @@ class ToolboxHome {
renderAdmin() { renderAdmin() {
if (!this.bookmarkAdmin) return; if (!this.bookmarkAdmin) return;
const categorySelect = this.bookmarkForm ? this.bookmarkForm.querySelector('select[name="category_code"]') : null; if (!this.adminVisible) return;
if (categorySelect) { this.switchAdminTab(this.adminTab);
categorySelect.innerHTML = ''; }
const emptyOpt = document.createElement('option');
emptyOpt.value = ''; // 后台维护 tab 切换(分类 / 书签)
emptyOpt.textContent = '未分类'; switchAdminTab(tab) {
categorySelect.appendChild(emptyOpt); if (!this.bookmarkAdmin) return;
this.categories.forEach(cat => { this.adminTab = tab;
const opt = document.createElement('option'); this.bookmarkAdmin.querySelectorAll('.admin-tab-btn').forEach(btn => {
opt.value = cat.code; btn.classList.toggle('active', btn.dataset.admTab === tab);
opt.textContent = `${cat.code} - ${cat.name}`; });
categorySelect.appendChild(opt); 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 => { esc(s) {
const row = document.createElement('div'); return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
row.className = 'admin-row'; }
row.innerHTML = `
<div class="admin-row-main"> // 判断网址是否为文件下载链接(按扩展名 + 下载关键字)
<div class="admin-row-title">${cat.code} - ${cat.name}</div> isFileUrl(url) {
<div class="admin-row-meta">排序 ${cat.sort_order || 0}</div> const raw = String(url || '');
</div> 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 = '<tr><td colspan="5" class="empty-row">暂无分类</td></tr>';
return;
}
this.categoryListEl.innerHTML = this.adminCategories.map(cat => `
<tr>
<td>${this.esc(cat.code)}</td>
<td>${this.esc(cat.name)}</td>
<td>${Number(cat.sort_order || 0)}</td>
<td>${Number(cat.bookmark_count || 0)}</td>
<td class="td-actions">
<div class="admin-row-actions"> <div class="admin-row-actions">
<button class="bookmark-btn" type="button" data-action="edit-category" data-code="${cat.code}">编辑</button> <button class="bookmark-btn" type="button" data-action="edit-category" data-code="${this.esc(cat.code)}">编辑</button>
<button class="bookmark-btn danger" type="button" data-action="delete-category" data-code="${cat.code}">删除</button> <button class="bookmark-btn danger" type="button" data-action="delete-category" data-code="${this.esc(cat.code)}">删除</button>
</div> </div>
`; </td>
this.categoryListEl.appendChild(row); </tr>
}); `).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 = `<tr><td colspan="7" class="empty-row">暂无书签${hasFilter ? '(筛选条件无结果)' : ''}</td></tr>`;
return;
} }
if (this.bookmarkListEl) { this.bookmarkListEl.innerHTML = this.adminBookmarks.map(item => {
this.bookmarkListEl.innerHTML = ''; const iconUrl = this.getIconUrl(item.icon);
this.bookmarks.forEach(item => { const iconHtml = iconUrl ? `<img src="${this.esc(iconUrl)}" alt="" loading="lazy" />` : '';
const cat = this.categories.find(c => String(c.code) === String(item.category_code)) || { name: '未分类' }; const isFile = this.isFileUrl(item.url);
const row = document.createElement('div'); const urlIcon = `<span class="url-type-icon" title="${isFile ? '文件下载' : '网页链接'}">${isFile ? '📄' : '🔗'}</span>`;
row.className = 'admin-row'; const remarkHtml = item.remark ? `
row.innerHTML = ` <div class="td-remark-wrap">
<div class="admin-row-main"> <span class="td-sub">${this.esc(item.remark)}</span>
<div class="admin-row-title">${item.name || '未命名'}</div> <button class="remark-toggle" type="button" data-action="toggle-remark" title="展开/收起备注">👇</button>
<div class="admin-row-meta">${cat.name} · 排序 ${item.sort_order || 0}</div> </div>` : '';
</div> return `
<div class="admin-row-actions"> <tr>
<button class="bookmark-btn" type="button" data-action="edit-bookmark" data-id="${item.id}">编辑</button> <td class="td-icon">${iconHtml}</td>
<button class="bookmark-btn danger" type="button" data-action="delete-bookmark" data-id="${item.id}">删除</button> <td class="td-main">
</div> <div>${this.esc(item.name || '未命名')}</div>
`; ${remarkHtml}
this.bookmarkListEl.appendChild(row); </td>
}); <td class="td-url">${urlIcon}<a href="${this.esc(item.url || '#')}" target="_blank" rel="noopener">${this.esc(item.url || '-')}</a></td>
<td>${this.esc(this.getCategoryName(item.category_code))}</td>
<td>${Number(item.sort_order || 0)}</td>
<td>${this.fmtNavTime(item.created_at)}</td>
<td class="td-actions">
<div class="admin-row-actions">
<button class="bookmark-btn" type="button" data-action="edit-bookmark" data-id="${this.esc(item.id)}">编辑</button>
<button class="bookmark-btn danger" type="button" data-action="delete-bookmark" data-id="${this.esc(item.id)}">删除</button>
</div>
</td>
</tr>
`;
}).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() { 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) { if (this.bookmarkContainer) {
this.bookmarkContainer.addEventListener('click', (e) => { this.bookmarkContainer.addEventListener('click', (e) => {
const toggle = e.target.closest('.category-toggle'); const toggle = e.target.closest('.category-toggle');
@@ -785,6 +1140,7 @@ class ToolboxHome {
} }
if (this.categoryForm) { if (this.categoryForm) {
const categorySubmitBtn = this.categoryForm.querySelector('button[type="submit"]');
this.categoryForm.addEventListener('submit', async (e) => { this.categoryForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const data = new FormData(this.categoryForm); const data = new FormData(this.categoryForm);
@@ -793,6 +1149,8 @@ class ToolboxHome {
name: String(data.get('name') || '').trim(), name: String(data.get('name') || '').trim(),
sort_order: Number(data.get('sort_order') || 0) sort_order: Number(data.get('sort_order') || 0)
}; };
const wasEditing = !!this.editingCategoryCode;
if (categorySubmitBtn) { categorySubmitBtn.disabled = true; categorySubmitBtn.textContent = '保存中…'; }
try { try {
if (this.editingCategoryCode) { if (this.editingCategoryCode) {
await this.apiRequest(`/api/nav_bookmarks/categories/${encodeURIComponent(this.editingCategoryCode)}`, { await this.apiRequest(`/api/nav_bookmarks/categories/${encodeURIComponent(this.editingCategoryCode)}`, {
@@ -808,20 +1166,28 @@ class ToolboxHome {
}); });
} }
this.resetCategoryForm(); this.resetCategoryForm();
this.closeAdminModal();
if (!wasEditing) this.adminCatPage = 1;
await this.loadBookmarks(); await this.loadBookmarks();
await this.loadAdminCategories();
this.renderAdminCategoryFilter();
} catch (err) { } catch (err) {
alert(err.message || '保存失败'); alert(err.message || '保存失败');
} finally {
if (categorySubmitBtn) { categorySubmitBtn.disabled = false; categorySubmitBtn.textContent = '保存'; }
} }
}); });
const cancelBtn = this.categoryForm.querySelector('[data-action="cancel-category"]'); const cancelBtn = this.categoryForm.querySelector('[data-action="cancel-category"]');
if (cancelBtn) { if (cancelBtn) {
cancelBtn.addEventListener('click', () => { cancelBtn.addEventListener('click', () => {
this.resetCategoryForm(); this.resetCategoryForm();
this.closeAdminModal();
}); });
} }
} }
if (this.bookmarkForm) { if (this.bookmarkForm) {
const bookmarkSubmitBtn = this.bookmarkForm.querySelector('button[type="submit"]');
this.bookmarkForm.addEventListener('submit', async (e) => { this.bookmarkForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const data = new FormData(this.bookmarkForm); const data = new FormData(this.bookmarkForm);
@@ -833,6 +1199,8 @@ class ToolboxHome {
remark: String(data.get('remark') || '').trim(), remark: String(data.get('remark') || '').trim(),
sort_order: Number(data.get('sort_order') || 0) sort_order: Number(data.get('sort_order') || 0)
}; };
const wasEditing = !!this.editingBookmarkId;
if (bookmarkSubmitBtn) { bookmarkSubmitBtn.disabled = true; bookmarkSubmitBtn.textContent = '保存中…'; }
try { try {
if (this.editingBookmarkId) { if (this.editingBookmarkId) {
await this.apiRequest(`/api/nav_bookmarks/bookmarks/${encodeURIComponent(this.editingBookmarkId)}`, { await this.apiRequest(`/api/nav_bookmarks/bookmarks/${encodeURIComponent(this.editingBookmarkId)}`, {
@@ -848,15 +1216,21 @@ class ToolboxHome {
}); });
} }
this.resetBookmarkForm(); this.resetBookmarkForm();
this.closeAdminModal();
if (!wasEditing) this.adminBookPage = 1;
await this.loadBookmarks(); await this.loadBookmarks();
await this.loadAdminBookmarks();
} catch (err) { } catch (err) {
alert(err.message || '保存失败'); alert(err.message || '保存失败');
} finally {
if (bookmarkSubmitBtn) { bookmarkSubmitBtn.disabled = false; bookmarkSubmitBtn.textContent = '保存'; }
} }
}); });
const cancelBtn = this.bookmarkForm.querySelector('[data-action="cancel-bookmark"]'); const cancelBtn = this.bookmarkForm.querySelector('[data-action="cancel-bookmark"]');
if (cancelBtn) { if (cancelBtn) {
cancelBtn.addEventListener('click', () => { cancelBtn.addEventListener('click', () => {
this.resetBookmarkForm(); this.resetBookmarkForm();
this.closeAdminModal();
}); });
} }
this.bookmarkForm.addEventListener('input', (e) => { this.bookmarkForm.addEventListener('input', (e) => {
@@ -886,14 +1260,16 @@ class ToolboxHome {
const action = btn.dataset.action; const action = btn.dataset.action;
const code = btn.dataset.code; const code = btn.dataset.code;
if (action === 'edit-category') { if (action === 'edit-category') {
const cat = this.categories.find(c => String(c.code) === String(code)); const cat = this.adminCategories.find(c => String(c.code) === String(code)) || this.categories.find(c => String(c.code) === String(code));
if (cat) this.fillCategoryForm(cat); if (cat) this.openCategoryModal(cat);
} }
if (action === 'delete-category') { if (action === 'delete-category') {
if (!confirm('确定删除该分类吗?')) return; if (!confirm('确定删除该分类吗?')) return;
try { try {
await this.apiRequest(`/api/nav_bookmarks/categories/${encodeURIComponent(code)}`, { method: 'DELETE' }); await this.apiRequest(`/api/nav_bookmarks/categories/${encodeURIComponent(code)}`, { method: 'DELETE' });
await this.loadBookmarks(); await this.loadBookmarks();
await this.loadAdminCategories();
this.renderAdminCategoryFilter();
} catch (err) { } catch (err) {
alert(err.message || '删除失败'); alert(err.message || '删除失败');
} }
@@ -902,23 +1278,29 @@ class ToolboxHome {
} }
if (this.bookmarkListEl) { if (this.bookmarkListEl) {
this.bookmarkListEl.addEventListener('click', async (e) => { this.bookmarkListEl.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action]'); const btn = e.target.closest('button[data-action]');
if (!btn) return; if (!btn) return;
const action = btn.dataset.action; const action = btn.dataset.action;
const id = btn.dataset.id; 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') { if (action === 'edit-bookmark') {
const item = this.bookmarks.find(b => String(b.id) === String(id)); const item = this.adminBookmarks.find(b => String(b.id) === String(id)) || this.bookmarks.find(b => String(b.id) === String(id));
if (item) this.fillBookmarkForm(item); if (item) this.openBookmarkModal(item);
} }
if (action === 'delete-bookmark') { if (action === 'delete-bookmark') {
if (!confirm('确定删除该书签吗?')) return; if (!confirm('确定删除该书签吗?')) return;
try { this.apiRequest(`/api/nav_bookmarks/bookmarks/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(async () => {
await this.apiRequest(`/api/nav_bookmarks/bookmarks/${encodeURIComponent(id)}`, { method: 'DELETE' });
await this.loadBookmarks(); await this.loadBookmarks();
} catch (err) { await this.loadAdminBookmarks();
alert(err.message || '删除失败'); }).catch(err => alert(err.message || '删除失败'));
}
} }
}); });
} }
@@ -1165,7 +1547,7 @@ class ToolboxHome {
if (!this.bookmarkForm) return; if (!this.bookmarkForm) return;
this.editingBookmarkId = String(item.id); this.editingBookmarkId = String(item.id);
const nameInput = this.bookmarkForm.querySelector('input[name="name"]'); 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 catSelect = this.bookmarkForm.querySelector('select[name="category_code"]');
const iconInput = this.bookmarkForm.querySelector('input[name="icon"]'); const iconInput = this.bookmarkForm.querySelector('input[name="icon"]');
const remarkInput = this.bookmarkForm.querySelector('textarea[name="remark"]'); const remarkInput = this.bookmarkForm.querySelector('textarea[name="remark"]');
+6
View File
@@ -51,6 +51,12 @@
<div class="stale-desc">如果这是上次异常退出遗留,可以先清理占用再重新启动。</div> <div class="stale-desc">如果这是上次异常退出遗留,可以先清理占用再重新启动。</div>
<button class="btn-stale-clean" id="btnCleanStale">清理占用</button> <button class="btn-stale-clean" id="btnCleanStale">清理占用</button>
</div> </div>
<!-- 语音不可用预警 -->
<div id="voiceWarning" class="stale-warning voice-warning" style="display:none">
<div class="stale-title">语音播报可能不可用</div>
<div class="stale-desc" id="voiceWarningDesc">本机系统语音未启动,已尝试云端语音兜底。</div>
<button class="btn-stale-clean" id="btnRetryVoice">重新检测</button>
</div>
<!-- 新建按钮 --> <!-- 新建按钮 -->
<div class="cards-toolbar"> <div class="cards-toolbar">
<button class="btn-primary" id="btnNewCard"> <button class="btn-primary" id="btnNewCard">
+7 -1
View File
@@ -120,6 +120,12 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
.btn-stale-clean:hover { filter: brightness(1.08); } .btn-stale-clean:hover { filter: brightness(1.08); }
.btn-stale-clean:active { transform: scale(0.96); } .btn-stale-clean:active { transform: scale(0.96); }
/* 语音不可用预警:沿用 stale-warning 结构,改用错误色以区别于残留 session 警告 */
.voice-warning { border-color: var(--error); background: rgba(239,68,68,0.07); }
.voice-warning .stale-title { color: var(--error); }
.voice-warning .btn-stale-clean { background: var(--error); color: #fff; }
.voice-probe-text { color: var(--primary); font-weight: 600; }
/* ============================================================ /* ============================================================
卡片页 卡片页
============================================================ */ ============================================================ */
@@ -154,7 +160,7 @@ input[type='number'] { -moz-appearance: textfield; appearance: textfield; }
.step-preview-line { font-size: 13px; color: var(--text-secondary); line-height: 1.6; } .step-preview-line { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
.step-preview-more { font-size: 13px; color: var(--text-disabled); font-style: italic; line-height: 1.6; } .step-preview-more { font-size: 13px; color: var(--text-disabled); font-style: italic; line-height: 1.6; }
.card-actions { display: flex; flex-direction: row; gap: 8px; margin-top: 12px; } .card-actions { display: flex; flex-direction: row; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
/* 卡片按钮 — JS 用 .btn-card-primary / .btn-card-ghost */ /* 卡片按钮 — JS 用 .btn-card-primary / .btn-card-ghost */
.btn-card-primary { .btn-card-primary {
+306 -16
View File
@@ -490,6 +490,9 @@
async function handleEnterMotion() { async function handleEnterMotion() {
const btn = document.getElementById('btnEnterMotion'); const btn = document.getElementById('btnEnterMotion');
// 必须在 await 之前(用户手势上下文中)解锁音频与语音,供后续探测与播报使用
unlockAudio();
unlockSpeech();
setButtonLoading(btn, '进入中…'); setButtonLoading(btn, '进入中…');
const res = await runTrackedInteraction('设置激活 kit(motion_reminder)', function () { const res = await runTrackedInteraction('设置激活 kit(motion_reminder)', function () {
return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: 'motion_reminder' }); return apiFetch('PUT', '/api/app_order_box/state/active-kit', { kitId: 'motion_reminder' });
@@ -503,6 +506,8 @@
await refreshState(true); await refreshState(true);
showView('motion'); showView('motion');
switchTab('cards'); switchTab('cards');
// 交互详情弹窗(debug 3s / normal 2s 自动关闭)会盖住探测弹窗,等它先收起再探测语音
setTimeout(function () { probeSpeech(); }, getUiMode() === 'debug' ? 3200 : 2200);
} }
async function handleBackHub() { async function handleBackHub() {
@@ -560,6 +565,7 @@
'</button>' + '</button>' +
'<button type="button" class="btn-card-ghost btn-edit-card"><span class="btn-text">编辑</span></button>' + '<button type="button" class="btn-card-ghost btn-edit-card"><span class="btn-text">编辑</span></button>' +
'<button type="button" class="btn-card-ghost btn-delete-card"><span class="btn-text btn-text-danger">删除</span></button>' + '<button type="button" class="btn-card-ghost btn-delete-card"><span class="btn-text btn-text-danger">删除</span></button>' +
'<button type="button" class="btn-card-ghost btn-regen-tts"><span class="btn-text">重生语音</span></button>' +
'</div>' + '</div>' +
'</div>'; '</div>';
}).join(''); }).join('');
@@ -600,6 +606,23 @@
if (btn.classList.contains('btn-start-card')) handleStartCard(card, btn); 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-edit-card')) openEditor(card);
else if (btn.classList.contains('btn-delete-card')) handleDeleteCard(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) { function handleDeleteCard(card) {
@@ -630,6 +653,8 @@
// 必须在 await 之前(用户手势上下文中)解锁音频和语音 // 必须在 await 之前(用户手势上下文中)解锁音频和语音
unlockAudio(); unlockAudio();
unlockSpeech(); unlockSpeech();
// 会话启动时批量预合成整卡云端语音(不阻塞启动,失败时运行期回退系统 TTS)
prepareCardTTS(card);
setButtonLoading(btn, '启动中…'); setButtonLoading(btn, '启动中…');
const res = await runTrackedInteraction('启动执行会话', function () { const res = await runTrackedInteraction('启动执行会话', function () {
return apiFetch('POST', '/api/app_order_box/kits/motion_reminder/sessions/start', { card: card }); 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<boolean> */
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) { function speakText(text, pitch) {
try { const t = String(text || '').trim();
if (!('speechSynthesis' in window)) return; if (!t) return;
window.speechSynthesis.cancel(); if (speechEngineMode === 'cloud' && playCloudAtoms(t)) return;
const utterance = new SpeechSynthesisUtterance(text); speakSystem(t, pitch).then(function (r) {
utterance.lang = 'zh-CN'; if (r.ok) return;
utterance.pitch = Number(pitch) || 1; // 系统 TTS 本次失败:有云端音频就切过去,否则显式预警
utterance.rate = 0.95; if (playCloudAtoms(t)) {
window.speechSynthesis.speak(utterance); if (speechEngineMode !== 'cloud') {
} catch (e) { /* 不支持时静默 */ } speechEngineMode = 'cloud';
showToast('已自动切换到云端语音播报', 'info', 2000);
}
return;
}
markVoiceUnavailable('系统语音与云端语音均不可用,请检查系统语音引擎或音量设置。');
});
} }
/** 在用户手势中调用,解锁 iOS Safari 的语音合成限制 */ /** 在用户手势中调用,解锁 iOS Safari 的语音合成限制 */
@@ -1374,6 +1553,112 @@
try { try {
if ('speechSynthesis' in window) window.speechSynthesis.cancel(); if ('speechSynthesis' in window) window.speechSynthesis.cancel();
} catch (e) { /* 静默 */ } } 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('语音检查',
'正在播报欢迎语:<br><span class="voice-probe-text">' + escHtml(WELCOME_TEXT) + '</span><br>请确认是否听到声音。',
[
{
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}" */ /** 步骤切换播报:"第N轮,{名称},{时长}。下一步,{nextName}" */
@@ -1387,11 +1672,13 @@
if (!step) return; if (!step) return;
const nextStep = runnerState.card.steps[runnerState.currentStepIndex + 1] || null; const nextStep = runnerState.card.steps[runnerState.currentStepIndex + 1] || null;
const pitch = step.type === 'action' ? 1.1 : 0.9; const pitch = step.type === 'action' ? 1.1 : 0.9;
const title = (step.voiceText && String(step.voiceText).trim()) const atoms = stepSpeechAtoms(runnerState.currentLoop, step, nextStep, runnerState.card.loopMode);
? String(step.voiceText).trim() if (speechEngineMode === 'cloud' && playCloudAtoms(atoms)) return;
: (step.name + ',' + formatSpeechDuration(step.durationSec)); // 系统 TTS 用合并句播报更自然;'下一步' 前保留句号,保持原有语气
const nextText = nextStep ? '。下一步,' + nextStep.name : ''; const merged = atoms.map(function (t, i) {
speakText('第 ' + runnerState.currentLoop + ' 轮,' + title + nextText, pitch); return (i > 0 && t.indexOf('下一步') === 0) ? '。' + t : t;
}).join(',');
speakText(merged, pitch);
} }
// ==================== 提示音 ==================== // ==================== 提示音 ====================
@@ -1536,6 +1823,9 @@
document.getElementById('btnBackHub').addEventListener('click', handleBackHub); document.getElementById('btnBackHub').addEventListener('click', handleBackHub);
document.getElementById('btnNewCard').addEventListener('click', function () { openEditor(null); }); document.getElementById('btnNewCard').addEventListener('click', function () { openEditor(null); });
document.getElementById('btnCleanStale').addEventListener('click', handleCleanStale); 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('btnEditorClose').addEventListener('click', closeEditor);
document.getElementById('btnInteractionCopy').addEventListener('click', handleCopyInteraction); document.getElementById('btnInteractionCopy').addEventListener('click', handleCopyInteraction);
document.getElementById('cardsList').addEventListener('click', handleCardsListClick); document.getElementById('cardsList').addEventListener('click', handleCardsListClick);
@@ -1,5 +1,6 @@
const crypto = require('crypto') const crypto = require('crypto')
const store = require('./store') const store = require('./store')
const tts = require('./tts')
const createId = () => { const createId = () => {
try { return crypto.randomUUID() } catch { return `${Date.now()}_${Math.random().toString(36).slice(2)}` } 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) => { app.post('/api/app_order_box/kits/motion_reminder/sessions/start', (req, res) => {
try { try {
const raw = req.body && req.body.card ? req.body.card : {} const raw = req.body && req.body.card ? req.body.card : {}
@@ -34,6 +34,15 @@ const getDb = () => {
manual_stopped INTEGER NOT NULL DEFAULT 0, manual_stopped INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL 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_cards_updated ON cards(updated_at);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_motion_sessions_started ON sessions(started_at);`) db.exec(`CREATE INDEX IF NOT EXISTS idx_motion_sessions_started ON sessions(started_at);`)
return db return db
@@ -219,7 +228,43 @@ const listHistory = () => {
return rows.map(mapSession) 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 = { module.exports = {
getDb,
nowIso,
listCards, listCards,
getCardById, getCardById,
insertCard, insertCard,
@@ -230,4 +275,7 @@ module.exports = {
syncSession, syncSession,
finishSession, finishSession,
listHistory, listHistory,
getTTSCacheRow,
insertTTSCache,
updateTTSCacheUrl,
} }
@@ -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,
}
+52 -11
View File
@@ -1044,6 +1044,20 @@ app.use('/uploads/mp_rep_clock', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next() if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).send('未授权') return res.status(401).send('未授权')
}) })
app.use('/uploads/app_order_box', (req, res, next) => {
// 运动提醒宝的 TTS 兜底音频由 <audio> 标签直接拉取,无法携带自定义鉴权头。
// 这里只对白名单目录放开,其他上传资源仍保持鉴权。
if (req && typeof req.path === 'string' && req.path.indexOf('/tts_cache/') === 0) return next()
try {
const authConfigPath = path.join(process.cwd(), 'public', 'tools', 'web_order_box', 'auth_config.json')
if (fs.existsSync(authConfigPath)) {
const authConfig = JSON.parse(fs.readFileSync(authConfigPath, 'utf-8'))
if (authConfig.enable_auth === false) return next()
}
} catch {}
if (hasNavAuthRoot(req)) return next()
return res.status(401).send('未授权')
})
const PLANT_HOME_AUTH_COOKIE = 'plant_home_gate' const PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
const readPlantHomeAuthConfig = () => { const readPlantHomeAuthConfig = () => {
@@ -5610,8 +5624,23 @@ app.delete('/api/ccb_private_funds/attachments/:id', (req, res) => {
// Nav Bookmarks API // Nav Bookmarks API
app.get('/api/nav_bookmarks/categories', (req, res) => { app.get('/api/nav_bookmarks/categories', (req, res) => {
try { try {
const rows = NAV_BOOKMARKS_DB.prepare('SELECT code, name, sort_order, created_at, updated_at FROM nav_categories ORDER BY sort_order ASC, code ASC').all() // 兼容旧调用:不带 page 参数时返回全量;带 page 参数时服务端分页
res.json({ ok: true, rows }) const hasPage = req.query.page !== undefined && req.query.page !== ''
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize, 10) || 20))
const total = NAV_BOOKMARKS_DB.prepare('SELECT COUNT(1) AS c FROM nav_categories').get().c
const base = `
SELECT c.code, c.name, c.sort_order, c.created_at, c.updated_at,
COALESCE((SELECT COUNT(1) FROM nav_bookmarks b WHERE b.category_code = c.code), 0) AS bookmark_count
FROM nav_categories c
`
let rows
if (hasPage) {
rows = NAV_BOOKMARKS_DB.prepare(`${base} ORDER BY c.sort_order ASC, c.code ASC LIMIT ? OFFSET ?`).all(pageSize, (page - 1) * pageSize)
} else {
rows = NAV_BOOKMARKS_DB.prepare(`${base} ORDER BY c.sort_order ASC, c.code ASC`).all()
}
res.json({ ok: true, rows, total, total_pages: Math.ceil(total / (pageSize || 1)), page: hasPage ? page : undefined, page_size: pageSize })
} catch (e) { } catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) }) res.status(500).json({ ok: false, error: String(e.message || e) })
} }
@@ -5662,17 +5691,29 @@ app.get('/api/nav_bookmarks/bookmarks', (req, res) => {
try { try {
const category = String(req.query.category_code || '').trim() const category = String(req.query.category_code || '').trim()
const q = String(req.query.q || '').trim().toLowerCase() const q = String(req.query.q || '').trim().toLowerCase()
let rows = NAV_BOOKMARKS_DB.prepare('SELECT id, name, url, icon, category_code, remark, sort_order, created_at, updated_at FROM nav_bookmarks ORDER BY sort_order ASC, created_at ASC').all() // 兼容旧调用:不带 page 参数时返回全量;带 page 参数时服务端分页
if (category) rows = rows.filter(r => String(r.category_code || '') === category) const hasPage = req.query.page !== undefined && req.query.page !== ''
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize, 10) || 20))
const where = []
const params = []
if (category) { where.push('category_code = ?'); params.push(category) }
if (q) { if (q) {
rows = rows.filter(r => { where.push('(LOWER(name) LIKE ? OR LOWER(url) LIKE ? OR LOWER(remark) LIKE ?)')
const name = String(r.name || '').toLowerCase() const like = `%${q}%`
const url = String(r.url || '').toLowerCase() params.push(like, like, like)
const remark = String(r.remark || '').toLowerCase()
return name.includes(q) || url.includes(q) || remark.includes(q)
})
} }
res.json({ ok: true, rows }) const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : ''
const total = NAV_BOOKMARKS_DB.prepare(`SELECT COUNT(1) AS c FROM nav_bookmarks ${whereSql}`).get(...params).c
let rows
if (hasPage) {
rows = NAV_BOOKMARKS_DB.prepare(`SELECT id, name, url, icon, category_code, remark, sort_order, created_at, updated_at
FROM nav_bookmarks ${whereSql} ORDER BY sort_order ASC, created_at ASC LIMIT ? OFFSET ?`).all(...params, pageSize, (page - 1) * pageSize)
} else {
rows = NAV_BOOKMARKS_DB.prepare(`SELECT id, name, url, icon, category_code, remark, sort_order, created_at, updated_at
FROM nav_bookmarks ${whereSql} ORDER BY sort_order ASC, created_at ASC`).all(...params)
}
res.json({ ok: true, rows, total, total_pages: Math.ceil(total / (pageSize || 1)), page: hasPage ? page : undefined, page_size: pageSize })
} catch (e) { } catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) }) res.status(500).json({ ok: false, error: String(e.message || e) })
} }
+43 -1
View File
@@ -244,6 +244,45 @@ const bindRoutes = app => {
} }
}) })
app.get(`${API_PREFIX}/trash`, (req, res) => {
try {
return res.json({ ok: true, data: store.listTrash() })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put(`${API_PREFIX}/trash/restore`, (req, res) => {
try {
const id = String((req.body && req.body.id) || req.query.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const ok = store.restoreItem(id)
if (!ok) return res.status(404).json({ ok: false, error: '回收站中不存在该收藏' })
return res.json({ ok: true })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete(`${API_PREFIX}/trash`, (req, res) => {
try {
const id = String((req.body && req.body.id) || req.query.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const r = store.purgeItem(id)
if (!r || !r.ok) return res.status(404).json({ ok: false, error: r && r.error || 'not found' })
return res.json({
ok: true,
data: {
filesRemoved: r.filesRemoved,
bytesRemoved: r.bytesRemoved,
attachments: r.attachments,
},
})
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post(`${API_PREFIX}/upload`, upload.single('file'), (req, res) => { app.post(`${API_PREFIX}/upload`, upload.single('file'), (req, res) => {
try { try {
const f = req.file const f = req.file
@@ -253,7 +292,10 @@ const bindRoutes = app => {
const relPath = path.join(relDir, f.filename).replace(/\\/g, '/') const relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
const urlPath = '/' + relPath const urlPath = '/' + relPath
const mimeType = String(f.mimetype || '').trim() const mimeType = String(f.mimetype || '').trim()
const originalName = fixFileNameEncoding(f.originalname) // 优先使用小程序端透传的原始文件名(UTF-8 完好);
// 兜底才解析 multipart 里的 originalname(桌面微信传输中文名可能已被损坏)
const originalNameRaw = String(req.body && req.body.originalName || '').trim() || f.originalname
const originalName = fixFileNameEncoding(originalNameRaw)
const isImage = isImageByMime(mimeType) || isImageByName(originalName) const isImage = isImageByMime(mimeType) || isImageByName(originalName)
const saved = store.insertAttachment({ const saved = store.insertAttachment({
id: createId(), id: createId(),
+57
View File
@@ -1,5 +1,6 @@
const Database = require('better-sqlite3') const Database = require('better-sqlite3')
const path = require('path') const path = require('path')
const fs = require('fs')
const iconv = require('iconv-lite') const iconv = require('iconv-lite')
function scoreChinese(str) { function scoreChinese(str) {
@@ -278,6 +279,59 @@ const deleteItem = id => {
getDb().prepare(`UPDATE favorites SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), id) getDb().prepare(`UPDATE favorites SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), id)
} }
// 回收站:列出软删除的收藏(含附件),按删除时间倒序
const listTrash = () => {
const rows = getDb().prepare(`
SELECT * FROM favorites
WHERE deleted = 1
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
`).all()
return rows.map(row => {
const item = mapFavorite(row)
item.attachments = getAttachmentsByFavoriteId(row.id)
return item
})
}
// 回收站:恢复软删除的收藏
const restoreItem = id => {
const info = getDb().prepare(`UPDATE favorites SET deleted = 0, updated_at = ? WHERE id = ? AND deleted = 1`).run(nowIso(), id)
return info.changes > 0
}
// 回收站:彻底删除(物理删除记录 + 磁盘文件 + 清理空目录),不可恢复
const purgeItem = id => {
const row = getDb().prepare(`SELECT * FROM favorites WHERE id = ? AND deleted = 1 LIMIT 1`).get(id)
if (!row) return { ok: false, error: '回收站中不存在该收藏' }
const attachments = getDb().prepare(`SELECT * FROM attachments WHERE favorite_id = ?`).all(id)
let filesRemoved = 0
let bytesRemoved = 0
const dirsToTry = new Set()
for (const a of attachments) {
const rel = String(a.file_path || '')
if (rel) {
const abs = path.join(process.cwd(), rel)
try {
if (fs.statSync(abs).isFile()) fs.unlinkSync(abs)
filesRemoved++
bytesRemoved += Number(a.file_size || 0)
} catch {}
try { dirsToTry.add(path.dirname(abs)) } catch {}
}
}
const tx = getDb().transaction(() => {
getDb().prepare(`DELETE FROM attachments WHERE favorite_id = ?`).run(id)
getDb().prepare(`DELETE FROM favorites WHERE id = ? AND deleted = 1`).run(id)
})
tx()
for (const dir of dirsToTry) {
try {
if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir)
} catch {}
}
return { ok: true, filesRemoved, bytesRemoved, attachments: attachments.length }
}
const insertAttachment = ({ id, favoriteId, fileName, storedName, filePath, fileSize, mimeType, isImage }) => { const insertAttachment = ({ id, favoriteId, fileName, storedName, filePath, fileSize, mimeType, isImage }) => {
const createdAt = nowIso() const createdAt = nowIso()
const payload = { const payload = {
@@ -324,6 +378,9 @@ module.exports = {
insertItem, insertItem,
updateItem, updateItem,
deleteItem, deleteItem,
listTrash,
restoreItem,
purgeItem,
insertAttachment, insertAttachment,
getAttachmentById, getAttachmentById,
getAttachmentsByFavoriteId, getAttachmentsByFavoriteId,