优化: UI/UX遗留批(审计/AI命令层/前端组件,会话前基线收尾)
- AuditLog +298(筛选/详情/i18n)+ audit 后端 record/mod - AI 命令层:generate_image +81 / fetch_url / fetch_search / skills / tool_registry / tools/file / provider / conversation - 前端组件:AiChat/TopBar/ConversationSidebar/GitChanges/ApprovalPopup/Dashboard/ProjectDetail 等 30+ + composables + i18n - 诊断文档: aichat历史会话实证诊断-2026-08-04 + project_soft_delete 测试
This commit is contained in:
+3
-2
@@ -109,11 +109,12 @@ function initTheme() {
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+I / Cmd+I 切换 AI 面板
|
||||
// Ctrl+I / Cmd+I 三态循环:关闭 → 分栏打开 → 最大化 → 关闭(cyclePanelMode)
|
||||
// UI 按钮/审批激活仍用 togglePanel 二态开关,语义互不干扰
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
|
||||
e.preventDefault()
|
||||
aiStore.togglePanel()
|
||||
aiStore.cyclePanelMode()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ const projectsStore = useProjectStore()
|
||||
// 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。
|
||||
// 第三批抽离: 默认 model 选中逻辑(modelOverride=enabledModels[0])随 enabledModels 迁移至
|
||||
// TopBar.vue 的 activeConversationId watch;本组件仅保留编辑态取消。
|
||||
// 滚动边沿/跟随意图重置(SW-260618-18/B-260618-24)随 MessageList 迁移,由子组件自管。
|
||||
// 滚动边沿/跟随意图重置(SW-260618-18)随 MessageList 迁移,由子组件自管。
|
||||
watch(() => store.state.activeConversationId, () => {
|
||||
if (editingMsgId.value) cancelEdit()
|
||||
})
|
||||
|
||||
@@ -147,7 +147,7 @@ function onPageSizeChange(val: string) {
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: rgba(108, 99, 255, 0.06);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ function onDismiss() {
|
||||
border: 0.5px solid var(--df-accent);
|
||||
border-left: 3px solid var(--df-accent);
|
||||
border-radius: var(--df-radius-md);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(123, 111, 240, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--df-accent-soft);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--df-ease);
|
||||
user-select: none;
|
||||
@@ -155,7 +155,7 @@ function onDismiss() {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
border-radius: 8px;
|
||||
background: var(--df-danger);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
|
||||
@@ -118,6 +118,6 @@ onMounted(async () => {
|
||||
.ci-item--success { border-color: var(--df-success); }
|
||||
.ci-item--failure { border-color: var(--df-danger); }
|
||||
.ci-item--pending { border-color: var(--df-warning); }
|
||||
.ci-item-icon { font-size: 10px; font-weight: 700; }
|
||||
.ci-item-icon { font-size: 10px; font-weight: 500; }
|
||||
.ci-item:hover { opacity: 0.8; }
|
||||
</style>
|
||||
|
||||
@@ -143,7 +143,7 @@ watch(
|
||||
.conflict-type-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.badge--file { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
|
||||
.badge--semantic { background: rgba(220, 53, 69, 0.15); color: #dc3545; }
|
||||
|
||||
@@ -549,7 +549,7 @@ body.ai-sidebar-resizing * {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -1001,7 +1001,7 @@ onBeforeUnmount(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-goal-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--df-success); flex-shrink: 0; }
|
||||
.ai-goal-check { color: var(--df-success); font-weight: bold; flex-shrink: 0; }
|
||||
.ai-goal-check { color: var(--df-success); font-weight: 500; flex-shrink: 0; }
|
||||
.ai-goal-remove { border: none; background: transparent; color: var(--df-text-dim); cursor: pointer; padding: 0 2px; font-size: 13px; line-height: 1; flex-shrink: 0; }
|
||||
.ai-goal-remove:hover {
|
||||
color: var(--df-danger, #e5484d);
|
||||
|
||||
@@ -130,7 +130,7 @@ const displayProjects = computed(() =>
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.chip-coding { background: rgba(123,111,240,0.12); color: var(--df-accent); }
|
||||
.chip-coding { background: var(--df-accent-soft); color: var(--df-accent); }
|
||||
.chip-testing { background: rgba(240,199,94,0.12); color: var(--df-warning); }
|
||||
.chip-release { background: rgba(61,219,160,0.12); color: var(--df-success); }
|
||||
.chip-planning { background: rgba(94,175,240,0.12); color: var(--df-info); }
|
||||
|
||||
@@ -180,9 +180,16 @@
|
||||
</template>
|
||||
<!-- 编辑态 -->
|
||||
<template v-else>
|
||||
<div v-if="candidateIdeas.length > 0" class="related-edit-list">
|
||||
<div class="related-search" v-if="candidateIdeas.length > 0">
|
||||
<input
|
||||
v-model="relatedSearch"
|
||||
class="related-search-input"
|
||||
:placeholder="$t('ideas.relatedSearchPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="filteredCandidates.length > 0" class="related-edit-list">
|
||||
<label
|
||||
v-for="idea in candidateIdeas"
|
||||
v-for="idea in filteredCandidates"
|
||||
:key="idea.id"
|
||||
class="related-edit-item"
|
||||
>
|
||||
@@ -202,10 +209,15 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 传统评分条(score-bar) -->
|
||||
<!-- 传统评分条(score-bar,从旧评估体系保留,默认折叠以降低信息密度) -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ $t('ideas.multiScoreTitle') }}</h3>
|
||||
<div class="score-bar-chart" v-if="parseScores(idea.scores).length > 0">
|
||||
<h3>
|
||||
<button class="section-toggle-btn" @click="scoreBarExpanded = !scoreBarExpanded">
|
||||
{{ $t('ideas.multiScoreTitle') }}
|
||||
<span class="section-toggle-arrow" :class="{ 'is-open': scoreBarExpanded }">▾</span>
|
||||
</button>
|
||||
</h3>
|
||||
<div v-show="scoreBarExpanded" class="score-bar-chart" v-if="parseScores(idea.scores).length > 0">
|
||||
<div class="score-bar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name">
|
||||
<span class="score-bar-label">{{ dim.name }}</span>
|
||||
<div class="score-bar-track">
|
||||
@@ -220,10 +232,30 @@
|
||||
<!-- 标签 -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ $t('ideas.tagsTitle') }}</h3>
|
||||
<div class="tag-list" v-if="parseTags(idea.tags).length > 0">
|
||||
<span class="tag" v-for="tag in parseTags(idea.tags)" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div v-else class="eval-report eval-report--muted">{{ $t('ideas.noTags') }}</div>
|
||||
<template v-if="tagsEditing">
|
||||
<input
|
||||
v-model="tagsEditInput"
|
||||
class="detail-tags-edit-input"
|
||||
:placeholder="$t('ideas.tagsPlaceholder')"
|
||||
@keyup.enter="saveTagsEdit"
|
||||
/>
|
||||
<div class="tags-edit-actions">
|
||||
<button class="btn btn-primary btn-sm" :disabled="savingTags" @click="saveTagsEdit">
|
||||
{{ savingTags ? $t('common.loading') : $t('common.save') }}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="savingTags" @click="cancelTagsEdit">{{ $t('common.cancel') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="tag-list" v-if="parseTags(idea.tags).length > 0">
|
||||
<span class="tag" v-for="tag in parseTags(idea.tags)" :key="tag">{{ tag }}</span>
|
||||
<button class="btn btn-ghost btn-sm" @click="startTagsEdit">{{ $t('ideas.editTags') }}</button>
|
||||
</div>
|
||||
<div v-else class="eval-report eval-report--muted">
|
||||
{{ $t('ideas.noTags') }}
|
||||
<button class="btn btn-ghost btn-sm" @click="startTagsEdit">{{ $t('ideas.editTags') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 状态管理:P1-④ select → badge 点击(当前态显色 badge,点击展开下拉选项) -->
|
||||
@@ -292,6 +324,7 @@ const emit = defineEmits<{
|
||||
(e: 'status-change', value: IdeaStatus): void
|
||||
(e: 'update-desc', value: string): void
|
||||
(e: 'update-related', ids: string[]): void
|
||||
(e: 'update-tags', tags: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -336,6 +369,8 @@ const descExpanded = ref(false)
|
||||
|
||||
// P0-① 详细论据(正反方/行动建议)折叠:默认收起,降低首屏信息密度,结论前置后用户按需展开
|
||||
const debateExpanded = ref(false)
|
||||
// score-bar 默认折叠(旧评估体系,降低信息密度)
|
||||
const scoreBarExpanded = ref(false)
|
||||
|
||||
function measureDesc() {
|
||||
const el = descRef.value
|
||||
@@ -348,6 +383,7 @@ function measureDesc() {
|
||||
watch(() => props.idea.id, () => {
|
||||
descExpanded.value = false
|
||||
debateExpanded.value = false
|
||||
scoreBarExpanded.value = false
|
||||
void nextTick(measureDesc)
|
||||
})
|
||||
watch(() => props.idea.description, () => void nextTick(measureDesc))
|
||||
@@ -386,6 +422,15 @@ const vClickOutside: Directive<HTMLElement, () => void> = {
|
||||
}
|
||||
|
||||
// ===== 关联灵感(related_ids JSON 数组字符串)=====
|
||||
// 关联编辑搜索
|
||||
const relatedSearch = ref('')
|
||||
const filteredCandidates = computed(() => {
|
||||
const q = relatedSearch.value.trim().toLowerCase()
|
||||
if (!q) return candidateIdeas.value
|
||||
return candidateIdeas.value.filter(
|
||||
i => (i.title ?? '').toLowerCase().includes(q) || (i.description ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
// 解析 props.idea.related_ids(JSON 字符串数组,null/空/非法 → [])
|
||||
const relatedIds = computed<string[]>(() => {
|
||||
const raw = props.idea.related_ids
|
||||
@@ -426,6 +471,7 @@ function confirmRelate() {
|
||||
|
||||
function cancelRelate() {
|
||||
relatedEditing.value = false
|
||||
relatedSearch.value = ''
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
@@ -437,6 +483,33 @@ function toggleSelect(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 标签编辑(show + edit 双态)=====
|
||||
const tagsEditing = ref(false)
|
||||
const tagsEditInput = ref('')
|
||||
const savingTags = ref(false)
|
||||
|
||||
function startTagsEdit() {
|
||||
tagsEditInput.value = parseTags(props.idea.tags).join(', ')
|
||||
tagsEditing.value = true
|
||||
}
|
||||
|
||||
async function saveTagsEdit() {
|
||||
if (savingTags.value) return
|
||||
savingTags.value = true
|
||||
try {
|
||||
const tags = tagsEditInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
emit('update-tags', JSON.stringify(tags))
|
||||
tagsEditing.value = false
|
||||
await nextTick()
|
||||
} finally {
|
||||
savingTags.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelTagsEdit() {
|
||||
tagsEditing.value = false
|
||||
}
|
||||
|
||||
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
|
||||
function statusLabelKey(status: IdeaStatus): string {
|
||||
return props.statusOptions.find(s => s.value === status)?.labelKey ?? status
|
||||
@@ -639,7 +712,7 @@ watch(() => props.idea.id, loadHistory)
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.6;
|
||||
padding: 14px 16px;
|
||||
background: rgba(108, 99, 255, 0.06);
|
||||
background: var(--df-accent-bg);
|
||||
border-left: 3px solid var(--df-accent);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
@@ -884,7 +957,7 @@ watch(() => props.idea.id, loadHistory)
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(108, 99, 255, 0.12);
|
||||
background: var(--df-accent-soft);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.latest-tag {
|
||||
@@ -937,7 +1010,7 @@ watch(() => props.idea.id, loadHistory)
|
||||
.tag {
|
||||
font-size: 12px; padding: 4px 10px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(108, 99, 255, 0.1);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
|
||||
@@ -946,13 +1019,13 @@ watch(() => props.idea.id, loadHistory)
|
||||
.related-chip {
|
||||
font-size: 12px; padding: 4px 10px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(108, 99, 255, 0.1);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.related-chip:hover { background: rgba(108, 99, 255, 0.2); }
|
||||
.related-chip:hover { background: var(--df-accent-soft); }
|
||||
.related-chip-missing { cursor: default; opacity: 0.6; }
|
||||
|
||||
.related-edit-list {
|
||||
@@ -971,6 +1044,52 @@ watch(() => props.idea.id, loadHistory)
|
||||
}
|
||||
.related-edit-item input { cursor: pointer; }
|
||||
.related-edit-actions { display: flex; gap: 8px; }
|
||||
/* 关联编辑搜索框 */
|
||||
.related-search { margin-bottom: 8px; }
|
||||
.related-search-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 10px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.related-search-input:focus {
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ===== 标签编辑 ===== */
|
||||
.detail-tags-edit-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 10px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.detail-tags-edit-input:focus {
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
.tags-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.tag-list .btn-ghost {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.eval-report .btn-ghost {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* ===== 按钮已提取到 global.css 全局(.btn/.btn-primary/.btn-ghost/.btn-sm) ===== */
|
||||
/* IdeaDetail 特有按钮定位 */
|
||||
@@ -1057,8 +1176,8 @@ watch(() => props.idea.id, loadHistory)
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.status-menu-item:hover { background: rgba(108, 99, 255, 0.08); }
|
||||
.status-menu-item.active { background: rgba(108, 99, 255, 0.12); font-weight: 500; }
|
||||
.status-menu-item:hover { background: var(--df-accent-bg); }
|
||||
.status-menu-item.active { background: var(--df-accent-soft); font-weight: 500; }
|
||||
/* menu 内 badge 取消 emoji 之外的全局 hover 等副作用,仅作色标 */
|
||||
.status-menu-item .status-tag { cursor: pointer; }
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ watch(renderedContent, () => {
|
||||
/* ===== 知识内容 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css)===== */
|
||||
.detail-content.ai-md { white-space: normal; }
|
||||
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tag { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); background: rgba(108,99,255,0.1); color: var(--df-accent); }
|
||||
.tag { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); background: var(--df-accent-bg); color: var(--df-accent); }
|
||||
.muted { color: var(--df-text-dim); font-size: 12px; }
|
||||
|
||||
/* 状态徽章 / 置信度 */
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
<!-- 7b:收缩/展开切换按钮(最底部,快捷键 Ctrl+B 同效) -->
|
||||
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')">
|
||||
<span class="nav-link-icon">
|
||||
<svg v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
<svg v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</span>
|
||||
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.collapse') }}</span>
|
||||
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+B</span>
|
||||
@@ -150,7 +150,7 @@ const secondaryNav = [
|
||||
{ path: '/knowledge', label: 'nav.knowledge',
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
|
||||
{ path: '/audit', label: 'nav.audit',
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>' },
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -17,6 +17,22 @@
|
||||
<div>{{ $t('dependencyGraph.empty') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除依赖确认弹层 -->
|
||||
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="confirmState.dangerLabel" @result="answerConfirm" />
|
||||
|
||||
<!-- 边删除按钮(HTML 遮罩层):edge hover 时定位到边中点显示,leave 时隐藏。
|
||||
用 HTML 而非 X6 port 方案,因为 graph.fromJSON 重建所有 edge 会清掉动态加的 port。 -->
|
||||
<button
|
||||
v-if="deleteDepBtnVisible"
|
||||
class="dep-edge-delete-btn"
|
||||
:style="deleteDepBtnStyle"
|
||||
@click="onDeleteDepBtnClick"
|
||||
type="button"
|
||||
:title="$t('fileExplorer.deleteCurrentModule')"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- 添加依赖弹窗 -->
|
||||
<div v-if="showAddDep" class="dep-graph__modal-overlay" @click.self="showAddDep = false">
|
||||
<div class="dep-graph__modal">
|
||||
@@ -60,12 +76,15 @@
|
||||
*/
|
||||
import { onMounted, onBeforeUnmount, ref, watch, markRaw } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6'
|
||||
import dagre from 'dagre'
|
||||
import '@antv/x6-vue-shape'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ModuleNode from './ModuleNode.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
@@ -81,8 +100,15 @@ const containerRef = ref<HTMLElement | null>(null)
|
||||
const modules = ref<ProjectModuleRecord[]>([])
|
||||
const dependencies = ref<ModuleDependencyRecord[]>([])
|
||||
const loading = ref(true)
|
||||
const { t } = useI18n()
|
||||
const { confirmDialog, confirmState, answerConfirm } = useConfirm()
|
||||
let graph: Graph | null = null
|
||||
|
||||
// 边删除按钮(HTML 遮罩层)
|
||||
const deleteDepBtnVisible = ref(false)
|
||||
const deleteDepBtnStyle = ref<{ left: string; top: string }>({ left: '0px', top: '0px' })
|
||||
let deleteDepTarget: ModuleDependencyRecord | null = null
|
||||
|
||||
// 添加依赖弹窗
|
||||
const showAddDep = ref(false)
|
||||
const depFrom = ref('')
|
||||
@@ -165,8 +191,9 @@ function renderGraph() {
|
||||
}
|
||||
})
|
||||
|
||||
// 边数据(module_dependencies 表)
|
||||
// 边数据(module_dependencies 表);edge id 用 dep.id,删除时按 id 反查源/目标
|
||||
const edges = dependencies.value.map(d => ({
|
||||
id: d.id,
|
||||
source: d.from_module_id,
|
||||
target: d.to_module_id,
|
||||
labels: d.label ? [{ text: d.label }] : [{ text: d.dep_type }],
|
||||
@@ -206,6 +233,7 @@ function buildGraph() {
|
||||
})
|
||||
|
||||
renderGraph()
|
||||
bindEdgeDeleteButtons()
|
||||
}
|
||||
|
||||
function fitContent() {
|
||||
@@ -254,6 +282,65 @@ async function exportPNG() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除依赖(二次确认 + 重绘)。 */
|
||||
async function confirmRemoveDep(dep: ModuleDependencyRecord) {
|
||||
const fromName = modules.value.find(m => m.id === dep.from_module_id)?.name ?? dep.from_module_id
|
||||
const toName = modules.value.find(m => m.id === dep.to_module_id)?.name ?? dep.to_module_id
|
||||
const ok = await confirmDialog(`确定删除「${fromName} → ${toName}」的依赖吗?此操作不可撤销。`, t('common.delete'))
|
||||
if (!ok) return
|
||||
try {
|
||||
await moduleApi.removeModuleDependency(dep.id)
|
||||
Message.info('依赖已删除')
|
||||
await loadModules()
|
||||
renderGraph()
|
||||
bindEdgeDeleteButtons()
|
||||
} catch (e) {
|
||||
console.error('[DependencyGraph] 删除依赖失败:', e)
|
||||
Message.error('删除依赖失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击遮罩层上的删除按钮触发。 */
|
||||
function onDeleteDepBtnClick() {
|
||||
const dep = deleteDepTarget
|
||||
if (dep) void confirmRemoveDep(dep)
|
||||
hideEdgeDeleteBtn()
|
||||
}
|
||||
|
||||
/** edge hover 时显示删除按钮(定位到边中点屏幕坐标)。 */
|
||||
function showEdgeDeleteBtn(edge: any) {
|
||||
const depId = String(edge.id)
|
||||
const dep = dependencies.value.find(d => d.id === depId)
|
||||
if (!dep || !containerRef.value || !graph) return
|
||||
deleteDepTarget = dep
|
||||
// 算边中点(view 坐标)→ 容器像素坐标:transform.scale + translate
|
||||
const bbox = edge.getBBox()
|
||||
const mx = bbox.x + bbox.width / 2
|
||||
const my = bbox.y + bbox.height / 2
|
||||
const ts = graph.translate()
|
||||
const sc = graph.scale()
|
||||
const viewX = mx * sc.sx + ts.tx
|
||||
const viewY = my * sc.sy + ts.ty
|
||||
deleteDepBtnStyle.value = { left: `${viewX - 8}px`, top: `${viewY - 8}px` }
|
||||
deleteDepBtnVisible.value = true
|
||||
}
|
||||
|
||||
/** edge leave 时隐藏删除按钮。 */
|
||||
function hideEdgeDeleteBtn() {
|
||||
deleteDepBtnVisible.value = false
|
||||
deleteDepTarget = null
|
||||
}
|
||||
|
||||
/** 给所有 edge 绑 hover 显示删除按钮的监听。 */
|
||||
function bindEdgeDeleteButtons() {
|
||||
if (!graph) return
|
||||
try { graph.off('edge:mouseenter', onEdgeEnter); graph.off('edge:mouseleave', onEdgeLeave) } catch { /* ignore */ }
|
||||
function onEdgeEnter(e: any) { showEdgeDeleteBtn(e.edge) }
|
||||
function onEdgeLeave() { hideEdgeDeleteBtn() }
|
||||
graph.on('edge:mouseenter', onEdgeEnter)
|
||||
graph.on('edge:mouseleave', onEdgeLeave)
|
||||
}
|
||||
|
||||
/** 依赖类型 → 边颜色 */
|
||||
function depTypeColor(depType: string): string {
|
||||
switch (depType) {
|
||||
@@ -378,4 +465,26 @@ onBeforeUnmount(() => {
|
||||
.dep-graph__modal-actions {
|
||||
display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 边删除按钮(HTML 遮罩层,绝对定位在 .dep-graph__canvas 上) */
|
||||
.dep-edge-delete-btn {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--df-danger);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
transition: background 0.15s;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.dep-edge-delete-btn:hover {
|
||||
background: #c24040;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -559,7 +559,7 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.15s;
|
||||
@@ -725,7 +725,7 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
|
||||
.gc-commit-diff-toggle {
|
||||
background: none; border: 0.5px solid var(--df-border);
|
||||
color: var(--df-text-dim); cursor: pointer; font-size: 11px;
|
||||
padding: 2px 8px; border-radius: 3px;
|
||||
padding: 2px 8px; border-radius: 4px;
|
||||
}
|
||||
.gc-commit-diff-toggle:hover { color: var(--df-text); border-color: var(--df-accent); }
|
||||
.gc-commit-diff .gc-diff-content { flex: 1; min-height: 0; }
|
||||
@@ -738,7 +738,7 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
|
||||
transform: translateY(12px); opacity: 0;
|
||||
}
|
||||
|
||||
.gc-file-status { width: 20px; text-align: center; font-size: 11px; font-weight: 700; flex-shrink: 0; }
|
||||
.gc-file-status { width: 20px; text-align: center; font-size: 11px; font-weight: 500; flex-shrink: 0; }
|
||||
.gc-st-modified { color: #f0a020; }
|
||||
.gc-st-added { color: #4caf50; }
|
||||
.gc-st-deleted { color: #e05050; }
|
||||
@@ -806,8 +806,8 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
|
||||
.gc-branch-tag {
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(108,99,255,0.15);
|
||||
border-radius: 4px;
|
||||
background: var(--df-accent-soft);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.gc-branch-picker-hint {
|
||||
|
||||
@@ -101,7 +101,7 @@ const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
|
||||
.module-node__tag {
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
background: rgba(83, 120, 232, 0.15);
|
||||
color: #7c9aff;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ watch(() => props.view, () => nextTick(measureDesc))
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.stage-design { background: rgba(100,181,246,0.15); color: var(--df-info); }
|
||||
.stage-coding { background: rgba(108,99,255,0.15); color: var(--df-accent); }
|
||||
.stage-coding { background: var(--df-accent-soft); color: var(--df-accent); }
|
||||
.stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
|
||||
|
||||
@@ -251,7 +251,7 @@ onMounted(loadConnections)
|
||||
font-weight: 500;
|
||||
}
|
||||
.type-mysql { background: rgba(100,181,246,0.15); color: var(--df-info); }
|
||||
.type-ssh { background: rgba(108,99,255,0.15); color: var(--df-accent); }
|
||||
.type-ssh { background: var(--df-accent-soft); color: var(--df-accent); }
|
||||
.type-redis { background: rgba(255,107,107,0.15); color: var(--df-danger); }
|
||||
.type-mongo { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ function cancelEdit() {
|
||||
.wf-dag-node-name { font-weight: 500; white-space: nowrap; }
|
||||
.wf-dag-node-type {
|
||||
font-size: 10px; color: var(--df-text-dim, #888);
|
||||
background: var(--df-bg-card, #f0f0f0); padding: 0 4px; border-radius: 3px;
|
||||
background: var(--df-bg-card, #f0f0f0); padding: 0 4px; border-radius: 4px;
|
||||
}
|
||||
.wf-dag-node-status { font-size: 11px; }
|
||||
.wf-dag-node-status-dot {
|
||||
@@ -270,7 +270,7 @@ function cancelEdit() {
|
||||
}
|
||||
.wf-dag-cond-label--uncond { background: transparent; color: var(--df-text-dim, #aaa); }
|
||||
.wf-dag-cond-label--editable {
|
||||
cursor: pointer; font-size: 11px; padding: 1px 6px; border-radius: 3px;
|
||||
cursor: pointer; font-size: 11px; padding: 1px 6px; border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.wf-dag-cond-label--editable:hover { background: rgba(74,144,226,0.18); }
|
||||
@@ -284,7 +284,7 @@ function cancelEdit() {
|
||||
.wf-dag-edge-arrow { color: var(--df-text-primary); white-space: nowrap; }
|
||||
.wf-dag-edge-input {
|
||||
flex: 1; min-width: 0; font-family: var(--df-font-mono); font-size: 11px;
|
||||
padding: 2px 6px; border: 0.5px solid var(--df-border); border-radius: 3px;
|
||||
padding: 2px 6px; border: 0.5px solid var(--df-border); border-radius: 4px;
|
||||
background: var(--df-bg); color: var(--df-text); outline: none;
|
||||
}
|
||||
.wf-dag-edge-input:focus { border-color: var(--df-accent); }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 面板与持久化 — togglePanel/toggleMaximize + UI 持久化(restoreUiState/persistUiState)
|
||||
//! 面板与持久化 — togglePanel/toggleMaximize/cyclePanelMode + UI 持久化(restoreUiState/persistUiState)
|
||||
//! + token 开关 + providers/skills/clearChat
|
||||
//!
|
||||
//! 模块级私有:
|
||||
@@ -13,7 +13,9 @@ import { aiApi } from '@/api'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { state } from '@/stores/ai'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import type { AiProviderConfig } from '@/api/types'
|
||||
import { nextMsgId } from './aiShared'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import type { AiMessage, AiProviderConfig } from '@/api/types'
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
@@ -46,16 +48,25 @@ function applyUiState(s: any) {
|
||||
state.sidebarWidth = clampSidebarWidth(s.sidebarWidth)
|
||||
}
|
||||
|
||||
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state)
|
||||
// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局 + activeProvider(模块级单例 state)
|
||||
// F-260616-10: activeProvider 恢复对齐 df-ai-active-conv 模式;loadProviders 后还会二次校验
|
||||
// DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。
|
||||
function restoreUiState() {
|
||||
applyUiState(appSettings.get<any>('df-ai-ui', null))
|
||||
const savedProvider = appSettings.get<string | null>('df-ai-active-provider', null)
|
||||
if (savedProvider) state.activeProvider = savedProvider
|
||||
}
|
||||
restoreUiState()
|
||||
|
||||
// F-260616-10: 启动恢复 activeProvider(对齐 df-ai-active-conv 模式)。
|
||||
// loadProviders 后还会二次校验 DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。
|
||||
const savedProvider = appSettings.get<string | null>('df-ai-active-provider', null)
|
||||
if (savedProvider) state.activeProvider = savedProvider
|
||||
// 循环依赖兜底:stores/ai ↔ useAiPanel 互 import,顶层 restoreUiState 执行时 state 可能尚未
|
||||
// 初始化(TDZ)。冷启动靠 appSettings 缓存为空 → applyUiState(null) 提前 return 绕过访问;
|
||||
// 但 HMR 热重载 stores/ai.ts 时 appSettings 已有缓存,restoreUiState 真正访问 state 触发
|
||||
// ReferenceError: Cannot access 'state' before initialization。对齐 stores/ai.ts __bindMessages
|
||||
// 既存模式:try 失败则微任务延迟重试,待 state 就绪后落库恢复。
|
||||
try {
|
||||
restoreUiState()
|
||||
} catch {
|
||||
Promise.resolve().then(restoreUiState)
|
||||
}
|
||||
|
||||
// loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次)
|
||||
watch(() => appSettings.cache['df-ai-ui'], (v) => applyUiState(v as any))
|
||||
@@ -96,6 +107,34 @@ function toggleMaximize() {
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl+I 三态循环(关闭 → 分栏打开 → 最大化 → 关闭)。
|
||||
*
|
||||
* 与 togglePanel(二态开关)区分:本函数仅供全局快捷键 Ctrl+I 用;UI 按钮(侧栏 AI 按钮 /
|
||||
* TopBar 关闭钮)与审批浮层激活仍走 togglePanel,保持"开/关"二态语义不被破坏。
|
||||
*
|
||||
* 循环方向逐级增强再回关闭,贴合"要用 AI → 专注看 AI → 用完了"心智:
|
||||
* 关闭(panelOpen=false) → 分栏打开(panelOpen=true, maximized=false)
|
||||
* 分栏打开(maximized=false) → 最大化(maximized=true,全宽占满 main)
|
||||
* 最大化(maximized=true) → 关闭(panelOpen=false, maximized=false)
|
||||
*
|
||||
* detached 模式 no-op:分离窗口下主窗口不渲染面板,循环无意义;分离窗口自身走系统标题栏最大化。
|
||||
*/
|
||||
function cyclePanelMode() {
|
||||
// 分离窗口模式:主窗口无面板,三态循环无意义(no-op;关闭分离窗口时 destroyed 钩子自恢复 panelOpen)
|
||||
if (state.detached) return
|
||||
if (!state.panelOpen) {
|
||||
state.panelOpen = true
|
||||
state.maximized = false
|
||||
} else if (!state.maximized) {
|
||||
state.maximized = true
|
||||
} else {
|
||||
state.panelOpen = false
|
||||
state.maximized = false
|
||||
}
|
||||
persistUiState()
|
||||
}
|
||||
|
||||
// ── Providers / Skills / 清屏 ──
|
||||
|
||||
/** 拉取 provider 列表(用于顶部 bar 展示与切换) + 同步 DB 默认 provider 到 activeProvider */
|
||||
@@ -129,17 +168,35 @@ async function setProvider(providerId: string) {
|
||||
|
||||
/** 清空当前会话消息(后端 + 本地 state) */
|
||||
async function clearChat() {
|
||||
await aiApi.clearChat()
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
setStreaming(false, { reason: 'clearChat' })
|
||||
try {
|
||||
await aiApi.clearChat()
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
setStreaming(false, { reason: 'clearChat' })
|
||||
} catch (e) {
|
||||
// A2-B9(G3.2):清空失败需反馈,原裸 await 失败静默(异常进 unhandledrejection,用户无感)。
|
||||
// 后端裁决「先停 loop → DB 单事务 → 内存清」:DB 失败时内存不动(回滚一致),故此处
|
||||
// 只推错误气泡告知用户(对齐 useAiConversations.pushConvOpFail 模式),不吞异常日志。
|
||||
// 注意:不 rethrow——confirmClearChat 无 try,裸抛会进 unhandledrejection 且用户无感;
|
||||
// 错误气泡已足够反馈,且状态保持未清(与后端内存一致)。
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
state.messages.push({
|
||||
id: `conv-op-fail-${nextMsgId()}`,
|
||||
role: 'assistant',
|
||||
content: t('aiChat.clearError', { msg }),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
} as AiMessage)
|
||||
console.error('[AI] 清空对话失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export function useAiPanel() {
|
||||
return {
|
||||
togglePanel,
|
||||
toggleMaximize,
|
||||
cyclePanelMode,
|
||||
loadProviders,
|
||||
loadSkills,
|
||||
setProvider,
|
||||
|
||||
@@ -16,7 +16,7 @@ export function useMessageScroll(containerRef: Ref<HTMLDivElement | undefined>)
|
||||
// 回到底部按钮:内容溢出且不在底部时显示
|
||||
const showBackToBottom = ref(false)
|
||||
|
||||
// B-260618-24: 跟随意图锁存。点回到底部/滚到底/发新消息→true;用户上滑→false。
|
||||
// 跟随意图锁存。点回到底部/滚到底/发新消息→true;用户上滑→false。
|
||||
const isFollowingBottom = ref(true)
|
||||
// 边沿检测:上一帧是否在底部(用于触发 onNearBottom 回调)
|
||||
let wasNearBottom = true
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface UseStreamRendererOptions {
|
||||
saveSelection: () => void
|
||||
/** UX-2025-01:恢复之前保存的选区(DOM 重建后调) */
|
||||
restoreSelection: () => void
|
||||
/** B-260618-24:跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */
|
||||
/** 跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */
|
||||
isFollowingBottom: Ref<boolean> | ComputedRef<boolean>
|
||||
/** 回到底部操作 */
|
||||
scrollToBottom: () => void
|
||||
@@ -185,7 +185,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||
streamingBlocks.value = renderStreamingBlocks(lastStreamText)
|
||||
nextTick(() => {
|
||||
opts.restoreSelection()
|
||||
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
|
||||
// rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
|
||||
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
|
||||
// 滚到渲染前高度)致"差一截"。
|
||||
if (opts.isFollowingBottom.value) opts.scrollToBottom()
|
||||
|
||||
@@ -26,6 +26,10 @@ import type { AiToolCallInfo } from '@/api/types'
|
||||
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
||||
export interface ToolResult {
|
||||
path?: string
|
||||
// A1-B5: 只读工具(git status/diff/log)失败路径后端返 {success:false,error} 合法 JSON 对象(非 Err),
|
||||
// 前端 isToolFailure 靠此字段判失败(防绿框假成功)
|
||||
success?: boolean
|
||||
error?: string
|
||||
pattern?: string
|
||||
from?: string
|
||||
to?: string
|
||||
@@ -131,9 +135,11 @@ export function shouldKeepOpen(tc: AiToolCallInfo): boolean {
|
||||
*
|
||||
* 后端 AR-6:Low 风险工具失败不 emit AiError,改 emit AiToolCallCompleted(result=错误),
|
||||
* 故失败工具 status 仍为 'completed',与成功工具视觉同态(原绿色边框+绿色结果框)。
|
||||
* 此函数区分两类失败:
|
||||
* 此函数区分三类失败:
|
||||
* - run_command exit_code≠0:结果 JSON 合法,parseResult 命中,看 exit_code 判失败
|
||||
* - 通用工具失败:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."),
|
||||
* - 通用工具 success===false:只读工具(git status/diff/log)失败返 {success:false,error}
|
||||
* 合法 JSON 对象(非 Err),parseResult 命中但显式 success 为 false → 判失败
|
||||
* - 通用工具 raw 文本:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."),
|
||||
* 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号
|
||||
* 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式,
|
||||
* 行首锚定避免误伤含这些子串的正常产出,见 UX-260617-18)。
|
||||
@@ -150,8 +156,12 @@ export function isToolFailure(tc: AiToolCallInfo): boolean {
|
||||
// run_command 但无 exit_code(异常)→ 保守判失败
|
||||
if (r) return true
|
||||
}
|
||||
// 通用工具:可解析 JSON 且显式 success===false(git status/diff/log 只读工具失败返
|
||||
// {success:false,error} 合法 JSON 对象非 Err)→ 判失败,防绿框假成功
|
||||
const r = parseResult(tc.result)
|
||||
if (r && r.success === false) return true
|
||||
// 通用工具:parseResult 返回 null(非 JSON,AR-6 错误字符串路径)→ 看文本前缀
|
||||
if (!parseResult(tc.result)) {
|
||||
if (!r) {
|
||||
const raw = typeof tc.result === 'string' ? tc.result : ''
|
||||
if (!raw) return false
|
||||
// UX-260617-18:收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串,
|
||||
|
||||
@@ -88,6 +88,12 @@ function highlightCode(code: string, infostring: string): string {
|
||||
// infostring 形如 "ts" / "javascript" / "rust,norun" —— 取首个 token 去空白小写
|
||||
const langRaw = (infostring || '').split(/[\s,]/)[0]?.trim().toLowerCase() ?? ''
|
||||
const lang = _LANG_ALIASES[langRaw] ?? langRaw
|
||||
// Mermaid 图:保留原始语法(渲染方按需 mermaid.render 成图,不经过 highlight 误处理)。
|
||||
// 全项目 markdown 渲染共用此 renderer,统一让 mermaid 块以原文 class="language-mermaid" 呈现。
|
||||
if (lang === 'mermaid') {
|
||||
const safe = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
return `<pre><code class="hljs language-mermaid">${safe}</code></pre>`
|
||||
}
|
||||
let highlighted: string
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
|
||||
@@ -50,5 +50,14 @@ export default {
|
||||
human: 'Human',
|
||||
auto: 'Auto',
|
||||
},
|
||||
// Tab navigation
|
||||
tabAudit: 'Approval History',
|
||||
tabConversations: 'Conversations',
|
||||
// Conversation history
|
||||
convEmpty: 'No conversations yet',
|
||||
convLoading: 'Loading conversations…',
|
||||
convTruncated: 'Content truncated (expand to view full conversation)',
|
||||
convSelectHint: '← Select a conversation to view full interaction log',
|
||||
convReasoning: 'Reasoning (thinking)',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ export default {
|
||||
relatedManage: 'Manage relations',
|
||||
relatedConfirm: 'Confirm',
|
||||
relatedCancel: 'Cancel',
|
||||
relatedSearchPlaceholder: 'Search idea title or description...',
|
||||
|
||||
// Idea stats panel (used by IdeasPanel)
|
||||
statsPending: 'Pending',
|
||||
@@ -97,7 +98,8 @@ export default {
|
||||
|
||||
// Tags
|
||||
tagsTitle: '🏷️ Tags',
|
||||
noTags: 'No tags yet',
|
||||
noTags: 'No tags',
|
||||
editTags: 'Edit tags',
|
||||
|
||||
// Status management
|
||||
statusTitle: '📋 Status',
|
||||
@@ -139,5 +141,10 @@ export default {
|
||||
confirmStatus: 'Change idea "{title}" status from {from} to {to}?',
|
||||
promoteFailed: 'Promotion failed',
|
||||
evalFailed: 'Evaluation failed',
|
||||
// Batch operations
|
||||
batchDelete: 'Batch delete',
|
||||
confirmBatchDelete: 'Delete {n} selected ideas? This cannot be undone.',
|
||||
batchDeleteSuccess: 'Deleted {n} idea(s)',
|
||||
selectedCount: '{n} selected',
|
||||
},
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ export default {
|
||||
projects: 'Projects',
|
||||
tasks: 'Tasks',
|
||||
knowledge: 'Knowledge',
|
||||
audit: 'Audit Log',
|
||||
audit: 'History',
|
||||
settings: 'Settings',
|
||||
workspace: 'Workspace',
|
||||
traceability: 'Traceability',
|
||||
|
||||
@@ -50,5 +50,14 @@ export default {
|
||||
human: '人工',
|
||||
auto: '自动',
|
||||
},
|
||||
// Tab 导航
|
||||
tabAudit: '审批历史',
|
||||
tabConversations: '对话记录',
|
||||
// 对话历史
|
||||
convEmpty: '暂无对话记录',
|
||||
convLoading: '加载对话列表…',
|
||||
convTruncated: '内容过长已截断(展开查看完整对话)',
|
||||
convSelectHint: '← 选择左侧一次对话查看完整交互日志',
|
||||
convReasoning: '推理过程(thinking)',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export default {
|
||||
relatedManage: '管理关联',
|
||||
relatedConfirm: '确定',
|
||||
relatedCancel: '取消',
|
||||
relatedSearchPlaceholder: '搜索灵感标题或描述...',
|
||||
|
||||
// 灵感统计看板(IdeasPanel 引用)
|
||||
statsPending: '待评估',
|
||||
@@ -89,6 +90,7 @@ export default {
|
||||
// 标签
|
||||
tagsTitle: '🏷️ 标签',
|
||||
noTags: '暂无标签',
|
||||
editTags: '编辑标签',
|
||||
|
||||
// 状态管理
|
||||
statusTitle: '📋 状态管理',
|
||||
@@ -139,5 +141,10 @@ export default {
|
||||
confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?',
|
||||
promoteFailed: '立项失败',
|
||||
evalFailed: '评估失败',
|
||||
// 批量操作
|
||||
batchDelete: '批量删除',
|
||||
confirmBatchDelete: '确定删除选中的 {n} 个灵感?此操作不可撤销。',
|
||||
batchDeleteSuccess: '已删除 {n} 个灵感',
|
||||
selectedCount: '已选 {n} 项',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
projects: '项目',
|
||||
tasks: '任务',
|
||||
knowledge: '知识库',
|
||||
audit: '审批历史',
|
||||
audit: '历史记录',
|
||||
settings: '设置',
|
||||
workspace: '工作区',
|
||||
traceability: '追溯',
|
||||
|
||||
+7
-1
@@ -16,4 +16,10 @@ app.config.errorHandler = (err, instance, info) => {
|
||||
console.error(`[Vue error] <${tag}> ${info}:`, err);
|
||||
};
|
||||
|
||||
app.use(router).use(i18n).mount("#app");
|
||||
app.use(router).use(i18n);
|
||||
|
||||
// 等路由就绪再 mount:分离窗口(approval-popup / ai-detached / fe-detached)的 hash 路由须先解析完,
|
||||
// App.vue 的 isDetached(route.path === '/approval-popup' ...)才命中真值。否则初始帧 route.path='/'
|
||||
// → isDetached=false → AppLayout 把完整主布局(侧栏+主区+AI 面板)渲染进分离小窗
|
||||
// (approval-popup 380×480 尤为明显,看起来像「小小的完整 DevFlow 界面」)。
|
||||
router.isReady().then(() => app.mount("#app"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
DevFlow Design System — Claude 设计系统规范
|
||||
扁平化 · 0.5px 边框 · 8/12px 圆角 · 400/500 字重
|
||||
扁平化 · 0.5px 边框 · 8/12px 圆角 · 400/500/600 字重
|
||||
紫/青绿/琥珀/珊瑚 四色体系 · 深色优先
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
--df-pad-panel: 14px 16px; /* 面板/卡片内边距 */
|
||||
--df-msg-max-width: 90%; /* AI 气泡/工具卡片最大宽度(对齐不撑满) */
|
||||
|
||||
/* — Typography: 仅 400/500 字重 — */
|
||||
/* — Typography: 400/500/600 字重 — */
|
||||
--df-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--df-font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', monospace;
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ function onDragEnd() { isDragging.value = false }
|
||||
border: 0.5px solid var(--df-accent);
|
||||
border-left: 3px solid var(--df-accent);
|
||||
border-radius: var(--df-radius-md);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(123, 111, 240, 0.2);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--df-accent-soft);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -554,7 +554,7 @@ function onDragEnd() { isDragging.value = false }
|
||||
background: var(--df-danger);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
border: 1.5px solid var(--df-bg-card);
|
||||
@@ -730,7 +730,7 @@ function onDragEnd() { isDragging.value = false }
|
||||
border-radius: var(--df-radius-xs, 3px);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
opacity: 0.7;
|
||||
|
||||
+295
-3
@@ -3,14 +3,28 @@
|
||||
<header class="page-header">
|
||||
<h1>{{ t('auditLog.title') }}</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="reload" :disabled="loading">
|
||||
<button class="btn btn-ghost" @click="reloadAudit" :disabled="loading" v-if="activeTab === 'audit'">
|
||||
<svg v-if="loading" class="audit-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
||||
{{ t('auditLog.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="page-desc">{{ t('auditLog.desc') }}</p>
|
||||
<!-- Tab 导航 -->
|
||||
<nav class="detail-tabs audit-tabs">
|
||||
<button class="tab-btn" :class="{ 'tab-active': activeTab === 'audit' }" type="button" @click="switchTab('audit')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
{{ t('auditLog.tabAudit') }}
|
||||
</button>
|
||||
<button class="tab-btn" :class="{ 'tab-active': activeTab === 'conversations' }" type="button" @click="switchTab('conversations')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
{{ t('auditLog.tabConversations') }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- ═══ Tab: 审批历史 ═══ -->
|
||||
<template v-if="activeTab === 'audit'">
|
||||
<p class="page-desc">{{ t('auditLog.desc') }}</p>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar audit-filters">
|
||||
@@ -95,6 +109,62 @@
|
||||
<span class="pager-info">{{ t('auditLog.pager.page', { n: page }) }}{{ hasMore ? '' : t('auditLog.pager.last') }}<span class="pager-total">{{ t('auditLog.pager.total', { n: total }) }}</span></span>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- ═══ Tab: 对话历史 ═══ -->
|
||||
<template v-else>
|
||||
<div class="conv-split">
|
||||
<!-- 左侧:对话标题列表 -->
|
||||
<div class="conv-list-panel">
|
||||
<div class="conv-list" v-if="conversations.length > 0">
|
||||
<div v-for="conv in conversations" :key="conv.id" class="conv-list-item" :class="{ active: expandedConv === conv.id }" @click="toggleConv(conv.id)">
|
||||
<span class="conv-list-item-title">{{ conv.title || '—' }}</span>
|
||||
<span class="conv-list-item-date">{{ formatDate(conv.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!convLoading" class="empty-state">{{ t('auditLog.convEmpty') }}</div>
|
||||
<div v-if="convLoading && conversations.length === 0" class="empty-state"><span class="spinner"></span>{{ t('auditLog.convLoading') }}</div>
|
||||
</div>
|
||||
<!-- 右侧:完整交互日志 -->
|
||||
<div class="conv-detail-panel">
|
||||
<div v-if="!expandedConv" class="conv-detail-empty">{{ t('auditLog.convSelectHint') }}</div>
|
||||
<div v-else-if="convLoading" class="conv-detail-loading"><span class="spinner"></span>{{ t('auditLog.loading') }}</div>
|
||||
<div v-else-if="convError" class="conv-detail-error">{{ convError }}</div>
|
||||
<div v-else class="conv-messages">
|
||||
<div v-for="(msg, mi) in convMessages" :key="mi" class="conv-msg" :class="'conv-msg--' + msg.role">
|
||||
<!-- 头部:角色 + model + 时间 + token -->
|
||||
<div class="conv-msg-head">
|
||||
<span class="conv-msg-role">{{ msg.role === 'user' ? '🧑 用户' : msg.role === 'assistant' ? '🤖 助手' : msg.role === 'tool' ? '🔧 工具' : '⚙️ 系统' }}</span>
|
||||
<span v-if="msg.model" class="conv-msg-model">{{ msg.model }}</span>
|
||||
<span v-if="msg.tool_call_id" class="conv-msg-toolname">tool_call: {{ msg.tool_call_id }}</span>
|
||||
<span v-if="msg.timestamp" class="conv-msg-time">{{ formatDate(msg.timestamp) }}</span>
|
||||
<span v-if="msg.prompt_tokens || msg.completion_tokens" class="conv-msg-token">
|
||||
in={{ msg.prompt_tokens ?? 0 }} · out={{ msg.completion_tokens ?? 0 }}<template v-if="msg.reasoning_tokens"> · think={{ msg.reasoning_tokens }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 推理内容(DeepSeek thinking) -->
|
||||
<div v-if="msg.reasoning_content" class="conv-msg-reasoning">
|
||||
<div class="conv-msg-reasoning-label">🧠 {{ t('auditLog.convReasoning') }}</div>
|
||||
<pre class="conv-msg-text conv-msg-text--reasoning">{{ msg.reasoning_content }}</pre>
|
||||
</div>
|
||||
<!-- 正文:完整内容(发往/来自 LLM 的完整载荷) -->
|
||||
<div class="conv-msg-body">
|
||||
<pre class="conv-msg-text">{{ msg.content || (msg.role === 'tool' ? '—' : '') }}</pre>
|
||||
</div>
|
||||
<!-- 工具调用(assistant 发起,完整 name + arguments JSON) -->
|
||||
<div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="conv-msg-tools">
|
||||
<div v-for="(tc, tci) in msg.tool_calls" :key="tci" class="conv-msg-toolcall">
|
||||
<div class="conv-msg-toolcall-head">
|
||||
<span class="conv-msg-toolcall-name">🔧 {{ tc.function?.name || '工具调用' }}</span>
|
||||
<span v-if="tc.id" class="conv-msg-toolcall-id">{{ tc.id }}</span>
|
||||
</div>
|
||||
<pre v-if="tc.function?.arguments" class="conv-msg-toolcall-args">{{ tc.function.arguments }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -104,6 +174,8 @@ import { useI18n } from 'vue-i18n'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { formatRelative, formatDate } from '@/utils/time'
|
||||
import { usePersistedRef } from '@/composables/usePersistedRef'
|
||||
import { aiApi } from '@/api/ai'
|
||||
import type { AiConversationSummary } from '@/api/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -228,7 +300,78 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
// ── Tab �──
|
||||
const activeTab = usePersistedRef<'audit' | 'conversations'>('auditLog.activeTab', 'audit')
|
||||
function switchTab(tab: 'audit' | 'conversations') {
|
||||
activeTab.value = tab
|
||||
if (tab === 'conversations') void loadConversations()
|
||||
}
|
||||
|
||||
// ── 对话历史 ──
|
||||
const conversations = ref<AiConversationSummary[]>([])
|
||||
const expandedConv = ref<string | null>(null)
|
||||
const convLoading = ref(false)
|
||||
const convError = ref('')
|
||||
interface ConvToolCall {
|
||||
id?: string
|
||||
type?: string
|
||||
function?: { name: string; arguments: string }
|
||||
}
|
||||
interface ConvMessage {
|
||||
role: string
|
||||
content: string
|
||||
model?: string | null
|
||||
/** Unix 毫秒(ChatMessage.timestamp) */
|
||||
timestamp?: number | null
|
||||
reasoning_content?: string | null
|
||||
/** 工具调用(Vec<ToolCall>,role=assistant 时可能有) */
|
||||
tool_calls?: ConvToolCall[] | null
|
||||
/** 工具调用 ID(role=tool 时) */
|
||||
tool_call_id?: string | null
|
||||
parts?: { type?: string; text?: string; base64?: string; url?: string }[] | null
|
||||
prompt_tokens?: number | null
|
||||
completion_tokens?: number | null
|
||||
reasoning_tokens?: number | null
|
||||
status?: string | null
|
||||
}
|
||||
const convMessages = ref<ConvMessage[]>([])
|
||||
|
||||
async function loadConversations() {
|
||||
convLoading.value = true
|
||||
try {
|
||||
const list = await aiApi.listConversations(200, false)
|
||||
// 按 updated_at 降序排列
|
||||
conversations.value = (list ?? []).sort((a, b) => (b.updated_at > a.updated_at ? 1 : b.updated_at < a.updated_at ? -1 : 0))
|
||||
} catch (e) {
|
||||
conversations.value = []
|
||||
console.error('[AuditLog] 加载对话列表失败:', e)
|
||||
} finally {
|
||||
convLoading.value = false
|
||||
}
|
||||
}
|
||||
async function toggleConv(id: string) {
|
||||
if (expandedConv.value === id) {
|
||||
expandedConv.value = null
|
||||
convMessages.value = []
|
||||
return
|
||||
}
|
||||
expandedConv.value = id
|
||||
convLoading.value = true
|
||||
convError.value = ''
|
||||
try {
|
||||
const json = await aiApi.exportConversation(id, 'json')
|
||||
const parsed = JSON.parse(json) as ConvMessage[] ?? []
|
||||
convMessages.value = parsed || []
|
||||
} catch (e) {
|
||||
convError.value = String(e)
|
||||
convMessages.value = []
|
||||
} finally {
|
||||
convLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function reloadAudit() {
|
||||
if (loading.value) return
|
||||
offset.value = 0
|
||||
expandedId.value = null
|
||||
@@ -278,6 +421,7 @@ function decidedClass(d: string): string {
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
if (activeTab.value === 'conversations') void loadConversations()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -476,4 +620,152 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* empty-state 已提取到 global.css 全局 */
|
||||
|
||||
/* ===== Tab 导航(对齐 ProjectDetail 同款交互) ===== */
|
||||
.audit-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.audit-tabs .tab-btn {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-bottom: -0.5px;
|
||||
}
|
||||
.audit-tabs .tab-btn:hover { color: var(--df-text); }
|
||||
.audit-tabs .tab-btn.tab-active {
|
||||
color: var(--df-text);
|
||||
border-bottom-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ===== 对话历史 ===== */
|
||||
.conv-split {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: var(--df-gap-grid);
|
||||
min-height: 400px;
|
||||
}
|
||||
.conv-list-panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 220px);
|
||||
}
|
||||
.conv-list-item {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.conv-list-item:last-child { border-bottom: none; }
|
||||
.conv-list-item:hover { background: var(--df-bg-card-hover); }
|
||||
.conv-list-item.active { background: var(--df-accent-soft); border-left: 3px solid var(--df-accent); }
|
||||
.conv-list-item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.conv-list-item-date { font-size: 11px; color: var(--df-text-dim); }
|
||||
.conv-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);
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 220px);
|
||||
}
|
||||
.conv-detail-empty, .conv-detail-loading, .conv-detail-error {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; min-height: 200px;
|
||||
font-size: 13px; color: var(--df-text-dim);
|
||||
}
|
||||
.conv-detail-error { color: var(--df-danger); }
|
||||
.conv-messages { display: flex; flex-direction: column; gap: 10px; }
|
||||
.conv-msg {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.conv-msg--user { background: var(--df-accent-bg); }
|
||||
.conv-msg--assistant { background: var(--df-bg-raised); }
|
||||
.conv-msg--tool { background: rgba(90,99,128,0.1); }
|
||||
.conv-msg-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.conv-msg-role { font-size: 12px; font-weight: 500; color: var(--df-text); }
|
||||
.conv-msg-model, .conv-msg-toolname, .conv-msg-token {
|
||||
font-size: 10px; padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(90,99,128,0.2); color: var(--df-text-dim);
|
||||
}
|
||||
.conv-msg-token { font-family: var(--df-font-mono); color: var(--df-info); background: var(--df-info-bg); }
|
||||
.conv-msg-time { font-size: 10px; color: var(--df-text-dim); margin-left: auto; }
|
||||
.conv-msg-body { min-width: 0; }
|
||||
.conv-msg-text {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--df-font-sans);
|
||||
}
|
||||
/* 推理过程(thinking)块 */
|
||||
.conv-msg-reasoning {
|
||||
margin: 4px 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 2px solid var(--df-info);
|
||||
background: var(--df-info-bg);
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.conv-msg-reasoning-label {
|
||||
font-size: 10px; color: var(--df-info); font-weight: 500; margin-bottom: 4px;
|
||||
}
|
||||
.conv-msg-text--reasoning {
|
||||
font-size: 11px; color: var(--df-text-dim); font-style: italic;
|
||||
}
|
||||
/* 工具调用块(完整 name + arguments JSON) */
|
||||
.conv-msg-tools { margin-top: 4px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.conv-msg-toolcall {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-warning-bg);
|
||||
border: 0.5px solid rgba(240,199,94,0.2);
|
||||
}
|
||||
.conv-msg-toolcall-head {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 4px;
|
||||
}
|
||||
.conv-msg-toolcall-name { font-size: 11px; font-weight: 500; color: var(--df-warning); }
|
||||
.conv-msg-toolcall-id { font-size: 10px; color: var(--df-text-dim); font-family: var(--df-font-mono); }
|
||||
.conv-msg-toolcall-args {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
background: var(--df-bg);
|
||||
border-radius: var(--df-radius-xs);
|
||||
padding: 6px 8px;
|
||||
font-family: var(--df-font-mono);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,10 +11,6 @@
|
||||
<svg v-if="loading" class="dash-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
||||
<svg v-else 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="btn btn-primary btn-sm" @click="quickCapture">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
{{ $t('dashboard.captureIdea') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -58,7 +54,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 仪表盘页壳 — 仅保留:Header(refresh/quickCapture) + 错误条 + 布局栅格 + 加载编排(loadAll) +
|
||||
// 仪表盘页壳 — 仅保留:Header(refresh) + 错误条 + 布局栅格 + 加载编排(loadAll) +
|
||||
// 底部统计概要行(原独立统计卡 StatCardRow 已下沉为底部一行小字,空间让给项目/灵感面板)。
|
||||
// 项目面板/灵感面板已抽至 components/dashboard/ 子组件,各自共享 project store(全局单例),无需 props。
|
||||
import { onMounted, ref } from 'vue'
|
||||
@@ -101,10 +97,6 @@ async function refresh() {
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
function quickCapture() {
|
||||
router.push('/ideas')
|
||||
}
|
||||
|
||||
// 底部统计概要项点击跳转(可点击项:ideas/projects/tasks;drafts 不可点)
|
||||
function go(link: string) {
|
||||
router.push(link)
|
||||
|
||||
@@ -485,7 +485,7 @@ async function runImport() {
|
||||
}
|
||||
.view-toggle-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.view-toggle-btn.active {
|
||||
background: rgba(108, 99, 255, 0.12);
|
||||
background: var(--df-accent-soft);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user