优化: 前端DRY收敛与命名对齐

审批等待格式化与阈值沉 utils/time 正典
相对时间阶梯三套收敛 formatRelative 参数化
灵感状态表沉 constants,透传通道退役
aggText/clearOwn 等命名对齐,IPC 参数名统一 conversation_id
This commit is contained in:
lxy
2026-10-01 19:35:20 +08:00
parent 6ef15a0940
commit 0560137840
16 changed files with 111 additions and 97 deletions
+10 -5
View File
@@ -103,8 +103,8 @@ export const aiApi = {
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。
* 返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
return invoke('ai_pending_tool_calls', { convId })
pendingToolCalls(conversationId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
return invoke('ai_pending_tool_calls', { conversationId })
},
/** 清空对话 */
@@ -251,9 +251,14 @@ export const aiApi = {
return invoke('ai_set_agent_max_retries', { value })
},
/** 列出本机 Claude 技能(skills + commands + plugins) */
listSkills(): Promise<SkillInfo[]> {
return invoke('ai_list_skills')
/**
* 列出本机 Claude 技能(project + skills + commands + plugins)。
* @param projectPath 当前项目根目录(可选)。传入时后端额外扫项目级
* `.claude/skills` 各技能目录下的 SKILL.md(source="project",同名覆盖 home 级);
* 不传沿用已设项目根(无则仅 home 级)。
*/
listSkills(projectPath?: string): Promise<SkillInfo[]> {
return invoke('ai_list_skills', { projectPath: projectPath ?? null })
},
/**
+1 -1
View File
@@ -38,6 +38,6 @@ export const settingsApi = {
/** 读取多 ReAct Plan 执行开关 */
getPlanExecution(): Promise<boolean> {
return invoke<boolean>('get_plan_execution')
return invoke<boolean>('is_plan_execution_enabled')
},
}
+2 -2
View File
@@ -52,7 +52,7 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import ToolCard from './ToolCard.vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { aggregateCalls, aggText } from '@/composables/ai/useTurnTrace'
import { aggregateCalls, turnSummaryText } from '@/composables/ai/useTurnTrace'
import type { AiToolCallInfo } from '@/api/types'
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
@@ -230,7 +230,7 @@ function groupDisplayName(group: ToolCallGroup): string {
/** 分组聚合行文案:「N 次调用 · 成功 x 失败 y」(零值项省略;与单卡状态点同源 isToolFailure 判定) */
function groupAggText(group: ToolCallGroup): string {
return aggText(aggregateCalls(group.calls))
return turnSummaryText(aggregateCalls(group.calls))
}
/**
+3 -3
View File
@@ -21,7 +21,7 @@ import { useMarkdown } from '../../composables/useMarkdown'
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
import { collectTurnStats, aggText, type TurnCallStats } from '../../composables/ai/useTurnTrace'
import { collectTurnStats, turnSummaryText, type TurnCallStats } from '../../composables/ai/useTurnTrace'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
import EmptyState from './EmptyState.vue'
@@ -741,9 +741,9 @@ function toggleTrace(key: string): void {
expandedTraceIds.value = next
}
/** 轨迹卡聚合行:「N 次调用 · 成功 x 失败 y · 总耗时 T」(拼装统一走 useTurnTrace.aggText) */
/** 轨迹卡聚合行:「N 次调用 · 成功 x 失败 y · 总耗时 T」(拼装统一走 useTurnTrace.turnSummaryText) */
function traceAggText(stats: TurnCallStats, startTs: number, endTs: number): string {
return aggText(stats, endTs - startTs)
return turnSummaryText(stats, endTs - startTs)
}
/** 轨迹卡 hover:绝对起止时刻(相对时长之外可溯源具体时间) */
+7 -10
View File
@@ -19,7 +19,7 @@
>
🚀 {{ $t('ideas.promotedProject') }} →
</router-link>
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
<span class="status-tag" :class="'status-' + idea.status">{{ $t(ideaStatusLabelKey(idea.status)) }}</span>
</div>
</div>
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail),空值回退 — -->
@@ -268,7 +268,7 @@
:class="'status-' + idea.status"
@click="statusMenuOpen = !statusMenuOpen"
>
{{ $t(statusLabelKey(idea.status)) }}
{{ $t(ideaStatusLabelKey(idea.status)) }}
<span class="status-caret" :class="{ 'is-open': statusMenuOpen }">▾</span>
</button>
<ul v-show="statusMenuOpen" class="status-menu">
@@ -306,6 +306,7 @@ import { useRendered } from '../../composables/useMarkdown'
import { ideaApi } from '../../api'
import { formatDate } from '../../utils/time'
import { parseScores, assessmentClass, assessmentLabel, scoreTier } from '../../utils/ideaEval'
import { IDEA_STATUS_OPTIONS, ideaStatusLabelKey } from '../../constants/project'
import type { IdeaRecord, IdeaStatus, IdeaEvaluationRecord } from '../../api/types'
const props = defineProps<{
@@ -314,7 +315,6 @@ const props = defineProps<{
evalError: string
promoting: boolean
deleting: boolean
statusOptions: { value: IdeaStatus; labelKey: string }[]
}>()
const emit = defineEmits<{
@@ -390,11 +390,11 @@ watch(() => props.idea.id, () => {
watch(() => props.idea.description, () => void nextTick(measureDesc))
// P1-④ 状态切换 select → badge 点击:点击当前 badge 展开/收起下拉菜单,
// 选中项后 emit('status-change') 交父组件确认+落库(沿用既有契约,不破坏 statusOptions 来源)。
// 选中项后 emit('status-change') 交父组件确认+落库(沿用既有契约)。
const statusMenuOpen = ref(false)
// 状态下拉排除 promoted(立项走独立 promoteToProject 流程,列表状态菜单同排除,
// 见 Ideas.vue onStatusChange 兜底);statusOptions 仍含 promoted 供 label 映射(已转立项的灵感 badge 正常显示)。
const menuStatusOptions = computed(() => props.statusOptions.filter(s => s.value !== 'promoted'))
// 见 Ideas.vue onStatusChange 兜底);状态表(含 promoted 供 badge label 映射)单一数据源在 @/constants/project。
const menuStatusOptions = computed(() => IDEA_STATUS_OPTIONS.filter(s => s.value !== 'promoted'))
function closeStatusMenu() {
statusMenuOpen.value = false
}
@@ -515,10 +515,7 @@ function cancelTagsEdit() {
tagsEditing.value = false
}
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
function statusLabelKey(status: IdeaStatus): string {
return props.statusOptions.find(s => s.value === status)?.labelKey ?? status
}
// 任意 status → i18n key 的映射走 @/constants/project ideaStatusLabelKey(与列表同源)
// 灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
+4 -11
View File
@@ -147,6 +147,7 @@ import {
knowledgeConfidenceLabel as confidenceLabel,
} from '@/stores/knowledge'
import { useRendered } from '@/composables/useMarkdown'
import { formatRelative } from '@/utils/time'
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
// 详情面板子组件(Knowledge.vue 拆分,SW-260618-20):
@@ -317,20 +318,12 @@ const timelineNodes = computed(() => {
return nodes
})
// ===== 标签/时间辅助(kind*/status/confidence label 由 @/stores/knowledge 共用,相对时间本组件自留 30 天截止)=====
// 相对时间:复用 common.* 既有 key(justNow/minutesAgo/hoursAgo/dayAgo),30 天以上回退绝对值
// ===== 标签/时间辅助(kind*/status/confidence label 由 @/stores/knowledge 共用)=====
// 相对时间:走 @/utils/time formatRelative(30 天以上回退绝对日期,档位与全局同源)
function relativeTime(millisStr: string): string {
const ms = Number(millisStr)
if (!ms) return ''
const diff = Date.now() - ms
const min = Math.floor(diff / 60000)
if (min < 1) return t('common.justNow')
if (min < 60) return t('common.minutesAgo', { n: min })
const hr = Math.floor(min / 60)
if (hr < 24) return t('common.hoursAgo', { n: hr })
const day = Math.floor(hr / 24)
if (day < 30) return t('common.dayAgo', { n: day })
return t('common.ago', { time: new Date(ms).toLocaleDateString() })
return formatRelative(ms, { absAfterDays: 30 })
}
// 切换知识时重置引用列表「加载更多」上限 + 折叠态(对齐原 Knowledge.vue selectKnowledge 内 refLimit=10 行为)
+3 -11
View File
@@ -165,6 +165,7 @@
*/
import { ref, computed, watch } from 'vue'
import { moduleApi, type GitStatusResult } from '@/api/module'
import { formatRelative } from '@/utils/time'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -274,19 +275,10 @@ function statusLabel(s: string): string {
return x.charAt(0) || '?'
}
/** 格式化 Unix 时间戳为相对时间或具体时间 */
/** 格式化 Unix 时间戳为相对时间或具体时间(阶梯走 @/utils/time formatRelative:昨天档 + 30 天截断 ISO 日期) */
function formatTime(ts: number): string {
if (!ts) return ''
const now = Math.floor(Date.now() / 1000)
const diffSec = now - ts
if (diffSec < 60) return t('common.justNow')
if (diffSec < 3600) return t('common.minutesAgo', { n: Math.floor(diffSec / 60) })
if (diffSec < 86400) return t('common.hoursAgo', { n: Math.floor(diffSec / 3600) })
if (diffSec < 172800) return t('common.yesterday')
if (diffSec < 2592000) return t('common.dayAgo', { n: Math.floor(diffSec / 86400) })
// 超过 30 天显示具体日期
const d = new Date(ts * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
return formatRelative(ts, { yesterday: true, absAfterDays: 30, absStyle: 'iso' })
}
/** 计算文件路径的目录深度(用于缩进)。root 级文件返回 0。 */
+3 -10
View File
@@ -12,6 +12,7 @@ import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm'
import { aiApi } from '@/api'
import { getApprovalRemainingMs } from './aiShared'
import { formatWaitTime, waitLevelOf, type WaitLevel } from '@/utils/time'
import type { AiToolCallInfo } from '@/api/types'
/**
@@ -115,17 +116,9 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
waitSecs.value = 0
}
function formatWaitTime(secs: number): string {
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
// formatWaitTime/waitLevelOf 收敛至 @/utils/time(原与 ApprovalPopup 逐字双写)
const waitLevel = computed<'normal' | 'long' | 'urgent'>(() => {
if (waitSecs.value >= 300) return 'urgent' // 5分钟
if (waitSecs.value >= 120) return 'long' // 2分钟
return 'normal'
})
const waitLevel = computed<WaitLevel>(() => waitLevelOf(waitSecs.value))
// ── 审批到期倒计时(BUG-260624-02)──
// 剩余 = aiShared 审批超时计时器真实起点推算(自动拒绝时刻精确对齐,非本地近似)。
+1 -1
View File
@@ -96,7 +96,7 @@ export function statsTail(s: TurnCallStats): string {
* 头部 total=0 显「过程记录」;零值段省略;durationMs 可选(轨迹卡传起止差,分组行不传)。
* 供 MessageList 轨迹卡与 ToolCardList 聚合分组行共用,拼装语义单一出口。
*/
export function aggText(s: TurnCallStats, durationMs?: number): string {
export function turnSummaryText(s: TurnCallStats, durationMs?: number): string {
const parts: string[] = []
parts.push(s.total > 0 ? t('ai.trace.calls', { n: s.total }) : t('ai.trace.processOnly'))
const tail = statsTail(s)
+3 -3
View File
@@ -16,7 +16,7 @@ export function useTimerOwnership() {
const timeouts = new Set<ReturnType<typeof setTimeout>>()
const intervals = new Set<ReturnType<typeof setInterval>>()
/** 注册一次性 timeout;触发后自动从注册表移除。返回可交给 clearOwn 的 id。 */
/** 注册一次性 timeout;触发后自动从注册表移除。返回可交给 clearOwnTimeout 的 id。 */
function setOwnTimeout(fn: () => void, ms: number) {
const id = setTimeout(() => {
timeouts.delete(id)
@@ -34,7 +34,7 @@ export function useTimerOwnership() {
}
/** 清理指定 timeout(id 为 null/undefined 时 no-op)。 */
function clearOwn(id?: ReturnType<typeof setTimeout> | null) {
function clearOwnTimeout(id?: ReturnType<typeof setTimeout> | null) {
if (id != null) {
clearTimeout(id)
timeouts.delete(id)
@@ -56,5 +56,5 @@ export function useTimerOwnership() {
intervals.clear()
})
return { setOwnTimeout, setOwnInterval, clearOwn, clearOwnInterval }
return { setOwnTimeout, setOwnInterval, clearOwnTimeout, clearOwnInterval }
}
+3 -3
View File
@@ -34,14 +34,14 @@ export function useToast(defaultDurationMs = 3000) {
// 会清掉组件 A 的 timer,致 A 的 toast 永不隐藏(跨实例 bug)。
// 已下沉为「每实例独立 timer」:useTimerOwnership 每实例注册表 + onUnmounted 只清自己。
// 不再保留模块级兜底 —— 模块级共享正是串扰根源,每实例自清理即正确语义。
const { setOwnTimeout, clearOwn } = useTimerOwnership()
const { setOwnTimeout, clearOwnTimeout } = useTimerOwnership()
let _timer: ReturnType<typeof setTimeout> | null = null
function showToast(msg: string, type: ToastType = 'info', durationMs?: number) {
toast.msg = msg
toast.type = type
toast.visible = true
if (_timer) clearOwn(_timer)
if (_timer) clearOwnTimeout(_timer)
_timer = setOwnTimeout(() => {
_timer = null
toast.visible = false
@@ -50,7 +50,7 @@ export function useToast(defaultDurationMs = 3000) {
function hideToast() {
if (_timer) {
clearOwn(_timer)
clearOwnTimeout(_timer)
_timer = null
}
toast.visible = false
+20
View File
@@ -4,6 +4,8 @@
//! 根治此前三套状态映射互相矛盾、任务文案两处不一致的问题。
//! 项目进度不在此处(按 stage 拍百分比已废弃),由任务完成率计算,见 store.projectTaskProgress。
import type { IdeaStatus } from '@/api/types'
// ── 项目状态 ──
// 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts)
@@ -221,3 +223,21 @@ export function priorityLabel(p: number): string {
export function priorityClass(p: number): string {
return PRIORITY_CLASSES[p] ?? 'priority-low'
}
// ── 灵感状态 ──
// 值为 i18n key,实际文案走 $t(见 src/i18n/{zh-CN,en}/ideas.ts)。
// 单一数据源:Ideas.vue 列表 badge/确认文案 与 IdeaDetail.vue 详情 badge/状态下拉共用
// (原 Ideas.vue 自持 + 经 props 透传 IdeaDetail 双写,已收敛至此;archived 无菜单入口不入表)。
export const IDEA_STATUS_OPTIONS: { 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;未知状态(含 archived)回退到原值显示 */
export function ideaStatusLabelKey(status: IdeaStatus): string {
return IDEA_STATUS_OPTIONS.find(s => s.value === status)?.labelKey ?? status
}
+1 -1
View File
@@ -253,7 +253,7 @@ const SCALAR_COMMANDS: Record<string, () => unknown> = {
settings_get_all: () => ({}),
settings_set: () => true,
settings_delete: () => true,
get_plan_execution: () => false,
is_plan_execution_enabled: () => false,
get_data_dir: () => 'C:\\devflow-browser-mock-data',
ai_is_generating: () => false,
list_tool_executions: () => ({ items: [], total: 0, has_more: false }),
+39 -2
View File
@@ -44,8 +44,19 @@ export function formatDate(v: string | number | null | undefined): string {
return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
}
/** 相对时间(走 i18n:justNow/minutesAgo/hoursAgo/dayAgo;en/zh 双语);无效返回 '—' */
export function formatRelative(v: string | number | null | undefined): string {
/** 相对时间分档差异参数(formatRelative 选项;不传 = 原默认阶梯) */
export interface FormatRelativeOpts {
/** 开启「昨天」档:24~48h 显示 common.yesterday(如 GitChanges 提交时间),默认关 */
yesterday?: boolean
/** 超过 N 天回退绝对日期;缺省不截断,一直显示「N 天前」 */
absAfterDays?: number
/** 绝对日期回退样式:'ago' = 「{date}前」(i18n,默认);'iso' = YYYY-MM-DD(定宽,列表对齐用) */
absStyle?: 'ago' | 'iso'
}
/** 相对时间(走 i18n:justNow/minutesAgo/hoursAgo/dayAgo;en/zh 双语);无效返回 '—'。
* 分档差异(yesterday 档 / N 天截断回退绝对日期)经 opts 表达,不传保持原行为 */
export function formatRelative(v: string | number | null | undefined, opts?: FormatRelativeOpts): string {
const ms = parseTs(v)
if (ms == null) return '—'
const diffMin = Math.floor((Date.now() - ms) / 60000)
@@ -54,5 +65,31 @@ export function formatRelative(v: string | number | null | undefined): string {
const diffH = Math.floor(diffMin / 60)
if (diffH < 24) return t('common.hoursAgo', { n: diffH })
const diffD = Math.floor(diffH / 24)
if (opts?.yesterday && diffD < 2) return t('common.yesterday')
if (opts?.absAfterDays != null && diffD >= opts.absAfterDays) {
const d = new Date(ms)
if (opts.absStyle === 'iso') {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
return t('common.ago', { time: d.toLocaleDateString() })
}
return t('common.dayAgo', { n: diffD })
}
// ── 审批等待时长(ToolCard / ApprovalPopup 双写收敛) ──
export type WaitLevel = 'normal' | 'long' | 'urgent'
/** 秒 → MM:SS(分秒两位补零,审批挂起时长展示) */
export function formatWaitTime(secs: number): string {
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
/** 等待分级阈值(与后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝对齐):≥300s urgent / ≥120s long */
export function waitLevelOf(secs: number): WaitLevel {
if (secs >= 300) return 'urgent'
if (secs >= 120) return 'long'
return 'normal'
}
+4 -15
View File
@@ -55,7 +55,7 @@
<!-- H1 修复:审批挂起时长提示(对齐 ToolCard: >2min 橙色 / >5min 红色脉冲)。
后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝,浮窗原本无计时,用户误以为还有时间。
基于审批到达时间(popup 首次见到该 tc.id 的时刻)计时,与 ToolCard 同款语义同款样式。 -->
<div v-if="waitSecsFor(tc.id) > 0" class="popup-item-wait" :class="'is-' + waitLevelFor(tc.id)">
<div v-if="waitSecsFor(tc.id) > 0" class="popup-item-wait" :class="'is-' + waitLevelOf(waitSecsFor(tc.id))">
{{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecsFor(tc.id)) }) }}
</div>
<!-- path 类:目录提示 -->
@@ -153,6 +153,7 @@ import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
import { formatWaitTime, waitLevelOf } from '@/utils/time'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useToast } from '@/composables/useToast'
import type { AiToolCallInfo } from '@/api/types'
@@ -189,30 +190,18 @@ function answerConfirm(ok: boolean) {
// ── H1:审批挂起时长计时(对齐 ToolCard useToolApproval 的 waitSecs/waitLevel)──
// 浮窗独立于主窗口,不直接复用 useToolApproval(那个绑定单 tc + 完整状态机 emit/防抖,
// 浮窗已用 processingId/runApproval 自管)。这里基于「到达时间」(popup 首次见到 tc.id 的时刻)
// 每秒刷新。语义/样式/阈值与 ToolCard 同款:120s long / 300s urgent。
// 每秒刷新。格式化(formatWaitTime)与分级阈值(waitLevelOf,120s long / 300s urgent)
// 收敛至 @/utils/time,与 ToolCard 同源。
/** tc.id → 到达时刻(ms)。审批首次出现在 approvals 即记时;移除即清。 */
const arrivalTimes = reactive(new Map<string, number>())
/** tc.id → 已等待秒数(每秒 tick 更新)。 */
const waitSecsMap = reactive(new Map<string, number>())
let _waitTimer: ReturnType<typeof setInterval> | null = null
/** 格式化秒为 MM:SS(与 useToolApproval.formatWaitTime 同款) */
function formatWaitTime(secs: number): string {
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
/** 取某条审批已等待秒数(无记录返回 0) */
function waitSecsFor(id: string): number {
return waitSecsMap.get(id) ?? 0
}
/** 取等待级别(对齐 ToolCard: 300s urgent / 120s long / 其余 normal) */
function waitLevelFor(id: string): 'normal' | 'long' | 'urgent' {
const s = waitSecsFor(id)
if (s >= 300) return 'urgent'
if (s >= 120) return 'long'
return 'normal'
}
/** 每秒刷新所有挂起审批的 waitSecs(单 timer 驱动多 tc,避免每条一个 interval) */
function _tickWaitSecs() {
const now = Date.now()
+7 -19
View File
@@ -70,7 +70,7 @@
</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="status-tag" :class="'status-' + idea.status">{{ $t(ideaStatusLabelKey(idea.status)) }}</span>
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
</div>
</div>
@@ -93,7 +93,6 @@
:eval-error="evalError"
:promoting="promoting"
:deleting="deleting"
:status-options="statusOptions"
@evaluate="evaluateCurrentIdea"
@promote="promoteToProject"
@delete="deleteCurrentIdea"
@@ -165,6 +164,7 @@ import type { IdeaStatus, IdeaQuery } from '../api/types'
import { formatDate } from '../utils/time'
import { stripMd } from '../utils/markdown'
import { scoreTier } from '../utils/ideaEval'
import { ideaStatusLabelKey } from '../constants/project'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import IdeaDetail from '../components/ideas/IdeaDetail.vue'
import { useConfirm } from '../composables/useConfirm'
@@ -214,19 +214,7 @@ const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
{ 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
}
// ── 状态映射(单一数据源在 @/constants/project,列表/详情/下拉共用) ──
// ── 后端查询构造status/keyword/order_by 下沉后端 WHERE) ──
//
@@ -260,11 +248,11 @@ function loadCurrentView() {
// 让 store.loading 驱动「加载中」空态显示(对齐 Tasks.vue 三态)。
// G5.3:keyword 防抖 timer 用 useTimerOwnership 每实例管理 + onUnmounted 自动清理,
// 根治卸载后 300ms 内 timer 触发 → store.loadIdeas 写单例 store(setState-after-unmount)。
const { setOwnTimeout, clearOwn } = useTimerOwnership()
const { setOwnTimeout, clearOwnTimeout } = useTimerOwnership()
let keywordDebounce: ReturnType<typeof setTimeout> | null = null
async function reloadIdeas(immediate = false) {
if (keywordDebounce) {
clearOwn(keywordDebounce)
clearOwnTimeout(keywordDebounce)
keywordDebounce = null
}
const run = () => loadCurrentView()
@@ -444,8 +432,8 @@ async function onStatusChange(newStatus: IdeaStatus) {
if (needsConfirm) {
const msg = t('ideas.confirmStatus', {
title: currentIdea.value.title,
from: t(statusOptions.find(s => s.value === from)?.labelKey ?? from),
to: t(statusOptions.find(s => s.value === newStatus)?.labelKey ?? newStatus),
from: t(ideaStatusLabelKey(from)),
to: t(ideaStatusLabelKey(newStatus)),
})
if (!await confirmDialog(msg, t('common.confirm'))) return
}