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

@@ -1,10 +1,21 @@
import { invoke } from '@tauri-apps/api/core'
import type { IdeaRecord, CreateIdeaInput, PromotionResult, IdeaEvaluationRecord } from './types'
import type { IdeaRecord, CreateIdeaInput, PromotionResult, IdeaEvaluationRecord, IdeaQuery } from './types'
export const ideaApi = {
/** 列出灵感,可选按 status 过滤draft/pending_review/promoted 等) */
list(status?: string): Promise<IdeaRecord[]> {
return invoke('list_ideas', { status: status ?? null })
/**
* 列出灵感。
*
* **双签名向后兼容**(F-260621-02):
* - `list(status?)`:旧签名,仅状态过滤(等价 `list({ status })`)。
* - `list(query?)`:新签名,多条件(status/keyword/order_by/limit/offset)走后端 WHERE。
*
* 参数为空 → 后端返回全量(created_at DESC,与旧 list_all 等价)。
*/
list(queryOrStatus?: IdeaQuery | string): Promise<IdeaRecord[]> {
if (typeof queryOrStatus === 'string') {
return invoke('list_ideas', { status: queryOrStatus ?? null, query: null })
}
return invoke('list_ideas', { status: null, query: queryOrStatus ?? null })
},
create(input: CreateIdeaInput): Promise<IdeaRecord> {

View File

@@ -46,9 +46,29 @@ export interface ImportBatchResult {
items: ImportBatchItemResult[]
}
/**
* 项目列表查询条件(F-260621-02 P2/P3)。
* 全可选;不传(或全 undefined)= 当前全量行为(deleted_at IS NULL + created_at DESC)。
* 对齐后端 df_storage::crud::ProjectQuery。
*/
export interface ProjectQuery {
/** 关键词:name/description LIKE %kw%(trim 后空字符串视为无关键词) */
keyword?: string
/** 排序:格式 "col" / "col asc" / "col desc",默认 created_at desc(列名白名单) */
order_by?: string
/** 返回上限(后端钳制 [0,200],undefined = 不加 LIMIT 全量) */
limit?: number
/** 偏移量(undefined = 0,配合 limit 分页) */
offset?: number
}
export const projectApi = {
list(): Promise<ProjectRecord[]> {
return invoke('list_projects')
/**
* 列出未删除项目。
* F-260621-02:可选 query(关键词/排序/分页)。不传 = 当前全量行为(向后兼容)。
*/
list(query?: ProjectQuery): Promise<ProjectRecord[]> {
return invoke('list_projects', { query })
},
create(input: CreateProjectInput): Promise<ProjectRecord> {

View File

@@ -1,9 +1,21 @@
import { invoke } from '@tauri-apps/api/core'
import type { TaskRecord, CreateTaskInput } from './types'
import type { TaskRecord, CreateTaskInput, TaskQuery } from './types'
export const taskApi = {
list(projectId?: string): Promise<TaskRecord[]> {
return invoke('list_tasks', { projectId: projectId ?? null })
/**
* 列出任务。
*
* **双签名向后兼容**(F-260621-02):
* - `list(projectId?)`:旧签名,仅项目过滤(等价 `list({ project_id: projectId })`)。
* - `list(query?)`:新签名,多条件(status/keyword/project_id/priority/assignee/...)走后端 WHERE。
*
* 参数为空 → 后端返回全量未删任务(created_at DESC,等价改造前 list_active)。
*/
list(queryOrProjectId?: TaskQuery | string): Promise<TaskRecord[]> {
if (typeof queryOrProjectId === 'string') {
return invoke('list_tasks', { projectId: queryOrProjectId ?? null, query: null })
}
return invoke('list_tasks', { projectId: null, query: queryOrProjectId ?? null })
},
get(id: string): Promise<TaskRecord> {

View File

@@ -34,6 +34,23 @@ export interface CreateIdeaInput {
source?: string
}
/**
* 灵感多条件查询入参(F-260621-02,对齐后端 IdeaQuery)。
*
* 所有字段可选;全空 → 后端返回全量(created_at DESC)。
* - `status`:状态精确匹配(后端 WHERE,取代前端 filter)
* - `keyword`:title/description LIKE %kw%(后端 LIKE,取代前端 filter)
* - `order_by`:白名单 created_at/updated_at/priority/status/score(后端白名单校验防注入)
* - `limit`/`offset`:分页(limit 钳制上限 200)
*/
export interface IdeaQuery {
status?: string | null
keyword?: string | null
order_by?: string | null
limit?: number | null
offset?: number | null
}
/**
* 灵感评估历史记录(后端 list_idea_evaluations 返回)。
* 每次评估(启发式/LLM/降级)落一行,version 递增,按 version DESC 返回。
@@ -152,6 +169,32 @@ export interface CreateTaskInput {
idea_id?: string
}
/**
* 任务多条件查询入参(F-260621-02,对齐后端 TaskQuery)。
*
* 所有字段可选;全空 → 后端返回全量未删任务(created_at DESC,等价改造前 list_active)。
* - `project_id`:项目精确匹配(命中 idx_tasks_project_id)
* - `status`:状态精确匹配(P1 下沉,命中 idx_tasks_status,取代前端 filter)
* - `priority`:优先级精确匹配(0=critical..3=low)
* - `assignee`:负责人精确匹配
* - `keyword`:title/description LIKE %kw%(P2,取代前端 filter)
* - `order_by`:白名单 created_at/updated_at/priority/status(后端白名单校验防注入),恒 DESC
* - `limit`/`offset`:分页(limit 钳制上限 500)
*
* 注:project_id/priority/assignee/order_by/limit/offset 为基建就绪,当前视图仅用
* status(P1)+keyword(P2);P3 排序分页 UI 待数据量增长。
*/
export interface TaskQuery {
project_id?: string | null
status?: string | null
priority?: number | null
assignee?: string | null
keyword?: string | null
order_by?: string | null
limit?: number | null
offset?: number | null
}
// ============================================================
// 工作流
// ============================================================

View File

@@ -5,7 +5,7 @@ import { createTasksStore } from './project/tasks'
import { createIdeasStore } from './project/ideas'
import { createWorkflowStore } from './project/workflow'
import { state, clearError } from './project/state'
import type { DfDataChangedPayload } from '@/api/types'
import type { DfDataChangedPayload, TaskQuery } from '@/api/types'
// ── barrel 组合器(零行为变更,纯重构) ──
//
@@ -34,10 +34,26 @@ function createStore() {
// - 'all' : 全量拉取
// - 具体 id : 按项目拉取
// 由 Tasks.vue 经 setActiveTaskProject 在 onMounted/watch 更新本变量。
//
// F-260621-02(P1 status 下沉):新增 _activeTaskStatus 追踪当前 status 筛选,数据变更
// 联动刷新时一并下沉到后端 query(保 B-29「store.tasks 反映当前筛选」契约,
// 避免刷新把已选 status 冲掉)。
let _activeTaskProject: string | undefined = undefined
let _activeTaskStatus: string | undefined = undefined
function setActiveTaskProject(projectKey: string | undefined) {
_activeTaskProject = projectKey
}
function setActiveTaskStatus(statusKey: string | undefined) {
_activeTaskStatus = statusKey
}
// 构造当前 task 视图筛选对应的 query(供数据变更 listener 复用同一筛选拉取)
function buildActiveTaskQuery(): TaskQuery | undefined {
if (_activeTaskProject === undefined) return undefined // 视图未挂载,不触发
const query: TaskQuery = {}
if (_activeTaskProject !== 'all') query.project_id = _activeTaskProject
if (_activeTaskStatus && _activeTaskStatus !== 'all') query.status = _activeTaskStatus
return query
}
let _dataChangedUnlisten: (() => void) | null = null
@@ -49,9 +65,11 @@ function createStore() {
if (entity === 'project') {
void projectsStore.loadProjects()
} else if (entity === 'task') {
// 无筛选状态(undefined)时不触发,保 B-29 筛选契约;有筛选则按当前 activeProject 拉
if (_activeTaskProject !== undefined) {
void tasksStore.loadTasks(_activeTaskProject === 'all' ? undefined : _activeTaskProject)
// 无筛选状态(_activeTaskProject undefined)时不触发,保 B-29 筛选契约;
// 有筛选则按当前 activeProject + activeStatus 构造 query 下沉后端拉取
const q = buildActiveTaskQuery()
if (q !== undefined) {
void tasksStore.loadTasks(q)
}
} else if (entity === 'idea') {
void ideasStore.loadIdeas()
@@ -126,6 +144,8 @@ function createStore() {
stopDataChangedListener,
// CR-260615-26(R-1): Tasks 视图登记当前 activeProject 筛选,供 listener 按筛选拉取
setActiveTaskProject,
// F-260621-02(P1 status 下沉):Tasks 视图登记当前 status 筛选,供 listener 复用同一筛选拉取
setActiveTaskStatus,
pendingApproval: computed(() => state.pendingApproval),
// computed
stats,

View File

@@ -1,12 +1,20 @@
import { ideaApi } from '@/api'
import type { IdeaQuery } from '@/api/types'
import { t } from '@/i18n/i18n-helpers'
import { state } from './state'
/** 灵感 CRUD 子 store(共享全局 state) */
export function createIdeasStore() {
async function loadIdeas() {
/**
* 加载灵感列表。
*
* F-260621-02:status/keyword/order_by 下沉后端 WHERE,取代前端 computed filter。
* - 传 `query` → 多条件查询(状态/关键词/排序/分页)。
* - 不传 → 全量(created_at DESC,等价旧行为)。
*/
async function loadIdeas(query?: IdeaQuery) {
try {
state.ideas = await ideaApi.list()
state.ideas = await ideaApi.list(query)
} catch (e: any) {
state.error = e?.toString() ?? t('ideas.err.loadFailed')
}

View File

@@ -1,15 +1,19 @@
import { projectApi } from '@/api'
import type { ImportProjectInput } from '@/api/project'
import type { ImportProjectInput, ProjectQuery } from '@/api/project'
import { t } from '@/i18n/i18n-helpers'
import { state, clearError } from './state'
/** 项目 CRUD 子 store(共享全局 state) */
export function createProjectsStore() {
async function loadProjects() {
/**
* 加载未删除项目。
* F-260621-02:可选 query 透传(关键词/排序/分页)。不传 = 当前全量行为(向后兼容)。
*/
async function loadProjects(query?: ProjectQuery) {
state.loading = true
state.error = null
try {
state.projects = await projectApi.list()
state.projects = await projectApi.list(query)
} catch (e: any) {
state.error = e?.toString() ?? t('projects.err.loadFailed')
} finally {

View File

@@ -1,12 +1,23 @@
import { taskApi } from '@/api'
import type { TaskQuery } from '@/api/types'
import { t } from '@/i18n/i18n-helpers'
import { state } from './state'
/** 任务 CRUD 子 store(共享全局 state) */
export function createTasksStore() {
async function loadTasks(projectId?: string) {
/**
* 加载任务列表。
*
* **双签名向后兼容**(F-260621-02):
* - `loadTasks(projectId?)`:旧签名,仅项目过滤(等价 `loadTasks({ project_id: projectId })`)。
* - `loadTasks(query?)`:新签名,多条件(status/keyword/...)走后端 WHERE。
*
* 不传 → 全量未删任务(created_at DESC,等价改造前 list_active)。
* status/keyword 下沉后端 WHERE(P1/P2),取代 Tasks.vue 旧的前端内存 filter。
*/
async function loadTasks(queryOrProjectId?: TaskQuery | string) {
try {
state.tasks = await taskApi.list(projectId)
state.tasks = await taskApi.list(queryOrProjectId)
} catch (e: any) {
state.error = e?.toString() ?? t('tasks.err.loadFailed')
}

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()])