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}} + + + + 标题 + + + + + 备注 + + +
+ + + + 请先点「预览 SQL」再入库 +
+ +
+
+
预览信息
+
+
+
+
样例(前 20 条):
+
开始结束提醒标题
+
SQL(前 503 行,完整的请下载文件):
+ +
+
+
+ +
+
+
+ +
+
+ + + + + + + +
批次名模式时间范围生成 / 入库 / 去重 / 已回滚状态创建时间操作
+
+
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + + + 建议先点「检查」确认语法/重复后再执行 +
+
+
+
+ 🧪 检查结果 + +
+
+
+
+
+
+
+ ⚠️ 检测到重复(宽松模式): + +
+
样例行(前 15 条):
+
+
语句#行#标题开始结束
+
+
+
+
+
+
+ + + + + + + + + + + + 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 条):
    +
    ${rows || ''}
    开始结束提醒标题状态
    空
    + ` + $('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时光助手 + + + + +
    +
    +
    +

    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时光助手 + + + + +
    +
    +
    +

    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 = + '' + 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 `
    ${escapeHtml(name)}${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() + ? `
    备注
    ${escapeHtml(item.remark)}
    ` + : `
    备注
    (无备注)
    ` + + const titleBlock = dispTitle + ? `
    ${escapeHtml(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 = `
    ⚠️
    参数错误:缺少 id
    ` + 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 = `
    😶
    未找到该条收藏
    可能已被删除,或 ID 不正确
    ` + 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 粘贴 / 选择本地文件
    +
    +
    +
    + +
    +
    + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    +
    + +
    + + +
    +
    + + +
    +
    +
    📂
    +
    拖拽文件到此处上传
    +
    或直接 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 + ? `
    ${escapeHtml(a.fileName || '')}
    ` + : `
    ${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(`
    ${escapeHtml(a.fileName || 'image')}
    `) + }) + 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 ( + `
    ` + + `
    ` + + `
    ${title}
    ` + + `
    ${formatTime(it.updatedAt || it.updated_at || it.createdAt || it.created_at)}
    ` + + `
    ` + + (it.remark ? `
    ${escapeHtml(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, +}