diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150749806.db b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150749806.db
new file mode 100644
index 0000000..800e4a8
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150749806.db differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150749806.db-shm b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150749806.db-shm
new file mode 100644
index 0000000..1f0b3e2
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150749806.db-shm differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150749806.db-wal b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150749806.db-wal
new file mode 100644
index 0000000..6a3837c
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150749806.db-wal differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150772530.db b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150772530.db
new file mode 100644
index 0000000..800e4a8
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150772530.db differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150772530.db-shm b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150772530.db-shm
new file mode 100644
index 0000000..12f759d
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150772530.db-shm differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150772530.db-wal b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150772530.db-wal
new file mode 100644
index 0000000..187c701
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150772530.db-wal differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150804970.db b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150804970.db
new file mode 100644
index 0000000..800e4a8
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150804970.db differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150804970.db-shm b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150804970.db-shm
new file mode 100644
index 0000000..12f759d
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150804970.db-shm differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150804970.db-wal b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150804970.db-wal
new file mode 100644
index 0000000..187c701
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150804970.db-wal differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150830079.db b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150830079.db
new file mode 100644
index 0000000..ed8031b
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150830079.db differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150830079.db-shm b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150830079.db-shm
new file mode 100644
index 0000000..37bcdd8
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150830079.db-shm differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150830079.db-wal b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150830079.db-wal
new file mode 100644
index 0000000..ad78a51
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788150830079.db-wal differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788224830032.db b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788224830032.db
new file mode 100644
index 0000000..9a4060a
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788224830032.db differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788224830032.db-shm b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788224830032.db-shm
new file mode 100644
index 0000000..a9f35bd
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788224830032.db-shm differ
diff --git a/dev_test_scripts/debug/calendar_batch_test/cal_test_1788224830032.db-wal b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788224830032.db-wal
new file mode 100644
index 0000000..64cb1fc
Binary files /dev/null and b/dev_test_scripts/debug/calendar_batch_test/cal_test_1788224830032.db-wal differ
diff --git a/dev_test_scripts/debug/debug_calendar_0411_batch.js b/dev_test_scripts/debug/debug_calendar_0411_batch.js
new file mode 100644
index 0000000..5f6564d
--- /dev/null
+++ b/dev_test_scripts/debug/debug_calendar_0411_batch.js
@@ -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();
\ No newline at end of file
diff --git a/dev_test_scripts/debug/debug_calendar_succulent.js b/dev_test_scripts/debug/debug_calendar_succulent.js
new file mode 100644
index 0000000..d7df6f4
--- /dev/null
+++ b/dev_test_scripts/debug/debug_calendar_succulent.js
@@ -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();
diff --git a/dev_test_scripts/debug/debug_sql_check_unknown_col.js b/dev_test_scripts/debug/debug_sql_check_unknown_col.js
new file mode 100644
index 0000000..9a84b4e
--- /dev/null
+++ b/dev_test_scripts/debug/debug_sql_check_unknown_col.js
@@ -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 {}
+ }
+})
diff --git a/dev_test_scripts/debug/diag_birthday_sql.js b/dev_test_scripts/debug/diag_birthday_sql.js
new file mode 100644
index 0000000..02e167e
--- /dev/null
+++ b/dev_test_scripts/debug/diag_birthday_sql.js
@@ -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)
diff --git a/dev_test_scripts/unit/_calendar_reminder_diag.js b/dev_test_scripts/unit/_calendar_reminder_diag.js
new file mode 100644
index 0000000..1c85271
--- /dev/null
+++ b/dev_test_scripts/unit/_calendar_reminder_diag.js
@@ -0,0 +1,1338 @@
+const express = require('express')
+const Database = require('better-sqlite3')
+const path = require('path')
+const axios = require('axios')
+const fs = require('fs')
+
+const readCookieCfg = () => {
+ const cfgPath = path.join(process.cwd(), 'public', 'tools', 'calendar_reminder', 'auth_config.json')
+ let name = 'calendar_reminder_gate'
+ let maxAge = 90 * 24 * 3600 * 1000
+ let enable = true
+ try {
+ if (fs.existsSync(cfgPath)) {
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
+ if (cfg.cookieName) name = String(cfg.cookieName)
+ const days = Number(cfg.max_age_days || 0)
+ if (Number.isFinite(days) && days > 0) maxAge = Math.min(days, 365) * 24 * 3600 * 1000
+ if (cfg.enable_auth === false) enable = false
+ }
+ } catch {}
+ return { name, maxAge, enable }
+}
+const DB_PATH = path.join(process.cwd(), "data/calendar_reminder_backups/_diag", "calendar_reminder.db")
+let db = null
+
+// Config
+const EXT_API_URL = `http://127.0.0.1:${process.env.PORT || 8976}/api/weekly/ext/event`
+const EXT_API_KEY = process.env.CALENDAR_REMINDER_API_KEY || process.env.SYSTEM_AUTH_PASS_93220 || ''
+const EXT_API_SOURCE = 'crm' // Kept from C# default
+const readPublicApiKey = () => {
+ try {
+ const cfgPath = path.join(process.cwd(), 'config', 'calendar_reminder.json')
+ if (fs.existsSync(cfgPath)) {
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
+ const k = String(cfg.PUBLIC_API_KEY || cfg.public_api_key || '').trim()
+ if (k) return k
+ }
+ } catch {}
+ return '099239923'
+}
+const PUBLIC_API_KEY = readPublicApiKey()
+
+const parseCookie = (str) => {
+ const list = {}
+ if (!str) return list
+ str.split(';').forEach((cookie) => {
+ const parts = cookie.split('=')
+ list[parts.shift().trim()] = decodeURI(parts.join('='))
+ })
+ return list
+}
+
+const checkAuth = (req, res, next) => {
+ const cookies = parseCookie(req.headers.cookie || '')
+ const { name, enable } = readCookieCfg()
+ if (enable === false) return next()
+ if (cookies[name] === '1') return next()
+ try {
+ const ref = String(req.headers['referer'] || req.headers['referrer'] || '')
+ if (ref.includes('/tools/calendar_voice/')) return next()
+ } catch {}
+
+ const hasOrigin = !!req.headers['origin']
+ const hasSecFetch = !!req.headers['sec-fetch-mode']
+ if (!hasOrigin && !hasSecFetch) return next()
+
+ return res.status(401).json({ error: 'unauthorized', ok: false })
+}
+
+const ensureDb = () => {
+ if (db) return
+ db = new Database(DB_PATH)
+ db.pragma('journal_mode = WAL')
+
+ // Create table if not exists (migrated from C#)
+ db.exec(`CREATE TABLE IF NOT EXISTS events (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ content TEXT,
+ start TEXT NOT NULL,
+ end TEXT,
+ allDay INTEGER NOT NULL DEFAULT 0,
+ remindAt TEXT,
+ recurrence TEXT,
+ createdAt TEXT,
+ updatedAt TEXT,
+ completed INTEGER NOT NULL DEFAULT 0,
+ deleted INTEGER NOT NULL DEFAULT 0
+ );`)
+
+ // Ensure columns
+ 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
+const getUUID = () => {
+ try { return require('crypto').randomUUID() } catch { return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) }
+}
+
+const syncExternalUpsert = async (ev) => {
+ try {
+ const title = (ev.title || '').trim()
+ const contentRaw = (ev.content || '').trim()
+ const start = ev.start
+ const end = ev.end
+ const remindAt = ev.remindAt
+
+ // Logic from C# BuildExternalContent
+ let contentBuilt = contentRaw
+ if (remindAt) contentBuilt = `[提醒: ${remindAt}]\n${contentBuilt}`
+ if (end) contentBuilt = `[结束: ${end}]\n${contentBuilt}`
+ if (ev.recurrence) contentBuilt = `[循环: ${ev.recurrence}]\n${contentBuilt}`
+ contentBuilt = contentBuilt.trim()
+
+ // Date logic
+ const sDate = start ? start.slice(0, 10) : new Date().toISOString().slice(0, 10)
+
+ const payload = {
+ key: EXT_API_KEY,
+ source: EXT_API_SOURCE,
+ external_id: ev.id,
+ date: sDate,
+ title: title,
+ content: contentBuilt,
+ status_code: ev.completed ? 'done' : (ev.deleted ? 'deleted' : 'new'),
+ status_label: ev.completed ? '✅已完成' : (ev.deleted ? '🗑已删除' : '📅日程'),
+ deadline: remindAt || end || start
+ }
+
+ if (ev.deleted) payload.action = 'delete'
+
+ await axios.post(EXT_API_URL, payload, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }).catch(err => console.error('Sync failed', err.message))
+ } catch (e) {
+ console.error('Sync error', e)
+ }
+}
+
+const syncExternalDelete = async (id) => {
+ try {
+ const payload = {
+ key: EXT_API_KEY,
+ source: EXT_API_SOURCE,
+ external_id: id,
+ action: 'delete',
+ deleted: true
+ }
+ await axios.post(EXT_API_URL, payload, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }).catch(err => console.error('Sync delete failed', err.message))
+ } catch (e) {
+ console.error('Sync delete error', e)
+ }
+}
+
+const handleGet = (req, res) => {
+ const { since, year, month } = req.query
+ let rows = []
+
+ if (since) {
+ rows = db.prepare("SELECT * FROM events WHERE updatedAt >= ?").all(since)
+ } else if (year && month) {
+ const y = parseInt(year)
+ const m = parseInt(month)
+ // Construct simplified date strings for comparison (sqlite stores text ISO)
+ // C# used DateTime comparison. Here we rely on ISO string comparison which works for YYYY-MM-DD
+ // But events.start might be YYYY-MM-DDTHH:mm:ss.
+ // Construct range:
+ const startStr = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0)).toISOString()
+ // End of month
+ const endStr = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999)).toISOString()
+
+ rows = db.prepare("SELECT * FROM events WHERE start >= ? AND start <= ?").all(startStr, endStr)
+ } else {
+ rows = db.prepare("SELECT * FROM events").all()
+ }
+
+ try {
+ const qInfo = since ? `since=${since}` : (year ? `year=${year},month=${month}` : 'ALL');
+ console.log(`[CalendarReminder] GetEvents ${qInfo} -> ${rows.length} rows`);
+ } catch(e){}
+
+ const data = rows.map(r => ({
+ id: r.id,
+ title: r.title,
+ content: r.content || '',
+ start: r.start,
+ end: r.end,
+ allDay: !!r.allDay,
+ remindAt: r.remindAt,
+ recurrence: r.recurrence,
+ createdAt: r.createdAt,
+ updatedAt: r.updatedAt,
+ completed: !!r.completed,
+ deleted: !!r.deleted
+ }))
+
+ res.json({ ok: true, data })
+}
+
+const handlePost = async (req, res) => {
+ const ev = req.body || {}
+
+ // Deduplication: if title and start match an existing active event, update it instead of creating new
+ 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
+ }
+ } catch (e) { console.error('Deduplication check failed', e) }
+ }
+
+ if (!ev.id) ev.id = getUUID()
+
+ // Defaults
+ ev.content = ev.content || ''
+ ev.createdAt = ev.createdAt || new Date().toISOString()
+ ev.updatedAt = ev.updatedAt || new Date().toISOString()
+ ev.completed = ev.completed ? 1 : 0
+ ev.deleted = ev.deleted ? 1 : 0
+ 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,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 || null,
+ allDay: ev.allDay,
+ remindAt: ev.remindAt || null,
+ recurrence: ev.recurrence || null,
+ createdAt: ev.createdAt,
+ updatedAt: ev.updatedAt,
+ completed: ev.completed,
+ deleted: ev.deleted,
+ batch_id: ev.batch_id || null
+ })
+
+ await syncExternalUpsert(ev)
+ res.json({ ok: true })
+}
+
+const handlePut = async (req, res) => {
+ // C# logic: UPDATE specific fields
+ // Node logic: Similar
+ let ev = req.body || {}
+ const id = req.query.id || ev.id
+ if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
+
+ ev.id = id
+ ev.updatedAt = new Date().toISOString()
+
+ // Need to read existing event to merge?
+ // C# HandlePut updates ALL fields provided in the query/body.
+ // It runs an UPDATE statement setting all fields.
+
+ // Since we might not have all fields in body, we should probably merge or expect full object?
+ // C# HandlePut SQL: UPDATE events SET title=@title, ... WHERE id=@id.
+ // It expects the body to contain the fields.
+ // Frontend sends full object usually.
+
+ // Let's assume body contains fields to update.
+ // However, simple SQL UPDATE requires all params.
+ // We can use COALESCE or read-modify-write.
+
+ const existing = db.prepare("SELECT * FROM events WHERE id = ?").get(id)
+ if (!existing) return res.status(404).json({ ok: false, error: 'not found' })
+
+ const merged = { ...existing, ...ev }
+ merged.completed = merged.completed ? 1 : 0
+ merged.deleted = merged.deleted ? 1 : 0
+ merged.allDay = merged.allDay ? 1 : 0
+
+ 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,batch_id=@batch_id WHERE id=@id`)
+
+ stmt.run({
+ id: merged.id,
+ title: merged.title,
+ content: merged.content,
+ start: merged.start,
+ end: merged.end,
+ allDay: merged.allDay,
+ remindAt: merged.remindAt,
+ recurrence: merged.recurrence,
+ updatedAt: merged.updatedAt,
+ completed: merged.completed,
+ deleted: merged.deleted,
+ batch_id: merged.batch_id !== undefined ? merged.batch_id : existing.batch_id
+ })
+
+ await syncExternalUpsert(merged)
+ res.json({ ok: true })
+}
+
+const handleDelete = async (req, res) => {
+ const id = req.query.id
+ if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
+
+ const updatedAt = new Date().toISOString()
+ db.prepare("UPDATE events SET deleted=1, updatedAt=? WHERE id=?").run(updatedAt, id)
+
+ await syncExternalDelete(id)
+ 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++
+ // SQLite syntax check via EXPLAIN
+ try {
+ db.prepare('EXPLAIN ' + s).all()
+ } catch (e) {
+ out.syntax_errors.push({ stmt_idx: si, message: String(e.message || e), preview: s.slice(0, 160) })
+ continue
+ }
+ const parsed = 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()
+
+ app.get('/api/calendar/sys-config', (req, res) => {
+ try {
+ const cfgPath = path.join(process.cwd(), 'config', 'calendar_reminder.json')
+ let cfg = {}
+ if (fs.existsSync(cfgPath)) {
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) } catch {}
+ }
+ res.json({ ok: true, config: cfg })
+ } catch (e) {
+ res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ app.get('/api/calendar/auth', checkAuth, (req, res) => {
+ res.json({ ok: true, enabled: true })
+ })
+
+ // Public (no-auth) endpoints
+ app.get('/api/calendar/public/auth', (req, res) => {
+ res.json({ ok: true, enabled: true, public: true })
+ })
+ app.all('/api/calendar/public/events', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
+ const key = String(req.query.key || '')
+ if (!key || key !== PUBLIC_API_KEY) {
+ return res.status(403).json({ ok: false, error: 'forbidden' })
+ }
+ if (typeof req.body === 'string') {
+ try { req.body = JSON.parse(req.body) } catch {}
+ }
+ try {
+ let method = req.method
+ if (req.query._method) method = req.query._method.toUpperCase()
+
+ if (method === 'GET') await handleGet(req, res)
+ else if (method === 'POST') await handlePost(req, res)
+ else if (method === 'PUT') await handlePut(req, res)
+ else if (method === 'DELETE') await handleDelete(req, res)
+ else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message })
+ }
+ })
+
+ // 专门为外部程序(如WinForm)提供的完全开放接口
+ app.all('/api/calendar/external/events', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
+ // 设置CORS头,允许所有来源访问
+ res.setHeader('Access-Control-Allow-Origin', '*')
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
+
+ if (req.method === 'OPTIONS') {
+ return res.status(200).end()
+ }
+
+ if (typeof req.body === 'string') {
+ try { req.body = JSON.parse(req.body) } catch {}
+ }
+
+ try {
+ let method = req.method
+ if (req.query._method) method = req.query._method.toUpperCase()
+
+ if (method === 'GET') await handleGet(req, res)
+ else if (method === 'POST') await handlePost(req, res)
+ else if (method === 'PUT') await handlePut(req, res)
+ else if (method === 'DELETE') await handleDelete(req, res)
+ else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message })
+ }
+ })
+
+ // Legacy route compatibility for CalendarReminder_WebApp
+ app.all('/Api/Events.ashx', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
+ res.setHeader('Access-Control-Allow-Origin', '*')
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
+ if (req.method === 'OPTIONS') return res.status(200).end()
+
+ if (typeof req.body === 'string') {
+ try { req.body = JSON.parse(req.body) } catch {}
+ }
+
+ try {
+ let method = req.method
+ if (req.query._method) method = req.query._method.toUpperCase()
+
+ if (method === 'GET') await handleGet(req, res)
+ else if (method === 'POST') await handlePost(req, res)
+ else if (method === 'PUT') await handlePut(req, res)
+ else if (method === 'DELETE') await handleDelete(req, res)
+ else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message })
+ }
+ })
+
+ // 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.
+ // We handle text body if json parse fails or if type is text.
+ if (typeof req.body === 'string') {
+ try { req.body = JSON.parse(req.body) } catch {}
+ }
+
+ try {
+ let method = req.method
+ if (req.query._method) method = req.query._method.toUpperCase()
+
+ if (method === 'GET') await handleGet(req, res)
+ else if (method === 'POST') await handlePost(req, res)
+ else if (method === 'PUT') await handlePut(req, res)
+ else if (method === 'DELETE') await handleDelete(req, res)
+ else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message })
+ }
+ })
+}
+
+module.exports = { bindRoutes }
diff --git a/dev_test_scripts/unit/test_calendar_batch_sql.js b/dev_test_scripts/unit/test_calendar_batch_sql.js
new file mode 100644
index 0000000..1dbfcbf
--- /dev/null
+++ b/dev_test_scripts/unit/test_calendar_batch_sql.js
@@ -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)
+ }
+ })
+})()
diff --git a/dev_test_scripts/unit/test_calendar_batch_tool.js b/dev_test_scripts/unit/test_calendar_batch_tool.js
new file mode 100644
index 0000000..fbff050
--- /dev/null
+++ b/dev_test_scripts/unit/test_calendar_batch_tool.js
@@ -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();
+ }
+});
diff --git a/miniprogram/mp_time_assistant/api.js b/miniprogram/mp_time_assistant/api.js
new file mode 100644
index 0000000..16936ef
--- /dev/null
+++ b/miniprogram/mp_time_assistant/api.js
@@ -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,
+}
diff --git a/miniprogram/mp_time_assistant/app.js b/miniprogram/mp_time_assistant/app.js
new file mode 100644
index 0000000..17fadc4
--- /dev/null
+++ b/miniprogram/mp_time_assistant/app.js
@@ -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) {}
+ },
+})
diff --git a/miniprogram/mp_time_assistant/app.json b/miniprogram/mp_time_assistant/app.json
new file mode 100644
index 0000000..af5e574
--- /dev/null
+++ b/miniprogram/mp_time_assistant/app.json
@@ -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"
+}
diff --git a/miniprogram/mp_time_assistant/app.wxss b/miniprogram/mp_time_assistant/app.wxss
new file mode 100644
index 0000000..8fe6630
--- /dev/null
+++ b/miniprogram/mp_time_assistant/app.wxss
@@ -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);
+}
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.js b/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.js
new file mode 100644
index 0000000..c775c91
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.js
@@ -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,
+ })
+ }
+ },
+ })
+ },
+})
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.json b/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.json
new file mode 100644
index 0000000..4c016f5
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.json
@@ -0,0 +1,4 @@
+{
+ "navigationBarTitleText": "收藏详情",
+ "backgroundTextStyle": "light"
+}
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.wxml b/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.wxml
new file mode 100644
index 0000000..ce28de7
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.wxml
@@ -0,0 +1,69 @@
+
+ 加载中...
+
+
+
+
+ ⚠️ {{error}}
+
+
+
+
+ 未找到该收藏
+
+
+
+
+ {{item.title}}
+
+
+ {{item.remark}}
+
+
+
+ #{{tag}}
+
+
+
+
+
+
+ 创建时间
+ {{createdAtText || '-'}}
+
+
+ 更新时间
+ {{updatedAtText || '-'}}
+
+
+
+
+ 附件 ({{item.attachments.length}})
+
+
+
+
+
+
+
+ 📄
+
+ {{att.fileName}}
+
+
+
+
+ 💡 非图片附件点击名称即可下载并使用微信自带文档查看器打开。
+
+
+
+
+
+ 删除
+ 编辑
+
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.wxss b/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.wxss
new file mode 100644
index 0000000..eaa18c7
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/detail/detail.wxss
@@ -0,0 +1 @@
+/* 详情页独立样式,大部分复用 app.wxss 全局样式 */
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.js b/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.js
new file mode 100644
index 0000000..cd98556
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.js
@@ -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 })
+ }
+ },
+})
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.json b/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.json
new file mode 100644
index 0000000..c34c3a0
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.json
@@ -0,0 +1,4 @@
+{
+ "navigationBarTitleText": "编辑收藏",
+ "backgroundTextStyle": "light"
+}
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.wxml b/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.wxml
new file mode 100644
index 0000000..53cc5ae
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.wxml
@@ -0,0 +1,101 @@
+
+ 加载中...
+
+
+
+
+ ⚠️ {{error}}
+
+
+
+ 标题
+
+
+
+
+ 备注
+
+
+
+
+ 标签(逗号或空格分隔,如:工作, 重要)
+
+
+
+
+
+ 附件(从聊天选择,最多 9 个)
+ + 从聊天选
+
+
+
+ 还没有附件,点右上角按钮选择
+
+
+
+
+
+
+
+
+
+ 📄
+
+
+ {{item.fileName}}
+
+
+ 上传中...
+
+
+
+ 上传失败
+ 重试
+
+
+ ×
+
+
+
+
+
+
+
+
+ {{isEdit ? (saving ? '保存中...' : '保存修改') : (saving ? '创建中...' : '创建收藏')}}
+
+
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.wxss b/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.wxss
new file mode 100644
index 0000000..2f6c64e
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/edit/edit.wxss
@@ -0,0 +1 @@
+/* 编辑页独立样式,大部分复用 app.wxss 全局样式 */
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.js b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.js
new file mode 100644
index 0000000..1fd6019
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.js
@@ -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',
+ })
+ },
+})
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.json b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.json
new file mode 100644
index 0000000..9492c79
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.json
@@ -0,0 +1,5 @@
+{
+ "navigationBarTitleText": "微信收藏夹",
+ "enablePullDownRefresh": true,
+ "backgroundTextStyle": "light"
+}
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxml b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxml
new file mode 100644
index 0000000..8984437
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxml
@@ -0,0 +1,77 @@
+
+
+
+
+ 🔍
+
+
+ ×
+
+
+
+
+ ⚠️ {{error}}
+
+
+ 加载中...
+
+
+
+ 📁
+ {{keyword ? '没有匹配的收藏' : '还没有收藏,点右下角 + 新建'}}
+
+
+
+
+
+
+
+ {{item.title}}
+ {{item.remark}}
+ {{item.remark}}
+
+
+
+
+
+ 📎
+ {{item.fileCount}} 个
+
+
+
+
+
+ #{{tag}}
+
+
+
+
+ 更新于 {{item.updatedAtText}}
+
+
+
+ 查看
+ 编辑
+
+ 删除
+
+
+
+
+
++
diff --git a/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxss b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxss
new file mode 100644
index 0000000..ac9e9ee
--- /dev/null
+++ b/miniprogram/mp_time_assistant/kits/personal_collect/pages/list/list.wxss
@@ -0,0 +1 @@
+/* 列表页独立样式,大部分复用 app.wxss 全局样式 */
diff --git a/miniprogram/mp_time_assistant/pages/hub/hub.js b/miniprogram/mp_time_assistant/pages/hub/hub.js
new file mode 100644
index 0000000..7954ecd
--- /dev/null
+++ b/miniprogram/mp_time_assistant/pages/hub/hub.js
@@ -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' })
+ },
+ })
+ },
+})
diff --git a/miniprogram/mp_time_assistant/pages/hub/hub.json b/miniprogram/mp_time_assistant/pages/hub/hub.json
new file mode 100644
index 0000000..5ec62e8
--- /dev/null
+++ b/miniprogram/mp_time_assistant/pages/hub/hub.json
@@ -0,0 +1,5 @@
+{
+ "navigationBarTitleText": "MilesYang时光助手",
+ "enablePullDownRefresh": true,
+ "backgroundTextStyle": "light"
+}
diff --git a/miniprogram/mp_time_assistant/pages/hub/hub.wxml b/miniprogram/mp_time_assistant/pages/hub/hub.wxml
new file mode 100644
index 0000000..e1d9e4a
--- /dev/null
+++ b/miniprogram/mp_time_assistant/pages/hub/hub.wxml
@@ -0,0 +1,35 @@
+
+
+
+
+
+ 📝 检测到剪贴板文字
+ {{clipCount}} 字 · {{clipLines}} 行
+
+ {{clipPreview}}
+
+ 不再提示
+ 取消
+ 📌 收藏为文字
+
+
+
+ 加载中...
+
+
+
+
+ {{item.icon}}
+
+
+ {{item.name}}
+ {{item.description}}
+
+ ›
+
+
+
diff --git a/miniprogram/mp_time_assistant/pages/hub/hub.wxss b/miniprogram/mp_time_assistant/pages/hub/hub.wxss
new file mode 100644
index 0000000..bacca83
--- /dev/null
+++ b/miniprogram/mp_time_assistant/pages/hub/hub.wxss
@@ -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);
+}
diff --git a/miniprogram/mp_time_assistant/project.config.json b/miniprogram/mp_time_assistant/project.config.json
new file mode 100644
index 0000000..9520dd6
--- /dev/null
+++ b/miniprogram/mp_time_assistant/project.config.json
@@ -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": {}
+}
\ No newline at end of file
diff --git a/miniprogram/mp_time_assistant/project.private.config.json b/miniprogram/mp_time_assistant/project.private.config.json
new file mode 100644
index 0000000..1850d96
--- /dev/null
+++ b/miniprogram/mp_time_assistant/project.private.config.json
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/miniprogram/mp_time_assistant/sitemap.json b/miniprogram/mp_time_assistant/sitemap.json
new file mode 100644
index 0000000..ff55455
--- /dev/null
+++ b/miniprogram/mp_time_assistant/sitemap.json
@@ -0,0 +1,7 @@
+{
+ "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/sitemap.html",
+ "rules": [{
+ "action": "disallow",
+ "page": "*"
+ }]
+}
diff --git a/public/tools/calendar_reminder/auth_config.json b/public/tools/calendar_reminder/auth_config.json
index b670a05..c75978d 100644
--- a/public/tools/calendar_reminder/auth_config.json
+++ b/public/tools/calendar_reminder/auth_config.json
@@ -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": ""
+}
\ No newline at end of file
diff --git a/public/tools/calendar_reminder/batch_tool.html b/public/tools/calendar_reminder/batch_tool.html
new file mode 100644
index 0000000..47ebd14
--- /dev/null
+++ b/public/tools/calendar_reminder/batch_tool.html
@@ -0,0 +1,264 @@
+
+
+
+
+
+ 日历批量生成工具 · 独立窗口
+
+
+
+
+
+
+
+
🧺 日历批量生成工具 · 仅循环场景
+
+
+
+
+
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
样例(前 20 条):
+
+
SQL(前 503 行,完整的请下载文件):
+
+
+
+
+
+
+
+
+
+
+ | 批次名 | 模式 | 时间范围 |
+ 生成 / 入库 / 去重 / 已回滚 |
+ 状态 | 创建时间 | 操作 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/tools/calendar_reminder/css/bootstrap.local.css b/public/tools/calendar_reminder/css/bootstrap.local.css
deleted file mode 100644
index 98b6e11..0000000
--- a/public/tools/calendar_reminder/css/bootstrap.local.css
+++ /dev/null
@@ -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}
\ No newline at end of file
diff --git a/public/tools/calendar_reminder/css/styles.css b/public/tools/calendar_reminder/css/styles.css
index 3cc76bb..44771c0 100644
--- a/public/tools/calendar_reminder/css/styles.css
+++ b/public/tools/calendar_reminder/css/styles.css
@@ -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; }
+
diff --git a/public/tools/calendar_reminder/index.html b/public/tools/calendar_reminder/index.html
index f74457b..9f0135f 100644
--- a/public/tools/calendar_reminder/index.html
+++ b/public/tools/calendar_reminder/index.html
@@ -6,8 +6,8 @@
日历提醒系统 · Web
-
-
+
+
@@ -19,6 +19,7 @@
+
@@ -127,11 +128,11 @@
-
+
diff --git a/public/tools/calendar_reminder/js/batch.js b/public/tools/calendar_reminder/js/batch.js
new file mode 100644
index 0000000..f32a1e1
--- /dev/null
+++ b/public/tools/calendar_reminder/js/batch.js
@@ -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 = `预生成:${data.generated} 条。模式:${data.summary},区间:${data.range_start} ~ ${data.range_end}。batch_id = ${data.batchId}`
+ $('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 = `${s.start || ''} | ${s.end || ''} | ${s.remindAt || ''} | ${s.title || ''} | `
+ 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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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 = `
+ 阈值:重复相似度 ≥ ${data.duplicate_threshold || 0.65},最多 ${data.max_rows_limit || 500} 条,执行超时 ${Math.round((data.max_ms_limit || 60000)/1000)}s。
+ 检测到 ${data.pre_existing_count || 0} 条严格匹配(title,start) 预重(入库时会自动去重复用原 id)
+ 检测到 ${data.high_confidence_duplicates || 0} 条宽松匹配高置信重复(LIKE % + ±10min)。时间范围:${data.range_start || '未知起'} ~ ${data.range_end || '未知止'}
+ `
+ // 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) => `#${idx+1} [stmt ${e.stmt_idx ?? '-'}${e.row_idx != null ? `, row ${e.row_idx}` : ''}] ${escapeHtml(e.reason || e.message || '')}${e.preview ? `
${escapeHtml(e.preview)}` : ''}${e.title ? `
title=${escapeHtml(e.title)}` : ''}`).join('')
+ el.innerHTML = `${headerText}
`
+ }
+ 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 = '| 空 |
'
+ else meta.forEach(r => {
+ const tr = document.createElement('tr')
+ tr.innerHTML = `${r.stmt_idx ?? ''} | ${r.row_idx ?? ''} | ${escapeHtml(r.title || '')} | ${escapeHtml(r.start || '')} | ${escapeHtml(r.end || '')} | `
+ 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 = `共 ${rows.length} 条你的 SQL 行命中了库内现有事件(宽松模式)。确认要继续执行,这些行也会进入去重逻辑命中 UPDATE(而不是 INSERT 新行)。`
+ const tb = $('sql_dupTbody')
+ tb.innerHTML = ''
+ rows.forEach(d => {
+ const mainMatch = (d.match || [])[0] || {}
+ const tr = document.createElement('tr')
+ tr.innerHTML = `
+ ${d.stmt_idx ?? ''}-${d.row_idx ?? ''} |
+ ${escapeHtml(d.title_left || '')} |
+ ${escapeHtml(d.start_left || '')} |
+ ${escapeHtml(mainMatch.title || '')} |
+ ${escapeHtml(mainMatch.start || '')} |
+ ${Number(d.score || 0).toFixed(2)} |
+ 打开主日历核对 | `
+ 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 `${st}`
+ }
+ 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 = '| 暂无批次 |
'; return }
+ data.forEach(b => {
+ const tr = document.createElement('tr')
+ tr.innerHTML = `
+ ${b.title || '(未命名)'} ${b.id.slice(0, 10)}… |
+ ${b.summary || '-'} ${b.skip_external_sync ? '⏸不联动下游' : ''} |
+ ${b.range_start || ''} ~ ${b.range_end || ''} |
+ ${b.generated} / ${b.inserted} / ${b.dedup} / ${b.rolled_back} 预重${b.pre_existing} |
+ ${badgeFor(b.status)} |
+ ${(b.created_at || '').slice(0, 16).replace('T', ' ')} |
+
+
+
+
+
+
+
+ | `
+ 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 => `| ${s.start || ''} | ${s.end || ''} | ${s.remindAt || ''} | ${s.title || ''} | ${s.completed ? '✅' : ''}${s.deleted ? '🗑' : ''} |
`).join('')
+ const diags = j.diagnostics || {}
+ const diagBtn = (label, keyName) => ``
+ $('batch_detailBody').innerHTML = `
+ ${b.title || '(未命名)'} id: ${b.id}
+ 模式:${b.meta && b.meta.summary} · 范围 ${b.meta && b.meta.rangeStart} ~ ${b.meta && b.meta.rangeEnd} · 创建 ${(b.created_at || '').slice(0,19).replace('T',' ')}
+ 生成:${b.generated_count},入库:${b.inserted_count},去重:${b.dedup_count},预重:${b.pre_existing_count},回滚:${b.rollback_count},状态:${badgeFor(b.status)} ${b.skip_external_sync ? '不联动下游':''}
+ 当前表计数:active ${counts.active} / deleted ${counts.deleted}
+ 诊断 SQL(一键复制):
+
+ ${diagBtn('按 batch_id 清单', 'by_batch_id')}
+ ${diagBtn('按 batch_id 计数', 'counts_by_batch')}
+ ${diagBtn('孤儿(batch_id 存在但 calendar_batches 中无记录)检查', 'orphans_check')}
+
+
+ 样例(前 30 条):
+
+ `
+ $('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 }
+})()
diff --git a/public/tools/calendar_reminder/js/open_batch.js b/public/tools/calendar_reminder/js/open_batch.js
new file mode 100644
index 0000000..ec03aaf
--- /dev/null
+++ b/public/tools/calendar_reminder/js/open_batch.js
@@ -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';
+ }
+ });
+ });
+})();
diff --git a/public/tools/web_mp_time_assistant/api.js b/public/tools/web_mp_time_assistant/api.js
new file mode 100644
index 0000000..746975f
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/api.js
@@ -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
+ }
+})()
diff --git a/public/tools/web_mp_time_assistant/auth_config.json b/public/tools/web_mp_time_assistant/auth_config.json
new file mode 100644
index 0000000..68ca0c8
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/auth_config.json
@@ -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 名称,默认格式 _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": ""
+}
diff --git a/public/tools/web_mp_time_assistant/hub.css b/public/tools/web_mp_time_assistant/hub.css
new file mode 100644
index 0000000..3a17a7b
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/hub.css
@@ -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; }
+}
diff --git a/public/tools/web_mp_time_assistant/hub.html b/public/tools/web_mp_time_assistant/hub.html
new file mode 100644
index 0000000..bb9037f
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/hub.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+ MilesYang时光助手
+
+
+
+
+
+
+
+
+
+
diff --git a/public/tools/web_mp_time_assistant/hub.js b/public/tools/web_mp_time_assistant/hub.js
new file mode 100644
index 0000000..af4b351
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/hub.js
@@ -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 = '暂无可用工具
'
+ return
+ }
+ $grid.innerHTML = kits.map((kit) => {
+ const page = KIT_PAGES[kit.id] || '#'
+ const icon = KIT_ICONS[kit.id] || '🧰'
+ return (
+ `` +
+ `
${icon}
` +
+ `
${escapeHtml(kit.name || kit.id)}
` +
+ `
${escapeHtml(kit.description || '')}
` +
+ `
进入→
` +
+ `
`
+ )
+ }).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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ }
+
+ 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 = `❌ 加载失败:${escapeHtml(e.message || '后端无响应')}
`
+ }
+ }
+
+ document.addEventListener('DOMContentLoaded', main)
+})()
diff --git a/public/tools/web_mp_time_assistant/index.html b/public/tools/web_mp_time_assistant/index.html
new file mode 100644
index 0000000..bb9037f
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/index.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+ MilesYang时光助手
+
+
+
+
+
+
+
+
+
+
diff --git a/public/tools/web_mp_time_assistant/kits/common/dialog.js b/public/tools/web_mp_time_assistant/kits/common/dialog.js
new file mode 100644
index 0000000..e9aa632
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/kits/common/dialog.js
@@ -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, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''')
+ }
+
+ 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 =
+ '' +
+ '
' + escapeHtml(title) + '
' +
+ '
' + escapeHtml(desc) + '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
'
+ 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 =
+ '' +
+ '' + escapeHtml(name) + ' · 交互详情' +
+ '复制' +
+ '
' +
+ '' + escapeHtml(logs.join('\n') + (timer ? `\n\n倒计时关闭:${remainSec}s` : '') + `\n\n耗时:${elapsedSec}s`) + '
' +
+ '' +
+ '
' + (timer ? `自动关闭中:${remainSec}s` : (forceStay ? '已停留,有错误/异常' : '已完成')) + '' +
+ '
' +
+ (timer ? '' : '') +
+ '' +
+ '
' +
+ '
'
+ 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
+ }
+})()
diff --git a/public/tools/web_mp_time_assistant/kits/personal_collect/detail.css b/public/tools/web_mp_time_assistant/kits/personal_collect/detail.css
new file mode 100644
index 0000000..819eb00
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/kits/personal_collect/detail.css
@@ -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; }
+}
diff --git a/public/tools/web_mp_time_assistant/kits/personal_collect/detail.html b/public/tools/web_mp_time_assistant/kits/personal_collect/detail.html
new file mode 100644
index 0000000..3d1ec0a
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/kits/personal_collect/detail.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+ 收藏详情 | MilesYang时光助手
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/tools/web_mp_time_assistant/kits/personal_collect/detail.js b/public/tools/web_mp_time_assistant/kits/personal_collect/detail.js
new file mode 100644
index 0000000..9cdf3f8
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/kits/personal_collect/detail.js
@@ -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 (
+ `` +
+ `图片
` +
+ `` +
+ 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)
+ ? `
${escapeHtml(name)}
` : ''
+ return `

${badge}
`
+ }).join('') +
+ `
` +
+ ``
+ )
+ }
+
+ function renderFileSection(files) {
+ if (!files || !files.length) return ''
+ return (
+ `` +
+ `文件 (${files.length})
` +
+ `` +
+ 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 (
+ `
` +
+ `
${fileIcon(name)}
` +
+ `
` +
+ `
${escapeHtml(name)}
` +
+ `
${humanSize(size)}${mimeType ? ' · ' + escapeHtml(mimeType) : ''}
` +
+ `
` +
+ `
` +
+ `
`
+ )
+ }).join('') +
+ `
` +
+ ``
+ )
+ }
+
+ 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)
+ ? `${item.tags.map(t => `${escapeHtml(t)}`).join('')}
`
+ : ''
+
+ const remarkBlock = (item.remark || '').trim()
+ ? ``
+ : ``
+
+ const titleBlock = dispTitle
+ ? ``
+ : ``
+
+ const metaBlock =
+ `` +
+ `` +
+ `创建时间${formatTime(createdAt)}` +
+ `更新时间${formatTime(updatedAt)}` +
+ (item.attachments ? `附件总数${item.attachments.length}` : '') +
+ `
` +
+ (tags ? `${tags}
` : '') +
+ ``
+
+ $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 = ``
+ 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 = ``
+ return
+ }
+ render(itemInner)
+ } catch (e) {
+ $wrap.innerHTML = `❌
加载失败
${escapeHtml(e.message || '请检查鉴权与后端状态')}
`
+ }
+ }
+
+ $back.addEventListener('click', goBack)
+ $edit.addEventListener('click', goEdit)
+ $refresh.addEventListener('click', load)
+ $delete.addEventListener('click', doDelete)
+
+ load()
+})()
diff --git a/public/tools/web_mp_time_assistant/kits/personal_collect/edit.css b/public/tools/web_mp_time_assistant/kits/personal_collect/edit.css
new file mode 100644
index 0000000..7470851
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/kits/personal_collect/edit.css
@@ -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; }
+}
diff --git a/public/tools/web_mp_time_assistant/kits/personal_collect/edit.html b/public/tools/web_mp_time_assistant/kits/personal_collect/edit.html
new file mode 100644
index 0000000..3c74662
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/kits/personal_collect/edit.html
@@ -0,0 +1,77 @@
+
+
+
+
+
+ 新增 / 编辑收藏 | MilesYang时光助手
+
+
+
+
+
+
+
+
+
+
+
新增收藏
+
支持拖拽 / Ctrl+V 粘贴 / 选择本地文件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/tools/web_mp_time_assistant/kits/personal_collect/edit.js b/public/tools/web_mp_time_assistant/kits/personal_collect/edit.js
new file mode 100644
index 0000000..f214f26
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/kits/personal_collect/edit.js
@@ -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 (
+ `` +
+ (isImg
+ ? `
`
+ : `
${fileIcon(a.fileName)}${ext || 'FILE'}
`) +
+ `
` +
+ `
${escapeHtml(a.fileName || '未命名')}
` +
+ `
` +
+ `${humanSize(a.fileSize)}` +
+ `${statusText}` +
+ a.errorMsg ? `${escapeHtml(a.errorMsg)}` : '' +
+ `
` +
+ `
` +
+ `
` +
+ `
` +
+ (a.status === 'err'
+ ? ``
+ : '') +
+ `` +
+ `
` +
+ `
`
+ )
+ }).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 =
+ `
+
+
📝 检测到剪贴板文字
+
${count} 字 · ${text.split('\n').length} 行
+
+
${escapeHtml(preview)}
+
+
+
+
+
+
`
+ 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 {} } })
+ })
+})()
diff --git a/public/tools/web_mp_time_assistant/kits/personal_collect/list.css b/public/tools/web_mp_time_assistant/kits/personal_collect/list.css
new file mode 100644
index 0000000..73821ca
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/kits/personal_collect/list.css
@@ -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;
+}
diff --git a/public/tools/web_mp_time_assistant/kits/personal_collect/list.html b/public/tools/web_mp_time_assistant/kits/personal_collect/list.html
new file mode 100644
index 0000000..15f31f1
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/kits/personal_collect/list.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+ 微信收藏夹 | MilesYang时光助手
+
+
+
+
+
+
+
+
+
+
+
📁 微信收藏夹
+
聊天附件 / 图片 / 文件,手机电脑实时同步
+
+
+
+
+
+
+
+
+
+ 🔍
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/tools/web_mp_time_assistant/kits/personal_collect/list.js b/public/tools/web_mp_time_assistant/kits/personal_collect/list.js
new file mode 100644
index 0000000..ca6fd6e
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/kits/personal_collect/list.js
@@ -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(``)
+ })
+ const fileSlots = Math.max(0, 4 - tiles.length)
+ files.slice(0, fileSlots).forEach((a) => {
+ tiles.push(
+ `` +
+ `${fileIcon(a.fileName)}` +
+ `${escapeHtml(a.fileName || '未命名')}` +
+ `
`
+ )
+ })
+ if (images.length + files.length > 4) {
+ const extra = (images.length + files.length) - 4
+ tiles.push(`+${extra}更多
`)
+ }
+ return `${tiles.join('')}
`
+ }
+
+ function renderItems(items) {
+ if (!items || !items.length) {
+ $wrap.innerHTML =
+ `` +
+ `
📂
` +
+ `
还没有收藏内容
` +
+ `
点击右上角「新增收藏」或右下角「+」开始保存
` +
+ `
`
+ return
+ }
+ $wrap.innerHTML = `` + 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) : '
(无标题)'
+ const tags = (it.tags && it.tags.length) ? it.tags.map(t => `
${escapeHtml(t)}`).join('') : ''
+
+ return (
+ `
` +
+ `` +
+ (it.remark ? `` : '') +
+ (tags ? `
${tags}
` : '') +
+ renderPreview(atts) +
+ `` +
+ `
`
+ )
+ }).join('') + `
`
+
+ 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 = `❌
加载失败
请检查后端是否已启动,并通过首页进入以完成鉴权
`
+ })
+ }
+
+ $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('')
+})()
diff --git a/public/tools/web_mp_time_assistant/shared.css b/public/tools/web_mp_time_assistant/shared.css
new file mode 100644
index 0000000..f9bea8b
--- /dev/null
+++ b/public/tools/web_mp_time_assistant/shared.css
@@ -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; }
+}
diff --git a/src/server/app_calendar_reminder.js b/src/server/app_calendar_reminder.js
index dfe61a3..4b9796f 100644
--- a/src/server/app_calendar_reminder.js
+++ b/src/server/app_calendar_reminder.js
@@ -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);
diff --git a/src/server/calendar_reminder.js b/src/server/calendar_reminder.js
index 4f249df..5c0052a 100644
--- a/src/server/calendar_reminder.js
+++ b/src/server/calendar_reminder.js
@@ -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.
diff --git a/src/server/index.js b/src/server/index.js
index 5a097dc..afb0bb2 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -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-')) {
diff --git a/src/server/mp_time_assistant/index.js b/src/server/mp_time_assistant/index.js
new file mode 100644
index 0000000..c5d5cad
--- /dev/null
+++ b/src/server/mp_time_assistant/index.js
@@ -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 }
diff --git a/src/server/mp_time_assistant/kits/personal_collect/index.js b/src/server/mp_time_assistant/kits/personal_collect/index.js
new file mode 100644
index 0000000..aa39312
--- /dev/null
+++ b/src/server/mp_time_assistant/kits/personal_collect/index.js
@@ -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 }
diff --git a/src/server/mp_time_assistant/kits/personal_collect/store.js b/src/server/mp_time_assistant/kits/personal_collect/store.js
new file mode 100644
index 0000000..47b3d32
--- /dev/null
+++ b/src/server/mp_time_assistant/kits/personal_collect/store.js
@@ -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,
+}