Add word list edit mode with bulk delete and fix word deletion

Show checkboxes and batch actions only after entering edit mode, and delete related quiz records before removing words to avoid foreign key failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-05 10:10:40 +08:00
parent ebd5440d45
commit 2fae90b597
3 changed files with 246 additions and 12 deletions
+5 -1
View File
@@ -4,7 +4,7 @@ from typing import Optional
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models import Word, User from models import QuizRecord, Word, User
def utc_now_iso() -> str: def utc_now_iso() -> str:
@@ -68,6 +68,10 @@ class WordService:
def delete_word(self, db: Session, user: User, word_id: int) -> None: def delete_word(self, db: Session, user: User, word_id: int) -> None:
word = self.get_word(db, user, word_id) word = self.get_word(db, user, word_id)
db.query(QuizRecord).filter(QuizRecord.word_id == word.id).delete(
synchronize_session=False
)
db.flush()
db.delete(word) db.delete(word)
db.commit() db.commit()
+44 -8
View File
@@ -4,10 +4,13 @@ import { formatTrainSeconds } from '../composables/useQuizTimer'
defineProps<{ defineProps<{
word: Word word: Word
selectable?: boolean
selected?: boolean
}>() }>()
defineEmits<{ defineEmits<{
delete: [id: number] delete: [id: number]
'toggle-select': [id: number]
}>() }>()
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
@@ -33,14 +36,22 @@ function formatEnteredAt(iso: string) {
</script> </script>
<template> <template>
<div class="card word-card"> <div class="card word-card" :class="{ 'word-card-selected': selected }">
<div class="word-header"> <label v-if="selectable" class="word-select" @click.stop>
<div> <input
<div class="word-pair">{{ enText(word) }} {{ zhText(word) }}</div> type="checkbox"
<span :class="['badge', `badge-${word.status}`]">{{ statusMap[word.status] || word.status }}</span> :checked="selected"
@change="$emit('toggle-select', word.id)"
/>
</label>
<div class="word-body">
<div class="word-header">
<div>
<div class="word-pair">{{ enText(word) }} {{ zhText(word) }}</div>
<span :class="['badge', `badge-${word.status}`]">{{ statusMap[word.status] || word.status }}</span>
</div>
<button type="button" class="btn btn-danger" @click="$emit('delete', word.id)">删除</button>
</div> </div>
<button class="btn btn-danger" @click="$emit('delete', word.id)">删除</button>
</div>
<div class="word-meta"> <div class="word-meta">
<span>训练 {{ word.train_count ?? 0 }} </span> <span>训练 {{ word.train_count ?? 0 }} </span>
<span>答对 {{ word.correct_count }}</span> <span>答对 {{ word.correct_count }}</span>
@@ -50,11 +61,36 @@ function formatEnteredAt(iso: string) {
<span v-if="word.total_train_seconds">用时 {{ formatTrainSeconds(word.total_train_seconds) }}</span> <span v-if="word.total_train_seconds">用时 {{ formatTrainSeconds(word.total_train_seconds) }}</span>
</div> </div>
<div class="word-entered">进入词库{{ formatEnteredAt(word.entered_at || word.created_at) }}</div> <div class="word-entered">进入词库{{ formatEnteredAt(word.entered_at || word.created_at) }}</div>
<div v-if="word.review_due_date" class="word-due">下次复习{{ word.review_due_date }}</div> <div v-if="word.review_due_date" class="word-due">下次复习{{ word.review_due_date }}</div>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.word-card {
display: flex;
align-items: flex-start;
gap: 10px;
}
.word-card-selected {
border-color: rgba(79, 110, 247, 0.45);
box-shadow: 0 0 0 2px rgba(79, 110, 247, 0.12);
}
.word-select {
flex-shrink: 0;
padding-top: 4px;
cursor: pointer;
}
.word-select input {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
}
.word-body {
flex: 1;
min-width: 0;
}
.word-header { .word-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
+197 -3
View File
@@ -75,6 +75,10 @@ const wordMemory = ref<WordMemoryDetail | null>(null)
const wordMemoryLoading = ref(false) const wordMemoryLoading = ref(false)
const memoryModel = ref<MemoryTransformerPredict | null>(null) const memoryModel = ref<MemoryTransformerPredict | null>(null)
const memoryModelLoading = ref(false) const memoryModelLoading = ref(false)
const listEditMode = ref(false)
const selectedIds = ref<Set<number>>(new Set())
const batchDeleting = ref(false)
const pageSelectRef = ref<HTMLInputElement | null>(null)
const { const {
graphStatusFilter, graphStatusFilter,
@@ -117,6 +121,53 @@ const pageSummary = computed(() => {
return `${currentPage.value} / ${totalPages.value} 页,显示 ${start}${end},共 ${words.value.length}` return `${currentPage.value} / ${totalPages.value} 页,显示 ${start}${end},共 ${words.value.length}`
}) })
const selectedCount = computed(() => selectedIds.value.size)
const allPageSelected = computed(() => {
if (!paginatedWords.value.length) return false
return paginatedWords.value.every((w) => selectedIds.value.has(w.id))
})
const pageSelectIndeterminate = computed(() => {
const pageIds = paginatedWords.value.map((w) => w.id)
const selectedOnPage = pageIds.filter((id) => selectedIds.value.has(id)).length
return selectedOnPage > 0 && selectedOnPage < pageIds.length
})
function clearSelection() {
selectedIds.value = new Set()
}
function enterListEdit() {
listEditMode.value = true
}
function exitListEdit() {
listEditMode.value = false
clearSelection()
}
function toggleWordSelect(id: number) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function togglePageSelect() {
const next = new Set(selectedIds.value)
if (allPageSelected.value) {
paginatedWords.value.forEach((w) => next.delete(w.id))
} else {
paginatedWords.value.forEach((w) => next.add(w.id))
}
selectedIds.value = next
}
function selectAllWords() {
selectedIds.value = new Set(words.value.map((w) => w.id))
}
function clampPage() { function clampPage() {
if (currentPage.value > totalPages.value) { if (currentPage.value > totalPages.value) {
currentPage.value = totalPages.value currentPage.value = totalPages.value
@@ -163,10 +214,33 @@ async function loadViz() {
async function handleDelete(id: number) { async function handleDelete(id: number) {
if (!confirm('确定删除这个单词?')) return if (!confirm('确定删除这个单词?')) return
await api.deleteWord(id) await api.deleteWord(id)
if (selectedIds.value.has(id)) {
const next = new Set(selectedIds.value)
next.delete(id)
selectedIds.value = next
}
await loadWords() await loadWords()
if (activeView.value === 'memory') await loadViz() if (activeView.value === 'memory') await loadViz()
} }
async function handleBatchDelete() {
const ids = [...selectedIds.value]
if (!ids.length) return
if (!confirm(`确定删除选中的 ${ids.length} 个单词?`)) return
batchDeleting.value = true
try {
const results = await Promise.allSettled(ids.map((id) => api.deleteWord(id)))
const failed = results.filter((r) => r.status === 'rejected').length
clearSelection()
await loadWords()
if (failed > 0) {
alert(`${failed} 个单词删除失败,请重试`)
}
} finally {
batchDeleting.value = false
}
}
async function loadWordMemory(wordId: number) { async function loadWordMemory(wordId: number) {
wordMemoryLoading.value = true wordMemoryLoading.value = true
memoryModelLoading.value = true memoryModelLoading.value = true
@@ -252,12 +326,21 @@ watch(selectedWord, (w) => {
watch(activeTab, () => { watch(activeTab, () => {
currentPage.value = 1 currentPage.value = 1
exitListEdit()
if (activeView.value === 'list') loadWords() if (activeView.value === 'list') loadWords()
}) })
watch([allPageSelected, pageSelectIndeterminate], () => {
if (pageSelectRef.value) {
pageSelectRef.value.indeterminate = pageSelectIndeterminate.value
}
})
watch(activeView, (v) => { watch(activeView, (v) => {
if (v === 'list') loadWords() if (v === 'list') {
else { loadWords()
} else {
exitListEdit()
refreshGraphPracticeResults() refreshGraphPracticeResults()
loadViz() loadViz()
} }
@@ -338,8 +421,55 @@ onMounted(() => {
</button> </button>
</Teleport> </Teleport>
<div class="bulk-bar" :class="{ editing: listEditMode }">
<button
v-if="!listEditMode"
type="button"
class="btn btn-outline bulk-edit"
@click="enterListEdit"
>
编辑
</button>
<template v-else>
<label class="bulk-select">
<input
ref="pageSelectRef"
type="checkbox"
:checked="allPageSelected"
@change="togglePageSelect"
/>
全选本页
</label>
<button
v-if="words.length > paginatedWords.length"
type="button"
class="bulk-link"
@click="selectAllWords"
>
全选全部 {{ words.length }}
</button>
<span v-if="selectedCount" class="bulk-count">已选 {{ selectedCount }} </span>
<button
type="button"
class="btn btn-danger bulk-delete"
:disabled="!selectedCount || batchDeleting"
@click="handleBatchDelete"
>
{{ batchDeleting ? '删除中...' : '批量删除' }}
</button>
<button type="button" class="bulk-done" @click="exitListEdit">完成</button>
</template>
</div>
<p class="page-summary">{{ pageSummary }}</p> <p class="page-summary">{{ pageSummary }}</p>
<WordCard v-for="w in paginatedWords" :key="w.id" :word="w" @delete="handleDelete" /> <WordCard
v-for="w in paginatedWords"
:key="w.id"
:word="w"
:selectable="listEditMode"
:selected="selectedIds.has(w.id)"
@delete="handleDelete"
@toggle-select="toggleWordSelect"
/>
<div v-if="totalPages > 1" class="pagination"> <div v-if="totalPages > 1" class="pagination">
<button <button
type="button" type="button"
@@ -556,6 +686,70 @@ onMounted(() => {
border-radius: 6px; border-radius: 6px;
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.bulk-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 12px;
margin-bottom: 12px;
}
.bulk-bar.editing {
padding: 10px 12px;
background: var(--card);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.bulk-edit {
width: auto;
padding: 8px 16px;
font-size: 14px;
margin-left: auto;
}
.bulk-done {
margin-left: auto;
border: none;
background: transparent;
color: var(--primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
padding: 8px 4px;
}
.bulk-select {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.bulk-select input {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
}
.bulk-link {
border: none;
background: transparent;
color: var(--primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.bulk-count {
font-size: 13px;
color: var(--muted);
}
.bulk-delete {
margin-left: auto;
width: auto;
padding: 8px 14px;
font-size: 13px;
}
.page-summary { .page-summary {
font-size: 13px; font-size: 13px;
color: var(--muted); color: var(--muted);