feat(personal_collect,motion_reminder): 新增回收站与云端TTS语音功能
- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作 - 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数 - 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放 - 新增语音引擎探测与异常预警UI,覆盖web端与移动端 - 优化web端订单盒页面布局,修复卡片操作按钮换行问题 - 新增调试探针脚本、ESLint配置与相关lint脚本 - 完善后端接口与路由,更新项目依赖与配置文件
This commit is contained in:
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
Generated
+18
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Windows
|
||||||
|
[Dd]esktop.ini
|
||||||
|
Thumbs.db
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
@@ -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,6 +45,17 @@ Page({
|
|||||||
async refresh() {
|
async refresh() {
|
||||||
this.setData({ loading: true, error: '' })
|
this.setData({ loading: true, error: '' })
|
||||||
try {
|
try {
|
||||||
|
if (this.data.trashMode) {
|
||||||
|
const res = await api.personalCollect.trash()
|
||||||
|
const raw = Array.isArray(res.data) ? res.data : []
|
||||||
|
const trashItems = raw.map(item => ({
|
||||||
|
...item,
|
||||||
|
deletedAtText: formatDate(item.updatedAt || item.createdAt || ''),
|
||||||
|
tagsText: (item.tags || []).join(' · '),
|
||||||
|
fileCount: Array.isArray(item.attachments) ? item.attachments.length : 0,
|
||||||
|
}))
|
||||||
|
this.setData({ trashItems })
|
||||||
|
} else {
|
||||||
const res = await api.personalCollect.list(this.data.keyword || '')
|
const res = await api.personalCollect.list(this.data.keyword || '')
|
||||||
const raw = Array.isArray(res.data) ? res.data : []
|
const raw = Array.isArray(res.data) ? res.data : []
|
||||||
const items = raw.map(item => ({
|
const items = raw.map(item => ({
|
||||||
@@ -53,6 +66,7 @@ Page({
|
|||||||
fileCount: Array.isArray(item.attachments) ? item.attachments.length : 0,
|
fileCount: Array.isArray(item.attachments) ? item.attachments.length : 0,
|
||||||
}))
|
}))
|
||||||
this.setData({ items })
|
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;
|
||||||
|
}
|
||||||
Generated
+890
-8
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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
@@ -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;
|
||||||
|
|||||||
+459
-77
@@ -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;
|
||||||
|
this.bookmarkAdmin.querySelectorAll('.admin-tab-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.admTab === tab);
|
||||||
|
});
|
||||||
|
this.bookmarkAdmin.querySelectorAll('.admin-tab-page').forEach(p => {
|
||||||
|
p.classList.toggle('active', p.dataset.admPage === tab);
|
||||||
|
});
|
||||||
|
if (tab === 'bookmarks') {
|
||||||
|
this.renderAdminCategoryFilter();
|
||||||
|
this.loadAdminBookmarks();
|
||||||
|
} else {
|
||||||
|
this.loadAdminCategories();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
esc(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断网址是否为文件下载链接(按扩展名 + 下载关键字)
|
||||||
|
isFileUrl(url) {
|
||||||
|
const raw = String(url || '');
|
||||||
|
const path = raw.split(/[?#]/)[0] || '';
|
||||||
|
if (/\.(exe|msi|zip|rar|7z|tar|gz|bz2|pdf|doc|docx|xls|xlsx|ppt|pptx|csv|json|apk|ipa|dmg|pkg|iso|jar|torrent|xmind|dwg)(\/|$)/i.test(path)) return true;
|
||||||
|
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif|mp3|wav|flac|ogg|aac|mp4|mkv|mov|avi|m4a|wmv|flv|torrent)(\/|$)/i.test(path)) return true;
|
||||||
|
if (/\/(download|downloads|dl|get|file|files)\//i.test(path)) return true;
|
||||||
|
if (/[?&](download|dl|file|action)=/i.test(raw)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一按北京时间展示创建时间
|
||||||
|
fmtNavTime(iso) {
|
||||||
|
if (!iso) return '-';
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false
|
||||||
|
}).formatToParts(new Date(iso));
|
||||||
|
const get = t => { const p = parts.find(x => x.type === t); return p ? p.value : ''; };
|
||||||
|
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}`;
|
||||||
|
} catch {
|
||||||
|
return String(iso || '').slice(0, 16).replace('T', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategoryName(code) {
|
||||||
|
const cat = (this.categories || []).find(c => String(c.code) === String(code == null ? '' : code));
|
||||||
|
return cat ? cat.name : '未分类';
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAdminCategories() {
|
||||||
|
if (!this.bookmarkAdmin || !this.categoryListEl) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/nav_bookmarks/categories?page=${this.adminCatPage}&pageSize=${this.adminCatPageSize}&t=${Date.now()}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data || !data.ok) return;
|
||||||
|
this.adminCategories = Array.isArray(data.rows) ? data.rows : [];
|
||||||
|
this.adminCatTotal = Number(data.total || 0);
|
||||||
|
const totalPages = Math.max(1, Number(data.total_pages || 1));
|
||||||
|
if (this.adminCatPage > totalPages) {
|
||||||
|
this.adminCatPage = totalPages;
|
||||||
|
this.loadAdminCategories();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.renderAdminCategoryTable();
|
||||||
|
if (this.categoryCountEl) this.categoryCountEl.textContent = `共 ${this.adminCatTotal} 个分类`;
|
||||||
|
this.renderAdminPagination(this.categoryPaginationEl, this.adminCatPage, totalPages, p => { this.adminCatPage = p; this.loadAdminCategories(); });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAdminCategoryTable() {
|
||||||
|
if (!this.categoryListEl) return;
|
||||||
|
if (!this.adminCategories.length) {
|
||||||
|
this.categoryListEl.innerHTML = '<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">
|
||||||
|
<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="${this.esc(cat.code)}">删除</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
this.bookmarkListEl.innerHTML = this.adminBookmarks.map(item => {
|
||||||
|
const iconUrl = this.getIconUrl(item.icon);
|
||||||
|
const iconHtml = iconUrl ? `<img src="${this.esc(iconUrl)}" alt="" loading="lazy" />` : '';
|
||||||
|
const isFile = this.isFileUrl(item.url);
|
||||||
|
const urlIcon = `<span class="url-type-icon" title="${isFile ? '文件下载' : '网页链接'}">${isFile ? '📄' : '🔗'}</span>`;
|
||||||
|
const remarkHtml = item.remark ? `
|
||||||
|
<div class="td-remark-wrap">
|
||||||
|
<span class="td-sub">${this.esc(item.remark)}</span>
|
||||||
|
<button class="remark-toggle" type="button" data-action="toggle-remark" title="展开/收起备注">👇</button>
|
||||||
|
</div>` : '';
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="td-icon">${iconHtml}</td>
|
||||||
|
<td class="td-main">
|
||||||
|
<div>${this.esc(item.name || '未命名')}</div>
|
||||||
|
${remarkHtml}
|
||||||
|
</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');
|
const opt = document.createElement('option');
|
||||||
opt.value = cat.code;
|
opt.value = cat.code;
|
||||||
opt.textContent = `${cat.code} - ${cat.name}`;
|
opt.textContent = `${cat.code} - ${cat.name}`;
|
||||||
categorySelect.appendChild(opt);
|
this.bookmarkAdminCatFilter.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
if (current) this.bookmarkAdminCatFilter.value = current;
|
||||||
}
|
}
|
||||||
if (this.categoryListEl) {
|
|
||||||
this.categoryListEl.innerHTML = '';
|
renderAdminPagination(container, page, totalPages, onChange) {
|
||||||
this.categories.forEach(cat => {
|
if (!container) return;
|
||||||
const row = document.createElement('div');
|
container.innerHTML = '';
|
||||||
row.className = 'admin-row';
|
if (totalPages <= 1) return;
|
||||||
row.innerHTML = `
|
const mkBtn = (text, p, disabled, active) => {
|
||||||
<div class="admin-row-main">
|
const b = document.createElement('button');
|
||||||
<div class="admin-row-title">${cat.code} - ${cat.name}</div>
|
b.type = 'button';
|
||||||
<div class="admin-row-meta">排序 ${cat.sort_order || 0}</div>
|
b.className = 'bookmark-btn pagination-btn' + (active ? ' active' : '');
|
||||||
</div>
|
b.textContent = text;
|
||||||
<div class="admin-row-actions">
|
b.disabled = !!disabled;
|
||||||
<button class="bookmark-btn" type="button" data-action="edit-category" data-code="${cat.code}">编辑</button>
|
if (!disabled) b.addEventListener('click', () => onChange(p));
|
||||||
<button class="bookmark-btn danger" type="button" data-action="delete-category" data-code="${cat.code}">删除</button>
|
return b;
|
||||||
</div>
|
};
|
||||||
`;
|
const mkDots = () => {
|
||||||
this.categoryListEl.appendChild(row);
|
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());
|
||||||
|
}
|
||||||
|
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;
|
||||||
if (this.bookmarkListEl) {
|
|
||||||
this.bookmarkListEl.innerHTML = '';
|
|
||||||
this.bookmarks.forEach(item => {
|
|
||||||
const cat = this.categories.find(c => String(c.code) === String(item.category_code)) || { name: '未分类' };
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'admin-row';
|
|
||||||
row.innerHTML = `
|
|
||||||
<div class="admin-row-main">
|
|
||||||
<div class="admin-row-title">${item.name || '未命名'}</div>
|
|
||||||
<div class="admin-row-meta">${cat.name} · 排序 ${item.sort_order || 0}</div>
|
|
||||||
</div>
|
|
||||||
<div class="admin-row-actions">
|
|
||||||
<button class="bookmark-btn" type="button" data-action="edit-bookmark" data-id="${item.id}">编辑</button>
|
|
||||||
<button class="bookmark-btn danger" type="button" data-action="delete-bookmark" data-id="${item.id}">删除</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
this.bookmarkListEl.appendChild(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.updateIconPreview();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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"]');
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)不重复播报。
|
||||||
|
|
||||||
function speakText(text, pitch) {
|
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 {
|
try {
|
||||||
if (!('speechSynthesis' in window)) return;
|
if (!('speechSynthesis' in window) || typeof SpeechSynthesisUtterance === 'undefined') {
|
||||||
window.speechSynthesis.cancel();
|
finish(false, 'unsupported');
|
||||||
const utterance = new SpeechSynthesisUtterance(text);
|
return;
|
||||||
|
}
|
||||||
|
const synth = window.speechSynthesis;
|
||||||
|
try { synth.cancel(); } catch (e) { /* 静默 */ }
|
||||||
|
const utterance = new SpeechSynthesisUtterance(String(text || ''));
|
||||||
utterance.lang = 'zh-CN';
|
utterance.lang = 'zh-CN';
|
||||||
utterance.pitch = Number(pitch) || 1;
|
utterance.pitch = Number(pitch) || 1;
|
||||||
utterance.rate = 0.95;
|
utterance.rate = 0.95;
|
||||||
window.speechSynthesis.speak(utterance);
|
timer = setTimeout(function () { finish(false, 'start-timeout'); }, SPEECH_PROBE_TIMEOUT_MS);
|
||||||
} catch (e) { /* 不支持时静默 */ }
|
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) {
|
||||||
|
const t = String(text || '').trim();
|
||||||
|
if (!t) return;
|
||||||
|
if (speechEngineMode === 'cloud' && playCloudAtoms(t)) return;
|
||||||
|
speakSystem(t, pitch).then(function (r) {
|
||||||
|
if (r.ok) return;
|
||||||
|
// 系统 TTS 本次失败:有云端音频就切过去,否则显式预警
|
||||||
|
if (playCloudAtoms(t)) {
|
||||||
|
if (speechEngineMode !== 'cloud') {
|
||||||
|
speechEngineMode = 'cloud';
|
||||||
|
showToast('已自动切换到云端语音播报', 'info', 2000);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markVoiceUnavailable('系统语音与云端语音均不可用,请检查系统语音引擎或音量设置。');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 在用户手势中调用,解锁 iOS Safari 的语音合成限制 */
|
/** 在用户手势中调用,解锁 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
@@ -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) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user