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 sqlalchemy.orm import Session
from models import Word, User
from models import QuizRecord, Word, User
def utc_now_iso() -> str:
@@ -68,6 +68,10 @@ class WordService:
def delete_word(self, db: Session, user: User, word_id: int) -> None:
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.commit()
+44 -8
View File
@@ -4,10 +4,13 @@ import { formatTrainSeconds } from '../composables/useQuizTimer'
defineProps<{
word: Word
selectable?: boolean
selected?: boolean
}>()
defineEmits<{
delete: [id: number]
'toggle-select': [id: number]
}>()
const statusMap: Record<string, string> = {
@@ -33,14 +36,22 @@ function formatEnteredAt(iso: string) {
</script>
<template>
<div class="card word-card">
<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 class="card word-card" :class="{ 'word-card-selected': selected }">
<label v-if="selectable" class="word-select" @click.stop>
<input
type="checkbox"
: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>
<button class="btn btn-danger" @click="$emit('delete', word.id)">删除</button>
</div>
<div class="word-meta">
<span>训练 {{ word.train_count ?? 0 }} </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>
</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>
</template>
<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 {
display: flex;
justify-content: space-between;
+197 -3
View File
@@ -75,6 +75,10 @@ const wordMemory = ref<WordMemoryDetail | null>(null)
const wordMemoryLoading = ref(false)
const memoryModel = ref<MemoryTransformerPredict | null>(null)
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 {
graphStatusFilter,
@@ -117,6 +121,53 @@ const pageSummary = computed(() => {
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() {
if (currentPage.value > totalPages.value) {
currentPage.value = totalPages.value
@@ -163,10 +214,33 @@ async function loadViz() {
async function handleDelete(id: number) {
if (!confirm('确定删除这个单词?')) return
await api.deleteWord(id)
if (selectedIds.value.has(id)) {
const next = new Set(selectedIds.value)
next.delete(id)
selectedIds.value = next
}
await loadWords()
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) {
wordMemoryLoading.value = true
memoryModelLoading.value = true
@@ -252,12 +326,21 @@ watch(selectedWord, (w) => {
watch(activeTab, () => {
currentPage.value = 1
exitListEdit()
if (activeView.value === 'list') loadWords()
})
watch([allPageSelected, pageSelectIndeterminate], () => {
if (pageSelectRef.value) {
pageSelectRef.value.indeterminate = pageSelectIndeterminate.value
}
})
watch(activeView, (v) => {
if (v === 'list') loadWords()
else {
if (v === 'list') {
loadWords()
} else {
exitListEdit()
refreshGraphPracticeResults()
loadViz()
}
@@ -338,8 +421,55 @@ onMounted(() => {
</button>
</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>
<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">
<button
type="button"
@@ -556,6 +686,70 @@ onMounted(() => {
border-radius: 6px;
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 {
font-size: 13px;
color: var(--muted);