新增: 列表分页UI(Paginator组件+三视图接入)
Paginator.vue复用组件(客户端切片,不发新IPC,数据量小前瞻基建) Tasks/Ideas/Projects.vue接store列表切片(pagedProjects/pagedIdeas) 向后兼容铁律:pageSize=0整组件v-if隐藏+切片返全量,等价改造前行为 旧调用方(store.loadTasks等签名)零改动,分页状态纯前端ref 筛选/排序变化page重置1防越界空页;i18n双语pagination键齐备
This commit is contained in:
168
src/components/Paginator.vue
Normal file
168
src/components/Paginator.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<!--
|
||||
通用分页器(F-260621-02 P3 分页 UI 基建)。
|
||||
纯客户端切片分页:父级传 total(总数) + v-model:page(当前页,1 起) +
|
||||
v-model:pageSize(每页条数),本组件只管 UI,不做数据请求(数据量 ~15 前瞻基建)。
|
||||
|
||||
向后兼容铁律:pageSize=0 → 不分页(全量),整组件隐藏,等价改造前行为。
|
||||
用户从下拉选具体 N → 触发分页(切换 page 重置到第 1 页由父级 watch 控制)。
|
||||
-->
|
||||
<div v-if="pageSize > 0 && totalPages > 0" class="paginator">
|
||||
<div class="page-size-group">
|
||||
<span class="paginator-label">{{ $t('common.pagination.perPage') }}</span>
|
||||
<select
|
||||
class="page-size-select"
|
||||
:value="pageSize"
|
||||
@change="onPageSizeChange(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="opt in pageSizeOptions" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
<span class="paginator-total">
|
||||
{{ $t('common.pagination.total', { n: total }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="page-nav">
|
||||
<button
|
||||
class="page-btn"
|
||||
:disabled="page <= 1"
|
||||
@click="goTo(page - 1)"
|
||||
>{{ $t('common.pagination.prev') }}</button>
|
||||
|
||||
<button
|
||||
v-for="p in pageNumbers"
|
||||
:key="p"
|
||||
class="page-btn page-num"
|
||||
:class="{ active: p === page }"
|
||||
@click="goTo(p)"
|
||||
>{{ p }}</button>
|
||||
|
||||
<button
|
||||
class="page-btn"
|
||||
:disabled="page >= totalPages"
|
||||
@click="goTo(page + 1)"
|
||||
>{{ $t('common.pagination.next') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 数据总数(父级传当前过滤后的总数) */
|
||||
total: number
|
||||
/** 每页条数;0 = 不分页(整组件隐藏,等价旧行为) */
|
||||
pageSize: number
|
||||
/** 当前页码(1 起) */
|
||||
page: number
|
||||
/** 每页条数候选(默认 10/20/50) */
|
||||
pageSizeOptions?: number[]
|
||||
}>(), {
|
||||
pageSizeOptions: () => [10, 20, 50],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:page': [page: number]
|
||||
'update:pageSize': [size: number]
|
||||
}>()
|
||||
|
||||
// 总页数:pageSize=0 已被 v-if 拦截,此处保证 pageSize ≥ 1。
|
||||
const totalPages = computed(() => {
|
||||
if (props.pageSize <= 0) return 0
|
||||
return Math.max(1, Math.ceil(props.total / props.pageSize))
|
||||
})
|
||||
|
||||
// 简单页码序列:数据量小,全列(1..totalPages),不做省略窗口(7 页内可读)。
|
||||
// 若数据量未来增长到几十页,再加窗口折叠逻辑。
|
||||
const pageNumbers = computed(() => {
|
||||
const arr: number[] = []
|
||||
for (let i = 1; i <= totalPages.value; i++) arr.push(i)
|
||||
return arr
|
||||
})
|
||||
|
||||
function goTo(p: number) {
|
||||
if (p < 1 || p > totalPages.value) return
|
||||
emit('update:page', p)
|
||||
}
|
||||
|
||||
function onPageSizeChange(val: string) {
|
||||
const n = Number(val)
|
||||
if (!Number.isFinite(n) || n < 0) return
|
||||
emit('update:pageSize', n)
|
||||
// 切换每页条数后页码可能越界,重置到第 1 页(父级也可自行 watch 处理)
|
||||
emit('update:page', 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.paginator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: var(--df-gap-grid, 16px);
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
|
||||
.page-size-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.paginator-label { white-space: nowrap; }
|
||||
|
||||
.page-size-select {
|
||||
padding: 3px 8px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paginator-total { color: var(--df-text-dim); }
|
||||
|
||||
.page-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 28px;
|
||||
padding: 3px 8px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: rgba(108, 99, 255, 0.06);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
.page-btn.active {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.paginator { justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -14,5 +14,12 @@ export default {
|
||||
close: 'Close',
|
||||
loading: 'Loading…',
|
||||
unknownError: 'Unknown error',
|
||||
// Pagination UI (F-260621-02 P3 baseline; off by default, opt-in per-page)
|
||||
pagination: {
|
||||
prev: 'Prev',
|
||||
next: 'Next',
|
||||
perPage: 'Per page',
|
||||
total: '{n} total',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,5 +14,12 @@ export default {
|
||||
close: '关闭',
|
||||
loading: '加载中…',
|
||||
unknownError: '未知错误',
|
||||
// 分页 UI(F-260621-02 P3 基建,默认不分页,用户选每页 N 条才触发)
|
||||
pagination: {
|
||||
prev: '上一页',
|
||||
next: '下一页',
|
||||
perPage: '每页',
|
||||
total: '共 {n} 条',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ $t('ideas.listEmpty') }}</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="idea in filteredIdeas"
|
||||
v-for="idea in pagedIdeas"
|
||||
:key="idea.id"
|
||||
class="idea-card"
|
||||
:class="{ selected: selectedId === idea.id }"
|
||||
@@ -70,6 +70,12 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 分页器(F-260621-02 P3):pageSize=0 不分页(全量,等价旧行为);用户选 N 条触发客户端切片 -->
|
||||
<Paginator
|
||||
v-model:page="page"
|
||||
v-model:pageSize="pageSize"
|
||||
:total="filteredIdeas.length"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- 右侧:灵感详情 -->
|
||||
@@ -132,6 +138,7 @@ import { stripMd } from '../utils/markdown'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import IdeaDetail from '../components/ideas/IdeaDetail.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import Paginator from '../components/Paginator.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -149,6 +156,10 @@ const searchQuery = ref('')
|
||||
// 列表排序:score 高分在前 / time 新在前(默认 score,避免高分灵感被淹没)
|
||||
const sortMode = ref<'score' | 'time'>('score')
|
||||
|
||||
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片
|
||||
const page = ref(1)
|
||||
const pageSize = ref(0)
|
||||
|
||||
// ── 新建灵感模态框 ──
|
||||
const showCaptureModal = ref(false)
|
||||
const newIdeaTitle = ref('')
|
||||
@@ -218,9 +229,9 @@ async function reloadIdeas(immediate = false) {
|
||||
}
|
||||
|
||||
// 筛选/排序变化即时重载;keyword 输入防抖重载
|
||||
watch(activeFilter, () => void reloadIdeas(true))
|
||||
watch(sortMode, () => void reloadIdeas(true))
|
||||
watch(searchQuery, () => void reloadIdeas(false))
|
||||
watch(activeFilter, () => { page.value = 1; void reloadIdeas(true) })
|
||||
watch(sortMode, () => { page.value = 1; void reloadIdeas(true) })
|
||||
watch(searchQuery, () => { page.value = 1; void reloadIdeas(false) })
|
||||
|
||||
const filteredIdeas = computed(() => {
|
||||
let ideas = store.ideas
|
||||
@@ -237,6 +248,13 @@ const filteredIdeas = computed(() => {
|
||||
return ideas
|
||||
})
|
||||
|
||||
// 分页视图(F-260621-02 P3):pageSize=0 → 全量(等价改造前);否则取当前页窗口。
|
||||
const pagedIdeas = computed(() => {
|
||||
if (pageSize.value <= 0) return filteredIdeas.value
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return filteredIdeas.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const currentIdea = computed(() => {
|
||||
if (!selectedId.value) return null
|
||||
return store.ideas.find(i => i.id === selectedId.value) ?? null
|
||||
|
||||
@@ -140,9 +140,16 @@
|
||||
|
||||
<!-- 项目卡片网格(单卡片渲染抽至 components/project/ProjectCard.vue,删除走 emit 回父处理) -->
|
||||
<div class="project-grid">
|
||||
<ProjectCard v-for="project in store.projects" :key="project.id" :project="project" @delete="handleDelete" />
|
||||
<ProjectCard v-for="project in pagedProjects" :key="project.id" :project="project" @delete="handleDelete" />
|
||||
</div>
|
||||
|
||||
<!-- 分页器(F-260621-02 P3):pageSize=0 不分页(全量,等价旧行为);用户选 N 条触发客户端切片 -->
|
||||
<Paginator
|
||||
v-model:page="page"
|
||||
v-model:pageSize="pageSize"
|
||||
:total="store.projects.length"
|
||||
/>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!store.loading && store.projects.length === 0" class="empty-state">
|
||||
{{ $t('projects.empty') }}
|
||||
@@ -165,6 +172,7 @@ import { projectApi } from '@/api'
|
||||
import { formatDate } from '@/utils/time'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ProjectCard from '@/components/project/ProjectCard.vue'
|
||||
import Paginator from '@/components/Paginator.vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import type { ProjectRecord } from '@/api/types'
|
||||
import type { ScannedProjectItem } from '@/api/project'
|
||||
@@ -172,6 +180,17 @@ import type { ScannedProjectItem } from '@/api/project'
|
||||
const store = useProjectStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片
|
||||
const page = ref(1)
|
||||
const pageSize = ref(0)
|
||||
|
||||
// 分页视图:pageSize=0 → 全量;否则取当前页窗口
|
||||
const pagedProjects = computed(() => {
|
||||
if (pageSize.value <= 0) return store.projects
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return store.projects.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
// 状态映射统一走 ../constants/project(与 ProjectDetail/Tasks/Dashboard 一致)
|
||||
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
|
||||
|
||||
|
||||
@@ -77,6 +77,13 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 分页器(F-260621-02 P3):pageSize=0 不分页(全量,等价旧行为);用户选 N 条触发客户端切片 -->
|
||||
<Paginator
|
||||
v-model:page="page"
|
||||
v-model:pageSize="pageSize"
|
||||
:total="store.tasks.length"
|
||||
/>
|
||||
|
||||
<!-- 新建任务模态框 -->
|
||||
<div class="modal-overlay" v-if="showCreateModal" @click.self="showCreateModal = false">
|
||||
<div class="modal-box">
|
||||
@@ -115,6 +122,7 @@ import { useProjectStore } from '@/stores/project'
|
||||
import { formatRelative } from '@/utils/time'
|
||||
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
|
||||
import type { TaskRecord, TaskQuery } from '@/api/types'
|
||||
import Paginator from '../components/Paginator.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useProjectStore()
|
||||
@@ -125,6 +133,10 @@ const activeStatus = ref('all')
|
||||
// Y-2: 本视图加载态(loadTasks 不翻转全局 state.loading,故用局部 ref 精确反映任务加载)
|
||||
const loading = ref(false)
|
||||
|
||||
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片
|
||||
const page = ref(1)
|
||||
const pageSize = ref(0)
|
||||
|
||||
// ── 新建任务模态框 ──
|
||||
const showCreateModal = ref(false)
|
||||
const newTaskProjectId = ref('')
|
||||
@@ -176,7 +188,12 @@ const projectIcons: Record<string, string> = {
|
||||
// 注:project 维度同样下沉后端(activeProject watch 构造 query),store.tasks 已按
|
||||
// project_id 过滤。groupMap 保留分组逻辑(展示按项目聚合),无需再 filter。
|
||||
const filteredGroups = computed(() => {
|
||||
const filtered = store.tasks
|
||||
// 分页(F-260621-02):pageSize=0 → 全量切片(等价改造前);否则取当前页窗口。
|
||||
// 先对扁平 store.tasks 切片,再分组(保持按项目聚合语义,分组在页窗口内)。
|
||||
const all = store.tasks
|
||||
const filtered = pageSize.value > 0
|
||||
? all.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)
|
||||
: all
|
||||
|
||||
const groupMap = new Map<string, TaskRecord[]>()
|
||||
for (const task of filtered) {
|
||||
@@ -254,6 +271,7 @@ async function confirmCreate() {
|
||||
// F-260621-02(P1 status 下沉):重载 query 同时含 project_id + status,前端不再 filter status
|
||||
watch(activeProject, () => {
|
||||
store.setActiveTaskProject(activeProject.value)
|
||||
page.value = 1
|
||||
loading.value = true
|
||||
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
|
||||
})
|
||||
@@ -262,6 +280,7 @@ watch(activeProject, () => {
|
||||
// 并登记到 store 供 AR-11 listener 复用同一筛选(保 B-29 契约)
|
||||
watch(activeStatus, (key) => {
|
||||
store.setActiveTaskStatus(key)
|
||||
page.value = 1
|
||||
loading.value = true
|
||||
store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user