优化: 前端批(审批体系一致 + GitChanges UI + token/控件统一 + 死代码)

审批体系一致:ApprovalPopup 浮窗加挂起时长提示(H1)+session 按钮+path deny 二次确认(M4/M5);ToolCard/useToolApproval 加 session+onDeny 确认(对齐 DirAuthDialog 四按钮)+ToolCardList decision 类型加 session;ApprovalDialog 失败显 toast(治 store 吞错 M31);ApprovalOverlay dismiss 改响应式(L26)

GitChanges UI(问题12 前端 12b-12h):showCommitFileDiff 精确匹配(12b)+去重 git log(12c)+详情滑出不挤压(12d)+双行提交行(12f)+搜索过滤(12g)+历史 Tab 显分支(12h)+diff 侧边栏内更宽可折叠面板(12e:FilePreview 不支持任意提交 diff,折中)

token+控件:global.css 定义 --df-primary(治幽灵变量 M26)+settings.css 加 .setting-number/.setting-input/.setting-textarea+4 Section 套用(问题8)

死代码:删 PlanProgress.vue(零引用)+PlanLayerInfo 类型+i18n 孤儿 key(L1)
This commit is contained in:
lxy
2026-08-02 12:42:52 +08:00
parent ffb202e2e9
commit caaabf0c15
19 changed files with 654 additions and 335 deletions
-10
View File
@@ -815,16 +815,6 @@ export interface ConflictRecord {
resolved_at: string | null resolved_at: string | null
} }
/** PlanProgress 展示用 DAG 层结构(事件载荷) */
export interface PlanLayerInfo {
items: Array<{
id: string
persona_id: string | null
intent: string
status: SubTaskStatus
}>
}
/** 冲突信息(AiMergeCompleted 事件载荷) */ /** 冲突信息(AiMergeCompleted 事件载荷) */
export interface ConflictInfo { export interface ConflictInfo {
id: string id: string
+19 -3
View File
@@ -49,7 +49,8 @@
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre> <pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
</div> </div>
<!-- path_auth 审批链阶段3b: tc.kind 分两组按钮 <!-- path_auth 审批链阶段3b: tc.kind 分两组按钮
- kind='path'(路径授权挂起):once/always/deny 选项, authorizeDir - kind='path'(路径授权挂起):once/session/always/deny 选项, authorizeDir
( DirAuthDialog.vue 同款四按钮;IPC authorizeDir 后端 once/session/always/deny 全支持)
- 其余(risk/缺省,普通 RiskLevel 审批):approve/reject 两选项, approve --> - 其余(risk/缺省,普通 RiskLevel 审批):approve/reject 两选项, approve -->
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path"> <div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
<button class="ai-tool-btn ai-tool-btn--approve" <button class="ai-tool-btn ai-tool-btn--approve"
@@ -59,6 +60,13 @@
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthOnce') }} {{ $t('aiChat.dirAuthOnce') }}
</button> </button>
<button class="ai-tool-btn ai-tool-btn--session"
:disabled="approving"
@click="onAuthorizeSession">
<span v-if="approving" class="ai-tool-btn-spinner" />
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthSession') }}
</button>
<button class="ai-tool-btn ai-tool-btn--always" <button class="ai-tool-btn ai-tool-btn--always"
:disabled="approving" :disabled="approving"
@click="onAuthorizeAlways"> @click="onAuthorizeAlways">
@@ -164,10 +172,10 @@ const emit = defineEmits<{
'install-obscura': [] 'install-obscura': []
/** /**
* 审批按钮 → 父级转发 store.approveToolCall。 * 审批按钮 → 父级转发 store.approveToolCall。
* path_auth 审批链阶段3b:risk 类带 approved boolean;path 类带 decision('once'|'always'|'deny')。 * path_auth 审批链阶段3b:risk 类带 approved boolean;path 类带 decision('once'|'session'|'always'|'deny')。
* 调用方(store.approveToolCall)按是否有 decision 分派 approve / authorizeDir。 * 调用方(store.approveToolCall)按是否有 decision 分派 approve / authorizeDir。
*/ */
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }] approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
}>() }>()
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。 // 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。
@@ -183,6 +191,7 @@ const {
onApprove, onApprove,
onReject, onReject,
onAuthorizeOnce, onAuthorizeOnce,
onAuthorizeSession,
onAuthorizeAlways, onAuthorizeAlways,
onDeny, onDeny,
confirmState, confirmState,
@@ -352,6 +361,13 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
} }
.ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); } .ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); }
.ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); } .ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); }
/* M4:session 按钮(accent 描边态,介于 approve 弱背景与 always 强 warning 之间;对齐 DirAuthDialog session 语义) */
.ai-tool-btn--session {
background: var(--df-accent-bg, rgba(123, 111, 240, 0.12));
color: var(--df-accent, #7b6ff0);
border-color: color-mix(in srgb, var(--df-accent, #7b6ff0) 40%, transparent);
}
.ai-tool-btn--session:hover { background: var(--df-accent, #7b6ff0); color: #fff; border-color: var(--df-accent, #7b6ff0); }
/* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */ /* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */
.ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); } .ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); }
.ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); } .ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); }
+2 -2
View File
@@ -65,9 +65,9 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
/** /**
* 审批按钮 → 父组件转发 store.approveToolCall。 * 审批按钮 → 父组件转发 store.approveToolCall。
* path_auth 审批链阶段3b:path 类带 decision('once'|'always'|'deny'),risk 类仅 approved boolean。 * path_auth 审批链阶段3b:path 类带 decision('once'|'session'|'always'|'deny'),risk 类仅 approved boolean。
*/ */
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }] approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
/** 批量审批 → 父组件调用 approveAll/rejectAll */ /** 批量审批 → 父组件调用 approveAll/rejectAll */
'batch-approve': [decision: 'approve' | 'reject'] 'batch-approve': [decision: 'approve' | 'reject']
/** /**
+34 -6
View File
@@ -41,7 +41,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useAiStore } from '@/stores/ai' import { useAiStore } from '@/stores/ai'
const store = useAiStore() const store = useAiStore()
@@ -57,18 +57,46 @@ const count = computed(() => store.state.pendingApprovals.length)
const dismissedAt = ref<number>(0) const dismissedAt = ref<number>(0)
const DISMISS_COOLDOWN_MS = 60_000 const DISMISS_COOLDOWN_MS = 60_000
// 显隐:有 pending + 未在冷却期 // L26: 响应式时钟。原 visible 直接读 Date.now()(非响应式),computed 仅在 dismissedAt/count 变化时
// 重算 → dismiss 满 60s 时无触发源,浮层不会自动重现。now 每秒翻一次驱动重算(60s 冷却期颗粒度足够,
// 浮层非精确计时器)。仅在 dismissed 时跑定时器节省空转。
const now = ref(Date.now())
let _tickTimer: ReturnType<typeof setInterval> | null = null
function startTick() {
if (_tickTimer) return
_tickTimer = setInterval(() => { now.value = Date.now() }, 1000)
}
function stopTick() {
if (_tickTimer) {
clearInterval(_tickTimer)
_tickTimer = null
}
}
// 显隐:有 pending + 未在冷却期(用响应式 now 替代裸 Date.now())
const visible = computed(() => { const visible = computed(() => {
if (count.value === 0) return false if (count.value === 0) return false
if (dismissedAt.value && Date.now() - dismissedAt.value < DISMISS_COOLDOWN_MS) return false if (dismissedAt.value && now.value - dismissedAt.value < DISMISS_COOLDOWN_MS) return false
return true return true
}) })
// 审批清空时复位 dismiss(下次有新审批会重新弹) // dismiss 启动时钟;冷却期到或审批清空时停时钟(避免无意义空转)
watch(count, (c) => { watch(dismissedAt, (d) => {
if (c === 0) dismissedAt.value = 0 if (d > 0) startTick()
else stopTick()
}) })
// 审批清空时复位 dismiss(下次有新审批会重新弹)+ 停时钟
watch(count, (c) => {
if (c === 0) {
dismissedAt.value = 0
stopTick()
}
})
onUnmounted(() => stopTick())
function onActivate() { function onActivate() {
emit('activate') emit('activate')
} }
-138
View File
@@ -1,138 +0,0 @@
<template>
<div class="plan-progress" v-if="visible && layers.length > 0">
<div class="plan-progress-header">
<span class="plan-progress-title">📋 {{ t('aiChat.planProgress') }}</span>
<span class="plan-progress-count">{{ doneCount }}/{{ totalCount }}</span>
</div>
<div class="plan-progress-layers">
<div v-for="(layer, li) in layers" :key="li" class="plan-layer">
<div class="plan-layer-label">{{ t('aiChat.planLayer', { n: li + 1 }) }}</div>
<div class="plan-layer-items">
<div
v-for="(item, ii) in layer.items"
:key="ii"
class="plan-item"
:class="'plan-item--' + item.status"
>
<span class="plan-item-icon" :class="'persona-' + (item.persona_id || 'default')">
{{ personaIcon(item.persona_id) }}
</span>
<span class="plan-item-label">{{ item.intent }}</span>
<span class="plan-item-status">{{ statusIcon(item.status) }}</span>
</div>
</div>
<div v-if="li < layers.length - 1" class="plan-layer-arrow"></div>
</div>
</div>
<!-- 冲突徽章 -->
<div v-if="conflicts.length > 0" class="plan-conflicts-badge" @click="emit('show-conflicts')">
{{ t('aiChat.conflictsPending', { n: conflicts.length }) }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PlanLayerInfo, ConflictInfo } from '@/api/types'
const { t } = useI18n()
const props = defineProps<{
visible: boolean
layers: PlanLayerInfo[]
conflicts?: ConflictInfo[]
}>()
const emit = defineEmits<{
(e: 'show-conflicts'): void
}>()
const conflicts = computed(() => props.conflicts || [])
const totalCount = computed(() =>
props.layers.reduce((s, l) => s + l.items.length, 0)
)
const doneCount = computed(() =>
props.layers.reduce((s, l) => s + l.items.filter(i => i.status === 'done').length, 0)
)
function personaIcon(personaId: string | null): string {
switch (personaId) {
case 'coder': return '🔵'
case 'reviewer': return '🔍'
case 'architect': return '📐'
case 'tester': return '🧪'
case 'analyst': return '📊'
default: return '⚪'
}
}
function statusIcon(status: string): string {
switch (status) {
case 'done': return '✓'
case 'running': return '▶'
case 'error': return '✗'
default: return '○'
}
}
</script>
<style scoped>
.plan-progress {
border: 1px solid var(--df-border);
border-radius: 8px;
padding: 8px 12px;
margin: 8px 0;
background: var(--df-bg-secondary);
font-size: 12px;
}
.plan-progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-weight: 600;
}
.plan-progress-title { color: var(--df-text); }
.plan-progress-count { color: var(--df-text-dim); }
.plan-layer { margin-bottom: 4px; }
.plan-layer-label {
font-size: 11px;
color: var(--df-text-dim);
margin-bottom: 4px;
}
.plan-layer-items {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.plan-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: var(--df-bg);
border: 1px solid var(--df-border);
}
.plan-item--done { border-color: var(--df-success); color: var(--df-success); }
.plan-item--running { border-color: var(--df-primary); color: var(--df-primary); }
.plan-item--error { border-color: var(--df-danger); color: var(--df-danger); }
.plan-item-icon { font-size: 10px; }
.plan-item-status { font-size: 10px; font-weight: 700; }
.plan-layer-arrow { text-align: center; color: var(--df-text-dim); font-size: 10px; }
.plan-conflicts-badge {
margin-top: 8px;
padding: 4px 8px;
background: var(--df-warning-bg, rgba(255, 193, 7, 0.1));
border: 1px solid var(--df-warning, #ffc107);
border-radius: 4px;
color: var(--df-warning, #ffc107);
cursor: pointer;
font-size: 11px;
}
.plan-conflicts-badge:hover {
opacity: 0.8;
}
</style>
+36
View File
@@ -49,12 +49,17 @@
<button class="btn btn-ghost" @click="emit('later')">{{ $t('projectDetail.approvalLater') }}</button> <button class="btn btn-ghost" @click="emit('later')">{{ $t('projectDetail.approvalLater') }}</button>
</div> </div>
</div> </div>
<!-- M31: 审批失败反馈(store approveHumanApproval 内部 catch 吞错,失败时 pendingApproval 不清空)
对话框保持开启,用户可看到错误并重试 -->
<div v-if="toast.visible" class="ad-toast" :class="'ad-toast-' + toast.type">{{ toast.msg }}</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import { useToast } from '@/composables/useToast'
// 抽离自 ProjectDetail.vue(L175-225 模板 + L416-471 审批逻辑/watch) // 抽离自 ProjectDetail.vue(L175-225 模板 + L416-471 审批逻辑/watch)
// store 共享:父级 useProjectStore() 实例传入(同源 store,响应式共享 pendingApproval) // store 共享:父级 useProjectStore() 实例传入(同源 store,响应式共享 pendingApproval)
@@ -76,11 +81,20 @@ const pendingApproval = computed(() => props.store.pendingApproval)
const isMultipleSelect = computed(() => pendingApproval.value?.select_type === 'multiple') const isMultipleSelect = computed(() => pendingApproval.value?.select_type === 'multiple')
const multiDecisions = ref<string[]>([]) const multiDecisions = ref<string[]>([])
const submitting = ref(false) const submitting = ref(false)
const { t } = useI18n()
const { toast, showToast } = useToast()
// M31: store.approveHumanApproval 内部 try/catch 吞错(只 console.error + 写 state.error,不 rethrow),
// 失败时 state.pendingApproval 不清空 → 以此为失败信号。失败显 toast、对话框保持开启,用户可重试或改取消。
async function handleApproval(decision: string) { async function handleApproval(decision: string) {
submitting.value = true submitting.value = true
try { try {
await props.store.approveHumanApproval([decision]) await props.store.approveHumanApproval([decision])
if (props.store.pendingApproval) {
// 仍 pending = IPC 失败(成功路径 store 已置 null)
showToast(props.store.error || t('projects.err.approveFailed'), 'error', 5000)
return
}
emit('cancel') emit('cancel')
} finally { } finally {
submitting.value = false submitting.value = false
@@ -94,6 +108,10 @@ async function handleApprovalMulti() {
try { try {
// 统一传 decisions 数组,store 按 select_type 分派单/多选 // 统一传 decisions 数组,store 按 select_type 分派单/多选
await props.store.approveHumanApproval([...multiDecisions.value]) await props.store.approveHumanApproval([...multiDecisions.value])
if (props.store.pendingApproval) {
showToast(props.store.error || t('projects.err.approveFailed'), 'error', 5000)
return
}
multiDecisions.value = [] multiDecisions.value = []
emit('cancel') emit('cancel')
} finally { } finally {
@@ -108,3 +126,21 @@ watch(() => pendingApproval.value, (newApproval) => {
} }
}, { immediate: true }) }, { immediate: true })
</script> </script>
<style scoped>
/* M31: 审批失败 toast(scoped 自治,不引入全局样式;沿用 Projects.vue .toast 视觉规范) */
.ad-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10001;
padding: 10px 18px;
border-radius: var(--df-radius-md);
font-size: 13px;
font-weight: 500;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.ad-toast-error { background: var(--df-danger); color: #fff; }
</style>
+283 -118
View File
@@ -1,10 +1,11 @@
<template> <template>
<div class="git-changes"> <div class="git-changes">
<!-- 分支信息 --> <!-- 分支信息(变更 + 历史 Tab 均显示当前分支) -->
<div v-if="branchName" class="git-branch-bar"> <div v-if="branchName" class="git-branch-bar">
<span class="branch-icon"></span> <span class="branch-icon"></span>
<span class="branch-name">{{ branchName }}</span> <span class="branch-name">{{ branchName }}</span>
<span v-if="!showHistory" class="branch-count">{{ gitStatus?.changed_files.length ?? 0 }} {{ $t('gitChanges.changedFiles') }}</span> <span v-if="!showHistory" class="branch-count">{{ gitStatus?.changed_files.length ?? 0 }} {{ $t('gitChanges.changedFiles') }}</span>
<span v-else class="branch-count">{{ totalCommits }} {{ $t('gitChanges.commitsUnit') }}</span>
<button v-if="!showHistory" class="branch-switch-btn" @click="onSwitchBranch" :title="$t('gitChanges.switchBranch')"> <button v-if="!showHistory" class="branch-switch-btn" @click="onSwitchBranch" :title="$t('gitChanges.switchBranch')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
</button> </button>
@@ -72,37 +73,60 @@
<span class="gc-file-name">{{ filePathLeaf(f.path) }}</span> <span class="gc-file-name">{{ filePathLeaf(f.path) }}</span>
</div> </div>
</div> </div>
</div>
<!-- 变更文件 diff 预览(选中文件时在列表下方展开) --> <!-- ====== 提交历史(列表始终可见,详情侧边/底部) ====== -->
<div v-if="selectedFile" class="gc-file-diff"> <div v-else class="gc-history">
<div class="gc-file-diff-header"> <!-- 提交搜索框(前端过滤已加载提交:subject/author) -->
<span class="gc-file-diff-path">{{ selectedFile }}</span> <div class="gc-search">
<button class="gc-file-diff-close" @click="selectedFile = ''; diffContent = ''"></button> <svg class="gc-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
v-model="commitSearch"
class="gc-search-input"
:placeholder="$t('gitChanges.searchPlaceholder')"
type="text"
/>
<button v-if="commitSearch" class="gc-search-clear" @click="commitSearch = ''"></button>
</div> </div>
<div v-if="diffLoading" class="gc-loading"><span class="spinner"></span></div>
<div v-else-if="!diffContent" class="gc-diff-empty">{{ $t('gitChanges.noDiff') }}</div> <!-- 提交列表(始终可见,双行布局) -->
<div v-else class="gc-diff-content"> <div class="gc-history-list">
<div v-for="(ln, idx) in fileDiffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type"> <div v-if="filteredCommits.length === 0" class="gc-empty gc-empty-inline">
<span class="diff-hdr-text" v-if="ln.type === 'hdr'">{{ ln.text }}</span> {{ commitSearch ? $t('gitChanges.noSearchResult') : $t('gitChanges.noCommits') }}
<template v-else>
<span class="diff-line-prefix">{{ ln.prefix }}</span>
<span class="diff-line-text">{{ ln.text }}</span>
</template>
</div> </div>
<div
v-for="c in filteredCommits"
:key="c.hash"
class="gc-commit-row"
:class="{ selected: selectedCommit?.hash === c.hash }"
@click="selectCommit(c)"
>
<div class="gc-commit-line1">
<span class="gc-commit-hash">{{ c.hash.slice(0, 8) }}</span>
<span class="gc-commit-author">{{ c.author }}</span>
<span class="gc-commit-time">{{ formatTime(c.timestamp) }}</span>
</div> </div>
<div class="gc-commit-msg">{{ c.subject }}</div>
</div> </div>
</div> </div>
<!-- ====== 提交历史 ====== --> <!-- 加载更多 -->
<div v-else class="gc-history-wrapper"> <div v-if="hasMoreCommits && !loadingMore" class="gc-load-more" @click="loadMoreCommits">
<!-- 已选提交的详情 --> {{ $t('gitChanges.loadMore') }}
</div>
<div v-if="loadingMore" class="gc-loading"><span class="spinner"></span></div>
</div>
</div>
<!-- ====== 提交详情(从底部/侧边滑出,列表始终可见) ====== -->
<transition name="gc-detail-slide">
<div v-if="selectedCommit" class="gc-commit-detail"> <div v-if="selectedCommit" class="gc-commit-detail">
<div class="gc-commit-detail-header"> <div class="gc-commit-detail-header">
<span class="gc-commit-detail-hash">{{ selectedCommit.hash }}</span> <span class="gc-commit-detail-hash">{{ selectedCommit.hash }}</span>
<span class="gc-commit-detail-author">{{ selectedCommit.author }}</span> <span class="gc-commit-detail-author">{{ selectedCommit.author }}</span>
<span class="gc-commit-detail-msg">{{ selectedCommit.subject }}</span>
<span class="gc-commit-detail-time">{{ formatTime(selectedCommit.timestamp) }}</span> <span class="gc-commit-detail-time">{{ formatTime(selectedCommit.timestamp) }}</span>
<button class="gc-detail-close" @click="selectedCommit = null; commitDetail = null"></button> <span class="gc-commit-detail-msg">{{ selectedCommit.subject }}</span>
<button class="gc-detail-close" @click="closeCommitDetail"></button>
</div> </div>
<div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div> <div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
<template v-else-if="commitDetail"> <template v-else-if="commitDetail">
@@ -119,8 +143,16 @@
<span class="gc-file-path">{{ f.path }}</span> <span class="gc-file-path">{{ f.path }}</span>
</div> </div>
</div> </div>
<!-- Diff 预览 --> <!-- Diff 预览(可收起,占更宽空间) -->
<div v-if="commitSelectedFile && commitDiff" class="gc-diff-content"> <div v-if="commitSelectedFile" class="gc-commit-diff">
<div class="gc-commit-diff-header">
<span class="gc-commit-diff-path">{{ commitSelectedFile }}</span>
<button class="gc-commit-diff-toggle" @click="commitDiffCollapsed = !commitDiffCollapsed">
{{ commitDiffCollapsed ? $t('gitChanges.expand') : $t('gitChanges.collapse') }}
</button>
</div>
<div v-show="!commitDiffCollapsed" class="gc-diff-content">
<div v-if="!commitDiff" class="gc-diff-empty">{{ $t('gitChanges.noDiff') }}</div>
<div v-for="(ln, idx) in commitDiffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type"> <div v-for="(ln, idx) in commitDiffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
<span class="diff-hdr-text" v-if="ln.type === 'hdr'">{{ ln.text }}</span> <span class="diff-hdr-text" v-if="ln.type === 'hdr'">{{ ln.text }}</span>
<template v-else> <template v-else>
@@ -129,38 +161,19 @@
</template> </template>
</div> </div>
</div> </div>
</div>
</template> </template>
</div> </div>
</transition>
<!-- 提交列表 -->
<div class="gc-history-list">
<div
v-for="c in commits"
:key="c.hash"
class="gc-commit-row"
:class="{ selected: selectedCommit?.hash === c.hash }"
@click="selectCommit(c)"
>
<span class="gc-commit-hash">{{ c.hash.slice(0, 8) }}</span>
<span class="gc-commit-author">{{ c.author }}</span>
<span class="gc-commit-msg">{{ c.subject }}</span>
<span class="gc-commit-time">{{ formatTime(c.timestamp) }}</span>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMoreCommits && !loadingMore" class="gc-load-more" @click="loadMoreCommits">
{{ $t('gitChanges.loadMore') }}
</div>
<div v-if="loadingMore" class="gc-loading"><span class="spinner"></span></div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/** /**
* Git 变更查看器 — 变更文件列表(按目录树分组) + 提交历史(分页) + 文件 diff 预览。 * Git 变更查看器 — 变更文件列表(按目录树分组) + 提交历史(分页/搜索) + 文件 diff 预览。
*
* 历史 Tab 布局:列表始终可见,选中提交的详情从底部滑出(可收起),diff 在更宽容器渲染,
* 避免几百行 diff 把列表挤走、或在 30% 窄栏内挤压。
*/ */
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { moduleApi, type GitStatusResult } from '@/api/module' import { moduleApi, type GitStatusResult } from '@/api/module'
@@ -183,20 +196,41 @@ const selectedFile = ref('')
const diffContent = ref('') const diffContent = ref('')
const diffLoading = ref(false) const diffLoading = ref(false)
/** 单条提交记录形状(对齐 getModuleCommits / GitRecentCommit)。 */
interface CommitItem {
hash: string
subject: string
timestamp: number
author: string
}
// 提交历史(分页) // 提交历史(分页)
const commits = ref<{ hash: string; subject: string; timestamp: number; author: string }[]>([]) const commits = ref<CommitItem[]>([])
const totalCommits = ref(0) const totalCommits = ref(0)
const hasMoreCommits = ref(false) const hasMoreCommits = ref(false)
const loadingMore = ref(false) const loadingMore = ref(false)
const commitSkip = ref(0) const commitSkip = ref(0)
const COMMIT_PAGE_SIZE = 50 const COMMIT_PAGE_SIZE = 50
// 提交搜索(前端过滤已加载提交:按 subject 关键词或作者)
const commitSearch = ref('')
/** 已加载提交按搜索词过滤(subject 包含或 author 包含)。 */
const filteredCommits = computed<CommitItem[]>(() => {
const q = commitSearch.value.trim().toLowerCase()
if (!q) return commits.value
return commits.value.filter(
(c) => c.subject.toLowerCase().includes(q) || c.author.toLowerCase().includes(q),
)
})
// 已选提交详情 // 已选提交详情
const selectedCommit = ref<{ hash: string; subject: string; timestamp: number; author: string } | null>(null) const selectedCommit = ref<CommitItem | null>(null)
const commitDetail = ref<{ files: { status: string; path: string }[]; diff: string } | null>(null) const commitDetail = ref<{ files: { status: string; path: string }[]; diff: string } | null>(null)
const commitDetailLoading = ref(false) const commitDetailLoading = ref(false)
const commitSelectedFile = ref('') const commitSelectedFile = ref('')
const commitDiff = ref('') const commitDiff = ref('')
const commitDiffCollapsed = ref(false)
const branchName = computed(() => gitStatus.value?.branch || '') const branchName = computed(() => gitStatus.value?.branch || '')
@@ -242,7 +276,6 @@ interface DiffLine {
} }
const commitDiffLines = computed<DiffLine[]>(() => parseDiff(commitDiff.value)) const commitDiffLines = computed<DiffLine[]>(() => parseDiff(commitDiff.value))
const fileDiffLines = computed<DiffLine[]>(() => parseDiff(diffContent.value))
function parseDiff(text: string): DiffLine[] { function parseDiff(text: string): DiffLine[] {
if (!text) return [] if (!text) return []
@@ -300,17 +333,80 @@ function filePathLeaf(path: string): string {
return segs[segs.length - 1] || path return segs[segs.length - 1] || path
} }
/**
* 从一次提交的全量 diff 文本中,精确提取指定 path 对应的那一块。
*
* 按 `diff --git a/<path> b/<path>` 头切块,避免子串误配
* (例:`line.includes("src/app.rs")` 会误匹配 `src/app.rs.bak`)。
* 解析 a/ 与 b/ 路径,任一等于目标 path 即命中;切块持续到下一个 `diff --git` 或文本末尾。
*/
function extractFileDiff(allDiff: string, targetPath: string): string {
if (!allDiff) return ''
const lines = allDiff.split('\n')
const chunks: string[][] = []
const chunkPaths: string[] = []
// 第一行若不是 diff 头(罕见:全局元信息),归入一个无 path 的前置块,不参与匹配。
let current: string[] | null = null
for (const line of lines) {
if (line.startsWith('diff --git ')) {
// 起新块
if (current) chunks.push(current)
current = [line]
chunkPaths.push(parseDiffGitPath(line))
} else {
if (!current) current = [] // 兜底:头之前的行
current.push(line)
}
}
if (current) chunks.push(current)
// 定位目标块:精确匹配 a/ 或 b/ 路径(支持重命名,两边任一命中即取该块)
for (let i = 0; i < chunks.length; i++) {
const p = chunkPaths[i]
if (p && (p === targetPath || pathEqualsAnySide(p, targetPath))) {
return chunks[i].join('\n')
}
}
return ''
}
/** 解析 `diff --git a/<path> b/<path>` 头,返回 "a/path|b/path" 复合串(用于后续精确比对)。 */
function parseDiffGitPath(header: string): string {
// 形如 "diff --git a/foo/bar.rs b/foo/bar.rs",空格分隔;path 内不含空格时成立。
// 取第 3、4 段(a/.. b/..),用 "|" 拼接便于 pathEqualsAnySide 判定。
const parts = header.split(/\s+/)
const a = parts[2] || ''
const b = parts[3] || ''
return `${a}|${b}`
}
/** 给定 parseDiffGitPath 产物与目标 path,判断目标是否等于 a/ 或 b/ 路径。 */
function pathEqualsAnySide(parsed: string, targetPath: string): boolean {
const [a, b] = parsed.split('|')
const aPath = a.startsWith('a/') ? a.slice(2) : a
const bPath = b.startsWith('b/') ? b.slice(2) : b
return aPath === targetPath || bPath === targetPath
}
async function loadStatus() { async function loadStatus() {
if (!props.moduleId) return if (!props.moduleId) return
loading.value = true loading.value = true
try { try {
gitStatus.value = await moduleApi.getModuleGitStatus(props.moduleId) gitStatus.value = await moduleApi.getModuleGitStatus(props.moduleId)
// 同时初始化前 50 条提交 // 复用 getModuleGitStatus 内部已返回的 recent_commits(避免再发一次 getModuleCommits
const res = await moduleApi.getModuleCommits(props.moduleId, 0, COMMIT_PAGE_SIZE) // 造成首次 git log -50 重复请求 + recent_commits 被丢弃)。
commits.value = res.commits const recent = gitStatus.value?.recent_commits ?? []
hasMoreCommits.value = res.has_more commits.value = recent.map((c) => ({
totalCommits.value = res.commits.length hash: c.hash,
commitSkip.value = res.commits.length subject: c.subject,
timestamp: c.timestamp,
author: c.author,
}))
totalCommits.value = commits.value.length
commitSkip.value = commits.value.length
// recent_commits 通常为 50 条;若返回等于页大小则假定还有更多(精确 total 需后端,见 12a)。
hasMoreCommits.value = commits.value.length >= COMMIT_PAGE_SIZE
} catch { } catch {
gitStatus.value = null gitStatus.value = null
commits.value = [] commits.value = []
@@ -352,9 +448,13 @@ async function loadMoreCommits() {
loadingMore.value = true loadingMore.value = true
try { try {
const res = await moduleApi.getModuleCommits(props.moduleId, commitSkip.value, COMMIT_PAGE_SIZE) const res = await moduleApi.getModuleCommits(props.moduleId, commitSkip.value, COMMIT_PAGE_SIZE)
commits.value.push(...res.commits) // 按哈希去重(防止分页边界与 recent_commits 重叠)
const exist = new Set(commits.value.map((c) => c.hash))
for (const c of res.commits) {
if (!exist.has(c.hash)) commits.value.push(c)
}
hasMoreCommits.value = res.has_more hasMoreCommits.value = res.has_more
commitSkip.value = commits.value.length commitSkip.value += res.commits.length
totalCommits.value = commits.value.length totalCommits.value = commits.value.length
} catch { } catch {
// 静默失败 // 静默失败
@@ -365,10 +465,7 @@ async function loadMoreCommits() {
async function switchToHistory() { async function switchToHistory() {
showHistory.value = true showHistory.value = true
// 如果只加载了初始数量,尝试补页 // recent_commits 已填首页,无需在此补页(原 < 50 补页逻辑已无意义,留 loadMore 手动触发)
if (hasMoreCommits.value && commits.value.length < 50) {
await loadMoreCommits()
}
} }
async function selectFile(path: string) { async function selectFile(path: string) {
@@ -387,11 +484,17 @@ async function selectFile(path: string) {
} }
/** 点击提交行 → 加载该提交的变更文件列表 */ /** 点击提交行 → 加载该提交的变更文件列表 */
async function selectCommit(c: { hash: string; subject: string; timestamp: number; author: string }) { async function selectCommit(c: CommitItem) {
// 点击已选提交 → 收起详情(二次点击切换)
if (selectedCommit.value?.hash === c.hash) {
closeCommitDetail()
return
}
selectedCommit.value = c selectedCommit.value = c
commitDetail.value = null commitDetail.value = null
commitSelectedFile.value = '' commitSelectedFile.value = ''
commitDiff.value = '' commitDiff.value = ''
commitDiffCollapsed.value = false
commitDetailLoading.value = true commitDetailLoading.value = true
try { try {
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash) const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
@@ -403,24 +506,22 @@ async function selectCommit(c: { hash: string; subject: string; timestamp: numbe
} }
} }
/** 在提交详情中点击文件 → 过滤出该文件的 diff */ function closeCommitDetail() {
selectedCommit.value = null
commitDetail.value = null
commitSelectedFile.value = ''
commitDiff.value = ''
}
/** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块) */
function showCommitFileDiff(path: string) { function showCommitFileDiff(path: string) {
commitSelectedFile.value = path commitSelectedFile.value = path
if (!commitDetail.value) return commitDiffCollapsed.value = false
// 从全量 diff 中提取该文件的块 if (!commitDetail.value) {
const diff = commitDetail.value.diff commitDiff.value = ''
const lines = diff.split('\n') return
let inTarget = false
const targetLines: string[] = []
for (const line of lines) {
if (line.startsWith('diff --git')) {
inTarget = line.includes(path)
} }
if (inTarget) { commitDiff.value = extractFileDiff(commitDetail.value.diff, path)
targetLines.push(line)
}
}
commitDiff.value = targetLines.join('\n')
} }
watch(() => props.moduleId, loadStatus, { immediate: true }) watch(() => props.moduleId, loadStatus, { immediate: true })
@@ -483,27 +584,6 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
position: sticky; top: 0; z-index: 1; position: sticky; top: 0; z-index: 1;
} }
/* 变更文件 diff 预览(列表下方展开) */
.gc-file-diff {
margin-top: 8px;
border-top: 0.5px solid var(--df-border);
}
.gc-file-diff-header {
display: flex;
align-items: center;
padding: 6px 14px;
background: rgba(255,255,255,0.03);
font-family: var(--df-font-mono, Consolas, monospace);
font-size: 11px;
border-bottom: 0.5px solid var(--df-border);
}
.gc-file-diff-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--df-text); }
.gc-file-diff-close {
background: none; border: none; color: var(--df-text-dim);
cursor: pointer; font-size: 14px; padding: 0 4px;
}
.gc-file-diff-close:hover { color: var(--df-text); }
/* Diff 预览 */ /* Diff 预览 */
.gc-diff-content { .gc-diff-content {
font-family: var(--df-font-mono, Consolas, monospace); font-family: var(--df-font-mono, Consolas, monospace);
@@ -522,52 +602,95 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
.diff-del .diff-line-prefix { color: #e05050; } .diff-del .diff-line-prefix { color: #e05050; }
.diff-ctx { color: var(--df-text); } .diff-ctx { color: var(--df-text); }
/* 提交历史 */ /* ====== 提交历史 ====== */
.gc-history-wrapper { display: flex; flex-direction: column; } .gc-history { display: flex; flex-direction: column; min-height: 0; }
/* 搜索框 */
.gc-search {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px; border-bottom: 0.5px solid var(--df-border);
background: var(--df-bg-card); flex-shrink: 0;
}
.gc-search-icon { color: var(--df-text-dim); flex-shrink: 0; }
.gc-search-input {
flex: 1; min-width: 0;
background: rgba(255,255,255,0.04); border: 0.5px solid var(--df-border);
border-radius: 4px; padding: 4px 8px;
color: var(--df-text); font-size: 12px; outline: none;
}
.gc-search-input:focus { border-color: var(--df-accent); }
.gc-search-input::placeholder { color: var(--df-text-dim); }
.gc-search-clear {
background: none; border: none; color: var(--df-text-dim);
cursor: pointer; font-size: 12px; padding: 0 4px;
}
.gc-search-clear:hover { color: var(--df-text); }
/* 提交列表 */
.gc-history-list { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; }
.gc-commit-row { .gc-commit-row {
display: flex; align-items: center; gap: 10px; display: flex; flex-direction: column; gap: 2px;
padding: 8px 14px; border-bottom: 0.5px solid rgba(255,255,255,0.03); padding: 6px 14px; border-bottom: 0.5px solid rgba(255,255,255,0.03);
cursor: pointer; transition: background 0.1s; cursor: pointer; transition: background 0.1s;
} }
.gc-commit-row:hover { background: rgba(255,255,255,0.04); } .gc-commit-row:hover { background: rgba(255,255,255,0.04); }
.gc-commit-row.selected { background: rgba(255,255,255,0.06); } .gc-commit-row.selected { background: rgba(255,255,255,0.06); }
.gc-commit-line1 {
display: flex; align-items: center; gap: 8px;
font-size: 11px; color: var(--df-text-dim);
}
.gc-commit-hash { .gc-commit-hash {
font-family: var(--df-font-mono, Consolas, monospace); font-family: var(--df-font-mono, Consolas, monospace);
font-size: 11px; color: var(--df-accent); flex-shrink: 0; font-size: 11px; color: var(--df-accent); flex-shrink: 0;
} }
.gc-commit-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.gc-commit-author { .gc-commit-author {
font-size: 11px; color: var(--df-text-dim); font-size: 11px; color: var(--df-text-dim);
flex-shrink: 0; flex-shrink: 0; max-width: 140px;
max-width: 100px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.gc-commit-time { font-size: 11px; color: var(--df-text-dim); flex-shrink: 0; } .gc-commit-time { margin-left: auto; font-size: 11px; color: var(--df-text-dim); flex-shrink: 0; }
.gc-commit-msg {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px;
color: var(--df-text);
}
/* 提交详情 */ /* ====== 提交详情(从底部滑出,列表始终可见) ====== */
.gc-commit-detail { border-bottom: 0.5px solid var(--df-border); } .gc-commit-detail {
flex-shrink: 0;
border-top: 0.5px solid var(--df-border);
background: var(--df-bg-card);
max-height: 55%;
display: flex; flex-direction: column;
box-shadow: 0 -4px 12px rgba(0,0,0,0.18);
}
.gc-commit-detail-header { .gc-commit-detail-header {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 8px 14px; background: rgba(255,255,255,0.03); padding: 8px 14px; background: rgba(255,255,255,0.03);
position: sticky; top: 0; z-index: 1; border-bottom: 0.5px solid var(--df-border);
} }
.gc-commit-detail-hash { .gc-commit-detail-hash {
font-family: var(--df-font-mono, Consolas, monospace); font-family: var(--df-font-mono, Consolas, monospace);
font-size: 11px; color: var(--df-accent); font-size: 11px; color: var(--df-accent); flex-shrink: 0;
} }
.gc-commit-detail-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.gc-commit-detail-author { .gc-commit-detail-author {
font-size: 11px; color: var(--df-text-dim); font-size: 11px; color: var(--df-text-dim); flex-shrink: 0;
flex-shrink: 0;
} }
.gc-commit-detail-time { font-size: 11px; color: var(--df-text-dim); flex-shrink: 0; } .gc-commit-detail-time { font-size: 11px; color: var(--df-text-dim); flex-shrink: 0; }
.gc-commit-detail-msg {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px;
}
.gc-detail-close { .gc-detail-close {
background: none; border: none; color: var(--df-text-dim); background: none; border: none; color: var(--df-text-dim);
cursor: pointer; font-size: 14px; padding: 0 4px; cursor: pointer; font-size: 14px; padding: 0 4px; flex-shrink: 0;
} }
.gc-detail-close:hover { color: var(--df-text); }
/* 提交的文件列表 */ /* 提交的文件列表 */
.gc-commit-files { border-bottom: 0.5px solid var(--df-border); } .gc-commit-files {
border-bottom: 0.5px solid var(--df-border);
overflow-y: auto; max-height: 140px; scrollbar-width: thin; flex-shrink: 0;
}
.gc-commit-file-row { .gc-commit-file-row {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 5px 14px 5px 28px; cursor: pointer; transition: background 0.1s; padding: 5px 14px 5px 28px; cursor: pointer; transition: background 0.1s;
@@ -576,25 +699,66 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
.gc-commit-file-row:hover { background: rgba(255,255,255,0.04); } .gc-commit-file-row:hover { background: rgba(255,255,255,0.04); }
.gc-commit-file-row.active { background: rgba(255,255,255,0.06); } .gc-commit-file-row.active { background: rgba(255,255,255,0.06); }
/* 提交 diff(可收起,占更宽空间) */
.gc-commit-diff {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
overflow: hidden;
}
.gc-commit-diff-header {
display: flex; align-items: center; gap: 8px;
padding: 5px 14px; background: rgba(255,255,255,0.03);
border-bottom: 0.5px solid var(--df-border); flex-shrink: 0;
}
.gc-commit-diff-path {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--df-font-mono, Consolas, monospace); font-size: 11px;
color: var(--df-text);
}
.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;
}
.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; }
/* 滑出动画 */
.gc-detail-slide-enter-active, .gc-detail-slide-leave-active {
transition: transform 0.18s ease, opacity 0.18s ease;
}
.gc-detail-slide-enter-from, .gc-detail-slide-leave-to {
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: 700; flex-shrink: 0; }
.gc-st-modified { color: #f0a020; } .gc-st-modified { color: #f0a020; }
.gc-st-added { color: #4caf50; } .gc-st-added { color: #4caf50; }
.gc-st-deleted { color: #e05050; } .gc-st-deleted { color: #e05050; }
.gc-st-untracked { color: #999; } .gc-st-untracked { color: #999; }
.gc-file-path { .gc-file-path {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--df-font-mono, Consolas, monospace); font-size: 12px; font-family: var(--df-font-mono, Consolas, monospace); font-size: 12px;
} }
.gc-file-name { .gc-file-name {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--df-font-mono, Consolas, monospace); font-size: 12px; font-family: var(--df-font-mono, Consolas, monospace); font-size: 12px;
} }
.gc-file-row {
display: flex; align-items: center; gap: 8px;
cursor: pointer; transition: background 0.1s;
}
.gc-file-row:hover { background: rgba(255,255,255,0.04); }
.gc-file-row.selected { background: rgba(255,255,255,0.06); }
/* 加载更多 */ /* 加载更多 */
.gc-load-more { .gc-load-more {
text-align: center; padding: 12px; cursor: pointer; text-align: center; padding: 12px; cursor: pointer;
color: var(--df-accent); font-size: 12px; color: var(--df-accent); font-size: 12px;
transition: background 0.1s; transition: background 0.1s; flex-shrink: 0;
} }
.gc-load-more:hover { background: rgba(255,255,255,0.04); } .gc-load-more:hover { background: rgba(255,255,255,0.04); }
@@ -671,6 +835,7 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
display: flex; justify-content: center; align-items: center; display: flex; justify-content: center; align-items: center;
padding: 40px 0; color: var(--df-text-dim); font-size: 13px; padding: 40px 0; color: var(--df-text-dim); font-size: 13px;
} }
.gc-empty-inline { padding: 24px 0; }
.gc-diff-loading { display: flex; justify-content: center; padding: 20px; } .gc-diff-loading { display: flex; justify-content: center; padding: 20px; }
.gc-diff-empty { padding: 20px 14px; color: var(--df-text-dim); font-size: 12px; text-align: center; } .gc-diff-empty { padding: 20px 14px; color: var(--df-text-dim); font-size: 12px; text-align: center; }
.spinner { .spinner {
+3 -20
View File
@@ -42,7 +42,7 @@
<SettingRow ref="rowCustomPrompt" :label="$t('settings.labelCustomPrompt')" :desc="$t('settings.descCustomPrompt')"> <SettingRow ref="rowCustomPrompt" :label="$t('settings.labelCustomPrompt')" :desc="$t('settings.descCustomPrompt')">
<textarea <textarea
v-model="customPrompt" v-model="customPrompt"
class="custom-prompt-input" class="setting-textarea"
:placeholder="$t('settings.placeholderCustomPrompt')" :placeholder="$t('settings.placeholderCustomPrompt')"
rows="4" rows="4"
@change="onCustomPromptChange" @change="onCustomPromptChange"
@@ -177,8 +177,8 @@ function onApprovalTimeoutChange() {
<style scoped> <style scoped>
/* 阶段5 UX 重构:复用 settings.css 全局 .panel/.panel-header/.general-settings/ /* 阶段5 UX 重构:复用 settings.css 全局 .panel/.panel-header/.general-settings/
.setting-select + SettingRow(封装 .setting-row/.setting-info/.setting-label/ .setting-select/.setting-textarea + SettingRow(封装 .setting-row/.setting-info/.setting-label/
.setting-desc/.setting-control) Section 特有 scoped 样式 .setting-desc/.setting-control) Section 仅保留 all 二次确认弹层特有 scoped 样式
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/ F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死删除) */ actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死删除) */
@@ -209,23 +209,6 @@ function onApprovalTimeoutChange() {
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; } .confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; } .btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); } .btn-danger:hover { filter: brightness(1.1); }
.custom-prompt-input {
width: 100%;
padding: 8px;
font-size: 13px;
font-family: inherit;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
resize: vertical;
min-height: 80px;
line-height: 1.5;
}
.custom-prompt-input:focus {
outline: none;
border-color: var(--df-primary);
}
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; } .confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; } .confirm-enter-from, .confirm-leave-to { opacity: 0; }
</style> </style>
+3 -3
View File
@@ -24,7 +24,7 @@
<div class="add-row"> <div class="add-row">
<input <input
v-model="newDir" v-model="newDir"
class="setting-select dir-input" class="setting-input dir-input"
:placeholder="$t('settings.allowedDirsInputPlaceholder')" :placeholder="$t('settings.allowedDirsInputPlaceholder')"
@keyup.enter="onAddClick" @keyup.enter="onAddClick"
/> />
@@ -53,7 +53,7 @@
<SettingRow :label="$t('settings.labelScriptWhitelist')" :desc="$t('settings.descScriptWhitelist')"> <SettingRow :label="$t('settings.labelScriptWhitelist')" :desc="$t('settings.descScriptWhitelist')">
<input <input
v-model="scriptWhitelist" v-model="scriptWhitelist"
class="setting-select script-safety-input" class="setting-input script-safety-input"
:placeholder="$t('settings.placeholderScriptWhitelist')" :placeholder="$t('settings.placeholderScriptWhitelist')"
@blur="onScriptWhitelistCommit" @blur="onScriptWhitelistCommit"
@keyup.enter="onScriptWhitelistCommit" @keyup.enter="onScriptWhitelistCommit"
@@ -63,7 +63,7 @@
<SettingRow :label="$t('settings.labelScriptBlacklist')" :desc="$t('settings.descScriptBlacklist')"> <SettingRow :label="$t('settings.labelScriptBlacklist')" :desc="$t('settings.descScriptBlacklist')">
<input <input
v-model="scriptBlacklist" v-model="scriptBlacklist"
class="setting-select script-safety-input" class="setting-input script-safety-input"
:placeholder="$t('settings.placeholderScriptBlacklist')" :placeholder="$t('settings.placeholderScriptBlacklist')"
@blur="onScriptBlacklistCommit" @blur="onScriptBlacklistCommit"
@keyup.enter="onScriptBlacklistCommit" @keyup.enter="onScriptBlacklistCommit"
+2 -2
View File
@@ -17,7 +17,7 @@
</select> </select>
</SettingRow> </SettingRow>
<SettingRow ref="rowMinMessages" :label="$t('settings.labelMinMessages')" :desc="$t('settings.descMinMessages')"> <SettingRow ref="rowMinMessages" :label="$t('settings.labelMinMessages')" :desc="$t('settings.descMinMessages')">
<input type="number" min="2" max="50" v-model.number="knowledgeConfig.min_messages" class="setting-select" style="width:80px" @change="markSaved('minMessages')" /> <input type="number" min="2" max="50" v-model.number="knowledgeConfig.min_messages" class="setting-number" style="width:80px" @change="markSaved('minMessages')" />
</SettingRow> </SettingRow>
<SettingRow ref="rowAutoInject" :label="$t('settings.labelAutoInject')" :desc="$t('settings.descAutoInject')"> <SettingRow ref="rowAutoInject" :label="$t('settings.labelAutoInject')" :desc="$t('settings.descAutoInject')">
<label class="toggle"> <label class="toggle">
@@ -38,7 +38,7 @@
</select> </select>
</SettingRow> </SettingRow>
<SettingRow v-if="knowledgeConfig.vector_enabled" ref="rowEmbeddingModel" :label="$t('settings.labelEmbeddingModel')" :desc="$t('settings.descEmbeddingModel')"> <SettingRow v-if="knowledgeConfig.vector_enabled" ref="rowEmbeddingModel" :label="$t('settings.labelEmbeddingModel')" :desc="$t('settings.descEmbeddingModel')">
<input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" @change="markSaved('embeddingModel')" class="setting-select" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px" /> <input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" @change="markSaved('embeddingModel')" class="setting-input" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px" />
</SettingRow> </SettingRow>
</div> </div>
</section> </section>
@@ -10,7 +10,7 @@
next-round next-round
:label="$t('settings.labelGlobalConcurrency')" :label="$t('settings.labelGlobalConcurrency')"
:desc="$t('settings.descGlobalConcurrency')"> :desc="$t('settings.descGlobalConcurrency')">
<input type="number" class="setting-select" min="1" style="width:80px" <input type="number" class="setting-number" min="1" style="width:80px"
v-model.number="settings.llmGlobalConcurrency" v-model.number="settings.llmGlobalConcurrency"
@change="onConcurrencyChange" /> @change="onConcurrencyChange" />
</SettingRow> </SettingRow>
@@ -19,7 +19,7 @@
next-round next-round
:label="$t('settings.labelPerConvConcurrency')" :label="$t('settings.labelPerConvConcurrency')"
:desc="$t('settings.descPerConvConcurrency')"> :desc="$t('settings.descPerConvConcurrency')">
<input type="number" class="setting-select" min="1" style="width:80px" <input type="number" class="setting-number" min="1" style="width:80px"
v-model.number="settings.llmPerConvConcurrency" v-model.number="settings.llmPerConvConcurrency"
@change="onConcurrencyChange" /> @change="onConcurrencyChange" />
</SettingRow> </SettingRow>
@@ -32,7 +32,7 @@
<input type="checkbox" v-model="unlimited" @change="markSaved('iter')" /> <input type="checkbox" v-model="unlimited" @change="markSaved('iter')" />
<span>{{ $t('settings.labelUnlimited') }}</span> <span>{{ $t('settings.labelUnlimited') }}</span>
</label> </label>
<input type="number" class="setting-select" min="1" max="50" style="width:80px" <input type="number" class="setting-number" min="1" max="50" style="width:80px"
v-model.number="settings.agentMaxIterations" v-model.number="settings.agentMaxIterations"
:disabled="unlimited" :disabled="unlimited"
@change="onIterChange" /> @change="onIterChange" />
@@ -42,7 +42,7 @@
next-round next-round
:label="$t('settings.labelAgentMaxRetries')" :label="$t('settings.labelAgentMaxRetries')"
:desc="$t('settings.descAgentMaxRetries')"> :desc="$t('settings.descAgentMaxRetries')">
<input type="number" class="setting-select" min="0" max="10" style="width:80px" <input type="number" class="setting-number" min="0" max="10" style="width:80px"
v-model.number="settings.agentMaxRetries" v-model.number="settings.agentMaxRetries"
@change="onRetryChange" /> @change="onRetryChange" />
</SettingRow> </SettingRow>
@@ -225,7 +225,7 @@ onUnmounted(() => {
<style scoped> <style scoped>
/* 阶段5 UX 重构:复用 settings.css 全局 .panel/.panel-header/.general-settings/ /* 阶段5 UX 重构:复用 settings.css 全局 .panel/.panel-header/.general-settings/
.setting-select + SettingRow(封装 .setting-row/.setting-info/.setting-label/ .setting-number + SettingRow(封装 .setting-row/.setting-info/.setting-label/
.setting-desc/.setting-control + nextRound badge) Section 无特有 scoped 样式 .setting-desc/.setting-control + nextRound badge) Section 无特有 scoped 样式
(.unlimited-toggle 沿用默认 label) */ (.unlimited-toggle 沿用默认 label) */
</style> </style>
+50
View File
@@ -34,6 +34,7 @@
.setting-control { display: flex; align-items: center; } .setting-control { display: flex; align-items: center; }
/* ===== 控件(5/5 Panel 重复) ===== */ /* ===== 控件(5/5 Panel 重复) ===== */
/* 控件统一基准(padding/border/radius/font 对齐),各 type 仅差异化必要属性 */
.setting-select { .setting-select {
padding: 6px 12px; padding: 6px 12px;
background: var(--df-bg); background: var(--df-bg);
@@ -47,6 +48,55 @@
} }
.setting-select:focus { border-color: var(--df-accent); } .setting-select:focus { border-color: var(--df-accent); }
/* number input:窄,无 spinner 累赘外观,继承基准样式 */
.setting-number {
padding: 6px 12px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
color: var(--df-text);
font-size: 13px;
font-family: inherit;
outline: none;
cursor: pointer;
min-width: 80px;
}
.setting-number:focus { border-color: var(--df-accent); }
/* text input:通用单行文本(目录/模型名/脚本名单) */
.setting-input {
padding: 6px 12px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
color: var(--df-text);
font-size: 13px;
font-family: inherit;
outline: none;
min-width: 160px;
}
.setting-input:focus { border-color: var(--df-accent); }
.setting-input::placeholder { color: var(--df-text-dim); }
/* textarea:多行(自定义提示词等),垂直拉伸 */
.setting-textarea {
width: 100%;
padding: 8px 12px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
color: var(--df-text);
font-size: 13px;
font-family: inherit;
line-height: 1.5;
outline: none;
resize: vertical;
min-height: 80px;
box-sizing: border-box;
}
.setting-textarea:focus { border-color: var(--df-accent); }
.setting-textarea::placeholder { color: var(--df-text-dim); }
/* Toggle(3/5 Panel 重复) */ /* Toggle(3/5 Panel 重复) */
.toggle { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; } .toggle { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; }
.toggle input { opacity: 0; width: 0; height: 0; } .toggle input { opacity: 0; width: 0; height: 0; }
+24 -1
View File
@@ -174,6 +174,24 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: any) {
emit('approve', { id: tc.value.id, approved: false, decision: 'once' }) emit('approve', { id: tc.value.id, approved: false, decision: 'once' })
} }
/**
* M4:path (decision='session')
* DirAuthDialog (once/session/always/deny)session ,;
* IPC authorizeDir 'session' (api/ai.ts authorizeDir + ai_authorize_dir)
* onAuthorizeOnce loading + ; High ( once/always ,)
*/
async function onAuthorizeSession() {
if (approving.value) return
approving.value = true
approvingTimer = setTimeout(() => {
approving.value = false
approvingTimer = null
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
showApproveTimeoutToast()
}, APPROVE_LOADING_TIMEOUT_MS)
emit('approve', { id: tc.value.id, approved: false, decision: 'session' })
}
/** /**
* path_auth 审批链阶段3b:path * path_auth 审批链阶段3b:path
* onAuthorizeOnce,decision='always' * onAuthorizeOnce,decision='always'
@@ -192,10 +210,14 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: any) {
/** /**
* path_auth 审批链阶段3b:path * path_auth 审批链阶段3b:path
* onAuthorizeOnce,decision='deny' * M5:拒绝前二次确认(useConfirm ) Errloop ,
* (once/session/always ),; approve()
* risk (onReject)不补确认:risk reject ,; path
*/ */
async function onDeny() { async function onDeny() {
if (approving.value) return if (approving.value) return
const ok = await confirmDialog(t('aiChat.dirAuthDenyConfirm'))
if (!ok) return
approving.value = true approving.value = true
approvingTimer = setTimeout(() => { approvingTimer = setTimeout(() => {
approving.value = false approving.value = false
@@ -232,6 +254,7 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: any) {
onApprove, onApprove,
onReject, onReject,
onAuthorizeOnce, onAuthorizeOnce,
onAuthorizeSession,
onAuthorizeAlways, onAuthorizeAlways,
onDeny, onDeny,
// 供模板 ConfirmDialog 绑定 // 供模板 ConfirmDialog 绑定
+2 -2
View File
@@ -1,8 +1,6 @@
export default { export default {
aiChat: { aiChat: {
// ── Multi-Agent parallel execution ── // ── Multi-Agent parallel execution ──
planProgress: 'Execution Plan',
planLayer: 'Layer {n}',
conflictsPending: '{n} conflicts to resolve', conflictsPending: '{n} conflicts to resolve',
acceptA: 'Accept A', acceptA: 'Accept A',
acceptB: 'Accept B', acceptB: 'Accept B',
@@ -182,6 +180,8 @@ export default {
dirAuthAlways: 'Always', dirAuthAlways: 'Always',
dirAuthDeny: 'Deny', dirAuthDeny: 'Deny',
dirAuthFailed: 'Authorization failed: {msg}', dirAuthFailed: 'Authorization failed: {msg}',
// M5:path deny second confirmation (deny is irreversible — tool returns Err, loop continues without retry; asymmetry with approve, prevents mis-clicks)
dirAuthDenyConfirm: 'Deny authorization? The tool will fail immediately and will not be retried this round.',
// ── Input Augmentation layer: mention/skill injection feedback ── // ── Input Augmentation layer: mention/skill injection feedback ──
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path) // search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
+5
View File
@@ -6,6 +6,11 @@ export default {
noChanges: 'No changes (clean working tree)', noChanges: 'No changes (clean working tree)',
noDiff: 'No diff available', noDiff: 'No diff available',
noCommits: 'No commits yet', noCommits: 'No commits yet',
noSearchResult: 'No matching commits',
commitsUnit: 'commits',
searchPlaceholder: 'Search commits (keyword/author)',
expand: 'Expand',
collapse: 'Collapse',
modified: 'Modified', modified: 'Modified',
added: 'Added', added: 'Added',
deleted: 'Deleted', deleted: 'Deleted',
+2 -2
View File
@@ -1,8 +1,6 @@
export default { export default {
aiChat: { aiChat: {
// ── 多 Agent 并行执行 ── // ── 多 Agent 并行执行 ──
planProgress: '执行计划',
planLayer: '第 {n} 层',
conflictsPending: '{n} 处冲突待处理', conflictsPending: '{n} 处冲突待处理',
acceptA: '接受 A', acceptA: '接受 A',
acceptB: '接受 B', acceptB: '接受 B',
@@ -183,6 +181,8 @@ export default {
dirAuthAlways: '始终放行', dirAuthAlways: '始终放行',
dirAuthDeny: '拒绝', dirAuthDeny: '拒绝',
dirAuthFailed: '授权失败:{msg}', dirAuthFailed: '授权失败:{msg}',
// M5:path 类拒绝二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试,与 approve 不对称,防误点)
dirAuthDenyConfirm: '确定拒绝授权吗?该工具将立即失败,且本轮不会重试。',
// ── Input Augmentation 层: mention/skill 注入反馈 ── // ── Input Augmentation 层: mention/skill 注入反馈 ──
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径) // search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
+5
View File
@@ -6,6 +6,11 @@ export default {
noChanges: '无变更(工作区干净)', noChanges: '无变更(工作区干净)',
noDiff: '无差异内容', noDiff: '无差异内容',
noCommits: '暂无提交记录', noCommits: '暂无提交记录',
noSearchResult: '无匹配提交',
commitsUnit: '个提交',
searchPlaceholder: '搜索提交(关键词/作者)',
expand: '展开',
collapse: '收起',
modified: '已修改', modified: '已修改',
added: '已新增', added: '已新增',
deleted: '已删除', deleted: '已删除',
+3
View File
@@ -24,6 +24,8 @@
--df-accent-hover: #6a5de0; --df-accent-hover: #6a5de0;
--df-accent-soft: rgba(123,111,240,0.15); --df-accent-soft: rgba(123,111,240,0.15);
--df-accent-bg: rgba(123,111,240,0.08); --df-accent-bg: rgba(123,111,240,0.08);
/* — Primary: 品牌主色(历史遗留 token,等价 accent,5 处引用:ConversationSidebar/PlanProgress/ConflictResolver/AdvancedSection 的 border/focus/活跃/link 态)— */
--df-primary: #7b6ff0;
/* — Semantic — */ /* — Semantic — */
/* 青绿:成功/流程 */ /* 青绿:成功/流程 */
@@ -83,6 +85,7 @@
--df-accent-hover: #5b4bd6; --df-accent-hover: #5b4bd6;
--df-accent-soft: rgba(108,92,231,0.15); --df-accent-soft: rgba(108,92,231,0.15);
--df-accent-bg: rgba(108,92,231,0.06); --df-accent-bg: rgba(108,92,231,0.06);
--df-primary: #6c5ce7;
--df-success: #00b894; --df-success: #00b894;
--df-success-bg: rgba(0,184,148,0.10); --df-success-bg: rgba(0,184,148,0.10);
+159 -6
View File
@@ -52,11 +52,17 @@
</div> </div>
<!-- 风险说明(后端 build_approval_reason 已含工具/参数/risk 描述) --> <!-- 风险说明(后端 build_approval_reason 已含工具/参数/risk 描述) -->
<div v-if="tc.reason" class="popup-item-reason"> {{ tc.reason }}</div> <div v-if="tc.reason" class="popup-item-reason"> {{ tc.reason }}</div>
<!-- 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)">
{{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecsFor(tc.id)) }) }}
</div>
<!-- path :目录提示 --> <!-- path :目录提示 -->
<div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir"> <div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir">
📁 {{ tc.dir }} 📁 {{ tc.dir }}
</div> </div>
<!-- 操作按钮组:path 类显 once/always/deny 选项( ToolCard 同款, always); <!-- 操作按钮组:path 类显 once/session/always/deny 选项( ToolCard/DirAuthDialog 同款);
risk 类显 approve/reject 两选项IPC 分派统一走 dispatchApprovalIPC --> risk 类显 approve/reject 两选项IPC 分派统一走 dispatchApprovalIPC -->
<div v-if="tc.kind === 'path'" class="popup-item-actions popup-item-actions--path"> <div v-if="tc.kind === 'path'" class="popup-item-actions popup-item-actions--path">
<button <button
@@ -68,6 +74,14 @@
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthOnce') }} {{ $t('aiChat.dirAuthOnce') }}
</button> </button>
<button
class="popup-btn popup-btn--session"
:disabled="processingId === tc.id"
@click.stop="onApproveSession(tc)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthSession') }}
</button>
<button <button
class="popup-btn popup-btn--always" class="popup-btn popup-btn--always"
:disabled="processingId === tc.id" :disabled="processingId === tc.id"
@@ -114,18 +128,31 @@
</svg> </svg>
{{ $t('aiChat.popupOpenPanel') }} {{ $t('aiChat.popupOpenPanel') }}
</button> </button>
<!-- M5:path deny 二次确认(拒绝不可逆, ToolCard 同款;独立窗口本地 ConfirmDialog,
不复用主窗口 useConfirm reactive WebviewWindow JS 上下文隔离) -->
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
:danger-label="$t('common.confirm')"
@result="answerConfirm"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { toolDisplayName } from '@/composables/ai/useToolCardHeader' import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval' import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup' import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import type { AiToolCallInfo } from '@/api/types' import type { AiToolCallInfo } from '@/api/types'
const { t } = useI18n()
/** 主窗口推送的快照(唯一真相源,本组件不监听 AI 事件) */ /** 主窗口推送的快照(唯一真相源,本组件不监听 AI 事件) */
const approvals = ref<AiToolCallInfo[]>([]) const approvals = ref<AiToolCallInfo[]>([])
/** 操作中 id(防抖 + 按钮加载态) */ /** 操作中 id(防抖 + 按钮加载态) */
@@ -133,6 +160,81 @@ const processingId = ref<string>('')
/** 拖动态(样式反馈) */ /** 拖动态(样式反馈) */
const isDragging = ref(false) const isDragging = ref(false)
// M5:path deny ( confirm; Errloop )
// WebviewWindow, JS , useConfirm reactive
// pendingDenyTc tc;answerConfirm result runApproval
const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null }>({
visible: false,
msg: '',
tc: null,
})
function answerConfirm(ok: boolean) {
const tc = confirmState.tc
confirmState.visible = false
confirmState.tc = null
if (ok && tc) {
void runApproval(tc, false, 'deny')
}
}
// H1:( ToolCard useToolApproval waitSecs/waitLevel)
// , useToolApproval( tc + emit/,
// processingId/runApproval )(popup tc.id )
// // ToolCard :120s long / 300s urgent
/** 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()
for (const [id, t0] of arrivalTimes.entries()) {
waitSecsMap.set(id, Math.floor((now - t0) / 1000))
}
}
/**
* 同步到达时间到当前 approvals 快照
* - 新出现的 id(popup 首次见到该审批) Date.now() 起算
* - 已不在快照的 id(被处理 / 后端清掉) arrival + waitSecs, Map 无界增长 + 防幽灵计时
* - 已记录的 id 保留原时刻(快照重推不影响计时,审批真实挂起时长不被重置)
*/
function _syncArrivalTimes(list: AiToolCallInfo[]) {
const seen = new Set<string>()
for (const tc of list) {
seen.add(tc.id)
if (!arrivalTimes.has(tc.id)) {
arrivalTimes.set(tc.id, Date.now())
waitSecsMap.set(tc.id, 0)
}
}
for (const id of Array.from(arrivalTimes.keys())) {
if (!seen.has(id)) {
arrivalTimes.delete(id)
waitSecsMap.delete(id)
}
}
}
let _unlistenUpdate: UnlistenFn | null = null let _unlistenUpdate: UnlistenFn | null = null
let _positioned = false let _positioned = false
/** : close() promise /** : close() promise
@@ -176,7 +278,10 @@ onMounted(async () => {
_unlistenUpdate = await listen<{ approvals: AiToolCallInfo[] }>( _unlistenUpdate = await listen<{ approvals: AiToolCallInfo[] }>(
ApprovalPopupEvents.EVT_UPDATE, ApprovalPopupEvents.EVT_UPDATE,
(e) => { (e) => {
approvals.value = e.payload?.approvals || [] const next = e.payload?.approvals || []
approvals.value = next
// H1:( id ; id + , Map )
_syncArrivalTimes(next)
// BUG2 :( pending ) , // BUG2 :( pending ) ,
void maybeAutoClose() void maybeAutoClose()
}, },
@@ -195,6 +300,12 @@ onMounted(async () => {
// Esc (:)Tab/Enter button , // Esc (:)Tab/Enter button ,
// _onKeyDown ,onBeforeUnmount removeEventListener() // _onKeyDown ,onBeforeUnmount removeEventListener()
window.addEventListener('keydown', _onKeyDown) window.addEventListener('keydown', _onKeyDown)
// H1:( waitSecsMap) timer tc,
// onBeforeUnmount tick ( approvals)
_tickWaitSecs()
if (_waitTimer) clearInterval(_waitTimer)
_waitTimer = setInterval(_tickWaitSecs, 1000)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -202,6 +313,8 @@ onBeforeUnmount(() => {
_unlistenUpdate = null _unlistenUpdate = null
// Esc listener( window /) // Esc listener( window /)
window.removeEventListener('keydown', _onKeyDown) window.removeEventListener('keydown', _onKeyDown)
// H1:( tick Map /)
if (_waitTimer) { clearInterval(_waitTimer); _waitTimer = null }
if (_closeFallbackTimer) { if (_closeFallbackTimer) {
clearTimeout(_closeFallbackTimer) clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null _closeFallbackTimer = null
@@ -251,6 +364,10 @@ async function closeWithFallback(): Promise<void> {
async function onApproveOnce(tc: AiToolCallInfo) { async function onApproveOnce(tc: AiToolCallInfo) {
await runApproval(tc, false, 'once') await runApproval(tc, false, 'once')
} }
/** M4:path 类:当前会话授权(decision='session',对齐 ToolCard/DirAuthDialog 四按钮) */
async function onApproveSession(tc: AiToolCallInfo) {
await runApproval(tc, false, 'session')
}
/** path 类:写持久白名单(decision='always') */ /** path 类:写持久白名单(decision='always') */
async function onApproveAlways(tc: AiToolCallInfo) { async function onApproveAlways(tc: AiToolCallInfo) {
await runApproval(tc, false, 'always') await runApproval(tc, false, 'always')
@@ -259,15 +376,22 @@ async function onApproveAlways(tc: AiToolCallInfo) {
async function onApprove(tc: AiToolCallInfo) { async function onApprove(tc: AiToolCallInfo) {
await runApproval(tc, true) await runApproval(tc, true)
} }
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派) */ /** path 类 deny / risk reject(统一入口,kind dispatchApprovalIPC tc.kind 分派)
* M5:path 类拒绝前二次确认(拒绝不可逆);risk reject 直接执行 */
async function onReject(tc: AiToolCallInfo) { async function onReject(tc: AiToolCallInfo) {
await runApproval(tc, false, tc.kind === 'path' ? 'deny' : undefined) if (tc.kind === 'path') {
confirmState.msg = t('aiChat.dirAuthDenyConfirm')
confirmState.tc = tc
confirmState.visible = true
return
}
await runApproval(tc, false, undefined)
} }
/** :(processingId)+ IPC + + /** :(processingId)+ IPC + +
* IPC 分派规则(pathauthorizeDir / riskapprove)抽离至 dispatchApprovalIPC, * IPC 分派规则(pathauthorizeDir / riskapprove)抽离至 dispatchApprovalIPC,
* useAiApproval.approveToolCall 同源,消除同语义两套逻辑漂移 */ * useAiApproval.approveToolCall 同源,消除同语义两套逻辑漂移 */
async function runApproval(tc: AiToolCallInfo, approved: boolean, decision?: 'once' | 'always' | 'deny') { async function runApproval(tc: AiToolCallInfo, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
if (processingId.value === tc.id) return if (processingId.value === tc.id) return
processingId.value = tc.id processingId.value = tc.id
try { try {
@@ -515,6 +639,27 @@ function onDragEnd() { isDragging.value = false }
white-space: nowrap; white-space: nowrap;
} }
/* H1:( ToolCard .ai-tool-wait-time)
>2min 橙色脉冲 / >5min 红色更急脉冲;pulseGlow 复用 styles/global.css 全局 keyframes
用户主要从浮窗审批,后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝前给可见预警 */
.popup-item-wait {
padding: 2px 8px;
margin-bottom: 6px;
font-size: 10px;
font-family: var(--df-font-mono);
color: var(--df-text-dim, #888);
border-radius: var(--df-radius-sm, 4px);
background: var(--df-bg-card, transparent);
}
.popup-item-wait.is-long {
color: var(--df-warning, #d97706);
animation: pulseGlow 1.5s ease-in-out infinite;
}
.popup-item-wait.is-urgent {
color: var(--df-danger, #e5484d);
animation: pulseGlow 1s ease-in-out infinite;
}
.popup-item-actions { .popup-item-actions {
display: flex; display: flex;
gap: 6px; gap: 6px;
@@ -545,6 +690,14 @@ function onDragEnd() { isDragging.value = false }
.popup-btn--approve:hover:not(:disabled) { .popup-btn--approve:hover:not(:disabled) {
background: var(--df-accent-hover, #6558d8); background: var(--df-accent-hover, #6558d8);
} }
/* M4:session 按钮(弱填充态,介于 approve 强填充与 always 描边之间,视觉层级 once>session>always) */
.popup-btn--session {
background: var(--df-accent-bg);
color: var(--df-accent);
}
.popup-btn--session:hover:not(:disabled) {
background: color-mix(in srgb, var(--df-accent) 30%, transparent);
}
.popup-btn--always { .popup-btn--always {
background: var(--df-accent-bg); background: var(--df-accent-bg);
color: var(--df-accent); color: var(--df-accent);