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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user