Files
DevFlow/src/views/Ideas.vue
绝尘 83cee61476 新增: 列表分页UI(Paginator组件+三视图接入)
Paginator.vue复用组件(客户端切片,不发新IPC,数据量小前瞻基建)
Tasks/Ideas/Projects.vue接store列表切片(pagedProjects/pagedIdeas)
向后兼容铁律:pageSize=0整组件v-if隐藏+切片返全量,等价改造前行为
旧调用方(store.loadTasks等签名)零改动,分页状态纯前端ref
筛选/排序变化page重置1防越界空页;i18n双语pagination键齐备
2026-06-22 01:24:50 +08:00

573 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="ideas">
<header class="page-header">
<h1>{{ $t('ideas.title') }}</h1>
<div class="header-actions">
<button class="btn btn-ghost" @click="refresh">{{ $t('ideas.refresh') }}</button>
<button class="btn btn-primary" @click="openCaptureModal">{{ $t('ideas.capture') }}</button>
</div>
</header>
<!-- 搜索和筛选栏 -->
<div class="filter-bar">
<div class="search-box">
<input
v-model="searchQuery"
type="text"
:placeholder="$t('ideas.searchPlaceholder')"
/>
</div>
<button
v-for="f in filters"
:key="f.key"
class="filter-btn"
:class="{ active: activeFilter === f.key }"
@click="activeFilter = f.key"
>
{{ f.icon }} {{ $t(f.labelKey) }}
</button>
<!-- 排序切换:score 高分在前 / time 新在前 -->
<div class="sort-group">
<button
class="filter-btn"
:class="{ active: sortMode === 'score' }"
@click="sortMode = 'score'"
>{{ $t('ideas.sortByScore') }}</button>
<button
class="filter-btn"
:class="{ active: sortMode === 'time' }"
@click="sortMode = 'time'"
>{{ $t('ideas.sortByTime') }}</button>
</div>
</div>
<!-- 两栏布局 -->
<div class="ideas-layout">
<!-- 左侧灵感列表 -->
<section class="idea-list-panel">
<div class="idea-list">
<!-- 三态:加载/错误/(对齐 Tasks.vue:43-45) -->
<div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div>
<div v-else-if="store.error" class="empty-state">{{ store.error }}</div>
<div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ $t('ideas.listEmpty') }}</div>
<template v-else>
<div
v-for="idea in pagedIdeas"
:key="idea.id"
class="idea-card"
:class="{ selected: selectedId === idea.id }"
@click="selectedId = idea.id"
>
<div class="idea-card-header">
<span class="idea-title">{{ idea.title }}</span>
<span class="idea-score" :class="scoreClass(idea.score)">{{ idea.score ?? '-' }}</span>
</div>
<p class="idea-desc-preview">{{ stripMd(idea.description).slice(0, 60) }}{{ stripMd(idea.description).length > 60 ? '...' : '' }}</p>
<div class="idea-card-footer">
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
</div>
</div>
</template>
</div>
<!-- 分页器(F-260621-02 P3):pageSize=0 不分页(全量,等价旧行为);用户选 N 条触发客户端切片 -->
<Paginator
v-model:page="page"
v-model:pageSize="pageSize"
:total="filteredIdeas.length"
/>
</section>
<!-- 右侧灵感详情 -->
<IdeaDetail
v-if="currentIdea"
:idea="currentIdea"
:evaluating="evaluating"
:eval-error="evalError"
:promoting="promoting"
:deleting="deleting"
:status-options="statusOptions"
@evaluate="evaluateCurrentIdea"
@promote="promoteToProject"
@delete="deleteCurrentIdea"
@status-change="onStatusChange"
@update-desc="onUpdateDesc"
@update-related="onUpdateRelated"
/>
<!-- 未选择 -->
<section class="idea-detail-panel idea-empty" v-else>
<div class="empty-state">
<div class="empty-icon">💡</div>
<p>{{ $t('ideas.emptyState') }}</p>
</div>
</section>
</div>
<!-- 捕捉灵感模态框 -->
<div class="modal-overlay" v-if="showCaptureModal" @click.self="showCaptureModal = false">
<div class="modal-box">
<h3>{{ $t('ideas.captureTitle') }}</h3>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldTitle') }}</label>
<input v-model="newIdeaTitle" :placeholder="$t('ideas.titlePlaceholder')" @keyup.enter="confirmCapture" />
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldDesc') }}</label>
<textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea>
<div class="modal-actions">
<button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
<button class="btn-confirm" :disabled="creating" @click="confirmCapture">
{{ creating ? $t('ideas.creating') : $t('common.confirm') }}
</button>
</div>
</div>
</div>
<!-- 确认弹层删除灵感替代原生 window.confirm -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
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, IdeaQuery } from '../api/types'
import { formatDate } from '../utils/time'
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()
const route = useRoute()
const store = useProjectStore()
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
const activeFilter = ref<FilterKey>('all')
const selectedId = ref<string | null>(null)
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('')
const newIdeaDesc = ref('')
// ── 异步按钮禁用态(防双击重复提交,对齐 ProviderPanel.vue saving ref) ──
const creating = ref(false)
const deleting = ref(false)
const promoting = ref(false)
const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
{ key: 'all', labelKey: 'ideas.filter.all', icon: '📋' },
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
{ key: 'pending', labelKey: 'ideas.filter.pending', icon: '⏳' },
{ key: 'promoted', labelKey: 'ideas.filter.promoted', icon: '🚀' },
]
// ── 状态映射(单一数据源,列表/详情/下拉共用) ──
const statusOptions: { value: IdeaStatus; labelKey: string }[] = [
{ value: 'draft', labelKey: 'ideas.status.draft' },
{ value: 'pending_review', labelKey: 'ideas.status.pending_review' },
{ value: 'approved', labelKey: 'ideas.status.approved' },
{ value: 'promoted', labelKey: 'ideas.status.promoted' },
{ value: 'rejected', labelKey: 'ideas.status.rejected' },
]
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
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, () => { 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
// 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
default: break // all / promoted:后端已过滤,前端不再 filter
}
// 排序与 keyword 已下沉后端(buildIdeaQuery),前端不再重排/重过滤。
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
})
// B-260615-25:灵感描述 Markdown 渲染已下沉至 IdeaDetail 子组件(共享模块单例渲染器)
function scoreClass(score: number | null) {
const s = score ?? 0
if (s >= 80) return 'score-high'
if (s >= 60) return 'score-mid'
return 'score-low'
}
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date
function openCaptureModal() {
newIdeaTitle.value = ''
newIdeaDesc.value = ''
showCaptureModal.value = true
}
async function confirmCapture() {
if (!newIdeaTitle.value.trim() || creating.value) return
creating.value = true
try {
await store.createIdea({
title: newIdeaTitle.value.trim(),
description: newIdeaDesc.value.trim() || undefined,
})
showCaptureModal.value = false
} finally {
creating.value = false
}
}
async function deleteCurrentIdea() {
if (!currentIdea.value || deleting.value) return
if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
deleting.value = true
try {
await store.deleteIdea(currentIdea.value.id)
selectedId.value = null
} finally {
deleting.value = false
}
}
async function promoteToProject() {
if (!currentIdea.value || promoting.value) return
promoting.value = true
try {
const res = await store.promoteIdea(currentIdea.value.id)
router.push(`/projects/${res.project_id}`)
} catch (e: any) {
const msg = e?.toString() ?? t('ideas.promoteFailed')
console.error(t('ideas.promoteFailed'), e)
Message.error(msg)
} finally {
promoting.value = false
}
}
async function refresh() {
await store.loadIdeas(buildIdeaQuery())
}
const evaluating = ref(false)
const evalError = ref('')
async function evaluateCurrentIdea() {
if (!currentIdea.value || evaluating.value) return
evaluating.value = true
evalError.value = ''
try {
await store.evaluateIdea(currentIdea.value.id)
} catch (e: any) {
evalError.value = e?.toString() ?? t('ideas.evalFailed')
console.error(t('ideas.evalFailed'), e)
} finally {
evaluating.value = false
}
}
async function onStatusChange(newStatus: IdeaStatus) {
if (!currentIdea.value) return
await store.updateIdea(currentIdea.value.id, 'status', newStatus)
}
// 接收 IdeaDetail 子组件 emit 的 'update-desc'(描述编辑保存)
async function onUpdateDesc(desc: string) {
if (!currentIdea.value) return
await store.updateIdea(currentIdea.value.id, 'description', desc)
}
// 接收 IdeaDetail 子组件 emit 的 'update-related'(关联灵感编辑保存)
async function onUpdateRelated(ids: string[]) {
if (!currentIdea.value) return
await store.updateIdea(currentIdea.value.id, 'related_ids', JSON.stringify(ids))
}
onMounted(async () => {
await store.loadIdeas(buildIdeaQuery())
// 从路由参数恢复选中灵感(修复 /ideas/:id 直接访问空白页 B-260615-36
if (route.params.id) {
const id = route.params.id as string
const exists = store.ideas.some(i => i.id === id)
if (!exists) {
Message.warning(t('ideas.notFound'))
}
selectedId.value = id
}
})
// 同组件内路由变化时同步 selectedId列表点击跳 /ideas/:id
watch(() => route.params.id, (id) => {
if (id) {
const ideaId = id as string
const exists = store.ideas.some(i => i.id === ideaId)
if (!exists) {
Message.warning(t('ideas.notFound'))
}
selectedId.value = ideaId
}
})
</script>
<style scoped>
.ideas { padding: 16px 20px 20px; }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-page);
}
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
.header-actions { display: flex; gap: 10px; }
/* ===== 按钮 ===== */
.btn {
padding: 8px 16px;
border: none;
border-radius: var(--df-radius-sm);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
/* ===== 筛选栏 ===== */
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: var(--df-gap-page);
align-items: center;
}
.search-box {
flex: 1;
max-width: 300px;
}
.search-box input {
width: 100%;
padding: 6px 12px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 13px;
box-sizing: border-box;
}
.search-box input:focus {
outline: none;
border-color: var(--df-accent);
background: var(--df-bg-raised);
}
.filter-btn {
padding: 6px 14px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
.filter-btn.active {
background: var(--df-accent);
color: #fff;
border-color: var(--df-accent);
}
/* 排序按钮组(与状态筛选按钮视觉分隔) */
.sort-group {
display: flex;
gap: 4px;
margin-left: auto;
}
/* ===== 两栏布局 ===== */
.ideas-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: var(--df-gap-grid);
}
/* ===== 左侧列表 ===== */
.idea-list-panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: 6px;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.idea-card {
padding: 14px;
border-radius: var(--df-radius);
cursor: pointer;
transition: all 0.15s;
margin-bottom: 4px;
}
.idea-card:hover { background: rgba(108, 99, 255, 0.06); }
.idea-card.selected { background: rgba(108, 99, 255, 0.12); border: 0.5px solid var(--df-accent); }
.idea-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.idea-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.idea-score {
font-size: 13px; font-weight: 500;
min-width: 32px; height: 26px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--df-radius-sm);
}
.score-high { background: rgba(100,255,218,0.15); color: var(--df-success); }
.score-mid { background: rgba(255,217,61,0.15); color: var(--df-warning); }
.score-low { background: rgba(255,107,107,0.15); color: var(--df-danger); }
.idea-desc-preview { font-size: 12px; color: var(--df-text-dim); margin-bottom: 8px; line-height: 1.4; }
.idea-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.idea-date { font-size: 11px; color: var(--df-text-dim); }
/* ===== 状态标签 ===== */
.status-tag {
font-size: 11px; font-weight: 500; padding: 2px 8px;
border-radius: var(--df-radius-xs);
}
.status-draft { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.status-pending_review { background: rgba(100,181,246,0.2); color: var(--df-info); }
.status-approved { background: rgba(100,255,218,0.15); color: var(--df-success); }
.status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); }
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* ===== 右侧详情(详情内容由 IdeaDetail 子组件渲染,父级仅保留未选中空态壳) ===== */
.idea-detail-panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.idea-empty {
display: flex;
align-items: center;
justify-content: center;
}
.empty-state { text-align: center; color: var(--df-text-dim); }
.empty-icon { font-size: 48px; margin-bottom: 12px; }
.empty-state p { font-size: 14px; }
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.ideas { padding: 16px; }
.ideas-layout {
grid-template-columns: 1fr;
}
.filter-bar {
flex-wrap: wrap;
}
.search-box {
max-width: 100%;
order: -1;
width: 100%;
margin-bottom: 8px;
}
}
/* ===== 模态框 ===== */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal-box { background: var(--df-bg-card); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-lg); padding: 24px; min-width: 360px; max-width: 90vw; }
.modal-box h3 { color: var(--df-text); margin-bottom: var(--df-gap-head); }
.modal-box input, .modal-box textarea { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; font-family: inherit; }
.modal-box .modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
.modal-box .btn-cancel { padding: 6px 16px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: transparent; color: var(--df-text-secondary); cursor: pointer; }
.modal-box .btn-confirm { padding: 6px 16px; border: none; border-radius: var(--df-radius-sm); background: var(--df-accent); color: #fff; cursor: pointer; }
</style>