重构: tool_registry拆分及多批改进

This commit is contained in:
2026-06-19 00:10:14 +08:00
parent a2871a66e0
commit 60b01d03ee
33 changed files with 703 additions and 247 deletions

View File

@@ -60,7 +60,7 @@
<input v-model="connForm.user" class="setting-input" :placeholder="$t('settings.phUser')" />
</div>
<div class="form-actions">
<button class="btn btn-primary" @click="saveConn">{{ $t('common.save') }}</button>
<button class="btn btn-primary" :disabled="submitting" @click="saveConn">{{ $t('common.save') }}</button>
</div>
</div>
</section>
@@ -106,6 +106,9 @@ const connForm = reactive({
user: '',
})
// 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮
const submitting = ref(false)
const CONN_STORAGE_KEY = 'df-connections'
function loadConnections() {
@@ -151,22 +154,27 @@ function saveConn() {
emit('toast', t('settings.toastConnIncomplete'), 'warning')
return
}
const record: ConnRecord = {
id: connForm.editId || Date.now().toString(36),
name: connForm.name,
type: connForm.type,
host: connForm.host,
port: connForm.port,
user: connForm.user,
submitting.value = true
try {
const record: ConnRecord = {
id: connForm.editId || Date.now().toString(36),
name: connForm.name,
type: connForm.type,
host: connForm.host,
port: connForm.port,
user: connForm.user,
}
if (connForm.editId) {
const idx = connections.value.findIndex(c => c.id === connForm.editId)
if (idx >= 0) connections.value[idx] = record
} else {
connections.value.push(record)
}
persistConnections()
connForm.visible = false
} finally {
submitting.value = false
}
if (connForm.editId) {
const idx = connections.value.findIndex(c => c.id === connForm.editId)
if (idx >= 0) connections.value[idx] = record
} else {
connections.value.push(record)
}
persistConnections()
connForm.visible = false
}
async function removeConn(id: string) {

View File

@@ -46,7 +46,7 @@ function applyUiState(s: any) {
}
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
export function restoreUiState() {
function restoreUiState() {
applyUiState(appSettings.get<any>('df-ai-ui', null))
}
restoreUiState()

View File

@@ -22,13 +22,6 @@ export interface ConfirmState {
resolve: null | ((v: boolean) => void)
}
/** 弹出确认对话框,返回 Promise;resolve(true) 表示用户确认,resolve(false) 表示取消。
* dangerLabel 可选:覆盖危险按钮文案(如「清空」),不传则回退组件兜底 common.confirm,保持旧行为。 */
export type ConfirmFn = (msg: string, dangerLabel?: string) => Promise<boolean>
/** 关闭弹层并回传结果 */
export type AnswerFn = (ok: boolean) => void
export function useConfirm() {
const confirmState = ref<ConfirmState>({
visible: false,

View File

@@ -132,8 +132,6 @@ export function useMarkdown() {
escapeFallback,
getMarked: () => _marked,
getPurify: () => _purify,
// 仅 AiChat 流式块级 memo 用(独立缓存,不与历史 _mdCache 混)
clearHistoryCache: () => _mdCache.clear(),
}
}

View File

@@ -32,6 +32,10 @@ export default {
noIdeas: 'No ideas yet, click above to capture',
noData: 'No data',
},
// Error fallback (refresh/onMounted load failures surfaced to user)
err: {
loadFailed: 'Failed to load data, please retry later',
},
stage: {
planning: 'Planning',
coding: 'Coding',

View File

@@ -16,6 +16,11 @@ export default {
// Empty state
emptyState: 'Select an idea to view details',
// List empty / async button placeholders
listEmpty: '📭 No ideas yet. Click "Capture Idea" at the top right to start.',
creating: 'Creating…',
deleting: 'Deleting…',
promoting: 'Promoting…',
// Adversarial evaluation
adversarialTitle: '⚖️ Adversarial Evaluation',

View File

@@ -32,6 +32,10 @@ export default {
noIdeas: '暂无灵感,点击上方按钮捕捉',
noData: '暂无数据',
},
// 错误回退(refresh/onMounted 加载失败对用户可见)
err: {
loadFailed: '加载数据失败,请稍后重试',
},
stage: {
planning: '规划中',
coding: '编码中',

View File

@@ -16,6 +16,11 @@ export default {
// 空状态
emptyState: '选择一个灵感查看详情',
// 列表空态/异步按钮占位
listEmpty: '📭 还没有灵感,点击右上角「捕捉灵感」开始记录',
creating: '创建中…',
deleting: '删除中…',
promoting: '立项中…',
// 对抗式评估
adversarialTitle: '⚖️ 对抗式评估',

View File

@@ -16,7 +16,7 @@ export function stripMd(text: string | null | undefined): string {
return text
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // 图片 alt
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接文本
.replace(/```[a-zA-Z]*\n?/g, '') // 代码块围栏开
.replace(/```[^\n]*\n?/g, '') // 代码块围栏开(含任意 infostring 至换行,如 rust,norun)
.replace(/```/g, '') // 代码块围栏闭
.replace(/`([^`]+)`/g, '$1') // 行内代码
.replace(/^\s{0,3}>\s?/gm, '') // 引用

View File

@@ -41,7 +41,8 @@ export function formatDate(v: string | number | null | undefined): string {
const ms = parseTs(v)
if (ms == null) return '—'
const d = new Date(ms)
return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
const locale = (i18n as any).global.locale.value === 'en' ? 'en-US' : 'zh-CN'
return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
}
/** 中文相对时间 '刚刚 / X 分钟前 / X 小时前 / X 天前';无效返回 '—' */

View File

@@ -7,7 +7,7 @@
<span class="dash-subtitle">{{ $t('dashboard.subtitle') }}</span>
</div>
<div class="dash-header-right">
<button class="df-btn df-btn--ghost" @click="refresh">
<button class="df-btn df-btn--ghost" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button>
<button class="df-btn df-btn--primary" @click="quickCapture">
@@ -17,6 +17,12 @@
</div>
</header>
<!-- 错误条:refresh/onMounted 加载失败对用户可见(对齐 Knowledge error-banner) -->
<div v-if="errorMsg" class="error-banner">
<span class="error-text">{{ errorMsg }}</span>
<button class="error-dismiss" @click="errorMsg = ''"></button>
</div>
<!-- Stat Cards -->
<div class="stat-row">
<div
@@ -104,7 +110,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
@@ -114,6 +120,10 @@ const router = useRouter()
const store = useProjectStore()
const { t } = useI18n()
// 加载态(refresh/load 期间 true,驱动刷新按钮禁用) + 错误态(加载失败对用户可见)
const loading = ref(false)
const errorMsg = ref('')
const stats = computed(() => [
{ key: 'ideas', icon: '\u{1F4A1}', label: t('dashboard.stats.ideas'), value: store.stats.ideas, trend: 0,
iconBg: 'rgba(123,111,240,0.12)' },
@@ -179,24 +189,32 @@ function ideaStatusLabel(status: string): string {
return t('dashboard.ideaStatus.' + status)
}
async function refresh() {
// 统一加载入口(projects/tasks/ideas 并行),管理 loading + errorMsg
// refresh 与 onMounted 共用,避免两处 catch 逻辑重复
async function loadAll() {
if (loading.value) return // 防重复触发
loading.value = true
errorMsg.value = ''
try {
await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()])
} catch (e) {
console.error('Dashboard refresh failed:', e)
} catch (e: any) {
console.error('Dashboard load failed:', e)
errorMsg.value = e?.toString() ?? t('dashboard.err.loadFailed')
} finally {
loading.value = false
}
}
async function refresh() {
await loadAll()
}
function quickCapture() {
router.push('/ideas')
}
onMounted(async () => {
try {
await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()])
} catch (e) {
console.error('Dashboard load failed:', e)
}
onMounted(() => {
loadAll()
})
</script>
@@ -271,6 +289,21 @@ onMounted(async () => {
border-color: var(--df-border-strong);
}
.df-btn--xs { padding: 4px 10px; font-size: 11px; }
.df-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ═══ 错误条(对齐 Knowledge error-banner) ═══ */
.error-banner {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 8px 12px; margin-bottom: 14px;
background: rgba(255,107,107,0.12); border: 0.5px solid var(--df-danger);
border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px;
}
.error-text { word-break: break-word; }
.error-dismiss {
flex-shrink: 0; background: transparent; border: none; color: var(--df-danger);
font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
}
.error-dismiss:hover { opacity: 0.7; }
/* ═══ Stat Cards ═══ */
.stat-row {

View File

@@ -33,23 +33,29 @@
<!-- 左侧灵感列表 -->
<section class="idea-list-panel">
<div class="idea-list">
<div
v-for="idea in filteredIdeas"
: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>
<!-- 三态:加载/错误/(对齐 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 filteredIdeas"
: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>
<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>
</section>
@@ -167,11 +173,14 @@
<button
v-if="currentIdea.status === 'approved' && !currentIdea.promoted_to"
class="btn btn-primary"
:disabled="promoting"
@click="promoteToProject"
>
{{ $t('ideas.promoteToProject') }}
{{ promoting ? $t('ideas.promoting') : $t('ideas.promoteToProject') }}
</button>
<button class="btn btn-ghost" :disabled="deleting" @click="deleteCurrentIdea">
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
</button>
<button class="btn btn-ghost" @click="deleteCurrentIdea">{{ $t('ideas.deleteIdea') }}</button>
</div>
</div>
</section>
@@ -195,7 +204,9 @@
<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" @click="confirmCapture">{{ $t('common.confirm') }}</button>
<button class="btn-confirm" :disabled="creating" @click="confirmCapture">
{{ creating ? $t('ideas.creating') : $t('common.confirm') }}
</button>
</div>
</div>
</div>
@@ -238,6 +249,11 @@ 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: '🔥' },
@@ -383,23 +399,34 @@ function openCaptureModal() {
}
async function confirmCapture() {
if (!newIdeaTitle.value.trim()) return
await store.createIdea({
title: newIdeaTitle.value.trim(),
description: newIdeaDesc.value.trim() || undefined,
})
showCaptureModal.value = false
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) return
if (!currentIdea.value || deleting.value) return
if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
await store.deleteIdea(currentIdea.value.id)
selectedId.value = null
deleting.value = true
try {
await store.deleteIdea(currentIdea.value.id)
selectedId.value = null
} finally {
deleting.value = false
}
}
async function promoteToProject() {
if (!currentIdea.value) return
if (!currentIdea.value || promoting.value) return
promoting.value = true
try {
const res = await store.promoteIdea(currentIdea.value.id)
router.push(`/projects/${res.project_id}`)
@@ -407,6 +434,8 @@ async function promoteToProject() {
const msg = e?.toString() ?? t('ideas.promoteFailed')
console.error(t('ideas.promoteFailed'), e)
Message.error(msg)
} finally {
promoting.value = false
}
}

View File

@@ -96,14 +96,14 @@
<div class="detail-actions" v-if="!editing">
<button class="btn btn-ghost btn-sm" @click="startEdit">{{ $t('knowledge.edit') }}</button>
<template v-if="topTab === 'inbox'">
<button class="btn btn-primary btn-sm" @click="publishCurrent">{{ $t('knowledge.publish') }}</button>
<button class="btn btn-ghost btn-sm" @click="rejectCurrent">{{ $t('knowledge.reject') }}</button>
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="publishCurrent">{{ $t('knowledge.publish') }}</button>
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="rejectCurrent">{{ $t('knowledge.reject') }}</button>
</template>
<button v-else class="btn btn-ghost btn-sm" @click="archiveCurrent">{{ $t('knowledge.archive') }}</button>
<button v-else class="btn btn-ghost btn-sm" :disabled="submitting" @click="archiveCurrent">{{ $t('knowledge.archive') }}</button>
</div>
<div class="detail-actions" v-else>
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary btn-sm" @click="saveEdit" :disabled="!editForm.title.trim()">{{ $t('common.save') }}</button>
<button class="btn btn-primary btn-sm" @click="saveEdit" :disabled="submitting || !editForm.title.trim()">{{ $t('common.save') }}</button>
</div>
</div>
@@ -230,7 +230,7 @@
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="closeCreateModal">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="!form.title.trim()">{{ $t('knowledge.create') }}</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="submitting || !form.title.trim()">{{ $t('knowledge.create') }}</button>
</div>
</div>
</div>
@@ -349,6 +349,9 @@ watch(listItems, items => {
const editing = ref(false)
const editForm = ref({ title: '', content: '', tagsInput: '', confidence: '' })
// 异步操作禁用态(IPC 期间防双击重复提交):saveEdit/publishCurrent/rejectCurrent/archiveCurrent/submitCreate 共用
const submitting = ref(false)
function startEdit() {
if (!detail.value) return
const k = detail.value.knowledge
@@ -367,38 +370,58 @@ function cancelEdit() {
async function saveEdit() {
if (!detail.value || !editForm.value.title.trim()) return
const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
await store.update(detail.value.knowledge.id, {
title: editForm.value.title.trim(),
content: editForm.value.content,
tags: JSON.stringify(tags),
confidence: editForm.value.confidence, // 空串=清空(后端映射 None),非 undefined
})
editing.value = false
// 重新拉详情
await selectKnowledge(detail.value.knowledge.id)
submitting.value = true
try {
const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
await store.update(detail.value.knowledge.id, {
title: editForm.value.title.trim(),
content: editForm.value.content,
tags: JSON.stringify(tags),
confidence: editForm.value.confidence, // 空串=清空(后端映射 None),非 undefined
})
editing.value = false
// 重新拉详情
await selectKnowledge(detail.value.knowledge.id)
} finally {
submitting.value = false
}
}
// ===== 审核操作(收件箱) =====
async function publishCurrent() {
if (!detail.value) return
await store.updateStatus(detail.value.knowledge.id, 'published')
selectedId.value = null
detail.value = null
submitting.value = true
try {
await store.updateStatus(detail.value.knowledge.id, 'published')
selectedId.value = null
detail.value = null
} finally {
submitting.value = false
}
}
async function rejectCurrent() {
if (!detail.value) return
await store.archive(detail.value.knowledge.id)
selectedId.value = null
detail.value = null
submitting.value = true
try {
await store.archive(detail.value.knowledge.id)
selectedId.value = null
detail.value = null
} finally {
submitting.value = false
}
}
async function archiveCurrent() {
if (!detail.value) return
await store.archive(detail.value.knowledge.id)
selectedId.value = null
detail.value = null
submitting.value = true
try {
await store.archive(detail.value.knowledge.id)
selectedId.value = null
detail.value = null
} finally {
submitting.value = false
}
}
// ===== 新增对话框 =====
@@ -413,16 +436,21 @@ function closeCreateModal() {
showCreateModal.value = false
}
async function submitCreate() {
const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
await store.create({
kind: form.value.kind,
title: form.value.title.trim(),
content: form.value.content.trim(),
tags: JSON.stringify(tags),
confidence: form.value.confidence || undefined,
})
showCreateModal.value = false
switchTopTab('inbox')
submitting.value = true
try {
const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
await store.create({
kind: form.value.kind,
title: form.value.title.trim(),
content: form.value.content.trim(),
tags: JSON.stringify(tags),
confidence: form.value.confidence || undefined,
})
showCreateModal.value = false
switchTopTab('inbox')
} finally {
submitting.value = false
}
}
// ===== 事件解析辅助 =====

View File

@@ -33,7 +33,7 @@
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showNewTaskModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitNewTask" :disabled="!newTaskTitle.trim()">{{ $t('projectDetail.confirmCreate') }}</button>
<button class="btn btn-primary" @click="submitNewTask" :disabled="submitting || !newTaskTitle.trim()">{{ $t('projectDetail.confirmCreate') }}</button>
</div>
</div>
</div>
@@ -208,6 +208,7 @@
:key="idx"
class="btn"
:class="idx === 0 ? 'btn-primary' : 'btn-ghost'"
:disabled="submitting"
@click="handleApproval(option)"
>
{{ option }}
@@ -319,19 +320,27 @@ const newTaskTitle = ref('')
const newTaskDesc = ref('')
const newTaskBranch = ref('')
// 异步操作禁用态(IPC 期间防双击重复提交):submitNewTask 创建任务期间禁用确认按钮
const submitting = ref(false)
async function submitNewTask() {
if (!newTaskTitle.value.trim()) return
const r = await store.createTask({
project_id: projectId.value,
title: newTaskTitle.value.trim(),
description: newTaskDesc.value.trim(),
branch_name: newTaskBranch.value.trim() || undefined,
})
if (!r) return // 失败已 toast,保持弹窗不关
showNewTaskModal.value = false
newTaskTitle.value = ''
newTaskDesc.value = ''
newTaskBranch.value = ''
submitting.value = true
try {
const r = await store.createTask({
project_id: projectId.value,
title: newTaskTitle.value.trim(),
description: newTaskDesc.value.trim(),
branch_name: newTaskBranch.value.trim() || undefined,
})
if (!r) return // 失败已 toast,保持弹窗不关
showNewTaskModal.value = false
newTaskTitle.value = ''
newTaskDesc.value = ''
newTaskBranch.value = ''
} finally {
submitting.value = false
}
}
// ── 同步 ──
@@ -406,8 +415,13 @@ async function handleImportDir() {
// ── 审批处理 ──
async function handleApproval(decision: string) {
await store.approveHumanApproval([decision])
showApprovalDialog.value = false
submitting.value = true
try {
await store.approveHumanApproval([decision])
showApprovalDialog.value = false
} finally {
submitting.value = false
}
}
// F-260615-01: 多选审批

View File

@@ -9,6 +9,12 @@
</div>
</header>
<!-- 错误条:消费 store.error(加载/创建/删除失败对用户可见,对齐 Knowledge error-banner) -->
<div v-if="store.error" class="error-banner">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="store.clearError()"></button>
</div>
<!-- 新建项目模态框 -->
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
<div class="modal-box">
@@ -42,7 +48,7 @@
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="!newName.trim()">{{ $t('projects.confirmCreate') }}</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="submitting || !newName.trim()">{{ $t('projects.confirmCreate') }}</button>
</div>
</div>
</div>
@@ -215,6 +221,8 @@ const pathWarning = ref('')
const scanning = ref(false)
const scanError = ref('')
const scanProjectType = ref('')
// 创建提交中(防双击重复提交,对齐 ProviderPanel.saving 模式)
const submitting = ref(false)
// 选择目录 → 探测技术栈 + 查重
async function pickDir() {
@@ -267,13 +275,19 @@ async function aiScan() {
async function submitCreate() {
if (!newName.value.trim()) return
if (pathWarning.value) return // 目录冲突,禁止创建
const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined
const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack)
if (!r) return // 失败已 toast,保持弹窗不关
showCreateModal.value = false
newName.value = ''
newDesc.value = ''
clearDir()
if (submitting.value) return // 防双击重复提交
submitting.value = true
try {
const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined
const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack)
if (!r) return // 失败已 toast,保持弹窗不关
showCreateModal.value = false
newName.value = ''
newDesc.value = ''
clearDir()
} finally {
submitting.value = false
}
}
// ── 删除 / 回收站 ──
@@ -603,6 +617,20 @@ async function runImport() {
font-size: 14px; color: var(--df-text-dim);
}
/* ===== 错误条(消费 store.error,对齐 Knowledge error-banner) ===== */
.error-banner {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 8px 12px; margin-bottom: var(--df-gap-page);
background: rgba(255,107,107,0.12); border: 0.5px solid var(--df-danger);
border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px;
}
.error-text { word-break: break-word; }
.error-dismiss {
flex-shrink: 0; background: transparent; border: none; color: var(--df-danger);
font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
}
.error-dismiss:hover { opacity: 0.7; }
/* ===== 卡片删除按钮 + 回收站 ===== */
.card-top-actions { display: flex; align-items: center; gap: 8px; }
.card-del-btn {

View File

@@ -100,7 +100,7 @@
</select>
<div class="modal-actions">
<button class="btn-cancel" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
<button class="btn-confirm" @click="confirmCreate">{{ $t('common.confirm') }}</button>
<button class="btn-confirm" @click="confirmCreate" :disabled="submitting || !newTaskTitle.trim()">{{ $t('common.confirm') }}</button>
</div>
</div>
</div>
@@ -133,6 +133,9 @@ const newTaskDesc = ref('')
const newTaskBranch = ref('')
const newTaskPriority = ref(2) // 默认 medium
// 异步操作禁用态(IPC 期间防双击重复提交):confirmCreate 创建任务期间禁用确认按钮
const submitting = ref(false)
interface TaskGroup {
projectName: string
icon: string
@@ -217,15 +220,20 @@ function openCreateModal() {
async function confirmCreate() {
if (!newTaskTitle.value.trim() || !newTaskProjectId.value) return
const r = await store.createTask({
project_id: newTaskProjectId.value,
title: newTaskTitle.value.trim(),
description: newTaskDesc.value.trim(),
branch_name: newTaskBranch.value.trim() || undefined,
priority: newTaskPriority.value,
})
if (!r) return // 失败已 toast,保持弹窗不关
showCreateModal.value = false
submitting.value = true
try {
const r = await store.createTask({
project_id: newTaskProjectId.value,
title: newTaskTitle.value.trim(),
description: newTaskDesc.value.trim(),
branch_name: newTaskBranch.value.trim() || undefined,
priority: newTaskPriority.value,
})
if (!r) return // 失败已 toast,保持弹窗不关
showCreateModal.value = false
} finally {
submitting.value = false
}
}
// B-260615-29: 切换项目筛选时按 projectId 重载(避免仅前端 filter,跨项目视图不同步)