新增: 三实体列表查询维度补全(status/关键词/排序/分页下沉后端)

任务/项目/灵感三实体新增 list_by_query 动态 WHERE(累积式 where_clauses
+params_vec 收口)+ order_by 白名单防注入 + limit/offset 钳制。命令层
list_{tasks,projects,ideas} 吃 Option<XxxQuery> 双参向后兼容(旧无参/单参
路径等价全量)。前端 Tasks/Ideas status/keyword 筛选下沉后端 query。

F-260621-02
This commit is contained in:
2026-06-22 01:03:37 +08:00
parent 72e57c6232
commit c9b6e28433
18 changed files with 878 additions and 76 deletions

View File

@@ -126,8 +126,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import { useProjectStore } from '../stores/project'
import type { IdeaStatus } from '../api/types'
import { parseTags } from '../stores/knowledge'
import type { IdeaStatus, IdeaQuery } from '../api/types'
import { formatDate } from '../utils/time'
import { stripMd } from '../utils/markdown'
import ConfirmDialog from '../components/ConfirmDialog.vue'
@@ -181,38 +180,61 @@ function statusLabelKey(status: IdeaStatus): string {
return statusOptions.find(s => s.value === status)?.labelKey ?? status
}
// ── 后端查询构造(F-260621-02:status/keyword/order_by 下沉后端 WHERE) ──
//
// status 下沉:单一状态(all→不限 / promoted→promoted)走后端精确匹配;
// hot(按 score≥80)与 pending(多状态 draft+pending_review)无法映射单一后端 status,
// 仍在前端 filter(轻量,数据量小;后端 status 仅单值,扩多值属过度设计)。
// keyword/title+description LIKE、order_by(排序)全下沉后端,避免前端全表扫描/重排。
function buildIdeaQuery(): IdeaQuery {
const q: IdeaQuery = {}
// 排序下沉后端(score/time)
q.order_by = sortMode.value === 'score' ? 'score' : 'created_at'
// keyword 下沉后端(title/description LIKE)
if (searchQuery.value.trim()) {
q.keyword = searchQuery.value.trim()
}
// 单一状态映射:promoted → status=promoted;all/hot/pending 不走单一 status
if (activeFilter.value === 'promoted') {
q.status = 'promoted'
}
return q
}
// 按当前筛选/搜索/排序触发后端加载(防抖仅对 keyword 输入,筛选/排序即时)。
// 让 store.loading 驱动「加载中」空态显示(对齐 Tasks.vue 三态)。
let keywordDebounce: ReturnType<typeof setTimeout> | null = null
async function reloadIdeas(immediate = false) {
if (keywordDebounce) {
clearTimeout(keywordDebounce)
keywordDebounce = null
}
const run = () => store.loadIdeas(buildIdeaQuery())
if (immediate) {
await run()
} else {
keywordDebounce = setTimeout(() => { void run() }, 300)
}
}
// 筛选/排序变化即时重载;keyword 输入防抖重载
watch(activeFilter, () => void reloadIdeas(true))
watch(sortMode, () => void reloadIdeas(true))
watch(searchQuery, () => void reloadIdeas(false))
const filteredIdeas = computed(() => {
let ideas = store.ideas
// 应用状态过滤
// hot(按 score≥80)/pending(多状态 draft+pending_review)保留前端 filter:
// 后端 status 仅单值,无法表达 OR,扩多值属过度设计;数据量小,前端 filter 轻量。
switch (activeFilter.value) {
case 'hot': ideas = ideas.filter(i => (i.score ?? 0) >= 80); break
case 'pending': ideas = ideas.filter(i => i.status === 'pending_review' || i.status === 'draft'); break
case 'promoted': ideas = ideas.filter(i => i.status === 'promoted'); break
default: break // 不过滤
default: break // all / promoted:后端已过滤,前端不再 filter
}
// 应用搜索过滤
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase().trim()
ideas = ideas.filter(i =>
i.title.toLowerCase().includes(query) ||
(i.description && i.description.toLowerCase().includes(query)) ||
parseTags(i.tags).some((tag: string) => tag.toLowerCase().includes(query))
)
}
// 排序:在过滤+搜索之后、return 之前对最终数组排序
// [...ideas] 浅拷贝避免改原数组(store.ideas)
const sorted = [...ideas].sort((a, b) => {
if (sortMode.value === 'score') {
return (b.score ?? 0) - (a.score ?? 0) // 高分在前
}
// time:created_at 为毫秒字符串,字符串比较即可(同位数字典序等价数值序)
return (b.created_at ?? '').localeCompare(a.created_at ?? '') // 新在前
})
return sorted
// 排序与 keyword 已下沉后端(buildIdeaQuery),前端不再重排/重过滤
return ideas
})
const currentIdea = computed(() => {
@@ -279,7 +301,7 @@ async function promoteToProject() {
}
async function refresh() {
await store.loadIdeas()
await store.loadIdeas(buildIdeaQuery())
}
const evaluating = ref(false)
@@ -318,7 +340,7 @@ async function onUpdateRelated(ids: string[]) {
}
onMounted(async () => {
await store.loadIdeas()
await store.loadIdeas(buildIdeaQuery())
// 从路由参数恢复选中灵感(修复 /ideas/:id 直接访问空白页 B-260615-36
if (route.params.id) {
const id = route.params.id as string