feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能

- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API
- 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据
- 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件
- 补充项目配置、站点地图、调试诊断脚本等配套文件
- 完善各模块的页面配置与样式文件
This commit is contained in:
yangxiangyuan
2026-09-02 22:20:37 +08:00
parent 132d132473
commit db7648eb4d
75 changed files with 8618 additions and 70 deletions
@@ -0,0 +1,31 @@
// 调试:确认 2026-04-11 批量插入的 47 条明细,以及是否属于"多肉养护-*"种子
const path = require('path');
const Database = require('better-sqlite3');
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db');
const db = new Database(DB_PATH);
const rows = db.prepare(`SELECT id, substr(id,1,20) AS id_pre, title, substr(start,1,16) AS start, substr(createdAt,1,16) AS created, deleted, completed, remindAt
FROM events WHERE substr(createdAt,1,10) = '2026-04-11' ORDER BY title, start`).all();
console.log(`2026-04-11 写入总数=${rows.length}`);
const byTitle = {};
rows.forEach(r => {
byTitle[r.title] = (byTitle[r.title] || 0) + 1;
});
console.log('\n按 title 聚合:');
Object.entries(byTitle).sort((a,b)=>b[1]-a[1]).forEach(([t,c]) => console.log(` x${c} ${t}`));
// 标题含"多肉养护-"的数量
const succu = rows.filter(r => r.title && r.title.includes('多肉养护-'));
console.log(`\ntitle 含 "多肉养护-" 的数量=${succu.length}`);
succu.slice(0,10).forEach(r => console.log(` ${r.title} | start=${r.start} | id=${r.id_pre} | del=${r.deleted}`));
// 看是否有重复种子(比如未来重复批次)
const dup = db.prepare(`SELECT title, COUNT(1) c FROM events
WHERE title LIKE '多肉养护-%'
GROUP BY title HAVING c > 1
ORDER BY c DESC`).all();
console.log('\n"多肉养护-*" 重复 title 汇总:');
dup.forEach(r => console.log(` x${r.c} ${r.title}`));
db.close();
@@ -0,0 +1,72 @@
// 调试:查询 calendar_reminder 数据库中"多肉"相关的批量插入记录统计
const path = require('path');
const fs = require('fs');
const Database = require('better-sqlite3');
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db');
console.log('DB exists:', fs.existsSync(DB_PATH), DB_PATH);
if (!fs.existsSync(DB_PATH)) { process.exit(1); }
const db = new Database(DB_PATH, { readonly: true });
db.pragma('journal_mode = WAL');
console.log('\n=== 表结构 ===');
db.prepare("PRAGMA table_info(events)").all().forEach(c => {
console.log(` ${c.name} (${c.type})`);
});
const total = db.prepare('SELECT COUNT(1) AS c FROM events').get().c;
console.log(`\n总记录数 events: ${total}`);
const byDel = db.prepare('SELECT deleted, COUNT(1) AS c FROM events GROUP BY deleted').all();
console.log('\n按 deleted 分组:');
byDel.forEach(r => console.log(` deleted=${r.deleted} -> ${r.c}`));
// 查询"多肉"相关(title / content 包含"多肉")
const kw = '%多肉%';
const succuAll = db.prepare("SELECT COUNT(1) AS c FROM events WHERE title LIKE ? OR content LIKE ?").get(kw, kw).c;
console.log(`\n含「多肉」的事件(title/content): ${succuAll}`);
if (succuAll > 0) {
const rows = db.prepare("SELECT id, title, substr(content,1,80) AS content, start, end, remindAt, recurrence, createdAt, updatedAt, deleted, completed FROM events WHERE title LIKE ? OR content LIKE ? ORDER BY createdAt DESC LIMIT 50").all(kw, kw);
console.log(`\n多肉记录抽样(最近 50 条):`);
rows.forEach(r => {
const tag = [
r.deleted ? '🗑删' : null,
r.completed ? '✅完' : null,
r.recurrence ? '🔁' + r.recurrence : null,
r.remindAt ? '⏰' : null
].filter(Boolean).join('');
console.log(` [${r.createdAt ? r.createdAt.slice(0,16) : '?'}] ${r.id.slice(0,8)} | ${r.title || '(无题)'} ${tag} | start=${r.start ? r.start.slice(0,16) : '?'} | content=${r.content || ''}`);
});
// 按 title 分组统计(看批量插入的规律)
const byTitle = db.prepare("SELECT title, COUNT(1) AS c, MIN(start) AS firstStart, MAX(start) AS lastStart FROM events WHERE title LIKE ? OR content LIKE ? GROUP BY title ORDER BY c DESC LIMIT 30").all(kw, kw);
console.log('\n按 title 聚合(多肉相关)TOP30:');
byTitle.forEach(r => console.log(` x${String(r.c).padStart(4,' ')} | ${r.firstStart ? r.firstStart.slice(0,10) : ''}~${r.lastStart ? r.lastStart.slice(0,10) : ''} | ${r.title || '(空)'}`));
// 按创建日期分组,看是否有批量插入特征
const byDay = db.prepare(`SELECT substr(createdAt,1,10) AS d, COUNT(1) AS c, COUNT(DISTINCT title) AS titles
FROM events WHERE title LIKE ? OR content LIKE ?
GROUP BY substr(createdAt,1,10) ORDER BY d DESC LIMIT 30`).all(kw, kw);
console.log('\n按 createdAt 日期聚合(多肉相关)TOP30:');
byDay.forEach(r => console.log(` ${r.d} x${String(r.c).padStart(4,' ')} distinct_titles=${r.titles}`));
// 按 recurrence 分组
const byRecur = db.prepare("SELECT recurrence, COUNT(1) AS c FROM events WHERE (title LIKE ? OR content LIKE ?) GROUP BY recurrence ORDER BY c DESC").all(kw, kw);
console.log('\n按 recurrence 分组:');
byRecur.forEach(r => console.log(` [${r.recurrence || '(无)'}] x${r.c}`));
// 删除 / 未删除 / 已完成比例
const stat = db.prepare(`SELECT
COUNT(1) AS total,
SUM(CASE WHEN deleted=1 THEN 1 ELSE 0 END) AS del,
SUM(CASE WHEN deleted=0 THEN 1 ELSE 0 END) AS alive,
SUM(CASE WHEN deleted=0 AND completed=1 THEN 1 ELSE 0 END) AS aliveDone,
SUM(CASE WHEN deleted=0 AND completed=0 THEN 1 ELSE 0 END) AS aliveTodo
FROM events WHERE title LIKE ? OR content LIKE ?`).get(kw, kw);
console.log('\n多肉记录状态:');
console.log(` 总数=${stat.total} 删除=${stat.del} 存活=${stat.alive} 存活中已完成=${stat.aliveDone} 存活中待办=${stat.aliveTodo}`);
}
db.close();
@@ -0,0 +1,64 @@
// 快速诊断:batch_sql_check 对含 xxx_col 的 INSERT 返回什么
// 用法:node 临时脚本放在项目根,直接 require
const fs = require('fs')
const path = require('path')
const os = require('os')
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cal_diag_'))
const BACKUP_DIR = path.join(process.cwd(), 'data', 'calendar_reminder_backups')
const latest = fs.readdirSync(BACKUP_DIR).filter(f => f.startsWith('calendar_reminder_2') && f.endsWith('.db')).sort().slice(-1)[0]
const SRC = path.join(BACKUP_DIR, latest)
const DST_DIR = path.join(process.cwd(), 'data', 'calendar_reminder_backups', '_diag')
fs.mkdirSync(DST_DIR, { recursive: true })
for (const suf of ['.db', '.db-wal', '.db-shm']) {
fs.copyFileSync(SRC.replace(/\.db$/,'') + suf, path.join(DST_DIR, 'calendar_reminder' + suf))
}
const modSrc = fs.readFileSync(path.join(process.cwd(), 'src','server','calendar_reminder.js'), 'utf8')
const NEEDLE = "const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db')"
if (!modSrc.includes(NEEDLE)) { throw new Error('NEEDLE NOT FOUND') }
const modTmp = path.join(process.cwd(), 'dev_test_scripts','unit','_calendar_reminder_diag.js')
fs.writeFileSync(modTmp, modSrc.replace(NEEDLE,
'const DB_PATH = path.join(process.cwd(), "data/calendar_reminder_backups/_diag", "calendar_reminder.db")'))
const express = require('express')
const { createHash } = require('crypto')
const cookie = require('cookie')
const app = express()
app.use((req, res, next) => {
req.headers.cookie = (req.headers.cookie || '') + '; calendar_reminder_gate=1'
next()
})
const mod = require(modTmp)
mod.bindRoutes(app)
const PORT = 18795
const server = app.listen(PORT, '127.0.0.1', async () => {
try {
const badCol = `INSERT INTO events (title,xxx_col,start) VALUES ('t',1,'2026-09-02 09:00');`
const http = require('http')
const r = await new Promise((resolve, reject) => {
const body = JSON.stringify({ sql_text: badCol })
const req = http.request({ hostname: '127.0.0.1', port: PORT, path: '/api/calendar_reminder/batch_sql_check', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, res => {
let buf = ''; res.on('data', d => buf += d); res.on('end', () => resolve({ status: res.statusCode, body: buf }))
})
req.on('error', reject); req.write(body); req.end()
})
console.log('STATUS', r.status)
console.log('BODY', r.body)
let j
try { j = JSON.parse(r.body) } catch (e) { j = null }
if (j) {
console.log('col_errors length:', j.col_errors && j.col_errors.length)
console.log('col_errors:', JSON.stringify(j.col_errors, null, 2))
console.log('syntax_errors:', JSON.stringify(j.syntax_errors, null, 2))
console.log('forbidden:', JSON.stringify(j.forbidden, null, 2))
console.log('ok?', j.ok)
}
server.close(() => {
fs.rmSync(DST_DIR, { recursive: true, force: true })
fs.unlinkSync(modTmp)
})
} catch (e) {
console.error(e)
try { server.close() } catch {}
try { fs.rmSync(DST_DIR, { recursive: true, force: true }) } catch {}
try { fs.unlinkSync(modTmp) } catch {}
}
})
@@ -0,0 +1,46 @@
// 只读诊断:用户刚执行的 16 条家人生日 SQL 是否真的入库
// 用法:node dev_test_scripts/debug/diag_birthday_sql.js
const path = require('path')
const fs = require('fs')
const Database = require('better-sqlite3')
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db')
if (!fs.existsSync(DB_PATH)) { console.error('DB NOT FOUND:', DB_PATH); process.exit(2) }
const db = new Database(DB_PATH, { readonly: true, fileMustExist: true })
console.log('=== 1. 最近 5 个 calendar_batches(找 sql_ 前缀最新批次) ===')
const batches = db.prepare(`SELECT id,title,status,params_json,generated_count,inserted_count,dedup_count,rollback_count,created_at,updated_at
FROM calendar_batches ORDER BY rowid DESC LIMIT 5`).all()
for (const b of batches) {
const p = b.params_json ? JSON.parse(b.params_json) : null
console.log(` - [${b.id}] ${b.title} | status=${b.status} | gen=${b.generated_count} ins=${b.inserted_count} dedup=${b.dedup_count} rb=${b.rollback_count} | created=${b.created_at} | range=${p && p.rangeStart}~${p && p.rangeEnd} | source=${p && p.source}`)
}
console.log('\n=== 2. 搜索 16 条 id 前缀的事件(bd_fa_ / bd_mo_ / bd_si_ / bd_me_) ===')
const prefixes = ['bd_fa_', 'bd_mo_', 'bd_si_', 'bd_me_']
const like = prefixes.map(p => `id LIKE '${p}%'`).join(' OR ')
const rows = db.prepare(`SELECT id,title,start,end,allDay,remindAt,deleted,completed,batch_id FROM events
WHERE ${like} ORDER BY start ASC`).all()
console.log(` 命中 ${rows.length} 行(期望 16)`)
for (const r of rows) {
console.log(` - [${r.id.slice(0,32)}...] ${r.title} | ${r.start} ~ ${r.end} | allDay=${r.allDay} remindAt=${r.remindAt} | deleted=${r.deleted} done=${r.completed} | batch=${r.batch_id}`)
}
if (rows.length === 16) {
const byY = {}
for (const r of rows) { const y = r.start.slice(0,4); byY[y] = (byY[y]||0)+1 }
console.log(' 按年分布:', JSON.stringify(byY))
const missingRemind = rows.filter(r => !r.remindAt)
console.log(' 未设置提醒(期望 0):', missingRemind.length)
const notAllDay = rows.filter(r => r.allDay != 1)
console.log(' 非全天事件(期望 0):', notAllDay.length)
const deletedOnes = rows.filter(r => r.deleted != 0)
console.log(' 已逻辑删除(期望 0):', deletedOnes.length)
const batch_ids = [...new Set(rows.filter(r => r.batch_id).map(r => r.batch_id))]
console.log(' 关联 batch_id 集合(期望 1 个 sql_ 前缀):', batch_ids)
}
console.log('\n=== 3. 时间跨度 sanity check(期望 start∈[2027-06-15, 2030-12-01]) ===')
const span = db.prepare(`SELECT MIN(start) min_s, MAX(end) max_e FROM events WHERE ${like}`).get()
console.log(' min start =', span.min_s, ' | max end =', span.max_e)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,127 @@
/* eslint-disable no-console */
// Loopback HTTP 自测:SQL Manual Batch(INSERT-only 模式)
// 用临时副本 DB,不污染生产。跟之前的 batch_tool 自测同策略。
const path = require('path')
const fs = require('fs')
const os = require('os')
const crypto = require('crypto')
const http = require('http')
const express = require('express')
process.on('unhandledRejection', (e) => { console.error('[Unhandled]', e); process.exit(1) })
const ROOT = process.cwd()
const SRC = path.join(ROOT, 'data', 'calendar_reminder.db')
const DST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cal_sql_batch_'))
const DST = path.join(DST_DIR, 'calendar_reminder.db')
for (const suf of ['', '-wal', '-shm', '-journal']) {
const s = SRC + suf; const d = DST + suf
if (fs.existsSync(s)) fs.copyFileSync(s, d)
}
// patch calendar_reminder.js 的 DB_PATH 指向临时副本
const CAL_MOD = path.join(ROOT, 'src', 'server', 'calendar_reminder.js')
const orig = fs.readFileSync(CAL_MOD, 'utf8')
const NEEDLE = 'const DB_PATH = path.join(process.cwd(), \'data\', \'calendar_reminder.db\')'
if (!orig.includes(NEEDLE)) { console.error('Cannot patch DB_PATH'); process.exit(2) }
const patched = orig.replace(NEEDLE, `const DB_PATH = ${JSON.stringify(DST)}`)
const modTmp = path.join(ROOT, 'dev_test_scripts', 'unit', '_calendar_reminder_patched_sql.js')
fs.writeFileSync(modTmp, patched, 'utf8')
// 伪造一个 index.js 式的 checkAuth 中间件(calendar_reminder.bindRoutes 需要 req/res 中存在 checkAuth 守卫,但我们直接加载模块会在 bindRoutes 里挂路由到外面的 app)
// 实际上 calendar_reminder.js 的 checkAuth 是在它自己模块内部定义的——只要我们 require 它,就能拿到 bindRoutes
const { bindRoutes } = require(modTmp)
const app = express()
app.use(express.json({ limit: '10mb' }))
// 用 calendar_reminder 自己的 checkAuth:模块内部在 bindRoutes 里自己挂中间件。
// 模块内部的 checkAuth 需要 calendar_reminder_gate cookie。我们手动在 express 里先塞一个白名单中间件,当请求带 test-auth=1 header 就注入 cookie
app.use((req, res, next) => {
if (req.headers['x-test-auth']) req.headers.cookie = (req.headers.cookie || '') + '; calendar_reminder_gate=1'
next()
})
bindRoutes(app)
app.use((err, req, res, next) => { console.error('[ERR]', req.method, req.url, err.message); res.status(500).json({ ok: false, error: err.message }) })
const SERVER_PORT = 18791
const authH = { 'Content-Type': 'application/json', 'X-Test-Auth': '1' }
const HOST = 'http://127.0.0.1:' + SERVER_PORT
const results = []
const assert = (name, cond, extra) => {
results.push({ name, ok: !!cond, extra })
console.log(`[${cond ? 'PASS' : 'FAIL'}] ${name}${extra ? ' | ' + JSON.stringify(extra) : ''}`)
}
const req = (method, url, body) => new Promise((resolve, reject) => {
const u = new URL(url)
const opts = { method: method, hostname: u.hostname, port: u.port, path: u.pathname + u.search, headers: authH }
const r = http.request(opts, (res) => {
let d = ''; res.setEncoding('utf8'); res.on('data', c => d += c); res.on('end', () => {
let j = d; try { j = JSON.parse(d) } catch {}
resolve({ status: res.statusCode, body: j })
})
})
r.on('error', reject)
if (body !== undefined) r.write(JSON.stringify(body))
r.end()
})
const mkSql = (n = 10, titlePrefix = 'SQL自测洗被子 ' + Date.now()) => {
const lines = []
for (let i = 1; i <= n; i++) {
const mm = String(9).padStart(2, '0')
const dd = String(i).padStart(2, '0')
lines.push(`('${titlePrefix} (${mm}-${dd})','自测备注','2026-${mm}-${dd} 09:00','2026-${mm}-${dd} 10:00',0,'2026-${mm}-${dd} 08:30',0,0,NULL)`)
}
return `INSERT INTO events (title,content,start,end,allDay,remindAt,completed,deleted,batch_id) VALUES\n${lines.join(',\n')};`
}
;(async () => {
const server = app.listen(SERVER_PORT, '127.0.0.1', async () => {
try {
// (1) 先检查 calendar_batches 表存在(ensureDb 会建)
const { bindRoutes: _ } = require(modTmp)
// (2) SQL 检查:空 SQL → 400
const r0 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: '' })
assert('1. 空 SQL 检查 = 400', r0.status === 400, { s: r0.status })
// (3) SQL 检查:正确 10 条 INSERT → ok=true + total_rows=10
const sql10 = mkSql(10)
const r1 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: sql10, batch_name: 'SQL自测 10 条' })
assert('2. 10 条 INSERT 检查 ok=true + 10 行', r1.status === 200 && r1.body && r1.body.ok === true && Number(r1.body.total_rows) === 10 && Number(r1.body.insert_statements) === 1, { s: r1.status, ok: r1.body && r1.body.ok, rows: r1.body && r1.body.total_rows })
// (4) SQL 检查:含 UPDATE → forbidden 且 ok=false
const badSql = sql10 + ` UPDATE events SET title='bad' WHERE 1=1;`
const r2 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: badSql })
assert('3. 含 UPDATE 语句:检查 forbidden / ok=false', r2.status === 200 && r2.body.ok === false && Array.isArray(r2.body.forbidden) && r2.body.forbidden.length >= 1, { forb: r2.body && r2.body.forbidden && r2.body.forbidden.length, ok: r2.body && r2.body.ok })
// (5) SQL 检查:未知列 xxx_col 拒绝 → col_errors
const badCol = `INSERT INTO events (title,xxx_col,start) VALUES ('t',1,'2026-09-02 09:00');`
const r3 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: badCol })
assert('4. 含未知列 xxx_col:检查 col_errors', r3.status === 200 && r3.body.ok === false && Array.isArray(r3.body.col_errors) && r3.body.col_errors.length >= 1, { cols: r3.body && r3.body.col_errors && r3.body.col_errors.length })
// (5) SQL apply:10 条 INSERT → 生成 batch_id、inserted=10,可回滚 rolled_back == inserted(可能为 9 因为 title+start 严格等值有重复,所以 rolled_back >= 8 就算 pass)
const apply10 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_apply`, { sql_text: sql10, batch_name: 'SQL自测 10 条', skip_external_sync: true, confirm_duplicates: true, confirm_forbidden: true })
const applyOk = apply10.status === 200 && apply10.body.ok === true && Number(apply10.body.inserted) >= 9 && String(apply10.body.batch_id || '').startsWith('sql_')
assert('5. SQL 执行 10 条:HTTP 200 + inserted≥9 + batch_id 非空', applyOk, { s: apply10.status, b: apply10.body })
const bId = apply10.body.batch_id
// 回滚:逻辑删除(可能为 rolled_back >= 8,因为冷备份副本 DB 之前就可能含这些 events 的 dedup)
const rb = await req('POST', `${HOST}/api/calendar_reminder/batch/rollback`, { id: bId, hard: false })
const rbOk = rb.status === 200 && rb.body.ok === true && Number(rb.body.rolled_back) >= Math.max(0, Number(apply10.body.inserted) - 3)
assert('6. 批次回滚:rolled_back ≥ inserted-3(允许副本老数据的去重差异)', rbOk, { rb: rb.body, inserted: apply10.body.inserted })
// 结果总结
const pass = results.filter(r => r.ok).length
console.log(`\n--- TOTAL: ${pass}/${results.length} PASS ---`)
server.close(() => {
try {
fs.rmSync(DST_DIR, { recursive: true, force: true })
} catch {}
try { fs.unlinkSync(modTmp) } catch {}
process.exit(pass === results.length ? 0 : 1)
})
} catch (e) {
console.error('[RUN ERR]', e)
try { server.close() } catch {}
try { fs.rmSync(DST_DIR, { recursive: true, force: true }) } catch {}
try { fs.unlinkSync(modTmp) } catch {}
process.exit(1)
}
})
})()
@@ -0,0 +1,173 @@
const path = require('path');
process.chdir(path.resolve(__dirname, '..', '..'));
const fs = require('fs');
const testDir = path.join(__dirname, '..', 'debug', 'calendar_batch_test');
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
const ts = Date.now();
const testDB = path.join(testDir, `cal_test_${ts}.db`);
const testWal = testDB + '-wal';
const testShm = testDB + '-shm';
const srcDB = path.join(process.cwd(), 'data', 'calendar_reminder_backups', 'calendar_reminder_20260831_122130.db');
const srcWal = srcDB + '-wal';
const srcShm = srcDB + '-shm';
console.log('Copying source snapshot DB to:', testDB);
fs.copyFileSync(srcDB, testDB);
if (fs.existsSync(srcWal)) fs.copyFileSync(srcWal, testWal);
if (fs.existsSync(srcShm)) fs.copyFileSync(srcShm, testShm);
// 把 calendar_reminder.js 的 DB_PATH 指向我们的临时 DB 副本
// 做法:在 require calendar_reminder 前 hook better-sqlite3? 或者更简单:重写 require cache
// 更简单:用进程内 NODE_OPTIONS 不行,但 require.cache 可以改。
// 直接 patch module exports 之前先在 calendar_reminder 里用全局变量?
// 最快做法:写一个 wrapper,覆盖 `const DB_PATH` 那行 -> 重新写 tempDBPath 赋值到 module scope 不方便,改用:
// 把 `data/calendar_reminder.db` 临时 symlink/copy?但 Windows symlink 麻烦。
// 就用最简单:创建独立 require 上下文。直接把 calendar_reminder.js 的 src 读出来、替换 DB_PATH,然后 vm.runInNewContext 太麻烦。
// 更简便:我们用 require 的 module.__proto 拦截。
// 改文件替换(完成后改回):
const modFile = path.join(process.cwd(), 'src', 'server', 'calendar_reminder.js');
const orig = fs.readFileSync(modFile, 'utf8');
const modded = orig.replace(
/const DB_PATH = path\.join\(process\.cwd\(\), 'data', 'calendar_reminder\.db'\)/,
`const DB_PATH = process.env.CALENDAR_DB_OVERRIDE || path.join(process.cwd(), 'data', 'calendar_reminder.db')`
);
if (modded === orig) { console.error('ERR: replace 未生效,DB_PATH 模式变了'); process.exit(3); }
const backup = path.join(testDir, `calendar_reminder_${ts}.bak.js`);
fs.copyFileSync(modFile, backup);
fs.writeFileSync(modFile, modded, 'utf8');
console.log('patched calendar_reminder.js to env override (backup at', backup, ')');
process.env.CALENDAR_DB_OVERRIDE = testDB;
console.log('CALENDAR_DB_OVERRIDE =', process.env.CALENDAR_DB_OVERRIDE);
// 清 require cache
for (const k of Object.keys(require.cache)) {
if (k.includes('calendar_reminder') || k.includes('calendar_voice')) delete require.cache[k];
}
const express = require('express');
const Database = require('better-sqlite3');
const mod = require(modFile);
const db = new Database(testDB);
console.log('[自测] 确保 migrate 已执行:先 bindRoutes 一次');
const express_check_app = express();
mod.bindRoutes(express_check_app);
const cols = new Set(db.prepare("PRAGMA table_info(events)").all().map(r => r.name));
console.log('[自测] events 列(迁移后):', [...cols].join(','));
if (!cols.has('batch_id')) { console.error('ERR: 迁移后仍然缺 batch_id'); process.exit(5) }
const batchesExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calendar_batches'").get();
if (!batchesExists) { console.error('ERR: calendar_batches 未创建'); process.exit(6) }
const port = 8791;
const app = express();
app.use(express.json({ limit: '10mb' }));
app.use((req, res, next) => { req.headers.cookie = 'calendar_reminder_gate=1'; next(); });
mod.bindRoutes(app);
const params = {
batchName: '【自测】洗被子提醒 2026Q4-2027Q2',
mode: 'monthlyByDay',
rangeStart: '2026-09-01',
rangeEnd: '2027-06-30',
monthlyEvery: 2,
monthlyDays: '1-5',
startTime: '09:00',
endTime: '10:00',
remindTime: '08:30',
allDay: false,
completed: false,
title: '洗被子提醒({MM}-{dd})',
content: ''
};
const server = app.listen(port, async () => {
let exitCode = 0;
try {
const axios = require('axios');
const base = `http://127.0.0.1:${port}`;
const headers = { 'Content-Type': 'application/json', 'Cookie': 'calendar_reminder_gate=1' };
const ax = axios.create({ headers, validateStatus: () => true });
const r1 = await ax.post(`${base}/api/calendar_reminder/batch/preview`, params);
console.log('[自测] 1.preview status=', r1.status, 'generated=', r1.data.generated, 'summary=', r1.data.summary);
if (r1.status !== 200 || !r1.data.ok) { console.error('preview fail', r1.status, r1.data); throw new Error('preview') }
if (r1.data.generated !== 25) { console.error('expected 25, got', r1.data.generated); throw new Error('count') }
const batchId = r1.data.batchId;
console.log('[自测] batch_id =', batchId);
const r2 = await ax.post(`${base}/api/calendar_reminder/batch/apply`, { batch_id: batchId, params: r1.data.params_saved, sql_full: r1.data.sql_full, skip_external_sync: true });
console.log('[自测] 2.apply=', r2.status, r2.data);
if (r2.status !== 200 || !r2.data.ok) throw new Error('apply');
const counts = db.prepare("SELECT deleted, COUNT(1) c FROM events WHERE batch_id=? GROUP BY deleted").all(batchId);
console.log('[自测] 3.active counts=', counts);
const active = (counts.find(r => r.deleted === 0) || {}).c || 0;
if (active !== 25) throw new Error('active !=25, got ' + active);
const batchRow = db.prepare("SELECT id, status, inserted_count, dedup_count, rollback_count, pre_existing_count, skip_external_sync FROM calendar_batches WHERE id=?").get(batchId);
console.log('[自测] 4.batch row=', batchRow);
const rL = await ax.get(`${base}/api/calendar_reminder/batches?q=%E6%B4%97%E8%A2%AB%E5%AD%90`);
console.log('[自测] 5.batches list len=', rL.data.data && rL.data.data.length);
const rD = await ax.get(`${base}/api/calendar_reminder/batch/detail?id=${encodeURIComponent(batchId)}`);
console.log('[自测] 6.detail counts=', rD.data.batch && rD.data.batch.counts, 'diag ok?', !!rD.data.diagnostics && !!rD.data.diagnostics.by_batch_id);
const rE = await ax.get(`${base}/api/calendar_reminder/batch/export_sql?id=${encodeURIComponent(batchId)}`);
console.log('[自测] 7.export_sql len=', typeof rE.data === 'string' ? rE.data.length : (Buffer.isBuffer(rE.data) ? rE.data.length : '??'), 'headers?', rE.headers && (rE.headers['content-disposition'] || rE.headers['content-type']));
const rR = await ax.post(`${base}/api/calendar_reminder/batch/rollback`, { id: batchId, hard: false });
console.log('[自测] 8.rollback=', rR.status, rR.data);
const c2 = db.prepare("SELECT deleted, COUNT(1) c FROM events WHERE batch_id=? GROUP BY deleted").all(batchId);
console.log('[自测] 9.post-rollback counts=', c2);
const d = (c2.find(r => r.deleted === 1) || {}).c || 0;
if (d !== 25) throw new Error('deleted!=25, got ' + d);
const rRepeat = await ax.post(`${base}/api/calendar_reminder/batch/preview`, Object.assign({}, params, { batchName: '【自测-复制】洗被子' }));
console.log('[自测] 10.复制后的新批次 preview 预重(deleted=1 不命中去重)=', rRepeat.data.pre_existing_count);
if (rRepeat.data.pre_existing_count !== 0) throw new Error('pre_existing_count !=0');
// 再做 apply 回滚然后 rollback 再做第二次去重测试
const bid2 = rRepeat.data.batchId;
const rA2 = await ax.post(`${base}/api/calendar_reminder/batch/apply`, { batch_id: bid2, params: rRepeat.data.params_saved, sql_full: rRepeat.data.sql_full, skip_external_sync: true });
console.log('[自测] 11.复制 apply=', rA2.status, rA2.data);
const rDup = await ax.post(`${base}/api/calendar_reminder/batch/preview`, Object.assign({}, params));
console.log('[自测] 12.相同参数再预览(因为 bid2 刚 active=25,所以 pre_existing_count=25)=', rDup.data.pre_existing_count);
if (rDup.data.pre_existing_count !== 25) throw new Error('pre_existing_count should be 25');
// 清理两条批次元数据 + events,让临时 DB 恢复干净(虽然是临时的,但也清一下)
const del1 = db.prepare("DELETE FROM events WHERE batch_id IN (?,?)").run(batchId, bid2).changes;
const del2 = db.prepare("DELETE FROM calendar_batches WHERE id IN (?,?)").run(batchId, bid2).changes;
console.log('[自测] 13.cleanup del events=', del1, ', del batches=', del2);
// 再测 monthlyByNthWeekday:2026-09 到 2027-01 每月第 2 个周一,应得到 5 条
const wd = { batchName: '房租 demo', mode: 'monthlyByNthWeekday', rangeStart: '2026-09-01', rangeEnd: '2027-01-31', monthlyEvery2: 1, nthWeek: 2, nthWeekday: 1, title: '交房租提醒 {date}', content: '', startTime: '09:00' };
const rw = await ax.post(`${base}/api/calendar_reminder/batch/preview`, wd);
console.log('[自测] 14.monthlyByNthWeekday=第2个周一 9月-1月 生成=', rw.data.generated, (rw.data.sample_list||[]).map(x=>x.start));
if (rw.data.generated !== 5) throw new Error('monthlyByNthWeekday expected 5, got ' + rw.data.generated);
// weekly:每 2 周的周一、周三,2026-09-01~2026-10-15(每 2 周 2 条,6 周 * ?大概 6~7 条)
const wk = { batchName: '健身', mode: 'weekly', rangeStart: '2026-09-01', rangeEnd: '2026-10-15', weeklyEvery: 2, weekdays: [1,3], title: '健身 {date} 周{W}', content: '', startTime: '20:00', endTime: '22:00', remindTime: '19:30' };
const rwk = await ax.post(`${base}/api/calendar_reminder/batch/preview`, wk);
console.log('[自测] 15.weekly 每 2 周 Mon/Wed 生成=', rwk.data.generated, 'list=', (rwk.data.sample_list||[]).map(x=>x.start + ' ' + x.title));
// daily:每 3 天 2026-09-01~2026-09-20 => 7条 (1 4 7 10 13 16 19)
const dl = { batchName: '喝水', mode: 'daily', rangeStart: '2026-09-01', rangeEnd: '2026-09-20', dailyEvery: 3, title: '喝水打卡 {date}', startTime: '10:00' };
const rdl = await ax.post(`${base}/api/calendar_reminder/batch/preview`, dl);
console.log('[自测] 16.daily 每3天 9/1-20 生成=', rdl.data.generated, 'list=', (rdl.data.sample_list||[]).map(x=>x.start));
if (rdl.data.generated !== 7) throw new Error('daily expected 7, got ' + rdl.data.generated);
console.log('\n✅ 自测全部通过。退出。');
} catch (e) {
console.error('[自测] 异常:', e.message, e.stack);
exitCode = 1;
} finally {
// 恢复 calendar_reminder.js 源码
fs.copyFileSync(backup, modFile);
fs.unlinkSync(backup);
console.log('restored calendar_reminder.js 原文');
// 删临时 DB
try { fs.unlinkSync(testDB) } catch {}
try { fs.unlinkSync(testWal) } catch {}
try { fs.unlinkSync(testShm) } catch {}
server.close(() => process.exit(exitCode));
setTimeout(() => process.exit(exitCode), 2000).unref();
}
});
+145
View File
@@ -0,0 +1,145 @@
const app = () => getApp()
const BASE_URL = function () {
try {
const a = getApp()
if (a && a.globalData && a.globalData.apiBase) return a.globalData.apiBase
} catch (e) {}
return 'https://traesite.umersoft.com:8975'
}
const request = (options) => {
return new Promise((resolve, reject) => {
const { url, method = 'GET', data = {}, header = {}, timeout = 30000 } = options
const fullUrl = url.indexOf('http') === 0 ? url : BASE_URL() + url
wx.request({
url: fullUrl,
method,
data,
header: Object.assign({
'content-type': 'application/json',
}, header),
timeout,
success: (res) => {
const body = res.data || {}
if (res.statusCode >= 200 && res.statusCode < 300 && body && body.ok) {
resolve(body)
} else {
const msg = body && body.error ? body.error : `请求失败 (HTTP ${res.statusCode})`
reject(new Error(msg))
}
},
fail: (err) => {
reject(new Error(err && err.errMsg ? err.errMsg : '网络错误'))
},
})
})
}
const uploadFile = (options) => {
return new Promise((resolve, reject) => {
const { url, filePath, name = 'file', formData = {}, timeout = 120000 } = options
const fullUrl = url.indexOf('http') === 0 ? url : BASE_URL() + url
wx.uploadFile({
url: fullUrl,
filePath,
name,
formData,
timeout,
success: (res) => {
try {
const body = typeof res.data === 'string' ? JSON.parse(res.data) : (res.data || {})
if (res.statusCode >= 200 && res.statusCode < 300 && body && body.ok) {
resolve(body)
} else {
const msg = body && body.error ? body.error : `上传失败 (HTTP ${res.statusCode})`
reject(new Error(msg))
}
} catch (e) {
reject(new Error('上传响应解析失败'))
}
},
fail: (err) => {
reject(new Error(err && err.errMsg ? err.errMsg : '上传网络错误'))
},
})
})
}
const core = {
getMeta: () => request({ url: '/api/mp_time_assistant/meta', method: 'GET' }),
getState: () => request({ url: '/api/mp_time_assistant/state', method: 'GET' }),
setActiveKit: (kitId) => request({
url: '/api/mp_time_assistant/state/active-kit',
method: 'PUT',
data: { kitId },
}),
}
const personalCollect = {
list: (keyword = '') => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
method: 'GET',
data: { keyword: keyword || '' },
}),
get: (id) => request({
url: `/api/mp_time_assistant/kits/personal_collect/items/${encodeURIComponent(id)}`,
method: 'GET',
}),
create: (payload) => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
method: 'POST',
data: payload,
}),
update: (payload) => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
method: 'PUT',
data: payload,
}),
remove: (id) => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
method: 'DELETE',
data: { id },
}),
upload: (filePath, groupId = '') => uploadFile({
url: '/api/mp_time_assistant/kits/personal_collect/upload',
filePath,
name: 'file',
formData: groupId ? { groupId } : {},
}),
}
const chooseChatAttachments = (count = 9) => {
return new Promise((resolve, reject) => {
try {
if (!wx.chooseMessageFile) {
reject(new Error('当前微信版本不支持从聊天选择文件,请升级微信'))
return
}
wx.chooseMessageFile({
count: Math.max(1, Math.min(9, Number(count) || 9)),
type: 'all',
success: (res) => resolve(res.tempFiles || []),
fail: (err) => {
const msg = err && err.errMsg ? String(err.errMsg) : '用户取消或授权失败'
if (msg.indexOf('cancel') !== -1 || msg.indexOf('取消') !== -1) {
resolve([])
} else {
reject(new Error(msg))
}
},
})
} catch (e) {
reject(e)
}
})
}
module.exports = {
BASE_URL,
request,
uploadFile,
core,
personalCollect,
chooseChatAttachments,
}
+23
View File
@@ -0,0 +1,23 @@
App({
globalData: {
apiBase: 'https://traesite.umersoft.com:8975',
meta: null,
kits: [],
},
onLaunch() {
try {
const stored = wx.getStorageSync('mp_ta_meta')
if (stored) {
this.globalData.meta = stored.meta || null
this.globalData.kits = stored.kits || []
}
} catch (e) {}
},
setMeta(meta) {
this.globalData.meta = meta || null
this.globalData.kits = (meta && meta.kits) || []
try {
wx.setStorageSync('mp_ta_meta', { meta, kits: this.globalData.kits })
} catch (e) {}
},
})
+19
View File
@@ -0,0 +1,19 @@
{
"pages": [
"pages/hub/hub",
"kits/personal_collect/pages/list/list",
"kits/personal_collect/pages/edit/edit",
"kits/personal_collect/pages/detail/detail"
],
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTitleText": "MilesYang时光助手",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a"
},
"style": "v2",
"sitemapLocation": "sitemap.json",
"__comment_requiredPrivateInfos_chooseMessageFile": "chooseMessageFile 不再需要在 requiredPrivateInfos 中静态声明,调用时微信自动弹窗授权即可。",
"lazyCodeLoading": "requiredComponents"
}
+328
View File
@@ -0,0 +1,328 @@
page {
background: #0f0f1a;
color: #e8e8f0;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 28rpx;
line-height: 1.6;
}
.container {
padding: 32rpx;
min-height: 100vh;
box-sizing: border-box;
}
.page-header {
padding: 80rpx 0 48rpx 0;
}
.page-title {
font-size: 48rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 12rpx;
}
.page-subtitle {
font-size: 26rpx;
color: #8888a8;
line-height: 1.6;
}
.card {
background: #1a1a2e;
border: 1rpx solid #2a2a44;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-sizing: border-box;
}
.kit-card {
display: flex;
flex-direction: row;
align-items: center;
background: #1a1a2e;
border: 1rpx solid #2a2a44;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-sizing: border-box;
}
.kit-card:active {
opacity: 0.75;
}
.kit-icon {
width: 96rpx;
height: 96rpx;
border-radius: 48rpx;
background: rgba(108, 122, 224, 0.15);
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
}
.kit-icon-text {
font-size: 40rpx;
}
.kit-body {
flex: 1;
min-width: 0;
}
.kit-title {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 8rpx;
}
.kit-desc {
font-size: 24rpx;
color: #8888a8;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.chevron {
color: #555570;
font-size: 36rpx;
margin-left: 16rpx;
flex-shrink: 0;
}
.btn {
display: inline-block;
padding: 20rpx 48rpx;
border-radius: 16rpx;
font-size: 28rpx;
font-weight: 500;
text-align: center;
box-sizing: border-box;
line-height: 1.4;
}
.btn-primary {
background: #6c7ae0;
color: #ffffff;
}
.btn-primary:active {
opacity: 0.85;
}
.btn-ghost {
background: transparent;
border: 1rpx solid #3a3a5a;
color: #b8b8d0;
}
.btn-ghost:active {
background: rgba(108, 122, 224, 0.08);
}
.btn-danger {
background: #e05561;
color: #ffffff;
}
.btn-danger:active {
opacity: 0.85;
}
.btn-block {
display: block;
width: 100%;
}
.input-group {
margin-bottom: 32rpx;
}
.input-label {
font-size: 26rpx;
color: #8888a8;
margin-bottom: 16rpx;
display: block;
}
.input,
.textarea {
width: 100%;
background: #12121f;
border: 1rpx solid #2a2a44;
border-radius: 16rpx;
padding: 24rpx;
color: #ffffff !important;
font-size: 28rpx;
box-sizing: border-box;
line-height: 1.6;
}
.textarea {
min-height: 240rpx;
}
.tag {
display: inline-block;
padding: 8rpx 20rpx;
border-radius: 20rpx;
background: rgba(108, 122, 224, 0.12);
color: #8fa0ee;
font-size: 22rpx;
margin-right: 12rpx;
margin-bottom: 12rpx;
}
.empty {
padding: 120rpx 32rpx;
text-align: center;
color: #555570;
font-size: 28rpx;
}
.search-box {
display: flex;
flex-direction: row;
align-items: center;
background: #12121f;
border: 1rpx solid #2a2a44;
border-radius: 16rpx;
padding: 0 24rpx;
margin-bottom: 32rpx;
box-sizing: border-box;
}
.search-input {
flex: 1;
padding: 24rpx 0;
color: #ffffff !important;
font-size: 28rpx;
}
.search-icon {
color: #555570;
font-size: 32rpx;
margin-right: 16rpx;
}
.row {
display: flex;
flex-direction: row;
align-items: center;
box-sizing: border-box;
}
.row-between {
justify-content: space-between;
}
.spacer {
flex: 1;
}
.divider {
height: 1rpx;
background: #2a2a44;
margin: 24rpx 0;
}
.muted {
color: #8888a8;
}
.small {
font-size: 24rpx;
}
.item-actions {
display: flex;
flex-direction: row;
gap: 16rpx;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #23233a;
}
.mini-btn {
padding: 12rpx 24rpx;
border-radius: 12rpx;
font-size: 24rpx;
line-height: 1.3;
}
.attachments-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 16rpx;
}
.attachment-thumb {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
background: #12121f;
border: 1rpx solid #2a2a44;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.attachment-thumb image {
width: 100%;
height: 100%;
}
.attachment-icon {
font-size: 48rpx;
color: #8888a8;
}
.attachment-name {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8rpx 12rpx;
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
font-size: 18rpx;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fab {
position: fixed;
right: 40rpx;
bottom: 60rpx;
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
background: #6c7ae0;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
font-weight: 300;
box-shadow: 0 8rpx 32rpx rgba(108, 122, 224, 0.35);
z-index: 99;
}
.fab:active {
transform: scale(0.95);
}
@@ -0,0 +1,165 @@
const api = require('../../../../api.js')
const formatDate = iso => {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
const pad = n => n < 10 ? '0' + n : '' + n
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
Page({
data: {
loading: true,
id: '',
item: null,
createdAtText: '',
updatedAtText: '',
error: '',
imageUrls: [],
hasNonImageAttachment: false,
},
onLoad(options) {
const id = options && options.id ? String(options.id) : ''
if (!id) {
this.setData({ loading: false, error: '缺少 ID 参数' })
return
}
this.setData({ id })
this.loadItem(id)
},
onShow() {
if (this.data.id && !this.data.loading) {
this.loadItem(this.data.id)
}
},
async loadItem(id) {
this.setData({ loading: true, error: '' })
try {
const res = await api.personalCollect.get(id)
const item = res.data || null
const images = (item && item.attachments || []).filter(a => a && a.isImage && a.filePath)
const imageUrls = images.map(a => `https://traesite.umersoft.com:8975/${a.filePath}`)
const attachments = (item && item.attachments || []).map(a => ({
...a,
url: a.filePath ? `https://traesite.umersoft.com:8975/${a.filePath}` : '',
}))
const hasNonImage = (item.attachments || []).some(a => !a.isImage)
this.setData({
item: Object.assign({}, item, { attachments }),
createdAtText: formatDate(item.createdAt || ''),
updatedAtText: formatDate(item.updatedAt || ''),
imageUrls,
hasNonImageAttachment: !!hasNonImage,
})
} catch (e) {
this.setData({ error: e && e.message ? e.message : '加载失败' })
} finally {
this.setData({ loading: false })
}
},
onPreviewImage(e) {
const url = e.currentTarget.dataset.url
if (!url || !this.data.imageUrls.length) return
wx.previewImage({
current: url,
urls: this.data.imageUrls,
})
},
onOpenFile(e) {
const url = e.currentTarget.dataset.url
const name = e.currentTarget.dataset.name || 'file'
if (!url) return
wx.showLoading({ title: '打开中...', mask: true })
wx.downloadFile({
url,
timeout: 120000,
success: (res) => {
wx.hideLoading()
if (res.statusCode >= 200 && res.statusCode < 300 && res.tempFilePath) {
wx.openDocument({
filePath: res.tempFilePath,
showMenu: true,
fileType: this.detectFileType(name),
success: () => {},
fail: (err) => {
wx.showModal({
title: '打开失败',
content: err && err.errMsg ? err.errMsg : '无法预览此类型文件',
showCancel: false,
})
},
})
} else {
wx.showModal({
title: '下载失败',
content: `HTTP ${res.statusCode}`,
showCancel: false,
})
}
},
fail: (err) => {
wx.hideLoading()
wx.showModal({
title: '下载失败',
content: err && err.errMsg ? err.errMsg : '网络错误',
showCancel: false,
})
},
})
},
detectFileType(name) {
const ext = (name || '').split('.').pop().toLowerCase()
const map = {
pdf: 'pdf',
doc: 'doc', docx: 'doc',
xls: 'xls', xlsx: 'xls',
ppt: 'ppt', pptx: 'ppt',
}
return map[ext] || ''
},
onEditTap() {
if (!this.data.id) return
wx.navigateTo({
url: `/kits/personal_collect/pages/edit/edit?id=${encodeURIComponent(this.data.id)}`,
})
},
onDeleteTap() {
const title = this.data.item && this.data.item.title
? this.data.item.title
: (this.data.item && this.data.item.remark && this.data.item.remark.slice(0, 10)) || '此收藏'
wx.showModal({
title: '确认删除',
content: `确定删除「${title}」吗?删除后不再显示。`,
confirmText: '删除',
confirmColor: '#e05561',
success: async (res) => {
if (!res.confirm) return
try {
wx.showLoading({ title: '删除中...', mask: true })
await api.personalCollect.remove(this.data.id)
wx.hideLoading()
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => {
wx.navigateBack()
}, 800)
} catch (err) {
wx.hideLoading()
wx.showModal({
title: '删除失败',
content: err && err.message ? err.message : '请重试',
showCancel: false,
})
}
},
})
},
})
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "收藏详情",
"backgroundTextStyle": "light"
}
@@ -0,0 +1,69 @@
<view wx:if="{{loading}}" class="container">
<view class="empty">加载中...</view>
</view>
<view wx:elif="{{error}}" class="container">
<view class="card" style="border-color:#55333a;">
<text style="color:#e05561;">⚠️ {{error}}</text>
</view>
</view>
<view wx:elif="{{!item}}" class="container">
<view class="empty">未找到该收藏</view>
</view>
<view wx:else class="container" style="padding-bottom: 200rpx;">
<view class="card">
<view wx:if="{{item.title}}" style="font-size: 40rpx; font-weight:600; color:#ffffff; line-height:1.4;">{{item.title}}</view>
<view class="divider"></view>
<view wx:if="{{item.remark}}" style="white-space: pre-wrap; font-size: 28rpx; color:#e8e8f0; line-height:1.8;">{{item.remark}}</view>
<view wx:if="{{item.tags && item.tags.length}}" style="margin-top: 32rpx;">
<block wx:for="{{item.tags}}" wx:for-item="tag" wx:key="*this">
<view class="tag">#{{tag}}</view>
</block>
</view>
<view class="divider"></view>
<view class="row" style="margin-bottom: 12rpx;">
<text class="muted small" style="width: 180rpx;">创建时间</text>
<text class="small">{{createdAtText || '-'}}</text>
</view>
<view class="row">
<text class="muted small" style="width: 180rpx;">更新时间</text>
<text class="small">{{updatedAtText || '-'}}</text>
</view>
</view>
<view wx:if="{{item.attachments && item.attachments.length}}" class="card">
<view style="font-size: 30rpx; font-weight:600; color:#ffffff; margin-bottom: 24rpx;">附件 ({{item.attachments.length}})</view>
<view class="attachments-grid">
<block wx:for="{{item.attachments}}" wx:for-item="att" wx:key="id">
<view class="attachment-thumb">
<block wx:if="{{att.isImage}}">
<image
src="{{att.url}}"
mode="aspectFill"
data-url="{{att.url}}"
bindtap="onPreviewImage"
/>
</block>
<block wx:else>
<text class="attachment-icon" data-url="{{att.url}}" data-name="{{att.fileName}}" catchtap="onOpenFile" style="padding: 20rpx;">📄</text>
</block>
<view class="attachment-name" data-url="{{att.url}}" data-name="{{att.fileName}}" catchtap="{{att.isImage ? 'onPreviewImage' : 'onOpenFile'}}">{{att.fileName}}</view>
</view>
</block>
</view>
<view wx:if="{{hasNonImageAttachment}}" class="muted small" style="margin-top: 24rpx;">
💡 非图片附件点击名称即可下载并使用微信自带文档查看器打开。
</view>
</view>
</view>
<view wx:if="{{item}}" style="position:fixed; left:0; right:0; bottom:0; padding:24rpx 32rpx; background:#0f0f1a; border-top:1rpx solid #2a2a44; box-sizing:border-box; display:flex; gap:20rpx; z-index:99;">
<view class="btn btn-ghost" style="flex:1;" bindtap="onDeleteTap">删除</view>
<view class="btn btn-primary" style="flex:1;" bindtap="onEditTap">编辑</view>
</view>
@@ -0,0 +1 @@
/* 详情页独立样式,大部分复用 app.wxss 全局样式 */
@@ -0,0 +1,345 @@
const api = require('../../../../api.js')
Page({
data: {
isEdit: false,
loading: true,
saving: false,
id: '',
title: '',
remark: '',
tagsText: '',
attachments: [],
uploadGroupId: '',
error: '',
},
onLoad(options) {
const id = options && options.id ? String(options.id) : ''
this.setData({ isEdit: !!id, id })
this.generateUploadGroupId()
if (id) {
this.loadItem(id)
} else {
this.applyClipPrefill()
this.setData({ loading: false })
}
},
applyClipPrefill() {
try {
const text = wx.getStorageSync('mp_ta_clip_text')
if (!text || typeof text !== 'string') return
const mode = wx.getStorageSync('mp_ta_clip_mode') || 'new'
try { wx.removeStorageSync('mp_ta_clip_text') } catch (e) {}
try { wx.removeStorageSync('mp_ta_clip_mode') } catch (e) {}
const title = ((String(text || '').split('\n').map(s => s.trim()).find(s => s.length) || '').slice(0, 28)) || '文字收藏'
this.setData({
title: title,
remark: text,
})
wx.showToast({
title: mode === 'append' ? '已追加剪贴板文字' : '已预填剪贴板文字',
icon: 'none',
duration: 1600,
})
} catch (e) {}
},
generateUploadGroupId() {
const ts = Date.now().toString(36)
const rnd = Math.random().toString(36).slice(2, 10)
this.setData({ uploadGroupId: `${ts}_${rnd}` })
},
async loadItem(id) {
this.setData({ loading: true, error: '' })
try {
const res = await api.personalCollect.get(id)
const item = res.data || {}
const tags = Array.isArray(item.tags) ? item.tags : []
const attachments = (item.attachments || []).map(a => ({
...a,
url: a.filePath ? `https://traesite.umersoft.com:8975/${a.filePath}` : '',
uploaded: true,
uploading: false,
}))
this.setData({
title: item.title || '',
remark: item.remark || '',
tagsText: tags.join(', '),
attachments,
})
} catch (e) {
this.setData({ error: e && e.message ? e.message : '加载失败' })
} finally {
this.setData({ loading: false })
}
},
onTitleInput(e) {
this.setData({ title: String(e.detail.value || '') })
},
onRemarkInput(e) {
this.setData({ remark: String(e.detail.value || '') })
},
onTagsInput(e) {
this.setData({ tagsText: String(e.detail.value || '') })
},
async onChooseAttachments() {
try {
const files = await api.chooseChatAttachments(9 - this.data.attachments.length)
if (!files || !files.length) return
for (const f of files) {
await this.uploadOne(f)
}
} catch (e) {
const msg = e && e.message ? String(e.message) : ''
if (msg.indexOf('cancel') !== -1 || msg.indexOf('取消') !== -1) return
if (msg.indexOf('auth') !== -1 || msg.indexOf('deny') !== -1 || msg.indexOf('权限') !== -1 || msg.indexOf('授权') !== -1) {
wx.showModal({
title: '需要聊天文件权限',
content: '请点击右上角「···」→ 设置,打开「聊天记录」权限后重试。',
showCancel: false,
})
return
}
wx.showModal({
title: '选择失败',
content: msg || '请重试',
showCancel: false,
})
}
},
async uploadOne(tempFile) {
const tempId = `tmp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const isImage = tempFile.type === 'image' || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(tempFile.name || '')
const pending = {
id: tempId,
fileName: tempFile.name || '未命名',
filePath: '',
fileSize: Number(tempFile.size || 0),
isImage,
url: tempFile.path || tempFile.tempFilePath || '',
uploaded: false,
uploading: true,
error: '',
}
const list = this.data.attachments.concat([pending])
this.setData({ attachments: list })
const idx = list.length - 1
try {
const res = await api.personalCollect.upload(tempFile.path || tempFile.tempFilePath, this.data.uploadGroupId)
const data = (res && res.data) || {}
const attachment = {
id: data.id || tempId,
fileName: data.fileName || tempFile.name || '未命名',
storedName: data.storedName || '',
filePath: data.filePath || '',
fileSize: Number(data.fileSize || tempFile.size || 0),
mimeType: data.mimeType || '',
isImage: !!data.isImage || isImage,
url: data.url || (data.filePath ? `https://traesite.umersoft.com:8975/${data.filePath}` : '') || '',
groupId: data.groupId || '',
uploaded: true,
uploading: false,
error: '',
}
const next = this.data.attachments.slice()
next[idx] = attachment
this.setData({ attachments: next })
this.autoFillFromAttachments(false)
} catch (e) {
const next = this.data.attachments.slice()
next[idx] = Object.assign({}, next[idx], {
uploading: false,
error: e && e.message ? e.message : '上传失败',
})
this.setData({ attachments: next })
}
},
autoFillFromAttachments(force) {
if (this.data.isEdit && !force) return
const atts = this.data.attachments.filter(a => a && a.uploaded)
if (!atts.length) return
const hasTitle = !!(this.data.title || '').trim()
const hasRemark = !!(this.data.remark || '').trim()
const { title, remark } = this.buildAutoText(atts)
const patch = {}
if (!hasTitle || force) patch.title = title
if (!hasRemark || force) patch.remark = remark
if (Object.keys(patch).length) this.setData(patch)
},
buildAutoText(atts) {
const images = atts.filter(a => a.isImage)
const files = atts.filter(a => !a.isImage)
const imageCount = images.length
const fileCount = files.length
const total = atts.length
const pad = n => n < 10 ? '0' + n : '' + n
const now = new Date()
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
const humanSize = bytes => {
const n = Number(bytes || 0)
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / 1024 / 1024).toFixed(2) + ' MB'
}
let title = ''
if (total === 1) {
const only = atts[0]
const rawName = only.fileName || '附件'
const ext = (rawName.split('.').pop() || '').toLowerCase()
const stem = rawName.lastIndexOf('.') > 0 ? rawName.slice(0, rawName.lastIndexOf('.')) : rawName
if (only.isImage) {
title = `图片 - ${ts}`
} else {
title = stem || rawName
}
} else if (imageCount > 0 && fileCount === 0) {
title = `${imageCount} 张图片 - ${ts}`
} else if (fileCount > 0 && imageCount === 0) {
if (fileCount === 1) {
const only = files[0]
const rawName = only.fileName || '附件'
const stem = rawName.lastIndexOf('.') > 0 ? rawName.slice(0, rawName.lastIndexOf('.')) : rawName
title = stem || rawName
} else {
title = `${fileCount} 个文件 - ${ts}`
}
} else {
title = `${imageCount} 图 + ${fileCount} 文件 - ${ts}`
}
const lines = []
lines.push(`附件数量:${total}`)
if (imageCount) lines.push(`图片:${imageCount} 张`)
if (fileCount) lines.push(`文件:${fileCount} 个`)
lines.push('')
lines.push('附件清单:')
atts.slice(0, 9).forEach((a, i) => {
lines.push(` ${i + 1}. ${a.fileName || '未命名'}(${humanSize(a.fileSize)})`)
})
if (atts.length > 9) lines.push(` ... 另有 ${atts.length - 9} 个附件未列出`)
lines.push('')
lines.push(`整理时间:${ts}`)
const remark = lines.join('\n')
return { title, remark }
},
onPreviewImage(e) {
const url = e.currentTarget.dataset.url
const urls = this.data.attachments.filter(a => a && a.isImage && a.url).map(a => a.url)
if (!url || !urls.length) return
wx.previewImage({
current: url,
urls,
})
},
onRemoveAttachment(e) {
const id = e.currentTarget.dataset.id
const idx = this.data.attachments.findIndex(a => a.id === id)
if (idx < 0) return
const next = this.data.attachments.slice()
next.splice(idx, 1)
this.setData({ attachments: next })
},
onRetryUpload(e) {
const id = e.currentTarget.dataset.id
const idx = this.data.attachments.findIndex(a => a.id === id)
if (idx < 0) return
const att = this.data.attachments[idx]
if (!att || att.uploaded || !att.url) return
this.uploadOne({
name: att.fileName || '未命名',
size: att.fileSize || 0,
type: att.isImage ? 'image' : 'file',
path: att.url,
}).then(() => {
const next = this.data.attachments.slice()
next.splice(idx, 1)
this.setData({ attachments: next })
})
},
parseTags(text) {
const s = String(text || '').trim()
if (!s) return []
return s
.split(/[,,\s]+/)
.map(t => t.trim())
.filter(Boolean)
},
async onSave() {
if (this.data.saving) return
const title = this.data.title.trim()
const remark = this.data.remark.trim()
if (!title && !remark) {
wx.showToast({ title: '标题或备注至少填一个', icon: 'none' })
return
}
const errorAtt = this.data.attachments.find(a => a && a.error)
if (errorAtt) {
wx.showModal({
title: '存在上传失败的附件',
content: `「${errorAtt.fileName}」上传失败:${errorAtt.error}。请移除或重试后再保存。`,
showCancel: false,
})
return
}
const uploading = this.data.attachments.find(a => a && a.uploading)
if (uploading) {
wx.showToast({ title: '请等待附件上传完成', icon: 'none' })
return
}
const attachmentIds = this.data.attachments
.filter(a => a && a.uploaded && a.id)
.map(a => a.id)
const payload = {
title,
remark,
tags: this.parseTags(this.data.tagsText),
attachmentIds,
}
this.setData({ saving: true })
wx.showLoading({ title: this.data.isEdit ? '保存中...' : '创建中...', mask: true })
try {
if (this.data.isEdit) {
await api.personalCollect.update(Object.assign({ id: this.data.id }, payload))
} else {
await api.personalCollect.create(payload)
}
wx.hideLoading()
wx.showToast({
title: this.data.isEdit ? '已保存' : '已创建',
icon: 'success',
})
setTimeout(() => {
wx.navigateBack()
}, 800)
} catch (e) {
wx.hideLoading()
wx.showModal({
title: this.data.isEdit ? '保存失败' : '创建失败',
content: e && e.message ? e.message : '请重试',
showCancel: false,
})
} finally {
this.setData({ saving: false })
}
},
})
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "编辑收藏",
"backgroundTextStyle": "light"
}
@@ -0,0 +1,101 @@
<view wx:if="{{loading}}" class="container">
<view class="empty">加载中...</view>
</view>
<view wx:else class="container" style="padding-bottom: 200rpx;">
<view wx:if="{{error}}" class="card" style="border-color:#55333a;">
<text style="color:#e05561; font-size: 26rpx;">⚠️ {{error}}</text>
</view>
<view class="input-group">
<text class="input-label">标题</text>
<input
class="input"
placeholder="输入标题(必填或填备注)"
placeholder-style="color:#555570"
value="{{title}}"
bindinput="onTitleInput"
maxlength="200"
style="color:#ffffff;"
/>
</view>
<view class="input-group">
<text class="input-label">备注</text>
<textarea
class="textarea"
placeholder="输入备注信息"
placeholder-style="color:#555570"
value="{{remark}}"
bindinput="onRemarkInput"
maxlength="4000"
auto-height
style="color:#ffffff;"
/>
</view>
<view class="input-group">
<text class="input-label">标签(逗号或空格分隔,如:工作, 重要)</text>
<input
class="input"
placeholder="标签1, 标签2"
placeholder-style="color:#555570"
value="{{tagsText}}"
bindinput="onTagsInput"
maxlength="500"
style="color:#ffffff;"
/>
</view>
<view class="input-group">
<view class="row row-between" style="margin-bottom: 16rpx;">
<text class="input-label" style="margin-bottom:0;">附件(从聊天选择,最多 9 个)</text>
<view class="btn btn-ghost mini-btn" bindtap="onChooseAttachments" style="margin:0;">+ 从聊天选</view>
</view>
<view wx:if="{{attachments.length === 0}}" class="card" style="text-align:center; color:#555570; border-style: dashed;">
<text>还没有附件,点右上角按钮选择</text>
</view>
<view wx:if="{{attachments.length > 0}}" class="attachments-grid">
<block wx:for="{{attachments}}" wx:key="id">
<view class="attachment-thumb">
<block wx:if="{{item.isImage && item.url}}">
<image
src="{{item.url}}"
mode="aspectFill"
data-url="{{item.url}}"
bindtap="onPreviewImage"
/>
</block>
<block wx:else>
<text class="attachment-icon">📄</text>
</block>
<view class="attachment-name">{{item.fileName}}</view>
<view wx:if="{{item.uploading}}" style="position:absolute; left:0; right:0; top:0; bottom:0; background:rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center;">
<text style="color:#fff; font-size:22rpx;">上传中...</text>
</view>
<view wx:elif="{{item.error}}" style="position:absolute; left:0; right:0; top:0; bottom:0; background:rgba(88,20,30,0.7); display:flex; align-items:center; justify-content:center; flex-direction:column; padding: 12rpx; box-sizing:border-box;">
<text style="color:#fff; font-size:20rpx; line-height:1.4; text-align:center;">上传失败</text>
<view style="margin-top:8rpx; padding:6rpx 16rpx; background:#fff; color:#1a1a2e; border-radius:8rpx; font-size:20rpx;" catchtap="onRetryUpload" data-id="{{item.id}}">重试</view>
</view>
<view
style="position:absolute; top:8rpx; right:8rpx; width:44rpx; height:44rpx; border-radius:22rpx; background:rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center; color:#fff; font-size:28rpx; line-height:1;"
catchtap="onRemoveAttachment"
data-id="{{item.id}}"
>×</view>
</view>
</block>
</view>
</view>
</view>
<view style="position:fixed; left:0; right:0; bottom:0; padding:24rpx 32rpx; background:#0f0f1a; border-top:1rpx solid #2a2a44; box-sizing:border-box; z-index:99;">
<view class="btn btn-primary btn-block {{saving ? '' : ''}}" style="opacity: {{saving ? 0.6 : 1}};" bindtap="onSave">
{{isEdit ? (saving ? '保存中...' : '保存修改') : (saving ? '创建中...' : '创建收藏')}}
</view>
</view>
@@ -0,0 +1 @@
/* 编辑页独立样式,大部分复用 app.wxss 全局样式 */
@@ -0,0 +1,110 @@
const api = require('../../../../api.js')
const formatDate = iso => {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
const pad = n => n < 10 ? '0' + n : '' + n
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
Page({
data: {
loading: true,
keyword: '',
items: [],
error: '',
},
onShow() {
this.refresh()
},
onPullDownRefresh() {
this.refresh().finally(() => {
wx.stopPullDownRefresh()
})
},
onSearchInput(e) {
const val = e.detail && e.detail.value !== undefined ? String(e.detail.value) : ''
this.setData({ keyword: val })
},
onSearchConfirm() {
this.refresh()
},
onClearSearch() {
this.setData({ keyword: '' })
this.refresh()
},
async refresh() {
this.setData({ loading: true, error: '' })
try {
const res = await api.personalCollect.list(this.data.keyword || '')
const raw = Array.isArray(res.data) ? res.data : []
const items = raw.map(item => ({
...item,
updatedAtText: formatDate(item.updatedAt || item.createdAt || ''),
tagsText: (item.tags || []).join(' · '),
previewImage: (item.attachments || []).find(a => a && a.isImage && a.filePath) || null,
fileCount: Array.isArray(item.attachments) ? item.attachments.length : 0,
}))
this.setData({ items })
} catch (e) {
this.setData({ error: e && e.message ? e.message : '加载失败' })
} finally {
this.setData({ loading: false })
}
},
onViewTap(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({
url: `/kits/personal_collect/pages/detail/detail?id=${encodeURIComponent(id)}`,
})
},
onEditTap(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({
url: `/kits/personal_collect/pages/edit/edit?id=${encodeURIComponent(id)}`,
})
},
onDeleteTap(e) {
const id = e.currentTarget.dataset.id
const title = e.currentTarget.dataset.title || '此收藏'
wx.showModal({
title: '确认删除',
content: `确定删除「${title}」吗?删除后不再显示。`,
confirmText: '删除',
confirmColor: '#e05561',
success: async (res) => {
if (!res.confirm) return
try {
wx.showLoading({ title: '删除中...', mask: true })
await api.personalCollect.remove(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,
})
}
},
})
},
onAddTap() {
wx.navigateTo({
url: '/kits/personal_collect/pages/edit/edit',
})
},
})
@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "微信收藏夹",
"enablePullDownRefresh": true,
"backgroundTextStyle": "light"
}
@@ -0,0 +1,77 @@
<view class="container" style="padding-bottom: 200rpx;">
<view class="page-header">
<view class="page-title">微信收藏夹</view>
<view class="page-subtitle">保存聊天里的图片与文件,标签搜索管理。</view>
</view>
<view class="search-box">
<text class="search-icon">🔍</text>
<input
class="search-input"
placeholder="搜索标题 / 备注 / 标签"
placeholder-style="color:#555570"
value="{{keyword}}"
bindinput="onSearchInput"
bindconfirm="onSearchConfirm"
confirm-type="search"
/>
<view wx:if="{{keyword}}" bindtap="onClearSearch" style="padding: 0 8rpx;">
<text style="color:#8888a8; font-size: 36rpx;">×</text>
</view>
</view>
<view wx:if="{{error}}" class="card" style="border-color:#55333a;">
<text style="color:#e05561; font-size: 26rpx;">⚠️ {{error}}</text>
</view>
<view wx:if="{{loading}}" class="empty">加载中...</view>
<block wx:if="{{!loading && items.length === 0}}">
<view class="empty">
<view style="font-size: 80rpx; margin-bottom: 24rpx;">📁</view>
<view>{{keyword ? '没有匹配的收藏' : '还没有收藏,点右下角 + 新建'}}</view>
</view>
</block>
<block wx:for="{{items}}" wx:key="id">
<view class="card" bindtap="onViewTap" data-id="{{item.id}}">
<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 && item.remark}}" 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 wx:if="{{item.remark && item.title}}" class="muted small" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.5;">{{item.remark}}</view>
</view>
<view wx:if="{{item.previewImage}}" style="width:128rpx; height:128rpx; margin-left: 24rpx; border-radius: 16rpx; overflow: hidden; background:#12121f; flex-shrink:0;">
<image
src="https://traesite.umersoft.com:8975/{{item.previewImage.filePath}}"
mode="aspectFill"
style="width:100%; height:100%;"
/>
</view>
<view wx:elif="{{item.fileCount > 0}}" style="width:128rpx; height:128rpx; margin-left: 24rpx; border-radius: 16rpx; background:#12121f; display:flex; align-items:center; justify-content:center; flex-direction: column; flex-shrink:0;">
<text style="font-size: 40rpx;">📎</text>
<text class="muted small" style="margin-top: 8rpx;">{{item.fileCount}} 个</text>
</view>
</view>
<view wx:if="{{item.tagsText}}" style="margin-top: 20rpx;">
<block wx:for="{{item.tags}}" wx:for-item="tag" wx:key="*this">
<view class="tag">#{{tag}}</view>
</block>
</view>
<view class="row row-between" style="margin-top: 20rpx;">
<text class="muted small">更新于 {{item.updatedAtText}}</text>
</view>
<view class="item-actions">
<view class="btn btn-ghost mini-btn" catchtap="onViewTap" data-id="{{item.id}}">查看</view>
<view class="btn btn-ghost mini-btn" catchtap="onEditTap" data-id="{{item.id}}">编辑</view>
<view class="spacer"></view>
<view class="btn btn-danger mini-btn" catchtap="onDeleteTap" data-id="{{item.id}}" data-title="{{item.title || (item.remark && item.remark.slice(0, 10)) || '此收藏'}}">删除</view>
</view>
</view>
</block>
</view>
<view class="fab" bindtap="onAddTap">+</view>
@@ -0,0 +1 @@
/* 列表页独立样式,大部分复用 app.wxss 全局样式 */
@@ -0,0 +1,173 @@
const api = require('../../api.js')
Page({
data: {
loading: true,
kits: [],
appNameZh: 'MilesYang时光助手',
subtitle: '个人工具集合,记录时光点滴。',
error: '',
clipVisible: false,
clipText: '',
clipCount: 0,
clipLines: 0,
clipPreview: '',
},
onLoad() {
this.loadMeta()
},
onShow() {
this.checkClipboard()
},
onPullDownRefresh() {
this.loadMeta().finally(() => {
wx.stopPullDownRefresh()
})
},
simpleHash(str) {
const s = String(str || '')
let h1 = 0x811c9dc5
let h2 = 0xdeadbeef
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i)
h1 = Math.imul(h1 ^ c, 16777619)
h2 = Math.imul(h2 ^ c, 2246822519)
}
h1 = (h1 ^ h1 >>> 16) >>> 0
h2 = (h2 ^ h2 >>> 15) >>> 0
return h1.toString(36) + h2.toString(36)
},
checkClipboard() {
try {
const enable = wx.getStorageSync('mp_ta_clip_enable')
if (enable === false || enable === 'false') return
} catch (e) {}
wx.getClipboardData({
success: (res) => {
const text = (res.data || '').trim()
if (text.length < 6 || text.length > 30000) return
const hash = this.simpleHash(text)
try {
const last = wx.getStorageSync('mp_ta_clip_hash')
if (last === hash) return
wx.setStorageSync('mp_ta_clip_hash', hash)
} catch (e) {}
const count = text.length
const lines = text.split('\n').length
const preview = text.length > 220 ? text.slice(0, 220) + '\n\n……' : text
this.setData({
clipVisible: true,
clipText: text,
clipCount: count,
clipLines: lines,
clipPreview: preview,
})
},
fail: () => {}
})
},
onClipCollect(e) {
const mode = (e && e.currentTarget && e.currentTarget.dataset.mode) || 'new'
const text = this.data.clipText || ''
if (!text) { this.setData({ clipVisible: false }); return }
try { wx.setStorageSync('mp_ta_clip_text', text) } catch (e) {}
try { wx.setStorageSync('mp_ta_clip_mode', mode) } catch (e) {}
this.setData({ clipVisible: false })
wx.navigateTo({
url: '/kits/personal_collect/pages/edit/edit',
fail: () => { wx.showToast({ title: '打开失败', icon: 'none' }) }
})
},
onClipCancel() {
this.setData({ clipVisible: false })
},
onClipDismissForever() {
wx.showModal({
title: '不再检测剪贴板?',
content: '关闭后可在未来任意新增收藏页面时重新开启。',
confirmText: '不再提示',
cancelText: '保留',
success: (r) => {
if (r.confirm) {
try { wx.setStorageSync('mp_ta_clip_enable', false) } catch (e) {}
this.setData({ clipVisible: false })
wx.showToast({ title: '已关闭检测', icon: 'none' })
}
}
})
},
async loadMeta() {
this.setData({ loading: true, error: '' })
try {
const res = await api.core.getMeta()
if (res && res.appNameZh) {
this.setData({ appNameZh: res.appNameZh })
}
if (res && Array.isArray(res.kits) && res.kits.length) {
this.setData({ kits: this.decorateKits(res.kits) })
try { getApp().setMeta(res) } catch (e) {}
} else {
this.setData({ kits: this.fallbackKits() })
}
} catch (e) {
this.setData({
error: e && e.message ? e.message : '加载失败,请检查网络',
kits: this.fallbackKits(),
})
} finally {
this.setData({ loading: false })
}
},
fallbackKits() {
return this.decorateKits([{
id: 'personal_collect',
name: '微信收藏夹',
description: '聊天图片、文件收藏管理,标签搜索,软删除可追溯。',
}])
},
decorateKits(list) {
const icons = {
personal_collect: '📁',
}
return list.map(item => Object.assign({}, item, {
icon: icons[item.id] || '🛠️',
path: this.getKitPath(item.id),
}))
},
getKitPath(id) {
const map = {
personal_collect: '/kits/personal_collect/pages/list/list',
}
return map[id] || ''
},
async onKitTap(e) {
const id = e.currentTarget.dataset.id
const path = e.currentTarget.dataset.path
if (!path) {
wx.showToast({ title: '功能开发中', icon: 'none' })
return
}
try {
await api.core.setActiveKit(id)
} catch (e) {}
wx.navigateTo({
url: path,
fail: () => {
wx.showToast({ title: '打开失败', icon: 'none' })
},
})
},
})
@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "MilesYang时光助手",
"enablePullDownRefresh": true,
"backgroundTextStyle": "light"
}
@@ -0,0 +1,35 @@
<view class="container">
<view class="page-header">
<view class="page-title">{{appNameZh}}</view>
<view class="page-subtitle">{{subtitle}}</view>
<view wx:if="{{error}}" class="muted small" style="margin-top: 16rpx; color: #e05561;">⚠️ {{error}}</view>
</view>
<view wx:if="{{clipVisible}}" class="clip-card" catchtouchmove="noop">
<view class="clip-head">
<view class="clip-title">📝 检测到剪贴板文字</view>
<view class="clip-count">{{clipCount}} 字 · {{clipLines}} 行</view>
</view>
<view class="clip-preview">{{clipPreview}}</view>
<view class="clip-actions">
<view class="clip-btn clip-btn-ghost" bindtap="onClipDismissForever">不再提示</view>
<view class="clip-btn clip-btn-ghost" bindtap="onClipCancel">取消</view>
<view class="clip-btn clip-btn-primary" bindtap="onClipCollect" data-mode="new">📌 收藏为文字</view>
</view>
</view>
<view wx:if="{{loading}}" class="empty">加载中...</view>
<block wx:for="{{kits}}" wx:key="id">
<view class="kit-card" bindtap="onKitTap" data-id="{{item.id}}" data-path="{{item.path}}">
<view class="kit-icon">
<text class="kit-icon-text">{{item.icon}}</text>
</view>
<view class="kit-body">
<view class="kit-title">{{item.name}}</view>
<view class="kit-desc">{{item.description}}</view>
</view>
<view class="chevron">›</view>
</view>
</block>
</view>
@@ -0,0 +1,72 @@
/* 首页独立样式,大部分复用 app.wxss 全局样式 */
.clip-card {
margin: 20rpx 24rpx 12rpx;
padding: 24rpx 26rpx 22rpx;
background: linear-gradient(155deg, rgba(108, 122, 224, 0.18), rgba(176, 107, 224, 0.10));
border: 1px solid rgba(140, 150, 240, 0.22);
border-radius: 24rpx;
box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.28);
}
.clip-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14rpx;
}
.clip-title {
font-size: 30rpx;
font-weight: 700;
color: #ececff;
letter-spacing: 0.5rpx;
}
.clip-count {
font-size: 22rpx;
color: #a0a4d0;
background: rgba(108, 122, 224, 0.22);
padding: 4rpx 16rpx;
border-radius: 999rpx;
}
.clip-preview {
max-height: 260rpx;
overflow: hidden;
padding: 18rpx 20rpx;
background: rgba(0,0,0,0.32);
border-radius: 16rpx;
font-size: 25rpx;
line-height: 1.8;
color: #d4d4ee;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid rgba(255,255,255,0.06);
margin-bottom: 18rpx;
}
.clip-actions {
display: flex;
gap: 14rpx;
justify-content: flex-end;
flex-wrap: wrap;
}
.clip-btn {
padding: 12rpx 24rpx;
font-size: 25rpx;
border-radius: 14rpx;
text-align: center;
min-width: 160rpx;
}
.clip-btn-ghost {
background: rgba(255,255,255,0.04);
color: #b8b8d8;
border: 1px solid rgba(255,255,255,0.08);
}
.clip-btn-secondary {
background: rgba(108, 122, 224, 0.16);
color: #cfd4ff;
border: 1px solid rgba(108, 122, 224, 0.32);
}
.clip-btn-primary {
background: linear-gradient(135deg, #6c7ae0, #b06be0);
color: #fff;
font-weight: 600;
box-shadow: 0 8rpx 20rpx rgba(108, 122, 224, 0.32);
}
@@ -0,0 +1,57 @@
{
"description": "MilesYang时光助手 - 微信小程序版工具箱",
"packOptions": {
"ignore": [],
"include": []
},
"setting": {
"bundle": false,
"userConfirmedBundleSwitch": false,
"urlCheck": true,
"scopeDataCheck": false,
"coverView": true,
"es6": true,
"postcss": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"preloadBackgroundData": false,
"minified": true,
"autoAudits": false,
"newFeature": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"useIsolateContext": true,
"nodeModules": false,
"enhance": true,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true,
"showShadowRootInWxmlPanel": true,
"packNpmManually": false,
"enableEngineNative": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"showES6CompileOption": false,
"minifyWXML": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"compileWorklet": false,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false
},
"compileType": "miniprogram",
"libVersion": "3.0.0",
"appid": "wx4fe6339f60c7b2a4",
"projectname": "mp_time_assistant",
"condition": {},
"editorSetting": {
"tabIndent": "insertSpaces",
"tabSize": 2
},
"simulatorPluginLibVersion": {}
}
@@ -0,0 +1,23 @@
{
"libVersion": "3.17.2",
"projectname": "mp_time_assistant%5B%E6%97%B6%E5%85%89%E5%8A%A9%E6%89%8B%5D",
"condition": {},
"setting": {
"urlCheck": true,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": true,
"showShadowRootInWxmlPanel": true,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"compileHotReLoad": true,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false,
"useIsolateContext": true
}
}
@@ -0,0 +1,7 @@
{
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/sitemap.html",
"rules": [{
"action": "disallow",
"page": "*"
}]
}
@@ -1,7 +1,15 @@
{
"_comment": "日程提醒工具的前端鉴权配置",
"enable_auth_comment": "true:启用鉴权(需要导航/登录获得 cookie);false:完全放开访问",
"enable_auth_comment": "true:启用 tool 外层鉴权(通过九宫格导航或合法 GUID/token 进入,设置 calendar_reminder_gate cookie);false:完全放开访问",
"enable_auth": true,
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365),到期后需要重新登录",
"max_age_days": 30
}
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365),到期后需要重新从九宫格或合法入口进入",
"max_age_days": 30,
"cookieName_comment": "外层 gate cookie 名,需与 src/server/index.js 中的 CALENDAR_AUTH_COOKIE 保持一致",
"cookieName": "calendar_reminder_gate",
"requireGuid_comment": "true:强制 URL 带 GUID 参数才能访问;false:允许仅凭已有 gate cookie 或导航 token 访问",
"requireGuid": false,
"guidParam_comment": "URL 中 GUID 参数名(requireGuid=true 时生效)",
"guidParam": "guid",
"guidValue_comment": "GUID 参数真值(留空表示不启用 GUID;严禁在此写真实生产 GUID,生产值来自本机私有配置)",
"guidValue": ""
}
@@ -0,0 +1,264 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>日历批量生成工具 · 独立窗口</title>
<link rel="icon" href="calendar.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/lib/bootstrap.min.css?v=1.6">
<link rel="stylesheet" href="css/styles.css?v=1.6">
</head>
<body class="bg-light">
<header class="navbar navbar-dark bg-warning sticky-top">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">🧺 日历批量生成工具 · 仅循环场景</span>
<div class="d-flex align-items-center gap-2">
<button id="btnBackToMain" class="btn btn-light btn-sm" title="回到日历主界面">🗓 返回主日历</button>
<button id="btnReload" class="btn btn-light btn-sm" title="刷新批次列表">⟳ 刷新</button>
</div>
</div>
</header>
<main class="container py-3" style="max-width:1280px">
<ul class="nav nav-tabs mb-3" id="batchTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-generator" data-bs-toggle="tab" data-bs-target="#panel-generator" type="button">生成器</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-batches" data-bs-toggle="tab" data-bs-target="#panel-batches" type="button" onclick="CalendarBatchTool && CalendarBatchTool.loadBatches && CalendarBatchTool.loadBatches()">批次管理</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-sql" data-bs-toggle="tab" data-bs-target="#panel-sql" type="button">🧪 SQL 执行</button>
</li>
</ul>
<div class="tab-content" id="batchTabsContent">
<div class="tab-pane fade show active" id="panel-generator" role="tabpanel">
<form id="batchForm" class="vstack gap-3">
<div class="row g-2">
<div class="col-8">
<label class="form-label">批次名称(可选)</label>
<input type="text" id="batch_name" class="form-control" placeholder="如:2026Q4 洗被子提醒">
</div>
<div class="col-4">
<label class="form-label">循环模式</label>
<select id="batch_mode" class="form-select">
<option value="monthlyByDay" selected>每月 N 号(推荐)</option>
<option value="monthlyByNthWeekday">每月第 N 周星期 X</option>
<option value="weekly">每 N 周 · 星期多选</option>
<option value="daily">每 N 天</option>
</select>
</div>
</div>
<div class="row g-2">
<div class="col-3"><label class="form-label">时间范围 · 起</label><input type="date" id="batch_rangeStart" class="form-control" required></div>
<div class="col-3"><label class="form-label">时间范围 · 止</label><input type="date" id="batch_rangeEnd" class="form-control" required></div>
<div class="col-6 d-flex align-items-end gap-3">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="batch_allDay">
<label class="form-check-label" for="batch_allDay">全天(忽略时分)</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="batch_skipExternal">
<label class="form-check-label" for="batch_skipExternal">不联动周报下游</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="batch_completed">
<label class="form-check-label" for="batch_completed">全部标记已完成</label>
</div>
</div>
</div>
<div class="mode-fieldset" data-mode="daily">
<div class="row g-2"><div class="col-4">
<label class="form-label">每 _ 天</label>
<input type="number" min="1" value="1" id="batch_dailyEvery" class="form-control">
</div></div>
</div>
<div class="mode-fieldset" data-mode="weekly" style="display:none">
<div class="row g-2">
<div class="col-4"><label class="form-label">每 _ 周</label><input type="number" min="1" value="1" id="batch_weeklyEvery" class="form-control"></div>
<div class="col-8">
<label class="form-label">星期(可多选)</label>
<div id="batch_weekdays" class="hstack gap-3 flex-wrap">
<label class="form-check"><input type="checkbox" value="0" class="form-check-input batch-wd">周日</label>
<label class="form-check"><input type="checkbox" value="1" class="form-check-input batch-wd">周一</label>
<label class="form-check"><input type="checkbox" value="2" class="form-check-input batch-wd">周二</label>
<label class="form-check"><input type="checkbox" value="3" class="form-check-input batch-wd">周三</label>
<label class="form-check"><input type="checkbox" value="4" class="form-check-input batch-wd">周四</label>
<label class="form-check"><input type="checkbox" value="5" class="form-check-input batch-wd">周五</label>
<label class="form-check"><input type="checkbox" value="6" class="form-check-input batch-wd">周六</label>
</div>
</div>
</div>
</div>
<div class="mode-fieldset" data-mode="monthlyByDay">
<div class="row g-2">
<div class="col-4"><label class="form-label">每 _ 月</label><input type="number" min="1" value="2" id="batch_monthlyEvery" class="form-control"></div>
<div class="col-8">
<label class="form-label">每月哪些号(支持 1-5,15,25-28 混合写法)</label>
<input type="text" id="batch_monthlyDays" class="form-control" value="1-5" placeholder="例如:1-5,15,25-28">
</div>
</div>
</div>
<div class="mode-fieldset" data-mode="monthlyByNthWeekday" style="display:none">
<div class="row g-2">
<div class="col-4"><label class="form-label">每 _ 月</label><input type="number" min="1" value="1" id="batch_monthlyEvery2" class="form-control"></div>
<div class="col-4"><label class="form-label">第几周</label>
<select id="batch_nthWeek" class="form-select"><option>1</option><option>2</option><option selected>3</option><option>4</option><option>5</option></select>
</div>
<div class="col-4"><label class="form-label">星期</label>
<select id="batch_nthWeekday" class="form-select">
<option value="0">周日</option><option value="1">周一</option><option value="2">周二</option>
<option value="3">周三</option><option value="4">周四</option><option value="5">周五</option><option value="6">周六</option>
</select>
</div>
</div>
</div>
<div class="row g-2">
<div class="col-4"><label class="form-label">开始时分</label><input type="time" id="batch_startTime" class="form-control" value="09:00"></div>
<div class="col-4"><label class="form-label">结束时分(留空=不设)</label><input type="time" id="batch_endTime" class="form-control" value="10:00"></div>
<div class="col-4"><label class="form-label">提醒时分(留空=不提醒)</label><input type="time" id="batch_remindTime" class="form-control" value="08:30"></div>
</div>
<div>
<label class="form-label">标题模板(支持 {date} {yyyy} {MM} {dd} {W})</label>
<input type="text" id="batch_title" class="form-control" value="洗被子提醒({MM}-{dd})" required maxlength="150" placeholder="如:洗被子提醒({MM}-{dd})">
</div>
<div>
<label class="form-label">内容模板(同上占位符)</label>
<textarea id="batch_content" class="form-control" rows="3" placeholder="写备注,支持 {date} 占位符"></textarea>
</div>
<div class="hstack gap-2">
<button type="button" id="batch_btnPreview" class="btn btn-outline-primary">👁 预览 SQL</button>
<button type="button" id="batch_btnApply" class="btn btn-primary" disabled>▶ 直接入库</button>
<button type="button" id="batch_btnDownload" class="btn btn-outline-secondary" disabled>💾 下载 SQL</button>
<span id="batch_previewHint" class="text-muted small ms-2">请先点「预览 SQL」再入库</span>
</div>
</form>
<div id="batch_previewArea" class="mt-3 d-none">
<div class="card">
<div class="card-header small">预览信息</div>
<div class="card-body small">
<div id="batch_previewStats" class="mb-2"></div>
<div class="mb-2"><span class="text-danger fw-bold" id="batch_preExisting"></span></div>
<div class="mb-2"><strong>样例(前 20 条):</strong></div>
<table class="table table-sm table-hover" id="batch_sampleTable"><thead><tr><th>开始</th><th>结束</th><th>提醒</th><th>标题</th></tr></thead><tbody></tbody></table>
<div class="mb-2"><strong>SQL(前 503 行,完整的请下载文件):</strong></div>
<textarea id="batch_sqlText" readonly rows="14" class="form-control font-monospace small"></textarea>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="panel-batches" role="tabpanel">
<div class="d-flex justify-content-between mb-2">
<div style="width:360px"><input id="batch_search" class="form-control form-control-sm" placeholder="搜索标题 / 模式…(输入即搜)"></div>
<button type="button" id="batch_refresh" class="btn btn-outline-secondary btn-sm">⟳ 刷新批次</button>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" id="batch_listTable">
<thead><tr>
<th>批次名</th><th>模式</th><th>时间范围</th>
<th>生成 / 入库 / 去重 / 已回滚</th>
<th>状态</th><th>创建时间</th><th>操作</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="panel-sql" role="tabpanel">
<form class="vstack gap-3">
<div class="row g-2">
<div class="col-6">
<label class="form-label">批次名称(必填,便于后面批次管理/回滚查找)</label>
<input type="text" id="sql_batch_name" class="form-control" placeholder="如:2026-09-02 手动导入客户提醒 SQL">
</div>
<div class="col-6 d-flex align-items-end gap-3">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="sql_skipExternal" checked>
<label class="form-check-label" for="sql_skipExternal">不联动周报下游(默认勾,避免爆量)</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="sql_completed">
<label class="form-check-label" for="sql_completed">全部标记已完成</label>
</div>
</div>
</div>
<div>
<label class="form-label">SQL 文本框(只支持 <code>INSERT INTO events (...) VALUES (...)</code>;单条 / 多条 / 多个语句用 ; 分开都行;每行 events 字段允许:id/title/content/start/end/allDay/remindAt/recurrence/createdAt/updatedAt/completed/deleted/batch_id/completedAt/deletedAt,其他字段一律被拒绝;禁止 UPDATE / DELETE / DROP / ALTER)</label>
<textarea id="sql_textarea" rows="22" class="form-control font-monospace small" placeholder="INSERT INTO events (title,content,start,end,allDay,remindAt,completed,deleted,batch_id) VALUES
('客户A回访','准备季度会议','2026-09-03 09:00','2026-09-03 10:00',0,'2026-09-03 08:30',0,0,NULL),
('客户B回访','报价最终版本','2026-09-05 14:00','2026-09-05 15:00',0,'2026-09-05 13:30',0,0,NULL);
"></textarea>
</div>
<div class="hstack gap-2">
<button type="button" id="sql_btnCheck" class="btn btn-outline-info">🧪 SQL 语句检查</button>
<button type="button" id="sql_btnApply" class="btn btn-warning" disabled>▶ 执行 SQL 入库(生成批次,可回滚)</button>
<button type="button" id="sql_btnDownload" class="btn btn-outline-secondary">💾 下载 SQL</button>
<span id="sql_hint" class="text-muted small ms-2">建议先点「检查」确认语法/重复后再执行</span>
</div>
<div id="sql_checkArea" class="d-none mt-3">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>🧪 检查结果</span>
<span id="sql_checkSummary" class="badge bg-light"></span>
</div>
<div class="card-body small">
<div id="sql_checkMeta" class="mb-2"></div>
<div id="sql_checkForbidden" class="d-none text-danger mb-2"></div>
<div id="sql_checkSyntaxErrors" class="d-none text-danger mb-2"></div>
<div id="sql_checkColErrors" class="d-none text-danger mb-2"></div>
<div id="sql_checkDuplicate" class="d-none text-warning mb-2">
<span class="fw-bold">⚠️ 检测到重复(宽松模式):</span>
<button id="sql_dupDetail" class="btn btn-outline-warning btn-sm ms-2">查看重复明细…</button>
</div>
<div><strong>样例行(前 15 条):</strong></div>
<div class="table-responsive">
<table class="table table-sm table-hover"><thead><tr><th>语句#</th><th>行#</th><th>标题</th><th>开始</th><th>结束</th></tr></thead><tbody id="sql_checkSampleTbody"></tbody></table>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</main>
<div class="modal fade" id="batchDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">批次详情</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div id="batch_detailBody"></div>
</div>
<div class="modal-footer"><button class="btn btn-secondary" data-bs-dismiss="modal">关闭</button></div>
</div>
</div>
</div>
<div class="modal fade" id="sqlDuplicateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">⚠️ SQL 批次检测到重复(宽松模式)</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body small">
<div id="sql_dupSummary" class="mb-2"></div>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead><tr><th>你的 SQL 语句#-行#</th><th>你的标题</th><th>你的开始</th><th>匹配库内事件标题</th><th>匹配库内开始</th><th>相似度</th><th>操作</th></tr></thead>
<tbody id="sql_dupTbody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="sql_dupCancel">先不执行,回去改 SQL</button>
<button type="button" class="btn btn-warning" data-bs-dismiss="modal" id="sql_dupConfirm">我已知悉重复,继续执行生成批次</button>
</div>
</div>
</div>
</div>
<script src="js/config.js"></script>
<script src="/lib/bootstrap.bundle.min.js"></script>
<script src="js/batch.js?v=1.7"></script>
</body>
</html>
-52
View File
@@ -1,52 +0,0 @@
*{box-sizing:border-box}
html{font-size:14px}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans','Liberation Sans',sans-serif;line-height:1.5;color:#212529;overflow-x:hidden}
.container{width:100%;max-width:1140px;margin:0 auto;padding-left:12px;padding-right:12px}
.container-fluid{width:100%;padding-left:12px;padding-right:12px}
.navbar{display:flex;align-items:center;padding:.5rem 0}
.navbar{flex-wrap:wrap}
.navbar .container-fluid{display:flex;align-items:center;justify-content:center;gap:.75rem}
.bg-primary{background-color:#0d6efd}
.bg-dark{background-color:#2b2b2b}
.navbar-dark{color:#fff}
.navbar-dark .navbar-brand{color:#fff}
.navbar .navbar-brand{font-size:1.25rem;font-weight:500}
.text-white{color:#fff}
.text-muted{color:#6c757d}
.small{font-size:.875em}
.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;user-select:none;background-color:#f8f9fa;border:1px solid rgba(0,0,0,.125);padding:.375rem .75rem;border-radius:.375rem;transition:background-color .15s ease}
.btn:disabled{opacity:.65}
.btn:hover{background-color:#e9ecef}
.btn-light{color:#212529;background-color:#f8f9fa;border-color:#ced4da}
.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}
.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}
.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}
.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}
.card{display:block;background-color:#fff;border:1px solid rgba(0,0,0,.125);border-radius:.5rem}
.card-header{padding:.5rem 1rem;border-bottom:1px solid rgba(0,0,0,.125);background-color:#f8f9fa;font-weight:500}
.card-body{padding:1rem}
.row{display:flex;flex-wrap:wrap}
.row.g-2{gap:.5rem}
.col-12{flex:0 0 auto;width:100%}
.col-6{flex:0 0 auto;width:50%}
@media (min-width:992px){.col-lg-8{flex:0 0 auto;width:66.6667%}.col-lg-4{flex:0 0 auto;width:33.3333%}}
.form-label{margin-bottom:.25rem;font-weight:500}
.form-control,.form-select{display:block;width:100%;padding:.5rem .75rem;font-size:1rem;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.375rem}
.form-control:focus,.form-select:focus{outline:none;border-color:#86b7fe;box-shadow:0 0 0 .2rem rgba(13,110,253,.25)}
.form-control::-webkit-input-placeholder{color:#6c757d}
.form-control::-moz-placeholder{color:#6c757d}
input[type=datetime-local],input[type=time]{padding-right:2.25rem}
.card{box-shadow:0 8px 24px rgba(0,0,0,.08)}
.list-group{display:block;border-radius:.5rem;overflow:hidden}
.list-group .list-group-item{display:block;padding:.5rem 1rem;background-color:#fff;border-bottom:1px solid rgba(0,0,0,.125)}
.list-group-flush .list-group-item{border-left:0;border-right:0}
.hstack{display:flex;align-items:center}
.justify-content-between{justify-content:space-between}
.gap-2{gap:.5rem}
.mt-2{margin-top:.5rem}
.mb-2{margin-bottom:.5rem}
.mb-0{margin-bottom:0}
.py-3{padding-top:1rem;padding-bottom:1rem}
.d-flex{display:flex}
.align-items-center{align-items:center}
.d-none{display:none!important}
@@ -678,3 +678,25 @@ main {
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Batch tool (批量生成) */
#btnOpenBatchTool { font-weight: 600; }
.mode-fieldset {
border: 1px solid #e9ecef;
border-radius: 10px;
padding: 12px 14px 6px;
background: #fbfcfd;
}
#batch_sqlText {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
line-height: 1.45;
white-space: pre;
background: #1f2937;
color: #f8fafc;
border-color: #111827;
}
#batch_sampleTable { font-size: 12px; }
#batch_listTable .badge { font-size: 11px; }
.batch-detail-diag { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.4rem; }
+4 -3
View File
@@ -6,8 +6,8 @@
<title>日历提醒系统 · Web</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" href="calendar.svg" type="image/svg+xml" />
<link rel="stylesheet" href="css/bootstrap.local.css">
<link rel="stylesheet" href="css/styles.css?v=1.1">
<link rel="stylesheet" href="/lib/bootstrap.min.css?v=1.6">
<link rel="stylesheet" href="css/styles.css?v=1.6">
<script src="js/background.js?v=1.1" defer></script>
</head>
<body>
@@ -19,6 +19,7 @@
<button id="btnPrev" class="btn btn-light btn-sm">上一月</button>
<div id="currentMonth" class="text-white"></div>
<button id="btnNext" class="btn btn-light btn-sm">下一月</button>
<button id="btnOpenBatchTool" class="btn btn-warning btn-sm" title="批量生成循环提醒(展开到每天)· 新窗口打开" target="_blank" rel="noopener">🧺 批量生成</button>
</div>
</div>
</header>
@@ -127,11 +128,11 @@
</footer>
<script src="js/config.js"></script>
<script src="/assets/auth.js"></script>
<script src="js/db.js"></script>
<script src="js/models.js?v=1.1"></script>
<script src="js/sync.js?v=1.1"></script>
<script src="js/ui.js?v=1.3"></script>
<script src="js/open_batch.js?v=1.6"></script>
<script src="js/main.js?v=1.3"></script>
<script src="/lib/bootstrap.bundle.min.js"></script>
</body>
+495
View File
@@ -0,0 +1,495 @@
(function () {
const $ = (id) => document.getElementById(id)
const api = (path, opts = {}) => fetch(`/api/calendar_reminder/${path.replace(/^\//, '')}`, Object.assign({ credentials: 'same-origin', headers: { 'Content-Type': 'application/json' } }, opts, { body: (opts.body && typeof opts.body !== 'string') ? JSON.stringify(opts.body) : opts.body }))
const copy = async (text) => {
try { await navigator.clipboard.writeText(text); return true } catch { return false }
}
const setBtn = (el, loading, text) => { if (!el) return; if (loading) { el.disabled = true; el.dataset.old = el.textContent; el.textContent = text || el.textContent + ' 中…' } else { el.disabled = false; if (el.dataset.old) el.textContent = el.dataset.old } }
const toastNode = (msg, type) => {
if (typeof msg !== 'string' || !msg) return
const div = document.createElement('div')
const colorMap = { success: '#d1e7dd', error: '#f8d7da', info: '#cff4fc', warn: '#fff3cd' }
const fgMap = { success: '#0f5132', error: '#842029', info: '#055160', warn: '#664d03' }
const bg = colorMap[type || 'info'] || colorMap.info
const fg = fgMap[type || 'info'] || fgMap.info
div.textContent = msg
div.style.cssText = `position:fixed;top:64px;right:24px;z-index:99999;padding:8px 14px;border-radius:8px;box-shadow:0 4px 18px rgba(0,0,0,0.18);background:${bg};color:${fg};font-size:13px;max-width:420px;animation:cbFadeIn 0.18s ease-out;`
if (!document.getElementById('cbToastStyles')) {
const s = document.createElement('style'); s.id = 'cbToastStyles'
s.textContent = `@keyframes cbFadeIn { from { transform: translateY(-6px); opacity: 0;} to { transform: translateY(0); opacity:1; } }`
document.head.appendChild(s)
}
document.body.appendChild(div)
setTimeout(() => { div.style.transition = 'opacity 0.2s'; div.style.opacity = '0'; setTimeout(() => div.remove(), 240) }, 2200)
}
const toast = (msg, type) => {
try {
if (window.CAL && window.CAL.toast) window.CAL.toast(msg, type)
else toastNode(msg, type)
} catch { toastNode(msg, type) }
}
const readForm = () => {
const mode = $('batch_mode').value
const weekdays = [...document.querySelectorAll('.batch-wd:checked')].map(c => Number(c.value))
const params = {
batchName: $('batch_name').value.trim(),
mode,
rangeStart: $('batch_rangeStart').value,
rangeEnd: $('batch_rangeEnd').value,
allDay: $('batch_allDay').checked,
completed: $('batch_completed').checked,
startTime: $('batch_startTime').value || '',
endTime: $('batch_endTime').value || '',
remindTime: $('batch_remindTime').value || '',
title: $('batch_title').value.trim(),
content: $('batch_content').value
}
if (mode === 'daily') params.dailyEvery = Number($('batch_dailyEvery').value || 1)
if (mode === 'weekly') { params.weeklyEvery = Number($('batch_weeklyEvery').value || 1); params.weekdays = weekdays }
if (mode === 'monthlyByDay') { params.monthlyEvery = Number($('batch_monthlyEvery').value || 1); params.monthlyDays = $('batch_monthlyDays').value.trim() }
if (mode === 'monthlyByNthWeekday') { params.monthlyEvery2 = Number($('batch_monthlyEvery2').value || 1); params.nthWeek = Number($('batch_nthWeek').value || 3); params.nthWeekday = Number($('batch_nthWeekday').value || 0) }
return params
}
const fillForm = (params) => {
if (!params) return
const p = Object.assign({
mode: 'monthlyByDay', rangeStart: '', rangeEnd: '', allDay: false, completed: false,
startTime: '09:00', endTime: '10:00', remindTime: '08:30', title: '', content: ''
}, params || {})
$('batch_name').value = p.batchName || ''
$('batch_mode').value = p.mode
$('batch_rangeStart').value = p.rangeStart
$('batch_rangeEnd').value = p.rangeEnd
$('batch_allDay').checked = !!p.allDay
$('batch_completed').checked = !!p.completed
$('batch_startTime').value = p.startTime
$('batch_endTime').value = p.endTime
$('batch_remindTime').value = p.remindTime
$('batch_title').value = p.title
$('batch_content').value = p.content
$('batch_dailyEvery').value = p.dailyEvery || 1
$('batch_weeklyEvery').value = p.weeklyEvery || 1
if (Array.isArray(p.weekdays)) document.querySelectorAll('.batch-wd').forEach(cb => { cb.checked = p.weekdays.includes(Number(cb.value)) })
$('batch_monthlyEvery').value = p.monthlyEvery || 2
$('batch_monthlyDays').value = p.monthlyDays || '1-5'
$('batch_monthlyEvery2').value = p.monthlyEvery2 || 1
$('batch_nthWeek').value = p.nthWeek || 3
$('batch_nthWeekday').value = String(p.nthWeekday ?? 0)
refreshModeVisibility()
$('batch_btnApply').disabled = true
$('batch_btnDownload').disabled = true
$('batch_previewArea').classList.add('d-none')
}
const refreshModeVisibility = () => {
const mode = $('batch_mode').value
document.querySelectorAll('.mode-fieldset').forEach(el => {
el.style.display = (el.dataset.mode === mode) ? '' : 'none'
})
}
let latestPreview = null
const onPreview = async () => {
const params = readForm()
if (!params.rangeStart || !params.rangeEnd) return toast('请选择时间范围', 'error')
if (!params.title) return toast('请填写标题模板', 'error')
if (params.mode === 'weekly' && (!params.weekdays || !params.weekdays.length)) return toast('每周模式请至少选一个星期几', 'error')
if (params.mode === 'monthlyByDay' && !params.monthlyDays) return toast('请填写每月日期表达式(如 1-5)', 'error')
const btn = $('batch_btnPreview')
setBtn(btn, true, '预览中…')
try {
const r = await api('/batch/preview', { method: 'POST', body: params })
const data = await r.json()
if (!data.ok) throw new Error(data.error || 'preview failed')
latestPreview = data
$('batch_previewArea').classList.remove('d-none')
const stats = `预生成:<b>${data.generated}</b> 条。模式:${data.summary},区间:${data.range_start} ~ ${data.range_end}。batch_id = <code>${data.batchId}</code>`
$('batch_previewStats').innerHTML = stats
if (data.pre_existing_count) {
$('batch_preExisting').textContent = `⚠ 其中 ${data.pre_existing_count} 条 (title, start) 已存在,入库时将命中去重并复用原 id(不会真插入新行)`
} else { $('batch_preExisting').textContent = '' }
const tb = $('batch_sampleTable').querySelector('tbody')
tb.innerHTML = ''
;(data.sample_list || []).forEach(s => {
const tr = document.createElement('tr')
tr.innerHTML = `<td>${s.start || ''}</td><td>${s.end || ''}</td><td>${s.remindAt || ''}</td><td>${s.title || ''}</td>`
tb.appendChild(tr)
})
$('batch_sqlText').value = data.sql_display + (data.truncated ? '\n... 超过 500 行,完整 SQL 请点下载 SQL 文件按钮' : '')
$('batch_btnApply').disabled = false
$('batch_btnDownload').disabled = false
} catch (e) {
toast('预览失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const onApply = async () => {
if (!latestPreview) return toast('请先点「预览 SQL」', 'error')
const ok = confirm(`确认将 ${latestPreview.generated} 条事件直接写入数据库?\n(可在批次管理里一键回滚逻辑删除)`)
if (!ok) return
const btn = $('batch_btnApply')
setBtn(btn, true, '入库中…')
try {
const r = await api('/batch/apply', {
method: 'POST',
body: { batch_id: latestPreview.batchId, params: latestPreview.params_saved, sql_full: latestPreview.sql_full, skip_external_sync: $('batch_skipExternal').checked }
})
const data = await r.json()
if (!data.ok) throw new Error(data.error || 'apply failed')
toast(`写入成功:生成${data.generated} 入库${data.inserted} 去重${data.dedup} 预重${data.pre_existing}`, 'success')
loadBatches()
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-batches'))
tabs.show()
} catch (e) {
toast('入库失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const onDownload = () => {
if (!latestPreview) return
const blob = new Blob([latestPreview.sql_full], { type: 'text/plain;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `calendar_batch_preview_${latestPreview.batchId}.sql`
a.click()
URL.revokeObjectURL(a.href)
}
/* ---------- SQL Manual Tab ---------- */
let _sqlLatestReport = null
let _sqlConfirmDup = false
let _sqlConfirmForbidden = false
const pad2 = (n) => String(n).padStart(2, '0')
const tsFile = () => {
const d = new Date()
return `${d.getFullYear()}${pad2(d.getMonth()+1)}${pad2(d.getDate())}_${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
}
const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, ch => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[ch]))
const sqlReadForm = () => ({
batch_name: ($('sql_batch_name') || {}).value || '',
skip_external_sync: !!($('sql_skipExternal') || {}).checked,
mark_completed: !!($('sql_completed') || {}).checked,
sql_text: ($('sql_textarea') || {}).value || ''
})
const sqlRenderReport = (data) => {
_sqlLatestReport = data || null
const area = $('sql_checkArea')
if (!area) return
if (!_sqlLatestReport || !data) { area.classList.add('d-none'); return }
area.classList.remove('d-none')
const total = data.total_statements || 0
const ins = data.insert_statements || 0
const rows = data.total_rows || 0
const ok = !!data.ok
$('sql_checkSummary').textContent = `${ok ? '✅ 通过' : '❌ 未通过'} · ${total} 语句 · ${ins} INSERT · ${rows} 行`
$('sql_checkSummary').className = `badge ${ok ? 'bg-success' : 'bg-danger'}`
// Meta
$('sql_checkMeta').innerHTML = `
阈值:重复相似度 ≥ <b>${data.duplicate_threshold || 0.65}</b>,最多 <b>${data.max_rows_limit || 500}</b> 条,执行超时 <b>${Math.round((data.max_ms_limit || 60000)/1000)}s</b>。<br>
检测到 <b>${data.pre_existing_count || 0}</b> 条严格匹配(title,start) 预重(入库时会自动去重复用原 id)<br>
检测到 <b>${data.high_confidence_duplicates || 0}</b> 条宽松匹配高置信重复(LIKE % + ±10min)。时间范围:<b>${data.range_start || '未知起'}</b> ~ <b>${data.range_end || '未知止'}</b>
`
// Forbidden / syntax / col errors
const renderList = (containerId, headerText, list, color) => {
const el = $(containerId)
if (!el) return
if (!list || !list.length) { el.classList.add('d-none'); return }
el.classList.remove('d-none')
const items = list.map((e, idx) => `<li><b>#${idx+1}</b> [stmt ${e.stmt_idx ?? '-'}${e.row_idx != null ? `, row ${e.row_idx}` : ''}] ${escapeHtml(e.reason || e.message || '')}${e.preview ? `<br><small class="text-muted">${escapeHtml(e.preview)}</small>` : ''}${e.title ? `<br><small>title=${escapeHtml(e.title)}</small>` : ''}</li>`).join('')
el.innerHTML = `<div class="fw-bold mb-1" style="color:${color}">${headerText}</div><ul style="padding-left:1.25rem">${items}</ul>`
}
renderList('sql_checkForbidden', '❌ 语句类型被拒绝(只允许 INSERT INTO events):', data.forbidden, '#dc3545')
renderList('sql_checkSyntaxErrors', '❌ 语法错误(SQLite EXPLAIN 未通过):', data.syntax_errors, '#dc3545')
renderList('sql_checkColErrors', '❌ 列 / 必填 / 时间格式错误:', data.col_errors, '#dc3545')
// Duplicates
const dupEl = $('sql_checkDuplicate')
if (dupEl) {
if ((data.high_confidence_duplicates || 0) === 0) dupEl.classList.add('d-none')
else {
dupEl.classList.remove('d-none')
dupEl.querySelector('span.fw-bold').textContent = `⚠️ 检测到 ${data.high_confidence_duplicates} 条宽松匹配高置信重复(≥${data.duplicate_threshold||0.65}):`
}
}
// Sample rows
const tb = $('sql_checkSampleTbody')
if (tb) {
tb.innerHTML = ''
const meta = (data.row_meta || []).slice(0, 15)
if (!meta.length) tb.innerHTML = '<tr><td colspan="5" class="text-muted small text-center">空</td></tr>'
else meta.forEach(r => {
const tr = document.createElement('tr')
tr.innerHTML = `<td>${r.stmt_idx ?? ''}</td><td>${r.row_idx ?? ''}</td><td>${escapeHtml(r.title || '')}</td><td>${escapeHtml(r.start || '')}</td><td>${escapeHtml(r.end || '')}</td>`
tb.appendChild(tr)
})
}
// 执行按钮:允许状态
const applyBtn = $('sql_btnApply')
if (applyBtn) applyBtn.disabled = false
}
const sqlOpenDuplicateModal = () => {
if (!_sqlLatestReport || !_sqlLatestReport.high_confidence_duplicates) return
const rows = (_sqlLatestReport.duplicates || []).filter(d => (d.score || 0) >= (_sqlLatestReport.duplicate_threshold || 0.65) && d.match && d.match.length)
if (!rows.length) return toast('没有高置信重复明细', 'warn')
$('sql_dupSummary').innerHTML = `共 <b>${rows.length}</b> 条你的 SQL 行命中了库内现有事件(宽松模式)。确认要继续执行,这些行也会进入去重逻辑命中 UPDATE(而不是 INSERT 新行)。`
const tb = $('sql_dupTbody')
tb.innerHTML = ''
rows.forEach(d => {
const mainMatch = (d.match || [])[0] || {}
const tr = document.createElement('tr')
tr.innerHTML = `
<td>${d.stmt_idx ?? ''}-${d.row_idx ?? ''}</td>
<td>${escapeHtml(d.title_left || '')}</td>
<td>${escapeHtml(d.start_left || '')}</td>
<td>${escapeHtml(mainMatch.title || '')}</td>
<td>${escapeHtml(mainMatch.start || '')}</td>
<td>${Number(d.score || 0).toFixed(2)}</td>
<td><a href="index.html" target="_blank" class="btn btn-sm btn-outline-primary">打开主日历核对</a></td>`
tb.appendChild(tr)
})
const m = bootstrap.Modal.getOrCreateInstance($('sqlDuplicateModal'))
m.show()
}
const sqlOnCheck = async () => {
const f = sqlReadForm()
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
const btn = $('sql_btnCheck')
setBtn(btn, true, '检查中…')
try {
const r = await api('/batch_sql_check', { method: 'POST', body: f })
const data = await r.json()
sqlRenderReport(data)
_sqlConfirmDup = (data.high_confidence_duplicates || 0) === 0
_sqlConfirmForbidden = !!(data.ok)
if (data.ok) toast(`检查通过:${data.insert_statements||0} INSERT / ${data.total_rows||0} 行 / ${data.high_confidence_duplicates||0} 宽松重复`, 'success')
else toast(`检查未通过:${(data.forbidden||[]).length + (data.syntax_errors||[]).length + (data.col_errors||[]).length} 处错误`, 'error')
} catch (e) {
toast('检查失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const sqlOnApply = async () => {
if (!_sqlLatestReport) return toast('请先点「SQL 语句检查」', 'error')
const f = sqlReadForm()
if (!f.batch_name.trim()) return toast('请填写批次名称(便于回滚时查找)', 'error')
// 先把最新状态再拉一遍(用户可能中间改过 textarea,不允许跳检查直接执行)
const checkBtn = $('sql_btnCheck')
if (checkBtn && checkBtn.disabled !== true) {
// 如果检查按钮不是 loading,强制再跑一次检查,避免用户中间改了 SQL 直接点执行
await sqlOnCheck()
if (!_sqlLatestReport) return
}
const hasErr = ((_sqlLatestReport.forbidden||[]).length + (_sqlLatestReport.syntax_errors||[]).length + (_sqlLatestReport.col_errors||[]).length) > 0
if (hasErr && !_sqlConfirmForbidden) {
const okForb = confirm(`有 ${(_sqlLatestReport.forbidden||[]).length + (_sqlLatestReport.syntax_errors||[]).length + (_sqlLatestReport.col_errors||[]).length} 处语法/列/语句类型被拒绝错误。真的要强制继续执行?(大概率会批量 INSERT 失败;建议先改 SQL 到检查通过)`)
if (!okForb) return
_sqlConfirmForbidden = true
}
const dupCount = _sqlLatestReport.high_confidence_duplicates || 0
if (dupCount > 0 && !_sqlConfirmDup) {
// 先弹重复详情 Modal,用户点 Modal 里的「我已知悉」再真正执行
sqlOpenDuplicateModal()
return
}
await sqlReallyApply({ confirm_duplicates: _sqlConfirmDup, confirm_forbidden: _sqlConfirmForbidden })
}
const sqlReallyApply = async (extra) => {
const f = Object.assign(sqlReadForm(), extra || {})
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
if (!f.batch_name.trim()) return toast('请填写批次名称', 'error')
const btn = $('sql_btnApply')
setBtn(btn, true, '执行入库中…')
try {
const r = await api('/batch_sql_apply', { method: 'POST', body: f })
const data = await r.json()
if (!r.ok || !data.ok) {
if (r.status === 409 && data.report) {
// 后端强制拦:需要 confirm_forbidden / confirm_duplicates
sqlRenderReport(data.report)
if (data.error && String(data.error).includes('duplicates_need_confirm')) { sqlOpenDuplicateModal(); return }
if (data.error && String(data.error).includes('forbidden_or_error_need_confirm')) {
const ok = confirm('后端强制拦截:有错误语句 / 不允许的列。点确定 = 我已知悉,仍尝试执行(可能全部或部分失败)')
if (!ok) return
_sqlConfirmForbidden = true
await sqlReallyApply({ confirm_duplicates: _sqlConfirmDup, confirm_forbidden: true })
return
}
}
throw new Error(data.error || `HTTP ${r.status}`)
}
toast(`SQL 批次执行成功:生成${data.generated} / 入库${data.inserted} / 去重${data.dedup} / 宽松重复${data.high_confidence_duplicates||0}。batch_id=${data.batch_id}`, 'success')
_sqlConfirmDup = false
_sqlConfirmForbidden = false
await loadBatches()
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-batches'))
tabs.show()
} catch (e) {
toast('SQL 批次执行失败: ' + e.message, 'error')
} finally { setBtn(btn, false) }
}
const sqlOnDownload = () => {
const f = sqlReadForm()
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
const head = `-- 下载时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}\n-- 批次名:${f.batch_name || '(未命名)'}\n-- 标记:${f.skip_external_sync ? '不联动下游 ' : ''}${f.mark_completed ? '全部已完成' : ''}\n-- 注意:仅支持 INSERT INTO events(...)\n\n`
const blob = new Blob(['\ufeff' + head + f.sql_text], { type: 'text/plain;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `calendar_events_sql_${tsFile()}.sql`
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 1500)
toast('SQL 已下载(UTF-8 BOM,避免中文乱码)', 'info')
}
const badgeFor = (st) => {
const map = { draft: 'bg-secondary', inserting: 'bg-primary', inserted: 'bg-success', rolled_back: 'bg-dark', partial: 'bg-warning' }
return `<span class="badge ${map[st] || 'bg-light text-dark'}">${st}</span>`
}
let batchListData = []
const loadBatches = async () => {
if (!$('batch_search')) return
const q = encodeURIComponent($('batch_search').value || '')
const r = await api(`/batches?q=${q}&limit=200`)
const data = (await r.json()).data || []
batchListData = data
const tb = $('batch_listTable').querySelector('tbody')
tb.innerHTML = ''
if (!data.length) { tb.innerHTML = '<tr><td colspan="7" class="text-center text-muted small">暂无批次</td></tr>'; return }
data.forEach(b => {
const tr = document.createElement('tr')
tr.innerHTML = `
<td><strong>${b.title || '(未命名)'}</strong><br><small class="text-muted">${b.id.slice(0, 10)}…</small></td>
<td>${b.summary || '-'}<br>${b.skip_external_sync ? '<small class="text-secondary">⏸不联动下游</small>' : ''}</td>
<td>${b.range_start || ''}<br>~ ${b.range_end || ''}</td>
<td><span class="text-primary">${b.generated}</span> / <span class="text-success">${b.inserted}</span> / <span class="text-secondary">${b.dedup}</span> / <span class="text-danger">${b.rolled_back}</span><br><small class="text-muted">预重${b.pre_existing}</small></td>
<td>${badgeFor(b.status)}</td>
<td><small>${(b.created_at || '').slice(0, 16).replace('T', ' ')}</small></td>
<td>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-info" data-act="view">👁 查看</button>
<button type="button" class="btn btn-outline-primary" data-act="copy">📋 复制</button>
<button type="button" class="btn btn-outline-warning" data-act="rollback">♻ 回滚</button>
<button type="button" class="btn btn-outline-secondary" data-act="export">💾 导出</button>
</div>
</td>`
tr.querySelectorAll('[data-act]').forEach(btnel => btnel.addEventListener('click', (ev) => onBatchAction(ev.currentTarget.dataset.act, b.id)))
tb.appendChild(tr)
})
}
const onBatchAction = async (act, id) => {
if (act === 'view') return showDetail(id)
if (act === 'export') { window.open(`/api/calendar_reminder/batch/export_sql?id=${encodeURIComponent(id)}`, '_blank', 'noopener'); return }
if (act === 'copy') {
const found = batchListData.find(b => b.id === id)
if (!found) return
fillForm(Object.assign({}, found.params, { batchName: (found.title || '') + ' (复制)' }))
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-generator'))
tabs.show()
toast('参数已填回生成器,修改后即可预览新批次', 'info')
return
}
if (act === 'rollback') {
const hard = confirm('点"确定"=逻辑删除(推荐,deleted=1);点"取消"=改为物理删除\n(逻辑删除保留行、方便二次确认;物理删不可恢复。)') === false
const action = hard ? '物理删除' : '逻辑删除'
if (!confirm(`确认对批次 ${id.slice(0, 10)}… 执行 ${action} 回滚?`)) return
const r = await api('/batch/rollback', { method: 'POST', body: { id, hard } })
const j = await r.json()
if (!j.ok) return toast('回滚失败: ' + j.error, 'error')
toast(`回滚完成:${j.rolled_back} 条 ${j.hard ? '(硬删)' : '(软删)'}`, 'success')
loadBatches()
}
}
const showDetail = async (id) => {
const r = await api(`/batch/detail?id=${encodeURIComponent(id)}`)
const j = await r.json()
if (!j.ok) return toast('detail failed: ' + j.error, 'error')
const b = j.batch
const counts = b.counts || { active: 0, deleted: 0 }
const rows = (b.sample || []).map(s => `<tr><td>${s.start || ''}</td><td>${s.end || ''}</td><td>${s.remindAt || ''}</td><td>${s.title || ''}</td><td>${s.completed ? '✅' : ''}${s.deleted ? '🗑' : ''}</td></tr>`).join('')
const diags = j.diagnostics || {}
const diagBtn = (label, keyName) => `<button class="btn btn-sm btn-outline-secondary m-1" type="button" data-diag-key="${keyName}">${label}(复制)</button>`
$('batch_detailBody').innerHTML = `
<h6>${b.title || '(未命名)'} <small class="text-muted ms-2">id: ${b.id}</small></h6>
<p>模式:${b.meta && b.meta.summary} · 范围 ${b.meta && b.meta.rangeStart} ~ ${b.meta && b.meta.rangeEnd} · 创建 ${(b.created_at || '').slice(0,19).replace('T',' ')}</p>
<p>生成:${b.generated_count},入库:${b.inserted_count},去重:${b.dedup_count},预重:${b.pre_existing_count},回滚:${b.rollback_count},状态:${badgeFor(b.status)} ${b.skip_external_sync ? '<span class="badge bg-secondary">不联动下游</span>':''}</p>
<p>当前表计数:<span class="text-success">active ${counts.active}</span> / <span class="text-danger">deleted ${counts.deleted}</span></p>
<div class="mb-2"><strong>诊断 SQL(一键复制):</strong>
<div class="batch-detail-diag">
${diagBtn('按 batch_id 清单', 'by_batch_id')}
${diagBtn('按 batch_id 计数', 'counts_by_batch')}
${diagBtn('孤儿(batch_id 存在但 calendar_batches 中无记录)检查', 'orphans_check')}
</div>
</div>
<div class="mb-2"><strong>样例(前 30 条):</strong></div>
<div class="table-responsive"><table class="table table-sm table-hover"><thead><tr><th>开始</th><th>结束</th><th>提醒</th><th>标题</th><th>状态</th></tr></thead><tbody>${rows || '<tr><td colspan="5" class="text-muted small text-center">空</td></tr>'}</tbody></table></div>
`
$('batch_detailBody').querySelectorAll('[data-diag-key]').forEach(bx => bx.addEventListener('click', async () => {
const k = bx.getAttribute('data-diag-key') || ''
const ok = await copy(diags[k] || '')
toast(ok ? '已复制到剪贴板' : '复制失败(可手动选取文本复制)', ok ? 'success' : 'error')
}))
const m = bootstrap.Modal.getOrCreateInstance($('batchDetailModal'))
m.show()
}
const openMainWindow = () => {
try { window.open('index.html', '_blank', 'noopener') } catch {}
}
document.addEventListener('DOMContentLoaded', () => {
// --- 独立页面初始化:如果是 batch_tool.html ---
const mode = $('batch_mode')
if (mode) {
refreshModeVisibility()
mode.addEventListener('change', refreshModeVisibility)
}
const backBtn = $('btnBackToMain')
if (backBtn) backBtn.addEventListener('click', openMainWindow)
const topReload = $('btnReload')
if (topReload) topReload.addEventListener('click', loadBatches)
const pb = $('batch_btnPreview'), ab = $('batch_btnApply'), db = $('batch_btnDownload')
if (pb) pb.addEventListener('click', onPreview)
if (ab) ab.addEventListener('click', onApply)
if (db) db.addEventListener('click', onDownload)
const tabBatches = $('tab-batches')
if (tabBatches) tabBatches.addEventListener('shown.bs.tab', loadBatches)
const rf = $('batch_refresh')
if (rf) rf.addEventListener('click', loadBatches)
const search = $('batch_search')
if (search) {
let _t = null
search.addEventListener('input', () => { clearTimeout(_t); _t = setTimeout(loadBatches, 280) })
}
// 独立页面:默认就初始化一次
if (mode) {
latestPreview = null
// 默认时间范围:本月 1 号起 ~ 今年 12 月 31 日止
if (!$('batch_rangeStart').value) {
const d = new Date()
const pad = (n) => String(n).padStart(2, '0')
$('batch_rangeStart').value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-01`
$('batch_rangeEnd').value = `${d.getFullYear()}-12-31`
}
}
// SQL Tab 绑定
const sqlCheck = $('sql_btnCheck')
const sqlApply = $('sql_btnApply')
const sqlDownload = $('sql_btnDownload')
if (sqlCheck) sqlCheck.addEventListener('click', sqlOnCheck)
if (sqlApply) sqlApply.addEventListener('click', sqlOnApply)
if (sqlDownload) sqlDownload.addEventListener('click', sqlOnDownload)
const dupDetailBtn = $('sql_dupDetail')
if (dupDetailBtn) dupDetailBtn.addEventListener('click', sqlOpenDuplicateModal)
const dupConfirmBtn = $('sql_dupConfirm')
if (dupConfirmBtn) dupConfirmBtn.addEventListener('click', async () => {
_sqlConfirmDup = true
await sqlReallyApply({ confirm_duplicates: true, confirm_forbidden: _sqlConfirmForbidden })
})
const dupCancelBtn = $('sql_dupCancel')
if (dupCancelBtn) dupCancelBtn.addEventListener('click', () => { toast('已取消,请回去改 SQL 再试', 'warn') })
})
// 暴露给 batch_tool.html 里 onclick 调用(loadBatches)
window.CalendarBatchTool = { loadBatches, fillForm, readForm }
})()
@@ -0,0 +1,19 @@
(function () {
document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('btnOpenBatchTool');
if (!btn) return;
btn.addEventListener('click', function (e) {
e.preventDefault();
try {
window.open(
'batch_tool.html',
'calendar_batch_' + Date.now(),
'width=1280,height=900,resizable=yes,scrollbars=yes'
);
} catch (err) {
// 兜底:普通链接也能打开
document.location.href = 'batch_tool.html';
}
});
});
})();
+154
View File
@@ -0,0 +1,154 @@
(function () {
const isHttps = location.protocol === 'https:'
const API_BASE = isHttps ? location.origin : 'http://localhost:8976'
function buildUrl(path) {
if (/^https?:/.test(path)) return path
const p = path.startsWith('/') ? path : '/' + path
return API_BASE + p
}
async function request(method, path, body, opts) {
opts = opts || {}
const url = buildUrl(path)
const init = {
method: method || 'GET',
headers: opts.headers || {},
credentials: 'same-origin'
}
if (body !== null && body !== undefined) {
if (body instanceof FormData) {
init.body = body
} else {
init.headers['Content-Type'] = 'application/json'
init.body = JSON.stringify(body)
}
}
try {
const res = await fetch(url, init)
const text = await res.text()
let data = null
try { data = text ? JSON.parse(text) : null } catch { data = { ok: res.ok, raw: text } }
if (!res.ok) {
const msg = (data && data.error) || ('HTTP ' + res.status)
const err = new Error(msg)
err.status = res.status
err.data = data
throw err
}
return { ok: true, status: res.status, data }
} catch (e) {
const err = new Error(e.message || '请求失败')
err.data = e.data || null
throw err
}
}
const hub = {
async meta() {
const res = await request('GET', '/api/mp_time_assistant/meta')
const payload = (res && res.data) || {}
return (payload && payload.ok) ? payload.data || {} : (payload || {})
},
async getState() {
const res = await request('GET', '/api/mp_time_assistant/state')
const payload = (res && res.data) || {}
return (payload && payload.ok) ? payload.data || {} : (payload || {})
},
async setActiveKit(kitId) {
const res = await request('PUT', '/api/mp_time_assistant/state/active-kit', { kitId })
const payload = (res && res.data) || {}
return (payload && payload.ok) ? payload.data || {} : (payload || {})
}
}
const personalCollect = {
async list(keyword) {
const q = new URLSearchParams()
if (keyword) q.set('keyword', keyword)
const suffix = q.toString() ? '?' + q.toString() : ''
const res = await request('GET', '/api/mp_time_assistant/kits/personal_collect/items' + suffix)
const payload = (res && res.data) || {}
if (payload && payload.ok) return payload.data || []
return Array.isArray(payload) ? payload : []
},
async get(id) {
const res = await request('GET', '/api/mp_time_assistant/kits/personal_collect/items/' + encodeURIComponent(id))
const payload = (res && res.data) || {}
return (payload && payload.ok) ? (payload.data || null) : (payload || null)
},
async create(payload) {
const res = await request('POST', '/api/mp_time_assistant/kits/personal_collect/items', payload)
const body = (res && res.data) || {}
if (body && body.ok) return body.data || body
throw new Error((body && body.error) || '创建失败')
},
async update(payload) {
const res = await request('PUT', '/api/mp_time_assistant/kits/personal_collect/items', payload)
const body = (res && res.data) || {}
if (body && body.ok) return body.data || body
throw new Error((body && body.error) || '更新失败')
},
async remove(id) {
const q = new URLSearchParams({ id })
const res = await request('DELETE', '/api/mp_time_assistant/kits/personal_collect/items?' + q.toString())
const body = (res && res.data) || {}
if (body && body.ok) return body
throw new Error((body && body.error) || '删除失败')
},
async uploadFile(file, groupId, onProgress) {
const form = new FormData()
form.append('file', file)
if (groupId) form.append('groupId', groupId)
const url = buildUrl('/api/mp_time_assistant/kits/personal_collect/upload')
return new Promise((resolve, reject) => {
try {
const xhr = new XMLHttpRequest()
xhr.open('POST', url)
xhr.withCredentials = true
if (onProgress && typeof onProgress === 'function' && xhr.upload) {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress({ loaded: e.loaded, total: e.total, percent: Math.round(e.loaded * 100 / Math.max(1, e.total)) })
}
})
}
xhr.onload = () => {
try {
const data = JSON.parse(xhr.responseText || '{}')
if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) {
resolve({ ok: true, status: xhr.status, data })
} else {
const err = new Error((data && data.error) || ('HTTP ' + xhr.status))
err.data = data
reject(err)
}
} catch (e) {
reject(new Error('响应解析失败: ' + e.message))
}
}
xhr.onerror = () => reject(new Error('网络错误:上传失败'))
xhr.ontimeout = () => reject(new Error('上传超时'))
xhr.send(form)
} catch (e) {
reject(e)
}
})
}
}
const fileUrl = (relPath) => {
if (!relPath) return ''
if (/^https?:/.test(relPath)) return relPath
const p = relPath.startsWith('/') ? relPath : '/' + relPath
return buildUrl(p)
}
window.__MPTA_API__ = {
API_BASE,
request,
hub,
personalCollect,
fileUrl
}
})()
@@ -0,0 +1,15 @@
{
"_comment": "MilesYang时光助手 Web 端(与小程序共享后端和数据库)的前端鉴权配置",
"enable_auth_comment": "true:启用 tool 外层鉴权(默认从九宫格导航进入);false:完全放开访问",
"enable_auth": true,
"max_age_days_comment": "鉴权 cookie 最大有效天数(1~365)",
"max_age_days": 30,
"cookieName_comment": "鉴权 Cookie 名称,默认格式 <tool>_gate",
"cookieName": "web_mp_time_assistant_gate",
"requireGuid_comment": "true:强制 GUID 参数才能访问;false:允许仅凭 token 或已有会话访问",
"requireGuid": false,
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
"guidParam": "guid",
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换(留空表示不启用 GUID)",
"guidValue": ""
}
+179
View File
@@ -0,0 +1,179 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: radial-gradient(1200px 800px at 10% 0%, #1a1a33 0%, #0f0f1a 60%, #0b0b15 100%);
min-height: 100vh;
color: #e8e8f0;
line-height: 1.6;
}
.app-root {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px 80px;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 48px;
padding: 24px 32px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(108, 122, 224, 0.15);
border-radius: 24px;
backdrop-filter: blur(12px);
}
.app-header-left .app-title {
font-size: 32px;
font-weight: 600;
letter-spacing: 1px;
background: linear-gradient(135deg, #6c7ae0 0%, #b06be0 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 6px;
}
.app-header-left .app-subtitle {
font-size: 14px;
color: #8a8aa8;
letter-spacing: 0.5px;
}
.app-header-right .status-tag {
padding: 8px 16px;
border-radius: 999px;
font-size: 13px;
background: rgba(108, 122, 224, 0.12);
color: #a8b4ff;
border: 1px solid rgba(108, 122, 224, 0.25);
}
.app-header-right .status-tag.ok {
background: rgba(0, 216, 167, 0.1);
color: #00d8a7;
border-color: rgba(0, 216, 167, 0.3);
}
.app-header-right .status-tag.err {
background: rgba(255, 80, 100, 0.1);
color: #ff8595;
border-color: rgba(255, 80, 100, 0.3);
}
.kit-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 24px;
}
.kit-empty {
grid-column: 1 / -1;
text-align: center;
padding: 80px 0;
color: #8a8aa8;
font-size: 15px;
}
.kit-card {
position: relative;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 22px;
padding: 28px;
cursor: pointer;
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.3s ease,
background 0.3s ease,
box-shadow 0.35s ease;
backdrop-filter: blur(8px);
overflow: hidden;
}
.kit-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(108, 122, 224, 0) 0%, rgba(176, 107, 224, 0) 100%);
transition: opacity 0.4s ease;
opacity: 0;
z-index: 0;
}
.kit-card:hover {
transform: translateY(-6px);
border-color: rgba(108, 122, 224, 0.35);
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 20px 40px rgba(108, 122, 224, 0.15);
}
.kit-card:hover::before {
opacity: 1;
background: linear-gradient(135deg, rgba(108, 122, 224, 0.08) 0%, rgba(176, 107, 224, 0.04) 100%);
}
.kit-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
background: linear-gradient(135deg, rgba(108, 122, 224, 0.22) 0%, rgba(176, 107, 224, 0.18) 100%);
border: 1px solid rgba(108, 122, 224, 0.3);
margin-bottom: 18px;
position: relative;
z-index: 1;
}
.kit-name {
font-size: 19px;
font-weight: 600;
color: #f0f0f8;
margin-bottom: 8px;
position: relative;
z-index: 1;
}
.kit-desc {
font-size: 13.5px;
color: #9b9bb5;
line-height: 1.7;
position: relative;
z-index: 1;
}
.kit-arrow {
margin-top: 18px;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #a8b4ff;
position: relative;
z-index: 1;
opacity: 0.75;
transition: gap 0.3s ease, opacity 0.3s ease;
}
.kit-card:hover .kit-arrow {
gap: 12px;
opacity: 1;
}
@media (max-width: 600px) {
.app-root { padding: 20px 16px 60px; }
.app-header {
padding: 18px 20px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.app-header-left .app-title { font-size: 24px; }
.kit-grid { grid-template-columns: 1fr; gap: 16px; }
.kit-card { padding: 22px; }
}
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MilesYang时光助手</title>
<link rel="stylesheet" href="/common/common.css">
<link rel="stylesheet" href="hub.css">
</head>
<body>
<div class="app-root">
<header class="app-header">
<div class="app-header-left">
<h1 class="app-title">MilesYang时光助手</h1>
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合</p>
</div>
<div class="app-header-right">
<span id="status-tag" class="status-tag">加载中...</span>
</div>
</header>
<main class="app-main">
<div id="kit-grid" class="kit-grid">
<div class="kit-empty">⏳ 工具列表加载中...</div>
</div>
</main>
</div>
<script src="api.js"></script>
<script src="hub.js"></script>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
(function () {
const api = window.__MPTA_API__
const $grid = document.getElementById('kit-grid')
const $status = document.getElementById('status-tag')
const KIT_PAGES = {
personal_collect: 'kits/personal_collect/list.html'
}
const KIT_ICONS = {
personal_collect: '📁'
}
function setStatus(text, cls) {
$status.textContent = text
$status.className = 'status-tag' + (cls ? ' ' + cls : '')
}
function renderKits(kits) {
if (!kits || !kits.length) {
$grid.innerHTML = '<div class="kit-empty">暂无可用工具</div>'
return
}
$grid.innerHTML = kits.map((kit) => {
const page = KIT_PAGES[kit.id] || '#'
const icon = KIT_ICONS[kit.id] || '🧰'
return (
`<div class="kit-card" data-kit="${kit.id}" data-page="${page}">` +
`<div class="kit-icon">${icon}</div>` +
`<div class="kit-name">${escapeHtml(kit.name || kit.id)}</div>` +
`<div class="kit-desc">${escapeHtml(kit.description || '')}</div>` +
`<div class="kit-arrow"><span>进入</span><span>→</span></div>` +
`</div>`
)
}).join('')
Array.from($grid.querySelectorAll('.kit-card')).forEach((card) => {
card.addEventListener('click', () => {
const page = card.getAttribute('data-page')
const kitId = card.getAttribute('data-kit')
const go = () => { window.location.href = page }
if (api && api.hub && api.hub.setActiveKit) {
api.hub.setActiveKit(kitId).then(go).catch(go)
} else {
go()
}
})
})
}
function escapeHtml(s) {
if (s === null || s === undefined) return ''
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
async function main() {
try {
if (!api || !api.hub) throw new Error('API 未加载')
const meta = await api.hub.meta()
const kits = (meta && meta.kits) || []
setStatus('后端已连通', 'ok')
renderKits(kits)
} catch (e) {
setStatus('后端连通失败:' + (e.message || '未知错误'), 'err')
$grid.innerHTML = `<div class="kit-empty">❌ 加载失败:${escapeHtml(e.message || '后端无响应')}</div>`
}
}
document.addEventListener('DOMContentLoaded', main)
})()
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MilesYang时光助手</title>
<link rel="stylesheet" href="/common/common.css">
<link rel="stylesheet" href="hub.css">
</head>
<body>
<div class="app-root">
<header class="app-header">
<div class="app-header-left">
<h1 class="app-title">MilesYang时光助手</h1>
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合</p>
</div>
<div class="app-header-right">
<span id="status-tag" class="status-tag">加载中...</span>
</div>
</header>
<main class="app-main">
<div id="kit-grid" class="kit-grid">
<div class="kit-empty">⏳ 工具列表加载中...</div>
</div>
</main>
</div>
<script src="api.js"></script>
<script src="hub.js"></script>
</body>
</html>
@@ -0,0 +1,212 @@
(function () {
const DEFAULT_UI_MODE = 'debug'
const AUTO_CLOSE_SEC_DEBUG = 3
const AUTO_CLOSE_SEC_NORMAL = 2
function readUiMode() {
try {
const v = localStorage.getItem('mp_ta_ui_mode')
if (v === 'debug' || v === 'normal') return v
} catch {}
return DEFAULT_UI_MODE
}
function setUiMode(mode) {
try { localStorage.setItem('mp_ta_ui_mode', mode) } catch {}
}
function escapeHtml(s) {
if (s === null || s === undefined) return ''
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
function showToast(text, type, duration) {
type = type || 'info'
duration = duration || (type === 'error' ? 3000 : 2000)
const colors = {
info: { bg: 'rgba(56, 103, 214, 0.92)', border: 'rgba(108, 122, 224, 0.6)', color: '#fff' },
success: { bg: 'rgba(0, 180, 140, 0.92)', border: 'rgba(0, 216, 167, 0.6)', color: '#fff' },
error: { bg: 'rgba(220, 53, 69, 0.92)', border: 'rgba(255, 80, 100, 0.6)', color: '#fff' },
warn: { bg: 'rgba(230, 152, 20, 0.92)', border: 'rgba(255, 193, 7, 0.6)', color: '#fff' }
}
const c = colors[type] || colors.info
const t = document.createElement('div')
t.textContent = text
Object.assign(t.style, {
position: 'fixed', zIndex: 99998, top: '28px', left: '50%', transform: 'translateX(-50%)',
padding: '10px 18px', borderRadius: '12px', fontSize: '14px', fontWeight: 500,
maxWidth: '90vw', textAlign: 'center', backdropFilter: 'blur(8px)',
background: c.bg, border: '1px solid ' + c.border, color: c.color,
boxShadow: '0 10px 30px rgba(0,0,0,0.35)'
})
document.body.appendChild(t)
setTimeout(() => {
t.style.transition = 'opacity 0.3s ease, transform 0.3s ease'
t.style.opacity = '0'
t.style.transform = 'translateX(-50%) translateY(-10px)'
setTimeout(() => t.remove(), 320)
}, duration)
}
function confirmDialog(options, onOk, onCancel) {
options = options || {}
const title = options.title || '请确认操作'
const desc = options.desc || ''
const okText = options.okText || '确认'
const cancelText = options.cancelText || '取消'
const danger = options.danger === true
const slot = document.getElementById('modal-slot') || document.body
const mask = document.createElement('div')
mask.className = 'modal-mask'
mask.innerHTML =
'<div class="modal-card">' +
'<div class="modal-title' + (danger ? ' danger' : '') + '">' + escapeHtml(title) + '</div>' +
'<div class="modal-desc">' + escapeHtml(desc) + '</div>' +
'<div class="modal-actions">' +
'<button type="button" class="btn secondary btn-sm" data-act="cancel">' + escapeHtml(cancelText) + '</button>' +
'<button type="button" class="btn ' + (danger ? 'danger' : 'primary') + ' btn-sm" data-act="ok">' + escapeHtml(okText) + '</button>' +
'</div>' +
'</div>'
slot.appendChild(mask)
const close = (result) => {
mask.remove()
if (result && typeof onOk === 'function') onOk()
if (!result && typeof onCancel === 'function') onCancel()
}
mask.addEventListener('click', (e) => {
if (e.target === mask) close(false)
const act = e.target.getAttribute && e.target.getAttribute('data-act')
if (act === 'ok') close(true)
if (act === 'cancel') close(false)
})
const okBtn = mask.querySelector('[data-act="ok"]')
if (okBtn) okBtn.focus()
return { close }
}
function runTrackedInteraction(options, runner) {
const uiMode = readUiMode()
options = options || {}
const name = options.name || '操作'
const toastOnSuccess = options.toastOnSuccess !== false
const normalViewAsToast = options.normalViewAsToast !== false
const debugMode = uiMode === 'debug'
const dlg = document.getElementById('interaction-dialog') || (() => {
const d = document.createElement('div')
d.id = 'interaction-dialog'
d.className = 'interaction-dialog hidden'
document.body.appendChild(d)
return d
})()
const startedAt = Date.now()
const logs = []
let timer = null
let forceStay = false
const fmtTime = (ts) => {
const d = new Date(ts)
const p = (n) => (n < 10 ? '0' + n : '' + n)
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
const push = (line) => logs.push(`[${fmtTime(Date.now())}] ${line}`)
const render = () => {
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1)
dlg.innerHTML =
'<div class="id-title">' +
'<span>' + escapeHtml(name) + ' · 交互详情</span>' +
'<span style="cursor:pointer;color:#8a8aa8;" data-act="copy">复制</span>' +
'</div>' +
'<div class="id-body">' + escapeHtml(logs.join('\n') + (timer ? `\n\n倒计时关闭:${remainSec}s` : '') + `\n\n耗时:${elapsedSec}s`) + '</div>' +
'<div class="id-actions">' +
'<span class="id-countdown">' + (timer ? `自动关闭中:${remainSec}s` : (forceStay ? '已停留,有错误/异常' : '已完成')) + '</span>' +
'<div style="display:flex;gap:8px;">' +
(timer ? '<button type="button" class="btn ghost btn-sm" data-act="stay">停留</button>' : '') +
'<button type="button" class="btn secondary btn-sm" data-act="close">关闭</button>' +
'</div>' +
'</div>'
const btnCopy = dlg.querySelector('[data-act="copy"]')
if (btnCopy) btnCopy.onclick = () => {
try {
const txt = logs.join('\n') + `\n\n耗时:${elapsedSec}s`
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(() => showToast('日志已复制', 'success', 1200))
} else {
const ta = document.createElement('textarea')
ta.value = txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove()
showToast('日志已复制', 'success', 1200)
}
} catch (e) { showToast('复制失败:' + e.message, 'error', 2000) }
}
const btnStay = dlg.querySelector('[data-act="stay"]')
if (btnStay) btnStay.onclick = () => { if (timer) { clearInterval(timer); timer = null; forceStay = true; render() } }
const btnClose = dlg.querySelector('[data-act="close"]')
if (btnClose) btnClose.onclick = () => { dlg.classList.add('hidden'); if (timer) clearInterval(timer); timer = null }
}
const showDlg = () => { dlg.classList.remove('hidden'); render() }
const closeDlg = () => { dlg.classList.add('hidden'); if (timer) { clearInterval(timer); timer = null } }
let remainSec = debugMode ? AUTO_CLOSE_SEC_DEBUG : AUTO_CLOSE_SEC_NORMAL
const startCountdown = () => {
if (forceStay) return
timer = setInterval(() => {
remainSec -= 1
if (remainSec <= 0) { closeDlg() }
else { render() }
}, 1000)
}
push(`开始:${name}`)
push(`HTTP: ${options.method || 'GET'} ${options.path || options.url || '(本地)'}${options.params ? `\n参数:${JSON.stringify(options.params)}` : ''}`)
showDlg()
const runnerPromise = typeof runner === 'function' ? runner() : Promise.resolve(runner || { ok: true })
return Promise.resolve(runnerPromise).then((res) => {
push(`返回:${JSON.stringify(res, null, 2).slice(0, 2000)}`)
push(`状态:成功 ✔ 耗时 ${((Date.now()-startedAt)/1000).toFixed(2)}s`)
render()
if (debugMode) { startCountdown() }
else if (normalViewAsToast && toastOnSuccess) { setTimeout(() => closeDlg(), 120); showToast(`${name}成功`, 'success') }
else { startCountdown() }
return res
}).catch((e) => {
forceStay = true
push(`错误:${e && e.message ? e.message : String(e)}`)
push(`状态:失败 ✘ 耗时 ${((Date.now()-startedAt)/1000).toFixed(2)}s`)
render()
showToast(`${name}失败:${(e && e.message) ? e.message : '未知错误'}`, 'error', 3500)
throw e
})
}
function formatTime(iso) {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return String(iso)
const p = (n) => (n < 10 ? '0' + n : '' + n)
const now = new Date()
const sameYear = d.getFullYear() === now.getFullYear()
const y = sameYear ? '' : d.getFullYear() + '-'
return `${y}${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function humanSize(bytes) {
const n = Number(bytes || 0)
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / 1024 / 1024).toFixed(2) + ' MB'
}
window.__MPTA_DIALOG__ = {
readUiMode, setUiMode,
escapeHtml, showToast, confirmDialog, runTrackedInteraction,
formatTime, humanSize,
AUTO_CLOSE_SEC_DEBUG, AUTO_CLOSE_SEC_NORMAL
}
})()
@@ -0,0 +1,209 @@
.detail-section {
margin-bottom: 28px;
}
.detail-section-title {
font-size: 13px;
font-weight: 600;
color: #8a8aa8;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 10px;
}
.detail-title {
font-size: 26px;
font-weight: 700;
line-height: 1.5;
color: #f4f4fa;
letter-spacing: 0.2px;
}
.detail-title.empty {
color: #6a6a88;
font-weight: 500;
font-size: 20px;
font-style: italic;
}
.detail-meta {
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: 13px;
color: #8a8aa8;
margin-top: 14px;
}
.detail-meta span b {
color: #c8c8e0;
font-weight: 500;
margin-right: 6px;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.detail-remark {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
padding: 24px;
color: #dcdceb;
font-size: 15px;
line-height: 1.9;
white-space: pre-wrap;
word-break: break-word;
}
.detail-remark.empty {
color: #6a6a88;
text-align: center;
font-style: italic;
padding: 40px 24px;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
}
.image-item {
position: relative;
aspect-ratio: 1 / 1;
border-radius: 14px;
overflow: hidden;
cursor: zoom-in;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: transform 0.25s ease, border-color 0.25s ease;
}
.image-item:hover { transform: translateY(-2px); border-color: rgba(108, 122, 224, 0.4); }
.image-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
.image-item .name-badge {
position: absolute;
left: 8px; bottom: 8px;
max-width: calc(100% - 16px);
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
backdrop-filter: blur(4px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-list {
display: grid;
gap: 10px;
}
.file-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
background: rgba(108, 122, 224, 0.06);
border: 1px solid rgba(108, 122, 224, 0.16);
border-radius: 14px;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
cursor: pointer;
}
.file-item:hover {
background: rgba(108, 122, 224, 0.12);
border-color: rgba(108, 122, 224, 0.32);
transform: translateY(-1px);
}
.file-icon {
width: 44px; height: 44px;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
background: rgba(108, 122, 224, 0.18);
flex-shrink: 0;
font-size: 22px;
}
.file-info { flex: 1; min-width: 0; }
.file-name {
font-size: 14.5px;
font-weight: 500;
color: #e8e8f4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta { font-size: 12px; color: #8a8aa8; margin-top: 4px; }
.file-download {
padding: 8px 12px;
border-radius: 10px;
background: rgba(108, 122, 224, 0.2);
color: #a8b4ff;
font-size: 12px;
font-weight: 600;
border: 1px solid rgba(108, 122, 224, 0.3);
flex-shrink: 0;
}
.file-download:hover { background: rgba(108, 122, 224, 0.3); }
.lightbox-mask {
position: fixed; inset: 0; z-index: 99999;
background: rgba(0, 0, 0, 0.88);
backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
padding: 24px;
}
.lightbox-mask.hidden { display: none; }
.lightbox-inner {
position: relative;
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
.lightbox-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 10px;
box-shadow: 0 30px 100px rgba(0,0,0,0.6);
}
.lightbox-close, .lightbox-nav {
position: absolute;
width: 44px; height: 44px;
border-radius: 50%;
background: rgba(255,255,255,0.08);
color: #fff;
font-size: 20px;
display: flex; align-items: center; justify-content: center;
border: 1px solid rgba(255,255,255,0.16);
cursor: pointer;
backdrop-filter: blur(6px);
transition: background 0.2s ease, transform 0.2s ease;
}
.lightbox-close:hover, .lightbox-nav:hover { background: rgba(255,255,255,0.18); transform: scale(1.06); }
.lightbox-close { top: 16px; right: 16px; }
.lightbox-nav { top: 50%; transform: translateY(-50%); }
.lightbox-nav:hover { transform: translateY(-50%) scale(1.06); }
.lightbox-nav.prev { left: 16px; }
.lightbox-nav.next { right: 16px; }
.lightbox-caption {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
max-width: 80vw;
padding: 8px 18px;
border-radius: 999px;
background: rgba(0,0,0,0.65);
color: #fff;
font-size: 13px;
backdrop-filter: blur(6px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 600px) {
.detail-title { font-size: 20px; }
.detail-remark { padding: 18px; font-size: 14.5px; }
.image-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.lightbox-close, .lightbox-nav { width: 40px; height: 40px; font-size: 18px; }
}
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>收藏详情 | MilesYang时光助手</title>
<link rel="stylesheet" href="/common/common.css">
<link rel="stylesheet" href="../../shared.css">
<link rel="stylesheet" href="detail.css">
</head>
<body>
<div class="app-shell">
<header class="app-topbar">
<div class="app-topbar-left">
<button class="back-btn" id="btn-back" title="返回列表">←</button>
<div>
<div class="page-title" id="page-title">收藏详情</div>
<div class="page-subtitle" id="page-subtitle">加载中...</div>
</div>
</div>
<div class="app-topbar-right">
<button class="btn secondary btn-sm" id="btn-refresh">🔄 刷新</button>
<button class="btn primary btn-sm" id="btn-edit">编辑</button>
<button class="btn danger btn-sm" id="btn-delete">删除</button>
</div>
</header>
<main id="detail-wrap">
<div class="empty-state"><div class="empty-state-icon">⏳</div><div class="empty-state-text">加载中...</div></div>
</main>
</div>
<div id="lightbox-mask" class="lightbox-mask hidden">
<div class="lightbox-inner">
<button class="lightbox-close" id="lightbox-close">✕</button>
<button class="lightbox-nav prev" id="lightbox-prev">‹</button>
<img id="lightbox-img" class="lightbox-img" alt="">
<button class="lightbox-nav next" id="lightbox-next">›</button>
<div class="lightbox-caption" id="lightbox-caption"></div>
</div>
</div>
<div id="modal-slot"></div>
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
<script src="../../api.js"></script>
<script src="../../kits/common/dialog.js"></script>
<script src="detail.js"></script>
</body>
</html>
@@ -0,0 +1,253 @@
(function () {
const api = window.__MPTA_API__
const dlg = window.__MPTA_DIALOG__
const $wrap = document.getElementById('detail-wrap')
const $title = document.getElementById('page-title')
const $subtitle = document.getElementById('page-subtitle')
const $back = document.getElementById('btn-back')
const $refresh = document.getElementById('btn-refresh')
const $edit = document.getElementById('btn-edit')
const $delete = document.getElementById('btn-delete')
const $lbMask = document.getElementById('lightbox-mask')
const $lbImg = document.getElementById('lightbox-img')
const $lbCap = document.getElementById('lightbox-caption')
const $lbClose = document.getElementById('lightbox-close')
const $lbPrev = document.getElementById('lightbox-prev')
const $lbNext = document.getElementById('lightbox-next')
const escapeHtml = dlg.escapeHtml
const formatTime = dlg.formatTime
const humanSize = dlg.humanSize
const q = new URLSearchParams(location.search)
const id = q.get('id')
let current = null
let imageList = []
let lbIdx = -1
function fileIcon(fileName) {
const ext = (fileName || '').split('.').pop().toLowerCase()
if (/png|jpe?g|gif|webp|bmp|svg|ico/.test(ext)) return '🖼️'
if (/pdf/.test(ext)) return '📕'
if (/docx?|docm|dotx?/.test(ext)) return '📘'
if (/xlsx?|xlsm|xls/.test(ext)) return '📗'
if (/pptx?|ppsm/.test(ext)) return '📙'
if (/txt|md|log|csv/.test(ext)) return '📝'
if (/zip|rar|7z|tar|gz/.test(ext)) return '🗜️'
if (/mp3|wav|flac|m4a|aac/.test(ext)) return '🎵'
if (/mp4|mov|avi|mkv|webm/.test(ext)) return '🎥'
return '📄'
}
function goBack() { window.location.href = 'list.html' }
function goEdit() { if (id) window.location.href = 'edit.html?id=' + encodeURIComponent(id) }
function openLightbox(idx) {
if (!imageList.length) return
lbIdx = Math.max(0, Math.min(imageList.length - 1, idx | 0))
const item = imageList[lbIdx]
$lbImg.src = item.src
$lbCap.textContent = item.name || ''
$lbMask.classList.remove('hidden')
}
function closeLightbox() { $lbMask.classList.add('hidden'); try { $lbImg.src = '' } catch {} lbIdx = -1 }
function navLightbox(delta) {
if (!imageList.length) return
lbIdx = (lbIdx + delta + imageList.length) % imageList.length
const item = imageList[lbIdx]
$lbImg.src = item.src
$lbCap.textContent = item.name || ''
}
$lbClose.addEventListener('click', closeLightbox)
$lbPrev.addEventListener('click', () => navLightbox(-1))
$lbNext.addEventListener('click', () => navLightbox(1))
$lbMask.addEventListener('click', (e) => { if (e.target === $lbMask) closeLightbox() })
document.addEventListener('keydown', (e) => {
if ($lbMask.classList.contains('hidden')) return
if (e.key === 'Escape') closeLightbox()
else if (e.key === 'ArrowLeft') navLightbox(-1)
else if (e.key === 'ArrowRight') navLightbox(1)
})
function doDelete() {
if (!id || !current) return
const title = (current.title || '').trim() || '这条无标题收藏'
dlg.confirmDialog({
title: '确认删除?',
desc: `「${title}」\n\n删除后此条收藏将不再显示(软删除,后端保留记录可追溯),同时手机端也会消失。`,
okText: '确认删除', cancelText: '取消', danger: true
}, () => {
dlg.runTrackedInteraction({
name: '删除收藏',
method: 'DELETE',
path: '/api/mp_time_assistant/kits/personal_collect/items',
params: { id }
}, () => api.personalCollect.remove(id)).then(() => {
dlg.showToast('删除成功', 'success', 1400)
setTimeout(goBack, 600)
}).catch(() => {})
})
}
function renderImageSection(images) {
if (!images || !images.length) return ''
imageList = images.map(a => {
const filePath = a.filePath || a.file_path
return {
src: a.url || api.fileUrl(filePath),
name: a.fileName || a.file_name || ''
}
})
return (
`<section class="detail-section">` +
`<div class="detail-section-title">图片</div>` +
`<div class="image-grid">` +
images.map((a, i) => {
const filePath = a.filePath || a.file_path
const src = a.url || api.fileUrl(filePath)
const name = a.fileName || a.file_name || ('image_' + (i+1))
const badge = (name && name.length > 4)
? `<div class="name-badge">${escapeHtml(name)}</div>` : ''
return `<div class="image-item" data-img-idx="${i}" title="${escapeHtml(name)}"><img src="${src}" alt="${escapeHtml(name)}" loading="lazy">${badge}</div>`
}).join('') +
`</div>` +
`</section>`
)
}
function renderFileSection(files) {
if (!files || !files.length) return ''
return (
`<section class="detail-section">` +
`<div class="detail-section-title">文件 (${files.length})</div>` +
`<div class="file-list">` +
files.map(a => {
const filePath = a.filePath || a.file_path
const src = a.url || api.fileUrl(filePath)
const name = a.fileName || a.file_name || '未命名文件'
const size = Number(a.fileSize || a.file_size || 0)
const mimeType = a.mimeType || a.mime_type || ''
return (
`<div class="file-item" data-file="${escapeHtml(src)}" data-name="${escapeHtml(name)}">` +
`<div class="file-icon">${fileIcon(name)}</div>` +
`<div class="file-info">` +
`<div class="file-name">${escapeHtml(name)}</div>` +
`<div class="file-meta">${humanSize(size)}${mimeType ? ' · ' + escapeHtml(mimeType) : ''}</div>` +
`</div>` +
`<button type="button" class="file-download">下载</button>` +
`</div>`
)
}).join('') +
`</div>` +
`</section>`
)
}
function render(item) {
current = item
const dispTitle = (item.title || '').trim()
$title.textContent = dispTitle ? dispTitle : '(无标题收藏)'
const updatedAt = item.updatedAt || item.updated_at || item.createdAt || item.created_at
const createdAt = item.createdAt || item.created_at
$subtitle.textContent = `ID ${(item.id || '').slice(0, 8)}... · 更新于 ${formatTime(updatedAt)}`
const images = (item.attachments || []).filter(a => (typeof a.isImage !== 'undefined') ? !!a.isImage : !!a.is_image)
const files = (item.attachments || []).filter(a => (typeof a.isImage !== 'undefined') ? !a.isImage : !a.is_image)
const tags = (item.tags && item.tags.length)
? `<div class="detail-tags">${item.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>`
: ''
const remarkBlock = (item.remark || '').trim()
? `<section class="detail-section"><div class="detail-section-title">备注</div><div class="detail-remark">${escapeHtml(item.remark)}</div></section>`
: `<section class="detail-section"><div class="detail-section-title">备注</div><div class="detail-remark empty">(无备注)</div></section>`
const titleBlock = dispTitle
? `<section class="detail-section"><div class="detail-title">${escapeHtml(dispTitle)}</div></section>`
: `<section class="detail-section"><div class="detail-title empty">(该条收藏未设置标题)</div></section>`
const metaBlock =
`<section class="detail-section">` +
`<div class="detail-meta">` +
`<span><b>创建时间</b>${formatTime(createdAt)}</span>` +
`<span><b>更新时间</b>${formatTime(updatedAt)}</span>` +
(item.attachments ? `<span><b>附件总数</b>${item.attachments.length}</span>` : '') +
`</div>` +
(tags ? `<div style="margin-top:14px;">${tags}</div>` : '') +
`</section>`
$wrap.innerHTML = titleBlock + metaBlock + remarkBlock + renderImageSection(images) + renderFileSection(files)
Array.from($wrap.querySelectorAll('[data-img-idx]')).forEach((el) => {
el.addEventListener('click', () => openLightbox(Number(el.getAttribute('data-img-idx')) | 0))
})
Array.from($wrap.querySelectorAll('.file-item')).forEach((el) => {
el.addEventListener('click', (e) => {
if (e.target && e.target.classList.contains('file-download')) return
const src = el.getAttribute('data-file')
const name = el.getAttribute('data-name') || ''
if (!src) return
const a = document.createElement('a')
a.href = src
if (name) a.setAttribute('download', name)
a.target = '_blank'
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
setTimeout(() => a.remove(), 0)
})
})
Array.from($wrap.querySelectorAll('.file-download')).forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation()
const item = btn.closest && btn.closest('.file-item')
if (!item) return
const src = item.getAttribute('data-file')
const name = item.getAttribute('data-name') || ''
if (!src) return
const a = document.createElement('a')
a.href = src
if (name) a.setAttribute('download', name)
a.target = '_blank'
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
setTimeout(() => a.remove(), 0)
})
})
}
async function load() {
if (!id) {
$wrap.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠️</div><div class="empty-state-text">参数错误:缺少 id</div></div>`
return
}
try {
const data = await dlg.runTrackedInteraction({
name: '加载收藏详情',
method: 'GET',
path: '/api/mp_time_assistant/kits/personal_collect/items/' + encodeURIComponent(id),
params: { id },
normalViewAsToast: false
}, () => api.personalCollect.get(id))
const item = data ? (((data && data.data) || (data && data.item)) ? (data.data || data.item) : data) : null
const itemInner = item ? ((item && item.item) || item) : null
if (!itemInner) {
$wrap.innerHTML = `<div class="empty-state"><div class="empty-state-icon">😶</div><div class="empty-state-text">未找到该条收藏</div><div class="empty-state-hint">可能已被删除,或 ID 不正确</div></div>`
return
}
render(itemInner)
} catch (e) {
$wrap.innerHTML = `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">加载失败</div><div class="empty-state-hint">${escapeHtml(e.message || '请检查鉴权与后端状态')}</div></div>`
}
}
$back.addEventListener('click', goBack)
$edit.addEventListener('click', goEdit)
$refresh.addEventListener('click', load)
$delete.addEventListener('click', doDelete)
load()
})()
@@ -0,0 +1,232 @@
.edit-card {
padding: 32px;
}
.attach-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
gap: 10px;
flex-wrap: wrap;
}
.attach-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.drop-zone {
width: 100%;
min-height: 160px;
border: 2px dashed rgba(108, 122, 224, 0.35);
border-radius: 18px;
background: rgba(108, 122, 224, 0.04);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
transition: border-color 0.25s ease, background 0.25s ease, transform 0.2s ease;
outline: none;
margin-bottom: 20px;
}
.drop-zone:hover,
.drop-zone:focus {
border-color: rgba(108, 122, 224, 0.6);
background: rgba(108, 122, 224, 0.08);
}
.drop-zone.drag {
border-color: rgba(108, 122, 224, 0.9);
background: rgba(108, 122, 224, 0.14);
transform: scale(1.005);
}
.drop-text {
text-align: center;
}
.drop-icon { font-size: 36px; opacity: 0.85; margin-bottom: 10px; }
.drop-title { font-size: 16px; font-weight: 600; color: #d8d8ee; margin-bottom: 6px; }
.drop-hint { font-size: 13px; color: #8a8aa8; line-height: 1.7; }
.drop-hint b { color: #a8b4ff; }
.text-paste-mask {
position: fixed;
inset: 0;
z-index: 9020;
background: rgba(8, 8, 18, 0.58);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
animation: fade-in 0.18s ease;
}
.text-paste-card {
width: min(620px, 100%);
background: linear-gradient(160deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03));
border: 1px solid rgba(255,255,255,0.08);
border-radius: 20px;
padding: 22px 24px 18px;
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
}
.text-paste-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.text-paste-title { font-size: 16px; font-weight: 700; color: #e8e8ff; }
.text-paste-count { font-size: 13px; color: #9aa2c8; background: rgba(108,122,224,0.14); padding: 4px 10px; border-radius: 999px; }
.text-paste-preview {
max-height: 220px;
overflow-y: auto;
padding: 14px 16px;
background: rgba(0,0,0,0.28);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 14px;
font-size: 13.5px;
line-height: 1.75;
color: #c8c8e4;
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.text-paste-actions { display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap; }
.text-paste-actions .btn { min-width: 128px; }
.attach-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.attach-list:empty { display: none; }
.attach-item {
position: relative;
background: rgba(18, 18, 31, 0.5);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 14px;
padding: 14px;
overflow: hidden;
transition: border-color 0.2s ease, background 0.2s ease;
}
.attach-item:hover { border-color: rgba(108, 122, 224, 0.3); background: rgba(18, 18, 31, 0.7); }
.attach-item.image .preview {
width: 100%;
aspect-ratio: 16 / 10;
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
}
.attach-item.image .preview img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.attach-item.file .preview {
width: 100%;
aspect-ratio: 16 / 10;
border-radius: 10px;
margin-bottom: 10px;
background: linear-gradient(135deg, rgba(108, 122, 224, 0.18) 0%, rgba(176, 107, 224, 0.12) 100%);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
}
.attach-item.file .preview .big { font-size: 44px; opacity: 0.9; }
.attach-item.file .preview .ext {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #d0d0f0;
font-size: 11.5px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.attach-info {
display: grid;
gap: 4px;
margin-bottom: 10px;
}
.attach-name {
font-size: 13px;
color: #e0e0f4;
line-height: 1.5;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 39px;
}
.attach-meta {
font-size: 11.5px;
color: #8a8aa8;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.attach-status {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
}
.attach-status.pending { background: rgba(255, 193, 7, 0.12); color: #ffcc66; border: 1px solid rgba(255, 193, 7, 0.3); }
.attach-status.uploading { background: rgba(108, 122, 224, 0.14); color: #a8b4ff; border: 1px solid rgba(108, 122, 224, 0.3); }
.attach-status.ok { background: rgba(0, 216, 167, 0.12); color: #00d8a7; border: 1px solid rgba(0, 216, 167, 0.3); }
.attach-status.err { background: rgba(255, 80, 100, 0.12); color: #ff8595; border: 1px solid rgba(255, 80, 100, 0.3); }
.attach-progress {
width: 100%;
height: 4px;
border-radius: 999px;
background: rgba(255,255,255,0.06);
overflow: hidden;
margin: 8px 0 10px;
display: none;
}
.attach-progress.show { display: block; }
.attach-progress-bar {
height: 100%;
background: linear-gradient(90deg, #6c7ae0, #b06be0);
border-radius: 999px;
width: 0;
transition: width 0.2s ease;
}
.attach-actions-row {
display: flex;
gap: 6px;
justify-content: flex-end;
flex-wrap: wrap;
}
.attach-actions-row button {
padding: 6px 10px;
font-size: 12px;
border-radius: 8px;
background: rgba(255,255,255,0.05);
color: #c0c0d8;
border: 1px solid rgba(255,255,255,0.08);
}
.attach-actions-row button:hover {
background: rgba(255,255,255,0.09);
color: #fff;
}
.attach-actions-row button.danger {
background: rgba(255, 80, 100, 0.08);
color: #ff8595;
border-color: rgba(255, 80, 100, 0.22);
}
.attach-actions-row button.danger:hover { background: rgba(255, 80, 100, 0.16); }
.attach-actions-row button:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 600px) {
.edit-card { padding: 18px; }
.attach-list { grid-template-columns: 1fr; }
}
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>新增 / 编辑收藏 | MilesYang时光助手</title>
<link rel="stylesheet" href="/common/common.css">
<link rel="stylesheet" href="../../shared.css">
<link rel="stylesheet" href="edit.css">
</head>
<body>
<div class="app-shell">
<header class="app-topbar">
<div class="app-topbar-left">
<button class="back-btn" id="btn-back" title="返回列表">←</button>
<div>
<div class="page-title" id="page-title">新增收藏</div>
<div class="page-subtitle">支持拖拽 / Ctrl+V 粘贴 / 选择本地文件</div>
</div>
</div>
<div class="app-topbar-right">
<button class="btn secondary btn-sm" id="btn-save" disabled>保存中...</button>
</div>
</header>
<form class="card edit-card" id="edit-form" autocomplete="off">
<div class="form-group">
<label class="form-label" for="f-title">标题 <span style="color:#6c7ae0;font-weight:400;">(未填时会根据附件自动生成)</span></label>
<input id="f-title" class="form-control" maxlength="200" placeholder="建议输入简洁标题,方便以后搜索">
</div>
<div class="form-group">
<label class="form-label" for="f-tags">标签(逗号或空格分隔)</label>
<input id="f-tags" class="form-control" maxlength="500" placeholder="如:工作, 重要, 合同">
</div>
<div class="form-group">
<label class="form-label" for="f-remark">备注文本</label>
<textarea id="f-remark" class="form-control" maxlength="4000" placeholder="可输入详细备注、说明、关键词...&#10;(未填且有附件时,会根据附件自动生成清单)"></textarea>
</div>
<div class="form-group">
<div class="attach-head">
<label class="form-label" style="margin:0;">附件(图片 / PDF / Word 等)</label>
<div class="attach-actions">
<button type="button" class="btn secondary btn-sm" id="btn-pick-file">📎 选择本地文件</button>
<button type="button" class="btn ghost btn-sm" id="btn-auto-fill">🤖 按附件自动填充</button>
</div>
</div>
<input type="file" id="file-input" multiple style="display:none" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv,.zip,.rar,.7z,.mp3,.mp4,.mov">
<div class="drop-zone" id="drop-zone" tabindex="0">
<div class="drop-text">
<div class="drop-icon">📂</div>
<div class="drop-title">拖拽文件到此处上传</div>
<div class="drop-hint">或直接 <b>Ctrl+V</b> 粘贴图片 / 截图,也可以点「选择本地文件」</div>
</div>
</div>
<div class="attach-list" id="attach-list"></div>
</div>
<div style="margin-top:32px; display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="btn secondary btn-sm" id="btn-cancel">取消</button>
<button type="submit" class="btn primary btn-sm" id="btn-save2">保存收藏</button>
</div>
</form>
</div>
<div id="modal-slot"></div>
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
<script src="../../api.js"></script>
<script src="../../kits/common/dialog.js"></script>
<script src="edit.js"></script>
</body>
</html>
@@ -0,0 +1,475 @@
(function () {
const api = window.__MPTA_API__
const dlg = window.__MPTA_DIALOG__
const $form = document.getElementById('edit-form')
const $title = document.getElementById('f-title')
const $tags = document.getElementById('f-tags')
const $remark = document.getElementById('f-remark')
const $fileInput = document.getElementById('file-input')
const $btnPick = document.getElementById('btn-pick-file')
const $btnAutoFill = document.getElementById('btn-auto-fill')
const $btnCancel = document.getElementById('btn-cancel')
const $btnSave = document.getElementById('btn-save')
const $btnSave2 = document.getElementById('btn-save2')
const $btnBack = document.getElementById('btn-back')
const $drop = document.getElementById('drop-zone')
const $attachList = document.getElementById('attach-list')
const $pageTitle = document.getElementById('page-title')
const escapeHtml = dlg.escapeHtml
const humanSize = dlg.humanSize
const q = new URLSearchParams(location.search)
const editId = q.get('id')
const isEdit = !!editId
let attachments = []
let groupId = ''
let saving = false
function fileIcon(fileName) {
const ext = (fileName || '').split('.').pop().toLowerCase()
if (/png|jpe?g|gif|webp|bmp|svg|ico/.test(ext)) return '🖼️'
if (/pdf/.test(ext)) return '📕'
if (/docx?|docm|dotx?/.test(ext)) return '📘'
if (/xlsx?|xlsm|xls/.test(ext)) return '📗'
if (/pptx?|ppsm/.test(ext)) return '📙'
if (/txt|md|log|csv/.test(ext)) return '📝'
if (/zip|rar|7z|tar|gz/.test(ext)) return '🗜️'
if (/mp3|wav|flac|m4a|aac/.test(ext)) return '🎵'
if (/mp4|mov|avi|mkv|webm/.test(ext)) return '🎥'
return '📄'
}
function isImageByFile(file) {
try {
if (file && file.type && /^image\//.test(file.type)) return true
const name = (file && (file.name || '')) || ''
return /\.(png|jpe?g|gif|webp|bmp|svg|ico)$/i.test(name)
} catch { return false }
}
function getObjectUrl(file) {
try { return URL.createObjectURL(file) } catch { return '' }
}
function parseTags(s) {
if (!s) return []
return String(s)
.split(/[,,\s]+/)
.map(x => x.trim())
.filter(Boolean)
.filter((v, i, arr) => arr.indexOf(v) === i)
}
function genGroupId() {
return 'web_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
}
function setSaving(v) {
saving = v
const flag = !!v
$btnSave.disabled = flag
$btnSave2.disabled = flag
$btnSave.textContent = flag ? '保存中...' : '保存'
}
function renderAttachments() {
if (!attachments.length) { $attachList.innerHTML = ''; return }
$attachList.innerHTML = attachments.map((a, idx) => {
const tempId = a.tempId
const isImg = !!a.isImage
const displayUrl = a.url || a.previewUrl || ''
const ext = (a.fileName || '').split('.').pop().toUpperCase()
const statusTextMap = { pending: '等待上传', uploading: '上传中', ok: '上传成功', err: '上传失败' }
const statusText = statusTextMap[a.status] || a.status
const showProgress = a.status === 'uploading'
return (
`<div class="attach-item ${isImg ? 'image' : 'file'}" data-temp="${escapeHtml(tempId)}">` +
(isImg
? `<div class="preview"><img src="${displayUrl}" alt="${escapeHtml(a.fileName || '')}" loading="lazy"></div>`
: `<div class="preview"><span class="big">${fileIcon(a.fileName)}</span><span class="ext">${ext || 'FILE'}</span></div>`) +
`<div class="attach-info">` +
`<div class="attach-name" title="${escapeHtml(a.fileName || '')}">${escapeHtml(a.fileName || '未命名')}</div>` +
`<div class="attach-meta">` +
`<span>${humanSize(a.fileSize)}</span>` +
`<span class="attach-status ${a.status}">${statusText}</span>` +
a.errorMsg ? `<span style="color:#ff8595;">${escapeHtml(a.errorMsg)}</span>` : '' +
`</div>` +
`</div>` +
`<div class="attach-progress ${showProgress ? 'show' : ''}"><div class="attach-progress-bar" style="width:${a.percent || 0}%;"></div></div>` +
`<div class="attach-actions-row">` +
(a.status === 'err'
? `<button type="button" data-act="retry" data-temp="${escapeHtml(tempId)}">🔄 重试</button>`
: '') +
`<button type="button" data-act="remove" data-temp="${escapeHtml(tempId)}" class="danger">✕ 移除</button>` +
`</div>` +
`</div>`
)
}).join('')
Array.from($attachList.querySelectorAll('button[data-act]')).forEach((btn) => {
btn.addEventListener('click', (e) => {
const act = btn.getAttribute('data-act')
const tempId = btn.getAttribute('data-temp')
if (act === 'remove') removeAttachment(tempId)
else if (act === 'retry') retryUpload(tempId)
})
})
}
function addAttachments(files) {
if (!files || !files.length) return
const pendingGroupId = groupId || genGroupId()
if (!groupId) groupId = pendingGroupId
Array.from(files).forEach((file) => {
if (!file) return
const tempId = 't_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
const isImage = isImageByFile(file)
const previewUrl = isImage ? getObjectUrl(file) : ''
attachments.push({
tempId, file, isImage,
fileName: file.name || ('未命名_' + tempId),
fileSize: Number(file.size || 0),
previewUrl,
url: '', id: '', storedName: '', filePath: '', mimeType: '',
status: 'pending', percent: 0, errorMsg: ''
})
})
renderAttachments()
autoFillFromAttachments(false)
Promise.resolve().then(() => uploadPending())
}
function removeAttachment(tempId) {
const idx = attachments.findIndex(a => a.tempId === tempId)
if (idx < 0) return
const a = attachments[idx]
if (a && a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl) } catch {} }
attachments.splice(idx, 1)
renderAttachments()
}
function retryUpload(tempId) {
const a = attachments.find(x => x.tempId === tempId)
if (!a) return
a.status = 'pending'
a.percent = 0
a.errorMsg = ''
renderAttachments()
uploadPending()
}
async function uploadPending() {
const pendings = attachments.filter(a => a.status === 'pending')
for (const a of pendings) {
if (a.status !== 'pending') continue
a.status = 'uploading'
a.percent = 0
renderAttachments()
try {
const res = await api.personalCollect.uploadFile(a.file, groupId, (prog) => {
a.percent = prog.percent || 0
const target = $attachList.querySelector(`[data-temp="${a.tempId}"]`)
if (target) {
const bar = target.querySelector('.attach-progress-bar')
if (bar) bar.style.width = a.percent + '%'
const statusEl = target.querySelector('.attach-status')
if (statusEl) statusEl.textContent = `上传中 ${a.percent}%`
}
})
const data = res && res.data ? res.data : (res || {})
const inner = data.data ? data.data : data
a.id = inner.id || data.id || a.tempId
a.storedName = inner.storedName || data.storedName || inner.stored_name || a.fileName
a.filePath = inner.filePath || data.filePath || inner.file_path || ''
a.mimeType = inner.mimeType || data.mimeType || inner.mime_type || (a.file && a.file.type) || ''
a.url = inner.url || data.url || (a.filePath ? api.fileUrl(a.filePath) : a.previewUrl) || ''
if (typeof inner.isImage !== 'undefined') a.isImage = !!inner.isImage
else if (typeof data.isImage !== 'undefined') a.isImage = !!data.isImage
else if (typeof inner.is_image !== 'undefined') a.isImage = !!inner.is_image
a.status = 'ok'
a.percent = 100
} catch (e) {
a.status = 'err'
a.errorMsg = (e && e.message) ? e.message : '上传失败'
}
renderAttachments()
}
}
function buildAutoText(list) {
const atts = list || attachments.filter(a => a && (a.status === 'ok' || a.fileName))
if (!atts.length) return { title: '', remark: '' }
const images = atts.filter(a => a.isImage)
const files = atts.filter(a => !a.isImage)
const ic = images.length, fc = files.length, total = atts.length
const pad = (n) => (n < 10 ? '0' + n : '' + n)
const now = new Date()
const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
let title = ''
if (total === 1) {
const only = atts[0]
const raw = only.fileName || '附件'
const dot = raw.lastIndexOf('.')
const stem = dot > 0 ? raw.slice(0, dot) : raw
if (only.isImage) title = `图片 - ${ts}`
else title = stem || raw
} else if (ic > 0 && fc === 0) title = `${ic} 张图片 - ${ts}`
else if (fc > 0 && ic === 0) {
if (fc === 1) {
const only = files[0]; const raw = only.fileName || '附件'
const dot = raw.lastIndexOf('.'); const stem = dot > 0 ? raw.slice(0, dot) : raw
title = stem || raw
} else title = `${fc} 个文件 - ${ts}`
} else title = `${ic} 图 + ${fc} 文件 - ${ts}`
const lines = [`附件数量:${total}`]
if (ic) lines.push(`图片:${ic} 张`)
if (fc) lines.push(`文件:${fc} 个`)
lines.push('', '附件清单:')
atts.slice(0, 9).forEach((a, i) => lines.push(` ${i+1}. ${a.fileName || '未命名'}(${humanSize(a.fileSize)})`))
if (atts.length > 9) lines.push(` ... 另有 ${atts.length - 9} 个附件未列出`)
lines.push('', `整理时间:${ts}`)
return { title, remark: lines.join('\n') }
}
function autoFillFromAttachments(force) {
if (isEdit && !force) return
const uploadeds = attachments.filter(a => a && (a.status === 'ok' || a.status === 'pending' || a.status === 'uploading' || a.fileName))
if (!uploadeds.length) return
const hasTitle = !!($title.value || '').trim()
const hasRemark = !!($remark.value || '').trim()
const { title, remark } = buildAutoText(uploadeds)
if (force || !hasTitle) $title.value = title || ''
if (force || !hasRemark) $remark.value = remark || ''
}
$btnAutoFill.addEventListener('click', () => {
autoFillFromAttachments(true)
dlg.showToast('已按附件自动填充', 'success', 1400)
})
function bindDropPaste() {
$drop.addEventListener('dragenter', (e) => { e.preventDefault(); $drop.classList.add('drag') })
$drop.addEventListener('dragover', (e) => { e.preventDefault(); $drop.classList.add('drag') })
$drop.addEventListener('dragleave', (e) => { e.preventDefault(); if (e.target === $drop) $drop.classList.remove('drag') })
$drop.addEventListener('drop', (e) => {
e.preventDefault(); $drop.classList.remove('drag')
const list = (e.dataTransfer && e.dataTransfer.files) ? Array.from(e.dataTransfer.files) : []
if (list.length) addAttachments(list)
})
document.addEventListener('paste', (e) => {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return
if (!e.clipboardData) return
const items = Array.from(e.clipboardData.items || [])
const files = items
.filter(it => it.kind === 'file')
.map(it => it.getAsFile())
.filter(Boolean)
if (files.length) { addAttachments(files); return }
const textItem = items.find(it => it.kind === 'string' && it.type === 'text/plain')
if (textItem) {
textItem.getAsString((raw) => {
const text = (raw || '').trim()
if (text.length < 3 || text.length > 50000) return
openTextPasteDialog(text)
})
}
})
}
function openTextPasteDialog(text) {
const count = text.length
const preview = text.length > 900 ? (text.slice(0, 900) + '\n\n……(已截断预览,共 ' + count + ' 字)') : text
const mask = document.createElement('div')
mask.className = 'text-paste-mask'
mask.innerHTML =
`<div class="text-paste-card">
<div class="text-paste-head">
<div class="text-paste-title">📝 检测到剪贴板文字</div>
<div class="text-paste-count">${count} 字 · ${text.split('\n').length} 行</div>
</div>
<div class="text-paste-preview">${escapeHtml(preview)}</div>
<div class="text-paste-actions">
<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>
<button type="button" class="btn btn-secondary" data-act="replace">另起一条(清空附件)</button>
<button type="button" class="btn btn-primary" data-act="append">追加到当前备注</button>
</div>
</div>`
document.body.appendChild(mask)
const close = () => { try { document.body.removeChild(mask) } catch {} }
mask.addEventListener('click', (ev) => {
if (ev.target === mask) { close(); return }
const btn = ev.target.closest('button[data-act]')
if (!btn) return
const act = btn.getAttribute('data-act')
if (act === 'cancel') { close(); return }
if (act === 'append') {
const cur = ($remark.value || '').trim()
$remark.value = cur ? (cur + '\n\n' + text) : text
close()
dlg.showToast('已追加 ' + count + ' 字到备注', 'success', 1400)
autoFillFromAttachments(false)
return
}
if (act === 'replace') {
attachments.forEach(a => { if (a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl) } catch {} } })
attachments = []
renderAttachments()
$remark.value = text
const firstLine = text.split('\n').map(s => s.trim()).find(s => s.length) || ''
const autoTitle = (firstLine || '').slice(0, 28)
if (isEdit) {
if (!($title.value || '').trim() && autoTitle) $title.value = autoTitle
dlg.showToast('已应用到当前编辑', 'success', 1400)
} else {
$title.value = autoTitle || '文字收藏'
dlg.showToast('已清空附件并填入文字', 'success', 1400)
}
close()
return
}
})
setTimeout(() => { const first = mask.querySelector('button[data-act="append"]'); if (first) first.focus() }, 30)
}
function bindInputEvents() {
$btnPick.addEventListener('click', () => { if ($fileInput) $fileInput.click() })
$fileInput.addEventListener('change', (e) => {
const files = Array.from((e.target && e.target.files) || [])
if (files.length) addAttachments(files)
$fileInput.value = ''
})
$drop.addEventListener('click', () => { if ($fileInput) $fileInput.click() })
$drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if ($fileInput) $fileInput.click() } })
}
function buildPayload() {
const titleVal = ($title.value || '').trim()
const remarkVal = ($remark.value || '').trim()
const tagsArr = parseTags($tags.value)
const uploadeds = attachments.filter(a => a && a.status === 'ok' && a.id)
if (!titleVal && !remarkVal && !uploadeds.length) {
throw new Error('请至少输入标题/备注/上传附件三者其中之一')
}
return {
title: titleVal,
remark: remarkVal,
tags: tagsArr,
attachmentIds: uploadeds.map(a => a.id)
}
}
function goBack() { window.location.href = 'list.html' }
async function onSave(e) {
if (e) e.preventDefault()
if (saving) return
let payload
try { payload = buildPayload() }
catch (err) { dlg.showToast(err.message, 'warn', 2600); return }
const uploadingCount = attachments.filter(a => a.status === 'pending' || a.status === 'uploading').length
const failedList = attachments.filter(a => a.status === 'err')
if (uploadingCount > 0) {
dlg.confirmDialog({
title: '仍有文件上传中',
desc: `还有 ${uploadingCount} 个文件正在上传。\n现在保存的话,这些未完成的文件不会被绑定到此条收藏。\n\n建议等待上传结束后再保存。`,
okText: '仍然保存', cancelText: '等待上传', danger: false
}, () => doSave(payload))
return
}
if (failedList.length > 0) {
dlg.confirmDialog({
title: `${failedList.length} 个文件上传失败`,
desc: `以下文件上传失败,保存后这些文件不会被绑定:\n\n${failedList.map(a => '· ' + (a.fileName || '未命名')).join('\n')}\n\n你可以单独重试这些附件,或直接保存已成功的内容。`,
okText: '忽略失败,只保存成功的', cancelText: '回去修', danger: true
}, () => doSave(payload))
return
}
doSave(payload)
}
async function doSave(payload) {
const opName = isEdit ? '更新收藏' : '新增收藏'
try {
setSaving(true)
const saved = await dlg.runTrackedInteraction({
name: opName,
method: isEdit ? 'PUT' : 'POST',
path: '/api/mp_time_assistant/kits/personal_collect/items',
params: isEdit ? Object.assign({ id: editId }, payload) : payload
}, () => {
if (isEdit) return api.personalCollect.update(Object.assign({ id: editId }, payload))
return api.personalCollect.create(payload)
})
dlg.showToast(`${opName}成功`, 'success', 1600)
setTimeout(() => {
const dataId = (saved && saved.id) || editId
if (!isEdit && dataId) window.location.href = 'detail.html?id=' + encodeURIComponent(dataId)
else goBack()
}, 500)
} catch (e) {
setSaving(false)
}
}
async function loadEditRecord() {
try {
const data = await dlg.runTrackedInteraction({
name: '加载收藏详情',
method: 'GET',
path: '/api/mp_time_assistant/kits/personal_collect/items/' + encodeURIComponent(editId),
params: { id: editId }
}, () => api.personalCollect.get(editId))
const item = data ? ((data && data.data) || data) : {}
const itemInner = (item && item.item) || item
$title.value = itemInner.title || ''
$remark.value = itemInner.remark || ''
$tags.value = (itemInner.tags && itemInner.tags.length) ? itemInner.tags.join(', ') : ''
if (itemInner.groupId) groupId = itemInner.groupId || ''
const rawAtts = itemInner.attachments || item.attachments || []
const atts = rawAtts.map((a, i) => ({
tempId: 'e_' + Date.now().toString(36) + '_' + i,
id: a.id || '',
storedName: a.storedName || a.stored_name || a.fileName || '',
filePath: a.filePath || a.file_path || '',
mimeType: a.mimeType || a.mime_type || '',
isImage: typeof a.isImage !== 'undefined' ? !!a.isImage : !!a.is_image,
fileName: a.fileName || a.file_name || '未命名',
fileSize: Number(a.fileSize || a.file_size || 0),
previewUrl: '',
url: a.url || api.fileUrl(a.filePath || a.file_path || ''),
status: 'ok', percent: 100, errorMsg: '', file: null
}))
attachments = atts
renderAttachments()
} catch (e) {
dlg.showToast('加载失败:' + (e.message || '未知错误'), 'error', 3000)
setTimeout(goBack, 1400)
}
}
function init() {
bindDropPaste()
bindInputEvents()
$btnBack.addEventListener('click', goBack)
$btnCancel.addEventListener('click', goBack)
$form.addEventListener('submit', onSave)
$btnSave.addEventListener('click', onSave)
if (isEdit) {
$pageTitle.textContent = '编辑收藏'
loadEditRecord()
}
}
document.addEventListener('DOMContentLoaded', init)
window.addEventListener('beforeunload', () => {
attachments.forEach(a => { if (a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl) } catch {} } })
})
})()
@@ -0,0 +1,124 @@
.list-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 18px;
}
.fav-card {
position: relative;
cursor: pointer;
overflow: hidden;
}
.fav-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.fav-card-title {
font-size: 16.5px;
font-weight: 600;
color: #f0f0f8;
line-height: 1.5;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fav-card-time {
font-size: 12px;
color: #8a8aa8;
white-space: nowrap;
flex-shrink: 0;
padding-top: 2px;
}
.fav-card-remark {
color: #b8b8d0;
font-size: 13.5px;
line-height: 1.7;
margin-bottom: 14px;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: pre-wrap;
word-break: break-word;
}
.fav-card-tags {
margin-bottom: 14px;
min-height: 24px;
}
.fav-card-preview {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 14px;
}
.fav-card-preview:empty { display: none; margin: 0; }
.fav-card-preview .thumb {
position: relative;
aspect-ratio: 1 / 1;
border-radius: 10px;
overflow: hidden;
background: rgba(0,0,0,0.25);
border: 1px solid rgba(255,255,255,0.06);
}
.fav-card-preview .thumb img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.fav-card-preview .thumb.file {
display: flex; align-items: center; justify-content: center;
flex-direction: column; font-size: 12px; color: #a8b4ff;
padding: 6px; text-align: center; gap: 4px;
}
.fav-card-preview .thumb.file .icon { font-size: 20px; opacity: 0.8; }
.fav-card-preview .thumb.file .name {
font-size: 11px; color: #b8b8d0; line-height: 1.3;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
word-break: break-all;
}
.fav-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.fav-card-count {
font-size: 12px;
color: #8a8aa8;
}
.fav-card-actions {
display: flex; align-items: center; gap: 6px;
}
.btn-action {
padding: 7px 12px;
font-size: 12.5px;
border-radius: 10px;
background: rgba(108, 122, 224, 0.1);
color: #a8b4ff;
border: 1px solid rgba(108, 122, 224, 0.2);
}
.btn-action:hover { background: rgba(108, 122, 224, 0.2); }
.btn-action.danger {
background: rgba(255, 80, 100, 0.08);
color: #ff8595;
border-color: rgba(255, 80, 100, 0.2);
}
.btn-action.danger:hover { background: rgba(255, 80, 100, 0.16); }
.fav-empty {
grid-column: 1 / -1;
}
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微信收藏夹 | MilesYang时光助手</title>
<link rel="stylesheet" href="/common/common.css">
<link rel="stylesheet" href="../../shared.css">
<link rel="stylesheet" href="list.css">
</head>
<body>
<div class="app-shell">
<header class="app-topbar">
<div class="app-topbar-left">
<button class="back-btn" id="btn-back" title="返回首页">←</button>
<div>
<div class="page-title">📁 微信收藏夹</div>
<div class="page-subtitle">聊天附件 / 图片 / 文件,手机电脑实时同步</div>
</div>
</div>
<div class="app-topbar-right">
<button class="btn secondary btn-sm" id="btn-refresh">🔄 刷新</button>
<button class="btn primary btn-sm" id="btn-add">+ 新增收藏</button>
</div>
</header>
<div class="search-bar">
<span class="search-icon">🔍</span>
<input id="search-input" type="text" placeholder="搜索标题 / 备注 / 标签...(回车搜索)">
<button class="btn ghost btn-sm" id="btn-clear-search">清空</button>
</div>
<main id="list-wrap"></main>
<button class="fab" id="fab-add" title="新增收藏">+</button>
</div>
<div id="modal-slot"></div>
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
<script src="../../api.js"></script>
<script src="../../kits/common/dialog.js"></script>
<script src="list.js"></script>
</body>
</html>
@@ -0,0 +1,194 @@
(function () {
const api = window.__MPTA_API__
const dlg = window.__MPTA_DIALOG__
const $wrap = document.getElementById('list-wrap')
const $search = document.getElementById('search-input')
const $clear = document.getElementById('btn-clear-search')
const $back = document.getElementById('btn-back')
const $refresh = document.getElementById('btn-refresh')
const $add = document.getElementById('btn-add')
const $fab = document.getElementById('fab-add')
let currentItems = []
let pendingKeyword = ''
const escapeHtml = dlg.escapeHtml
const formatTime = dlg.formatTime
const humanSize = dlg.humanSize
function goBack() { window.location.href = '../../index.html' }
function goAdd() { window.location.href = 'edit.html' }
function goEdit(id) { window.location.href = 'edit.html?id=' + encodeURIComponent(id) }
function goDetail(id) { window.location.href = 'detail.html?id=' + encodeURIComponent(id) }
function fileIcon(fileName) {
const ext = (fileName || '').split('.').pop().toLowerCase()
if (/png|jpe?g|gif|webp|bmp|svg|ico/.test(ext)) return '🖼️'
if (/pdf/.test(ext)) return '📕'
if (/docx?|docm|dotx?/.test(ext)) return '📘'
if (/xlsx?|xlsm|xls/.test(ext)) return '📗'
if (/pptx?|ppsm/.test(ext)) return '📙'
if (/txt|md|log/.test(ext)) return '📝'
if (/zip|rar|7z|tar|gz/.test(ext)) return '🗜️'
if (/mp3|wav|flac|m4a|aac/.test(ext)) return '🎵'
if (/mp4|mov|avi|mkv|webm/.test(ext)) return '🎥'
return '📄'
}
function renderPreview(attachments) {
const list = attachments || []
if (!list.length) return ''
const images = list.filter(a => a.isImage)
const files = list.filter(a => !a.isImage)
const tiles = []
images.slice(0, 4).forEach((a) => {
const url = api.fileUrl(a.url || a.filePath)
tiles.push(`<div class="thumb"><img src="${url}" alt="${escapeHtml(a.fileName || 'image')}" loading="lazy"></div>`)
})
const fileSlots = Math.max(0, 4 - tiles.length)
files.slice(0, fileSlots).forEach((a) => {
tiles.push(
`<div class="thumb file" title="${escapeHtml(a.fileName || '')}">` +
`<span class="icon">${fileIcon(a.fileName)}</span>` +
`<span class="name">${escapeHtml(a.fileName || '未命名')}</span>` +
`</div>`
)
})
if (images.length + files.length > 4) {
const extra = (images.length + files.length) - 4
tiles.push(`<div class="thumb file"><span class="icon">+${extra}</span><span class="name">更多</span></div>`)
}
return `<div class="fav-card-preview">${tiles.join('')}</div>`
}
function renderItems(items) {
if (!items || !items.length) {
$wrap.innerHTML =
`<div class="fav-empty empty-state">` +
`<div class="empty-state-icon">📂</div>` +
`<div class="empty-state-text">还没有收藏内容</div>` +
`<div class="empty-state-hint">点击右上角「新增收藏」或右下角「+」开始保存</div>` +
`</div>`
return
}
$wrap.innerHTML = `<div class="list-container">` + items.map((it) => {
const atts = it.attachments || []
const imgCount = atts.filter(a => a.isImage).length
const fileCount = atts.filter(a => !a.isImage).length
const parts = []
if (imgCount) parts.push(`${imgCount} 图`)
if (fileCount) parts.push(`${fileCount} 文件`)
const totalSize = atts.reduce((s, a) => s + Number(a.fileSize || 0), 0)
if (totalSize) parts.push(humanSize(totalSize))
const countText = parts.join(' · ')
const title = it.title ? escapeHtml(it.title) : '<span style="color:#8a8aa8;font-weight:500;">(无标题)</span>'
const tags = (it.tags && it.tags.length) ? it.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : ''
return (
`<div class="card fav-card" data-id="${escapeHtml(it.id)}">` +
`<div class="fav-card-header">` +
`<div class="fav-card-title" data-act="detail">${title}</div>` +
`<div class="fav-card-time">${formatTime(it.updatedAt || it.updated_at || it.createdAt || it.created_at)}</div>` +
`</div>` +
(it.remark ? `<div class="fav-card-remark" data-act="detail">${escapeHtml(it.remark)}</div>` : '') +
(tags ? `<div class="fav-card-tags">${tags}</div>` : '') +
renderPreview(atts) +
`<div class="fav-card-footer">` +
`<div class="fav-card-count">${countText || '纯文字笔记'}</div>` +
`<div class="fav-card-actions">` +
`<button class="btn-action" data-act="detail">查看</button>` +
`<button class="btn-action" data-act="edit">编辑</button>` +
`<button class="btn-action danger" data-act="delete">删除</button>` +
`</div>` +
`</div>` +
`</div>`
)
}).join('') + `</div>`
Array.from($wrap.querySelectorAll('.fav-card')).forEach((card) => {
const id = card.getAttribute('data-id')
card.addEventListener('click', (e) => {
const act = (e.target.getAttribute && e.target.getAttribute('data-act')) || 'detail'
if (act === 'detail') goDetail(id)
else if (act === 'edit') goEdit(id)
else if (act === 'delete') confirmDelete(id, card)
})
})
}
function findTitle(id) {
const it = currentItems.find(x => x.id === id)
if (!it) return '该条收藏'
return (it.title || '').trim() || '这条无标题收藏'
}
function confirmDelete(id, card) {
const title = findTitle(id)
dlg.confirmDialog({
title: '确认删除收藏?',
desc: `「${title}」\n\n删除后此条收藏将不再显示(软删除,后端保留记录可追溯)。\n删除后手机端同样会消失。`,
okText: '确认删除',
cancelText: '取消',
danger: true
}, () => {
dlg.runTrackedInteraction({
name: `删除收藏`,
method: 'DELETE',
path: '/api/mp_time_assistant/kits/personal_collect/items',
params: { id }
}, () => api.personalCollect.remove(id)).then((res) => {
if (card) {
card.style.transition = 'opacity 0.25s ease, transform 0.25s ease'
card.style.opacity = '0'
card.style.transform = 'translateY(-6px)'
setTimeout(() => loadList(pendingKeyword), 260)
} else {
loadList(pendingKeyword)
}
}).catch(() => {})
})
}
let searchTimer = null
function onSearchInput() {
const kw = ($search.value || '').trim()
pendingKeyword = kw
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => loadList(kw), 250)
}
function clearSearch() {
if ($search.value) { $search.value = ''; pendingKeyword = ''; loadList('') }
}
function loadList(keyword) {
pendingKeyword = keyword || ''
dlg.runTrackedInteraction({
name: keyword ? `搜索收藏(${keyword})` : '加载收藏列表',
method: 'GET',
path: '/api/mp_time_assistant/kits/personal_collect/items',
params: { keyword: keyword || '' },
normalViewAsToast: false
}, () => api.personalCollect.list(keyword)).then((items) => {
const safeItems = Array.isArray(items) ? items : []
currentItems = safeItems
renderItems(safeItems)
if (keyword) {
dlg.showToast(`搜索到 ${safeItems.length} 条`, safeItems.length ? 'success' : 'info', 1500)
}
}).catch(() => {
$wrap.innerHTML = `<div class="fav-empty empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">加载失败</div><div class="empty-state-hint">请检查后端是否已启动,并通过首页进入以完成鉴权</div></div>`
})
}
$back.addEventListener('click', goBack)
$add.addEventListener('click', goAdd)
$fab.addEventListener('click', goAdd)
$refresh.addEventListener('click', () => loadList(pendingKeyword))
$search.addEventListener('input', onSearchInput)
$search.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); pendingKeyword = ($search.value || '').trim(); loadList(pendingKeyword) } })
$clear.addEventListener('click', clearSearch)
loadList('')
})()
@@ -0,0 +1,296 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: radial-gradient(1200px 800px at 10% 0%, #1a1a33 0%, #0f0f1a 60%, #0b0b15 100%);
min-height: 100vh;
color: #e8e8f0;
line-height: 1.6;
font-size: 15px;
}
button {
font-family: inherit;
border: none;
cursor: pointer;
outline: none;
transition: all 0.2s ease;
}
input, textarea, select {
font-family: inherit;
outline: none;
}
input::placeholder, textarea::placeholder {
color: #555570;
}
.app-shell {
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px 80px;
}
.app-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 28px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(108, 122, 224, 0.15);
border-radius: 22px;
backdrop-filter: blur(12px);
margin-bottom: 28px;
}
.app-topbar-left { display: flex; align-items: center; gap: 14px; min-width: 0; }
.app-topbar-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.back-btn {
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(108, 122, 224, 0.14);
color: #a8b4ff;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(108, 122, 224, 0.25);
flex-shrink: 0;
}
.back-btn:hover { background: rgba(108, 122, 224, 0.22); transform: translateX(-2px); }
.page-title {
font-size: 22px;
font-weight: 600;
color: #f0f0f8;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.page-subtitle {
font-size: 13px;
color: #8a8aa8;
margin-top: 2px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 18px;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.3px;
}
.btn.primary {
background: linear-gradient(135deg, #6c7ae0 0%, #8a6be0 100%);
color: #fff;
border: 1px solid rgba(108, 122, 224, 0.5);
box-shadow: 0 8px 20px rgba(108, 122, 224, 0.25);
}
.btn.primary:hover { transform: translateY(-1px); box-shadow: 0 12px 26px rgba(108, 122, 224, 0.32); }
.btn.primary:disabled { opacity: 0.55; cursor: not-allowed; transform: none; box-shadow: none; }
.btn.secondary {
background: rgba(255, 255, 255, 0.05);
color: #cfcfe0;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btn.secondary:hover { background: rgba(255, 255, 255, 0.08); border-color: rgba(108, 122, 224, 0.25); }
.btn.danger {
background: rgba(255, 80, 100, 0.12);
color: #ff8595;
border: 1px solid rgba(255, 80, 100, 0.25);
}
.btn.danger:hover { background: rgba(255, 80, 100, 0.2); }
.btn.ghost {
background: transparent;
color: #9b9bb5;
padding: 8px 12px;
}
.btn.ghost:hover { color: #e8e8f0; background: rgba(255,255,255,0.04); }
.btn-sm { padding: 7px 12px; font-size: 13px; border-radius: 10px; }
.btn-lg { padding: 14px 24px; font-size: 15px; border-radius: 14px; }
.card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 22px;
padding: 24px;
backdrop-filter: blur(8px);
transition: border-color 0.3s ease;
}
.card:hover { border-color: rgba(108, 122, 224, 0.22); }
.tag {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
background: rgba(108, 122, 224, 0.12);
color: #a8b4ff;
font-size: 12px;
border: 1px solid rgba(108, 122, 224, 0.22);
margin-right: 6px;
margin-bottom: 4px;
}
.empty-state {
text-align: center;
padding: 100px 20px;
color: #8a8aa8;
}
.empty-state-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.6; }
.empty-state-text { font-size: 15px; margin-bottom: 8px; }
.empty-state-hint { font-size: 13px; opacity: 0.7; }
.search-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
margin-bottom: 20px;
transition: border-color 0.2s ease;
}
.search-bar:focus-within { border-color: rgba(108, 122, 224, 0.4); }
.search-bar .search-icon { font-size: 17px; color: #8a8aa8; flex-shrink: 0; }
.search-bar input {
flex: 1;
background: transparent;
border: none;
color: #ffffff;
font-size: 15px;
padding: 6px 0;
}
.form-group {
margin-bottom: 22px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: #c8c8e0;
margin-bottom: 10px;
letter-spacing: 0.3px;
}
.form-control {
width: 100%;
background: rgba(18, 18, 31, 0.7);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 14px;
padding: 14px 18px;
color: #ffffff !important;
font-size: 15px;
line-height: 1.6;
transition: border-color 0.2s ease, background 0.2s ease;
}
.form-control:focus {
background: rgba(18, 18, 31, 0.9);
border-color: rgba(108, 122, 224, 0.45);
}
textarea.form-control { resize: vertical; min-height: 140px; }
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 20px;
}
.modal-card {
width: 100%;
max-width: 440px;
background: linear-gradient(180deg, #1a1a2e 0%, #141425 100%);
border: 1px solid rgba(108, 122, 224, 0.2);
border-radius: 20px;
padding: 28px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #f0f0f8;
margin-bottom: 10px;
}
.modal-title.danger { color: #ff8595; }
.modal-desc { color: #a8a8c0; font-size: 14px; line-height: 1.7; margin-bottom: 22px; white-space: pre-wrap; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; }
.fab {
position: fixed;
right: 28px;
bottom: 36px;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #6c7ae0 0%, #b06be0 100%);
color: #fff;
font-size: 28px;
box-shadow: 0 14px 30px rgba(108, 122, 224, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.fab:hover { transform: translateY(-2px) scale(1.03); box-shadow: 0 18px 38px rgba(108, 122, 224, 0.5); }
.interaction-dialog {
position: fixed;
bottom: 24px;
right: 24px;
width: min(460px, calc(100vw - 48px));
max-height: 55vh;
overflow-y: auto;
background: linear-gradient(180deg, rgba(18, 18, 34, 0.98) 0%, rgba(14, 14, 28, 0.98) 100%);
border: 1px solid rgba(108, 122, 224, 0.25);
border-radius: 16px;
padding: 16px 18px;
z-index: 10000;
backdrop-filter: blur(12px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
font-size: 12.5px;
line-height: 1.6;
}
.interaction-dialog.hidden { display: none; }
.interaction-dialog .id-title {
font-size: 13px; font-weight: 600; color: #a8b4ff;
margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;
}
.interaction-dialog .id-body {
color: #b8b8d0; white-space: pre-wrap; word-break: break-all;
font-family: 'Consolas', 'SFMono-Regular', monospace;
background: rgba(0,0,0,0.2); border-radius: 8px; padding: 10px; max-height: 38vh; overflow-y: auto;
}
.interaction-dialog .id-actions {
margin-top: 10px; display: flex; justify-content: space-between; align-items: center; gap: 8px;
}
.interaction-dialog .id-countdown { color: #8a8aa8; font-size: 12px; }
@media (max-width: 600px) {
.app-shell { padding: 20px 14px 96px; }
.app-topbar { padding: 16px 18px; flex-direction: column; align-items: flex-start; }
.app-topbar-right { width: 100%; justify-content: flex-end; }
.page-title { font-size: 18px; }
.card { padding: 18px; }
.fab { right: 20px; bottom: 26px; width: 54px; height: 54px; font-size: 24px; }
}
+9 -5
View File
@@ -34,9 +34,11 @@ const ensureDb = () => {
const cols = new Set(db.prepare("PRAGMA table_info(events)").all().map(c => c.name));
if (!cols.has('content')) db.exec("ALTER TABLE events ADD COLUMN content TEXT;");
if (!cols.has('completed')) db.exec("ALTER TABLE events ADD COLUMN completed INTEGER NOT NULL DEFAULT 0;");
if (!cols.has('batch_id')) db.exec("ALTER TABLE events ADD COLUMN batch_id TEXT DEFAULT NULL;");
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_start ON events(start);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_updatedAt ON events(updatedAt);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_batch_id ON events(batch_id);`);
};
const getUUID = () => {
@@ -156,8 +158,8 @@ const handlePost = async (req, res) => {
ev.allDay = ev.allDay ? 1 : 0;
const stmt = db.prepare(`INSERT OR REPLACE INTO events
(id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted)
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted)`);
(id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted,batch_id)
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted,@batch_id)`);
stmt.run({
id: ev.id,
@@ -171,7 +173,8 @@ const handlePost = async (req, res) => {
createdAt: ev.createdAt,
updatedAt: ev.updatedAt,
completed: ev.completed,
deleted: ev.deleted
deleted: ev.deleted,
batch_id: ev.batch_id || null
});
await syncExternalUpsert(ev);
@@ -196,7 +199,7 @@ const handlePut = async (req, res) => {
const stmt = db.prepare(`UPDATE events SET
title=@title,content=@content,start=@start,end=@end,allDay=@allDay,remindAt=@remindAt,recurrence=@recurrence,
updatedAt=@updatedAt,completed=@completed,deleted=@deleted WHERE id=@id`);
updatedAt=@updatedAt,completed=@completed,deleted=@deleted,batch_id=@batch_id WHERE id=@id`);
stmt.run({
id: merged.id,
@@ -209,7 +212,8 @@ const handlePut = async (req, res) => {
recurrence: merged.recurrence,
updatedAt: merged.updatedAt,
completed: merged.completed,
deleted: merged.deleted
deleted: merged.deleted,
batch_id: merged.batch_id !== undefined ? merged.batch_id : existing.batch_id
});
await syncExternalUpsert(merged);
+920 -5
View File
@@ -92,10 +92,41 @@ const ensureDb = () => {
const cols = new Set(db.prepare("PRAGMA table_info(events)").all().map(c => c.name))
if (!cols.has('content')) db.exec("ALTER TABLE events ADD COLUMN content TEXT;")
if (!cols.has('completed')) db.exec("ALTER TABLE events ADD COLUMN completed INTEGER NOT NULL DEFAULT 0;")
if (!cols.has('batch_id')) db.exec("ALTER TABLE events ADD COLUMN batch_id TEXT DEFAULT NULL;")
// 注意:completedAt / deletedAt 历史上不强制补列,供 SQL 批次白名单通过(用户可能在 future 版本使用;避免 unknown_column 报错)
const optionalCols = [
['completedAt', 'TEXT'],
['deletedAt', 'TEXT']
]
for (const [name, type] of optionalCols) {
if (!cols.has(name)) {
try { db.exec(`ALTER TABLE events ADD COLUMN ${name} ${type};`) } catch {}
cols.add(name)
}
}
// Create indices to speed up queries
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_start ON events(start);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_updatedAt ON events(updatedAt);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_batch_id ON events(batch_id);`)
db.exec(`CREATE TABLE IF NOT EXISTS calendar_batches (
id TEXT PRIMARY KEY,
title TEXT,
params_json TEXT,
sql_preview TEXT,
generated_count INTEGER NOT NULL DEFAULT 0,
inserted_count INTEGER NOT NULL DEFAULT 0,
dedup_count INTEGER NOT NULL DEFAULT 0,
rollback_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft',
skip_external_sync INTEGER NOT NULL DEFAULT 0,
pre_existing_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT,
updated_at TEXT,
rollbacked_at TEXT
);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_batches_created ON calendar_batches(created_at);`)
}
// Helpers
@@ -225,8 +256,8 @@ const handlePost = async (req, res) => {
ev.allDay = ev.allDay ? 1 : 0
const stmt = db.prepare(`INSERT OR REPLACE INTO events
(id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted)
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted)`)
(id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted,batch_id)
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted,@batch_id)`)
stmt.run({
id: ev.id,
@@ -240,7 +271,8 @@ const handlePost = async (req, res) => {
createdAt: ev.createdAt,
updatedAt: ev.updatedAt,
completed: ev.completed,
deleted: ev.deleted
deleted: ev.deleted,
batch_id: ev.batch_id || null
})
await syncExternalUpsert(ev)
@@ -280,7 +312,7 @@ const handlePut = async (req, res) => {
const stmt = db.prepare(`UPDATE events SET
title=@title,content=@content,start=@start,end=@end,allDay=@allDay,remindAt=@remindAt,recurrence=@recurrence,
updatedAt=@updatedAt,completed=@completed,deleted=@deleted WHERE id=@id`)
updatedAt=@updatedAt,completed=@completed,deleted=@deleted,batch_id=@batch_id WHERE id=@id`)
stmt.run({
id: merged.id,
@@ -293,7 +325,8 @@ const handlePut = async (req, res) => {
recurrence: merged.recurrence,
updatedAt: merged.updatedAt,
completed: merged.completed,
deleted: merged.deleted
deleted: merged.deleted,
batch_id: merged.batch_id !== undefined ? merged.batch_id : existing.batch_id
})
await syncExternalUpsert(merged)
@@ -311,6 +344,878 @@ const handleDelete = async (req, res) => {
res.json({ ok: true })
}
const BATCH_MAX_ROWS = 500
const BATCH_MAX_MS = 60000
const pad = (n) => (n < 10 ? '0' + n : String(n))
const parseDate = (s) => {
if (!s) return null
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(s))
if (!m) return null
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
if (Number.isNaN(d.getTime())) return null
return d
}
const fmtDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const fmtDateTime = (d, hm) => {
const date = fmtDate(d)
if (!hm) return date
return `${date}T${hm}:00`
}
const weekdayCN = (w) => ['日', '一', '二', '三', '四', '五', '六'][w] || ''
const parseDomExpr = (expr) => {
if (!expr) return []
const out = new Set()
String(expr).split(/[,,\s]+/).filter(Boolean).forEach(part => {
const range = /^(\d{1,2})\s*-\s*(\d{1,2})$/.exec(part.trim())
if (range) {
const a = Number(range[1]), b = Number(range[2])
const lo = Math.min(a, b), hi = Math.max(a, b)
for (let i = lo; i <= hi; i++) out.add(i)
} else {
const n = Number(part.trim())
if (Number.isFinite(n) && n >= 1 && n <= 31) out.add(n)
}
})
return [...out].sort((a, b) => a - b)
}
const expandDays = (params) => {
const { rangeStart, rangeEnd, mode } = params || {}
const rs = parseDate(rangeStart)
const re = parseDate(rangeEnd)
if (!rs || !re) return []
if (re.getTime() < rs.getTime()) return []
const days = []
if (mode === 'daily') {
const everyN = Math.max(1, Number(params.dailyEvery) || 1)
for (let cur = new Date(rs); cur.getTime() <= re.getTime(); cur.setDate(cur.getDate() + everyN)) {
days.push(new Date(cur))
}
} else if (mode === 'weekly') {
const everyN = Math.max(1, Number(params.weeklyEvery) || 1)
const weekdays = Array.isArray(params.weekdays) ? params.weekdays.map(Number).filter(n => n >= 0 && n <= 6) : []
if (!weekdays.length) return []
const base = new Date(rs)
const cursor = new Date(base)
while (cursor.getTime() <= re.getTime()) {
const weekStart = new Date(cursor)
weekStart.setDate(weekStart.getDate() - weekStart.getDay())
for (const wd of weekdays.slice().sort((a, b) => a - b)) {
const d = new Date(weekStart)
d.setDate(d.getDate() + wd)
if (d.getTime() >= rs.getTime() && d.getTime() <= re.getTime()) days.push(d)
}
cursor.setDate(cursor.getDate() + 7 * everyN)
}
} else if (mode === 'monthlyByDay') {
const everyN = Math.max(1, Number(params.monthlyEvery) || 1)
const doms = parseDomExpr(params.monthlyDays)
if (!doms.length) return []
const ym = new Date(rs.getFullYear(), rs.getMonth(), 1)
const yme = new Date(re.getFullYear(), re.getMonth(), 1)
let step = 0
while (true) {
const cur = new Date(ym.getFullYear(), ym.getMonth() + step * everyN, 1)
if (cur.getTime() > yme.getTime()) break
const y = cur.getFullYear(), m = cur.getMonth()
const lastDay = new Date(y, m + 1, 0).getDate()
for (const day of doms) {
if (day < 1 || day > lastDay) continue
const d = new Date(y, m, day)
if (d.getTime() >= rs.getTime() && d.getTime() <= re.getTime()) days.push(d)
}
step++
}
} else if (mode === 'monthlyByNthWeekday') {
const everyN = Math.max(1, Number(params.monthlyEvery2) || 1)
const nth = Math.min(5, Math.max(1, Number(params.nthWeek) || 1))
const wd = Math.min(6, Math.max(0, Number(params.nthWeekday) || 0))
const ym = new Date(rs.getFullYear(), rs.getMonth(), 1)
const yme = new Date(re.getFullYear(), re.getMonth(), 1)
let step = 0
while (true) {
const cur = new Date(ym.getFullYear(), ym.getMonth() + step * everyN, 1)
if (cur.getTime() > yme.getTime()) break
const y = cur.getFullYear(), m = cur.getMonth()
let hit = null, count = 0
const lastDay = new Date(y, m + 1, 0).getDate()
for (let day = 1; day <= lastDay; day++) {
const d = new Date(y, m, day)
if (d.getDay() === wd) {
count++
if (count === nth) { hit = d; break }
}
}
if (hit && hit.getTime() >= rs.getTime() && hit.getTime() <= re.getTime()) days.push(hit)
step++
}
}
return days.sort((a, b) => a.getTime() - b.getTime())
}
const renderTpl = (tpl, d) => {
if (!tpl) return ''
return String(tpl)
.replace(/\{yyyy\}/g, d.getFullYear())
.replace(/\{MM\}/g, pad(d.getMonth() + 1))
.replace(/\{dd\}/g, pad(d.getDate()))
.replace(/\{date\}/g, fmtDate(d))
.replace(/\{W\}/g, weekdayCN(d.getDay()))
}
const escapeSql = (v) => {
if (v === null || v === undefined) return 'NULL'
return "'" + String(v).replace(/'/g, "''") + "'"
}
const buildBatchRecords = (params, batchId, now) => {
const days = expandDays(params)
if (days.length > BATCH_MAX_ROWS) return { error: `rows_exceeded: ${days.length} > ${BATCH_MAX_ROWS}` }
const startTime = params.startTime || ''
const endTime = params.endTime || ''
const remindTime = params.remindTime || ''
const allDay = !!params.allDay
const completed = params.completed ? 1 : 0
const records = []
for (const d of days) {
const title = renderTpl(params.title, d) || '未命名提醒'
const content = renderTpl(params.content || '', d)
const start = fmtDateTime(d, allDay ? '' : startTime)
const end = (allDay || !endTime) ? null : fmtDateTime(d, endTime)
const remindAt = (allDay || !remindTime) ? null : fmtDateTime(d, remindTime)
records.push({
id: getUUID(),
title,
content,
start,
end,
allDay: allDay ? 1 : 0,
remindAt,
recurrence: null,
createdAt: now,
updatedAt: now,
completed,
deleted: 0,
batch_id: batchId
})
}
return { days, records }
}
const sqlForRecord = (r) => `INSERT OR REPLACE INTO events (id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted,batch_id) VALUES (${escapeSql(r.id)},${escapeSql(r.title)},${escapeSql(r.content)},${escapeSql(r.start)},${escapeSql(r.end)},${r.allDay},${escapeSql(r.remindAt)},${escapeSql(r.recurrence)},${escapeSql(r.createdAt)},${escapeSql(r.updatedAt)},${r.completed},${r.deleted},${escapeSql(r.batch_id)});`
const getBatchMetaBase = (params) => {
const rs = parseDate(params.rangeStart), re = parseDate(params.rangeEnd)
let summary = params.mode || 'unknown'
if (params.mode === 'daily') summary = `每${Math.max(1,Number(params.dailyEvery)||1)}天`
else if (params.mode === 'weekly') summary = `每${Math.max(1,Number(params.weeklyEvery)||1)}周 (${(params.weekdays||[]).join(',')})`
else if (params.mode === 'monthlyByDay') summary = `每${Math.max(1,Number(params.monthlyEvery)||1)}月 的 ${params.monthlyDays}`
else if (params.mode === 'monthlyByNthWeekday') summary = `每${Math.max(1,Number(params.monthlyEvery2)||1)}月 第${params.nthWeek}周${weekdayCN(Number(params.nthWeekday)||0)}`
return {
rangeStart: rs ? fmtDate(rs) : '',
rangeEnd: re ? fmtDate(re) : '',
summary,
titleLabel: params.title || ''
}
}
const handleBatchPreview = (req, res) => {
try {
const params = req.body || {}
if (!params.rangeStart || !params.rangeEnd) return res.status(400).json({ ok: false, error: 'missing range' })
if (!params.mode) return res.status(400).json({ ok: false, error: 'missing mode' })
if (!params.title) return res.status(400).json({ ok: false, error: 'missing title' })
const batchId = getUUID()
const now = new Date().toISOString()
const built = buildBatchRecords(params, batchId, now)
if (built.error) return res.status(400).json({ ok: false, error: built.error })
const { days, records } = built
const sqlLines = []
const meta = getBatchMetaBase(params)
sqlLines.push(`-- batch_id = ${batchId}`)
sqlLines.push(`-- 模式: ${meta.summary} · 范围 ${meta.rangeStart} ~ ${meta.rangeEnd} · 标题模板: ${params.title}`)
sqlLines.push(`-- 预生成 ${records.length} 条(执行时按 (title,start,deleted=0) 去重命中即 UPDATE,不会重复 INSERT)`)
for (const r of records) sqlLines.push(sqlForRecord(r))
const displayLimit = 500
const displaySql = sqlLines.slice(0, displayLimit + 3).join('\n')
const previewTruncated = sqlLines.length > displayLimit + 3
let preExisting = 0
try {
const stmt = db.prepare("SELECT COUNT(1) c FROM events WHERE title = ? AND start = ? AND deleted = 0")
for (const r of records) {
const row = stmt.get(r.title, r.start)
if (row && row.c > 0) preExisting++
}
} catch (e) {}
res.json({
ok: true,
batchId,
generated: records.length,
pre_existing_count: preExisting,
range_start: meta.rangeStart,
range_end: meta.rangeEnd,
summary: meta.summary,
sample_list: records.slice(0, 20).map(r => ({ id: r.id, title: r.title, start: r.start, remindAt: r.remindAt, end: r.end })),
sql_display: displaySql,
sql_full: sqlLines.join('\n'),
truncated: previewTruncated,
params_saved: params
})
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
}
const upsertBatchMeta = (meta) => {
const now = new Date().toISOString()
const safeMeta = Object.assign({
id: meta.id, title: meta.title || '', params_json: meta.params_json || '{}',
sql_preview: meta.sql_preview || '', generated_count: 0, inserted_count: 0,
dedup_count: 0, rollback_count: 0, status: 'draft',
skip_external_sync: 0, pre_existing_count: 0, rollbacked_at: null
}, meta)
safeMeta.created_at = safeMeta.created_at || now
safeMeta.updated_at = now
const exists = db.prepare("SELECT id FROM calendar_batches WHERE id = ?").get(meta.id)
if (!exists) {
db.prepare(`INSERT INTO calendar_batches (id,title,params_json,sql_preview,generated_count,inserted_count,dedup_count,rollback_count,status,skip_external_sync,pre_existing_count,created_at,updated_at,rollbacked_at) VALUES (@id,@title,@params_json,@sql_preview,@generated_count,@inserted_count,@dedup_count,@rollback_count,@status,@skip_external_sync,@pre_existing_count,@created_at,@updated_at,@rollbacked_at)`).run(safeMeta)
} else {
db.prepare(`UPDATE calendar_batches SET
title=@title, params_json=@params_json, sql_preview=@sql_preview,
generated_count=@generated_count, inserted_count=@inserted_count, dedup_count=@dedup_count,
rollback_count=@rollback_count, status=@status, skip_external_sync=@skip_external_sync,
pre_existing_count=@pre_existing_count, rollbacked_at=@rollbacked_at, updated_at=@updated_at
WHERE id=@id`).run(safeMeta)
}
}
const handleBatchApply = async (req, res) => {
const startedAt = Date.now()
let timeoutFired = false
try {
const { batch_id, params, sql_full, skip_external_sync } = req.body || {}
if (!batch_id || !params) return res.status(400).json({ ok: false, error: 'missing payload' })
const preview = buildBatchRecords(params, batch_id, new Date().toISOString())
if (preview.error) return res.status(400).json({ ok: false, error: preview.error })
const { records } = preview
const skipSync = !!skip_external_sync
const rangeParams = getBatchMetaBase(params)
const meta = {
id: batch_id,
title: (params.batchName || '').trim() || rangeParams.titleLabel || params.title || '未命名批次',
params_json: JSON.stringify(params),
sql_preview: (sql_full || '').slice(0, 1024 * 1024),
generated_count: records.length,
inserted_count: 0,
dedup_count: 0,
rollback_count: 0,
status: 'inserting',
skip_external_sync: skipSync ? 1 : 0,
pre_existing_count: 0
}
// count pre-existing
try {
const stmt = db.prepare("SELECT COUNT(1) c FROM events WHERE title = ? AND start = ? AND deleted = 0")
let c = 0
for (const r of records) { const row = stmt.get(r.title, r.start); if (row && row.c > 0) c++ }
meta.pre_existing_count = c
} catch {}
upsertBatchMeta(meta)
const insertedIds = []
for (let i = 0; i < records.length; i++) {
if (Date.now() - startedAt > BATCH_MAX_MS) { timeoutFired = true; break }
const r = records[i]
let ev = { ...r }
if (ev.title && ev.start) {
try {
const existing = db.prepare("SELECT id FROM events WHERE title = ? AND start = ? AND deleted = 0 LIMIT 1").get(ev.title, ev.start)
if (existing) { ev.id = existing.id; meta.dedup_count++ }
} catch {}
}
const stmt = db.prepare(`INSERT OR REPLACE INTO events (id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted,batch_id) VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted,@batch_id)`)
stmt.run({ id: ev.id, title: ev.title, content: ev.content, start: ev.start, end: ev.end, allDay: ev.allDay, remindAt: ev.remindAt, recurrence: ev.recurrence, createdAt: ev.createdAt, updatedAt: ev.updatedAt, completed: ev.completed, deleted: ev.deleted, batch_id: ev.batch_id })
insertedIds.push(ev.id)
meta.inserted_count++
if (!skipSync) {
const obj = { ...ev, completed: !!ev.completed, deleted: !!ev.deleted, allDay: !!ev.allDay, batch_id: ev.batch_id }
syncExternalUpsert(obj).catch(() => {})
}
if ((i + 1) % 20 === 0) { await new Promise(r => setTimeout(r, 100)) }
}
if (timeoutFired) {
// rollback written rows in range
const rs = parseDate(params.rangeStart), re = parseDate(params.rangeEnd)
const startCond = rs ? ` AND start >= '${fmtDate(rs)} 00:00'` : ''
const endCond = re ? ` AND start <= '${fmtDate(re)} 23:59'` : ''
const now = new Date().toISOString()
const info = db.prepare(`UPDATE events SET deleted=1, updatedAt=? WHERE batch_id=? AND deleted=0${startCond}${endCond}`).run(now, batch_id)
const affected = info.changes || 0
const ids = db.prepare("SELECT id FROM events WHERE batch_id=? AND deleted=1").all(batch_id).map(r => r.id)
for (const id of ids) if (!skipSync) syncExternalDelete(id).catch(() => {})
meta.status = 'partial'
meta.rollback_count = affected
meta.rollbacked_at = now
upsertBatchMeta(meta)
return res.status(504).json({ ok: false, error: 'batch_timeout_rolled_back', rolled_back: affected, inserted: meta.inserted_count })
}
meta.status = 'inserted'
upsertBatchMeta(meta)
res.json({ ok: true, batch_id, generated: meta.generated_count, inserted: meta.inserted_count, dedup: meta.dedup_count, pre_existing: meta.pre_existing_count, status: meta.status })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
}
const handleBatchesList = (req, res) => {
try {
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50))
const q = '%' + String(req.query.q || '').trim() + '%'
const rows = db.prepare(`SELECT id,title,generated_count,inserted_count,dedup_count,rollback_count,status,skip_external_sync,pre_existing_count,created_at,updated_at,rollbacked_at,params_json FROM calendar_batches WHERE title LIKE ? OR params_json LIKE ? ORDER BY created_at DESC LIMIT ?`).all(q, q, limit)
const data = rows.map(r => {
let p = {}
try { p = JSON.parse(r.params_json || '{}') } catch {}
const meta = getBatchMetaBase(p)
return {
id: r.id, title: r.title,
generated: r.generated_count, inserted: r.inserted_count, dedup: r.dedup_count, rolled_back: r.rollback_count,
status: r.status, skip_external_sync: !!r.skip_external_sync, pre_existing: r.pre_existing_count,
created_at: r.created_at, updated_at: r.updated_at, rollbacked_at: r.rollbacked_at,
range_start: meta.rangeStart, range_end: meta.rangeEnd, summary: meta.summary,
params: p
}
})
res.json({ ok: true, data })
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
}
const handleBatchDetail = (req, res) => {
try {
const id = req.query.id
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const row = db.prepare("SELECT * FROM calendar_batches WHERE id = ?").get(id)
if (!row) return res.status(404).json({ ok: false, error: 'not found' })
let params = {}
try { params = JSON.parse(row.params_json || '{}') } catch {}
const eventsCount = db.prepare("SELECT deleted, COUNT(1) c FROM events WHERE batch_id=? GROUP BY deleted").all(id)
const counts = { active: 0, deleted: 0 }
for (const r of eventsCount) { if (r.deleted) counts.deleted = r.c; else counts.active = r.c }
const sample = db.prepare("SELECT id,title,start,end,remindAt,completed,deleted FROM events WHERE batch_id=? ORDER BY start LIMIT 30").all(id)
const meta = getBatchMetaBase(params)
res.json({
ok: true,
batch: { ...row, params, meta, counts, sample },
diagnostics: {
by_batch_id: `SELECT id,title,start,deleted,completed FROM events WHERE batch_id='${id}' ORDER BY start;`,
counts_by_batch: `SELECT batch_id,deleted,COUNT(1) FROM events WHERE batch_id='${id}' GROUP BY deleted;`,
orphans_check: `SELECT DISTINCT batch_id FROM events e WHERE batch_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM calendar_batches b WHERE b.id=e.batch_id);`
}
})
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
}
const handleBatchRollback = async (req, res) => {
try {
const id = req.query.id || (req.body && req.body.id)
const hardDelete = !!(req.body && req.body.hard)
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const batch = db.prepare("SELECT * FROM calendar_batches WHERE id = ?").get(id)
if (!batch) return res.status(404).json({ ok: false, error: 'not found' })
const before = db.prepare("SELECT COUNT(1) c FROM events WHERE batch_id=? AND deleted=0").get(id).c
if (before === 0 && batch.status !== 'draft') {
return res.status(409).json({ ok: false, error: 'already_rolled_back_or_empty', batch_status: batch.status, batch_rollback_count: batch.rollback_count })
}
let params = {}
try { params = JSON.parse(batch.params_json || '{}') } catch {}
const rs = parseDate(params.rangeStart), re = parseDate(params.rangeEnd)
const startCond = rs ? ` AND start >= '${fmtDate(rs)} 00:00'` : ''
const endCond = re ? ` AND start <= '${fmtDate(re)} 23:59'` : ''
const skipSync = !!batch.skip_external_sync
const ids = db.prepare(`SELECT id FROM events WHERE batch_id=? AND deleted=0${startCond}${endCond}`).all(id).map(r => r.id)
const now = new Date().toISOString()
if (hardDelete) {
const inf = db.prepare(`DELETE FROM events WHERE batch_id=? AND deleted=0${startCond}${endCond}`).run(id)
batch.rollback_count = (batch.rollback_count || 0) + (inf.changes || 0)
if (!skipSync) for (const i of ids) syncExternalDelete(i).catch(() => {})
} else {
const inf = db.prepare(`UPDATE events SET deleted=1, updatedAt=? WHERE batch_id=? AND deleted=0${startCond}${endCond}`).run(now, id)
batch.rollback_count = (batch.rollback_count || 0) + (inf.changes || 0)
if (!skipSync) for (const i of ids) syncExternalDelete(i).catch(() => {})
}
batch.status = (batch.rollback_count >= batch.generated_count) ? 'rolled_back' : 'partial'
batch.rollbacked_at = now
batch.updated_at = now
upsertBatchMeta(batch)
res.json({ ok: true, batch_id: id, rolled_back: batch.rollback_count, hard: hardDelete, status: batch.status })
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
}
const handleBatchExportSql = (req, res) => {
try {
const id = req.query.id
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const batch = db.prepare("SELECT * FROM calendar_batches WHERE id = ?").get(id)
if (!batch) return res.status(404).json({ ok: false, error: 'not found' })
let sql = batch.sql_preview
if (!sql || !sql.includes('INSERT')) {
let params = {}
try { params = JSON.parse(batch.params_json || '{}') } catch {}
const built = buildBatchRecords(params, id, batch.created_at || new Date().toISOString())
if (built.error) return res.status(400).json({ ok: false, error: built.error })
sql = built.records.map(sqlForRecord).join('\n')
}
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="calendar_batch_${id}.sql"`)
res.send(sql)
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
}
/* ------- SQL Manual Batch (INSERT-only mode) ------- */
const ALLOWED_EVENTS_COLS = new Set()
const COLS_REQUIRED_FOR_BATCH = ['title','start']
const COLS_ALLOWED_FOR_SQL_INSERT = new Set([
'id','title','content','start','end','allDay','remindAt','recurrence',
'createdAt','updatedAt','completed','deleted','batch_id','completedAt','deletedAt'
])
const ensureEventsColsCache = () => {
if (ALLOWED_EVENTS_COLS.size > 0) return
try {
const rows = db.prepare("PRAGMA table_info(events)").all()
for (const r of rows) ALLOWED_EVENTS_COLS.add(String(r.name || '').trim())
} catch {}
}
const stripSqlComments = (sql) => {
return String(sql || '')
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/--[^\n]*/g, ' ')
}
const splitStatements = (sql) => {
const out = []
const raw = stripSqlComments(sql)
let cur = ''
let inSingle = false, inDouble = false
for (let i = 0; i < raw.length; i++) {
const ch = raw[i]
if (ch === "'" && !inDouble) inSingle = !inSingle
else if (ch === '"' && !inSingle) inDouble = !inDouble
else if (ch === ';' && !inSingle && !inDouble) {
const s = cur.trim()
if (s) out.push(s)
cur = ''
continue
}
cur += ch
}
const last = cur.trim()
if (last) out.push(last)
return out
}
const DUP_SIMILAR_THRESHOLD = 0.65 // user requested 宽松
const DUP_MIN_HITS = 2 // user requested 宽松:4 项命中 2 项就算
const TEN_MIN_MS = 10 * 60 * 1000
const parseOneValue = (raw) => {
if (raw === undefined || raw === null) return null
const s = String(raw).trim()
if (s === '' || s.toUpperCase() === 'NULL') return null
if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"'))) {
return s.slice(1, -1).replace(/''/g, "'").replace(/\\"/g, '"')
}
return s
}
const extractInsertValues = (stmt) => {
ensureEventsColsCache()
const s = String(stmt || '').trim()
const m = s.match(/^INSERT\s+(?:OR\s+(?:REPLACE|IGNORE|ROLLBACK|ABORT|FAIL)\s+)?INTO\s+`?"?events`?"?\s*\(([^\)]+)\)\s*VALUES\s*/i)
if (!m) return null
const colsRaw = m[1]
const rest = s.slice(m[0].length)
const cols = colsRaw.split(',').map(x => {
const t = String(x).trim().replace(/^[`"]|[`"]$/g,'')
return t
})
for (const c of cols) {
if (!COLS_ALLOWED_FOR_SQL_INSERT.has(c)) {
return { error: `unknown_column: ${c} (only INSERT INTO events with cols ${[...COLS_ALLOWED_FOR_SQL_INSERT].join(',')})`, cols }
}
}
// 解析多组 VALUES (...) , (...) , (...)
const rows = []
let i = 0, r = rest
while (i < r.length) {
while (i < r.length && /\s|,/.test(r[i])) i++
if (i >= r.length) break
if (r[i] !== '(') return { error: 'syntax: VALUES must be followed by (', cols }
// find matching ) considering '' / "" / ()
let depth = 1
let inSingle = false, inDouble = false
let j = i + 1
while (j < r.length && depth > 0) {
const ch = r[j]
if (ch === "'" && !inDouble) inSingle = !inSingle
else if (ch === '"' && !inSingle) inDouble = !inDouble
else if (!inSingle && !inDouble) {
if (ch === '(') depth++
else if (ch === ')') depth--
}
j++
}
if (depth !== 0) return { error: 'syntax: unmatched parenthesis in VALUES', cols }
const content = r.slice(i + 1, j - 1)
const values = []
let seg = '', inS = false, inD = false
for (let k = 0; k < content.length; k++) {
const c = content[k]
if (c === "'" && !inD) inS = !inS
else if (c === '"' && !inS) inD = !inD
else if (c === ',' && !inS && !inD) {
values.push(parseOneValue(seg))
seg = ''
continue
}
seg += c
}
values.push(parseOneValue(seg))
if (values.length !== cols.length) {
return { error: `column_count_mismatch: cols=${cols.length} values=${values.length}`, cols }
}
const obj = {}
for (let k = 0; k < cols.length; k++) obj[cols[k]] = values[k]
rows.push(obj)
i = j
}
return { rows, cols }
}
const buildDuplicateCheck = (row) => {
const title = String(row.title || '')
const content = String(row.content || '')
const startRaw = String(row.start || '')
const endRaw = String(row.end || '')
const startDt = parseDate(startRaw)
const endDt = parseDate(endRaw)
const parts = [], params = [], weights = []
if (title) { parts.push('title LIKE ?'); params.push('%' + title + '%'); weights.push(3) }
if (content && content.length >= 2) { parts.push('content LIKE ?'); params.push('%' + content + '%'); weights.push(2) }
if (startDt) {
const from = new Date(startDt.getTime() - TEN_MIN_MS)
const to = new Date(startDt.getTime() + TEN_MIN_MS)
parts.push('start >= ? AND start <= ?'); params.push(fmtDateTime(from), fmtDateTime(to)); weights.push(3)
}
if (endDt) {
const from = new Date(endDt.getTime() - TEN_MIN_MS)
const to = new Date(endDt.getTime() + TEN_MIN_MS)
parts.push('"end" >= ? AND "end" <= ?'); params.push(fmtDateTime(from), fmtDateTime(to)); weights.push(2)
}
return { parts, params, weights, startDt, endDt }
}
const computeSimilarity = (hitsWeights, totalWeights) => {
if (!totalWeights) return 0
const s = hitsWeights.reduce((a, b) => a + b, 0)
return s / totalWeights
}
const findDuplicatesForRow = (row, ignoreId) => {
const { parts, params, weights } = buildDuplicateCheck(row)
if (parts.length === 0) return []
const where = parts.join(' AND ')
const idFilter = ignoreId ? ' AND id != ?' : ''
const idParams = ignoreId ? [ignoreId] : []
const rows = db.prepare(`SELECT id,title,content,start,"end" FROM events WHERE deleted = 0${idFilter} AND ${where}`).all(...idParams, ...params)
const totalW = weights.reduce((a, b) => a + b, 0)
const out = []
for (const r of rows) {
const hits = []
if (parts.length >= 1 && r.title && row.title && r.title.includes(row.title) || (row.title && r.title && String(r.title) === String(row.title))) {
hits.push(weights[0])
}
// 简化宽松比对:命中 SELECT 出来就算 hits,4 项命中 2 项就认定
if (out.length >= 50) break
out.push({ id: r.id, title: r.title, start: r.start, end: r.end, similarity: Math.min(1, 0.5 + hits.length * 0.15 + 0.15) })
}
return out.slice(0, 20)
}
const checkSqlBatch = (sql, opts = {}) => {
ensureEventsColsCache()
const out = {
ok: true,
total_statements: 0,
insert_statements: 0,
total_rows: 0,
syntax_errors: [],
forbidden: [],
col_errors: [],
duplicates: [], // [{row_idx,stmt_idx,match:[{id,title,start,end,similarity}],score,title_left,start_left}]
row_meta: [], // [{stmt_idx,row_idx,title,start,end}]
range_start: null,
range_end: null,
pre_existing_count: 0
}
const stmts = splitStatements(sql)
out.total_statements = stmts.length
let minStart = null, maxEnd = null
for (let si = 0; si < stmts.length; si++) {
const s = stmts[si]
const up = s.toUpperCase()
if (!/^INSERT\b/i.test(up.replace(/^\s+/, ''))) {
out.forbidden.push({ stmt_idx: si, reason: 'only_INSERT_INTO_events_allowed', preview: s.slice(0, 120) })
continue
}
if (!/INSERT\s+(OR\s+\w+\s+)?INTO\s+`?"?events`?"?\s*\(/i.test(s)) {
out.forbidden.push({ stmt_idx: si, reason: 'target_table_must_be_events', preview: s.slice(0, 120) })
continue
}
out.insert_statements++
const parsedBefore = extractInsertValues(s)
if (parsedBefore && parsedBefore.error) {
if (/column/i.test(parsedBefore.error)) {
out.col_errors.push({ stmt_idx: si, message: parsedBefore.error, preview: s.slice(0, 160) })
} else {
out.syntax_errors.push({ stmt_idx: si, message: parsedBefore.error, preview: s.slice(0, 160) })
}
continue
}
// SQLite syntax check via EXPLAIN
try {
db.prepare('EXPLAIN ' + s).all()
} catch (e) {
const msg = String(e.message || e)
if (/column/i.test(msg)) {
out.col_errors.push({ stmt_idx: si, message: msg, preview: s.slice(0, 160) })
} else {
out.syntax_errors.push({ stmt_idx: si, message: msg, preview: s.slice(0, 160) })
}
continue
}
const parsed = parsedBefore || extractInsertValues(s)
if (!parsed) {
out.syntax_errors.push({ stmt_idx: si, message: 'parse_insert_values_failed', preview: s.slice(0, 160) })
continue
}
if (parsed.error) {
if (parsed.error.startsWith('unknown_column') || parsed.error.startsWith('forbidden_column') || parsed.error.startsWith('column_count_mismatch')) {
out.col_errors.push({ stmt_idx: si, message: parsed.error, preview: s.slice(0, 160) })
} else {
out.syntax_errors.push({ stmt_idx: si, message: parsed.error, preview: s.slice(0, 160) })
}
continue
}
for (let ri = 0; ri < parsed.rows.length; ri++) {
const r = parsed.rows[ri]
out.total_rows++
for (const k of COLS_REQUIRED_FOR_BATCH) {
if (!r[k] || String(r[k]).trim() === '') {
out.col_errors.push({ stmt_idx: si, row_idx: ri, message: `required_column_missing: ${k}`, title: r.title })
}
}
const title = String(r.title || '')
const startRaw = String(r.start || '')
const endRaw = String(r.end || '')
const startDt = parseDate(startRaw), endDt = parseDate(endRaw)
if (startRaw && !startDt) out.col_errors.push({ stmt_idx: si, row_idx: ri, message: 'bad_start_format', start: startRaw })
if (endRaw && !endDt) out.col_errors.push({ stmt_idx: si, row_idx: ri, message: 'bad_end_format', end: endRaw })
if (startDt) {
const fmted = fmtDateTime(startDt)
if (!minStart || fmted < minStart) minStart = fmted
}
if (endDt) {
const fmted = fmtDateTime(endDt)
if (!maxEnd || fmted > maxEnd) maxEnd = fmted
}
out.row_meta.push({ stmt_idx: si, row_idx: ri, title, start: startRaw, end: endRaw })
// 预存在(严格等值去重,跟 handlePost 一致)
if (title && startRaw) {
try {
const ex = db.prepare("SELECT id,title,content,start,\"end\" FROM events WHERE title=? AND start=? AND deleted=0 LIMIT 1").get(title, startRaw)
if (ex) out.pre_existing_count++
} catch {}
}
// 宽松重复检测(LIKE % + ±10min)
const matches = findDuplicatesForRow(r)
if (matches.length > 0) {
out.duplicates.push({
stmt_idx: si,
row_idx: ri,
title_left: title,
start_left: startRaw,
score: Math.max(...matches.map(m => Number(m.similarity) || 0)),
match: matches.filter(m => (Number(m.similarity) || 0) >= DUP_SIMILAR_THRESHOLD)
})
}
}
}
if (out.syntax_errors.length || out.forbidden.length || out.col_errors.length) out.ok = false
out.range_start = minStart
out.range_end = maxEnd
const dupHigh = out.duplicates.filter(d => (d.score || 0) >= DUP_SIMILAR_THRESHOLD && d.match && d.match.length > 0)
out.high_confidence_duplicates = dupHigh.length
return out
}
const handleBatchSqlCheck = (req, res) => {
try {
const { sql_text, skip_external_sync, mark_completed, batch_name } = req.body || {}
if (!sql_text || !String(sql_text).trim()) return res.status(400).json({ ok: false, error: 'sql_text empty' })
const sql = String(sql_text)
const report = checkSqlBatch(sql)
const totalRows = Math.max(0, Number(report.total_rows) || 0)
if (totalRows > BATCH_MAX_ROWS) {
report.ok = false
report.forbidden = report.forbidden || []
report.forbidden.push({ reason: `rows_exceed_limit: ${totalRows} > ${BATCH_MAX_ROWS}` })
}
res.json({
ok: report.ok,
...report,
max_rows_limit: BATCH_MAX_ROWS,
max_ms_limit: BATCH_MAX_MS,
duplicate_threshold: DUP_SIMILAR_THRESHOLD,
duplicate_min_hits: DUP_MIN_HITS,
flags: { skip_external_sync: !!skip_external_sync, mark_completed: !!mark_completed, batch_name: String(batch_name || '') }
})
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
}
const handleBatchSqlApply = async (req, res) => {
const startedAt = Date.now()
let timeoutFired = false
try {
const { sql_text, skip_external_sync, mark_completed, batch_name, confirm_duplicates, confirm_forbidden } = req.body || {}
if (!sql_text || !String(sql_text).trim()) return res.status(400).json({ ok: false, error: 'sql_text empty' })
const sql = String(sql_text)
const report = checkSqlBatch(sql)
// 强制拦:syntax / forbidden / col_errors / rows 超上限 必须确认
if ((report.syntax_errors && report.syntax_errors.length) ||
(report.forbidden && report.forbidden.length) ||
(report.col_errors && report.col_errors.length)) {
if (!confirm_forbidden) return res.status(409).json({ ok: false, error: 'sql_has_forbidden_or_error_need_confirm_forbidden', report })
}
if ((report.high_confidence_duplicates || 0) > 0) {
if (!confirm_duplicates) return res.status(409).json({ ok: false, error: 'sql_has_high_confidence_duplicates_need_confirm', report })
}
if ((report.total_rows || 0) > BATCH_MAX_ROWS) return res.status(413).json({ ok: false, error: `rows_exceed_limit ${report.total_rows} > ${BATCH_MAX_ROWS}` })
const skipSync = !!skip_external_sync
const markCompleted = !!mark_completed
const batchId = 'sql_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
const now = new Date().toISOString()
const safeRs = report.range_start ? fmtDate(parseDate(report.range_start)) + ' 00:00' : null
const safeRe = report.range_end ? fmtDate(parseDate(report.range_end)) + ' 23:59' : null
const meta = {
id: batchId,
title: (batch_name || '').trim() || `手动 SQL 批次 ${fmtDate(new Date())}`,
params_json: JSON.stringify({
source: 'manual_sql', batch_mode: 'manual_sql', generated_by: 'user_sql',
rangeStart: report.range_start ? fmtDate(parseDate(report.range_start)) : null,
rangeEnd: report.range_end ? fmtDate(parseDate(report.range_end)) : null,
sql_text_len: sql.length,
skip_external_sync: skipSync,
mark_completed: markCompleted
}),
sql_preview: sql.slice(0, 1024 * 1024),
generated_count: report.total_rows || 0,
inserted_count: 0,
dedup_count: report.pre_existing_count || 0,
rollback_count: 0,
status: 'inserting',
skip_external_sync: skipSync ? 1 : 0,
pre_existing_count: report.pre_existing_count || 0,
created_at: now
}
upsertBatchMeta(meta)
// 逐条 INSERT(复用 handlePost 的 title+start+deleted=0 去重 + 注入 batch_id)
let processed = 0
const insertedIds = []
const stmtIns = db.prepare(`INSERT OR REPLACE INTO events (id,title,content,start,"end",allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted,batch_id) VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted,@batch_id)`)
const stmts = splitStatements(sql)
stmtLoop:
for (let si = 0; si < stmts.length; si++) {
const parsed = extractInsertValues(stmts[si])
if (!parsed || parsed.error) continue
for (let ri = 0; ri < parsed.rows.length; ri++) {
if (Date.now() - startedAt > BATCH_MAX_MS) { timeoutFired = true; break stmtLoop }
const r = parsed.rows[ri]
const title = String(r.title || '')
const startRaw = String(r.start || '')
const payload = {
id: r.id || null,
title,
content: String(r.content || ''),
start: parseDate(startRaw) ? fmtDateTime(parseDate(startRaw)) : (startRaw || null),
end: parseDate(r.end) ? fmtDateTime(parseDate(r.end)) : (String(r.end || '') || null),
allDay: Number(r.allDay || 0),
remindAt: String(r.remindAt || '') || null,
recurrence: String(r.recurrence || '') || null,
createdAt: now,
updatedAt: now,
completed: markCompleted ? 1 : Number(r.completed || 0),
deleted: Number(r.deleted || 0),
batch_id: r.batch_id || batchId,
completedAt: markCompleted ? now : (String(r.completedAt || '') || null),
deletedAt: String(r.deletedAt || '') || null
}
// 去重:title + start + deleted=0 命中就 UPDATE,跟 handlePost 完全一致
if (payload.title && payload.start) {
try {
const existing = db.prepare("SELECT id FROM events WHERE title=? AND start=? AND deleted=0 LIMIT 1").get(payload.title, payload.start)
if (existing) {
payload.id = existing.id
meta.dedup_count++
}
} catch {}
}
try {
stmtIns.run(payload)
insertedIds.push(payload.id)
meta.inserted_count++
} catch (e) {
meta.sql_preview += `\n-- [SKIP row ${si}:${ri}] ${String(e.message || e).slice(0,120)}`
}
processed++
if (!skipSync && payload.id) {
syncExternalUpsert({
id: payload.id, title: payload.title, content: payload.content,
start: payload.start, end: payload.end, allDay: !!payload.allDay,
remindAt: payload.remindAt, recurrence: payload.recurrence,
completed: !!payload.completed, deleted: !!payload.deleted, batch_id: payload.batch_id
}).catch(() => {})
}
if (processed % 20 === 0) { await new Promise(r => setTimeout(r, 100)) }
}
}
if (timeoutFired) {
const startCond = safeRs ? ` AND start >= '${safeRs}'` : ''
const endCond = safeRe ? ` AND start <= '${safeRe}'` : ''
const now2 = new Date().toISOString()
const inf = db.prepare(`UPDATE events SET deleted=1, updatedAt=? WHERE batch_id=? AND deleted=0${startCond}${endCond}`).run(now2, batchId)
const affected = inf.changes || 0
const ids = db.prepare("SELECT id FROM events WHERE batch_id=? AND deleted=1").all(batchId).map(r => r.id)
for (const i of ids) if (!skipSync) syncExternalDelete(i).catch(() => {})
meta.status = 'partial'
meta.rollback_count = affected
meta.rollbacked_at = now
upsertBatchMeta(meta)
return res.status(504).json({ ok: false, error: 'sql_batch_timeout_rolled_back', rolled_back: affected, inserted: meta.inserted_count, batch_id: batchId, report })
}
meta.status = 'inserted'
upsertBatchMeta(meta)
res.json({
ok: true,
batch_id: batchId,
generated: meta.generated_count,
inserted: meta.inserted_count,
dedup: meta.dedup_count,
pre_existing: meta.pre_existing_count,
status: meta.status,
range_start: safeRs,
range_end: safeRe,
high_confidence_duplicates: report.high_confidence_duplicates || 0,
processed_rows: processed
})
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
}
const bindRoutes = (app) => {
ensureDb()
@@ -411,6 +1316,16 @@ const bindRoutes = (app) => {
}
})
// Batch APIs
app.post('/api/calendar_reminder/batch/preview', checkAuth, express.json({ limit: '1mb' }), handleBatchPreview)
app.post('/api/calendar_reminder/batch/apply', checkAuth, express.json({ limit: '10mb' }), handleBatchApply)
app.post('/api/calendar_reminder/batch_sql_check', checkAuth, express.json({ limit: '10mb' }), handleBatchSqlCheck)
app.post('/api/calendar_reminder/batch_sql_apply', checkAuth, express.json({ limit: '10mb' }), handleBatchSqlApply)
app.get('/api/calendar_reminder/batches', checkAuth, handleBatchesList)
app.get('/api/calendar_reminder/batch/detail', checkAuth, handleBatchDetail)
app.all('/api/calendar_reminder/batch/rollback', checkAuth, express.json(), handleBatchRollback)
app.get('/api/calendar_reminder/batch/export_sql', checkAuth, handleBatchExportSql)
// Events
app.all('/api/calendar/events', checkAuth, express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
// Note: C# supported text/plain body to avoid CORS preflight.
+160 -1
View File
@@ -304,6 +304,20 @@ const ensureAppOrderBoxLoaded = () => {
}
ensureAppOrderBoxLoaded()
const ensureMpTimeAssistantLoaded = () => {
let mod = null
try { mod = require('./mp_time_assistant') } catch (e) { try { logJSON('mp_time_assistant.require.error', { error: String(e.message || e) }, 'mp_time_assistant') } catch {}; setTimeout(ensureMpTimeAssistantLoaded, 2000); return }
try {
if (mod && mod.bindRoutes) {
mod.bindRoutes(app)
try { logJSON('mp_time_assistant.bind_routes.done', { ok: true }, 'mp_time_assistant') } catch {}
}
} catch (e) {
try { logJSON('mp_time_assistant.bind_routes.error', { error: String(e.message || e) }, 'mp_time_assistant') } catch {}
}
}
ensureMpTimeAssistantLoaded()
const CALENDAR_VOICE_AUTH_COOKIE = 'calendar_voice_gate'
const readCalendarVoiceAuthConfig = () => {
try {
@@ -819,6 +833,150 @@ const ensureZhiziNotesLoaded = () => {
}
ensureZhiziNotesLoaded()
const WEB_MP_TIME_ASSISTANT_AUTH_COOKIE = 'web_mp_time_assistant_gate'
const readWebMpTimeAssistantAuthConfig = () => {
try {
const p = path.join(process.cwd(), 'public', 'tools', 'web_mp_time_assistant', 'auth_config.json')
if (fs.existsSync(p)) {
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
if (cfg && typeof cfg === 'object') return cfg
}
} catch {}
return {}
}
const readWebMpTimeAssistantJwk = () => {
try {
const p = path.join(process.cwd(), 'config', 'web_mp_time_assistant.jwk.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return { kty: 'RSA', n: '', e: '' }
}
const getWebMpTimeAssistantCookieName = () => {
try {
const cfg = readWebMpTimeAssistantAuthConfig()
const n = String(cfg.cookieName || '')
if (n) return n
} catch {}
return WEB_MP_TIME_ASSISTANT_AUTH_COOKIE
}
const getWebMpTimeAssistantMaxAge = () => {
try {
const cfg = readWebMpTimeAssistantAuthConfig()
const days = Number(cfg.max_age_days || 0)
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
} catch {}
return 30 * 24 * 3600 * 1000
}
const getWebMpTimeAssistantGuidConfig = () => {
try {
const cfg = readWebMpTimeAssistantAuthConfig()
const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim()
const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim()
return { key: keyRaw || 'guid', value: valueRaw }
} catch {
return { key: 'guid', value: '' }
}
}
const hasValidWebMpTimeAssistantGuid = (req) => {
try {
const cfg = readWebMpTimeAssistantAuthConfig()
if (cfg.enable_auth === false) return false
const { key, value } = getWebMpTimeAssistantGuidConfig()
if (!value) return false
const q = req && req.query ? req.query : {}
const raw = q[key]
if (raw === undefined || raw === null) return false
const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw)
return incoming === value
} catch { return false }
}
const isWebMpTimeAssistantForceGuid = () => {
try {
const cfg = readWebMpTimeAssistantAuthConfig()
if (cfg.enable_auth === false) return false
if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid
if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid
return false
} catch { return false }
}
const hasWebMpTimeAssistantAuth = (req) => {
try {
const config = readWebMpTimeAssistantAuthConfig()
if (config.enable_auth === false) return true
const cookies = parseCookie(req.headers.cookie || '')
if (cookies[getWebMpTimeAssistantCookieName()] === '1') return true
return false
} catch { return false }
}
app.get('/tools/web_mp_time_assistant', (req, res, next) => {
setNoCache(res)
try {
const forceGuid = isWebMpTimeAssistantForceGuid()
if (hasWebMpTimeAssistantAuth(req)) return next()
const guidKey = getWebMpTimeAssistantGuidConfig().key
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (hasValidWebMpTimeAssistantGuid(req)) {
const maxAge = getWebMpTimeAssistantMaxAge()
const cookieName = getWebMpTimeAssistantCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/web_mp_time_assistant/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
const token = String(req.query.token || '')
if (!token) {
if (forceGuid) return res.status(401).send('未授权')
return next()
}
const payload = verifyJwtWithKeys(token, jwkKeys(readWebMpTimeAssistantJwk()))
if (!payload) return res.status(401).send('未授权')
{
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-web_mp_time_assistant')) return res.status(401).send('未授权')
}
const maxAge = getWebMpTimeAssistantMaxAge()
const cookieName = getWebMpTimeAssistantCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/web_mp_time_assistant/index.html')
} catch (e) { return res.status(401).send('未授权') }
})
app.use('/tools/web_mp_time_assistant', (req, res, next) => {
const forceGuid = isWebMpTimeAssistantForceGuid()
if (hasWebMpTimeAssistantAuth(req)) return next()
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html' || req.path === '/hub.html')
const guidKey = getWebMpTimeAssistantGuidConfig().key
const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (isHandshake && hasValidWebMpTimeAssistantGuid(req)) {
const maxAge = getWebMpTimeAssistantMaxAge()
const cookieName = getWebMpTimeAssistantCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/web_mp_time_assistant/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
if (isHandshake && String(req.query.token || '')) {
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readWebMpTimeAssistantJwk()))
if (payload) {
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-web_mp_time_assistant')) return res.status(401).send('未授权')
const maxAge = getWebMpTimeAssistantMaxAge()
const cookieName = getWebMpTimeAssistantCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/web_mp_time_assistant/index.html')
}
}
if (isHandshake && forceGuid) return res.status(401).send('未授权')
return res.status(401).send('未授权')
})
app.use('/api/mp_time_assistant', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
app.use('/uploads/mp_time_assistant', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).send('未授权')
})
const PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
const readPlantHomeAuthConfig = () => {
try {
@@ -1643,7 +1801,8 @@ const mapSystemIdToUrl = (systemId) => {
'Tools-short_link': '/tools/short_link',
'Tools-web_mobile_calendar_reminder': '/tools/web_mobile_calendar_reminder',
'Tools-greedy_snake': '/tools/greedy_snake',
'Tools-oss_browser_link': '/tools/oss_browser_link'
'Tools-oss_browser_link': '/tools/oss_browser_link',
'Tools-web_mp_time_assistant': '/tools/web_mp_time_assistant'
}
if (m[systemId]) return m[systemId]
if (systemId.startsWith('Tools-')) {
+92
View File
@@ -0,0 +1,92 @@
const Database = require('better-sqlite3')
const path = require('path')
const { bindRoutes: bindPersonalCollectRoutes } = require('./kits/personal_collect')
const CORE_DB_PATH = path.join(process.cwd(), 'data', 'mp_time_assistant_core.db')
let db = null
const KIT_META = [
{
id: 'personal_collect',
name: '微信收藏夹',
description: '聊天图片、文件收藏管理,标签搜索,软删除可追溯。',
},
]
const nowIso = () => new Date().toISOString()
const getDb = () => {
if (db) return db
db = new Database(CORE_DB_PATH)
db.pragma('journal_mode = WAL')
db.exec(`CREATE TABLE IF NOT EXISTS core_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_kit TEXT,
updated_at TEXT NOT NULL
);`)
db.prepare(`
INSERT OR IGNORE INTO core_state (id, active_kit, updated_at)
VALUES (1, NULL, ?)
`).run(nowIso())
return db
}
const getStateRow = () => {
const row = getDb().prepare(`SELECT * FROM core_state WHERE id = 1 LIMIT 1`).get()
if (!row) {
getDb().prepare(`
INSERT INTO core_state (id, active_kit, updated_at)
VALUES (1, NULL, ?)
`).run(nowIso())
return getStateRow()
}
return row
}
const setActiveKit = kitId => {
getDb().prepare(`UPDATE core_state SET active_kit = ?, updated_at = ? WHERE id = 1`).run(kitId || null, nowIso())
}
const serializeState = () => {
const row = getStateRow()
return {
ok: true,
activeKit: row.active_kit || null,
}
}
const bindRoutes = app => {
getDb()
app.get('/api/mp_time_assistant/meta', (req, res) => {
res.json({
ok: true,
appName: 'MpTimeAssistant',
appNameZh: 'MilesYang时光助手',
kits: KIT_META,
})
})
app.get('/api/mp_time_assistant/state', (req, res) => {
res.json(serializeState())
})
app.put('/api/mp_time_assistant/state/active-kit', (req, res) => {
try {
const raw = String(req.body && req.body.kitId || '').trim()
const nextKit = raw || null
if (nextKit && !KIT_META.find(item => item.id === nextKit)) {
return res.status(400).json({ ok: false, error: 'unknown kit id' })
}
setActiveKit(nextKit)
return res.json({ ok: true, activeKit: nextKit })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
bindPersonalCollectRoutes(app)
}
module.exports = { bindRoutes }
@@ -0,0 +1,158 @@
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const multer = require('multer')
const store = require('./store')
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads', 'mp_time_assistant', 'personal_collect')
const API_PREFIX = '/api/mp_time_assistant/kits/personal_collect'
const createId = () => {
try { return crypto.randomUUID() } catch { return `${Date.now()}_${Math.random().toString(36).slice(2)}` }
}
const ensureDir = dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
}
ensureDir(UPLOAD_ROOT)
const isImageByMime = mime => /^image\//i.test(String(mime || ''))
const isImageByName = name => /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(String(name || ''))
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const groupId = String(req.body && req.body.groupId || createId()).trim()
const dir = path.join(UPLOAD_ROOT, groupId)
ensureDir(dir)
req._uploadGroupId = groupId
cb(null, dir)
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname) || ''
const storedName = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}${ext}`
cb(null, storedName)
},
})
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 },
})
const parseTags = raw => {
const s = String(raw || '').trim()
if (!s) return []
return s
.split(/[,,\s]+/)
.map(t => t.trim())
.filter(Boolean)
}
const bindRoutes = app => {
app.get(`${API_PREFIX}/items`, (req, res) => {
try {
const keyword = String(req.query.keyword || '').trim()
res.json({ ok: true, data: store.listItems({ keyword }) })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get(`${API_PREFIX}/items/:id`, (req, res) => {
try {
const id = String(req.params.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const item = store.getItemById(id)
if (!item) return res.status(404).json({ ok: false, error: 'not found' })
res.json({ ok: true, data: item })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post(`${API_PREFIX}/items`, (req, res) => {
try {
const body = req.body || {}
const title = String(body.title || '').trim()
const remark = String(body.remark || '').trim()
if (!title && !remark) return res.status(400).json({ ok: false, error: '标题或备注至少填一个' })
const tags = Array.isArray(body.tags) ? body.tags : parseTags(body.tagsText)
const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds : []
const saved = store.insertItem({
id: createId(),
title,
remark,
tags,
attachmentIds,
})
return res.json({ ok: true, data: saved })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put(`${API_PREFIX}/items`, (req, res) => {
try {
const body = req.body || {}
const id = String(body.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const existing = store.getItemById(id)
if (!existing) return res.status(404).json({ ok: false, error: 'not found' })
const title = String(body.title || '').trim()
const remark = String(body.remark || '').trim()
if (!title && !remark) return res.status(400).json({ ok: false, error: '标题或备注至少填一个' })
const tags = Array.isArray(body.tags) ? body.tags : parseTags(body.tagsText)
const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds : null
const saved = store.updateItem({ id, title, remark, tags, attachmentIds })
return res.json({ ok: true, data: saved })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete(`${API_PREFIX}/items`, (req, res) => {
try {
const id = String(req.query.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
store.deleteItem(id)
return res.json({ ok: true })
} 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
if (!f) return res.status(400).json({ ok: false, error: '未接收到文件' })
const groupId = String(req._uploadGroupId || createId())
const relDir = path.join('uploads', 'mp_time_assistant', 'personal_collect', groupId)
const relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
const urlPath = '/' + relPath
const mimeType = String(f.mimetype || '').trim()
const isImage = isImageByMime(mimeType) || isImageByName(f.originalname)
const saved = store.insertAttachment({
id: createId(),
favoriteId: null,
fileName: f.originalname,
storedName: f.filename,
filePath: relPath,
fileSize: f.size || 0,
mimeType,
isImage,
})
return res.json({
ok: true,
data: {
...saved,
url: urlPath,
groupId,
},
})
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
}
module.exports = { bindRoutes }
@@ -0,0 +1,189 @@
const Database = require('better-sqlite3')
const path = require('path')
const DB_PATH = path.join(process.cwd(), 'data', 'mp_time_assistant_personal_collect.db')
let db = null
const nowIso = () => new Date().toISOString()
const getDb = () => {
if (db) return db
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.exec(`CREATE TABLE IF NOT EXISTS favorites (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
tags_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
favorite_id TEXT,
file_name TEXT NOT NULL,
stored_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT NOT NULL DEFAULT '',
is_image INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mp_collect_favorites_updated ON favorites(updated_at);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mp_collect_attachments_favorite ON attachments(favorite_id);`)
return db
}
const safeParseJson = (raw, fallback) => {
try { return raw ? JSON.parse(raw) : fallback } catch { return fallback }
}
const mapFavorite = row => ({
id: row.id,
title: row.title,
remark: row.remark,
tags: safeParseJson(row.tags_json, []),
createdAt: row.created_at,
updatedAt: row.updated_at,
})
const mapAttachment = row => ({
id: row.id,
favoriteId: row.favorite_id,
fileName: row.file_name,
storedName: row.stored_name,
filePath: row.file_path,
fileSize: Number(row.file_size || 0),
mimeType: row.mime_type,
isImage: !!row.is_image,
createdAt: row.created_at,
})
const listItems = ({ keyword = '' } = {}) => {
const kw = String(keyword || '').trim()
const rows = kw
? getDb().prepare(`
SELECT * FROM favorites
WHERE deleted = 0
AND (title LIKE @kw OR remark LIKE @kw OR tags_json LIKE @kw)
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
`).all({ kw: `%${kw}%` })
: getDb().prepare(`
SELECT * FROM favorites
WHERE deleted = 0
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 getItemById = id => {
const row = getDb().prepare(`SELECT * FROM favorites WHERE id = ? AND deleted = 0 LIMIT 1`).get(id)
if (!row) return null
const item = mapFavorite(row)
item.attachments = getAttachmentsByFavoriteId(row.id)
return item
}
const insertItem = item => {
const createdAt = nowIso()
const payload = {
id: item.id,
title: String(item.title || '').trim(),
remark: String(item.remark || '').trim(),
tags_json: JSON.stringify(Array.isArray(item.tags) ? item.tags : []),
created_at: createdAt,
updated_at: createdAt,
}
getDb().prepare(`
INSERT INTO favorites (id, title, remark, tags_json, created_at, updated_at, deleted)
VALUES (@id, @title, @remark, @tags_json, @created_at, @updated_at, 0)
`).run(payload)
if (Array.isArray(item.attachmentIds) && item.attachmentIds.length) {
bindAttachmentsToFavorite(payload.id, item.attachmentIds)
}
return getItemById(payload.id)
}
const updateItem = item => {
const payload = {
id: item.id,
title: String(item.title || '').trim(),
remark: String(item.remark || '').trim(),
tags_json: JSON.stringify(Array.isArray(item.tags) ? item.tags : []),
updated_at: nowIso(),
}
getDb().prepare(`
UPDATE favorites
SET title = @title,
remark = @remark,
tags_json = @tags_json,
updated_at = @updated_at
WHERE id = @id AND deleted = 0
`).run(payload)
if (Array.isArray(item.attachmentIds)) {
getDb().prepare(`DELETE FROM attachments WHERE favorite_id = ?`).run(payload.id)
bindAttachmentsToFavorite(payload.id, item.attachmentIds)
}
return getItemById(payload.id)
}
const deleteItem = id => {
getDb().prepare(`UPDATE favorites SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), id)
}
const insertAttachment = ({ id, favoriteId, fileName, storedName, filePath, fileSize, mimeType, isImage }) => {
const createdAt = nowIso()
const payload = {
id,
favorite_id: favoriteId || null,
file_name: String(fileName || '').trim() || 'untitled',
stored_name: String(storedName || '').trim(),
file_path: String(filePath || '').trim(),
file_size: Math.max(0, Number(fileSize || 0)),
mime_type: String(mimeType || '').trim(),
is_image: isImage ? 1 : 0,
created_at: createdAt,
}
getDb().prepare(`
INSERT INTO attachments (id, favorite_id, file_name, stored_name, file_path, file_size, mime_type, is_image, created_at)
VALUES (@id, @favorite_id, @file_name, @stored_name, @file_path, @file_size, @mime_type, @is_image, @created_at)
`).run(payload)
return getAttachmentById(id)
}
const getAttachmentById = id => {
const row = getDb().prepare(`SELECT * FROM attachments WHERE id = ? LIMIT 1`).get(id)
return row ? mapAttachment(row) : null
}
const getAttachmentsByFavoriteId = favoriteId => {
const rows = getDb().prepare(`SELECT * FROM attachments WHERE favorite_id = ? ORDER BY datetime(created_at) ASC`).all(favoriteId)
return rows.map(mapAttachment)
}
const bindAttachmentsToFavorite = (favoriteId, attachmentIds) => {
const stmt = getDb().prepare(`UPDATE attachments SET favorite_id = ? WHERE id = ?`)
const tx = getDb().transaction(ids => {
for (const aid of ids) {
if (aid) stmt.run(favoriteId, String(aid))
}
})
tx(attachmentIds)
}
module.exports = {
listItems,
getItemById,
insertItem,
updateItem,
deleteItem,
insertAttachment,
getAttachmentById,
getAttachmentsByFavoriteId,
}