feat(todos): 修改 todos 表结构,迁移至复合主键并添加索引

This commit is contained in:
oudecheng 2026-06-16 16:17:12 +08:00
parent 66e40fc714
commit edc1a50d1c
2 changed files with 63 additions and 8 deletions

View File

@ -1493,7 +1493,7 @@ impl SessionStore {
// Insert new todos
for item in items {
conn.execute(
"INSERT INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at)
"INSERT OR REPLACE INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
item.id,
@ -1904,7 +1904,7 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
id TEXT NOT NULL,
scope_key TEXT NOT NULL,
session_id TEXT NOT NULL,
topic_id TEXT,
@ -1912,7 +1912,8 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
status TEXT NOT NULL DEFAULT 'pending',
priority TEXT NOT NULL DEFAULT 'medium',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
updated_at INTEGER NOT NULL,
PRIMARY KEY (id, scope_key)
);
CREATE INDEX IF NOT EXISTS idx_todos_scope
@ -1922,6 +1923,55 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
ON todos(session_id);
",
)?;
return Ok(());
}
// Migration: check if old schema has single-column PRIMARY KEY on `id`
// If so, migrate to composite PRIMARY KEY (id, scope_key)
let sql: String = conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='todos'",
[],
|row| row.get::<_, String>(0),
)
.unwrap_or_default();
let needs_migration = sql.contains("id TEXT PRIMARY KEY")
|| (sql.contains("PRIMARY KEY") && !sql.contains("PRIMARY KEY (id, scope_key)"));
if needs_migration {
tracing::info!("Migrating todos table to composite PRIMARY KEY (id, scope_key)");
conn.execute_batch(
"
CREATE TABLE todos_new (
id TEXT NOT NULL,
scope_key TEXT NOT NULL,
session_id TEXT NOT NULL,
topic_id TEXT,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
priority TEXT NOT NULL DEFAULT 'medium',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (id, scope_key)
);
INSERT OR IGNORE INTO todos_new
SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at
FROM todos;
DROP TABLE todos;
ALTER TABLE todos_new RENAME TO todos;
CREATE INDEX IF NOT EXISTS idx_todos_scope
ON todos(scope_key, created_at ASC);
CREATE INDEX IF NOT EXISTS idx_todos_session
ON todos(session_id);
",
)?;
tracing::info!("Todos table migration complete");
}
Ok(())

View File

@ -165,6 +165,11 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
const [toast, setToast] = useState('')
const [dirty, setDirty] = useState(false)
const handleClose = useCallback(() => {
if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return
onClose()
}, [dirty, onClose])
// Load config
useEffect(() => {
fetch('/api/config').then(r => r.json()).then(data => {
@ -175,10 +180,10 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
// ESC to close
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
document.addEventListener('keydown', h)
return () => document.removeEventListener('keydown', h)
}, [onClose])
}, [handleClose])
const update = useCallback(<K extends keyof AppConfig>(key: K, value: AppConfig[K]) => {
setConfig(prev => prev ? { ...prev, [key]: value } : prev)
@ -589,7 +594,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]" onClick={onClose}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]" onClick={handleClose}>
<div
className="relative flex flex-col w-[92vw] max-w-4xl h-[85vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-primary)] shadow-2xl overflow-hidden animate-[scaleIn_0.2s_ease-out]"
onClick={e => e.stopPropagation()}
@ -600,7 +605,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
<span className="text-lg font-semibold text-[var(--text-primary)]"></span>
{dirty && <span className="text-xs text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full"></span>}
<div className="flex-1" />
<button onClick={onClose} className="p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors" title="关闭 (Esc)">
<button onClick={handleClose} className="p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors" title="关闭 (Esc)">
<X className="h-5 w-5" />
</button>
</div>
@ -640,7 +645,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md flex items-center gap-3">
{error && <span className="text-sm text-red-400 truncate max-w-xs">{error}</span>}
<div className="flex-1" />
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors">
<button onClick={handleClose} className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors">
</button>
<button