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

- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作
- 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数
- 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放
- 新增语音引擎探测与异常预警UI,覆盖web端与移动端
- 优化web端订单盒页面布局,修复卡片操作按钮换行问题
- 新增调试探针脚本、ESLint配置与相关lint脚本
- 完善后端接口与路由,更新项目依赖与配置文件
This commit is contained in:
yangxiangyuan
2026-09-17 16:26:27 +08:00
parent d4bf3627fd
commit bbc7ca6523
29 changed files with 3228 additions and 163 deletions
+43 -1
View File
@@ -244,6 +244,45 @@ const bindRoutes = app => {
}
})
app.get(`${API_PREFIX}/trash`, (req, res) => {
try {
return res.json({ ok: true, data: store.listTrash() })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put(`${API_PREFIX}/trash/restore`, (req, res) => {
try {
const id = String((req.body && req.body.id) || req.query.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const ok = store.restoreItem(id)
if (!ok) return res.status(404).json({ ok: false, error: '回收站中不存在该收藏' })
return res.json({ ok: true })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete(`${API_PREFIX}/trash`, (req, res) => {
try {
const id = String((req.body && req.body.id) || req.query.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const r = store.purgeItem(id)
if (!r || !r.ok) return res.status(404).json({ ok: false, error: r && r.error || 'not found' })
return res.json({
ok: true,
data: {
filesRemoved: r.filesRemoved,
bytesRemoved: r.bytesRemoved,
attachments: r.attachments,
},
})
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post(`${API_PREFIX}/upload`, upload.single('file'), (req, res) => {
try {
const f = req.file
@@ -253,7 +292,10 @@ const bindRoutes = app => {
const relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
const urlPath = '/' + relPath
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 saved = store.insertAttachment({
id: createId(),
+57
View File
@@ -1,5 +1,6 @@
const Database = require('better-sqlite3')
const path = require('path')
const fs = require('fs')
const iconv = require('iconv-lite')
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)
}
// 回收站:列出软删除的收藏(含附件),按删除时间倒序
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 createdAt = nowIso()
const payload = {
@@ -324,6 +378,9 @@ module.exports = {
insertItem,
updateItem,
deleteItem,
listTrash,
restoreItem,
purgeItem,
insertAttachment,
getAttachmentById,
getAttachmentsByFavoriteId,