优化: 批A前端(timer所有权+会话族+审批conv-scoped+FilePreview+失败反馈)

A1-B1 timer所有权:新建 useTimerOwnership composable(每实例独立ref+onUnmounted清理)+ useToast _timer 下沉 + Ideas debounce 补清理(治跨实例串扰)

A1-B2 FilePreview:reqSeq 双计数器守卫(loadFile/loadDiff 最新seq才写,防乱序覆盖)+ mermaid securityLevel strict + filePath 比对(防跨文件SVG注入)

A1-B3 会话族:load_more 滚顶加载接线(switch透传has_more/earliest_seq+prepend去重+scrollTop恢复)+ switch失败保留视图+报错(仅对话不存在才create-new)+ new/delete失败反馈(withConvOp helper收敛)+ delete清ai_messages孤儿

A1-B5前端契约:isToolFailure 三处加 success===false 判定(useToolCard/ToolResultBody/ToolCard,git只读失败不再绿框)

A2-B10 审批conv-scoped:pendingApprovals 补conversationId + cleanup 按convId filter + 移除全局清(AiError不再误清其他conv)+ :676 dir auth filter方向修

usage打标前端:is_estimated 字段+事件透传+MessageList『估算』角标(详情面板说明)

A2-B9前端:clearChat try/catch 错误气泡
This commit is contained in:
lxy
2026-08-05 22:10:50 +08:00
parent 5667da6cf4
commit c480627ba6
14 changed files with 837 additions and 177 deletions
+151 -14
View File
@@ -45,6 +45,27 @@
<div class="ideas-layout">
<!-- 左侧:灵感列表 -->
<section class="idea-list-panel">
<div class="idea-list-header">
<label class="idea-batch-check" v-if="filteredIdeas.length > 0">
<input
type="checkbox"
:checked="selectedAll"
:indeterminate="selectedAny && !selectedAll"
@change="toggleSelectAll"
/>
<span v-if="selectedAny" class="batch-selected-note">{{ $t('ideas.selectedCount', { n: selectedIds.size }) }}</span>
</label>
<div class="idea-list-actions">
<button
v-if="selectedAny"
class="btn btn-danger btn-sm"
:disabled="batchDeleting"
@click="confirmBatchDelete"
>
{{ batchDeleting ? $t('ideas.deleting') : $t('ideas.batchDelete') }}
</button>
</div>
</div>
<div class="idea-list">
<!-- 三态:加载/错误/空(对齐 Tasks.vue:43-45) -->
<div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div>
@@ -58,14 +79,24 @@
: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 class="idea-card-check">
<input
type="checkbox"
:checked="selectedIds.has(idea.id)"
@click.stop
@change="toggleSelect(idea.id)"
/>
</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 class="idea-card-body">
<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">{{ descPreviews.get(idea.id) || '' }}</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>
</div>
</template>
@@ -93,6 +124,7 @@
@status-change="onStatusChange"
@update-desc="onUpdateDesc"
@update-related="onUpdateRelated"
@update-tags="onUpdateTags"
/>
<!-- 未选择 -->
@@ -148,7 +180,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, type Ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
@@ -161,6 +193,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue'
import IdeaDetail from '../components/ideas/IdeaDetail.vue'
import { useConfirm } from '../composables/useConfirm'
import { usePersistedRef } from '../composables/usePersistedRef'
import { useTimerOwnership } from '../composables/useTimerOwnership'
import Paginator from '../components/Paginator.vue'
const { t } = useI18n()
@@ -178,8 +211,47 @@ type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
const activeFilter = usePersistedRef<FilterKey>('ideas.activeFilter', 'all')
const selectedId = ref<string | null>(null)
const searchQuery = ref('')
// 列表排序:score 高分在前 / time 新在前(默认 score,避免高分灵感被淹没)
const sortMode = usePersistedRef<'score' | 'time'>('ideas.sortMode', 'score')
// 批量选择
const selectedIds: Ref<Set<string>> = ref(new Set()) as Ref<Set<string>>
const batchDeleting = ref(false)
const selectedAll = computed(
() => filteredIdeas.value.length > 0 && selectedIds.value.size === filteredIdeas.value.length,
)
const selectedAny = computed(() => selectedIds.value.size > 0)
function toggleSelect(id: string) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id); else next.add(id)
selectedIds.value = next
}
function toggleSelectAll() {
if (selectedAll.value) {
selectedIds.value = new Set()
} else {
selectedIds.value = new Set(filteredIdeas.value.map(i => i.id))
}
}
async function confirmBatchDelete() {
if (selectedIds.value.size === 0) return
if (!await confirmDialog(t('ideas.confirmBatchDelete', { n: selectedIds.value.size }), t('common.delete'))) return
batchDeleting.value = true
const ids = [...selectedIds.value]
try {
for (const id of ids) {
await store.deleteIdea(id)
}
selectedIds.value = new Set()
selectedId.value = null
Message.success(t('ideas.batchDeleteSuccess', { n: ids.length }))
await store.loadIdeas(buildIdeaQuery())
} catch (e) {
Message.error(t('ideas.deleteFailed'))
console.error('[Ideas] 批量删除失败:', e)
} finally {
batchDeleting.value = false
}
}
// 列表排序:time 新在前 / score 高分在前(默认 time,按创建时间倒序)
const sortMode = usePersistedRef<'score' | 'time'>('ideas.sortMode', 'time')
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片
const page = usePersistedRef<number>('ideas.page', 1)
@@ -242,17 +314,23 @@ function buildIdeaQuery(): IdeaQuery {
// 按当前筛选/搜索/排序触发后端加载(防抖仅对 keyword 输入,筛选/排序即时)。
// 让 store.loading 驱动「加载中」空态显示(对齐 Tasks.vue 三态)。
// G5.3:keyword 防抖 timer 用 useTimerOwnership 每实例管理 + onUnmounted 自动清理,
// 根治卸载后 300ms 内 timer 触发 → store.loadIdeas 写单例 store(setState-after-unmount)。
const { setOwnTimeout, clearOwn } = useTimerOwnership()
let keywordDebounce: ReturnType<typeof setTimeout> | null = null
async function reloadIdeas(immediate = false) {
if (keywordDebounce) {
clearTimeout(keywordDebounce)
clearOwn(keywordDebounce)
keywordDebounce = null
}
const run = () => store.loadIdeas(buildIdeaQuery())
if (immediate) {
await run()
} else {
keywordDebounce = setTimeout(() => { void run() }, 300)
keywordDebounce = setOwnTimeout(() => {
keywordDebounce = null
void run()
}, 300)
}
}
@@ -276,6 +354,16 @@ const filteredIdeas = computed(() => {
return ideas
})
// 预计算描述预览(消除 stripMd 模板重复调用)
const descPreviews = computed(() => {
const map = new Map<string, string>()
for (const idea of filteredIdeas.value) {
const stripped = stripMd(idea.description)
map.set(idea.id, stripped.length > 60 ? stripped.slice(0, 60) + '...' : stripped)
}
return map
})
// 分页视图(F-260621-02 P3):pageSize=0 → 全量(等价改造前);否则取当前页窗口。
const pagedIdeas = computed(() => {
if (pageSize.value <= 0) return filteredIdeas.value
@@ -415,6 +503,12 @@ async function onUpdateRelated(ids: string[]) {
await store.relateIdeas(currentIdea.value.id, ids)
}
// 接收 IdeaDetail 子组件 emit 的 'update-tags'(标签编辑保存)
async function onUpdateTags(tags: string) {
if (!currentIdea.value) return
await store.updateIdea(currentIdea.value.id, 'tags', tags)
}
onMounted(async () => {
await store.loadIdeas(buildIdeaQuery())
// 从路由参数恢复选中灵感(修复 /ideas/:id 直接访问空白页 B-260615-36)
@@ -470,14 +564,36 @@ watch(() => route.params.id, (id) => {
overflow-y: auto;
}
.idea-card {
display: flex;
gap: 10px;
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-body {
flex: 1;
min-width: 0;
/* 批量操作重构后 desc/footer 移入 body,须纵向堆叠(原 .idea-card 直接子元素横排 bug) */
display: flex;
flex-direction: column;
}
.idea-card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.idea-card-check {
display: flex;
align-items: flex-start;
padding-top: 2px;
}
.idea-card-check input { cursor: pointer; }
.idea-card:hover { background: var(--df-accent-bg); }
.idea-card.selected { background: var(--df-accent-soft); border: 0.5px solid var(--df-accent); }
.idea-card-header {
display: flex;
@@ -506,6 +622,27 @@ watch(() => route.params.id, (id) => {
}
.idea-date { font-size: 11px; color: var(--df-text-dim); }
/* ===== 批量操作头 ===== */
.idea-list-header {
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px 8px;
border-bottom: 0.5px solid var(--df-border);
margin-bottom: 4px;
}
.idea-batch-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--df-text-dim);
cursor: pointer;
}
.idea-batch-check input { cursor: pointer; }
.batch-selected-note { font-size: 11px; color: var(--df-text-dim); white-space: nowrap; }
.idea-list-actions { margin-left: auto; }
/* ===== 状态标签已提取到 styles/components.css 全局(.status-tag 系列) ===== */
/* ===== 右侧详情(详情内容由 IdeaDetail 子组件渲染,父级仅保留未选中空态壳) ===== */