feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能
- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API - 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据 - 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件 - 补充项目配置、站点地图、调试诊断脚本等配套文件 - 完善各模块的页面配置与样式文件
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,31 @@
|
|||||||
|
// 调试:确认 2026-04-11 批量插入的 47 条明细,以及是否属于"多肉养护-*"种子
|
||||||
|
const path = require('path');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db');
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
const rows = db.prepare(`SELECT id, substr(id,1,20) AS id_pre, title, substr(start,1,16) AS start, substr(createdAt,1,16) AS created, deleted, completed, remindAt
|
||||||
|
FROM events WHERE substr(createdAt,1,10) = '2026-04-11' ORDER BY title, start`).all();
|
||||||
|
|
||||||
|
console.log(`2026-04-11 写入总数=${rows.length}`);
|
||||||
|
const byTitle = {};
|
||||||
|
rows.forEach(r => {
|
||||||
|
byTitle[r.title] = (byTitle[r.title] || 0) + 1;
|
||||||
|
});
|
||||||
|
console.log('\n按 title 聚合:');
|
||||||
|
Object.entries(byTitle).sort((a,b)=>b[1]-a[1]).forEach(([t,c]) => console.log(` x${c} ${t}`));
|
||||||
|
|
||||||
|
// 标题含"多肉养护-"的数量
|
||||||
|
const succu = rows.filter(r => r.title && r.title.includes('多肉养护-'));
|
||||||
|
console.log(`\ntitle 含 "多肉养护-" 的数量=${succu.length}`);
|
||||||
|
succu.slice(0,10).forEach(r => console.log(` ${r.title} | start=${r.start} | id=${r.id_pre} | del=${r.deleted}`));
|
||||||
|
|
||||||
|
// 看是否有重复种子(比如未来重复批次)
|
||||||
|
const dup = db.prepare(`SELECT title, COUNT(1) c FROM events
|
||||||
|
WHERE title LIKE '多肉养护-%'
|
||||||
|
GROUP BY title HAVING c > 1
|
||||||
|
ORDER BY c DESC`).all();
|
||||||
|
console.log('\n"多肉养护-*" 重复 title 汇总:');
|
||||||
|
dup.forEach(r => console.log(` x${r.c} ${r.title}`));
|
||||||
|
|
||||||
|
db.close();
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// 调试:查询 calendar_reminder 数据库中"多肉"相关的批量插入记录统计
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db');
|
||||||
|
console.log('DB exists:', fs.existsSync(DB_PATH), DB_PATH);
|
||||||
|
if (!fs.existsSync(DB_PATH)) { process.exit(1); }
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH, { readonly: true });
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
console.log('\n=== 表结构 ===');
|
||||||
|
db.prepare("PRAGMA table_info(events)").all().forEach(c => {
|
||||||
|
console.log(` ${c.name} (${c.type})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = db.prepare('SELECT COUNT(1) AS c FROM events').get().c;
|
||||||
|
console.log(`\n总记录数 events: ${total}`);
|
||||||
|
|
||||||
|
const byDel = db.prepare('SELECT deleted, COUNT(1) AS c FROM events GROUP BY deleted').all();
|
||||||
|
console.log('\n按 deleted 分组:');
|
||||||
|
byDel.forEach(r => console.log(` deleted=${r.deleted} -> ${r.c}`));
|
||||||
|
|
||||||
|
// 查询"多肉"相关(title / content 包含"多肉")
|
||||||
|
const kw = '%多肉%';
|
||||||
|
const succuAll = db.prepare("SELECT COUNT(1) AS c FROM events WHERE title LIKE ? OR content LIKE ?").get(kw, kw).c;
|
||||||
|
console.log(`\n含「多肉」的事件(title/content): ${succuAll}`);
|
||||||
|
|
||||||
|
if (succuAll > 0) {
|
||||||
|
const rows = db.prepare("SELECT id, title, substr(content,1,80) AS content, start, end, remindAt, recurrence, createdAt, updatedAt, deleted, completed FROM events WHERE title LIKE ? OR content LIKE ? ORDER BY createdAt DESC LIMIT 50").all(kw, kw);
|
||||||
|
console.log(`\n多肉记录抽样(最近 50 条):`);
|
||||||
|
rows.forEach(r => {
|
||||||
|
const tag = [
|
||||||
|
r.deleted ? '🗑删' : null,
|
||||||
|
r.completed ? '✅完' : null,
|
||||||
|
r.recurrence ? '🔁' + r.recurrence : null,
|
||||||
|
r.remindAt ? '⏰' : null
|
||||||
|
].filter(Boolean).join('');
|
||||||
|
console.log(` [${r.createdAt ? r.createdAt.slice(0,16) : '?'}] ${r.id.slice(0,8)} | ${r.title || '(无题)'} ${tag} | start=${r.start ? r.start.slice(0,16) : '?'} | content=${r.content || ''}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按 title 分组统计(看批量插入的规律)
|
||||||
|
const byTitle = db.prepare("SELECT title, COUNT(1) AS c, MIN(start) AS firstStart, MAX(start) AS lastStart FROM events WHERE title LIKE ? OR content LIKE ? GROUP BY title ORDER BY c DESC LIMIT 30").all(kw, kw);
|
||||||
|
console.log('\n按 title 聚合(多肉相关)TOP30:');
|
||||||
|
byTitle.forEach(r => console.log(` x${String(r.c).padStart(4,' ')} | ${r.firstStart ? r.firstStart.slice(0,10) : ''}~${r.lastStart ? r.lastStart.slice(0,10) : ''} | ${r.title || '(空)'}`));
|
||||||
|
|
||||||
|
// 按创建日期分组,看是否有批量插入特征
|
||||||
|
const byDay = db.prepare(`SELECT substr(createdAt,1,10) AS d, COUNT(1) AS c, COUNT(DISTINCT title) AS titles
|
||||||
|
FROM events WHERE title LIKE ? OR content LIKE ?
|
||||||
|
GROUP BY substr(createdAt,1,10) ORDER BY d DESC LIMIT 30`).all(kw, kw);
|
||||||
|
console.log('\n按 createdAt 日期聚合(多肉相关)TOP30:');
|
||||||
|
byDay.forEach(r => console.log(` ${r.d} x${String(r.c).padStart(4,' ')} distinct_titles=${r.titles}`));
|
||||||
|
|
||||||
|
// 按 recurrence 分组
|
||||||
|
const byRecur = db.prepare("SELECT recurrence, COUNT(1) AS c FROM events WHERE (title LIKE ? OR content LIKE ?) GROUP BY recurrence ORDER BY c DESC").all(kw, kw);
|
||||||
|
console.log('\n按 recurrence 分组:');
|
||||||
|
byRecur.forEach(r => console.log(` [${r.recurrence || '(无)'}] x${r.c}`));
|
||||||
|
|
||||||
|
// 删除 / 未删除 / 已完成比例
|
||||||
|
const stat = db.prepare(`SELECT
|
||||||
|
COUNT(1) AS total,
|
||||||
|
SUM(CASE WHEN deleted=1 THEN 1 ELSE 0 END) AS del,
|
||||||
|
SUM(CASE WHEN deleted=0 THEN 1 ELSE 0 END) AS alive,
|
||||||
|
SUM(CASE WHEN deleted=0 AND completed=1 THEN 1 ELSE 0 END) AS aliveDone,
|
||||||
|
SUM(CASE WHEN deleted=0 AND completed=0 THEN 1 ELSE 0 END) AS aliveTodo
|
||||||
|
FROM events WHERE title LIKE ? OR content LIKE ?`).get(kw, kw);
|
||||||
|
console.log('\n多肉记录状态:');
|
||||||
|
console.log(` 总数=${stat.total} 删除=${stat.del} 存活=${stat.alive} 存活中已完成=${stat.aliveDone} 存活中待办=${stat.aliveTodo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// 快速诊断:batch_sql_check 对含 xxx_col 的 INSERT 返回什么
|
||||||
|
// 用法:node 临时脚本放在项目根,直接 require
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const os = require('os')
|
||||||
|
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cal_diag_'))
|
||||||
|
const BACKUP_DIR = path.join(process.cwd(), 'data', 'calendar_reminder_backups')
|
||||||
|
const latest = fs.readdirSync(BACKUP_DIR).filter(f => f.startsWith('calendar_reminder_2') && f.endsWith('.db')).sort().slice(-1)[0]
|
||||||
|
const SRC = path.join(BACKUP_DIR, latest)
|
||||||
|
const DST_DIR = path.join(process.cwd(), 'data', 'calendar_reminder_backups', '_diag')
|
||||||
|
fs.mkdirSync(DST_DIR, { recursive: true })
|
||||||
|
for (const suf of ['.db', '.db-wal', '.db-shm']) {
|
||||||
|
fs.copyFileSync(SRC.replace(/\.db$/,'') + suf, path.join(DST_DIR, 'calendar_reminder' + suf))
|
||||||
|
}
|
||||||
|
const modSrc = fs.readFileSync(path.join(process.cwd(), 'src','server','calendar_reminder.js'), 'utf8')
|
||||||
|
const NEEDLE = "const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db')"
|
||||||
|
if (!modSrc.includes(NEEDLE)) { throw new Error('NEEDLE NOT FOUND') }
|
||||||
|
const modTmp = path.join(process.cwd(), 'dev_test_scripts','unit','_calendar_reminder_diag.js')
|
||||||
|
fs.writeFileSync(modTmp, modSrc.replace(NEEDLE,
|
||||||
|
'const DB_PATH = path.join(process.cwd(), "data/calendar_reminder_backups/_diag", "calendar_reminder.db")'))
|
||||||
|
const express = require('express')
|
||||||
|
const { createHash } = require('crypto')
|
||||||
|
const cookie = require('cookie')
|
||||||
|
const app = express()
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.headers.cookie = (req.headers.cookie || '') + '; calendar_reminder_gate=1'
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
const mod = require(modTmp)
|
||||||
|
mod.bindRoutes(app)
|
||||||
|
const PORT = 18795
|
||||||
|
const server = app.listen(PORT, '127.0.0.1', async () => {
|
||||||
|
try {
|
||||||
|
const badCol = `INSERT INTO events (title,xxx_col,start) VALUES ('t',1,'2026-09-02 09:00');`
|
||||||
|
const http = require('http')
|
||||||
|
const r = await new Promise((resolve, reject) => {
|
||||||
|
const body = JSON.stringify({ sql_text: badCol })
|
||||||
|
const req = http.request({ hostname: '127.0.0.1', port: PORT, path: '/api/calendar_reminder/batch_sql_check', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, res => {
|
||||||
|
let buf = ''; res.on('data', d => buf += d); res.on('end', () => resolve({ status: res.statusCode, body: buf }))
|
||||||
|
})
|
||||||
|
req.on('error', reject); req.write(body); req.end()
|
||||||
|
})
|
||||||
|
console.log('STATUS', r.status)
|
||||||
|
console.log('BODY', r.body)
|
||||||
|
let j
|
||||||
|
try { j = JSON.parse(r.body) } catch (e) { j = null }
|
||||||
|
if (j) {
|
||||||
|
console.log('col_errors length:', j.col_errors && j.col_errors.length)
|
||||||
|
console.log('col_errors:', JSON.stringify(j.col_errors, null, 2))
|
||||||
|
console.log('syntax_errors:', JSON.stringify(j.syntax_errors, null, 2))
|
||||||
|
console.log('forbidden:', JSON.stringify(j.forbidden, null, 2))
|
||||||
|
console.log('ok?', j.ok)
|
||||||
|
}
|
||||||
|
server.close(() => {
|
||||||
|
fs.rmSync(DST_DIR, { recursive: true, force: true })
|
||||||
|
fs.unlinkSync(modTmp)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
try { server.close() } catch {}
|
||||||
|
try { fs.rmSync(DST_DIR, { recursive: true, force: true }) } catch {}
|
||||||
|
try { fs.unlinkSync(modTmp) } catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// 只读诊断:用户刚执行的 16 条家人生日 SQL 是否真的入库
|
||||||
|
// 用法:node dev_test_scripts/debug/diag_birthday_sql.js
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const Database = require('better-sqlite3')
|
||||||
|
|
||||||
|
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db')
|
||||||
|
if (!fs.existsSync(DB_PATH)) { console.error('DB NOT FOUND:', DB_PATH); process.exit(2) }
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH, { readonly: true, fileMustExist: true })
|
||||||
|
|
||||||
|
console.log('=== 1. 最近 5 个 calendar_batches(找 sql_ 前缀最新批次) ===')
|
||||||
|
const batches = db.prepare(`SELECT id,title,status,params_json,generated_count,inserted_count,dedup_count,rollback_count,created_at,updated_at
|
||||||
|
FROM calendar_batches ORDER BY rowid DESC LIMIT 5`).all()
|
||||||
|
for (const b of batches) {
|
||||||
|
const p = b.params_json ? JSON.parse(b.params_json) : null
|
||||||
|
console.log(` - [${b.id}] ${b.title} | status=${b.status} | gen=${b.generated_count} ins=${b.inserted_count} dedup=${b.dedup_count} rb=${b.rollback_count} | created=${b.created_at} | range=${p && p.rangeStart}~${p && p.rangeEnd} | source=${p && p.source}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== 2. 搜索 16 条 id 前缀的事件(bd_fa_ / bd_mo_ / bd_si_ / bd_me_) ===')
|
||||||
|
const prefixes = ['bd_fa_', 'bd_mo_', 'bd_si_', 'bd_me_']
|
||||||
|
const like = prefixes.map(p => `id LIKE '${p}%'`).join(' OR ')
|
||||||
|
const rows = db.prepare(`SELECT id,title,start,end,allDay,remindAt,deleted,completed,batch_id FROM events
|
||||||
|
WHERE ${like} ORDER BY start ASC`).all()
|
||||||
|
console.log(` 命中 ${rows.length} 行(期望 16)`)
|
||||||
|
for (const r of rows) {
|
||||||
|
console.log(` - [${r.id.slice(0,32)}...] ${r.title} | ${r.start} ~ ${r.end} | allDay=${r.allDay} remindAt=${r.remindAt} | deleted=${r.deleted} done=${r.completed} | batch=${r.batch_id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 16) {
|
||||||
|
const byY = {}
|
||||||
|
for (const r of rows) { const y = r.start.slice(0,4); byY[y] = (byY[y]||0)+1 }
|
||||||
|
console.log(' 按年分布:', JSON.stringify(byY))
|
||||||
|
const missingRemind = rows.filter(r => !r.remindAt)
|
||||||
|
console.log(' 未设置提醒(期望 0):', missingRemind.length)
|
||||||
|
const notAllDay = rows.filter(r => r.allDay != 1)
|
||||||
|
console.log(' 非全天事件(期望 0):', notAllDay.length)
|
||||||
|
const deletedOnes = rows.filter(r => r.deleted != 0)
|
||||||
|
console.log(' 已逻辑删除(期望 0):', deletedOnes.length)
|
||||||
|
const batch_ids = [...new Set(rows.filter(r => r.batch_id).map(r => r.batch_id))]
|
||||||
|
console.log(' 关联 batch_id 集合(期望 1 个 sql_ 前缀):', batch_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== 3. 时间跨度 sanity check(期望 start∈[2027-06-15, 2030-12-01]) ===')
|
||||||
|
const span = db.prepare(`SELECT MIN(start) min_s, MAX(end) max_e FROM events WHERE ${like}`).get()
|
||||||
|
console.log(' min start =', span.min_s, ' | max end =', span.max_e)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
// Loopback HTTP 自测:SQL Manual Batch(INSERT-only 模式)
|
||||||
|
// 用临时副本 DB,不污染生产。跟之前的 batch_tool 自测同策略。
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const os = require('os')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const http = require('http')
|
||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (e) => { console.error('[Unhandled]', e); process.exit(1) })
|
||||||
|
|
||||||
|
const ROOT = process.cwd()
|
||||||
|
const SRC = path.join(ROOT, 'data', 'calendar_reminder.db')
|
||||||
|
const DST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cal_sql_batch_'))
|
||||||
|
const DST = path.join(DST_DIR, 'calendar_reminder.db')
|
||||||
|
for (const suf of ['', '-wal', '-shm', '-journal']) {
|
||||||
|
const s = SRC + suf; const d = DST + suf
|
||||||
|
if (fs.existsSync(s)) fs.copyFileSync(s, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// patch calendar_reminder.js 的 DB_PATH 指向临时副本
|
||||||
|
const CAL_MOD = path.join(ROOT, 'src', 'server', 'calendar_reminder.js')
|
||||||
|
const orig = fs.readFileSync(CAL_MOD, 'utf8')
|
||||||
|
const NEEDLE = 'const DB_PATH = path.join(process.cwd(), \'data\', \'calendar_reminder.db\')'
|
||||||
|
if (!orig.includes(NEEDLE)) { console.error('Cannot patch DB_PATH'); process.exit(2) }
|
||||||
|
const patched = orig.replace(NEEDLE, `const DB_PATH = ${JSON.stringify(DST)}`)
|
||||||
|
const modTmp = path.join(ROOT, 'dev_test_scripts', 'unit', '_calendar_reminder_patched_sql.js')
|
||||||
|
fs.writeFileSync(modTmp, patched, 'utf8')
|
||||||
|
|
||||||
|
// 伪造一个 index.js 式的 checkAuth 中间件(calendar_reminder.bindRoutes 需要 req/res 中存在 checkAuth 守卫,但我们直接加载模块会在 bindRoutes 里挂路由到外面的 app)
|
||||||
|
// 实际上 calendar_reminder.js 的 checkAuth 是在它自己模块内部定义的——只要我们 require 它,就能拿到 bindRoutes
|
||||||
|
const { bindRoutes } = require(modTmp)
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
app.use(express.json({ limit: '10mb' }))
|
||||||
|
// 用 calendar_reminder 自己的 checkAuth:模块内部在 bindRoutes 里自己挂中间件。
|
||||||
|
// 模块内部的 checkAuth 需要 calendar_reminder_gate cookie。我们手动在 express 里先塞一个白名单中间件,当请求带 test-auth=1 header 就注入 cookie
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.headers['x-test-auth']) req.headers.cookie = (req.headers.cookie || '') + '; calendar_reminder_gate=1'
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
bindRoutes(app)
|
||||||
|
app.use((err, req, res, next) => { console.error('[ERR]', req.method, req.url, err.message); res.status(500).json({ ok: false, error: err.message }) })
|
||||||
|
|
||||||
|
const SERVER_PORT = 18791
|
||||||
|
const authH = { 'Content-Type': 'application/json', 'X-Test-Auth': '1' }
|
||||||
|
const HOST = 'http://127.0.0.1:' + SERVER_PORT
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
const assert = (name, cond, extra) => {
|
||||||
|
results.push({ name, ok: !!cond, extra })
|
||||||
|
console.log(`[${cond ? 'PASS' : 'FAIL'}] ${name}${extra ? ' | ' + JSON.stringify(extra) : ''}`)
|
||||||
|
}
|
||||||
|
const req = (method, url, body) => new Promise((resolve, reject) => {
|
||||||
|
const u = new URL(url)
|
||||||
|
const opts = { method: method, hostname: u.hostname, port: u.port, path: u.pathname + u.search, headers: authH }
|
||||||
|
const r = http.request(opts, (res) => {
|
||||||
|
let d = ''; res.setEncoding('utf8'); res.on('data', c => d += c); res.on('end', () => {
|
||||||
|
let j = d; try { j = JSON.parse(d) } catch {}
|
||||||
|
resolve({ status: res.statusCode, body: j })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.on('error', reject)
|
||||||
|
if (body !== undefined) r.write(JSON.stringify(body))
|
||||||
|
r.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mkSql = (n = 10, titlePrefix = 'SQL自测洗被子 ' + Date.now()) => {
|
||||||
|
const lines = []
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
const mm = String(9).padStart(2, '0')
|
||||||
|
const dd = String(i).padStart(2, '0')
|
||||||
|
lines.push(`('${titlePrefix} (${mm}-${dd})','自测备注','2026-${mm}-${dd} 09:00','2026-${mm}-${dd} 10:00',0,'2026-${mm}-${dd} 08:30',0,0,NULL)`)
|
||||||
|
}
|
||||||
|
return `INSERT INTO events (title,content,start,end,allDay,remindAt,completed,deleted,batch_id) VALUES\n${lines.join(',\n')};`
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const server = app.listen(SERVER_PORT, '127.0.0.1', async () => {
|
||||||
|
try {
|
||||||
|
// (1) 先检查 calendar_batches 表存在(ensureDb 会建)
|
||||||
|
const { bindRoutes: _ } = require(modTmp)
|
||||||
|
// (2) SQL 检查:空 SQL → 400
|
||||||
|
const r0 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: '' })
|
||||||
|
assert('1. 空 SQL 检查 = 400', r0.status === 400, { s: r0.status })
|
||||||
|
// (3) SQL 检查:正确 10 条 INSERT → ok=true + total_rows=10
|
||||||
|
const sql10 = mkSql(10)
|
||||||
|
const r1 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: sql10, batch_name: 'SQL自测 10 条' })
|
||||||
|
assert('2. 10 条 INSERT 检查 ok=true + 10 行', r1.status === 200 && r1.body && r1.body.ok === true && Number(r1.body.total_rows) === 10 && Number(r1.body.insert_statements) === 1, { s: r1.status, ok: r1.body && r1.body.ok, rows: r1.body && r1.body.total_rows })
|
||||||
|
// (4) SQL 检查:含 UPDATE → forbidden 且 ok=false
|
||||||
|
const badSql = sql10 + ` UPDATE events SET title='bad' WHERE 1=1;`
|
||||||
|
const r2 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: badSql })
|
||||||
|
assert('3. 含 UPDATE 语句:检查 forbidden / ok=false', r2.status === 200 && r2.body.ok === false && Array.isArray(r2.body.forbidden) && r2.body.forbidden.length >= 1, { forb: r2.body && r2.body.forbidden && r2.body.forbidden.length, ok: r2.body && r2.body.ok })
|
||||||
|
// (5) SQL 检查:未知列 xxx_col 拒绝 → col_errors
|
||||||
|
const badCol = `INSERT INTO events (title,xxx_col,start) VALUES ('t',1,'2026-09-02 09:00');`
|
||||||
|
const r3 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: badCol })
|
||||||
|
assert('4. 含未知列 xxx_col:检查 col_errors', r3.status === 200 && r3.body.ok === false && Array.isArray(r3.body.col_errors) && r3.body.col_errors.length >= 1, { cols: r3.body && r3.body.col_errors && r3.body.col_errors.length })
|
||||||
|
// (5) SQL apply:10 条 INSERT → 生成 batch_id、inserted=10,可回滚 rolled_back == inserted(可能为 9 因为 title+start 严格等值有重复,所以 rolled_back >= 8 就算 pass)
|
||||||
|
const apply10 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_apply`, { sql_text: sql10, batch_name: 'SQL自测 10 条', skip_external_sync: true, confirm_duplicates: true, confirm_forbidden: true })
|
||||||
|
const applyOk = apply10.status === 200 && apply10.body.ok === true && Number(apply10.body.inserted) >= 9 && String(apply10.body.batch_id || '').startsWith('sql_')
|
||||||
|
assert('5. SQL 执行 10 条:HTTP 200 + inserted≥9 + batch_id 非空', applyOk, { s: apply10.status, b: apply10.body })
|
||||||
|
const bId = apply10.body.batch_id
|
||||||
|
// 回滚:逻辑删除(可能为 rolled_back >= 8,因为冷备份副本 DB 之前就可能含这些 events 的 dedup)
|
||||||
|
const rb = await req('POST', `${HOST}/api/calendar_reminder/batch/rollback`, { id: bId, hard: false })
|
||||||
|
const rbOk = rb.status === 200 && rb.body.ok === true && Number(rb.body.rolled_back) >= Math.max(0, Number(apply10.body.inserted) - 3)
|
||||||
|
assert('6. 批次回滚:rolled_back ≥ inserted-3(允许副本老数据的去重差异)', rbOk, { rb: rb.body, inserted: apply10.body.inserted })
|
||||||
|
|
||||||
|
// 结果总结
|
||||||
|
const pass = results.filter(r => r.ok).length
|
||||||
|
console.log(`\n--- TOTAL: ${pass}/${results.length} PASS ---`)
|
||||||
|
server.close(() => {
|
||||||
|
try {
|
||||||
|
fs.rmSync(DST_DIR, { recursive: true, force: true })
|
||||||
|
} catch {}
|
||||||
|
try { fs.unlinkSync(modTmp) } catch {}
|
||||||
|
process.exit(pass === results.length ? 0 : 1)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[RUN ERR]', e)
|
||||||
|
try { server.close() } catch {}
|
||||||
|
try { fs.rmSync(DST_DIR, { recursive: true, force: true }) } catch {}
|
||||||
|
try { fs.unlinkSync(modTmp) } catch {}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
const path = require('path');
|
||||||
|
process.chdir(path.resolve(__dirname, '..', '..'));
|
||||||
|
const fs = require('fs');
|
||||||
|
const testDir = path.join(__dirname, '..', 'debug', 'calendar_batch_test');
|
||||||
|
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
|
||||||
|
const ts = Date.now();
|
||||||
|
const testDB = path.join(testDir, `cal_test_${ts}.db`);
|
||||||
|
const testWal = testDB + '-wal';
|
||||||
|
const testShm = testDB + '-shm';
|
||||||
|
const srcDB = path.join(process.cwd(), 'data', 'calendar_reminder_backups', 'calendar_reminder_20260831_122130.db');
|
||||||
|
const srcWal = srcDB + '-wal';
|
||||||
|
const srcShm = srcDB + '-shm';
|
||||||
|
console.log('Copying source snapshot DB to:', testDB);
|
||||||
|
fs.copyFileSync(srcDB, testDB);
|
||||||
|
if (fs.existsSync(srcWal)) fs.copyFileSync(srcWal, testWal);
|
||||||
|
if (fs.existsSync(srcShm)) fs.copyFileSync(srcShm, testShm);
|
||||||
|
|
||||||
|
// 把 calendar_reminder.js 的 DB_PATH 指向我们的临时 DB 副本
|
||||||
|
// 做法:在 require calendar_reminder 前 hook better-sqlite3? 或者更简单:重写 require cache
|
||||||
|
// 更简单:用进程内 NODE_OPTIONS 不行,但 require.cache 可以改。
|
||||||
|
// 直接 patch module exports 之前先在 calendar_reminder 里用全局变量?
|
||||||
|
// 最快做法:写一个 wrapper,覆盖 `const DB_PATH` 那行 -> 重新写 tempDBPath 赋值到 module scope 不方便,改用:
|
||||||
|
// 把 `data/calendar_reminder.db` 临时 symlink/copy?但 Windows symlink 麻烦。
|
||||||
|
// 就用最简单:创建独立 require 上下文。直接把 calendar_reminder.js 的 src 读出来、替换 DB_PATH,然后 vm.runInNewContext 太麻烦。
|
||||||
|
// 更简便:我们用 require 的 module.__proto 拦截。
|
||||||
|
|
||||||
|
// 改文件替换(完成后改回):
|
||||||
|
const modFile = path.join(process.cwd(), 'src', 'server', 'calendar_reminder.js');
|
||||||
|
const orig = fs.readFileSync(modFile, 'utf8');
|
||||||
|
const modded = orig.replace(
|
||||||
|
/const DB_PATH = path\.join\(process\.cwd\(\), 'data', 'calendar_reminder\.db'\)/,
|
||||||
|
`const DB_PATH = process.env.CALENDAR_DB_OVERRIDE || path.join(process.cwd(), 'data', 'calendar_reminder.db')`
|
||||||
|
);
|
||||||
|
if (modded === orig) { console.error('ERR: replace 未生效,DB_PATH 模式变了'); process.exit(3); }
|
||||||
|
const backup = path.join(testDir, `calendar_reminder_${ts}.bak.js`);
|
||||||
|
fs.copyFileSync(modFile, backup);
|
||||||
|
fs.writeFileSync(modFile, modded, 'utf8');
|
||||||
|
console.log('patched calendar_reminder.js to env override (backup at', backup, ')');
|
||||||
|
|
||||||
|
process.env.CALENDAR_DB_OVERRIDE = testDB;
|
||||||
|
console.log('CALENDAR_DB_OVERRIDE =', process.env.CALENDAR_DB_OVERRIDE);
|
||||||
|
|
||||||
|
// 清 require cache
|
||||||
|
for (const k of Object.keys(require.cache)) {
|
||||||
|
if (k.includes('calendar_reminder') || k.includes('calendar_voice')) delete require.cache[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const mod = require(modFile);
|
||||||
|
const db = new Database(testDB);
|
||||||
|
|
||||||
|
console.log('[自测] 确保 migrate 已执行:先 bindRoutes 一次');
|
||||||
|
const express_check_app = express();
|
||||||
|
mod.bindRoutes(express_check_app);
|
||||||
|
const cols = new Set(db.prepare("PRAGMA table_info(events)").all().map(r => r.name));
|
||||||
|
console.log('[自测] events 列(迁移后):', [...cols].join(','));
|
||||||
|
if (!cols.has('batch_id')) { console.error('ERR: 迁移后仍然缺 batch_id'); process.exit(5) }
|
||||||
|
const batchesExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calendar_batches'").get();
|
||||||
|
if (!batchesExists) { console.error('ERR: calendar_batches 未创建'); process.exit(6) }
|
||||||
|
|
||||||
|
const port = 8791;
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use((req, res, next) => { req.headers.cookie = 'calendar_reminder_gate=1'; next(); });
|
||||||
|
mod.bindRoutes(app);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
batchName: '【自测】洗被子提醒 2026Q4-2027Q2',
|
||||||
|
mode: 'monthlyByDay',
|
||||||
|
rangeStart: '2026-09-01',
|
||||||
|
rangeEnd: '2027-06-30',
|
||||||
|
monthlyEvery: 2,
|
||||||
|
monthlyDays: '1-5',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '10:00',
|
||||||
|
remindTime: '08:30',
|
||||||
|
allDay: false,
|
||||||
|
completed: false,
|
||||||
|
title: '洗被子提醒({MM}-{dd})',
|
||||||
|
content: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = app.listen(port, async () => {
|
||||||
|
let exitCode = 0;
|
||||||
|
try {
|
||||||
|
const axios = require('axios');
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const headers = { 'Content-Type': 'application/json', 'Cookie': 'calendar_reminder_gate=1' };
|
||||||
|
const ax = axios.create({ headers, validateStatus: () => true });
|
||||||
|
|
||||||
|
const r1 = await ax.post(`${base}/api/calendar_reminder/batch/preview`, params);
|
||||||
|
console.log('[自测] 1.preview status=', r1.status, 'generated=', r1.data.generated, 'summary=', r1.data.summary);
|
||||||
|
if (r1.status !== 200 || !r1.data.ok) { console.error('preview fail', r1.status, r1.data); throw new Error('preview') }
|
||||||
|
if (r1.data.generated !== 25) { console.error('expected 25, got', r1.data.generated); throw new Error('count') }
|
||||||
|
const batchId = r1.data.batchId;
|
||||||
|
console.log('[自测] batch_id =', batchId);
|
||||||
|
|
||||||
|
const r2 = await ax.post(`${base}/api/calendar_reminder/batch/apply`, { batch_id: batchId, params: r1.data.params_saved, sql_full: r1.data.sql_full, skip_external_sync: true });
|
||||||
|
console.log('[自测] 2.apply=', r2.status, r2.data);
|
||||||
|
if (r2.status !== 200 || !r2.data.ok) throw new Error('apply');
|
||||||
|
|
||||||
|
const counts = db.prepare("SELECT deleted, COUNT(1) c FROM events WHERE batch_id=? GROUP BY deleted").all(batchId);
|
||||||
|
console.log('[自测] 3.active counts=', counts);
|
||||||
|
const active = (counts.find(r => r.deleted === 0) || {}).c || 0;
|
||||||
|
if (active !== 25) throw new Error('active !=25, got ' + active);
|
||||||
|
const batchRow = db.prepare("SELECT id, status, inserted_count, dedup_count, rollback_count, pre_existing_count, skip_external_sync FROM calendar_batches WHERE id=?").get(batchId);
|
||||||
|
console.log('[自测] 4.batch row=', batchRow);
|
||||||
|
|
||||||
|
const rL = await ax.get(`${base}/api/calendar_reminder/batches?q=%E6%B4%97%E8%A2%AB%E5%AD%90`);
|
||||||
|
console.log('[自测] 5.batches list len=', rL.data.data && rL.data.data.length);
|
||||||
|
const rD = await ax.get(`${base}/api/calendar_reminder/batch/detail?id=${encodeURIComponent(batchId)}`);
|
||||||
|
console.log('[自测] 6.detail counts=', rD.data.batch && rD.data.batch.counts, 'diag ok?', !!rD.data.diagnostics && !!rD.data.diagnostics.by_batch_id);
|
||||||
|
const rE = await ax.get(`${base}/api/calendar_reminder/batch/export_sql?id=${encodeURIComponent(batchId)}`);
|
||||||
|
console.log('[自测] 7.export_sql len=', typeof rE.data === 'string' ? rE.data.length : (Buffer.isBuffer(rE.data) ? rE.data.length : '??'), 'headers?', rE.headers && (rE.headers['content-disposition'] || rE.headers['content-type']));
|
||||||
|
|
||||||
|
const rR = await ax.post(`${base}/api/calendar_reminder/batch/rollback`, { id: batchId, hard: false });
|
||||||
|
console.log('[自测] 8.rollback=', rR.status, rR.data);
|
||||||
|
const c2 = db.prepare("SELECT deleted, COUNT(1) c FROM events WHERE batch_id=? GROUP BY deleted").all(batchId);
|
||||||
|
console.log('[自测] 9.post-rollback counts=', c2);
|
||||||
|
const d = (c2.find(r => r.deleted === 1) || {}).c || 0;
|
||||||
|
if (d !== 25) throw new Error('deleted!=25, got ' + d);
|
||||||
|
const rRepeat = await ax.post(`${base}/api/calendar_reminder/batch/preview`, Object.assign({}, params, { batchName: '【自测-复制】洗被子' }));
|
||||||
|
console.log('[自测] 10.复制后的新批次 preview 预重(deleted=1 不命中去重)=', rRepeat.data.pre_existing_count);
|
||||||
|
if (rRepeat.data.pre_existing_count !== 0) throw new Error('pre_existing_count !=0');
|
||||||
|
|
||||||
|
// 再做 apply 回滚然后 rollback 再做第二次去重测试
|
||||||
|
const bid2 = rRepeat.data.batchId;
|
||||||
|
const rA2 = await ax.post(`${base}/api/calendar_reminder/batch/apply`, { batch_id: bid2, params: rRepeat.data.params_saved, sql_full: rRepeat.data.sql_full, skip_external_sync: true });
|
||||||
|
console.log('[自测] 11.复制 apply=', rA2.status, rA2.data);
|
||||||
|
const rDup = await ax.post(`${base}/api/calendar_reminder/batch/preview`, Object.assign({}, params));
|
||||||
|
console.log('[自测] 12.相同参数再预览(因为 bid2 刚 active=25,所以 pre_existing_count=25)=', rDup.data.pre_existing_count);
|
||||||
|
if (rDup.data.pre_existing_count !== 25) throw new Error('pre_existing_count should be 25');
|
||||||
|
|
||||||
|
// 清理两条批次元数据 + events,让临时 DB 恢复干净(虽然是临时的,但也清一下)
|
||||||
|
const del1 = db.prepare("DELETE FROM events WHERE batch_id IN (?,?)").run(batchId, bid2).changes;
|
||||||
|
const del2 = db.prepare("DELETE FROM calendar_batches WHERE id IN (?,?)").run(batchId, bid2).changes;
|
||||||
|
console.log('[自测] 13.cleanup del events=', del1, ', del batches=', del2);
|
||||||
|
|
||||||
|
// 再测 monthlyByNthWeekday:2026-09 到 2027-01 每月第 2 个周一,应得到 5 条
|
||||||
|
const wd = { batchName: '房租 demo', mode: 'monthlyByNthWeekday', rangeStart: '2026-09-01', rangeEnd: '2027-01-31', monthlyEvery2: 1, nthWeek: 2, nthWeekday: 1, title: '交房租提醒 {date}', content: '', startTime: '09:00' };
|
||||||
|
const rw = await ax.post(`${base}/api/calendar_reminder/batch/preview`, wd);
|
||||||
|
console.log('[自测] 14.monthlyByNthWeekday=第2个周一 9月-1月 生成=', rw.data.generated, (rw.data.sample_list||[]).map(x=>x.start));
|
||||||
|
if (rw.data.generated !== 5) throw new Error('monthlyByNthWeekday expected 5, got ' + rw.data.generated);
|
||||||
|
|
||||||
|
// weekly:每 2 周的周一、周三,2026-09-01~2026-10-15(每 2 周 2 条,6 周 * ?大概 6~7 条)
|
||||||
|
const wk = { batchName: '健身', mode: 'weekly', rangeStart: '2026-09-01', rangeEnd: '2026-10-15', weeklyEvery: 2, weekdays: [1,3], title: '健身 {date} 周{W}', content: '', startTime: '20:00', endTime: '22:00', remindTime: '19:30' };
|
||||||
|
const rwk = await ax.post(`${base}/api/calendar_reminder/batch/preview`, wk);
|
||||||
|
console.log('[自测] 15.weekly 每 2 周 Mon/Wed 生成=', rwk.data.generated, 'list=', (rwk.data.sample_list||[]).map(x=>x.start + ' ' + x.title));
|
||||||
|
|
||||||
|
// daily:每 3 天 2026-09-01~2026-09-20 => 7条 (1 4 7 10 13 16 19)
|
||||||
|
const dl = { batchName: '喝水', mode: 'daily', rangeStart: '2026-09-01', rangeEnd: '2026-09-20', dailyEvery: 3, title: '喝水打卡 {date}', startTime: '10:00' };
|
||||||
|
const rdl = await ax.post(`${base}/api/calendar_reminder/batch/preview`, dl);
|
||||||
|
console.log('[自测] 16.daily 每3天 9/1-20 生成=', rdl.data.generated, 'list=', (rdl.data.sample_list||[]).map(x=>x.start));
|
||||||
|
if (rdl.data.generated !== 7) throw new Error('daily expected 7, got ' + rdl.data.generated);
|
||||||
|
|
||||||
|
console.log('\n✅ 自测全部通过。退出。');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[自测] 异常:', e.message, e.stack);
|
||||||
|
exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
// 恢复 calendar_reminder.js 源码
|
||||||
|
fs.copyFileSync(backup, modFile);
|
||||||
|
fs.unlinkSync(backup);
|
||||||
|
console.log('restored calendar_reminder.js 原文');
|
||||||
|
// 删临时 DB
|
||||||
|
try { fs.unlinkSync(testDB) } catch {}
|
||||||
|
try { fs.unlinkSync(testWal) } catch {}
|
||||||
|
try { fs.unlinkSync(testShm) } catch {}
|
||||||
|
server.close(() => process.exit(exitCode));
|
||||||
|
setTimeout(() => process.exit(exitCode), 2000).unref();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
page {
|
||||||
|
background: #0f0f1a;
|
||||||
|
color: #e8e8f0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 32rpx;
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
padding: 80rpx 0 48rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #8888a8;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1rpx solid #2a2a44;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1rpx solid #2a2a44;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-card:active {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-icon {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 48rpx;
|
||||||
|
background: rgba(108, 122, 224, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 24rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-icon-text {
|
||||||
|
font-size: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #8888a8;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
color: #555570;
|
||||||
|
font-size: 36rpx;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 20rpx 48rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #6c7ae0;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1rpx solid #3a3a5a;
|
||||||
|
color: #b8b8d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:active {
|
||||||
|
background: rgba(108, 122, 224, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e05561;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-block {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #8888a8;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: #12121f;
|
||||||
|
border: 1rpx solid #2a2a44;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
min-height: 240rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: rgba(108, 122, 224, 0.12);
|
||||||
|
color: #8fa0ee;
|
||||||
|
font-size: 22rpx;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 120rpx 32rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: #555570;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
background: #12121f;
|
||||||
|
border: 1rpx solid #2a2a44;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
color: #555570;
|
||||||
|
font-size: 32rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1rpx;
|
||||||
|
background: #2a2a44;
|
||||||
|
margin: 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #8888a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1rpx solid #23233a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-btn {
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumb {
|
||||||
|
width: 160rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #12121f;
|
||||||
|
border: 1rpx solid #2a2a44;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-thumb image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-icon {
|
||||||
|
font-size: 48rpx;
|
||||||
|
color: #8888a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-name {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 8rpx 12rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 18rpx;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
right: 40rpx;
|
||||||
|
bottom: 60rpx;
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 60rpx;
|
||||||
|
background: #6c7ae0;
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 56rpx;
|
||||||
|
font-weight: 300;
|
||||||
|
box-shadow: 0 8rpx 32rpx rgba(108, 122, 224, 0.35);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
const api = require('../../../../api.js')
|
||||||
|
|
||||||
|
const formatDate = iso => {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (isNaN(d.getTime())) return iso
|
||||||
|
const pad = n => n < 10 ? '0' + n : '' + n
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
loading: true,
|
||||||
|
id: '',
|
||||||
|
item: null,
|
||||||
|
createdAtText: '',
|
||||||
|
updatedAtText: '',
|
||||||
|
error: '',
|
||||||
|
imageUrls: [],
|
||||||
|
hasNonImageAttachment: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad(options) {
|
||||||
|
const id = options && options.id ? String(options.id) : ''
|
||||||
|
if (!id) {
|
||||||
|
this.setData({ loading: false, error: '缺少 ID 参数' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ id })
|
||||||
|
this.loadItem(id)
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
if (this.data.id && !this.data.loading) {
|
||||||
|
this.loadItem(this.data.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadItem(id) {
|
||||||
|
this.setData({ loading: true, error: '' })
|
||||||
|
try {
|
||||||
|
const res = await api.personalCollect.get(id)
|
||||||
|
const item = res.data || null
|
||||||
|
const images = (item && item.attachments || []).filter(a => a && a.isImage && a.filePath)
|
||||||
|
const imageUrls = images.map(a => `https://traesite.umersoft.com:8975/${a.filePath}`)
|
||||||
|
const attachments = (item && item.attachments || []).map(a => ({
|
||||||
|
...a,
|
||||||
|
url: a.filePath ? `https://traesite.umersoft.com:8975/${a.filePath}` : '',
|
||||||
|
}))
|
||||||
|
const hasNonImage = (item.attachments || []).some(a => !a.isImage)
|
||||||
|
this.setData({
|
||||||
|
item: Object.assign({}, item, { attachments }),
|
||||||
|
createdAtText: formatDate(item.createdAt || ''),
|
||||||
|
updatedAtText: formatDate(item.updatedAt || ''),
|
||||||
|
imageUrls,
|
||||||
|
hasNonImageAttachment: !!hasNonImage,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
this.setData({ error: e && e.message ? e.message : '加载失败' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onPreviewImage(e) {
|
||||||
|
const url = e.currentTarget.dataset.url
|
||||||
|
if (!url || !this.data.imageUrls.length) return
|
||||||
|
wx.previewImage({
|
||||||
|
current: url,
|
||||||
|
urls: this.data.imageUrls,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onOpenFile(e) {
|
||||||
|
const url = e.currentTarget.dataset.url
|
||||||
|
const name = e.currentTarget.dataset.name || 'file'
|
||||||
|
if (!url) return
|
||||||
|
wx.showLoading({ title: '打开中...', mask: true })
|
||||||
|
wx.downloadFile({
|
||||||
|
url,
|
||||||
|
timeout: 120000,
|
||||||
|
success: (res) => {
|
||||||
|
wx.hideLoading()
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300 && res.tempFilePath) {
|
||||||
|
wx.openDocument({
|
||||||
|
filePath: res.tempFilePath,
|
||||||
|
showMenu: true,
|
||||||
|
fileType: this.detectFileType(name),
|
||||||
|
success: () => {},
|
||||||
|
fail: (err) => {
|
||||||
|
wx.showModal({
|
||||||
|
title: '打开失败',
|
||||||
|
content: err && err.errMsg ? err.errMsg : '无法预览此类型文件',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
wx.showModal({
|
||||||
|
title: '下载失败',
|
||||||
|
content: `HTTP ${res.statusCode}`,
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showModal({
|
||||||
|
title: '下载失败',
|
||||||
|
content: err && err.errMsg ? err.errMsg : '网络错误',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
detectFileType(name) {
|
||||||
|
const ext = (name || '').split('.').pop().toLowerCase()
|
||||||
|
const map = {
|
||||||
|
pdf: 'pdf',
|
||||||
|
doc: 'doc', docx: 'doc',
|
||||||
|
xls: 'xls', xlsx: 'xls',
|
||||||
|
ppt: 'ppt', pptx: 'ppt',
|
||||||
|
}
|
||||||
|
return map[ext] || ''
|
||||||
|
},
|
||||||
|
|
||||||
|
onEditTap() {
|
||||||
|
if (!this.data.id) return
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/kits/personal_collect/pages/edit/edit?id=${encodeURIComponent(this.data.id)}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onDeleteTap() {
|
||||||
|
const title = this.data.item && this.data.item.title
|
||||||
|
? this.data.item.title
|
||||||
|
: (this.data.item && this.data.item.remark && this.data.item.remark.slice(0, 10)) || '此收藏'
|
||||||
|
wx.showModal({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定删除「${title}」吗?删除后不再显示。`,
|
||||||
|
confirmText: '删除',
|
||||||
|
confirmColor: '#e05561',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
try {
|
||||||
|
wx.showLoading({ title: '删除中...', mask: true })
|
||||||
|
await api.personalCollect.remove(this.data.id)
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
wx.navigateBack()
|
||||||
|
}, 800)
|
||||||
|
} catch (err) {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除失败',
|
||||||
|
content: err && err.message ? err.message : '请重试',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "收藏详情",
|
||||||
|
"backgroundTextStyle": "light"
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<view wx:if="{{loading}}" class="container">
|
||||||
|
<view class="empty">加载中...</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:elif="{{error}}" class="container">
|
||||||
|
<view class="card" style="border-color:#55333a;">
|
||||||
|
<text style="color:#e05561;">⚠️ {{error}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:elif="{{!item}}" class="container">
|
||||||
|
<view class="empty">未找到该收藏</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:else class="container" style="padding-bottom: 200rpx;">
|
||||||
|
<view class="card">
|
||||||
|
<view wx:if="{{item.title}}" style="font-size: 40rpx; font-weight:600; color:#ffffff; line-height:1.4;">{{item.title}}</view>
|
||||||
|
<view class="divider"></view>
|
||||||
|
|
||||||
|
<view wx:if="{{item.remark}}" style="white-space: pre-wrap; font-size: 28rpx; color:#e8e8f0; line-height:1.8;">{{item.remark}}</view>
|
||||||
|
|
||||||
|
<view wx:if="{{item.tags && item.tags.length}}" style="margin-top: 32rpx;">
|
||||||
|
<block wx:for="{{item.tags}}" wx:for-item="tag" wx:key="*this">
|
||||||
|
<view class="tag">#{{tag}}</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="divider"></view>
|
||||||
|
|
||||||
|
<view class="row" style="margin-bottom: 12rpx;">
|
||||||
|
<text class="muted small" style="width: 180rpx;">创建时间</text>
|
||||||
|
<text class="small">{{createdAtText || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="muted small" style="width: 180rpx;">更新时间</text>
|
||||||
|
<text class="small">{{updatedAtText || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{item.attachments && item.attachments.length}}" class="card">
|
||||||
|
<view style="font-size: 30rpx; font-weight:600; color:#ffffff; margin-bottom: 24rpx;">附件 ({{item.attachments.length}})</view>
|
||||||
|
<view class="attachments-grid">
|
||||||
|
<block wx:for="{{item.attachments}}" wx:for-item="att" wx:key="id">
|
||||||
|
<view class="attachment-thumb">
|
||||||
|
<block wx:if="{{att.isImage}}">
|
||||||
|
<image
|
||||||
|
src="{{att.url}}"
|
||||||
|
mode="aspectFill"
|
||||||
|
data-url="{{att.url}}"
|
||||||
|
bindtap="onPreviewImage"
|
||||||
|
/>
|
||||||
|
</block>
|
||||||
|
<block wx:else>
|
||||||
|
<text class="attachment-icon" data-url="{{att.url}}" data-name="{{att.fileName}}" catchtap="onOpenFile" style="padding: 20rpx;">📄</text>
|
||||||
|
</block>
|
||||||
|
<view class="attachment-name" data-url="{{att.url}}" data-name="{{att.fileName}}" catchtap="{{att.isImage ? 'onPreviewImage' : 'onOpenFile'}}">{{att.fileName}}</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{hasNonImageAttachment}}" class="muted small" style="margin-top: 24rpx;">
|
||||||
|
💡 非图片附件点击名称即可下载并使用微信自带文档查看器打开。
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{item}}" style="position:fixed; left:0; right:0; bottom:0; padding:24rpx 32rpx; background:#0f0f1a; border-top:1rpx solid #2a2a44; box-sizing:border-box; display:flex; gap:20rpx; z-index:99;">
|
||||||
|
<view class="btn btn-ghost" style="flex:1;" bindtap="onDeleteTap">删除</view>
|
||||||
|
<view class="btn btn-primary" style="flex:1;" bindtap="onEditTap">编辑</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 详情页独立样式,大部分复用 app.wxss 全局样式 */
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
const api = require('../../../../api.js')
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
isEdit: false,
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
remark: '',
|
||||||
|
tagsText: '',
|
||||||
|
attachments: [],
|
||||||
|
uploadGroupId: '',
|
||||||
|
error: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad(options) {
|
||||||
|
const id = options && options.id ? String(options.id) : ''
|
||||||
|
this.setData({ isEdit: !!id, id })
|
||||||
|
this.generateUploadGroupId()
|
||||||
|
if (id) {
|
||||||
|
this.loadItem(id)
|
||||||
|
} else {
|
||||||
|
this.applyClipPrefill()
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
applyClipPrefill() {
|
||||||
|
try {
|
||||||
|
const text = wx.getStorageSync('mp_ta_clip_text')
|
||||||
|
if (!text || typeof text !== 'string') return
|
||||||
|
const mode = wx.getStorageSync('mp_ta_clip_mode') || 'new'
|
||||||
|
try { wx.removeStorageSync('mp_ta_clip_text') } catch (e) {}
|
||||||
|
try { wx.removeStorageSync('mp_ta_clip_mode') } catch (e) {}
|
||||||
|
const title = ((String(text || '').split('\n').map(s => s.trim()).find(s => s.length) || '').slice(0, 28)) || '文字收藏'
|
||||||
|
this.setData({
|
||||||
|
title: title,
|
||||||
|
remark: text,
|
||||||
|
})
|
||||||
|
wx.showToast({
|
||||||
|
title: mode === 'append' ? '已追加剪贴板文字' : '已预填剪贴板文字',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 1600,
|
||||||
|
})
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
generateUploadGroupId() {
|
||||||
|
const ts = Date.now().toString(36)
|
||||||
|
const rnd = Math.random().toString(36).slice(2, 10)
|
||||||
|
this.setData({ uploadGroupId: `${ts}_${rnd}` })
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadItem(id) {
|
||||||
|
this.setData({ loading: true, error: '' })
|
||||||
|
try {
|
||||||
|
const res = await api.personalCollect.get(id)
|
||||||
|
const item = res.data || {}
|
||||||
|
const tags = Array.isArray(item.tags) ? item.tags : []
|
||||||
|
const attachments = (item.attachments || []).map(a => ({
|
||||||
|
...a,
|
||||||
|
url: a.filePath ? `https://traesite.umersoft.com:8975/${a.filePath}` : '',
|
||||||
|
uploaded: true,
|
||||||
|
uploading: false,
|
||||||
|
}))
|
||||||
|
this.setData({
|
||||||
|
title: item.title || '',
|
||||||
|
remark: item.remark || '',
|
||||||
|
tagsText: tags.join(', '),
|
||||||
|
attachments,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
this.setData({ error: e && e.message ? e.message : '加载失败' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onTitleInput(e) {
|
||||||
|
this.setData({ title: String(e.detail.value || '') })
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemarkInput(e) {
|
||||||
|
this.setData({ remark: String(e.detail.value || '') })
|
||||||
|
},
|
||||||
|
|
||||||
|
onTagsInput(e) {
|
||||||
|
this.setData({ tagsText: String(e.detail.value || '') })
|
||||||
|
},
|
||||||
|
|
||||||
|
async onChooseAttachments() {
|
||||||
|
try {
|
||||||
|
const files = await api.chooseChatAttachments(9 - this.data.attachments.length)
|
||||||
|
if (!files || !files.length) return
|
||||||
|
for (const f of files) {
|
||||||
|
await this.uploadOne(f)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e && e.message ? String(e.message) : ''
|
||||||
|
if (msg.indexOf('cancel') !== -1 || msg.indexOf('取消') !== -1) return
|
||||||
|
if (msg.indexOf('auth') !== -1 || msg.indexOf('deny') !== -1 || msg.indexOf('权限') !== -1 || msg.indexOf('授权') !== -1) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '需要聊天文件权限',
|
||||||
|
content: '请点击右上角「···」→ 设置,打开「聊天记录」权限后重试。',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showModal({
|
||||||
|
title: '选择失败',
|
||||||
|
content: msg || '请重试',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadOne(tempFile) {
|
||||||
|
const tempId = `tmp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
const isImage = tempFile.type === 'image' || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(tempFile.name || '')
|
||||||
|
const pending = {
|
||||||
|
id: tempId,
|
||||||
|
fileName: tempFile.name || '未命名',
|
||||||
|
filePath: '',
|
||||||
|
fileSize: Number(tempFile.size || 0),
|
||||||
|
isImage,
|
||||||
|
url: tempFile.path || tempFile.tempFilePath || '',
|
||||||
|
uploaded: false,
|
||||||
|
uploading: true,
|
||||||
|
error: '',
|
||||||
|
}
|
||||||
|
const list = this.data.attachments.concat([pending])
|
||||||
|
this.setData({ attachments: list })
|
||||||
|
|
||||||
|
const idx = list.length - 1
|
||||||
|
try {
|
||||||
|
const res = await api.personalCollect.upload(tempFile.path || tempFile.tempFilePath, this.data.uploadGroupId)
|
||||||
|
const data = (res && res.data) || {}
|
||||||
|
const attachment = {
|
||||||
|
id: data.id || tempId,
|
||||||
|
fileName: data.fileName || tempFile.name || '未命名',
|
||||||
|
storedName: data.storedName || '',
|
||||||
|
filePath: data.filePath || '',
|
||||||
|
fileSize: Number(data.fileSize || tempFile.size || 0),
|
||||||
|
mimeType: data.mimeType || '',
|
||||||
|
isImage: !!data.isImage || isImage,
|
||||||
|
url: data.url || (data.filePath ? `https://traesite.umersoft.com:8975/${data.filePath}` : '') || '',
|
||||||
|
groupId: data.groupId || '',
|
||||||
|
uploaded: true,
|
||||||
|
uploading: false,
|
||||||
|
error: '',
|
||||||
|
}
|
||||||
|
const next = this.data.attachments.slice()
|
||||||
|
next[idx] = attachment
|
||||||
|
this.setData({ attachments: next })
|
||||||
|
this.autoFillFromAttachments(false)
|
||||||
|
} catch (e) {
|
||||||
|
const next = this.data.attachments.slice()
|
||||||
|
next[idx] = Object.assign({}, next[idx], {
|
||||||
|
uploading: false,
|
||||||
|
error: e && e.message ? e.message : '上传失败',
|
||||||
|
})
|
||||||
|
this.setData({ attachments: next })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
autoFillFromAttachments(force) {
|
||||||
|
if (this.data.isEdit && !force) return
|
||||||
|
const atts = this.data.attachments.filter(a => a && a.uploaded)
|
||||||
|
if (!atts.length) return
|
||||||
|
const hasTitle = !!(this.data.title || '').trim()
|
||||||
|
const hasRemark = !!(this.data.remark || '').trim()
|
||||||
|
const { title, remark } = this.buildAutoText(atts)
|
||||||
|
const patch = {}
|
||||||
|
if (!hasTitle || force) patch.title = title
|
||||||
|
if (!hasRemark || force) patch.remark = remark
|
||||||
|
if (Object.keys(patch).length) this.setData(patch)
|
||||||
|
},
|
||||||
|
|
||||||
|
buildAutoText(atts) {
|
||||||
|
const images = atts.filter(a => a.isImage)
|
||||||
|
const files = atts.filter(a => !a.isImage)
|
||||||
|
const imageCount = images.length
|
||||||
|
const fileCount = files.length
|
||||||
|
const total = atts.length
|
||||||
|
const pad = n => n < 10 ? '0' + n : '' + n
|
||||||
|
const now = new Date()
|
||||||
|
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
|
||||||
|
|
||||||
|
const humanSize = bytes => {
|
||||||
|
const n = Number(bytes || 0)
|
||||||
|
if (n < 1024) return n + ' B'
|
||||||
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
|
||||||
|
return (n / 1024 / 1024).toFixed(2) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = ''
|
||||||
|
if (total === 1) {
|
||||||
|
const only = atts[0]
|
||||||
|
const rawName = only.fileName || '附件'
|
||||||
|
const ext = (rawName.split('.').pop() || '').toLowerCase()
|
||||||
|
const stem = rawName.lastIndexOf('.') > 0 ? rawName.slice(0, rawName.lastIndexOf('.')) : rawName
|
||||||
|
if (only.isImage) {
|
||||||
|
title = `图片 - ${ts}`
|
||||||
|
} else {
|
||||||
|
title = stem || rawName
|
||||||
|
}
|
||||||
|
} else if (imageCount > 0 && fileCount === 0) {
|
||||||
|
title = `${imageCount} 张图片 - ${ts}`
|
||||||
|
} else if (fileCount > 0 && imageCount === 0) {
|
||||||
|
if (fileCount === 1) {
|
||||||
|
const only = files[0]
|
||||||
|
const rawName = only.fileName || '附件'
|
||||||
|
const stem = rawName.lastIndexOf('.') > 0 ? rawName.slice(0, rawName.lastIndexOf('.')) : rawName
|
||||||
|
title = stem || rawName
|
||||||
|
} else {
|
||||||
|
title = `${fileCount} 个文件 - ${ts}`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title = `${imageCount} 图 + ${fileCount} 文件 - ${ts}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = []
|
||||||
|
lines.push(`附件数量:${total}`)
|
||||||
|
if (imageCount) lines.push(`图片:${imageCount} 张`)
|
||||||
|
if (fileCount) lines.push(`文件:${fileCount} 个`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push('附件清单:')
|
||||||
|
atts.slice(0, 9).forEach((a, i) => {
|
||||||
|
lines.push(` ${i + 1}. ${a.fileName || '未命名'}(${humanSize(a.fileSize)})`)
|
||||||
|
})
|
||||||
|
if (atts.length > 9) lines.push(` ... 另有 ${atts.length - 9} 个附件未列出`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`整理时间:${ts}`)
|
||||||
|
const remark = lines.join('\n')
|
||||||
|
|
||||||
|
return { title, remark }
|
||||||
|
},
|
||||||
|
|
||||||
|
onPreviewImage(e) {
|
||||||
|
const url = e.currentTarget.dataset.url
|
||||||
|
const urls = this.data.attachments.filter(a => a && a.isImage && a.url).map(a => a.url)
|
||||||
|
if (!url || !urls.length) return
|
||||||
|
wx.previewImage({
|
||||||
|
current: url,
|
||||||
|
urls,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemoveAttachment(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
const idx = this.data.attachments.findIndex(a => a.id === id)
|
||||||
|
if (idx < 0) return
|
||||||
|
const next = this.data.attachments.slice()
|
||||||
|
next.splice(idx, 1)
|
||||||
|
this.setData({ attachments: next })
|
||||||
|
},
|
||||||
|
|
||||||
|
onRetryUpload(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
const idx = this.data.attachments.findIndex(a => a.id === id)
|
||||||
|
if (idx < 0) return
|
||||||
|
const att = this.data.attachments[idx]
|
||||||
|
if (!att || att.uploaded || !att.url) return
|
||||||
|
this.uploadOne({
|
||||||
|
name: att.fileName || '未命名',
|
||||||
|
size: att.fileSize || 0,
|
||||||
|
type: att.isImage ? 'image' : 'file',
|
||||||
|
path: att.url,
|
||||||
|
}).then(() => {
|
||||||
|
const next = this.data.attachments.slice()
|
||||||
|
next.splice(idx, 1)
|
||||||
|
this.setData({ attachments: next })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
parseTags(text) {
|
||||||
|
const s = String(text || '').trim()
|
||||||
|
if (!s) return []
|
||||||
|
return s
|
||||||
|
.split(/[,,\s]+/)
|
||||||
|
.map(t => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
},
|
||||||
|
|
||||||
|
async onSave() {
|
||||||
|
if (this.data.saving) return
|
||||||
|
const title = this.data.title.trim()
|
||||||
|
const remark = this.data.remark.trim()
|
||||||
|
if (!title && !remark) {
|
||||||
|
wx.showToast({ title: '标题或备注至少填一个', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const errorAtt = this.data.attachments.find(a => a && a.error)
|
||||||
|
if (errorAtt) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '存在上传失败的附件',
|
||||||
|
content: `「${errorAtt.fileName}」上传失败:${errorAtt.error}。请移除或重试后再保存。`,
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const uploading = this.data.attachments.find(a => a && a.uploading)
|
||||||
|
if (uploading) {
|
||||||
|
wx.showToast({ title: '请等待附件上传完成', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const attachmentIds = this.data.attachments
|
||||||
|
.filter(a => a && a.uploaded && a.id)
|
||||||
|
.map(a => a.id)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title,
|
||||||
|
remark,
|
||||||
|
tags: this.parseTags(this.data.tagsText),
|
||||||
|
attachmentIds,
|
||||||
|
}
|
||||||
|
this.setData({ saving: true })
|
||||||
|
wx.showLoading({ title: this.data.isEdit ? '保存中...' : '创建中...', mask: true })
|
||||||
|
try {
|
||||||
|
if (this.data.isEdit) {
|
||||||
|
await api.personalCollect.update(Object.assign({ id: this.data.id }, payload))
|
||||||
|
} else {
|
||||||
|
await api.personalCollect.create(payload)
|
||||||
|
}
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({
|
||||||
|
title: this.data.isEdit ? '已保存' : '已创建',
|
||||||
|
icon: 'success',
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
wx.navigateBack()
|
||||||
|
}, 800)
|
||||||
|
} catch (e) {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showModal({
|
||||||
|
title: this.data.isEdit ? '保存失败' : '创建失败',
|
||||||
|
content: e && e.message ? e.message : '请重试',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.setData({ saving: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "编辑收藏",
|
||||||
|
"backgroundTextStyle": "light"
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<view wx:if="{{loading}}" class="container">
|
||||||
|
<view class="empty">加载中...</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:else class="container" style="padding-bottom: 200rpx;">
|
||||||
|
<view wx:if="{{error}}" class="card" style="border-color:#55333a;">
|
||||||
|
<text style="color:#e05561; font-size: 26rpx;">⚠️ {{error}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<text class="input-label">标题</text>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
placeholder="输入标题(必填或填备注)"
|
||||||
|
placeholder-style="color:#555570"
|
||||||
|
value="{{title}}"
|
||||||
|
bindinput="onTitleInput"
|
||||||
|
maxlength="200"
|
||||||
|
style="color:#ffffff;"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<text class="input-label">备注</text>
|
||||||
|
<textarea
|
||||||
|
class="textarea"
|
||||||
|
placeholder="输入备注信息"
|
||||||
|
placeholder-style="color:#555570"
|
||||||
|
value="{{remark}}"
|
||||||
|
bindinput="onRemarkInput"
|
||||||
|
maxlength="4000"
|
||||||
|
auto-height
|
||||||
|
style="color:#ffffff;"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<text class="input-label">标签(逗号或空格分隔,如:工作, 重要)</text>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
placeholder="标签1, 标签2"
|
||||||
|
placeholder-style="color:#555570"
|
||||||
|
value="{{tagsText}}"
|
||||||
|
bindinput="onTagsInput"
|
||||||
|
maxlength="500"
|
||||||
|
style="color:#ffffff;"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<view class="row row-between" style="margin-bottom: 16rpx;">
|
||||||
|
<text class="input-label" style="margin-bottom:0;">附件(从聊天选择,最多 9 个)</text>
|
||||||
|
<view class="btn btn-ghost mini-btn" bindtap="onChooseAttachments" style="margin:0;">+ 从聊天选</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{attachments.length === 0}}" class="card" style="text-align:center; color:#555570; border-style: dashed;">
|
||||||
|
<text>还没有附件,点右上角按钮选择</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{attachments.length > 0}}" class="attachments-grid">
|
||||||
|
<block wx:for="{{attachments}}" wx:key="id">
|
||||||
|
<view class="attachment-thumb">
|
||||||
|
<block wx:if="{{item.isImage && item.url}}">
|
||||||
|
<image
|
||||||
|
src="{{item.url}}"
|
||||||
|
mode="aspectFill"
|
||||||
|
data-url="{{item.url}}"
|
||||||
|
bindtap="onPreviewImage"
|
||||||
|
/>
|
||||||
|
</block>
|
||||||
|
<block wx:else>
|
||||||
|
<text class="attachment-icon">📄</text>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<view class="attachment-name">{{item.fileName}}</view>
|
||||||
|
|
||||||
|
<view wx:if="{{item.uploading}}" style="position:absolute; left:0; right:0; top:0; bottom:0; background:rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center;">
|
||||||
|
<text style="color:#fff; font-size:22rpx;">上传中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:elif="{{item.error}}" style="position:absolute; left:0; right:0; top:0; bottom:0; background:rgba(88,20,30,0.7); display:flex; align-items:center; justify-content:center; flex-direction:column; padding: 12rpx; box-sizing:border-box;">
|
||||||
|
<text style="color:#fff; font-size:20rpx; line-height:1.4; text-align:center;">上传失败</text>
|
||||||
|
<view style="margin-top:8rpx; padding:6rpx 16rpx; background:#fff; color:#1a1a2e; border-radius:8rpx; font-size:20rpx;" catchtap="onRetryUpload" data-id="{{item.id}}">重试</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
style="position:absolute; top:8rpx; right:8rpx; width:44rpx; height:44rpx; border-radius:22rpx; background:rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center; color:#fff; font-size:28rpx; line-height:1;"
|
||||||
|
catchtap="onRemoveAttachment"
|
||||||
|
data-id="{{item.id}}"
|
||||||
|
>×</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view style="position:fixed; left:0; right:0; bottom:0; padding:24rpx 32rpx; background:#0f0f1a; border-top:1rpx solid #2a2a44; box-sizing:border-box; z-index:99;">
|
||||||
|
<view class="btn btn-primary btn-block {{saving ? '' : ''}}" style="opacity: {{saving ? 0.6 : 1}};" bindtap="onSave">
|
||||||
|
{{isEdit ? (saving ? '保存中...' : '保存修改') : (saving ? '创建中...' : '创建收藏')}}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 编辑页独立样式,大部分复用 app.wxss 全局样式 */
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
const api = require('../../../../api.js')
|
||||||
|
|
||||||
|
const formatDate = iso => {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (isNaN(d.getTime())) return iso
|
||||||
|
const pad = n => n < 10 ? '0' + n : '' + n
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
loading: true,
|
||||||
|
keyword: '',
|
||||||
|
items: [],
|
||||||
|
error: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
this.refresh()
|
||||||
|
},
|
||||||
|
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh().finally(() => {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onSearchInput(e) {
|
||||||
|
const val = e.detail && e.detail.value !== undefined ? String(e.detail.value) : ''
|
||||||
|
this.setData({ keyword: val })
|
||||||
|
},
|
||||||
|
|
||||||
|
onSearchConfirm() {
|
||||||
|
this.refresh()
|
||||||
|
},
|
||||||
|
|
||||||
|
onClearSearch() {
|
||||||
|
this.setData({ keyword: '' })
|
||||||
|
this.refresh()
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
this.setData({ loading: true, error: '' })
|
||||||
|
try {
|
||||||
|
const res = await api.personalCollect.list(this.data.keyword || '')
|
||||||
|
const raw = Array.isArray(res.data) ? res.data : []
|
||||||
|
const items = raw.map(item => ({
|
||||||
|
...item,
|
||||||
|
updatedAtText: formatDate(item.updatedAt || item.createdAt || ''),
|
||||||
|
tagsText: (item.tags || []).join(' · '),
|
||||||
|
previewImage: (item.attachments || []).find(a => a && a.isImage && a.filePath) || null,
|
||||||
|
fileCount: Array.isArray(item.attachments) ? item.attachments.length : 0,
|
||||||
|
}))
|
||||||
|
this.setData({ items })
|
||||||
|
} catch (e) {
|
||||||
|
this.setData({ error: e && e.message ? e.message : '加载失败' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onViewTap(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/kits/personal_collect/pages/detail/detail?id=${encodeURIComponent(id)}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onEditTap(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/kits/personal_collect/pages/edit/edit?id=${encodeURIComponent(id)}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onDeleteTap(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
const title = e.currentTarget.dataset.title || '此收藏'
|
||||||
|
wx.showModal({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定删除「${title}」吗?删除后不再显示。`,
|
||||||
|
confirmText: '删除',
|
||||||
|
confirmColor: '#e05561',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
try {
|
||||||
|
wx.showLoading({ title: '删除中...', mask: true })
|
||||||
|
await api.personalCollect.remove(id)
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
this.refresh()
|
||||||
|
} catch (err) {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除失败',
|
||||||
|
content: err && err.message ? err.message : '请重试',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onAddTap() {
|
||||||
|
wx.navigateTo({
|
||||||
|
url: '/kits/personal_collect/pages/edit/edit',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "微信收藏夹",
|
||||||
|
"enablePullDownRefresh": true,
|
||||||
|
"backgroundTextStyle": "light"
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<view class="container" style="padding-bottom: 200rpx;">
|
||||||
|
<view class="page-header">
|
||||||
|
<view class="page-title">微信收藏夹</view>
|
||||||
|
<view class="page-subtitle">保存聊天里的图片与文件,标签搜索管理。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="search-box">
|
||||||
|
<text class="search-icon">🔍</text>
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
placeholder="搜索标题 / 备注 / 标签"
|
||||||
|
placeholder-style="color:#555570"
|
||||||
|
value="{{keyword}}"
|
||||||
|
bindinput="onSearchInput"
|
||||||
|
bindconfirm="onSearchConfirm"
|
||||||
|
confirm-type="search"
|
||||||
|
/>
|
||||||
|
<view wx:if="{{keyword}}" bindtap="onClearSearch" style="padding: 0 8rpx;">
|
||||||
|
<text style="color:#8888a8; font-size: 36rpx;">×</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{error}}" class="card" style="border-color:#55333a;">
|
||||||
|
<text style="color:#e05561; font-size: 26rpx;">⚠️ {{error}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{loading}}" class="empty">加载中...</view>
|
||||||
|
|
||||||
|
<block wx:if="{{!loading && items.length === 0}}">
|
||||||
|
<view class="empty">
|
||||||
|
<view style="font-size: 80rpx; margin-bottom: 24rpx;">📁</view>
|
||||||
|
<view>{{keyword ? '没有匹配的收藏' : '还没有收藏,点右下角 + 新建'}}</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<block wx:for="{{items}}" wx:key="id">
|
||||||
|
<view class="card" bindtap="onViewTap" data-id="{{item.id}}">
|
||||||
|
<view class="row row-between">
|
||||||
|
<view style="flex:1; min-width:0;">
|
||||||
|
<view wx:if="{{item.title}}" style="font-size: 32rpx; font-weight: 600; color:#ffffff; margin-bottom: 12rpx;">{{item.title}}</view>
|
||||||
|
<view wx:if="{{!item.title && item.remark}}" style="font-size: 32rpx; font-weight: 600; color:#ffffff; margin-bottom: 12rpx; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;">{{item.remark}}</view>
|
||||||
|
<view wx:if="{{item.remark && item.title}}" class="muted small" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.5;">{{item.remark}}</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{item.previewImage}}" style="width:128rpx; height:128rpx; margin-left: 24rpx; border-radius: 16rpx; overflow: hidden; background:#12121f; flex-shrink:0;">
|
||||||
|
<image
|
||||||
|
src="https://traesite.umersoft.com:8975/{{item.previewImage.filePath}}"
|
||||||
|
mode="aspectFill"
|
||||||
|
style="width:100%; height:100%;"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view wx:elif="{{item.fileCount > 0}}" style="width:128rpx; height:128rpx; margin-left: 24rpx; border-radius: 16rpx; background:#12121f; display:flex; align-items:center; justify-content:center; flex-direction: column; flex-shrink:0;">
|
||||||
|
<text style="font-size: 40rpx;">📎</text>
|
||||||
|
<text class="muted small" style="margin-top: 8rpx;">{{item.fileCount}} 个</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{item.tagsText}}" style="margin-top: 20rpx;">
|
||||||
|
<block wx:for="{{item.tags}}" wx:for-item="tag" wx:key="*this">
|
||||||
|
<view class="tag">#{{tag}}</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="row row-between" style="margin-top: 20rpx;">
|
||||||
|
<text class="muted small">更新于 {{item.updatedAtText}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="item-actions">
|
||||||
|
<view class="btn btn-ghost mini-btn" catchtap="onViewTap" data-id="{{item.id}}">查看</view>
|
||||||
|
<view class="btn btn-ghost mini-btn" catchtap="onEditTap" data-id="{{item.id}}">编辑</view>
|
||||||
|
<view class="spacer"></view>
|
||||||
|
<view class="btn btn-danger mini-btn" catchtap="onDeleteTap" data-id="{{item.id}}" data-title="{{item.title || (item.remark && item.remark.slice(0, 10)) || '此收藏'}}">删除</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="fab" bindtap="onAddTap">+</view>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* 列表页独立样式,大部分复用 app.wxss 全局样式 */
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
const api = require('../../api.js')
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
loading: true,
|
||||||
|
kits: [],
|
||||||
|
appNameZh: 'MilesYang时光助手',
|
||||||
|
subtitle: '个人工具集合,记录时光点滴。',
|
||||||
|
error: '',
|
||||||
|
clipVisible: false,
|
||||||
|
clipText: '',
|
||||||
|
clipCount: 0,
|
||||||
|
clipLines: 0,
|
||||||
|
clipPreview: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
this.loadMeta()
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
this.checkClipboard()
|
||||||
|
},
|
||||||
|
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.loadMeta().finally(() => {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
simpleHash(str) {
|
||||||
|
const s = String(str || '')
|
||||||
|
let h1 = 0x811c9dc5
|
||||||
|
let h2 = 0xdeadbeef
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const c = s.charCodeAt(i)
|
||||||
|
h1 = Math.imul(h1 ^ c, 16777619)
|
||||||
|
h2 = Math.imul(h2 ^ c, 2246822519)
|
||||||
|
}
|
||||||
|
h1 = (h1 ^ h1 >>> 16) >>> 0
|
||||||
|
h2 = (h2 ^ h2 >>> 15) >>> 0
|
||||||
|
return h1.toString(36) + h2.toString(36)
|
||||||
|
},
|
||||||
|
|
||||||
|
checkClipboard() {
|
||||||
|
try {
|
||||||
|
const enable = wx.getStorageSync('mp_ta_clip_enable')
|
||||||
|
if (enable === false || enable === 'false') return
|
||||||
|
} catch (e) {}
|
||||||
|
wx.getClipboardData({
|
||||||
|
success: (res) => {
|
||||||
|
const text = (res.data || '').trim()
|
||||||
|
if (text.length < 6 || text.length > 30000) return
|
||||||
|
const hash = this.simpleHash(text)
|
||||||
|
try {
|
||||||
|
const last = wx.getStorageSync('mp_ta_clip_hash')
|
||||||
|
if (last === hash) return
|
||||||
|
wx.setStorageSync('mp_ta_clip_hash', hash)
|
||||||
|
} catch (e) {}
|
||||||
|
const count = text.length
|
||||||
|
const lines = text.split('\n').length
|
||||||
|
const preview = text.length > 220 ? text.slice(0, 220) + '\n\n……' : text
|
||||||
|
this.setData({
|
||||||
|
clipVisible: true,
|
||||||
|
clipText: text,
|
||||||
|
clipCount: count,
|
||||||
|
clipLines: lines,
|
||||||
|
clipPreview: preview,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: () => {}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onClipCollect(e) {
|
||||||
|
const mode = (e && e.currentTarget && e.currentTarget.dataset.mode) || 'new'
|
||||||
|
const text = this.data.clipText || ''
|
||||||
|
if (!text) { this.setData({ clipVisible: false }); return }
|
||||||
|
try { wx.setStorageSync('mp_ta_clip_text', text) } catch (e) {}
|
||||||
|
try { wx.setStorageSync('mp_ta_clip_mode', mode) } catch (e) {}
|
||||||
|
this.setData({ clipVisible: false })
|
||||||
|
wx.navigateTo({
|
||||||
|
url: '/kits/personal_collect/pages/edit/edit',
|
||||||
|
fail: () => { wx.showToast({ title: '打开失败', icon: 'none' }) }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onClipCancel() {
|
||||||
|
this.setData({ clipVisible: false })
|
||||||
|
},
|
||||||
|
|
||||||
|
onClipDismissForever() {
|
||||||
|
wx.showModal({
|
||||||
|
title: '不再检测剪贴板?',
|
||||||
|
content: '关闭后可在未来任意新增收藏页面时重新开启。',
|
||||||
|
confirmText: '不再提示',
|
||||||
|
cancelText: '保留',
|
||||||
|
success: (r) => {
|
||||||
|
if (r.confirm) {
|
||||||
|
try { wx.setStorageSync('mp_ta_clip_enable', false) } catch (e) {}
|
||||||
|
this.setData({ clipVisible: false })
|
||||||
|
wx.showToast({ title: '已关闭检测', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadMeta() {
|
||||||
|
this.setData({ loading: true, error: '' })
|
||||||
|
try {
|
||||||
|
const res = await api.core.getMeta()
|
||||||
|
if (res && res.appNameZh) {
|
||||||
|
this.setData({ appNameZh: res.appNameZh })
|
||||||
|
}
|
||||||
|
if (res && Array.isArray(res.kits) && res.kits.length) {
|
||||||
|
this.setData({ kits: this.decorateKits(res.kits) })
|
||||||
|
try { getApp().setMeta(res) } catch (e) {}
|
||||||
|
} else {
|
||||||
|
this.setData({ kits: this.fallbackKits() })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.setData({
|
||||||
|
error: e && e.message ? e.message : '加载失败,请检查网络',
|
||||||
|
kits: this.fallbackKits(),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fallbackKits() {
|
||||||
|
return this.decorateKits([{
|
||||||
|
id: 'personal_collect',
|
||||||
|
name: '微信收藏夹',
|
||||||
|
description: '聊天图片、文件收藏管理,标签搜索,软删除可追溯。',
|
||||||
|
}])
|
||||||
|
},
|
||||||
|
|
||||||
|
decorateKits(list) {
|
||||||
|
const icons = {
|
||||||
|
personal_collect: '📁',
|
||||||
|
}
|
||||||
|
return list.map(item => Object.assign({}, item, {
|
||||||
|
icon: icons[item.id] || '🛠️',
|
||||||
|
path: this.getKitPath(item.id),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
getKitPath(id) {
|
||||||
|
const map = {
|
||||||
|
personal_collect: '/kits/personal_collect/pages/list/list',
|
||||||
|
}
|
||||||
|
return map[id] || ''
|
||||||
|
},
|
||||||
|
|
||||||
|
async onKitTap(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
const path = e.currentTarget.dataset.path
|
||||||
|
if (!path) {
|
||||||
|
wx.showToast({ title: '功能开发中', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.core.setActiveKit(id)
|
||||||
|
} catch (e) {}
|
||||||
|
wx.navigateTo({
|
||||||
|
url: path,
|
||||||
|
fail: () => {
|
||||||
|
wx.showToast({ title: '打开失败', icon: 'none' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "MilesYang时光助手",
|
||||||
|
"enablePullDownRefresh": true,
|
||||||
|
"backgroundTextStyle": "light"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<view class="container">
|
||||||
|
<view class="page-header">
|
||||||
|
<view class="page-title">{{appNameZh}}</view>
|
||||||
|
<view class="page-subtitle">{{subtitle}}</view>
|
||||||
|
<view wx:if="{{error}}" class="muted small" style="margin-top: 16rpx; color: #e05561;">⚠️ {{error}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{clipVisible}}" class="clip-card" catchtouchmove="noop">
|
||||||
|
<view class="clip-head">
|
||||||
|
<view class="clip-title">📝 检测到剪贴板文字</view>
|
||||||
|
<view class="clip-count">{{clipCount}} 字 · {{clipLines}} 行</view>
|
||||||
|
</view>
|
||||||
|
<view class="clip-preview">{{clipPreview}}</view>
|
||||||
|
<view class="clip-actions">
|
||||||
|
<view class="clip-btn clip-btn-ghost" bindtap="onClipDismissForever">不再提示</view>
|
||||||
|
<view class="clip-btn clip-btn-ghost" bindtap="onClipCancel">取消</view>
|
||||||
|
<view class="clip-btn clip-btn-primary" bindtap="onClipCollect" data-mode="new">📌 收藏为文字</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{loading}}" class="empty">加载中...</view>
|
||||||
|
|
||||||
|
<block wx:for="{{kits}}" wx:key="id">
|
||||||
|
<view class="kit-card" bindtap="onKitTap" data-id="{{item.id}}" data-path="{{item.path}}">
|
||||||
|
<view class="kit-icon">
|
||||||
|
<text class="kit-icon-text">{{item.icon}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="kit-body">
|
||||||
|
<view class="kit-title">{{item.name}}</view>
|
||||||
|
<view class="kit-desc">{{item.description}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="chevron">›</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/* 首页独立样式,大部分复用 app.wxss 全局样式 */
|
||||||
|
|
||||||
|
.clip-card {
|
||||||
|
margin: 20rpx 24rpx 12rpx;
|
||||||
|
padding: 24rpx 26rpx 22rpx;
|
||||||
|
background: linear-gradient(155deg, rgba(108, 122, 224, 0.18), rgba(176, 107, 224, 0.10));
|
||||||
|
border: 1px solid rgba(140, 150, 240, 0.22);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.28);
|
||||||
|
}
|
||||||
|
.clip-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
}
|
||||||
|
.clip-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ececff;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
.clip-count {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #a0a4d0;
|
||||||
|
background: rgba(108, 122, 224, 0.22);
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
.clip-preview {
|
||||||
|
max-height: 260rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 18rpx 20rpx;
|
||||||
|
background: rgba(0,0,0,0.32);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 25rpx;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #d4d4ee;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
.clip-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.clip-btn {
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
font-size: 25rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 160rpx;
|
||||||
|
}
|
||||||
|
.clip-btn-ghost {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: #b8b8d8;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.clip-btn-secondary {
|
||||||
|
background: rgba(108, 122, 224, 0.16);
|
||||||
|
color: #cfd4ff;
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.32);
|
||||||
|
}
|
||||||
|
.clip-btn-primary {
|
||||||
|
background: linear-gradient(135deg, #6c7ae0, #b06be0);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 8rpx 20rpx rgba(108, 122, 224, 0.32);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"description": "MilesYang时光助手 - 微信小程序版工具箱",
|
||||||
|
"packOptions": {
|
||||||
|
"ignore": [],
|
||||||
|
"include": []
|
||||||
|
},
|
||||||
|
"setting": {
|
||||||
|
"bundle": false,
|
||||||
|
"userConfirmedBundleSwitch": false,
|
||||||
|
"urlCheck": true,
|
||||||
|
"scopeDataCheck": false,
|
||||||
|
"coverView": true,
|
||||||
|
"es6": true,
|
||||||
|
"postcss": true,
|
||||||
|
"compileHotReLoad": false,
|
||||||
|
"lazyloadPlaceholderEnable": false,
|
||||||
|
"preloadBackgroundData": false,
|
||||||
|
"minified": true,
|
||||||
|
"autoAudits": false,
|
||||||
|
"newFeature": false,
|
||||||
|
"uglifyFileName": false,
|
||||||
|
"uploadWithSourceMap": true,
|
||||||
|
"useIsolateContext": true,
|
||||||
|
"nodeModules": false,
|
||||||
|
"enhance": true,
|
||||||
|
"useMultiFrameRuntime": true,
|
||||||
|
"useApiHook": true,
|
||||||
|
"useApiHostProcess": true,
|
||||||
|
"showShadowRootInWxmlPanel": true,
|
||||||
|
"packNpmManually": false,
|
||||||
|
"enableEngineNative": false,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"minifyWXSS": true,
|
||||||
|
"showES6CompileOption": false,
|
||||||
|
"minifyWXML": true,
|
||||||
|
"babelSetting": {
|
||||||
|
"ignore": [],
|
||||||
|
"disablePlugins": [],
|
||||||
|
"outputPath": ""
|
||||||
|
},
|
||||||
|
"compileWorklet": false,
|
||||||
|
"localPlugins": false,
|
||||||
|
"disableUseStrict": false,
|
||||||
|
"useCompilerPlugins": false,
|
||||||
|
"condition": false
|
||||||
|
},
|
||||||
|
"compileType": "miniprogram",
|
||||||
|
"libVersion": "3.0.0",
|
||||||
|
"appid": "wx4fe6339f60c7b2a4",
|
||||||
|
"projectname": "mp_time_assistant",
|
||||||
|
"condition": {},
|
||||||
|
"editorSetting": {
|
||||||
|
"tabIndent": "insertSpaces",
|
||||||
|
"tabSize": 2
|
||||||
|
},
|
||||||
|
"simulatorPluginLibVersion": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"libVersion": "3.17.2",
|
||||||
|
"projectname": "mp_time_assistant%5B%E6%97%B6%E5%85%89%E5%8A%A9%E6%89%8B%5D",
|
||||||
|
"condition": {},
|
||||||
|
"setting": {
|
||||||
|
"urlCheck": true,
|
||||||
|
"coverView": true,
|
||||||
|
"lazyloadPlaceholderEnable": false,
|
||||||
|
"skylineRenderEnable": false,
|
||||||
|
"preloadBackgroundData": false,
|
||||||
|
"autoAudits": false,
|
||||||
|
"useApiHook": true,
|
||||||
|
"showShadowRootInWxmlPanel": true,
|
||||||
|
"useStaticServer": false,
|
||||||
|
"useLanDebug": false,
|
||||||
|
"showES6CompileOption": false,
|
||||||
|
"compileHotReLoad": true,
|
||||||
|
"checkInvalidKey": true,
|
||||||
|
"ignoreDevUnusedFiles": true,
|
||||||
|
"bigPackageSizeSupport": false,
|
||||||
|
"useIsolateContext": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/sitemap.html",
|
||||||
|
"rules": [{
|
||||||
|
"action": "disallow",
|
||||||
|
"page": "*"
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
{
|
{
|
||||||
"_comment": "日程提醒工具的前端鉴权配置",
|
"_comment": "日程提醒工具的前端鉴权配置",
|
||||||
"enable_auth_comment": "true:启用鉴权(需要导航/登录获得 cookie);false:完全放开访问",
|
"enable_auth_comment": "true:启用 tool 外层鉴权(通过九宫格导航或合法 GUID/token 进入,设置 calendar_reminder_gate cookie);false:完全放开访问",
|
||||||
"enable_auth": true,
|
"enable_auth": true,
|
||||||
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365),到期后需要重新登录",
|
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365),到期后需要重新从九宫格或合法入口进入",
|
||||||
"max_age_days": 30
|
"max_age_days": 30,
|
||||||
|
"cookieName_comment": "外层 gate cookie 名,需与 src/server/index.js 中的 CALENDAR_AUTH_COOKIE 保持一致",
|
||||||
|
"cookieName": "calendar_reminder_gate",
|
||||||
|
"requireGuid_comment": "true:强制 URL 带 GUID 参数才能访问;false:允许仅凭已有 gate cookie 或导航 token 访问",
|
||||||
|
"requireGuid": false,
|
||||||
|
"guidParam_comment": "URL 中 GUID 参数名(requireGuid=true 时生效)",
|
||||||
|
"guidParam": "guid",
|
||||||
|
"guidValue_comment": "GUID 参数真值(留空表示不启用 GUID;严禁在此写真实生产 GUID,生产值来自本机私有配置)",
|
||||||
|
"guidValue": ""
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>日历批量生成工具 · 独立窗口</title>
|
||||||
|
<link rel="icon" href="calendar.svg" type="image/svg+xml" />
|
||||||
|
<link rel="stylesheet" href="/lib/bootstrap.min.css?v=1.6">
|
||||||
|
<link rel="stylesheet" href="css/styles.css?v=1.6">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<header class="navbar navbar-dark bg-warning sticky-top">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand mb-0 h1">🧺 日历批量生成工具 · 仅循环场景</span>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button id="btnBackToMain" class="btn btn-light btn-sm" title="回到日历主界面">🗓 返回主日历</button>
|
||||||
|
<button id="btnReload" class="btn btn-light btn-sm" title="刷新批次列表">⟳ 刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container py-3" style="max-width:1280px">
|
||||||
|
<ul class="nav nav-tabs mb-3" id="batchTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="tab-generator" data-bs-toggle="tab" data-bs-target="#panel-generator" type="button">生成器</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-batches" data-bs-toggle="tab" data-bs-target="#panel-batches" type="button" onclick="CalendarBatchTool && CalendarBatchTool.loadBatches && CalendarBatchTool.loadBatches()">批次管理</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-sql" data-bs-toggle="tab" data-bs-target="#panel-sql" type="button">🧪 SQL 执行</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content" id="batchTabsContent">
|
||||||
|
<div class="tab-pane fade show active" id="panel-generator" role="tabpanel">
|
||||||
|
<form id="batchForm" class="vstack gap-3">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-8">
|
||||||
|
<label class="form-label">批次名称(可选)</label>
|
||||||
|
<input type="text" id="batch_name" class="form-control" placeholder="如:2026Q4 洗被子提醒">
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">循环模式</label>
|
||||||
|
<select id="batch_mode" class="form-select">
|
||||||
|
<option value="monthlyByDay" selected>每月 N 号(推荐)</option>
|
||||||
|
<option value="monthlyByNthWeekday">每月第 N 周星期 X</option>
|
||||||
|
<option value="weekly">每 N 周 · 星期多选</option>
|
||||||
|
<option value="daily">每 N 天</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-3"><label class="form-label">时间范围 · 起</label><input type="date" id="batch_rangeStart" class="form-control" required></div>
|
||||||
|
<div class="col-3"><label class="form-label">时间范围 · 止</label><input type="date" id="batch_rangeEnd" class="form-control" required></div>
|
||||||
|
<div class="col-6 d-flex align-items-end gap-3">
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" id="batch_allDay">
|
||||||
|
<label class="form-check-label" for="batch_allDay">全天(忽略时分)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" id="batch_skipExternal">
|
||||||
|
<label class="form-check-label" for="batch_skipExternal">不联动周报下游</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" id="batch_completed">
|
||||||
|
<label class="form-check-label" for="batch_completed">全部标记已完成</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mode-fieldset" data-mode="daily">
|
||||||
|
<div class="row g-2"><div class="col-4">
|
||||||
|
<label class="form-label">每 _ 天</label>
|
||||||
|
<input type="number" min="1" value="1" id="batch_dailyEvery" class="form-control">
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="mode-fieldset" data-mode="weekly" style="display:none">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-4"><label class="form-label">每 _ 周</label><input type="number" min="1" value="1" id="batch_weeklyEvery" class="form-control"></div>
|
||||||
|
<div class="col-8">
|
||||||
|
<label class="form-label">星期(可多选)</label>
|
||||||
|
<div id="batch_weekdays" class="hstack gap-3 flex-wrap">
|
||||||
|
<label class="form-check"><input type="checkbox" value="0" class="form-check-input batch-wd">周日</label>
|
||||||
|
<label class="form-check"><input type="checkbox" value="1" class="form-check-input batch-wd">周一</label>
|
||||||
|
<label class="form-check"><input type="checkbox" value="2" class="form-check-input batch-wd">周二</label>
|
||||||
|
<label class="form-check"><input type="checkbox" value="3" class="form-check-input batch-wd">周三</label>
|
||||||
|
<label class="form-check"><input type="checkbox" value="4" class="form-check-input batch-wd">周四</label>
|
||||||
|
<label class="form-check"><input type="checkbox" value="5" class="form-check-input batch-wd">周五</label>
|
||||||
|
<label class="form-check"><input type="checkbox" value="6" class="form-check-input batch-wd">周六</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mode-fieldset" data-mode="monthlyByDay">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-4"><label class="form-label">每 _ 月</label><input type="number" min="1" value="2" id="batch_monthlyEvery" class="form-control"></div>
|
||||||
|
<div class="col-8">
|
||||||
|
<label class="form-label">每月哪些号(支持 1-5,15,25-28 混合写法)</label>
|
||||||
|
<input type="text" id="batch_monthlyDays" class="form-control" value="1-5" placeholder="例如:1-5,15,25-28">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mode-fieldset" data-mode="monthlyByNthWeekday" style="display:none">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-4"><label class="form-label">每 _ 月</label><input type="number" min="1" value="1" id="batch_monthlyEvery2" class="form-control"></div>
|
||||||
|
<div class="col-4"><label class="form-label">第几周</label>
|
||||||
|
<select id="batch_nthWeek" class="form-select"><option>1</option><option>2</option><option selected>3</option><option>4</option><option>5</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-4"><label class="form-label">星期</label>
|
||||||
|
<select id="batch_nthWeekday" class="form-select">
|
||||||
|
<option value="0">周日</option><option value="1">周一</option><option value="2">周二</option>
|
||||||
|
<option value="3">周三</option><option value="4">周四</option><option value="5">周五</option><option value="6">周六</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-4"><label class="form-label">开始时分</label><input type="time" id="batch_startTime" class="form-control" value="09:00"></div>
|
||||||
|
<div class="col-4"><label class="form-label">结束时分(留空=不设)</label><input type="time" id="batch_endTime" class="form-control" value="10:00"></div>
|
||||||
|
<div class="col-4"><label class="form-label">提醒时分(留空=不提醒)</label><input type="time" id="batch_remindTime" class="form-control" value="08:30"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">标题模板(支持 {date} {yyyy} {MM} {dd} {W})</label>
|
||||||
|
<input type="text" id="batch_title" class="form-control" value="洗被子提醒({MM}-{dd})" required maxlength="150" placeholder="如:洗被子提醒({MM}-{dd})">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">内容模板(同上占位符)</label>
|
||||||
|
<textarea id="batch_content" class="form-control" rows="3" placeholder="写备注,支持 {date} 占位符"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="hstack gap-2">
|
||||||
|
<button type="button" id="batch_btnPreview" class="btn btn-outline-primary">👁 预览 SQL</button>
|
||||||
|
<button type="button" id="batch_btnApply" class="btn btn-primary" disabled>▶ 直接入库</button>
|
||||||
|
<button type="button" id="batch_btnDownload" class="btn btn-outline-secondary" disabled>💾 下载 SQL</button>
|
||||||
|
<span id="batch_previewHint" class="text-muted small ms-2">请先点「预览 SQL」再入库</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="batch_previewArea" class="mt-3 d-none">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header small">预览信息</div>
|
||||||
|
<div class="card-body small">
|
||||||
|
<div id="batch_previewStats" class="mb-2"></div>
|
||||||
|
<div class="mb-2"><span class="text-danger fw-bold" id="batch_preExisting"></span></div>
|
||||||
|
<div class="mb-2"><strong>样例(前 20 条):</strong></div>
|
||||||
|
<table class="table table-sm table-hover" id="batch_sampleTable"><thead><tr><th>开始</th><th>结束</th><th>提醒</th><th>标题</th></tr></thead><tbody></tbody></table>
|
||||||
|
<div class="mb-2"><strong>SQL(前 503 行,完整的请下载文件):</strong></div>
|
||||||
|
<textarea id="batch_sqlText" readonly rows="14" class="form-control font-monospace small"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="panel-batches" role="tabpanel">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<div style="width:360px"><input id="batch_search" class="form-control form-control-sm" placeholder="搜索标题 / 模式…(输入即搜)"></div>
|
||||||
|
<button type="button" id="batch_refresh" class="btn btn-outline-secondary btn-sm">⟳ 刷新批次</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover align-middle" id="batch_listTable">
|
||||||
|
<thead><tr>
|
||||||
|
<th>批次名</th><th>模式</th><th>时间范围</th>
|
||||||
|
<th>生成 / 入库 / 去重 / 已回滚</th>
|
||||||
|
<th>状态</th><th>创建时间</th><th>操作</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="panel-sql" role="tabpanel">
|
||||||
|
<form class="vstack gap-3">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">批次名称(必填,便于后面批次管理/回滚查找)</label>
|
||||||
|
<input type="text" id="sql_batch_name" class="form-control" placeholder="如:2026-09-02 手动导入客户提醒 SQL">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 d-flex align-items-end gap-3">
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" id="sql_skipExternal" checked>
|
||||||
|
<label class="form-check-label" for="sql_skipExternal">不联动周报下游(默认勾,避免爆量)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" id="sql_completed">
|
||||||
|
<label class="form-check-label" for="sql_completed">全部标记已完成</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">SQL 文本框(只支持 <code>INSERT INTO events (...) VALUES (...)</code>;单条 / 多条 / 多个语句用 ; 分开都行;每行 events 字段允许:id/title/content/start/end/allDay/remindAt/recurrence/createdAt/updatedAt/completed/deleted/batch_id/completedAt/deletedAt,其他字段一律被拒绝;禁止 UPDATE / DELETE / DROP / ALTER)</label>
|
||||||
|
<textarea id="sql_textarea" rows="22" class="form-control font-monospace small" placeholder="INSERT INTO events (title,content,start,end,allDay,remindAt,completed,deleted,batch_id) VALUES
|
||||||
|
('客户A回访','准备季度会议','2026-09-03 09:00','2026-09-03 10:00',0,'2026-09-03 08:30',0,0,NULL),
|
||||||
|
('客户B回访','报价最终版本','2026-09-05 14:00','2026-09-05 15:00',0,'2026-09-05 13:30',0,0,NULL);
|
||||||
|
"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="hstack gap-2">
|
||||||
|
<button type="button" id="sql_btnCheck" class="btn btn-outline-info">🧪 SQL 语句检查</button>
|
||||||
|
<button type="button" id="sql_btnApply" class="btn btn-warning" disabled>▶ 执行 SQL 入库(生成批次,可回滚)</button>
|
||||||
|
<button type="button" id="sql_btnDownload" class="btn btn-outline-secondary">💾 下载 SQL</button>
|
||||||
|
<span id="sql_hint" class="text-muted small ms-2">建议先点「检查」确认语法/重复后再执行</span>
|
||||||
|
</div>
|
||||||
|
<div id="sql_checkArea" class="d-none mt-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>🧪 检查结果</span>
|
||||||
|
<span id="sql_checkSummary" class="badge bg-light"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body small">
|
||||||
|
<div id="sql_checkMeta" class="mb-2"></div>
|
||||||
|
<div id="sql_checkForbidden" class="d-none text-danger mb-2"></div>
|
||||||
|
<div id="sql_checkSyntaxErrors" class="d-none text-danger mb-2"></div>
|
||||||
|
<div id="sql_checkColErrors" class="d-none text-danger mb-2"></div>
|
||||||
|
<div id="sql_checkDuplicate" class="d-none text-warning mb-2">
|
||||||
|
<span class="fw-bold">⚠️ 检测到重复(宽松模式):</span>
|
||||||
|
<button id="sql_dupDetail" class="btn btn-outline-warning btn-sm ms-2">查看重复明细…</button>
|
||||||
|
</div>
|
||||||
|
<div><strong>样例行(前 15 条):</strong></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover"><thead><tr><th>语句#</th><th>行#</th><th>标题</th><th>开始</th><th>结束</th></tr></thead><tbody id="sql_checkSampleTbody"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="modal fade" id="batchDetailModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header"><h5 class="modal-title">批次详情</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="batch_detailBody"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer"><button class="btn btn-secondary" data-bs-dismiss="modal">关闭</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="sqlDuplicateModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">⚠️ SQL 批次检测到重复(宽松模式)</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body small">
|
||||||
|
<div id="sql_dupSummary" class="mb-2"></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead><tr><th>你的 SQL 语句#-行#</th><th>你的标题</th><th>你的开始</th><th>匹配库内事件标题</th><th>匹配库内开始</th><th>相似度</th><th>操作</th></tr></thead>
|
||||||
|
<tbody id="sql_dupTbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="sql_dupCancel">先不执行,回去改 SQL</button>
|
||||||
|
<button type="button" class="btn btn-warning" data-bs-dismiss="modal" id="sql_dupConfirm">我已知悉重复,继续执行生成批次</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/config.js"></script>
|
||||||
|
<script src="/lib/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="js/batch.js?v=1.7"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
*{box-sizing:border-box}
|
|
||||||
html{font-size:14px}
|
|
||||||
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans','Liberation Sans',sans-serif;line-height:1.5;color:#212529;overflow-x:hidden}
|
|
||||||
.container{width:100%;max-width:1140px;margin:0 auto;padding-left:12px;padding-right:12px}
|
|
||||||
.container-fluid{width:100%;padding-left:12px;padding-right:12px}
|
|
||||||
.navbar{display:flex;align-items:center;padding:.5rem 0}
|
|
||||||
.navbar{flex-wrap:wrap}
|
|
||||||
.navbar .container-fluid{display:flex;align-items:center;justify-content:center;gap:.75rem}
|
|
||||||
.bg-primary{background-color:#0d6efd}
|
|
||||||
.bg-dark{background-color:#2b2b2b}
|
|
||||||
.navbar-dark{color:#fff}
|
|
||||||
.navbar-dark .navbar-brand{color:#fff}
|
|
||||||
.navbar .navbar-brand{font-size:1.25rem;font-weight:500}
|
|
||||||
.text-white{color:#fff}
|
|
||||||
.text-muted{color:#6c757d}
|
|
||||||
.small{font-size:.875em}
|
|
||||||
.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;user-select:none;background-color:#f8f9fa;border:1px solid rgba(0,0,0,.125);padding:.375rem .75rem;border-radius:.375rem;transition:background-color .15s ease}
|
|
||||||
.btn:disabled{opacity:.65}
|
|
||||||
.btn:hover{background-color:#e9ecef}
|
|
||||||
.btn-light{color:#212529;background-color:#f8f9fa;border-color:#ced4da}
|
|
||||||
.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}
|
|
||||||
.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}
|
|
||||||
.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}
|
|
||||||
.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}
|
|
||||||
.card{display:block;background-color:#fff;border:1px solid rgba(0,0,0,.125);border-radius:.5rem}
|
|
||||||
.card-header{padding:.5rem 1rem;border-bottom:1px solid rgba(0,0,0,.125);background-color:#f8f9fa;font-weight:500}
|
|
||||||
.card-body{padding:1rem}
|
|
||||||
.row{display:flex;flex-wrap:wrap}
|
|
||||||
.row.g-2{gap:.5rem}
|
|
||||||
.col-12{flex:0 0 auto;width:100%}
|
|
||||||
.col-6{flex:0 0 auto;width:50%}
|
|
||||||
@media (min-width:992px){.col-lg-8{flex:0 0 auto;width:66.6667%}.col-lg-4{flex:0 0 auto;width:33.3333%}}
|
|
||||||
.form-label{margin-bottom:.25rem;font-weight:500}
|
|
||||||
.form-control,.form-select{display:block;width:100%;padding:.5rem .75rem;font-size:1rem;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.375rem}
|
|
||||||
.form-control:focus,.form-select:focus{outline:none;border-color:#86b7fe;box-shadow:0 0 0 .2rem rgba(13,110,253,.25)}
|
|
||||||
.form-control::-webkit-input-placeholder{color:#6c757d}
|
|
||||||
.form-control::-moz-placeholder{color:#6c757d}
|
|
||||||
input[type=datetime-local],input[type=time]{padding-right:2.25rem}
|
|
||||||
.card{box-shadow:0 8px 24px rgba(0,0,0,.08)}
|
|
||||||
.list-group{display:block;border-radius:.5rem;overflow:hidden}
|
|
||||||
.list-group .list-group-item{display:block;padding:.5rem 1rem;background-color:#fff;border-bottom:1px solid rgba(0,0,0,.125)}
|
|
||||||
.list-group-flush .list-group-item{border-left:0;border-right:0}
|
|
||||||
.hstack{display:flex;align-items:center}
|
|
||||||
.justify-content-between{justify-content:space-between}
|
|
||||||
.gap-2{gap:.5rem}
|
|
||||||
.mt-2{margin-top:.5rem}
|
|
||||||
.mb-2{margin-bottom:.5rem}
|
|
||||||
.mb-0{margin-bottom:0}
|
|
||||||
.py-3{padding-top:1rem;padding-bottom:1rem}
|
|
||||||
.d-flex{display:flex}
|
|
||||||
.align-items-center{align-items:center}
|
|
||||||
.d-none{display:none!important}
|
|
||||||
@@ -678,3 +678,25 @@ main {
|
|||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
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; }
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<title>日历提醒系统 · Web</title>
|
<title>日历提醒系统 · Web</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link rel="icon" href="calendar.svg" type="image/svg+xml" />
|
<link rel="icon" href="calendar.svg" type="image/svg+xml" />
|
||||||
<link rel="stylesheet" href="css/bootstrap.local.css">
|
<link rel="stylesheet" href="/lib/bootstrap.min.css?v=1.6">
|
||||||
<link rel="stylesheet" href="css/styles.css?v=1.1">
|
<link rel="stylesheet" href="css/styles.css?v=1.6">
|
||||||
<script src="js/background.js?v=1.1" defer></script>
|
<script src="js/background.js?v=1.1" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<button id="btnPrev" class="btn btn-light btn-sm">上一月</button>
|
<button id="btnPrev" class="btn btn-light btn-sm">上一月</button>
|
||||||
<div id="currentMonth" class="text-white"></div>
|
<div id="currentMonth" class="text-white"></div>
|
||||||
<button id="btnNext" class="btn btn-light btn-sm">下一月</button>
|
<button id="btnNext" class="btn btn-light btn-sm">下一月</button>
|
||||||
|
<button id="btnOpenBatchTool" class="btn btn-warning btn-sm" title="批量生成循环提醒(展开到每天)· 新窗口打开" target="_blank" rel="noopener">🧺 批量生成</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -127,11 +128,11 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="js/config.js"></script>
|
<script src="js/config.js"></script>
|
||||||
<script src="/assets/auth.js"></script>
|
|
||||||
<script src="js/db.js"></script>
|
<script src="js/db.js"></script>
|
||||||
<script src="js/models.js?v=1.1"></script>
|
<script src="js/models.js?v=1.1"></script>
|
||||||
<script src="js/sync.js?v=1.1"></script>
|
<script src="js/sync.js?v=1.1"></script>
|
||||||
<script src="js/ui.js?v=1.3"></script>
|
<script src="js/ui.js?v=1.3"></script>
|
||||||
|
<script src="js/open_batch.js?v=1.6"></script>
|
||||||
<script src="js/main.js?v=1.3"></script>
|
<script src="js/main.js?v=1.3"></script>
|
||||||
<script src="/lib/bootstrap.bundle.min.js"></script>
|
<script src="/lib/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
(function () {
|
||||||
|
const $ = (id) => document.getElementById(id)
|
||||||
|
const api = (path, opts = {}) => fetch(`/api/calendar_reminder/${path.replace(/^\//, '')}`, Object.assign({ credentials: 'same-origin', headers: { 'Content-Type': 'application/json' } }, opts, { body: (opts.body && typeof opts.body !== 'string') ? JSON.stringify(opts.body) : opts.body }))
|
||||||
|
const copy = async (text) => {
|
||||||
|
try { await navigator.clipboard.writeText(text); return true } catch { return false }
|
||||||
|
}
|
||||||
|
const setBtn = (el, loading, text) => { if (!el) return; if (loading) { el.disabled = true; el.dataset.old = el.textContent; el.textContent = text || el.textContent + ' 中…' } else { el.disabled = false; if (el.dataset.old) el.textContent = el.dataset.old } }
|
||||||
|
const toastNode = (msg, type) => {
|
||||||
|
if (typeof msg !== 'string' || !msg) return
|
||||||
|
const div = document.createElement('div')
|
||||||
|
const colorMap = { success: '#d1e7dd', error: '#f8d7da', info: '#cff4fc', warn: '#fff3cd' }
|
||||||
|
const fgMap = { success: '#0f5132', error: '#842029', info: '#055160', warn: '#664d03' }
|
||||||
|
const bg = colorMap[type || 'info'] || colorMap.info
|
||||||
|
const fg = fgMap[type || 'info'] || fgMap.info
|
||||||
|
div.textContent = msg
|
||||||
|
div.style.cssText = `position:fixed;top:64px;right:24px;z-index:99999;padding:8px 14px;border-radius:8px;box-shadow:0 4px 18px rgba(0,0,0,0.18);background:${bg};color:${fg};font-size:13px;max-width:420px;animation:cbFadeIn 0.18s ease-out;`
|
||||||
|
if (!document.getElementById('cbToastStyles')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'cbToastStyles'
|
||||||
|
s.textContent = `@keyframes cbFadeIn { from { transform: translateY(-6px); opacity: 0;} to { transform: translateY(0); opacity:1; } }`
|
||||||
|
document.head.appendChild(s)
|
||||||
|
}
|
||||||
|
document.body.appendChild(div)
|
||||||
|
setTimeout(() => { div.style.transition = 'opacity 0.2s'; div.style.opacity = '0'; setTimeout(() => div.remove(), 240) }, 2200)
|
||||||
|
}
|
||||||
|
const toast = (msg, type) => {
|
||||||
|
try {
|
||||||
|
if (window.CAL && window.CAL.toast) window.CAL.toast(msg, type)
|
||||||
|
else toastNode(msg, type)
|
||||||
|
} catch { toastNode(msg, type) }
|
||||||
|
}
|
||||||
|
const readForm = () => {
|
||||||
|
const mode = $('batch_mode').value
|
||||||
|
const weekdays = [...document.querySelectorAll('.batch-wd:checked')].map(c => Number(c.value))
|
||||||
|
const params = {
|
||||||
|
batchName: $('batch_name').value.trim(),
|
||||||
|
mode,
|
||||||
|
rangeStart: $('batch_rangeStart').value,
|
||||||
|
rangeEnd: $('batch_rangeEnd').value,
|
||||||
|
allDay: $('batch_allDay').checked,
|
||||||
|
completed: $('batch_completed').checked,
|
||||||
|
startTime: $('batch_startTime').value || '',
|
||||||
|
endTime: $('batch_endTime').value || '',
|
||||||
|
remindTime: $('batch_remindTime').value || '',
|
||||||
|
title: $('batch_title').value.trim(),
|
||||||
|
content: $('batch_content').value
|
||||||
|
}
|
||||||
|
if (mode === 'daily') params.dailyEvery = Number($('batch_dailyEvery').value || 1)
|
||||||
|
if (mode === 'weekly') { params.weeklyEvery = Number($('batch_weeklyEvery').value || 1); params.weekdays = weekdays }
|
||||||
|
if (mode === 'monthlyByDay') { params.monthlyEvery = Number($('batch_monthlyEvery').value || 1); params.monthlyDays = $('batch_monthlyDays').value.trim() }
|
||||||
|
if (mode === 'monthlyByNthWeekday') { params.monthlyEvery2 = Number($('batch_monthlyEvery2').value || 1); params.nthWeek = Number($('batch_nthWeek').value || 3); params.nthWeekday = Number($('batch_nthWeekday').value || 0) }
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
const fillForm = (params) => {
|
||||||
|
if (!params) return
|
||||||
|
const p = Object.assign({
|
||||||
|
mode: 'monthlyByDay', rangeStart: '', rangeEnd: '', allDay: false, completed: false,
|
||||||
|
startTime: '09:00', endTime: '10:00', remindTime: '08:30', title: '', content: ''
|
||||||
|
}, params || {})
|
||||||
|
$('batch_name').value = p.batchName || ''
|
||||||
|
$('batch_mode').value = p.mode
|
||||||
|
$('batch_rangeStart').value = p.rangeStart
|
||||||
|
$('batch_rangeEnd').value = p.rangeEnd
|
||||||
|
$('batch_allDay').checked = !!p.allDay
|
||||||
|
$('batch_completed').checked = !!p.completed
|
||||||
|
$('batch_startTime').value = p.startTime
|
||||||
|
$('batch_endTime').value = p.endTime
|
||||||
|
$('batch_remindTime').value = p.remindTime
|
||||||
|
$('batch_title').value = p.title
|
||||||
|
$('batch_content').value = p.content
|
||||||
|
$('batch_dailyEvery').value = p.dailyEvery || 1
|
||||||
|
$('batch_weeklyEvery').value = p.weeklyEvery || 1
|
||||||
|
if (Array.isArray(p.weekdays)) document.querySelectorAll('.batch-wd').forEach(cb => { cb.checked = p.weekdays.includes(Number(cb.value)) })
|
||||||
|
$('batch_monthlyEvery').value = p.monthlyEvery || 2
|
||||||
|
$('batch_monthlyDays').value = p.monthlyDays || '1-5'
|
||||||
|
$('batch_monthlyEvery2').value = p.monthlyEvery2 || 1
|
||||||
|
$('batch_nthWeek').value = p.nthWeek || 3
|
||||||
|
$('batch_nthWeekday').value = String(p.nthWeekday ?? 0)
|
||||||
|
refreshModeVisibility()
|
||||||
|
$('batch_btnApply').disabled = true
|
||||||
|
$('batch_btnDownload').disabled = true
|
||||||
|
$('batch_previewArea').classList.add('d-none')
|
||||||
|
}
|
||||||
|
const refreshModeVisibility = () => {
|
||||||
|
const mode = $('batch_mode').value
|
||||||
|
document.querySelectorAll('.mode-fieldset').forEach(el => {
|
||||||
|
el.style.display = (el.dataset.mode === mode) ? '' : 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let latestPreview = null
|
||||||
|
const onPreview = async () => {
|
||||||
|
const params = readForm()
|
||||||
|
if (!params.rangeStart || !params.rangeEnd) return toast('请选择时间范围', 'error')
|
||||||
|
if (!params.title) return toast('请填写标题模板', 'error')
|
||||||
|
if (params.mode === 'weekly' && (!params.weekdays || !params.weekdays.length)) return toast('每周模式请至少选一个星期几', 'error')
|
||||||
|
if (params.mode === 'monthlyByDay' && !params.monthlyDays) return toast('请填写每月日期表达式(如 1-5)', 'error')
|
||||||
|
const btn = $('batch_btnPreview')
|
||||||
|
setBtn(btn, true, '预览中…')
|
||||||
|
try {
|
||||||
|
const r = await api('/batch/preview', { method: 'POST', body: params })
|
||||||
|
const data = await r.json()
|
||||||
|
if (!data.ok) throw new Error(data.error || 'preview failed')
|
||||||
|
latestPreview = data
|
||||||
|
$('batch_previewArea').classList.remove('d-none')
|
||||||
|
const stats = `预生成:<b>${data.generated}</b> 条。模式:${data.summary},区间:${data.range_start} ~ ${data.range_end}。batch_id = <code>${data.batchId}</code>`
|
||||||
|
$('batch_previewStats').innerHTML = stats
|
||||||
|
if (data.pre_existing_count) {
|
||||||
|
$('batch_preExisting').textContent = `⚠ 其中 ${data.pre_existing_count} 条 (title, start) 已存在,入库时将命中去重并复用原 id(不会真插入新行)`
|
||||||
|
} else { $('batch_preExisting').textContent = '' }
|
||||||
|
const tb = $('batch_sampleTable').querySelector('tbody')
|
||||||
|
tb.innerHTML = ''
|
||||||
|
;(data.sample_list || []).forEach(s => {
|
||||||
|
const tr = document.createElement('tr')
|
||||||
|
tr.innerHTML = `<td>${s.start || ''}</td><td>${s.end || ''}</td><td>${s.remindAt || ''}</td><td>${s.title || ''}</td>`
|
||||||
|
tb.appendChild(tr)
|
||||||
|
})
|
||||||
|
$('batch_sqlText').value = data.sql_display + (data.truncated ? '\n... 超过 500 行,完整 SQL 请点下载 SQL 文件按钮' : '')
|
||||||
|
$('batch_btnApply').disabled = false
|
||||||
|
$('batch_btnDownload').disabled = false
|
||||||
|
} catch (e) {
|
||||||
|
toast('预览失败: ' + e.message, 'error')
|
||||||
|
} finally { setBtn(btn, false) }
|
||||||
|
}
|
||||||
|
const onApply = async () => {
|
||||||
|
if (!latestPreview) return toast('请先点「预览 SQL」', 'error')
|
||||||
|
const ok = confirm(`确认将 ${latestPreview.generated} 条事件直接写入数据库?\n(可在批次管理里一键回滚逻辑删除)`)
|
||||||
|
if (!ok) return
|
||||||
|
const btn = $('batch_btnApply')
|
||||||
|
setBtn(btn, true, '入库中…')
|
||||||
|
try {
|
||||||
|
const r = await api('/batch/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { batch_id: latestPreview.batchId, params: latestPreview.params_saved, sql_full: latestPreview.sql_full, skip_external_sync: $('batch_skipExternal').checked }
|
||||||
|
})
|
||||||
|
const data = await r.json()
|
||||||
|
if (!data.ok) throw new Error(data.error || 'apply failed')
|
||||||
|
toast(`写入成功:生成${data.generated} 入库${data.inserted} 去重${data.dedup} 预重${data.pre_existing}`, 'success')
|
||||||
|
loadBatches()
|
||||||
|
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-batches'))
|
||||||
|
tabs.show()
|
||||||
|
} catch (e) {
|
||||||
|
toast('入库失败: ' + e.message, 'error')
|
||||||
|
} finally { setBtn(btn, false) }
|
||||||
|
}
|
||||||
|
const onDownload = () => {
|
||||||
|
if (!latestPreview) return
|
||||||
|
const blob = new Blob([latestPreview.sql_full], { type: 'text/plain;charset=utf-8' })
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = URL.createObjectURL(blob)
|
||||||
|
a.download = `calendar_batch_preview_${latestPreview.batchId}.sql`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(a.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- SQL Manual Tab ---------- */
|
||||||
|
let _sqlLatestReport = null
|
||||||
|
let _sqlConfirmDup = false
|
||||||
|
let _sqlConfirmForbidden = false
|
||||||
|
|
||||||
|
const pad2 = (n) => String(n).padStart(2, '0')
|
||||||
|
const tsFile = () => {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}${pad2(d.getMonth()+1)}${pad2(d.getDate())}_${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch]))
|
||||||
|
|
||||||
|
const sqlReadForm = () => ({
|
||||||
|
batch_name: ($('sql_batch_name') || {}).value || '',
|
||||||
|
skip_external_sync: !!($('sql_skipExternal') || {}).checked,
|
||||||
|
mark_completed: !!($('sql_completed') || {}).checked,
|
||||||
|
sql_text: ($('sql_textarea') || {}).value || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const sqlRenderReport = (data) => {
|
||||||
|
_sqlLatestReport = data || null
|
||||||
|
const area = $('sql_checkArea')
|
||||||
|
if (!area) return
|
||||||
|
if (!_sqlLatestReport || !data) { area.classList.add('d-none'); return }
|
||||||
|
area.classList.remove('d-none')
|
||||||
|
const total = data.total_statements || 0
|
||||||
|
const ins = data.insert_statements || 0
|
||||||
|
const rows = data.total_rows || 0
|
||||||
|
const ok = !!data.ok
|
||||||
|
$('sql_checkSummary').textContent = `${ok ? '✅ 通过' : '❌ 未通过'} · ${total} 语句 · ${ins} INSERT · ${rows} 行`
|
||||||
|
$('sql_checkSummary').className = `badge ${ok ? 'bg-success' : 'bg-danger'}`
|
||||||
|
// Meta
|
||||||
|
$('sql_checkMeta').innerHTML = `
|
||||||
|
阈值:重复相似度 ≥ <b>${data.duplicate_threshold || 0.65}</b>,最多 <b>${data.max_rows_limit || 500}</b> 条,执行超时 <b>${Math.round((data.max_ms_limit || 60000)/1000)}s</b>。<br>
|
||||||
|
检测到 <b>${data.pre_existing_count || 0}</b> 条严格匹配(title,start) 预重(入库时会自动去重复用原 id)<br>
|
||||||
|
检测到 <b>${data.high_confidence_duplicates || 0}</b> 条宽松匹配高置信重复(LIKE % + ±10min)。时间范围:<b>${data.range_start || '未知起'}</b> ~ <b>${data.range_end || '未知止'}</b>
|
||||||
|
`
|
||||||
|
// Forbidden / syntax / col errors
|
||||||
|
const renderList = (containerId, headerText, list, color) => {
|
||||||
|
const el = $(containerId)
|
||||||
|
if (!el) return
|
||||||
|
if (!list || !list.length) { el.classList.add('d-none'); return }
|
||||||
|
el.classList.remove('d-none')
|
||||||
|
const items = list.map((e, idx) => `<li><b>#${idx+1}</b> [stmt ${e.stmt_idx ?? '-'}${e.row_idx != null ? `, row ${e.row_idx}` : ''}] ${escapeHtml(e.reason || e.message || '')}${e.preview ? `<br><small class="text-muted">${escapeHtml(e.preview)}</small>` : ''}${e.title ? `<br><small>title=${escapeHtml(e.title)}</small>` : ''}</li>`).join('')
|
||||||
|
el.innerHTML = `<div class="fw-bold mb-1" style="color:${color}">${headerText}</div><ul style="padding-left:1.25rem">${items}</ul>`
|
||||||
|
}
|
||||||
|
renderList('sql_checkForbidden', '❌ 语句类型被拒绝(只允许 INSERT INTO events):', data.forbidden, '#dc3545')
|
||||||
|
renderList('sql_checkSyntaxErrors', '❌ 语法错误(SQLite EXPLAIN 未通过):', data.syntax_errors, '#dc3545')
|
||||||
|
renderList('sql_checkColErrors', '❌ 列 / 必填 / 时间格式错误:', data.col_errors, '#dc3545')
|
||||||
|
// Duplicates
|
||||||
|
const dupEl = $('sql_checkDuplicate')
|
||||||
|
if (dupEl) {
|
||||||
|
if ((data.high_confidence_duplicates || 0) === 0) dupEl.classList.add('d-none')
|
||||||
|
else {
|
||||||
|
dupEl.classList.remove('d-none')
|
||||||
|
dupEl.querySelector('span.fw-bold').textContent = `⚠️ 检测到 ${data.high_confidence_duplicates} 条宽松匹配高置信重复(≥${data.duplicate_threshold||0.65}):`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sample rows
|
||||||
|
const tb = $('sql_checkSampleTbody')
|
||||||
|
if (tb) {
|
||||||
|
tb.innerHTML = ''
|
||||||
|
const meta = (data.row_meta || []).slice(0, 15)
|
||||||
|
if (!meta.length) tb.innerHTML = '<tr><td colspan="5" class="text-muted small text-center">空</td></tr>'
|
||||||
|
else meta.forEach(r => {
|
||||||
|
const tr = document.createElement('tr')
|
||||||
|
tr.innerHTML = `<td>${r.stmt_idx ?? ''}</td><td>${r.row_idx ?? ''}</td><td>${escapeHtml(r.title || '')}</td><td>${escapeHtml(r.start || '')}</td><td>${escapeHtml(r.end || '')}</td>`
|
||||||
|
tb.appendChild(tr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 执行按钮:允许状态
|
||||||
|
const applyBtn = $('sql_btnApply')
|
||||||
|
if (applyBtn) applyBtn.disabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlOpenDuplicateModal = () => {
|
||||||
|
if (!_sqlLatestReport || !_sqlLatestReport.high_confidence_duplicates) return
|
||||||
|
const rows = (_sqlLatestReport.duplicates || []).filter(d => (d.score || 0) >= (_sqlLatestReport.duplicate_threshold || 0.65) && d.match && d.match.length)
|
||||||
|
if (!rows.length) return toast('没有高置信重复明细', 'warn')
|
||||||
|
$('sql_dupSummary').innerHTML = `共 <b>${rows.length}</b> 条你的 SQL 行命中了库内现有事件(宽松模式)。确认要继续执行,这些行也会进入去重逻辑命中 UPDATE(而不是 INSERT 新行)。`
|
||||||
|
const tb = $('sql_dupTbody')
|
||||||
|
tb.innerHTML = ''
|
||||||
|
rows.forEach(d => {
|
||||||
|
const mainMatch = (d.match || [])[0] || {}
|
||||||
|
const tr = document.createElement('tr')
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${d.stmt_idx ?? ''}-${d.row_idx ?? ''}</td>
|
||||||
|
<td>${escapeHtml(d.title_left || '')}</td>
|
||||||
|
<td>${escapeHtml(d.start_left || '')}</td>
|
||||||
|
<td>${escapeHtml(mainMatch.title || '')}</td>
|
||||||
|
<td>${escapeHtml(mainMatch.start || '')}</td>
|
||||||
|
<td>${Number(d.score || 0).toFixed(2)}</td>
|
||||||
|
<td><a href="index.html" target="_blank" class="btn btn-sm btn-outline-primary">打开主日历核对</a></td>`
|
||||||
|
tb.appendChild(tr)
|
||||||
|
})
|
||||||
|
const m = bootstrap.Modal.getOrCreateInstance($('sqlDuplicateModal'))
|
||||||
|
m.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlOnCheck = async () => {
|
||||||
|
const f = sqlReadForm()
|
||||||
|
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
|
||||||
|
const btn = $('sql_btnCheck')
|
||||||
|
setBtn(btn, true, '检查中…')
|
||||||
|
try {
|
||||||
|
const r = await api('/batch_sql_check', { method: 'POST', body: f })
|
||||||
|
const data = await r.json()
|
||||||
|
sqlRenderReport(data)
|
||||||
|
_sqlConfirmDup = (data.high_confidence_duplicates || 0) === 0
|
||||||
|
_sqlConfirmForbidden = !!(data.ok)
|
||||||
|
if (data.ok) toast(`检查通过:${data.insert_statements||0} INSERT / ${data.total_rows||0} 行 / ${data.high_confidence_duplicates||0} 宽松重复`, 'success')
|
||||||
|
else toast(`检查未通过:${(data.forbidden||[]).length + (data.syntax_errors||[]).length + (data.col_errors||[]).length} 处错误`, 'error')
|
||||||
|
} catch (e) {
|
||||||
|
toast('检查失败: ' + e.message, 'error')
|
||||||
|
} finally { setBtn(btn, false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlOnApply = async () => {
|
||||||
|
if (!_sqlLatestReport) return toast('请先点「SQL 语句检查」', 'error')
|
||||||
|
const f = sqlReadForm()
|
||||||
|
if (!f.batch_name.trim()) return toast('请填写批次名称(便于回滚时查找)', 'error')
|
||||||
|
// 先把最新状态再拉一遍(用户可能中间改过 textarea,不允许跳检查直接执行)
|
||||||
|
const checkBtn = $('sql_btnCheck')
|
||||||
|
if (checkBtn && checkBtn.disabled !== true) {
|
||||||
|
// 如果检查按钮不是 loading,强制再跑一次检查,避免用户中间改了 SQL 直接点执行
|
||||||
|
await sqlOnCheck()
|
||||||
|
if (!_sqlLatestReport) return
|
||||||
|
}
|
||||||
|
const hasErr = ((_sqlLatestReport.forbidden||[]).length + (_sqlLatestReport.syntax_errors||[]).length + (_sqlLatestReport.col_errors||[]).length) > 0
|
||||||
|
if (hasErr && !_sqlConfirmForbidden) {
|
||||||
|
const okForb = confirm(`有 ${(_sqlLatestReport.forbidden||[]).length + (_sqlLatestReport.syntax_errors||[]).length + (_sqlLatestReport.col_errors||[]).length} 处语法/列/语句类型被拒绝错误。真的要强制继续执行?(大概率会批量 INSERT 失败;建议先改 SQL 到检查通过)`)
|
||||||
|
if (!okForb) return
|
||||||
|
_sqlConfirmForbidden = true
|
||||||
|
}
|
||||||
|
const dupCount = _sqlLatestReport.high_confidence_duplicates || 0
|
||||||
|
if (dupCount > 0 && !_sqlConfirmDup) {
|
||||||
|
// 先弹重复详情 Modal,用户点 Modal 里的「我已知悉」再真正执行
|
||||||
|
sqlOpenDuplicateModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await sqlReallyApply({ confirm_duplicates: _sqlConfirmDup, confirm_forbidden: _sqlConfirmForbidden })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlReallyApply = async (extra) => {
|
||||||
|
const f = Object.assign(sqlReadForm(), extra || {})
|
||||||
|
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
|
||||||
|
if (!f.batch_name.trim()) return toast('请填写批次名称', 'error')
|
||||||
|
const btn = $('sql_btnApply')
|
||||||
|
setBtn(btn, true, '执行入库中…')
|
||||||
|
try {
|
||||||
|
const r = await api('/batch_sql_apply', { method: 'POST', body: f })
|
||||||
|
const data = await r.json()
|
||||||
|
if (!r.ok || !data.ok) {
|
||||||
|
if (r.status === 409 && data.report) {
|
||||||
|
// 后端强制拦:需要 confirm_forbidden / confirm_duplicates
|
||||||
|
sqlRenderReport(data.report)
|
||||||
|
if (data.error && String(data.error).includes('duplicates_need_confirm')) { sqlOpenDuplicateModal(); return }
|
||||||
|
if (data.error && String(data.error).includes('forbidden_or_error_need_confirm')) {
|
||||||
|
const ok = confirm('后端强制拦截:有错误语句 / 不允许的列。点确定 = 我已知悉,仍尝试执行(可能全部或部分失败)')
|
||||||
|
if (!ok) return
|
||||||
|
_sqlConfirmForbidden = true
|
||||||
|
await sqlReallyApply({ confirm_duplicates: _sqlConfirmDup, confirm_forbidden: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(data.error || `HTTP ${r.status}`)
|
||||||
|
}
|
||||||
|
toast(`SQL 批次执行成功:生成${data.generated} / 入库${data.inserted} / 去重${data.dedup} / 宽松重复${data.high_confidence_duplicates||0}。batch_id=${data.batch_id}`, 'success')
|
||||||
|
_sqlConfirmDup = false
|
||||||
|
_sqlConfirmForbidden = false
|
||||||
|
await loadBatches()
|
||||||
|
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-batches'))
|
||||||
|
tabs.show()
|
||||||
|
} catch (e) {
|
||||||
|
toast('SQL 批次执行失败: ' + e.message, 'error')
|
||||||
|
} finally { setBtn(btn, false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlOnDownload = () => {
|
||||||
|
const f = sqlReadForm()
|
||||||
|
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
|
||||||
|
const head = `-- 下载时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}\n-- 批次名:${f.batch_name || '(未命名)'}\n-- 标记:${f.skip_external_sync ? '不联动下游 ' : ''}${f.mark_completed ? '全部已完成' : ''}\n-- 注意:仅支持 INSERT INTO events(...)\n\n`
|
||||||
|
const blob = new Blob(['\ufeff' + head + f.sql_text], { type: 'text/plain;charset=utf-8' })
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = URL.createObjectURL(blob)
|
||||||
|
a.download = `calendar_events_sql_${tsFile()}.sql`
|
||||||
|
a.click()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 1500)
|
||||||
|
toast('SQL 已下载(UTF-8 BOM,避免中文乱码)', 'info')
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeFor = (st) => {
|
||||||
|
const map = { draft: 'bg-secondary', inserting: 'bg-primary', inserted: 'bg-success', rolled_back: 'bg-dark', partial: 'bg-warning' }
|
||||||
|
return `<span class="badge ${map[st] || 'bg-light text-dark'}">${st}</span>`
|
||||||
|
}
|
||||||
|
let batchListData = []
|
||||||
|
const loadBatches = async () => {
|
||||||
|
if (!$('batch_search')) return
|
||||||
|
const q = encodeURIComponent($('batch_search').value || '')
|
||||||
|
const r = await api(`/batches?q=${q}&limit=200`)
|
||||||
|
const data = (await r.json()).data || []
|
||||||
|
batchListData = data
|
||||||
|
const tb = $('batch_listTable').querySelector('tbody')
|
||||||
|
tb.innerHTML = ''
|
||||||
|
if (!data.length) { tb.innerHTML = '<tr><td colspan="7" class="text-center text-muted small">暂无批次</td></tr>'; return }
|
||||||
|
data.forEach(b => {
|
||||||
|
const tr = document.createElement('tr')
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><strong>${b.title || '(未命名)'}</strong><br><small class="text-muted">${b.id.slice(0, 10)}…</small></td>
|
||||||
|
<td>${b.summary || '-'}<br>${b.skip_external_sync ? '<small class="text-secondary">⏸不联动下游</small>' : ''}</td>
|
||||||
|
<td>${b.range_start || ''}<br>~ ${b.range_end || ''}</td>
|
||||||
|
<td><span class="text-primary">${b.generated}</span> / <span class="text-success">${b.inserted}</span> / <span class="text-secondary">${b.dedup}</span> / <span class="text-danger">${b.rolled_back}</span><br><small class="text-muted">预重${b.pre_existing}</small></td>
|
||||||
|
<td>${badgeFor(b.status)}</td>
|
||||||
|
<td><small>${(b.created_at || '').slice(0, 16).replace('T', ' ')}</small></td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" class="btn btn-outline-info" data-act="view">👁 查看</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary" data-act="copy">📋 复制</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning" data-act="rollback">♻ 回滚</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-act="export">💾 导出</button>
|
||||||
|
</div>
|
||||||
|
</td>`
|
||||||
|
tr.querySelectorAll('[data-act]').forEach(btnel => btnel.addEventListener('click', (ev) => onBatchAction(ev.currentTarget.dataset.act, b.id)))
|
||||||
|
tb.appendChild(tr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const onBatchAction = async (act, id) => {
|
||||||
|
if (act === 'view') return showDetail(id)
|
||||||
|
if (act === 'export') { window.open(`/api/calendar_reminder/batch/export_sql?id=${encodeURIComponent(id)}`, '_blank', 'noopener'); return }
|
||||||
|
if (act === 'copy') {
|
||||||
|
const found = batchListData.find(b => b.id === id)
|
||||||
|
if (!found) return
|
||||||
|
fillForm(Object.assign({}, found.params, { batchName: (found.title || '') + ' (复制)' }))
|
||||||
|
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-generator'))
|
||||||
|
tabs.show()
|
||||||
|
toast('参数已填回生成器,修改后即可预览新批次', 'info')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (act === 'rollback') {
|
||||||
|
const hard = confirm('点"确定"=逻辑删除(推荐,deleted=1);点"取消"=改为物理删除\n(逻辑删除保留行、方便二次确认;物理删不可恢复。)') === false
|
||||||
|
const action = hard ? '物理删除' : '逻辑删除'
|
||||||
|
if (!confirm(`确认对批次 ${id.slice(0, 10)}… 执行 ${action} 回滚?`)) return
|
||||||
|
const r = await api('/batch/rollback', { method: 'POST', body: { id, hard } })
|
||||||
|
const j = await r.json()
|
||||||
|
if (!j.ok) return toast('回滚失败: ' + j.error, 'error')
|
||||||
|
toast(`回滚完成:${j.rolled_back} 条 ${j.hard ? '(硬删)' : '(软删)'}`, 'success')
|
||||||
|
loadBatches()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const showDetail = async (id) => {
|
||||||
|
const r = await api(`/batch/detail?id=${encodeURIComponent(id)}`)
|
||||||
|
const j = await r.json()
|
||||||
|
if (!j.ok) return toast('detail failed: ' + j.error, 'error')
|
||||||
|
const b = j.batch
|
||||||
|
const counts = b.counts || { active: 0, deleted: 0 }
|
||||||
|
const rows = (b.sample || []).map(s => `<tr><td>${s.start || ''}</td><td>${s.end || ''}</td><td>${s.remindAt || ''}</td><td>${s.title || ''}</td><td>${s.completed ? '✅' : ''}${s.deleted ? '🗑' : ''}</td></tr>`).join('')
|
||||||
|
const diags = j.diagnostics || {}
|
||||||
|
const diagBtn = (label, keyName) => `<button class="btn btn-sm btn-outline-secondary m-1" type="button" data-diag-key="${keyName}">${label}(复制)</button>`
|
||||||
|
$('batch_detailBody').innerHTML = `
|
||||||
|
<h6>${b.title || '(未命名)'} <small class="text-muted ms-2">id: ${b.id}</small></h6>
|
||||||
|
<p>模式:${b.meta && b.meta.summary} · 范围 ${b.meta && b.meta.rangeStart} ~ ${b.meta && b.meta.rangeEnd} · 创建 ${(b.created_at || '').slice(0,19).replace('T',' ')}</p>
|
||||||
|
<p>生成:${b.generated_count},入库:${b.inserted_count},去重:${b.dedup_count},预重:${b.pre_existing_count},回滚:${b.rollback_count},状态:${badgeFor(b.status)} ${b.skip_external_sync ? '<span class="badge bg-secondary">不联动下游</span>':''}</p>
|
||||||
|
<p>当前表计数:<span class="text-success">active ${counts.active}</span> / <span class="text-danger">deleted ${counts.deleted}</span></p>
|
||||||
|
<div class="mb-2"><strong>诊断 SQL(一键复制):</strong>
|
||||||
|
<div class="batch-detail-diag">
|
||||||
|
${diagBtn('按 batch_id 清单', 'by_batch_id')}
|
||||||
|
${diagBtn('按 batch_id 计数', 'counts_by_batch')}
|
||||||
|
${diagBtn('孤儿(batch_id 存在但 calendar_batches 中无记录)检查', 'orphans_check')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2"><strong>样例(前 30 条):</strong></div>
|
||||||
|
<div class="table-responsive"><table class="table table-sm table-hover"><thead><tr><th>开始</th><th>结束</th><th>提醒</th><th>标题</th><th>状态</th></tr></thead><tbody>${rows || '<tr><td colspan="5" class="text-muted small text-center">空</td></tr>'}</tbody></table></div>
|
||||||
|
`
|
||||||
|
$('batch_detailBody').querySelectorAll('[data-diag-key]').forEach(bx => bx.addEventListener('click', async () => {
|
||||||
|
const k = bx.getAttribute('data-diag-key') || ''
|
||||||
|
const ok = await copy(diags[k] || '')
|
||||||
|
toast(ok ? '已复制到剪贴板' : '复制失败(可手动选取文本复制)', ok ? 'success' : 'error')
|
||||||
|
}))
|
||||||
|
const m = bootstrap.Modal.getOrCreateInstance($('batchDetailModal'))
|
||||||
|
m.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMainWindow = () => {
|
||||||
|
try { window.open('index.html', '_blank', 'noopener') } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// --- 独立页面初始化:如果是 batch_tool.html ---
|
||||||
|
const mode = $('batch_mode')
|
||||||
|
if (mode) {
|
||||||
|
refreshModeVisibility()
|
||||||
|
mode.addEventListener('change', refreshModeVisibility)
|
||||||
|
}
|
||||||
|
const backBtn = $('btnBackToMain')
|
||||||
|
if (backBtn) backBtn.addEventListener('click', openMainWindow)
|
||||||
|
const topReload = $('btnReload')
|
||||||
|
if (topReload) topReload.addEventListener('click', loadBatches)
|
||||||
|
const pb = $('batch_btnPreview'), ab = $('batch_btnApply'), db = $('batch_btnDownload')
|
||||||
|
if (pb) pb.addEventListener('click', onPreview)
|
||||||
|
if (ab) ab.addEventListener('click', onApply)
|
||||||
|
if (db) db.addEventListener('click', onDownload)
|
||||||
|
const tabBatches = $('tab-batches')
|
||||||
|
if (tabBatches) tabBatches.addEventListener('shown.bs.tab', loadBatches)
|
||||||
|
const rf = $('batch_refresh')
|
||||||
|
if (rf) rf.addEventListener('click', loadBatches)
|
||||||
|
const search = $('batch_search')
|
||||||
|
if (search) {
|
||||||
|
let _t = null
|
||||||
|
search.addEventListener('input', () => { clearTimeout(_t); _t = setTimeout(loadBatches, 280) })
|
||||||
|
}
|
||||||
|
// 独立页面:默认就初始化一次
|
||||||
|
if (mode) {
|
||||||
|
latestPreview = null
|
||||||
|
// 默认时间范围:本月 1 号起 ~ 今年 12 月 31 日止
|
||||||
|
if (!$('batch_rangeStart').value) {
|
||||||
|
const d = new Date()
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
$('batch_rangeStart').value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-01`
|
||||||
|
$('batch_rangeEnd').value = `${d.getFullYear()}-12-31`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SQL Tab 绑定
|
||||||
|
const sqlCheck = $('sql_btnCheck')
|
||||||
|
const sqlApply = $('sql_btnApply')
|
||||||
|
const sqlDownload = $('sql_btnDownload')
|
||||||
|
if (sqlCheck) sqlCheck.addEventListener('click', sqlOnCheck)
|
||||||
|
if (sqlApply) sqlApply.addEventListener('click', sqlOnApply)
|
||||||
|
if (sqlDownload) sqlDownload.addEventListener('click', sqlOnDownload)
|
||||||
|
const dupDetailBtn = $('sql_dupDetail')
|
||||||
|
if (dupDetailBtn) dupDetailBtn.addEventListener('click', sqlOpenDuplicateModal)
|
||||||
|
const dupConfirmBtn = $('sql_dupConfirm')
|
||||||
|
if (dupConfirmBtn) dupConfirmBtn.addEventListener('click', async () => {
|
||||||
|
_sqlConfirmDup = true
|
||||||
|
await sqlReallyApply({ confirm_duplicates: true, confirm_forbidden: _sqlConfirmForbidden })
|
||||||
|
})
|
||||||
|
const dupCancelBtn = $('sql_dupCancel')
|
||||||
|
if (dupCancelBtn) dupCancelBtn.addEventListener('click', () => { toast('已取消,请回去改 SQL 再试', 'warn') })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露给 batch_tool.html 里 onclick 调用(loadBatches)
|
||||||
|
window.CalendarBatchTool = { loadBatches, fillForm, readForm }
|
||||||
|
})()
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
(function () {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var btn = document.getElementById('btnOpenBatchTool');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
window.open(
|
||||||
|
'batch_tool.html',
|
||||||
|
'calendar_batch_' + Date.now(),
|
||||||
|
'width=1280,height=900,resizable=yes,scrollbars=yes'
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// 兜底:普通链接也能打开
|
||||||
|
document.location.href = 'batch_tool.html';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
(function () {
|
||||||
|
const isHttps = location.protocol === 'https:'
|
||||||
|
const API_BASE = isHttps ? location.origin : 'http://localhost:8976'
|
||||||
|
|
||||||
|
function buildUrl(path) {
|
||||||
|
if (/^https?:/.test(path)) return path
|
||||||
|
const p = path.startsWith('/') ? path : '/' + path
|
||||||
|
return API_BASE + p
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(method, path, body, opts) {
|
||||||
|
opts = opts || {}
|
||||||
|
const url = buildUrl(path)
|
||||||
|
const init = {
|
||||||
|
method: method || 'GET',
|
||||||
|
headers: opts.headers || {},
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}
|
||||||
|
if (body !== null && body !== undefined) {
|
||||||
|
if (body instanceof FormData) {
|
||||||
|
init.body = body
|
||||||
|
} else {
|
||||||
|
init.headers['Content-Type'] = 'application/json'
|
||||||
|
init.body = JSON.stringify(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, init)
|
||||||
|
const text = await res.text()
|
||||||
|
let data = null
|
||||||
|
try { data = text ? JSON.parse(text) : null } catch { data = { ok: res.ok, raw: text } }
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = (data && data.error) || ('HTTP ' + res.status)
|
||||||
|
const err = new Error(msg)
|
||||||
|
err.status = res.status
|
||||||
|
err.data = data
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return { ok: true, status: res.status, data }
|
||||||
|
} catch (e) {
|
||||||
|
const err = new Error(e.message || '请求失败')
|
||||||
|
err.data = e.data || null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hub = {
|
||||||
|
async meta() {
|
||||||
|
const res = await request('GET', '/api/mp_time_assistant/meta')
|
||||||
|
const payload = (res && res.data) || {}
|
||||||
|
return (payload && payload.ok) ? payload.data || {} : (payload || {})
|
||||||
|
},
|
||||||
|
async getState() {
|
||||||
|
const res = await request('GET', '/api/mp_time_assistant/state')
|
||||||
|
const payload = (res && res.data) || {}
|
||||||
|
return (payload && payload.ok) ? payload.data || {} : (payload || {})
|
||||||
|
},
|
||||||
|
async setActiveKit(kitId) {
|
||||||
|
const res = await request('PUT', '/api/mp_time_assistant/state/active-kit', { kitId })
|
||||||
|
const payload = (res && res.data) || {}
|
||||||
|
return (payload && payload.ok) ? payload.data || {} : (payload || {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const personalCollect = {
|
||||||
|
async list(keyword) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (keyword) q.set('keyword', keyword)
|
||||||
|
const suffix = q.toString() ? '?' + q.toString() : ''
|
||||||
|
const res = await request('GET', '/api/mp_time_assistant/kits/personal_collect/items' + suffix)
|
||||||
|
const payload = (res && res.data) || {}
|
||||||
|
if (payload && payload.ok) return payload.data || []
|
||||||
|
return Array.isArray(payload) ? payload : []
|
||||||
|
},
|
||||||
|
async get(id) {
|
||||||
|
const res = await request('GET', '/api/mp_time_assistant/kits/personal_collect/items/' + encodeURIComponent(id))
|
||||||
|
const payload = (res && res.data) || {}
|
||||||
|
return (payload && payload.ok) ? (payload.data || null) : (payload || null)
|
||||||
|
},
|
||||||
|
async create(payload) {
|
||||||
|
const res = await request('POST', '/api/mp_time_assistant/kits/personal_collect/items', payload)
|
||||||
|
const body = (res && res.data) || {}
|
||||||
|
if (body && body.ok) return body.data || body
|
||||||
|
throw new Error((body && body.error) || '创建失败')
|
||||||
|
},
|
||||||
|
async update(payload) {
|
||||||
|
const res = await request('PUT', '/api/mp_time_assistant/kits/personal_collect/items', payload)
|
||||||
|
const body = (res && res.data) || {}
|
||||||
|
if (body && body.ok) return body.data || body
|
||||||
|
throw new Error((body && body.error) || '更新失败')
|
||||||
|
},
|
||||||
|
async remove(id) {
|
||||||
|
const q = new URLSearchParams({ id })
|
||||||
|
const res = await request('DELETE', '/api/mp_time_assistant/kits/personal_collect/items?' + q.toString())
|
||||||
|
const body = (res && res.data) || {}
|
||||||
|
if (body && body.ok) return body
|
||||||
|
throw new Error((body && body.error) || '删除失败')
|
||||||
|
},
|
||||||
|
async uploadFile(file, groupId, onProgress) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
if (groupId) form.append('groupId', groupId)
|
||||||
|
const url = buildUrl('/api/mp_time_assistant/kits/personal_collect/upload')
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
xhr.open('POST', url)
|
||||||
|
xhr.withCredentials = true
|
||||||
|
if (onProgress && typeof onProgress === 'function' && xhr.upload) {
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
onProgress({ loaded: e.loaded, total: e.total, percent: Math.round(e.loaded * 100 / Math.max(1, e.total)) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
xhr.onload = () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText || '{}')
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) {
|
||||||
|
resolve({ ok: true, status: xhr.status, data })
|
||||||
|
} else {
|
||||||
|
const err = new Error((data && data.error) || ('HTTP ' + xhr.status))
|
||||||
|
err.data = data
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('响应解析失败: ' + e.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.onerror = () => reject(new Error('网络错误:上传失败'))
|
||||||
|
xhr.ontimeout = () => reject(new Error('上传超时'))
|
||||||
|
xhr.send(form)
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileUrl = (relPath) => {
|
||||||
|
if (!relPath) return ''
|
||||||
|
if (/^https?:/.test(relPath)) return relPath
|
||||||
|
const p = relPath.startsWith('/') ? relPath : '/' + relPath
|
||||||
|
return buildUrl(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__MPTA_API__ = {
|
||||||
|
API_BASE,
|
||||||
|
request,
|
||||||
|
hub,
|
||||||
|
personalCollect,
|
||||||
|
fileUrl
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"_comment": "MilesYang时光助手 Web 端(与小程序共享后端和数据库)的前端鉴权配置",
|
||||||
|
"enable_auth_comment": "true:启用 tool 外层鉴权(默认从九宫格导航进入);false:完全放开访问",
|
||||||
|
"enable_auth": true,
|
||||||
|
"max_age_days_comment": "鉴权 cookie 最大有效天数(1~365)",
|
||||||
|
"max_age_days": 30,
|
||||||
|
"cookieName_comment": "鉴权 Cookie 名称,默认格式 <tool>_gate",
|
||||||
|
"cookieName": "web_mp_time_assistant_gate",
|
||||||
|
"requireGuid_comment": "true:强制 GUID 参数才能访问;false:允许仅凭 token 或已有会话访问",
|
||||||
|
"requireGuid": false,
|
||||||
|
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
|
||||||
|
"guidParam": "guid",
|
||||||
|
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换(留空表示不启用 GUID)",
|
||||||
|
"guidValue": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
background: radial-gradient(1200px 800px at 10% 0%, #1a1a33 0%, #0f0f1a 60%, #0b0b15 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #e8e8f0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-root {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
padding: 24px 32px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.15);
|
||||||
|
border-radius: 24px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-left .app-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
background: linear-gradient(135deg, #6c7ae0 0%, #b06be0 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-left .app-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #8a8aa8;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-right .status-tag {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: rgba(108, 122, 224, 0.12);
|
||||||
|
color: #a8b4ff;
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-right .status-tag.ok {
|
||||||
|
background: rgba(0, 216, 167, 0.1);
|
||||||
|
color: #00d8a7;
|
||||||
|
border-color: rgba(0, 216, 167, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-right .status-tag.err {
|
||||||
|
background: rgba(255, 80, 100, 0.1);
|
||||||
|
color: #ff8595;
|
||||||
|
border-color: rgba(255, 80, 100, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
color: #8a8aa8;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-card {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
border-color 0.3s ease,
|
||||||
|
background 0.3s ease,
|
||||||
|
box-shadow 0.35s ease;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(108, 122, 224, 0) 0%, rgba(176, 107, 224, 0) 100%);
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-card:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
border-color: rgba(108, 122, 224, 0.35);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
box-shadow: 0 20px 40px rgba(108, 122, 224, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
background: linear-gradient(135deg, rgba(108, 122, 224, 0.08) 0%, rgba(176, 107, 224, 0.04) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
background: linear-gradient(135deg, rgba(108, 122, 224, 0.22) 0%, rgba(176, 107, 224, 0.18) 100%);
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.3);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-name {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f0f0f8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-desc {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #9b9bb5;
|
||||||
|
line-height: 1.7;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-arrow {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #a8b4ff;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0.75;
|
||||||
|
transition: gap 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-card:hover .kit-arrow {
|
||||||
|
gap: 12px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.app-root { padding: 20px 16px 60px; }
|
||||||
|
.app-header {
|
||||||
|
padding: 18px 20px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.app-header-left .app-title { font-size: 24px; }
|
||||||
|
.kit-grid { grid-template-columns: 1fr; gap: 16px; }
|
||||||
|
.kit-card { padding: 22px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MilesYang时光助手</title>
|
||||||
|
<link rel="stylesheet" href="/common/common.css">
|
||||||
|
<link rel="stylesheet" href="hub.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-root">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="app-header-left">
|
||||||
|
<h1 class="app-title">MilesYang时光助手</h1>
|
||||||
|
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合</p>
|
||||||
|
</div>
|
||||||
|
<div class="app-header-right">
|
||||||
|
<span id="status-tag" class="status-tag">加载中...</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<div id="kit-grid" class="kit-grid">
|
||||||
|
<div class="kit-empty">⏳ 工具列表加载中...</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="api.js"></script>
|
||||||
|
<script src="hub.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
(function () {
|
||||||
|
const api = window.__MPTA_API__
|
||||||
|
const $grid = document.getElementById('kit-grid')
|
||||||
|
const $status = document.getElementById('status-tag')
|
||||||
|
|
||||||
|
const KIT_PAGES = {
|
||||||
|
personal_collect: 'kits/personal_collect/list.html'
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIT_ICONS = {
|
||||||
|
personal_collect: '📁'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, cls) {
|
||||||
|
$status.textContent = text
|
||||||
|
$status.className = 'status-tag' + (cls ? ' ' + cls : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKits(kits) {
|
||||||
|
if (!kits || !kits.length) {
|
||||||
|
$grid.innerHTML = '<div class="kit-empty">暂无可用工具</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$grid.innerHTML = kits.map((kit) => {
|
||||||
|
const page = KIT_PAGES[kit.id] || '#'
|
||||||
|
const icon = KIT_ICONS[kit.id] || '🧰'
|
||||||
|
return (
|
||||||
|
`<div class="kit-card" data-kit="${kit.id}" data-page="${page}">` +
|
||||||
|
`<div class="kit-icon">${icon}</div>` +
|
||||||
|
`<div class="kit-name">${escapeHtml(kit.name || kit.id)}</div>` +
|
||||||
|
`<div class="kit-desc">${escapeHtml(kit.description || '')}</div>` +
|
||||||
|
`<div class="kit-arrow"><span>进入</span><span>→</span></div>` +
|
||||||
|
`</div>`
|
||||||
|
)
|
||||||
|
}).join('')
|
||||||
|
Array.from($grid.querySelectorAll('.kit-card')).forEach((card) => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const page = card.getAttribute('data-page')
|
||||||
|
const kitId = card.getAttribute('data-kit')
|
||||||
|
const go = () => { window.location.href = page }
|
||||||
|
if (api && api.hub && api.hub.setActiveKit) {
|
||||||
|
api.hub.setActiveKit(kitId).then(go).catch(go)
|
||||||
|
} else {
|
||||||
|
go()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s === null || s === undefined) return ''
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.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 = `<div class="kit-empty">❌ 加载失败:${escapeHtml(e.message || '后端无响应')}</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', main)
|
||||||
|
})()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MilesYang时光助手</title>
|
||||||
|
<link rel="stylesheet" href="/common/common.css">
|
||||||
|
<link rel="stylesheet" href="hub.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-root">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="app-header-left">
|
||||||
|
<h1 class="app-title">MilesYang时光助手</h1>
|
||||||
|
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合</p>
|
||||||
|
</div>
|
||||||
|
<div class="app-header-right">
|
||||||
|
<span id="status-tag" class="status-tag">加载中...</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<div id="kit-grid" class="kit-grid">
|
||||||
|
<div class="kit-empty">⏳ 工具列表加载中...</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="api.js"></script>
|
||||||
|
<script src="hub.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
(function () {
|
||||||
|
const DEFAULT_UI_MODE = 'debug'
|
||||||
|
const AUTO_CLOSE_SEC_DEBUG = 3
|
||||||
|
const AUTO_CLOSE_SEC_NORMAL = 2
|
||||||
|
|
||||||
|
function readUiMode() {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem('mp_ta_ui_mode')
|
||||||
|
if (v === 'debug' || v === 'normal') return v
|
||||||
|
} catch {}
|
||||||
|
return DEFAULT_UI_MODE
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUiMode(mode) {
|
||||||
|
try { localStorage.setItem('mp_ta_ui_mode', mode) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s === null || s === undefined) return ''
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&').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 =
|
||||||
|
'<div class="modal-card">' +
|
||||||
|
'<div class="modal-title' + (danger ? ' danger' : '') + '">' + escapeHtml(title) + '</div>' +
|
||||||
|
'<div class="modal-desc">' + escapeHtml(desc) + '</div>' +
|
||||||
|
'<div class="modal-actions">' +
|
||||||
|
'<button type="button" class="btn secondary btn-sm" data-act="cancel">' + escapeHtml(cancelText) + '</button>' +
|
||||||
|
'<button type="button" class="btn ' + (danger ? 'danger' : 'primary') + ' btn-sm" data-act="ok">' + escapeHtml(okText) + '</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
slot.appendChild(mask)
|
||||||
|
const close = (result) => {
|
||||||
|
mask.remove()
|
||||||
|
if (result && typeof onOk === 'function') onOk()
|
||||||
|
if (!result && typeof onCancel === 'function') onCancel()
|
||||||
|
}
|
||||||
|
mask.addEventListener('click', (e) => {
|
||||||
|
if (e.target === mask) close(false)
|
||||||
|
const act = e.target.getAttribute && e.target.getAttribute('data-act')
|
||||||
|
if (act === 'ok') close(true)
|
||||||
|
if (act === 'cancel') close(false)
|
||||||
|
})
|
||||||
|
const okBtn = mask.querySelector('[data-act="ok"]')
|
||||||
|
if (okBtn) okBtn.focus()
|
||||||
|
return { close }
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTrackedInteraction(options, runner) {
|
||||||
|
const uiMode = readUiMode()
|
||||||
|
options = options || {}
|
||||||
|
const name = options.name || '操作'
|
||||||
|
const toastOnSuccess = options.toastOnSuccess !== false
|
||||||
|
const normalViewAsToast = options.normalViewAsToast !== false
|
||||||
|
const debugMode = uiMode === 'debug'
|
||||||
|
const dlg = document.getElementById('interaction-dialog') || (() => {
|
||||||
|
const d = document.createElement('div')
|
||||||
|
d.id = 'interaction-dialog'
|
||||||
|
d.className = 'interaction-dialog hidden'
|
||||||
|
document.body.appendChild(d)
|
||||||
|
return d
|
||||||
|
})()
|
||||||
|
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const logs = []
|
||||||
|
let timer = null
|
||||||
|
let forceStay = false
|
||||||
|
|
||||||
|
const fmtTime = (ts) => {
|
||||||
|
const d = new Date(ts)
|
||||||
|
const p = (n) => (n < 10 ? '0' + n : '' + n)
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const push = (line) => logs.push(`[${fmtTime(Date.now())}] ${line}`)
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1)
|
||||||
|
dlg.innerHTML =
|
||||||
|
'<div class="id-title">' +
|
||||||
|
'<span>' + escapeHtml(name) + ' · 交互详情</span>' +
|
||||||
|
'<span style="cursor:pointer;color:#8a8aa8;" data-act="copy">复制</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="id-body">' + escapeHtml(logs.join('\n') + (timer ? `\n\n倒计时关闭:${remainSec}s` : '') + `\n\n耗时:${elapsedSec}s`) + '</div>' +
|
||||||
|
'<div class="id-actions">' +
|
||||||
|
'<span class="id-countdown">' + (timer ? `自动关闭中:${remainSec}s` : (forceStay ? '已停留,有错误/异常' : '已完成')) + '</span>' +
|
||||||
|
'<div style="display:flex;gap:8px;">' +
|
||||||
|
(timer ? '<button type="button" class="btn ghost btn-sm" data-act="stay">停留</button>' : '') +
|
||||||
|
'<button type="button" class="btn secondary btn-sm" data-act="close">关闭</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
const btnCopy = dlg.querySelector('[data-act="copy"]')
|
||||||
|
if (btnCopy) btnCopy.onclick = () => {
|
||||||
|
try {
|
||||||
|
const txt = logs.join('\n') + `\n\n耗时:${elapsedSec}s`
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(txt).then(() => showToast('日志已复制', 'success', 1200))
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove()
|
||||||
|
showToast('日志已复制', 'success', 1200)
|
||||||
|
}
|
||||||
|
} catch (e) { showToast('复制失败:' + e.message, 'error', 2000) }
|
||||||
|
}
|
||||||
|
const btnStay = dlg.querySelector('[data-act="stay"]')
|
||||||
|
if (btnStay) btnStay.onclick = () => { if (timer) { clearInterval(timer); timer = null; forceStay = true; render() } }
|
||||||
|
const btnClose = dlg.querySelector('[data-act="close"]')
|
||||||
|
if (btnClose) btnClose.onclick = () => { dlg.classList.add('hidden'); if (timer) clearInterval(timer); timer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDlg = () => { dlg.classList.remove('hidden'); render() }
|
||||||
|
const closeDlg = () => { dlg.classList.add('hidden'); if (timer) { clearInterval(timer); timer = null } }
|
||||||
|
|
||||||
|
let remainSec = debugMode ? AUTO_CLOSE_SEC_DEBUG : AUTO_CLOSE_SEC_NORMAL
|
||||||
|
|
||||||
|
const startCountdown = () => {
|
||||||
|
if (forceStay) return
|
||||||
|
timer = setInterval(() => {
|
||||||
|
remainSec -= 1
|
||||||
|
if (remainSec <= 0) { closeDlg() }
|
||||||
|
else { render() }
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
push(`开始:${name}`)
|
||||||
|
push(`HTTP: ${options.method || 'GET'} ${options.path || options.url || '(本地)'}${options.params ? `\n参数:${JSON.stringify(options.params)}` : ''}`)
|
||||||
|
showDlg()
|
||||||
|
|
||||||
|
const runnerPromise = typeof runner === 'function' ? runner() : Promise.resolve(runner || { ok: true })
|
||||||
|
return Promise.resolve(runnerPromise).then((res) => {
|
||||||
|
push(`返回:${JSON.stringify(res, null, 2).slice(0, 2000)}`)
|
||||||
|
push(`状态:成功 ✔ 耗时 ${((Date.now()-startedAt)/1000).toFixed(2)}s`)
|
||||||
|
render()
|
||||||
|
if (debugMode) { startCountdown() }
|
||||||
|
else if (normalViewAsToast && toastOnSuccess) { setTimeout(() => closeDlg(), 120); showToast(`${name}成功`, 'success') }
|
||||||
|
else { startCountdown() }
|
||||||
|
return res
|
||||||
|
}).catch((e) => {
|
||||||
|
forceStay = true
|
||||||
|
push(`错误:${e && e.message ? e.message : String(e)}`)
|
||||||
|
push(`状态:失败 ✘ 耗时 ${((Date.now()-startedAt)/1000).toFixed(2)}s`)
|
||||||
|
render()
|
||||||
|
showToast(`${name}失败:${(e && e.message) ? e.message : '未知错误'}`, 'error', 3500)
|
||||||
|
throw e
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (isNaN(d.getTime())) return String(iso)
|
||||||
|
const p = (n) => (n < 10 ? '0' + n : '' + n)
|
||||||
|
const now = new Date()
|
||||||
|
const sameYear = d.getFullYear() === now.getFullYear()
|
||||||
|
const y = sameYear ? '' : d.getFullYear() + '-'
|
||||||
|
return `${y}${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanSize(bytes) {
|
||||||
|
const n = Number(bytes || 0)
|
||||||
|
if (n < 1024) return n + ' B'
|
||||||
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
|
||||||
|
return (n / 1024 / 1024).toFixed(2) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__MPTA_DIALOG__ = {
|
||||||
|
readUiMode, setUiMode,
|
||||||
|
escapeHtml, showToast, confirmDialog, runTrackedInteraction,
|
||||||
|
formatTime, humanSize,
|
||||||
|
AUTO_CLOSE_SEC_DEBUG, AUTO_CLOSE_SEC_NORMAL
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
.detail-section {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.detail-section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8a8aa8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #f4f4fa;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.detail-title.empty {
|
||||||
|
color: #6a6a88;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8a8aa8;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.detail-meta span b {
|
||||||
|
color: #c8c8e0;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-remark {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
color: #dcdceb;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.9;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.detail-remark.empty {
|
||||||
|
color: #6a6a88;
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.image-item {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: zoom-in;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
transition: transform 0.25s ease, border-color 0.25s ease;
|
||||||
|
}
|
||||||
|
.image-item:hover { transform: translateY(-2px); border-color: rgba(108, 122, 224, 0.4); }
|
||||||
|
.image-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.image-item .name-badge {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px; bottom: 8px;
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: rgba(108, 122, 224, 0.06);
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.16);
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.file-item:hover {
|
||||||
|
background: rgba(108, 122, 224, 0.12);
|
||||||
|
border-color: rgba(108, 122, 224, 0.32);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.file-icon {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(108, 122, 224, 0.18);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.file-info { flex: 1; min-width: 0; }
|
||||||
|
.file-name {
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e8e8f4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.file-meta { font-size: 12px; color: #8a8aa8; margin-top: 4px; }
|
||||||
|
.file-download {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(108, 122, 224, 0.2);
|
||||||
|
color: #a8b4ff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.file-download:hover { background: rgba(108, 122, 224, 0.3); }
|
||||||
|
|
||||||
|
.lightbox-mask {
|
||||||
|
position: fixed; inset: 0; z-index: 99999;
|
||||||
|
background: rgba(0, 0, 0, 0.88);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.lightbox-mask.hidden { display: none; }
|
||||||
|
.lightbox-inner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.lightbox-img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 30px 100px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
.lightbox-close, .lightbox-nav {
|
||||||
|
position: absolute;
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border: 1px solid rgba(255,255,255,0.16);
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
transition: background 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.lightbox-close:hover, .lightbox-nav:hover { background: rgba(255,255,255,0.18); transform: scale(1.06); }
|
||||||
|
.lightbox-close { top: 16px; right: 16px; }
|
||||||
|
.lightbox-nav { top: 50%; transform: translateY(-50%); }
|
||||||
|
.lightbox-nav:hover { transform: translateY(-50%) scale(1.06); }
|
||||||
|
.lightbox-nav.prev { left: 16px; }
|
||||||
|
.lightbox-nav.next { right: 16px; }
|
||||||
|
.lightbox-caption {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: 80vw;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0,0,0,0.65);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.detail-title { font-size: 20px; }
|
||||||
|
.detail-remark { padding: 18px; font-size: 14.5px; }
|
||||||
|
.image-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||||
|
.lightbox-close, .lightbox-nav { width: 40px; height: 40px; font-size: 18px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>收藏详情 | MilesYang时光助手</title>
|
||||||
|
<link rel="stylesheet" href="/common/common.css">
|
||||||
|
<link rel="stylesheet" href="../../shared.css">
|
||||||
|
<link rel="stylesheet" href="detail.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="app-topbar">
|
||||||
|
<div class="app-topbar-left">
|
||||||
|
<button class="back-btn" id="btn-back" title="返回列表">←</button>
|
||||||
|
<div>
|
||||||
|
<div class="page-title" id="page-title">收藏详情</div>
|
||||||
|
<div class="page-subtitle" id="page-subtitle">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-topbar-right">
|
||||||
|
<button class="btn secondary btn-sm" id="btn-refresh">🔄 刷新</button>
|
||||||
|
<button class="btn primary btn-sm" id="btn-edit">编辑</button>
|
||||||
|
<button class="btn danger btn-sm" id="btn-delete">删除</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="detail-wrap">
|
||||||
|
<div class="empty-state"><div class="empty-state-icon">⏳</div><div class="empty-state-text">加载中...</div></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="lightbox-mask" class="lightbox-mask hidden">
|
||||||
|
<div class="lightbox-inner">
|
||||||
|
<button class="lightbox-close" id="lightbox-close">✕</button>
|
||||||
|
<button class="lightbox-nav prev" id="lightbox-prev">‹</button>
|
||||||
|
<img id="lightbox-img" class="lightbox-img" alt="">
|
||||||
|
<button class="lightbox-nav next" id="lightbox-next">›</button>
|
||||||
|
<div class="lightbox-caption" id="lightbox-caption"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-slot"></div>
|
||||||
|
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
||||||
|
|
||||||
|
<script src="../../api.js"></script>
|
||||||
|
<script src="../../kits/common/dialog.js"></script>
|
||||||
|
<script src="detail.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
(function () {
|
||||||
|
const api = window.__MPTA_API__
|
||||||
|
const dlg = window.__MPTA_DIALOG__
|
||||||
|
|
||||||
|
const $wrap = document.getElementById('detail-wrap')
|
||||||
|
const $title = document.getElementById('page-title')
|
||||||
|
const $subtitle = document.getElementById('page-subtitle')
|
||||||
|
const $back = document.getElementById('btn-back')
|
||||||
|
const $refresh = document.getElementById('btn-refresh')
|
||||||
|
const $edit = document.getElementById('btn-edit')
|
||||||
|
const $delete = document.getElementById('btn-delete')
|
||||||
|
|
||||||
|
const $lbMask = document.getElementById('lightbox-mask')
|
||||||
|
const $lbImg = document.getElementById('lightbox-img')
|
||||||
|
const $lbCap = document.getElementById('lightbox-caption')
|
||||||
|
const $lbClose = document.getElementById('lightbox-close')
|
||||||
|
const $lbPrev = document.getElementById('lightbox-prev')
|
||||||
|
const $lbNext = document.getElementById('lightbox-next')
|
||||||
|
|
||||||
|
const escapeHtml = dlg.escapeHtml
|
||||||
|
const formatTime = dlg.formatTime
|
||||||
|
const humanSize = dlg.humanSize
|
||||||
|
|
||||||
|
const q = new URLSearchParams(location.search)
|
||||||
|
const id = q.get('id')
|
||||||
|
let current = null
|
||||||
|
let imageList = []
|
||||||
|
let lbIdx = -1
|
||||||
|
|
||||||
|
function fileIcon(fileName) {
|
||||||
|
const ext = (fileName || '').split('.').pop().toLowerCase()
|
||||||
|
if (/png|jpe?g|gif|webp|bmp|svg|ico/.test(ext)) return '🖼️'
|
||||||
|
if (/pdf/.test(ext)) return '📕'
|
||||||
|
if (/docx?|docm|dotx?/.test(ext)) return '📘'
|
||||||
|
if (/xlsx?|xlsm|xls/.test(ext)) return '📗'
|
||||||
|
if (/pptx?|ppsm/.test(ext)) return '📙'
|
||||||
|
if (/txt|md|log|csv/.test(ext)) return '📝'
|
||||||
|
if (/zip|rar|7z|tar|gz/.test(ext)) return '🗜️'
|
||||||
|
if (/mp3|wav|flac|m4a|aac/.test(ext)) return '🎵'
|
||||||
|
if (/mp4|mov|avi|mkv|webm/.test(ext)) return '🎥'
|
||||||
|
return '📄'
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() { window.location.href = 'list.html' }
|
||||||
|
function goEdit() { if (id) window.location.href = 'edit.html?id=' + encodeURIComponent(id) }
|
||||||
|
|
||||||
|
function openLightbox(idx) {
|
||||||
|
if (!imageList.length) return
|
||||||
|
lbIdx = Math.max(0, Math.min(imageList.length - 1, idx | 0))
|
||||||
|
const item = imageList[lbIdx]
|
||||||
|
$lbImg.src = item.src
|
||||||
|
$lbCap.textContent = item.name || ''
|
||||||
|
$lbMask.classList.remove('hidden')
|
||||||
|
}
|
||||||
|
function closeLightbox() { $lbMask.classList.add('hidden'); try { $lbImg.src = '' } catch {} lbIdx = -1 }
|
||||||
|
function navLightbox(delta) {
|
||||||
|
if (!imageList.length) return
|
||||||
|
lbIdx = (lbIdx + delta + imageList.length) % imageList.length
|
||||||
|
const item = imageList[lbIdx]
|
||||||
|
$lbImg.src = item.src
|
||||||
|
$lbCap.textContent = item.name || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
$lbClose.addEventListener('click', closeLightbox)
|
||||||
|
$lbPrev.addEventListener('click', () => navLightbox(-1))
|
||||||
|
$lbNext.addEventListener('click', () => navLightbox(1))
|
||||||
|
$lbMask.addEventListener('click', (e) => { if (e.target === $lbMask) closeLightbox() })
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ($lbMask.classList.contains('hidden')) return
|
||||||
|
if (e.key === 'Escape') closeLightbox()
|
||||||
|
else if (e.key === 'ArrowLeft') navLightbox(-1)
|
||||||
|
else if (e.key === 'ArrowRight') navLightbox(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
function doDelete() {
|
||||||
|
if (!id || !current) return
|
||||||
|
const title = (current.title || '').trim() || '这条无标题收藏'
|
||||||
|
dlg.confirmDialog({
|
||||||
|
title: '确认删除?',
|
||||||
|
desc: `「${title}」\n\n删除后此条收藏将不再显示(软删除,后端保留记录可追溯),同时手机端也会消失。`,
|
||||||
|
okText: '确认删除', cancelText: '取消', danger: true
|
||||||
|
}, () => {
|
||||||
|
dlg.runTrackedInteraction({
|
||||||
|
name: '删除收藏',
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/mp_time_assistant/kits/personal_collect/items',
|
||||||
|
params: { id }
|
||||||
|
}, () => api.personalCollect.remove(id)).then(() => {
|
||||||
|
dlg.showToast('删除成功', 'success', 1400)
|
||||||
|
setTimeout(goBack, 600)
|
||||||
|
}).catch(() => {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImageSection(images) {
|
||||||
|
if (!images || !images.length) return ''
|
||||||
|
imageList = images.map(a => {
|
||||||
|
const filePath = a.filePath || a.file_path
|
||||||
|
return {
|
||||||
|
src: a.url || api.fileUrl(filePath),
|
||||||
|
name: a.fileName || a.file_name || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
`<section class="detail-section">` +
|
||||||
|
`<div class="detail-section-title">图片</div>` +
|
||||||
|
`<div class="image-grid">` +
|
||||||
|
images.map((a, i) => {
|
||||||
|
const filePath = a.filePath || a.file_path
|
||||||
|
const src = a.url || api.fileUrl(filePath)
|
||||||
|
const name = a.fileName || a.file_name || ('image_' + (i+1))
|
||||||
|
const badge = (name && name.length > 4)
|
||||||
|
? `<div class="name-badge">${escapeHtml(name)}</div>` : ''
|
||||||
|
return `<div class="image-item" data-img-idx="${i}" title="${escapeHtml(name)}"><img src="${src}" alt="${escapeHtml(name)}" loading="lazy">${badge}</div>`
|
||||||
|
}).join('') +
|
||||||
|
`</div>` +
|
||||||
|
`</section>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileSection(files) {
|
||||||
|
if (!files || !files.length) return ''
|
||||||
|
return (
|
||||||
|
`<section class="detail-section">` +
|
||||||
|
`<div class="detail-section-title">文件 (${files.length})</div>` +
|
||||||
|
`<div class="file-list">` +
|
||||||
|
files.map(a => {
|
||||||
|
const filePath = a.filePath || a.file_path
|
||||||
|
const src = a.url || api.fileUrl(filePath)
|
||||||
|
const name = a.fileName || a.file_name || '未命名文件'
|
||||||
|
const size = Number(a.fileSize || a.file_size || 0)
|
||||||
|
const mimeType = a.mimeType || a.mime_type || ''
|
||||||
|
return (
|
||||||
|
`<div class="file-item" data-file="${escapeHtml(src)}" data-name="${escapeHtml(name)}">` +
|
||||||
|
`<div class="file-icon">${fileIcon(name)}</div>` +
|
||||||
|
`<div class="file-info">` +
|
||||||
|
`<div class="file-name">${escapeHtml(name)}</div>` +
|
||||||
|
`<div class="file-meta">${humanSize(size)}${mimeType ? ' · ' + escapeHtml(mimeType) : ''}</div>` +
|
||||||
|
`</div>` +
|
||||||
|
`<button type="button" class="file-download">下载</button>` +
|
||||||
|
`</div>`
|
||||||
|
)
|
||||||
|
}).join('') +
|
||||||
|
`</div>` +
|
||||||
|
`</section>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(item) {
|
||||||
|
current = item
|
||||||
|
const dispTitle = (item.title || '').trim()
|
||||||
|
$title.textContent = dispTitle ? dispTitle : '(无标题收藏)'
|
||||||
|
const updatedAt = item.updatedAt || item.updated_at || item.createdAt || item.created_at
|
||||||
|
const createdAt = item.createdAt || item.created_at
|
||||||
|
$subtitle.textContent = `ID ${(item.id || '').slice(0, 8)}... · 更新于 ${formatTime(updatedAt)}`
|
||||||
|
|
||||||
|
const images = (item.attachments || []).filter(a => (typeof a.isImage !== 'undefined') ? !!a.isImage : !!a.is_image)
|
||||||
|
const files = (item.attachments || []).filter(a => (typeof a.isImage !== 'undefined') ? !a.isImage : !a.is_image)
|
||||||
|
const tags = (item.tags && item.tags.length)
|
||||||
|
? `<div class="detail-tags">${item.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const remarkBlock = (item.remark || '').trim()
|
||||||
|
? `<section class="detail-section"><div class="detail-section-title">备注</div><div class="detail-remark">${escapeHtml(item.remark)}</div></section>`
|
||||||
|
: `<section class="detail-section"><div class="detail-section-title">备注</div><div class="detail-remark empty">(无备注)</div></section>`
|
||||||
|
|
||||||
|
const titleBlock = dispTitle
|
||||||
|
? `<section class="detail-section"><div class="detail-title">${escapeHtml(dispTitle)}</div></section>`
|
||||||
|
: `<section class="detail-section"><div class="detail-title empty">(该条收藏未设置标题)</div></section>`
|
||||||
|
|
||||||
|
const metaBlock =
|
||||||
|
`<section class="detail-section">` +
|
||||||
|
`<div class="detail-meta">` +
|
||||||
|
`<span><b>创建时间</b>${formatTime(createdAt)}</span>` +
|
||||||
|
`<span><b>更新时间</b>${formatTime(updatedAt)}</span>` +
|
||||||
|
(item.attachments ? `<span><b>附件总数</b>${item.attachments.length}</span>` : '') +
|
||||||
|
`</div>` +
|
||||||
|
(tags ? `<div style="margin-top:14px;">${tags}</div>` : '') +
|
||||||
|
`</section>`
|
||||||
|
|
||||||
|
$wrap.innerHTML = titleBlock + metaBlock + remarkBlock + renderImageSection(images) + renderFileSection(files)
|
||||||
|
|
||||||
|
Array.from($wrap.querySelectorAll('[data-img-idx]')).forEach((el) => {
|
||||||
|
el.addEventListener('click', () => openLightbox(Number(el.getAttribute('data-img-idx')) | 0))
|
||||||
|
})
|
||||||
|
Array.from($wrap.querySelectorAll('.file-item')).forEach((el) => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
if (e.target && e.target.classList.contains('file-download')) return
|
||||||
|
const src = el.getAttribute('data-file')
|
||||||
|
const name = el.getAttribute('data-name') || ''
|
||||||
|
if (!src) return
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = src
|
||||||
|
if (name) a.setAttribute('download', name)
|
||||||
|
a.target = '_blank'
|
||||||
|
a.rel = 'noopener'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
setTimeout(() => a.remove(), 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
Array.from($wrap.querySelectorAll('.file-download')).forEach((btn) => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const item = btn.closest && btn.closest('.file-item')
|
||||||
|
if (!item) return
|
||||||
|
const src = item.getAttribute('data-file')
|
||||||
|
const name = item.getAttribute('data-name') || ''
|
||||||
|
if (!src) return
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = src
|
||||||
|
if (name) a.setAttribute('download', name)
|
||||||
|
a.target = '_blank'
|
||||||
|
a.rel = 'noopener'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
setTimeout(() => a.remove(), 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!id) {
|
||||||
|
$wrap.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠️</div><div class="empty-state-text">参数错误:缺少 id</div></div>`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await dlg.runTrackedInteraction({
|
||||||
|
name: '加载收藏详情',
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/mp_time_assistant/kits/personal_collect/items/' + encodeURIComponent(id),
|
||||||
|
params: { id },
|
||||||
|
normalViewAsToast: false
|
||||||
|
}, () => api.personalCollect.get(id))
|
||||||
|
const item = data ? (((data && data.data) || (data && data.item)) ? (data.data || data.item) : data) : null
|
||||||
|
const itemInner = item ? ((item && item.item) || item) : null
|
||||||
|
if (!itemInner) {
|
||||||
|
$wrap.innerHTML = `<div class="empty-state"><div class="empty-state-icon">😶</div><div class="empty-state-text">未找到该条收藏</div><div class="empty-state-hint">可能已被删除,或 ID 不正确</div></div>`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
render(itemInner)
|
||||||
|
} catch (e) {
|
||||||
|
$wrap.innerHTML = `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">加载失败</div><div class="empty-state-hint">${escapeHtml(e.message || '请检查鉴权与后端状态')}</div></div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$back.addEventListener('click', goBack)
|
||||||
|
$edit.addEventListener('click', goEdit)
|
||||||
|
$refresh.addEventListener('click', load)
|
||||||
|
$delete.addEventListener('click', doDelete)
|
||||||
|
|
||||||
|
load()
|
||||||
|
})()
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
.edit-card {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.attach-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 160px;
|
||||||
|
border: 2px dashed rgba(108, 122, 224, 0.35);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(108, 122, 224, 0.04);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
transition: border-color 0.25s ease, background 0.25s ease, transform 0.2s ease;
|
||||||
|
outline: none;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.drop-zone:hover,
|
||||||
|
.drop-zone:focus {
|
||||||
|
border-color: rgba(108, 122, 224, 0.6);
|
||||||
|
background: rgba(108, 122, 224, 0.08);
|
||||||
|
}
|
||||||
|
.drop-zone.drag {
|
||||||
|
border-color: rgba(108, 122, 224, 0.9);
|
||||||
|
background: rgba(108, 122, 224, 0.14);
|
||||||
|
transform: scale(1.005);
|
||||||
|
}
|
||||||
|
.drop-text {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.drop-icon { font-size: 36px; opacity: 0.85; margin-bottom: 10px; }
|
||||||
|
.drop-title { font-size: 16px; font-weight: 600; color: #d8d8ee; margin-bottom: 6px; }
|
||||||
|
.drop-hint { font-size: 13px; color: #8a8aa8; line-height: 1.7; }
|
||||||
|
.drop-hint b { color: #a8b4ff; }
|
||||||
|
|
||||||
|
.text-paste-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9020;
|
||||||
|
background: rgba(8, 8, 18, 0.58);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
animation: fade-in 0.18s ease;
|
||||||
|
}
|
||||||
|
.text-paste-card {
|
||||||
|
width: min(620px, 100%);
|
||||||
|
background: linear-gradient(160deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03));
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 22px 24px 18px;
|
||||||
|
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
.text-paste-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.text-paste-title { font-size: 16px; font-weight: 700; color: #e8e8ff; }
|
||||||
|
.text-paste-count { font-size: 13px; color: #9aa2c8; background: rgba(108,122,224,0.14); padding: 4px 10px; border-radius: 999px; }
|
||||||
|
.text-paste-preview {
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(0,0,0,0.28);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: #c8c8e4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
.text-paste-actions { display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap; }
|
||||||
|
.text-paste-actions .btn { min-width: 128px; }
|
||||||
|
|
||||||
|
.attach-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.attach-list:empty { display: none; }
|
||||||
|
|
||||||
|
.attach-item {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(18, 18, 31, 0.5);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
.attach-item:hover { border-color: rgba(108, 122, 224, 0.3); background: rgba(18, 18, 31, 0.7); }
|
||||||
|
|
||||||
|
.attach-item.image .preview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.attach-item.image .preview img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-item.file .preview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: linear-gradient(135deg, rgba(108, 122, 224, 0.18) 0%, rgba(176, 107, 224, 0.12) 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.attach-item.file .preview .big { font-size: 44px; opacity: 0.9; }
|
||||||
|
.attach-item.file .preview .ext {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #d0d0f0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-info {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.attach-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e0e0f4;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 39px;
|
||||||
|
}
|
||||||
|
.attach-meta {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #8a8aa8;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.attach-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.attach-status.pending { background: rgba(255, 193, 7, 0.12); color: #ffcc66; border: 1px solid rgba(255, 193, 7, 0.3); }
|
||||||
|
.attach-status.uploading { background: rgba(108, 122, 224, 0.14); color: #a8b4ff; border: 1px solid rgba(108, 122, 224, 0.3); }
|
||||||
|
.attach-status.ok { background: rgba(0, 216, 167, 0.12); color: #00d8a7; border: 1px solid rgba(0, 216, 167, 0.3); }
|
||||||
|
.attach-status.err { background: rgba(255, 80, 100, 0.12); color: #ff8595; border: 1px solid rgba(255, 80, 100, 0.3); }
|
||||||
|
|
||||||
|
.attach-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 8px 0 10px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.attach-progress.show { display: block; }
|
||||||
|
.attach-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #6c7ae0, #b06be0);
|
||||||
|
border-radius: 999px;
|
||||||
|
width: 0;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.attach-actions-row button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
color: #c0c0d8;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.attach-actions-row button:hover {
|
||||||
|
background: rgba(255,255,255,0.09);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.attach-actions-row button.danger {
|
||||||
|
background: rgba(255, 80, 100, 0.08);
|
||||||
|
color: #ff8595;
|
||||||
|
border-color: rgba(255, 80, 100, 0.22);
|
||||||
|
}
|
||||||
|
.attach-actions-row button.danger:hover { background: rgba(255, 80, 100, 0.16); }
|
||||||
|
.attach-actions-row button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.edit-card { padding: 18px; }
|
||||||
|
.attach-list { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>新增 / 编辑收藏 | MilesYang时光助手</title>
|
||||||
|
<link rel="stylesheet" href="/common/common.css">
|
||||||
|
<link rel="stylesheet" href="../../shared.css">
|
||||||
|
<link rel="stylesheet" href="edit.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="app-topbar">
|
||||||
|
<div class="app-topbar-left">
|
||||||
|
<button class="back-btn" id="btn-back" title="返回列表">←</button>
|
||||||
|
<div>
|
||||||
|
<div class="page-title" id="page-title">新增收藏</div>
|
||||||
|
<div class="page-subtitle">支持拖拽 / Ctrl+V 粘贴 / 选择本地文件</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-topbar-right">
|
||||||
|
<button class="btn secondary btn-sm" id="btn-save" disabled>保存中...</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="card edit-card" id="edit-form" autocomplete="off">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="f-title">标题 <span style="color:#6c7ae0;font-weight:400;">(未填时会根据附件自动生成)</span></label>
|
||||||
|
<input id="f-title" class="form-control" maxlength="200" placeholder="建议输入简洁标题,方便以后搜索">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="f-tags">标签(逗号或空格分隔)</label>
|
||||||
|
<input id="f-tags" class="form-control" maxlength="500" placeholder="如:工作, 重要, 合同">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="f-remark">备注文本</label>
|
||||||
|
<textarea id="f-remark" class="form-control" maxlength="4000" placeholder="可输入详细备注、说明、关键词... (未填且有附件时,会根据附件自动生成清单)"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="attach-head">
|
||||||
|
<label class="form-label" style="margin:0;">附件(图片 / PDF / Word 等)</label>
|
||||||
|
<div class="attach-actions">
|
||||||
|
<button type="button" class="btn secondary btn-sm" id="btn-pick-file">📎 选择本地文件</button>
|
||||||
|
<button type="button" class="btn ghost btn-sm" id="btn-auto-fill">🤖 按附件自动填充</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="file-input" multiple style="display:none" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv,.zip,.rar,.7z,.mp3,.mp4,.mov">
|
||||||
|
|
||||||
|
<div class="drop-zone" id="drop-zone" tabindex="0">
|
||||||
|
<div class="drop-text">
|
||||||
|
<div class="drop-icon">📂</div>
|
||||||
|
<div class="drop-title">拖拽文件到此处上传</div>
|
||||||
|
<div class="drop-hint">或直接 <b>Ctrl+V</b> 粘贴图片 / 截图,也可以点「选择本地文件」</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="attach-list" id="attach-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:32px; display:flex; justify-content:flex-end; gap:10px;">
|
||||||
|
<button type="button" class="btn secondary btn-sm" id="btn-cancel">取消</button>
|
||||||
|
<button type="submit" class="btn primary btn-sm" id="btn-save2">保存收藏</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-slot"></div>
|
||||||
|
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
||||||
|
|
||||||
|
<script src="../../api.js"></script>
|
||||||
|
<script src="../../kits/common/dialog.js"></script>
|
||||||
|
<script src="edit.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
(function () {
|
||||||
|
const api = window.__MPTA_API__
|
||||||
|
const dlg = window.__MPTA_DIALOG__
|
||||||
|
|
||||||
|
const $form = document.getElementById('edit-form')
|
||||||
|
const $title = document.getElementById('f-title')
|
||||||
|
const $tags = document.getElementById('f-tags')
|
||||||
|
const $remark = document.getElementById('f-remark')
|
||||||
|
const $fileInput = document.getElementById('file-input')
|
||||||
|
const $btnPick = document.getElementById('btn-pick-file')
|
||||||
|
const $btnAutoFill = document.getElementById('btn-auto-fill')
|
||||||
|
const $btnCancel = document.getElementById('btn-cancel')
|
||||||
|
const $btnSave = document.getElementById('btn-save')
|
||||||
|
const $btnSave2 = document.getElementById('btn-save2')
|
||||||
|
const $btnBack = document.getElementById('btn-back')
|
||||||
|
const $drop = document.getElementById('drop-zone')
|
||||||
|
const $attachList = document.getElementById('attach-list')
|
||||||
|
const $pageTitle = document.getElementById('page-title')
|
||||||
|
|
||||||
|
const escapeHtml = dlg.escapeHtml
|
||||||
|
const humanSize = dlg.humanSize
|
||||||
|
|
||||||
|
const q = new URLSearchParams(location.search)
|
||||||
|
const editId = q.get('id')
|
||||||
|
const isEdit = !!editId
|
||||||
|
|
||||||
|
let attachments = []
|
||||||
|
let groupId = ''
|
||||||
|
let saving = false
|
||||||
|
|
||||||
|
function fileIcon(fileName) {
|
||||||
|
const ext = (fileName || '').split('.').pop().toLowerCase()
|
||||||
|
if (/png|jpe?g|gif|webp|bmp|svg|ico/.test(ext)) return '🖼️'
|
||||||
|
if (/pdf/.test(ext)) return '📕'
|
||||||
|
if (/docx?|docm|dotx?/.test(ext)) return '📘'
|
||||||
|
if (/xlsx?|xlsm|xls/.test(ext)) return '📗'
|
||||||
|
if (/pptx?|ppsm/.test(ext)) return '📙'
|
||||||
|
if (/txt|md|log|csv/.test(ext)) return '📝'
|
||||||
|
if (/zip|rar|7z|tar|gz/.test(ext)) return '🗜️'
|
||||||
|
if (/mp3|wav|flac|m4a|aac/.test(ext)) return '🎵'
|
||||||
|
if (/mp4|mov|avi|mkv|webm/.test(ext)) return '🎥'
|
||||||
|
return '📄'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageByFile(file) {
|
||||||
|
try {
|
||||||
|
if (file && file.type && /^image\//.test(file.type)) return true
|
||||||
|
const name = (file && (file.name || '')) || ''
|
||||||
|
return /\.(png|jpe?g|gif|webp|bmp|svg|ico)$/i.test(name)
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObjectUrl(file) {
|
||||||
|
try { return URL.createObjectURL(file) } catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTags(s) {
|
||||||
|
if (!s) return []
|
||||||
|
return String(s)
|
||||||
|
.split(/[,,\s]+/)
|
||||||
|
.map(x => x.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((v, i, arr) => arr.indexOf(v) === i)
|
||||||
|
}
|
||||||
|
|
||||||
|
function genGroupId() {
|
||||||
|
return 'web_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSaving(v) {
|
||||||
|
saving = v
|
||||||
|
const flag = !!v
|
||||||
|
$btnSave.disabled = flag
|
||||||
|
$btnSave2.disabled = flag
|
||||||
|
$btnSave.textContent = flag ? '保存中...' : '保存'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttachments() {
|
||||||
|
if (!attachments.length) { $attachList.innerHTML = ''; return }
|
||||||
|
$attachList.innerHTML = attachments.map((a, idx) => {
|
||||||
|
const tempId = a.tempId
|
||||||
|
const isImg = !!a.isImage
|
||||||
|
const displayUrl = a.url || a.previewUrl || ''
|
||||||
|
const ext = (a.fileName || '').split('.').pop().toUpperCase()
|
||||||
|
const statusTextMap = { pending: '等待上传', uploading: '上传中', ok: '上传成功', err: '上传失败' }
|
||||||
|
const statusText = statusTextMap[a.status] || a.status
|
||||||
|
const showProgress = a.status === 'uploading'
|
||||||
|
return (
|
||||||
|
`<div class="attach-item ${isImg ? 'image' : 'file'}" data-temp="${escapeHtml(tempId)}">` +
|
||||||
|
(isImg
|
||||||
|
? `<div class="preview"><img src="${displayUrl}" alt="${escapeHtml(a.fileName || '')}" loading="lazy"></div>`
|
||||||
|
: `<div class="preview"><span class="big">${fileIcon(a.fileName)}</span><span class="ext">${ext || 'FILE'}</span></div>`) +
|
||||||
|
`<div class="attach-info">` +
|
||||||
|
`<div class="attach-name" title="${escapeHtml(a.fileName || '')}">${escapeHtml(a.fileName || '未命名')}</div>` +
|
||||||
|
`<div class="attach-meta">` +
|
||||||
|
`<span>${humanSize(a.fileSize)}</span>` +
|
||||||
|
`<span class="attach-status ${a.status}">${statusText}</span>` +
|
||||||
|
a.errorMsg ? `<span style="color:#ff8595;">${escapeHtml(a.errorMsg)}</span>` : '' +
|
||||||
|
`</div>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="attach-progress ${showProgress ? 'show' : ''}"><div class="attach-progress-bar" style="width:${a.percent || 0}%;"></div></div>` +
|
||||||
|
`<div class="attach-actions-row">` +
|
||||||
|
(a.status === 'err'
|
||||||
|
? `<button type="button" data-act="retry" data-temp="${escapeHtml(tempId)}">🔄 重试</button>`
|
||||||
|
: '') +
|
||||||
|
`<button type="button" data-act="remove" data-temp="${escapeHtml(tempId)}" class="danger">✕ 移除</button>` +
|
||||||
|
`</div>` +
|
||||||
|
`</div>`
|
||||||
|
)
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
Array.from($attachList.querySelectorAll('button[data-act]')).forEach((btn) => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const act = btn.getAttribute('data-act')
|
||||||
|
const tempId = btn.getAttribute('data-temp')
|
||||||
|
if (act === 'remove') removeAttachment(tempId)
|
||||||
|
else if (act === 'retry') retryUpload(tempId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAttachments(files) {
|
||||||
|
if (!files || !files.length) return
|
||||||
|
const pendingGroupId = groupId || genGroupId()
|
||||||
|
if (!groupId) groupId = pendingGroupId
|
||||||
|
Array.from(files).forEach((file) => {
|
||||||
|
if (!file) return
|
||||||
|
const tempId = 't_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
|
||||||
|
const isImage = isImageByFile(file)
|
||||||
|
const previewUrl = isImage ? getObjectUrl(file) : ''
|
||||||
|
attachments.push({
|
||||||
|
tempId, file, isImage,
|
||||||
|
fileName: file.name || ('未命名_' + tempId),
|
||||||
|
fileSize: Number(file.size || 0),
|
||||||
|
previewUrl,
|
||||||
|
url: '', id: '', storedName: '', filePath: '', mimeType: '',
|
||||||
|
status: 'pending', percent: 0, errorMsg: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
renderAttachments()
|
||||||
|
autoFillFromAttachments(false)
|
||||||
|
Promise.resolve().then(() => uploadPending())
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachment(tempId) {
|
||||||
|
const idx = attachments.findIndex(a => a.tempId === tempId)
|
||||||
|
if (idx < 0) return
|
||||||
|
const a = attachments[idx]
|
||||||
|
if (a && a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl) } catch {} }
|
||||||
|
attachments.splice(idx, 1)
|
||||||
|
renderAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryUpload(tempId) {
|
||||||
|
const a = attachments.find(x => x.tempId === tempId)
|
||||||
|
if (!a) return
|
||||||
|
a.status = 'pending'
|
||||||
|
a.percent = 0
|
||||||
|
a.errorMsg = ''
|
||||||
|
renderAttachments()
|
||||||
|
uploadPending()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadPending() {
|
||||||
|
const pendings = attachments.filter(a => a.status === 'pending')
|
||||||
|
for (const a of pendings) {
|
||||||
|
if (a.status !== 'pending') continue
|
||||||
|
a.status = 'uploading'
|
||||||
|
a.percent = 0
|
||||||
|
renderAttachments()
|
||||||
|
try {
|
||||||
|
const res = await api.personalCollect.uploadFile(a.file, groupId, (prog) => {
|
||||||
|
a.percent = prog.percent || 0
|
||||||
|
const target = $attachList.querySelector(`[data-temp="${a.tempId}"]`)
|
||||||
|
if (target) {
|
||||||
|
const bar = target.querySelector('.attach-progress-bar')
|
||||||
|
if (bar) bar.style.width = a.percent + '%'
|
||||||
|
const statusEl = target.querySelector('.attach-status')
|
||||||
|
if (statusEl) statusEl.textContent = `上传中 ${a.percent}%`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const data = res && res.data ? res.data : (res || {})
|
||||||
|
const inner = data.data ? data.data : data
|
||||||
|
a.id = inner.id || data.id || a.tempId
|
||||||
|
a.storedName = inner.storedName || data.storedName || inner.stored_name || a.fileName
|
||||||
|
a.filePath = inner.filePath || data.filePath || inner.file_path || ''
|
||||||
|
a.mimeType = inner.mimeType || data.mimeType || inner.mime_type || (a.file && a.file.type) || ''
|
||||||
|
a.url = inner.url || data.url || (a.filePath ? api.fileUrl(a.filePath) : a.previewUrl) || ''
|
||||||
|
if (typeof inner.isImage !== 'undefined') a.isImage = !!inner.isImage
|
||||||
|
else if (typeof data.isImage !== 'undefined') a.isImage = !!data.isImage
|
||||||
|
else if (typeof inner.is_image !== 'undefined') a.isImage = !!inner.is_image
|
||||||
|
a.status = 'ok'
|
||||||
|
a.percent = 100
|
||||||
|
} catch (e) {
|
||||||
|
a.status = 'err'
|
||||||
|
a.errorMsg = (e && e.message) ? e.message : '上传失败'
|
||||||
|
}
|
||||||
|
renderAttachments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAutoText(list) {
|
||||||
|
const atts = list || attachments.filter(a => a && (a.status === 'ok' || a.fileName))
|
||||||
|
if (!atts.length) return { title: '', remark: '' }
|
||||||
|
const images = atts.filter(a => a.isImage)
|
||||||
|
const files = atts.filter(a => !a.isImage)
|
||||||
|
const ic = images.length, fc = files.length, total = atts.length
|
||||||
|
const pad = (n) => (n < 10 ? '0' + n : '' + n)
|
||||||
|
const now = new Date()
|
||||||
|
const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
|
||||||
|
|
||||||
|
let title = ''
|
||||||
|
if (total === 1) {
|
||||||
|
const only = atts[0]
|
||||||
|
const raw = only.fileName || '附件'
|
||||||
|
const dot = raw.lastIndexOf('.')
|
||||||
|
const stem = dot > 0 ? raw.slice(0, dot) : raw
|
||||||
|
if (only.isImage) title = `图片 - ${ts}`
|
||||||
|
else title = stem || raw
|
||||||
|
} else if (ic > 0 && fc === 0) title = `${ic} 张图片 - ${ts}`
|
||||||
|
else if (fc > 0 && ic === 0) {
|
||||||
|
if (fc === 1) {
|
||||||
|
const only = files[0]; const raw = only.fileName || '附件'
|
||||||
|
const dot = raw.lastIndexOf('.'); const stem = dot > 0 ? raw.slice(0, dot) : raw
|
||||||
|
title = stem || raw
|
||||||
|
} else title = `${fc} 个文件 - ${ts}`
|
||||||
|
} else title = `${ic} 图 + ${fc} 文件 - ${ts}`
|
||||||
|
|
||||||
|
const lines = [`附件数量:${total}`]
|
||||||
|
if (ic) lines.push(`图片:${ic} 张`)
|
||||||
|
if (fc) lines.push(`文件:${fc} 个`)
|
||||||
|
lines.push('', '附件清单:')
|
||||||
|
atts.slice(0, 9).forEach((a, i) => lines.push(` ${i+1}. ${a.fileName || '未命名'}(${humanSize(a.fileSize)})`))
|
||||||
|
if (atts.length > 9) lines.push(` ... 另有 ${atts.length - 9} 个附件未列出`)
|
||||||
|
lines.push('', `整理时间:${ts}`)
|
||||||
|
return { title, remark: lines.join('\n') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoFillFromAttachments(force) {
|
||||||
|
if (isEdit && !force) return
|
||||||
|
const uploadeds = attachments.filter(a => a && (a.status === 'ok' || a.status === 'pending' || a.status === 'uploading' || a.fileName))
|
||||||
|
if (!uploadeds.length) return
|
||||||
|
const hasTitle = !!($title.value || '').trim()
|
||||||
|
const hasRemark = !!($remark.value || '').trim()
|
||||||
|
const { title, remark } = buildAutoText(uploadeds)
|
||||||
|
if (force || !hasTitle) $title.value = title || ''
|
||||||
|
if (force || !hasRemark) $remark.value = remark || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
$btnAutoFill.addEventListener('click', () => {
|
||||||
|
autoFillFromAttachments(true)
|
||||||
|
dlg.showToast('已按附件自动填充', 'success', 1400)
|
||||||
|
})
|
||||||
|
|
||||||
|
function bindDropPaste() {
|
||||||
|
$drop.addEventListener('dragenter', (e) => { e.preventDefault(); $drop.classList.add('drag') })
|
||||||
|
$drop.addEventListener('dragover', (e) => { e.preventDefault(); $drop.classList.add('drag') })
|
||||||
|
$drop.addEventListener('dragleave', (e) => { e.preventDefault(); if (e.target === $drop) $drop.classList.remove('drag') })
|
||||||
|
$drop.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault(); $drop.classList.remove('drag')
|
||||||
|
const list = (e.dataTransfer && e.dataTransfer.files) ? Array.from(e.dataTransfer.files) : []
|
||||||
|
if (list.length) addAttachments(list)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('paste', (e) => {
|
||||||
|
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return
|
||||||
|
if (!e.clipboardData) return
|
||||||
|
const items = Array.from(e.clipboardData.items || [])
|
||||||
|
const files = items
|
||||||
|
.filter(it => it.kind === 'file')
|
||||||
|
.map(it => it.getAsFile())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (files.length) { addAttachments(files); return }
|
||||||
|
const textItem = items.find(it => it.kind === 'string' && it.type === 'text/plain')
|
||||||
|
if (textItem) {
|
||||||
|
textItem.getAsString((raw) => {
|
||||||
|
const text = (raw || '').trim()
|
||||||
|
if (text.length < 3 || text.length > 50000) return
|
||||||
|
openTextPasteDialog(text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTextPasteDialog(text) {
|
||||||
|
const count = text.length
|
||||||
|
const preview = text.length > 900 ? (text.slice(0, 900) + '\n\n……(已截断预览,共 ' + count + ' 字)') : text
|
||||||
|
const mask = document.createElement('div')
|
||||||
|
mask.className = 'text-paste-mask'
|
||||||
|
mask.innerHTML =
|
||||||
|
`<div class="text-paste-card">
|
||||||
|
<div class="text-paste-head">
|
||||||
|
<div class="text-paste-title">📝 检测到剪贴板文字</div>
|
||||||
|
<div class="text-paste-count">${count} 字 · ${text.split('\n').length} 行</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-paste-preview">${escapeHtml(preview)}</div>
|
||||||
|
<div class="text-paste-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-act="replace">另起一条(清空附件)</button>
|
||||||
|
<button type="button" class="btn btn-primary" data-act="append">追加到当前备注</button>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
document.body.appendChild(mask)
|
||||||
|
const close = () => { try { document.body.removeChild(mask) } catch {} }
|
||||||
|
mask.addEventListener('click', (ev) => {
|
||||||
|
if (ev.target === mask) { close(); return }
|
||||||
|
const btn = ev.target.closest('button[data-act]')
|
||||||
|
if (!btn) return
|
||||||
|
const act = btn.getAttribute('data-act')
|
||||||
|
if (act === 'cancel') { close(); return }
|
||||||
|
if (act === 'append') {
|
||||||
|
const cur = ($remark.value || '').trim()
|
||||||
|
$remark.value = cur ? (cur + '\n\n' + text) : text
|
||||||
|
close()
|
||||||
|
dlg.showToast('已追加 ' + count + ' 字到备注', 'success', 1400)
|
||||||
|
autoFillFromAttachments(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (act === 'replace') {
|
||||||
|
attachments.forEach(a => { if (a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl) } catch {} } })
|
||||||
|
attachments = []
|
||||||
|
renderAttachments()
|
||||||
|
$remark.value = text
|
||||||
|
const firstLine = text.split('\n').map(s => s.trim()).find(s => s.length) || ''
|
||||||
|
const autoTitle = (firstLine || '').slice(0, 28)
|
||||||
|
if (isEdit) {
|
||||||
|
if (!($title.value || '').trim() && autoTitle) $title.value = autoTitle
|
||||||
|
dlg.showToast('已应用到当前编辑', 'success', 1400)
|
||||||
|
} else {
|
||||||
|
$title.value = autoTitle || '文字收藏'
|
||||||
|
dlg.showToast('已清空附件并填入文字', 'success', 1400)
|
||||||
|
}
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setTimeout(() => { const first = mask.querySelector('button[data-act="append"]'); if (first) first.focus() }, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindInputEvents() {
|
||||||
|
$btnPick.addEventListener('click', () => { if ($fileInput) $fileInput.click() })
|
||||||
|
$fileInput.addEventListener('change', (e) => {
|
||||||
|
const files = Array.from((e.target && e.target.files) || [])
|
||||||
|
if (files.length) addAttachments(files)
|
||||||
|
$fileInput.value = ''
|
||||||
|
})
|
||||||
|
$drop.addEventListener('click', () => { if ($fileInput) $fileInput.click() })
|
||||||
|
$drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if ($fileInput) $fileInput.click() } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload() {
|
||||||
|
const titleVal = ($title.value || '').trim()
|
||||||
|
const remarkVal = ($remark.value || '').trim()
|
||||||
|
const tagsArr = parseTags($tags.value)
|
||||||
|
const uploadeds = attachments.filter(a => a && a.status === 'ok' && a.id)
|
||||||
|
if (!titleVal && !remarkVal && !uploadeds.length) {
|
||||||
|
throw new Error('请至少输入标题/备注/上传附件三者其中之一')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: titleVal,
|
||||||
|
remark: remarkVal,
|
||||||
|
tags: tagsArr,
|
||||||
|
attachmentIds: uploadeds.map(a => a.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() { window.location.href = 'list.html' }
|
||||||
|
|
||||||
|
async function onSave(e) {
|
||||||
|
if (e) e.preventDefault()
|
||||||
|
if (saving) return
|
||||||
|
let payload
|
||||||
|
try { payload = buildPayload() }
|
||||||
|
catch (err) { dlg.showToast(err.message, 'warn', 2600); return }
|
||||||
|
|
||||||
|
const uploadingCount = attachments.filter(a => a.status === 'pending' || a.status === 'uploading').length
|
||||||
|
const failedList = attachments.filter(a => a.status === 'err')
|
||||||
|
if (uploadingCount > 0) {
|
||||||
|
dlg.confirmDialog({
|
||||||
|
title: '仍有文件上传中',
|
||||||
|
desc: `还有 ${uploadingCount} 个文件正在上传。\n现在保存的话,这些未完成的文件不会被绑定到此条收藏。\n\n建议等待上传结束后再保存。`,
|
||||||
|
okText: '仍然保存', cancelText: '等待上传', danger: false
|
||||||
|
}, () => doSave(payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (failedList.length > 0) {
|
||||||
|
dlg.confirmDialog({
|
||||||
|
title: `${failedList.length} 个文件上传失败`,
|
||||||
|
desc: `以下文件上传失败,保存后这些文件不会被绑定:\n\n${failedList.map(a => '· ' + (a.fileName || '未命名')).join('\n')}\n\n你可以单独重试这些附件,或直接保存已成功的内容。`,
|
||||||
|
okText: '忽略失败,只保存成功的', cancelText: '回去修', danger: true
|
||||||
|
}, () => doSave(payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doSave(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSave(payload) {
|
||||||
|
const opName = isEdit ? '更新收藏' : '新增收藏'
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
const saved = await dlg.runTrackedInteraction({
|
||||||
|
name: opName,
|
||||||
|
method: isEdit ? 'PUT' : 'POST',
|
||||||
|
path: '/api/mp_time_assistant/kits/personal_collect/items',
|
||||||
|
params: isEdit ? Object.assign({ id: editId }, payload) : payload
|
||||||
|
}, () => {
|
||||||
|
if (isEdit) return api.personalCollect.update(Object.assign({ id: editId }, payload))
|
||||||
|
return api.personalCollect.create(payload)
|
||||||
|
})
|
||||||
|
dlg.showToast(`${opName}成功`, 'success', 1600)
|
||||||
|
setTimeout(() => {
|
||||||
|
const dataId = (saved && saved.id) || editId
|
||||||
|
if (!isEdit && dataId) window.location.href = 'detail.html?id=' + encodeURIComponent(dataId)
|
||||||
|
else goBack()
|
||||||
|
}, 500)
|
||||||
|
} catch (e) {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEditRecord() {
|
||||||
|
try {
|
||||||
|
const data = await dlg.runTrackedInteraction({
|
||||||
|
name: '加载收藏详情',
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/mp_time_assistant/kits/personal_collect/items/' + encodeURIComponent(editId),
|
||||||
|
params: { id: editId }
|
||||||
|
}, () => api.personalCollect.get(editId))
|
||||||
|
const item = data ? ((data && data.data) || data) : {}
|
||||||
|
const itemInner = (item && item.item) || item
|
||||||
|
$title.value = itemInner.title || ''
|
||||||
|
$remark.value = itemInner.remark || ''
|
||||||
|
$tags.value = (itemInner.tags && itemInner.tags.length) ? itemInner.tags.join(', ') : ''
|
||||||
|
if (itemInner.groupId) groupId = itemInner.groupId || ''
|
||||||
|
const rawAtts = itemInner.attachments || item.attachments || []
|
||||||
|
const atts = rawAtts.map((a, i) => ({
|
||||||
|
tempId: 'e_' + Date.now().toString(36) + '_' + i,
|
||||||
|
id: a.id || '',
|
||||||
|
storedName: a.storedName || a.stored_name || a.fileName || '',
|
||||||
|
filePath: a.filePath || a.file_path || '',
|
||||||
|
mimeType: a.mimeType || a.mime_type || '',
|
||||||
|
isImage: typeof a.isImage !== 'undefined' ? !!a.isImage : !!a.is_image,
|
||||||
|
fileName: a.fileName || a.file_name || '未命名',
|
||||||
|
fileSize: Number(a.fileSize || a.file_size || 0),
|
||||||
|
previewUrl: '',
|
||||||
|
url: a.url || api.fileUrl(a.filePath || a.file_path || ''),
|
||||||
|
status: 'ok', percent: 100, errorMsg: '', file: null
|
||||||
|
}))
|
||||||
|
attachments = atts
|
||||||
|
renderAttachments()
|
||||||
|
} catch (e) {
|
||||||
|
dlg.showToast('加载失败:' + (e.message || '未知错误'), 'error', 3000)
|
||||||
|
setTimeout(goBack, 1400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
bindDropPaste()
|
||||||
|
bindInputEvents()
|
||||||
|
$btnBack.addEventListener('click', goBack)
|
||||||
|
$btnCancel.addEventListener('click', goBack)
|
||||||
|
$form.addEventListener('submit', onSave)
|
||||||
|
$btnSave.addEventListener('click', onSave)
|
||||||
|
if (isEdit) {
|
||||||
|
$pageTitle.textContent = '编辑收藏'
|
||||||
|
loadEditRecord()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init)
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
attachments.forEach(a => { if (a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl) } catch {} } })
|
||||||
|
})
|
||||||
|
})()
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
.list-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-card {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-card-title {
|
||||||
|
font-size: 16.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f0f0f8;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-card-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8a8aa8;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-card-remark {
|
||||||
|
color: #b8b8d0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-card-tags {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-card-preview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.fav-card-preview:empty { display: none; margin: 0; }
|
||||||
|
|
||||||
|
.fav-card-preview .thumb {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.fav-card-preview .thumb img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||||
|
}
|
||||||
|
.fav-card-preview .thumb.file {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-direction: column; font-size: 12px; color: #a8b4ff;
|
||||||
|
padding: 6px; text-align: center; gap: 4px;
|
||||||
|
}
|
||||||
|
.fav-card-preview .thumb.file .icon { font-size: 20px; opacity: 0.8; }
|
||||||
|
.fav-card-preview .thumb.file .name {
|
||||||
|
font-size: 11px; color: #b8b8d0; line-height: 1.3;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-card-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8a8aa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-card-actions {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(108, 122, 224, 0.1);
|
||||||
|
color: #a8b4ff;
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.2);
|
||||||
|
}
|
||||||
|
.btn-action:hover { background: rgba(108, 122, 224, 0.2); }
|
||||||
|
.btn-action.danger {
|
||||||
|
background: rgba(255, 80, 100, 0.08);
|
||||||
|
color: #ff8595;
|
||||||
|
border-color: rgba(255, 80, 100, 0.2);
|
||||||
|
}
|
||||||
|
.btn-action.danger:hover { background: rgba(255, 80, 100, 0.16); }
|
||||||
|
|
||||||
|
.fav-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>微信收藏夹 | MilesYang时光助手</title>
|
||||||
|
<link rel="stylesheet" href="/common/common.css">
|
||||||
|
<link rel="stylesheet" href="../../shared.css">
|
||||||
|
<link rel="stylesheet" href="list.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="app-topbar">
|
||||||
|
<div class="app-topbar-left">
|
||||||
|
<button class="back-btn" id="btn-back" title="返回首页">←</button>
|
||||||
|
<div>
|
||||||
|
<div class="page-title">📁 微信收藏夹</div>
|
||||||
|
<div class="page-subtitle">聊天附件 / 图片 / 文件,手机电脑实时同步</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="app-topbar-right">
|
||||||
|
<button class="btn secondary btn-sm" id="btn-refresh">🔄 刷新</button>
|
||||||
|
<button class="btn primary btn-sm" id="btn-add">+ 新增收藏</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
<input id="search-input" type="text" placeholder="搜索标题 / 备注 / 标签...(回车搜索)">
|
||||||
|
<button class="btn ghost btn-sm" id="btn-clear-search">清空</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main id="list-wrap"></main>
|
||||||
|
|
||||||
|
<button class="fab" id="fab-add" title="新增收藏">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-slot"></div>
|
||||||
|
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
||||||
|
|
||||||
|
<script src="../../api.js"></script>
|
||||||
|
<script src="../../kits/common/dialog.js"></script>
|
||||||
|
<script src="list.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
(function () {
|
||||||
|
const api = window.__MPTA_API__
|
||||||
|
const dlg = window.__MPTA_DIALOG__
|
||||||
|
const $wrap = document.getElementById('list-wrap')
|
||||||
|
const $search = document.getElementById('search-input')
|
||||||
|
const $clear = document.getElementById('btn-clear-search')
|
||||||
|
const $back = document.getElementById('btn-back')
|
||||||
|
const $refresh = document.getElementById('btn-refresh')
|
||||||
|
const $add = document.getElementById('btn-add')
|
||||||
|
const $fab = document.getElementById('fab-add')
|
||||||
|
|
||||||
|
let currentItems = []
|
||||||
|
let pendingKeyword = ''
|
||||||
|
|
||||||
|
const escapeHtml = dlg.escapeHtml
|
||||||
|
const formatTime = dlg.formatTime
|
||||||
|
const humanSize = dlg.humanSize
|
||||||
|
|
||||||
|
function goBack() { window.location.href = '../../index.html' }
|
||||||
|
function goAdd() { window.location.href = 'edit.html' }
|
||||||
|
function goEdit(id) { window.location.href = 'edit.html?id=' + encodeURIComponent(id) }
|
||||||
|
function goDetail(id) { window.location.href = 'detail.html?id=' + encodeURIComponent(id) }
|
||||||
|
|
||||||
|
function fileIcon(fileName) {
|
||||||
|
const ext = (fileName || '').split('.').pop().toLowerCase()
|
||||||
|
if (/png|jpe?g|gif|webp|bmp|svg|ico/.test(ext)) return '🖼️'
|
||||||
|
if (/pdf/.test(ext)) return '📕'
|
||||||
|
if (/docx?|docm|dotx?/.test(ext)) return '📘'
|
||||||
|
if (/xlsx?|xlsm|xls/.test(ext)) return '📗'
|
||||||
|
if (/pptx?|ppsm/.test(ext)) return '📙'
|
||||||
|
if (/txt|md|log/.test(ext)) return '📝'
|
||||||
|
if (/zip|rar|7z|tar|gz/.test(ext)) return '🗜️'
|
||||||
|
if (/mp3|wav|flac|m4a|aac/.test(ext)) return '🎵'
|
||||||
|
if (/mp4|mov|avi|mkv|webm/.test(ext)) return '🎥'
|
||||||
|
return '📄'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview(attachments) {
|
||||||
|
const list = attachments || []
|
||||||
|
if (!list.length) return ''
|
||||||
|
const images = list.filter(a => a.isImage)
|
||||||
|
const files = list.filter(a => !a.isImage)
|
||||||
|
const tiles = []
|
||||||
|
images.slice(0, 4).forEach((a) => {
|
||||||
|
const url = api.fileUrl(a.url || a.filePath)
|
||||||
|
tiles.push(`<div class="thumb"><img src="${url}" alt="${escapeHtml(a.fileName || 'image')}" loading="lazy"></div>`)
|
||||||
|
})
|
||||||
|
const fileSlots = Math.max(0, 4 - tiles.length)
|
||||||
|
files.slice(0, fileSlots).forEach((a) => {
|
||||||
|
tiles.push(
|
||||||
|
`<div class="thumb file" title="${escapeHtml(a.fileName || '')}">` +
|
||||||
|
`<span class="icon">${fileIcon(a.fileName)}</span>` +
|
||||||
|
`<span class="name">${escapeHtml(a.fileName || '未命名')}</span>` +
|
||||||
|
`</div>`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (images.length + files.length > 4) {
|
||||||
|
const extra = (images.length + files.length) - 4
|
||||||
|
tiles.push(`<div class="thumb file"><span class="icon">+${extra}</span><span class="name">更多</span></div>`)
|
||||||
|
}
|
||||||
|
return `<div class="fav-card-preview">${tiles.join('')}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItems(items) {
|
||||||
|
if (!items || !items.length) {
|
||||||
|
$wrap.innerHTML =
|
||||||
|
`<div class="fav-empty empty-state">` +
|
||||||
|
`<div class="empty-state-icon">📂</div>` +
|
||||||
|
`<div class="empty-state-text">还没有收藏内容</div>` +
|
||||||
|
`<div class="empty-state-hint">点击右上角「新增收藏」或右下角「+」开始保存</div>` +
|
||||||
|
`</div>`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$wrap.innerHTML = `<div class="list-container">` + items.map((it) => {
|
||||||
|
const atts = it.attachments || []
|
||||||
|
const imgCount = atts.filter(a => a.isImage).length
|
||||||
|
const fileCount = atts.filter(a => !a.isImage).length
|
||||||
|
const parts = []
|
||||||
|
if (imgCount) parts.push(`${imgCount} 图`)
|
||||||
|
if (fileCount) parts.push(`${fileCount} 文件`)
|
||||||
|
const totalSize = atts.reduce((s, a) => s + Number(a.fileSize || 0), 0)
|
||||||
|
if (totalSize) parts.push(humanSize(totalSize))
|
||||||
|
const countText = parts.join(' · ')
|
||||||
|
|
||||||
|
const title = it.title ? escapeHtml(it.title) : '<span style="color:#8a8aa8;font-weight:500;">(无标题)</span>'
|
||||||
|
const tags = (it.tags && it.tags.length) ? it.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<div class="card fav-card" data-id="${escapeHtml(it.id)}">` +
|
||||||
|
`<div class="fav-card-header">` +
|
||||||
|
`<div class="fav-card-title" data-act="detail">${title}</div>` +
|
||||||
|
`<div class="fav-card-time">${formatTime(it.updatedAt || it.updated_at || it.createdAt || it.created_at)}</div>` +
|
||||||
|
`</div>` +
|
||||||
|
(it.remark ? `<div class="fav-card-remark" data-act="detail">${escapeHtml(it.remark)}</div>` : '') +
|
||||||
|
(tags ? `<div class="fav-card-tags">${tags}</div>` : '') +
|
||||||
|
renderPreview(atts) +
|
||||||
|
`<div class="fav-card-footer">` +
|
||||||
|
`<div class="fav-card-count">${countText || '纯文字笔记'}</div>` +
|
||||||
|
`<div class="fav-card-actions">` +
|
||||||
|
`<button class="btn-action" data-act="detail">查看</button>` +
|
||||||
|
`<button class="btn-action" data-act="edit">编辑</button>` +
|
||||||
|
`<button class="btn-action danger" data-act="delete">删除</button>` +
|
||||||
|
`</div>` +
|
||||||
|
`</div>` +
|
||||||
|
`</div>`
|
||||||
|
)
|
||||||
|
}).join('') + `</div>`
|
||||||
|
|
||||||
|
Array.from($wrap.querySelectorAll('.fav-card')).forEach((card) => {
|
||||||
|
const id = card.getAttribute('data-id')
|
||||||
|
card.addEventListener('click', (e) => {
|
||||||
|
const act = (e.target.getAttribute && e.target.getAttribute('data-act')) || 'detail'
|
||||||
|
if (act === 'detail') goDetail(id)
|
||||||
|
else if (act === 'edit') goEdit(id)
|
||||||
|
else if (act === 'delete') confirmDelete(id, card)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTitle(id) {
|
||||||
|
const it = currentItems.find(x => x.id === id)
|
||||||
|
if (!it) return '该条收藏'
|
||||||
|
return (it.title || '').trim() || '这条无标题收藏'
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(id, card) {
|
||||||
|
const title = findTitle(id)
|
||||||
|
dlg.confirmDialog({
|
||||||
|
title: '确认删除收藏?',
|
||||||
|
desc: `「${title}」\n\n删除后此条收藏将不再显示(软删除,后端保留记录可追溯)。\n删除后手机端同样会消失。`,
|
||||||
|
okText: '确认删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
danger: true
|
||||||
|
}, () => {
|
||||||
|
dlg.runTrackedInteraction({
|
||||||
|
name: `删除收藏`,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/mp_time_assistant/kits/personal_collect/items',
|
||||||
|
params: { id }
|
||||||
|
}, () => api.personalCollect.remove(id)).then((res) => {
|
||||||
|
if (card) {
|
||||||
|
card.style.transition = 'opacity 0.25s ease, transform 0.25s ease'
|
||||||
|
card.style.opacity = '0'
|
||||||
|
card.style.transform = 'translateY(-6px)'
|
||||||
|
setTimeout(() => loadList(pendingKeyword), 260)
|
||||||
|
} else {
|
||||||
|
loadList(pendingKeyword)
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchTimer = null
|
||||||
|
function onSearchInput() {
|
||||||
|
const kw = ($search.value || '').trim()
|
||||||
|
pendingKeyword = kw
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
searchTimer = setTimeout(() => loadList(kw), 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
if ($search.value) { $search.value = ''; pendingKeyword = ''; loadList('') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadList(keyword) {
|
||||||
|
pendingKeyword = keyword || ''
|
||||||
|
dlg.runTrackedInteraction({
|
||||||
|
name: keyword ? `搜索收藏(${keyword})` : '加载收藏列表',
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/mp_time_assistant/kits/personal_collect/items',
|
||||||
|
params: { keyword: keyword || '' },
|
||||||
|
normalViewAsToast: false
|
||||||
|
}, () => api.personalCollect.list(keyword)).then((items) => {
|
||||||
|
const safeItems = Array.isArray(items) ? items : []
|
||||||
|
currentItems = safeItems
|
||||||
|
renderItems(safeItems)
|
||||||
|
if (keyword) {
|
||||||
|
dlg.showToast(`搜索到 ${safeItems.length} 条`, safeItems.length ? 'success' : 'info', 1500)
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
$wrap.innerHTML = `<div class="fav-empty empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">加载失败</div><div class="empty-state-hint">请检查后端是否已启动,并通过首页进入以完成鉴权</div></div>`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$back.addEventListener('click', goBack)
|
||||||
|
$add.addEventListener('click', goAdd)
|
||||||
|
$fab.addEventListener('click', goAdd)
|
||||||
|
$refresh.addEventListener('click', () => loadList(pendingKeyword))
|
||||||
|
$search.addEventListener('input', onSearchInput)
|
||||||
|
$search.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); pendingKeyword = ($search.value || '').trim(); loadList(pendingKeyword) } })
|
||||||
|
$clear.addEventListener('click', clearSearch)
|
||||||
|
|
||||||
|
loadList('')
|
||||||
|
})()
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
background: radial-gradient(1200px 800px at 10% 0%, #1a1a33 0%, #0f0f1a 60%, #0b0b15 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #e8e8f0;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder, textarea::placeholder {
|
||||||
|
color: #555570;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.15);
|
||||||
|
border-radius: 22px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-topbar-left { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||||
|
.app-topbar-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(108, 122, 224, 0.14);
|
||||||
|
color: #a8b4ff;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.25);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.back-btn:hover { background: rgba(108, 122, 224, 0.22); transform: translateX(-2px); }
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f0f0f8;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8a8aa8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: linear-gradient(135deg, #6c7ae0 0%, #8a6be0 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.5);
|
||||||
|
box-shadow: 0 8px 20px rgba(108, 122, 224, 0.25);
|
||||||
|
}
|
||||||
|
.btn.primary:hover { transform: translateY(-1px); box-shadow: 0 12px 26px rgba(108, 122, 224, 0.32); }
|
||||||
|
.btn.primary:disabled { opacity: 0.55; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #cfcfe0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.btn.secondary:hover { background: rgba(255, 255, 255, 0.08); border-color: rgba(108, 122, 224, 0.25); }
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
background: rgba(255, 80, 100, 0.12);
|
||||||
|
color: #ff8595;
|
||||||
|
border: 1px solid rgba(255, 80, 100, 0.25);
|
||||||
|
}
|
||||||
|
.btn.danger:hover { background: rgba(255, 80, 100, 0.2); }
|
||||||
|
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #9b9bb5;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.btn.ghost:hover { color: #e8e8f0; background: rgba(255,255,255,0.04); }
|
||||||
|
|
||||||
|
.btn-sm { padding: 7px 12px; font-size: 13px; border-radius: 10px; }
|
||||||
|
.btn-lg { padding: 14px 24px; font-size: 15px; border-radius: 14px; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 24px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover { border-color: rgba(108, 122, 224, 0.22); }
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(108, 122, 224, 0.12);
|
||||||
|
color: #a8b4ff;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.22);
|
||||||
|
margin-right: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 100px 20px;
|
||||||
|
color: #8a8aa8;
|
||||||
|
}
|
||||||
|
.empty-state-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.6; }
|
||||||
|
.empty-state-text { font-size: 15px; margin-bottom: 8px; }
|
||||||
|
.empty-state-hint { font-size: 13px; opacity: 0.7; }
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.search-bar:focus-within { border-color: rgba(108, 122, 224, 0.4); }
|
||||||
|
.search-bar .search-icon { font-size: 17px; color: #8a8aa8; flex-shrink: 0; }
|
||||||
|
.search-bar input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #c8c8e0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(18, 18, 31, 0.7);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
.form-control:focus {
|
||||||
|
background: rgba(18, 18, 31, 0.9);
|
||||||
|
border-color: rgba(108, 122, 224, 0.45);
|
||||||
|
}
|
||||||
|
textarea.form-control { resize: vertical; min-height: 140px; }
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
background: linear-gradient(180deg, #1a1a2e 0%, #141425 100%);
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.2);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.modal-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f0f0f8;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.modal-title.danger { color: #ff8595; }
|
||||||
|
.modal-desc { color: #a8a8c0; font-size: 14px; line-height: 1.7; margin-bottom: 22px; white-space: pre-wrap; }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
right: 28px;
|
||||||
|
bottom: 36px;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #6c7ae0 0%, #b06be0 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28px;
|
||||||
|
box-shadow: 0 14px 30px rgba(108, 122, 224, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.fab:hover { transform: translateY(-2px) scale(1.03); box-shadow: 0 18px 38px rgba(108, 122, 224, 0.5); }
|
||||||
|
|
||||||
|
.interaction-dialog {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
width: min(460px, calc(100vw - 48px));
|
||||||
|
max-height: 55vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: linear-gradient(180deg, rgba(18, 18, 34, 0.98) 0%, rgba(14, 14, 28, 0.98) 100%);
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.25);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
z-index: 10000;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.interaction-dialog.hidden { display: none; }
|
||||||
|
.interaction-dialog .id-title {
|
||||||
|
font-size: 13px; font-weight: 600; color: #a8b4ff;
|
||||||
|
margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.interaction-dialog .id-body {
|
||||||
|
color: #b8b8d0; white-space: pre-wrap; word-break: break-all;
|
||||||
|
font-family: 'Consolas', 'SFMono-Regular', monospace;
|
||||||
|
background: rgba(0,0,0,0.2); border-radius: 8px; padding: 10px; max-height: 38vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.interaction-dialog .id-actions {
|
||||||
|
margin-top: 10px; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.interaction-dialog .id-countdown { color: #8a8aa8; font-size: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.app-shell { padding: 20px 14px 96px; }
|
||||||
|
.app-topbar { padding: 16px 18px; flex-direction: column; align-items: flex-start; }
|
||||||
|
.app-topbar-right { width: 100%; justify-content: flex-end; }
|
||||||
|
.page-title { font-size: 18px; }
|
||||||
|
.card { padding: 18px; }
|
||||||
|
.fab { right: 20px; bottom: 26px; width: 54px; height: 54px; font-size: 24px; }
|
||||||
|
}
|
||||||
@@ -34,9 +34,11 @@ const ensureDb = () => {
|
|||||||
const cols = new Set(db.prepare("PRAGMA table_info(events)").all().map(c => c.name));
|
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('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('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_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_updatedAt ON events(updatedAt);`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_batch_id ON events(batch_id);`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUUID = () => {
|
const getUUID = () => {
|
||||||
@@ -156,8 +158,8 @@ const handlePost = async (req, res) => {
|
|||||||
ev.allDay = ev.allDay ? 1 : 0;
|
ev.allDay = ev.allDay ? 1 : 0;
|
||||||
|
|
||||||
const stmt = db.prepare(`INSERT OR REPLACE INTO events
|
const stmt = db.prepare(`INSERT OR REPLACE INTO events
|
||||||
(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)`);
|
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted,@batch_id)`);
|
||||||
|
|
||||||
stmt.run({
|
stmt.run({
|
||||||
id: ev.id,
|
id: ev.id,
|
||||||
@@ -171,7 +173,8 @@ const handlePost = async (req, res) => {
|
|||||||
createdAt: ev.createdAt,
|
createdAt: ev.createdAt,
|
||||||
updatedAt: ev.updatedAt,
|
updatedAt: ev.updatedAt,
|
||||||
completed: ev.completed,
|
completed: ev.completed,
|
||||||
deleted: ev.deleted
|
deleted: ev.deleted,
|
||||||
|
batch_id: ev.batch_id || null
|
||||||
});
|
});
|
||||||
|
|
||||||
await syncExternalUpsert(ev);
|
await syncExternalUpsert(ev);
|
||||||
@@ -196,7 +199,7 @@ const handlePut = async (req, res) => {
|
|||||||
|
|
||||||
const stmt = db.prepare(`UPDATE events SET
|
const stmt = db.prepare(`UPDATE events SET
|
||||||
title=@title,content=@content,start=@start,end=@end,allDay=@allDay,remindAt=@remindAt,recurrence=@recurrence,
|
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({
|
stmt.run({
|
||||||
id: merged.id,
|
id: merged.id,
|
||||||
@@ -209,7 +212,8 @@ const handlePut = async (req, res) => {
|
|||||||
recurrence: merged.recurrence,
|
recurrence: merged.recurrence,
|
||||||
updatedAt: merged.updatedAt,
|
updatedAt: merged.updatedAt,
|
||||||
completed: merged.completed,
|
completed: merged.completed,
|
||||||
deleted: merged.deleted
|
deleted: merged.deleted,
|
||||||
|
batch_id: merged.batch_id !== undefined ? merged.batch_id : existing.batch_id
|
||||||
});
|
});
|
||||||
|
|
||||||
await syncExternalUpsert(merged);
|
await syncExternalUpsert(merged);
|
||||||
|
|||||||
@@ -92,10 +92,41 @@ const ensureDb = () => {
|
|||||||
const cols = new Set(db.prepare("PRAGMA table_info(events)").all().map(c => c.name))
|
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('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('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
|
// 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_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_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
|
// Helpers
|
||||||
@@ -225,8 +256,8 @@ const handlePost = async (req, res) => {
|
|||||||
ev.allDay = ev.allDay ? 1 : 0
|
ev.allDay = ev.allDay ? 1 : 0
|
||||||
|
|
||||||
const stmt = db.prepare(`INSERT OR REPLACE INTO events
|
const stmt = db.prepare(`INSERT OR REPLACE INTO events
|
||||||
(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)`)
|
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted,@batch_id)`)
|
||||||
|
|
||||||
stmt.run({
|
stmt.run({
|
||||||
id: ev.id,
|
id: ev.id,
|
||||||
@@ -240,7 +271,8 @@ const handlePost = async (req, res) => {
|
|||||||
createdAt: ev.createdAt,
|
createdAt: ev.createdAt,
|
||||||
updatedAt: ev.updatedAt,
|
updatedAt: ev.updatedAt,
|
||||||
completed: ev.completed,
|
completed: ev.completed,
|
||||||
deleted: ev.deleted
|
deleted: ev.deleted,
|
||||||
|
batch_id: ev.batch_id || null
|
||||||
})
|
})
|
||||||
|
|
||||||
await syncExternalUpsert(ev)
|
await syncExternalUpsert(ev)
|
||||||
@@ -280,7 +312,7 @@ const handlePut = async (req, res) => {
|
|||||||
|
|
||||||
const stmt = db.prepare(`UPDATE events SET
|
const stmt = db.prepare(`UPDATE events SET
|
||||||
title=@title,content=@content,start=@start,end=@end,allDay=@allDay,remindAt=@remindAt,recurrence=@recurrence,
|
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({
|
stmt.run({
|
||||||
id: merged.id,
|
id: merged.id,
|
||||||
@@ -293,7 +325,8 @@ const handlePut = async (req, res) => {
|
|||||||
recurrence: merged.recurrence,
|
recurrence: merged.recurrence,
|
||||||
updatedAt: merged.updatedAt,
|
updatedAt: merged.updatedAt,
|
||||||
completed: merged.completed,
|
completed: merged.completed,
|
||||||
deleted: merged.deleted
|
deleted: merged.deleted,
|
||||||
|
batch_id: merged.batch_id !== undefined ? merged.batch_id : existing.batch_id
|
||||||
})
|
})
|
||||||
|
|
||||||
await syncExternalUpsert(merged)
|
await syncExternalUpsert(merged)
|
||||||
@@ -311,6 +344,878 @@ const handleDelete = async (req, res) => {
|
|||||||
res.json({ ok: true })
|
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) => {
|
const bindRoutes = (app) => {
|
||||||
ensureDb()
|
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
|
// Events
|
||||||
app.all('/api/calendar/events', checkAuth, express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
|
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.
|
// Note: C# supported text/plain body to avoid CORS preflight.
|
||||||
|
|||||||
+160
-1
@@ -304,6 +304,20 @@ const ensureAppOrderBoxLoaded = () => {
|
|||||||
}
|
}
|
||||||
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 CALENDAR_VOICE_AUTH_COOKIE = 'calendar_voice_gate'
|
||||||
const readCalendarVoiceAuthConfig = () => {
|
const readCalendarVoiceAuthConfig = () => {
|
||||||
try {
|
try {
|
||||||
@@ -819,6 +833,150 @@ const ensureZhiziNotesLoaded = () => {
|
|||||||
}
|
}
|
||||||
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 PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
|
||||||
const readPlantHomeAuthConfig = () => {
|
const readPlantHomeAuthConfig = () => {
|
||||||
try {
|
try {
|
||||||
@@ -1643,7 +1801,8 @@ const mapSystemIdToUrl = (systemId) => {
|
|||||||
'Tools-short_link': '/tools/short_link',
|
'Tools-short_link': '/tools/short_link',
|
||||||
'Tools-web_mobile_calendar_reminder': '/tools/web_mobile_calendar_reminder',
|
'Tools-web_mobile_calendar_reminder': '/tools/web_mobile_calendar_reminder',
|
||||||
'Tools-greedy_snake': '/tools/greedy_snake',
|
'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 (m[systemId]) return m[systemId]
|
||||||
if (systemId.startsWith('Tools-')) {
|
if (systemId.startsWith('Tools-')) {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
const Database = require('better-sqlite3')
|
||||||
|
const path = require('path')
|
||||||
|
const { bindRoutes: bindPersonalCollectRoutes } = require('./kits/personal_collect')
|
||||||
|
|
||||||
|
const CORE_DB_PATH = path.join(process.cwd(), 'data', 'mp_time_assistant_core.db')
|
||||||
|
|
||||||
|
let db = null
|
||||||
|
|
||||||
|
const KIT_META = [
|
||||||
|
{
|
||||||
|
id: 'personal_collect',
|
||||||
|
name: '微信收藏夹',
|
||||||
|
description: '聊天图片、文件收藏管理,标签搜索,软删除可追溯。',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const nowIso = () => new Date().toISOString()
|
||||||
|
|
||||||
|
const getDb = () => {
|
||||||
|
if (db) return db
|
||||||
|
db = new Database(CORE_DB_PATH)
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS core_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
active_kit TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);`)
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO core_state (id, active_kit, updated_at)
|
||||||
|
VALUES (1, NULL, ?)
|
||||||
|
`).run(nowIso())
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStateRow = () => {
|
||||||
|
const row = getDb().prepare(`SELECT * FROM core_state WHERE id = 1 LIMIT 1`).get()
|
||||||
|
if (!row) {
|
||||||
|
getDb().prepare(`
|
||||||
|
INSERT INTO core_state (id, active_kit, updated_at)
|
||||||
|
VALUES (1, NULL, ?)
|
||||||
|
`).run(nowIso())
|
||||||
|
return getStateRow()
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
const setActiveKit = kitId => {
|
||||||
|
getDb().prepare(`UPDATE core_state SET active_kit = ?, updated_at = ? WHERE id = 1`).run(kitId || null, nowIso())
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializeState = () => {
|
||||||
|
const row = getStateRow()
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
activeKit: row.active_kit || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindRoutes = app => {
|
||||||
|
getDb()
|
||||||
|
|
||||||
|
app.get('/api/mp_time_assistant/meta', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
appName: 'MpTimeAssistant',
|
||||||
|
appNameZh: 'MilesYang时光助手',
|
||||||
|
kits: KIT_META,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/mp_time_assistant/state', (req, res) => {
|
||||||
|
res.json(serializeState())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/mp_time_assistant/state/active-kit', (req, res) => {
|
||||||
|
try {
|
||||||
|
const raw = String(req.body && req.body.kitId || '').trim()
|
||||||
|
const nextKit = raw || null
|
||||||
|
if (nextKit && !KIT_META.find(item => item.id === nextKit)) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'unknown kit id' })
|
||||||
|
}
|
||||||
|
setActiveKit(nextKit)
|
||||||
|
return res.json({ ok: true, activeKit: nextKit })
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
bindPersonalCollectRoutes(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { bindRoutes }
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const multer = require('multer')
|
||||||
|
const store = require('./store')
|
||||||
|
|
||||||
|
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads', 'mp_time_assistant', 'personal_collect')
|
||||||
|
const API_PREFIX = '/api/mp_time_assistant/kits/personal_collect'
|
||||||
|
|
||||||
|
const createId = () => {
|
||||||
|
try { return crypto.randomUUID() } catch { return `${Date.now()}_${Math.random().toString(36).slice(2)}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureDir = dir => {
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
ensureDir(UPLOAD_ROOT)
|
||||||
|
|
||||||
|
const isImageByMime = mime => /^image\//i.test(String(mime || ''))
|
||||||
|
const isImageByName = name => /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(String(name || ''))
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
const groupId = String(req.body && req.body.groupId || createId()).trim()
|
||||||
|
const dir = path.join(UPLOAD_ROOT, groupId)
|
||||||
|
ensureDir(dir)
|
||||||
|
req._uploadGroupId = groupId
|
||||||
|
cb(null, dir)
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname) || ''
|
||||||
|
const storedName = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}${ext}`
|
||||||
|
cb(null, storedName)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseTags = raw => {
|
||||||
|
const s = String(raw || '').trim()
|
||||||
|
if (!s) return []
|
||||||
|
return s
|
||||||
|
.split(/[,,\s]+/)
|
||||||
|
.map(t => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindRoutes = app => {
|
||||||
|
app.get(`${API_PREFIX}/items`, (req, res) => {
|
||||||
|
try {
|
||||||
|
const keyword = String(req.query.keyword || '').trim()
|
||||||
|
res.json({ ok: true, data: store.listItems({ keyword }) })
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get(`${API_PREFIX}/items/:id`, (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = String(req.params.id || '').trim()
|
||||||
|
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
|
||||||
|
const item = store.getItemById(id)
|
||||||
|
if (!item) return res.status(404).json({ ok: false, error: 'not found' })
|
||||||
|
res.json({ ok: true, data: item })
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post(`${API_PREFIX}/items`, (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {}
|
||||||
|
const title = String(body.title || '').trim()
|
||||||
|
const remark = String(body.remark || '').trim()
|
||||||
|
if (!title && !remark) return res.status(400).json({ ok: false, error: '标题或备注至少填一个' })
|
||||||
|
const tags = Array.isArray(body.tags) ? body.tags : parseTags(body.tagsText)
|
||||||
|
const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds : []
|
||||||
|
const saved = store.insertItem({
|
||||||
|
id: createId(),
|
||||||
|
title,
|
||||||
|
remark,
|
||||||
|
tags,
|
||||||
|
attachmentIds,
|
||||||
|
})
|
||||||
|
return res.json({ ok: true, data: saved })
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put(`${API_PREFIX}/items`, (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {}
|
||||||
|
const id = String(body.id || '').trim()
|
||||||
|
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
|
||||||
|
const existing = store.getItemById(id)
|
||||||
|
if (!existing) return res.status(404).json({ ok: false, error: 'not found' })
|
||||||
|
const title = String(body.title || '').trim()
|
||||||
|
const remark = String(body.remark || '').trim()
|
||||||
|
if (!title && !remark) return res.status(400).json({ ok: false, error: '标题或备注至少填一个' })
|
||||||
|
const tags = Array.isArray(body.tags) ? body.tags : parseTags(body.tagsText)
|
||||||
|
const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds : null
|
||||||
|
const saved = store.updateItem({ id, title, remark, tags, attachmentIds })
|
||||||
|
return res.json({ ok: true, data: saved })
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete(`${API_PREFIX}/items`, (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = String(req.query.id || '').trim()
|
||||||
|
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
|
||||||
|
store.deleteItem(id)
|
||||||
|
return res.json({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post(`${API_PREFIX}/upload`, upload.single('file'), (req, res) => {
|
||||||
|
try {
|
||||||
|
const f = req.file
|
||||||
|
if (!f) return res.status(400).json({ ok: false, error: '未接收到文件' })
|
||||||
|
const groupId = String(req._uploadGroupId || createId())
|
||||||
|
const relDir = path.join('uploads', 'mp_time_assistant', 'personal_collect', groupId)
|
||||||
|
const relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
|
||||||
|
const urlPath = '/' + relPath
|
||||||
|
const mimeType = String(f.mimetype || '').trim()
|
||||||
|
const isImage = isImageByMime(mimeType) || isImageByName(f.originalname)
|
||||||
|
const saved = store.insertAttachment({
|
||||||
|
id: createId(),
|
||||||
|
favoriteId: null,
|
||||||
|
fileName: f.originalname,
|
||||||
|
storedName: f.filename,
|
||||||
|
filePath: relPath,
|
||||||
|
fileSize: f.size || 0,
|
||||||
|
mimeType,
|
||||||
|
isImage,
|
||||||
|
})
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
...saved,
|
||||||
|
url: urlPath,
|
||||||
|
groupId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { bindRoutes }
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
const Database = require('better-sqlite3')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const DB_PATH = path.join(process.cwd(), 'data', 'mp_time_assistant_personal_collect.db')
|
||||||
|
|
||||||
|
let db = null
|
||||||
|
|
||||||
|
const nowIso = () => new Date().toISOString()
|
||||||
|
|
||||||
|
const getDb = () => {
|
||||||
|
if (db) return db
|
||||||
|
db = new Database(DB_PATH)
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
remark TEXT NOT NULL DEFAULT '',
|
||||||
|
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0
|
||||||
|
);`)
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
favorite_id TEXT,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
stored_name TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mime_type TEXT NOT NULL DEFAULT '',
|
||||||
|
is_image INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);`)
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_mp_collect_favorites_updated ON favorites(updated_at);`)
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_mp_collect_attachments_favorite ON attachments(favorite_id);`)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeParseJson = (raw, fallback) => {
|
||||||
|
try { return raw ? JSON.parse(raw) : fallback } catch { return fallback }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapFavorite = row => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
remark: row.remark,
|
||||||
|
tags: safeParseJson(row.tags_json, []),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapAttachment = row => ({
|
||||||
|
id: row.id,
|
||||||
|
favoriteId: row.favorite_id,
|
||||||
|
fileName: row.file_name,
|
||||||
|
storedName: row.stored_name,
|
||||||
|
filePath: row.file_path,
|
||||||
|
fileSize: Number(row.file_size || 0),
|
||||||
|
mimeType: row.mime_type,
|
||||||
|
isImage: !!row.is_image,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
const listItems = ({ keyword = '' } = {}) => {
|
||||||
|
const kw = String(keyword || '').trim()
|
||||||
|
const rows = kw
|
||||||
|
? getDb().prepare(`
|
||||||
|
SELECT * FROM favorites
|
||||||
|
WHERE deleted = 0
|
||||||
|
AND (title LIKE @kw OR remark LIKE @kw OR tags_json LIKE @kw)
|
||||||
|
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
|
||||||
|
`).all({ kw: `%${kw}%` })
|
||||||
|
: getDb().prepare(`
|
||||||
|
SELECT * FROM favorites
|
||||||
|
WHERE deleted = 0
|
||||||
|
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
|
||||||
|
`).all()
|
||||||
|
return rows.map(row => {
|
||||||
|
const item = mapFavorite(row)
|
||||||
|
item.attachments = getAttachmentsByFavoriteId(row.id)
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemById = id => {
|
||||||
|
const row = getDb().prepare(`SELECT * FROM favorites WHERE id = ? AND deleted = 0 LIMIT 1`).get(id)
|
||||||
|
if (!row) return null
|
||||||
|
const item = mapFavorite(row)
|
||||||
|
item.attachments = getAttachmentsByFavoriteId(row.id)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertItem = item => {
|
||||||
|
const createdAt = nowIso()
|
||||||
|
const payload = {
|
||||||
|
id: item.id,
|
||||||
|
title: String(item.title || '').trim(),
|
||||||
|
remark: String(item.remark || '').trim(),
|
||||||
|
tags_json: JSON.stringify(Array.isArray(item.tags) ? item.tags : []),
|
||||||
|
created_at: createdAt,
|
||||||
|
updated_at: createdAt,
|
||||||
|
}
|
||||||
|
getDb().prepare(`
|
||||||
|
INSERT INTO favorites (id, title, remark, tags_json, created_at, updated_at, deleted)
|
||||||
|
VALUES (@id, @title, @remark, @tags_json, @created_at, @updated_at, 0)
|
||||||
|
`).run(payload)
|
||||||
|
if (Array.isArray(item.attachmentIds) && item.attachmentIds.length) {
|
||||||
|
bindAttachmentsToFavorite(payload.id, item.attachmentIds)
|
||||||
|
}
|
||||||
|
return getItemById(payload.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateItem = item => {
|
||||||
|
const payload = {
|
||||||
|
id: item.id,
|
||||||
|
title: String(item.title || '').trim(),
|
||||||
|
remark: String(item.remark || '').trim(),
|
||||||
|
tags_json: JSON.stringify(Array.isArray(item.tags) ? item.tags : []),
|
||||||
|
updated_at: nowIso(),
|
||||||
|
}
|
||||||
|
getDb().prepare(`
|
||||||
|
UPDATE favorites
|
||||||
|
SET title = @title,
|
||||||
|
remark = @remark,
|
||||||
|
tags_json = @tags_json,
|
||||||
|
updated_at = @updated_at
|
||||||
|
WHERE id = @id AND deleted = 0
|
||||||
|
`).run(payload)
|
||||||
|
if (Array.isArray(item.attachmentIds)) {
|
||||||
|
getDb().prepare(`DELETE FROM attachments WHERE favorite_id = ?`).run(payload.id)
|
||||||
|
bindAttachmentsToFavorite(payload.id, item.attachmentIds)
|
||||||
|
}
|
||||||
|
return getItemById(payload.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteItem = id => {
|
||||||
|
getDb().prepare(`UPDATE favorites SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertAttachment = ({ id, favoriteId, fileName, storedName, filePath, fileSize, mimeType, isImage }) => {
|
||||||
|
const createdAt = nowIso()
|
||||||
|
const payload = {
|
||||||
|
id,
|
||||||
|
favorite_id: favoriteId || null,
|
||||||
|
file_name: String(fileName || '').trim() || 'untitled',
|
||||||
|
stored_name: String(storedName || '').trim(),
|
||||||
|
file_path: String(filePath || '').trim(),
|
||||||
|
file_size: Math.max(0, Number(fileSize || 0)),
|
||||||
|
mime_type: String(mimeType || '').trim(),
|
||||||
|
is_image: isImage ? 1 : 0,
|
||||||
|
created_at: createdAt,
|
||||||
|
}
|
||||||
|
getDb().prepare(`
|
||||||
|
INSERT INTO attachments (id, favorite_id, file_name, stored_name, file_path, file_size, mime_type, is_image, created_at)
|
||||||
|
VALUES (@id, @favorite_id, @file_name, @stored_name, @file_path, @file_size, @mime_type, @is_image, @created_at)
|
||||||
|
`).run(payload)
|
||||||
|
return getAttachmentById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAttachmentById = id => {
|
||||||
|
const row = getDb().prepare(`SELECT * FROM attachments WHERE id = ? LIMIT 1`).get(id)
|
||||||
|
return row ? mapAttachment(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAttachmentsByFavoriteId = favoriteId => {
|
||||||
|
const rows = getDb().prepare(`SELECT * FROM attachments WHERE favorite_id = ? ORDER BY datetime(created_at) ASC`).all(favoriteId)
|
||||||
|
return rows.map(mapAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindAttachmentsToFavorite = (favoriteId, attachmentIds) => {
|
||||||
|
const stmt = getDb().prepare(`UPDATE attachments SET favorite_id = ? WHERE id = ?`)
|
||||||
|
const tx = getDb().transaction(ids => {
|
||||||
|
for (const aid of ids) {
|
||||||
|
if (aid) stmt.run(favoriteId, String(aid))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tx(attachmentIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listItems,
|
||||||
|
getItemById,
|
||||||
|
insertItem,
|
||||||
|
updateItem,
|
||||||
|
deleteItem,
|
||||||
|
insertAttachment,
|
||||||
|
getAttachmentById,
|
||||||
|
getAttachmentsByFavoriteId,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user