新增: 三实体列表查询维度补全(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

View File

@@ -114,7 +114,7 @@ import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
import { formatRelative } from '@/utils/time'
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
import type { TaskRecord } from '@/api/types'
import type { TaskRecord, TaskQuery } from '@/api/types'
const router = useRouter()
const store = useProjectStore()
@@ -169,14 +169,14 @@ const projectIcons: Record<string, string> = {
'flux': '⚡',
}
// F-260621-02(P1 status 下沉):status 过滤从前端内存 filter 改为构造 query 调后端,
// store.tasks 已是筛选后的结果(后端 WHERE)。filteredGroups 仅做按项目分组(project
// 维度也有后端 query 下沉,这里 groupMap 仅做展示分组,不再二次 filter)。
//
// 注:project 维度同样下沉后端(activeProject watch 构造 query),store.tasks 已按
// project_id 过滤。groupMap 保留分组逻辑(展示按项目聚合),无需再 filter。
const filteredGroups = computed(() => {
let filtered = store.tasks
if (activeProject.value !== 'all') {
filtered = filtered.filter(t => t.project_id === activeProject.value)
}
if (activeStatus.value !== 'all') {
filtered = filtered.filter(t => t.status === activeStatus.value)
}
const filtered = store.tasks
const groupMap = new Map<string, TaskRecord[]>()
for (const task of filtered) {
@@ -200,10 +200,23 @@ const filteredGroups = computed(() => {
// statusLabel / priorityLabel / priorityClass 由 ../constants/project 提供(与 ProjectDetail 文案统一)
// F-260621-02(P1 status 下沉):按当前 activeProject + activeStatus 构造后端 query。
// - 全 all → undefined(全量,等价旧行为)
// - 任一具体值 → query 下沉后端 WHERE(project_id / status)
function buildTaskQuery(): TaskQuery | undefined {
const projAll = activeProject.value === 'all'
const statusAll = activeStatus.value === 'all'
if (projAll && statusAll) return undefined
const query: TaskQuery = {}
if (!projAll) query.project_id = activeProject.value
if (!statusAll) query.status = activeStatus.value
return query
}
async function refresh() {
loading.value = true
try {
await store.loadTasks(activeProject.value === 'all' ? undefined : activeProject.value)
await store.loadTasks(buildTaskQuery())
} finally {
loading.value = false
}
@@ -236,17 +249,27 @@ async function confirmCreate() {
}
}
// B-260615-29: 切换项目筛选时按 projectId 重载(避免仅前端 filter,跨项目视图不同步)
// B-260615-29: 切换项目筛选时按筛选重载(避免仅前端 filter,跨项目视图不同步)
// CR-260615-26(R-1): 同步登记当前筛选到 store,供 AR-11 listener 按筛选拉取
watch(activeProject, (key) => {
store.setActiveTaskProject(key)
// F-260621-02(P1 status 下沉):重载 query 同时含 project_id + status,前端不再 filter status
watch(activeProject, () => {
store.setActiveTaskProject(activeProject.value)
loading.value = true
store.loadTasks(key === 'all' ? undefined : key).finally(() => { loading.value = false })
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
})
// F-260621-02(P1 status 下沉):切换 status 筛选时构造 query 重载(后端 WHERE,非前端 filter),
// 并登记到 store 供 AR-11 listener 复用同一筛选(保 B-29 契约)
watch(activeStatus, (key) => {
store.setActiveTaskStatus(key)
loading.value = true
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
})
onMounted(async () => {
// R-1: 登记初始筛选(all=全量),让数据变更 listener 知道视图已挂载、按全量拉取
store.setActiveTaskProject(activeProject.value)
store.setActiveTaskStatus(activeStatus.value)
loading.value = true
try {
await Promise.all([store.loadProjects(), store.loadTasks()])