优化: 前端u-agent令牌对齐与组件拆分续批
- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底 - 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、 conversation-sidebar/ConversationList、settings/provider-panel - 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补 fill-draft父侧转发(重构半成品断链修复) - 验收:vue-tsc EXIT 0
This commit is contained in:
+12
-11
@@ -31,16 +31,16 @@
|
||||
|
||||
<!-- 走查 P3-16:全局 toast 统一收进同一纵向堆叠容器。原错误 toast 与托盘提示气泡
|
||||
各自 fixed 同位(bottom:24px 居中),两者同现时互相压盖、后弹者盖住先弹者。 -->
|
||||
<div class="df-toast-stack">
|
||||
<div class="app-toast-stack">
|
||||
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
|
||||
<transition name="toast">
|
||||
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
||||
<div v-if="errorMsg" class="app-toast">⚠ {{ errorMsg }}</div>
|
||||
</transition>
|
||||
|
||||
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
|
||||
告知会话转入后台继续生成、托盘图标可随时唤起,复用 df-toast 形态 -->
|
||||
告知会话转入后台继续生成、托盘图标可随时唤起,复用 app-toast 形态 -->
|
||||
<transition name="toast">
|
||||
<div v-if="trayNoticeMsg" class="df-toast df-toast--info">{{ trayNoticeMsg }}</div>
|
||||
<div v-if="trayNoticeMsg" class="app-toast app-toast--info">{{ trayNoticeMsg }}</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ watch(() => projectStore.error, (err) => {
|
||||
})
|
||||
|
||||
// ── 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice)──
|
||||
// 与错误 toast 同款 df-toast 形态,信息语义走 --info 变体底色,不与错误红混用
|
||||
// 与错误 toast 同款 app-toast 形态,信息语义走 --info 变体底色,不与错误红混用
|
||||
const trayNoticeMsg = ref('')
|
||||
let trayNoticeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let _unlistenTrayHideNotice: (() => void) | null = null
|
||||
@@ -353,20 +353,21 @@ onUnmounted(() => {
|
||||
|
||||
/* ═══ 全局 toast 纵向堆叠容器(走查 P3-16)═══
|
||||
容器独占 fixed 定位,内部 flex 纵排 + 底对齐,多条 toast 依次向上堆叠不再同位互盖。
|
||||
子项保持 .df-toast 原形态(定宽自适应),定位字段从子项上移到容器。 */
|
||||
.df-toast-stack {
|
||||
子项 .app-toast 独立命名(2026-10-18 与 global.css .df-toast 顶部居中族撞名根治,
|
||||
不再依赖定位覆盖对冲),主题保持根级强信号形态(实底白字)。 */
|
||||
.app-toast-stack {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
z-index: 9999; max-width: 90vw; pointer-events: none;
|
||||
}
|
||||
.df-toast {
|
||||
.app-toast {
|
||||
background: var(--df-danger); color: #fff;
|
||||
padding: 10px 18px; border-radius: var(--df-radius-sm);
|
||||
font-size: 13px; max-width: 90vw;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
box-shadow: var(--df-shadow-md);
|
||||
}
|
||||
.toast-enter-active, .toast-leave-active { transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease); }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); }
|
||||
/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */
|
||||
.df-toast--info { background: var(--df-accent); }
|
||||
/* 后台运行提示:同 app-toast 形态,信息语义用 accent 底色与错误红区分(实底需白字) */
|
||||
.app-toast--info { background: var(--df-accent); color: #fff; }
|
||||
</style>
|
||||
|
||||
+40
-254
@@ -53,17 +53,9 @@
|
||||
<div v-if="autoCompressedFlash" class="ai-autocompressed-hint">{{ t('aiChat.autoCompressed') }}</div>
|
||||
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
|
||||
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
|
||||
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
|
||||
单轮问答 agentRound 恒 0,不显示(避免误显)。
|
||||
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
|
||||
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
|
||||
完成聚合语义由 MessageList 的执行轨迹卡承接。
|
||||
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。 -->
|
||||
<div v-if="showAgenticProgress" class="ai-agentic-progress">
|
||||
<span class="ai-agentic-progress-dot"></span>
|
||||
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
|
||||
</div>
|
||||
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)。
|
||||
C4b 拆分至 ai-chat/agentic-progress-bar.vue(纯展示,store 单例直读,零行为变更) -->
|
||||
<AgenticProgressBar />
|
||||
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
|
||||
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
|
||||
@@ -75,46 +67,10 @@
|
||||
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
|
||||
DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 -->
|
||||
<DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目) -->
|
||||
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
|
||||
<div class="ai-queue-head">
|
||||
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
|
||||
<div class="ai-queue-actions">
|
||||
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
|
||||
<button class="ai-queue-clear" @click="handleClearQueue">{{ $t('aiChat.clearQueue') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-queue-list">
|
||||
<div v-for="(item, idx) in activeQueue" :key="idx" class="ai-queue-item">
|
||||
<!-- 编辑态只编 text:inline input,回车/失焦写回,ESC 取消 -->
|
||||
<template v-if="editingQueueIdx === idx">
|
||||
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
||||
<input
|
||||
v-model="editingQueueText"
|
||||
class="ai-queue-edit-input"
|
||||
type="text"
|
||||
@keydown="onQueueEditKeydown"
|
||||
@blur="saveEditQueued"
|
||||
/>
|
||||
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')" :aria-label="$t('aiChat.queueSave')">✓</button>
|
||||
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')" :aria-label="$t('aiChat.queueCancel')">✕</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
||||
<span class="ai-queue-text" :title="item.text">{{ item.text }}</span>
|
||||
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
|
||||
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
|
||||
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
|
||||
<button class="ai-queue-btn" @click="backfillQueued(idx)" :title="$t('aiChat.queueBackfill')">{{ $t('aiChat.queueBackfill') }}</button>
|
||||
<!-- 编辑按钮 -->
|
||||
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
|
||||
<!-- 立即发送(打断当前+发本条) -->
|
||||
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
|
||||
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')" :aria-label="$t('common.cancel')">×</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目)。
|
||||
C4b 拆分至 ai-chat/queue-panel.vue(L2 发送韧性 + V7 排队时钟 + 队列编辑/回填随迁,
|
||||
confirm 自持同 ChatInput 模式;hasUnsentInput/fillDraft 经 props/emits 接 ChatInput ref) -->
|
||||
<QueuePanel :has-unsent-input="hasUnsentInput" @fill-draft="onQueueFillDraft" />
|
||||
<!-- 第二批抽离: ChatInput 子组件(输入框+附件+技能/实体联想+发送/停止) -->
|
||||
<ChatInput
|
||||
ref="chatInputRef"
|
||||
@@ -132,28 +88,14 @@
|
||||
:danger-label="confirmState.dangerLabel"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
<!-- 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast;分离窗口无 App.vue 根 toast,故本组件自管) -->
|
||||
<Transition name="ai-toast">
|
||||
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
|
||||
<!-- 错误 toast(零依赖 useToast;分离窗口无 App.vue 根 toast,故本组件自管;样式消费全局 .df-toast 族,error 映射 danger) -->
|
||||
<Transition name="df-toast">
|
||||
<div v-if="toast.visible" class="df-toast" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
|
||||
</Transition>
|
||||
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
|
||||
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="shortcutsVisible" class="ai-shortcuts-mask" @click.self="shortcutsVisible = false">
|
||||
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
|
||||
<div class="ai-shortcuts-head">
|
||||
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
|
||||
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="shortcutsVisible = false">✕</button>
|
||||
</div>
|
||||
<ul class="ai-shortcuts-list">
|
||||
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
|
||||
<span class="ai-shortcuts-key">{{ s.key }}</span>
|
||||
<span class="ai-shortcuts-act">{{ s.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<!-- I12(2026-10-05):键盘快捷键速查。C4b 拆分至 ai-chat/shortcuts-overlay.vue
|
||||
(Teleport 到 body 防 .ai-panel overflow 裁剪;主窗/分离窗各自实例独立渲染,
|
||||
入口在 TopBar ... 菜单 emit show-shortcuts;visible 状态留父供 Esc 仲裁) -->
|
||||
<ShortcutsOverlay :visible="shortcutsVisible" @close="shortcutsVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -176,10 +118,13 @@ import DirAuthDialog from './ai/DirAuthDialog.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace'
|
||||
import { useAiContext } from '../composables/ai/useAiContext'
|
||||
import { emitReattachToMain, registerMainReattachListener, emitOpenSettings, registerMainOpenSettingsListener } from '@/composables/ai/useAiWindow'
|
||||
import type { AiMessage } from '../api/types'
|
||||
import type { AiMessage, SkillInfo } from '../api/types'
|
||||
// C4b 拆分(第五批): Agentic 进度条/待发送队列/快捷键速查 三自有区块抽至 ai-chat/ 子目录
|
||||
import AgenticProgressBar from './ai-chat/agentic-progress-bar.vue'
|
||||
import QueuePanel from './ai-chat/queue-panel.vue'
|
||||
import ShortcutsOverlay from './ai-chat/shortcuts-overlay.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
detached?: boolean
|
||||
@@ -262,22 +207,7 @@ const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
|
||||
// 头部审批徽标 — 当前对话积压的待审批工具数(>0 时显示)
|
||||
const pendingApprovalCount = computed(() => store.state.pendingApprovals.length)
|
||||
|
||||
// Agentic 循环进度条
|
||||
// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。
|
||||
const showAgenticProgress = computed(() =>
|
||||
store.state.streaming && store.state.agentRound > 0,
|
||||
)
|
||||
// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。
|
||||
// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议);
|
||||
// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。
|
||||
// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。
|
||||
const agenticProgressText = computed(() => {
|
||||
const pending = store.state.pendingApprovals.length
|
||||
const total = collectTurnStats(turnMessages(store.state.messages)).total
|
||||
return pending > 0
|
||||
? t('ai.agenticProgress', { round: store.state.agentRound, total, pending })
|
||||
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, total })
|
||||
})
|
||||
// C4b: Agentic 循环进度条(显示条件+文案 computed)已随 ai-chat/agentic-progress-bar.vue 迁出
|
||||
|
||||
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */
|
||||
function scrollToFirstPending(): void {
|
||||
@@ -337,10 +267,7 @@ const projectsStore = useProjectStore()
|
||||
// 滚动边沿/跟随意图重置随 MessageList 迁移,由子组件自管。
|
||||
watch(() => store.state.activeConversationId, () => {
|
||||
if (editingMsgId.value) cancelEdit()
|
||||
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue
|
||||
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
|
||||
// 新会话同下标队列项上,随消息编辑态一并重置。
|
||||
if (editingQueueIdx.value !== null) cancelEditQueued()
|
||||
// UX-P3: 队列编辑态重置已随 QueuePanel 迁出(子组件内自 watch activeConversationId)。
|
||||
})
|
||||
|
||||
|
||||
@@ -485,135 +412,23 @@ function onBeforeUnloadGuard(e: BeforeUnloadEvent) {
|
||||
// (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互)
|
||||
|
||||
// ── L2 发送韧性:排队超时检测 + 强制发送 ──
|
||||
|
||||
/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。
|
||||
* 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示
|
||||
* 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */
|
||||
const activeQueue = computed(() =>
|
||||
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
|
||||
)
|
||||
// (activeQueue 过滤视图已随 queue-panel.vue 子组件迁移,父侧死副本删除)
|
||||
|
||||
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
|
||||
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
|
||||
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
|
||||
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
|
||||
const nowTick = ref(Date.now())
|
||||
let queueTickTimer: ReturnType<typeof setInterval> | null = null
|
||||
// 队列超时横幅/强制发送/清空/等待时钟(V7 nowTick)已随 queue-panel.vue 子组件迁移
|
||||
// (2026-10-18 UI 重构批,子组件自带 interval 与清理),父侧死副本删除(vue-tsc TS6133;
|
||||
// 旧父组件时钟在无消费方后仍每秒空转,一并消除)。
|
||||
|
||||
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。
|
||||
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed
|
||||
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick
|
||||
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
|
||||
const queueTimedOut = computed(() => {
|
||||
void nowTick.value
|
||||
return store.isQueueTimedOut(store.state.activeConversationId)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => activeQueue.value.length > 0,
|
||||
(waiting) => {
|
||||
if (waiting && queueTickTimer === null) {
|
||||
nowTick.value = Date.now()
|
||||
queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000)
|
||||
} else if (!waiting && queueTickTimer !== null) {
|
||||
clearInterval(queueTickTimer)
|
||||
queueTickTimer = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */
|
||||
function formatWaitTime(totalSeconds: number): string {
|
||||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */
|
||||
function queueItemWait(item: { enqueuedAt: number }): string {
|
||||
return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000)))
|
||||
}
|
||||
|
||||
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
|
||||
async function handleForceSend() {
|
||||
await store.tryForceSend(confirmDialog, store.state.activeConversationId)
|
||||
}
|
||||
|
||||
/** UI-4(看板 943)清空队列:二次确认后全清当前活跃会话排队消息。
|
||||
* 一键误触会静默丢掉全部排队消息且不可恢复,复用本组件 ConfirmDialog 先例
|
||||
* (同 tryForceSend 的 confirmFn 注入形态),不引 window.confirm(仓风格)。 */
|
||||
async function handleClearQueue() {
|
||||
const convId = store.state.activeConversationId
|
||||
if (!convId) return
|
||||
const confirmed = await confirmDialog(t('aiChat.clearQueueConfirm'))
|
||||
if (confirmed) store.clearQueue(convId)
|
||||
/** QueuePanel emit fill-draft 的父侧转发:写入 ChatInput 草稿(拆分纪律:ChatInput ref 留父) */
|
||||
function onQueueFillDraft(text: string, skill: SkillInfo | null) {
|
||||
chatInputRef.value?.fillDraft(text, skill)
|
||||
}
|
||||
|
||||
// ── 队列消息编辑(只编 text)+ 立即发送(插队)──
|
||||
|
||||
/** 当前正在编辑的队列项 index;null=非编辑态 */
|
||||
const editingQueueIdx = ref<number | null>(null)
|
||||
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
|
||||
const editingQueueText = ref('')
|
||||
|
||||
/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */
|
||||
function startEditQueued(idx: number) {
|
||||
const item = activeQueue.value[idx]
|
||||
if (!item) return
|
||||
editingQueueIdx.value = idx
|
||||
editingQueueText.value = item.text
|
||||
}
|
||||
|
||||
/** 取消编辑:还原非编辑态,不写回 */
|
||||
function cancelEditQueued() {
|
||||
editingQueueIdx.value = null
|
||||
editingQueueText.value = ''
|
||||
}
|
||||
|
||||
/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
|
||||
* 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫,
|
||||
* 收敛到本函数统一判。 */
|
||||
function onQueueEditKeydown(e: KeyboardEvent) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
saveEditQueued()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditQueued()
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
|
||||
function saveEditQueued() {
|
||||
if (editingQueueIdx.value === null) return
|
||||
store.editQueued(editingQueueIdx.value, editingQueueText.value)
|
||||
editingQueueIdx.value = null
|
||||
editingQueueText.value = ''
|
||||
}
|
||||
|
||||
/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */
|
||||
async function handleSendQueuedNow(idx: number) {
|
||||
await store.sendQueuedNow(idx)
|
||||
}
|
||||
|
||||
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
|
||||
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
|
||||
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
|
||||
* 与队列原地编辑只保 text 同一取舍。 */
|
||||
async function backfillQueued(idx: number) {
|
||||
const item = activeQueue.value[idx]
|
||||
if (!item) return
|
||||
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
|
||||
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
|
||||
if (chatInputRef.value?.hasUnsentInput()) {
|
||||
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return
|
||||
}
|
||||
const skill = item.skill
|
||||
? store.state.skills.find(s => s.name === item.skill) ?? null
|
||||
: null
|
||||
store.cancelQueued(idx)
|
||||
chatInputRef.value?.fillDraft(item.text, skill)
|
||||
}
|
||||
// 队列编辑态(编辑中 index/文本/进取消存取)已随 queue-panel.vue 子组件迁移(2026-10-18
|
||||
// UI 重构批),父组件死副本删除(vue-tsc TS6133);IME 守卫等行为在子组件内同名实现。
|
||||
|
||||
// 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随
|
||||
// MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。
|
||||
@@ -627,8 +442,7 @@ onBeforeUnmount(() => {
|
||||
// 归还 listener 租约(最后一份归还才真拆单例 listener;双重归还/下溢由 useAiEvents dev 断言暴露)
|
||||
_listenerRelease?.()
|
||||
_listenerRelease = null
|
||||
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
|
||||
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
|
||||
// V7 排队时钟已随 queue-panel.vue 迁移,其 interval 由子组件 onBeforeUnmount 自清。
|
||||
// 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏
|
||||
window.removeEventListener('keydown', onGlobalKeydown)
|
||||
// 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
|
||||
@@ -786,16 +600,7 @@ async function toggleAlwaysOnTop() {
|
||||
// I12(2026-10-05):顶部 ... 菜单入口(TopBar emit show-shortcuts)→ 此处 Teleport 展示速查浮层。
|
||||
// 键位表 shortcuts 与 onGlobalKeydown 同文件同居——键区调整时表随手同步,防速查与真实键位漂移。
|
||||
const shortcutsVisible = ref(false)
|
||||
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略);t 已在本组件上方 useI18n 解构
|
||||
const shortcuts = computed(() => [
|
||||
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
|
||||
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
|
||||
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
|
||||
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
|
||||
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
|
||||
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
|
||||
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
|
||||
])
|
||||
// 键位表已随 shortcuts-overlay.vue 子组件迁移(C4b),父侧死副本删除(vue-tsc TS6133)。
|
||||
function showShortcuts() {
|
||||
shortcutsVisible.value = true
|
||||
}
|
||||
@@ -902,7 +707,7 @@ async function initContextEventListeners(): Promise<void> {
|
||||
复用按钮抽 components.css 全局。本块仅保留 AiChat 自身元素:面板布局/聊天区/input-area
|
||||
容器/agentic 进度/待发队列/toast。子组件元素由各子组件 scoped 负责,父级 scoped 不渗透。) ═══ */
|
||||
.ai-panel {
|
||||
position: relative; /* 容纳 .ai-toast(absolute,相对面板定位,嵌入/分离模式均生效) */
|
||||
position: relative; /* toast 已改全局 .df-toast(fixed 定位);relative 保留兜底其他绝对定位子元素 */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
@@ -924,7 +729,7 @@ async function initContextEventListeners(): Promise<void> {
|
||||
TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享,
|
||||
原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */
|
||||
|
||||
/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .ai-toast-warning) ── */
|
||||
/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .df-toast--warning) ── */
|
||||
.ai-auth-bypass-banner {
|
||||
padding: 5px 14px;
|
||||
background: var(--df-warning-bg);
|
||||
@@ -1054,27 +859,8 @@ async function initContextEventListeners(): Promise<void> {
|
||||
|
||||
/* (.ai-btn-icon:disabled / .ai-btn-spinner / @keyframes ai-btn-spin 已抽 components.css 全局共享) */
|
||||
|
||||
/* ═══ 错误 toast(复用 Settings.vue .toast 样式,前缀 ai- 防 scoped 冲突) ═══ */
|
||||
.ai-toast {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 12px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
max-width: 80%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||||
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
||||
/* ToastType 新增 'success' 兜底样式 */
|
||||
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
|
||||
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
||||
/* ═══ 错误 toast:消费 global.css .df-toast 四色族 + .df-toast-enter/leave 过渡
|
||||
(2026-10-18 整洁波双轨合一,原 .ai-toast-* 本地族删除;error 态映射 --danger) ═══ */
|
||||
|
||||
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
|
||||
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关) ═══ */
|
||||
@@ -1091,7 +877,7 @@ async function initContextEventListeners(): Promise<void> {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg, 8px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 48px);
|
||||
padding: 14px 16px;
|
||||
@@ -1116,7 +902,7 @@ async function initContextEventListeners(): Promise<void> {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-secondary); }
|
||||
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-card); }
|
||||
.ai-shortcuts-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -1132,9 +918,9 @@ async function initContextEventListeners(): Promise<void> {
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-shortcuts-key {
|
||||
font-family: var(--df-mono, monospace);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--df-bg-secondary);
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
|
||||
+72
-50
@@ -21,9 +21,9 @@
|
||||
<span v-html="toolIcon(tc.name)"></span>
|
||||
</span>
|
||||
<div class="ai-tool-header-text">
|
||||
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
|
||||
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
|
||||
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
|
||||
<span class="ai-tool-name df-mono">{{ toolDisplayName(tc) }}</span>
|
||||
<span v-if="tc.status === 'running'" class="ai-tool-sub df-badge df-badge--info ai-tool-chip-run">{{ $t('aiTool.running') }}</span>
|
||||
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed df-badge df-badge--danger" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
|
||||
<span v-else-if="resultSummary" class="ai-tool-sub" :title="summaryTitle">{{ resultSummary }}</span>
|
||||
</div>
|
||||
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span>
|
||||
@@ -31,7 +31,7 @@
|
||||
hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 -->
|
||||
<span
|
||||
v-if="durationText"
|
||||
class="ai-tool-duration"
|
||||
class="ai-tool-duration df-mono"
|
||||
:title="durationTitle"
|
||||
>{{ durationText }}</span>
|
||||
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }">▸</span>
|
||||
@@ -56,8 +56,9 @@
|
||||
|
||||
<!-- 审批:参数内容 + 风险提示 + 按钮 -->
|
||||
<div v-if="tc.status === 'pending_approval'" class="ai-tool-approval">
|
||||
<!-- 风险徽章:High 橙红/Medium 黄,一眼识别操作等级(与左侧着色条同色系) -->
|
||||
<div v-if="riskLevel !== 'low'" class="ai-tool-risk-badge" :class="'is-' + riskLevel">
|
||||
<!-- 风险徽章:High 橙红/Medium 黄,一眼识别操作等级(与左侧着色条同色系)
|
||||
wave2:迁 .df-badge 透明彩底三件套(danger/warning 变体) -->
|
||||
<div v-if="riskLevel !== 'low'" class="ai-tool-risk-badge df-badge" :class="riskLevel === 'high' ? 'df-badge--danger' : 'df-badge--warning'">
|
||||
{{ riskLevel === 'high' ? $t('aiTool.riskHigh') : $t('aiTool.riskMedium') }}
|
||||
</div>
|
||||
<div v-if="visibleArgs.length" class="ai-tool-approval-args">
|
||||
@@ -81,7 +82,7 @@
|
||||
</div>
|
||||
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content -->
|
||||
<div v-if="tc.diff" class="ai-tool-approval-diff">
|
||||
<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="df-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
||||
</div>
|
||||
<!-- path_auth 审批链阶段3b:按 tc.kind 分两组按钮。
|
||||
- kind='path'(路径授权挂起):once/session/always/deny 四选项,调 authorizeDir
|
||||
@@ -415,7 +416,7 @@ const visibleArgs = computed(() => {
|
||||
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,见 ToolCardList 注释)。 */
|
||||
transition: border-color 0.2s var(--df-ease), box-shadow 0.2s var(--df-ease), opacity 0.2s var(--df-ease);
|
||||
}
|
||||
.ai-tool-card--pending_approval { border-color: rgba(240,199,94,0.3); box-shadow: 0 0 0 1px rgba(240,199,94,0.06); }
|
||||
.ai-tool-card--pending_approval { border-color: var(--df-warning-bd); box-shadow: 0 0 0 1px rgba(240,199,94,0.06); }
|
||||
.ai-tool-card--completed { border-color: rgba(61,219,160,0.2); }
|
||||
.ai-tool-card--rejected { border-color: rgba(240,101,101,0.2); opacity: 0.55; }
|
||||
/* 工具失败态——AR-6 下 status 仍为 completed,故单独覆盖边框与 completed 区分 */
|
||||
@@ -424,13 +425,17 @@ const visibleArgs = computed(() => {
|
||||
.ai-tool-sub--failed { color: var(--df-danger); }
|
||||
.ai-tool-rejected {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px 8px 42px; font-size: 11px; color: var(--df-danger);
|
||||
padding: 4px 10px 8px var(--df-icon-gutter); font-size: 11px; color: var(--df-danger);
|
||||
}
|
||||
.ai-tool-rejected-icon { font-weight: 500; }
|
||||
|
||||
/* wave2:折叠轨迹卡的展开态左边 2px 强调边(对齐 workbench cv.on 范式);
|
||||
只作用于可折叠卡(completed),pending 的 data-risk 左缘与 running 强制展开态不参与 */
|
||||
.ai-tool-card--completed:not(.ai-tool-card--collapsed) { border-left: 2px solid var(--df-info); }
|
||||
|
||||
/* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */
|
||||
.ai-tool-card--collapsed .ai-tool-header { cursor: pointer; }
|
||||
.ai-tool-card--collapsed .ai-tool-header:hover { background: rgba(255,255,255,0.03); }
|
||||
.ai-tool-card--collapsed .ai-tool-header:hover { background: var(--df-bg-card-hover); }
|
||||
.ai-tool-body { overflow: hidden; }
|
||||
.ai-tool-collapse-hint {
|
||||
font-size: 10px; color: var(--df-text-dim); margin-left: auto; flex-shrink: 0;
|
||||
@@ -444,9 +449,9 @@ const visibleArgs = computed(() => {
|
||||
width: 24px; height: 24px; border-radius: var(--df-radius-sm);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-icon-wrap--file-read { background: rgba(94,175,240,0.12); color: var(--df-info); }
|
||||
.ai-tool-icon-wrap--file-read { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.ai-tool-icon-wrap--file-write { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.ai-tool-icon-wrap--dir { background: rgba(240,199,94,0.12); color: var(--df-warning); }
|
||||
.ai-tool-icon-wrap--dir { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.ai-tool-icon-wrap--create { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.ai-tool-icon-wrap--delete { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.ai-tool-icon-wrap--list { background: var(--df-accent-bg); color: var(--df-accent); }
|
||||
@@ -455,14 +460,40 @@ const visibleArgs = computed(() => {
|
||||
display: flex; flex-direction: row; flex-wrap: wrap; align-items: baseline;
|
||||
column-gap: 8px; row-gap: 1px; flex: 1; min-width: 0;
|
||||
}
|
||||
/* wave2:工具名迁 mono 元数据(df-mono 接管字体,此处 10px dim 收敛视觉) */
|
||||
.ai-tool-name {
|
||||
font-size: 12px; font-weight: 500; color: var(--df-text);
|
||||
font-size: 10px; font-weight: 500; color: var(--df-text-dim);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-sub {
|
||||
font-size: 10px; color: var(--df-text-dim);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
/* wave2:状态 chip 迁 .df-badge(running=info+脉冲点 / failed=danger);
|
||||
scoped 双类压过 .ai-tool-sub 灰字,色板与 df-badge 变体对齐(token 化) */
|
||||
.ai-tool-sub.df-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.ai-tool-sub.df-badge--info {
|
||||
background: var(--df-info-bg);
|
||||
color: var(--df-info);
|
||||
border-color: color-mix(in srgb, var(--df-info) 30%, transparent);
|
||||
}
|
||||
.ai-tool-sub.df-badge--danger {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
border-color: color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||
}
|
||||
/* running chip 内嵌脉冲点(全局 df-pulse 节奏,对齐 df-dot--run 活性语义) */
|
||||
.ai-tool-chip-run::before {
|
||||
content: '';
|
||||
width: 5px; height: 5px; border-radius: 50%;
|
||||
background: var(--df-info);
|
||||
flex-shrink: 0;
|
||||
animation: df-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
/* -- Status dot (脉冲动画) -- */
|
||||
.ai-tool-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
@@ -487,16 +518,16 @@ const visibleArgs = computed(() => {
|
||||
.ai-tool-status-dot--rejected { background: var(--df-danger); }
|
||||
|
||||
/* -- Skeleton (running 状态) -- */
|
||||
.ai-tool-skeleton { padding: 4px 10px 10px 42px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.ai-tool-skeleton { padding: 4px 10px 10px var(--df-icon-gutter); display: flex; flex-direction: column; gap: 5px; }
|
||||
.ai-tool-skeleton-line {
|
||||
height: 8px; border-radius: var(--df-radius-xs);
|
||||
background: linear-gradient(90deg, var(--df-bg) 25%, var(--df-bg-card-hover) 50%, var(--df-bg) 75%);
|
||||
background-size: 200% 100%; animation: shimmer 1.5s infinite;
|
||||
background-size: 200% 100%; animation: df-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/* -- 实时流式输出(running 态:run_command 行 / ask_uclaw 进度块) -- */
|
||||
.ai-tool-stream {
|
||||
padding: 4px 10px 10px 42px;
|
||||
padding: 4px 10px 10px var(--df-icon-gutter);
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
max-height: 160px; overflow-y: auto;
|
||||
}
|
||||
@@ -507,53 +538,47 @@ const visibleArgs = computed(() => {
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
.ai-tool-stream-more {
|
||||
font-size: 10px; color: var(--df-text-tertiary);
|
||||
font-size: 10px; color: var(--df-text-dim);
|
||||
padding-top: 2px; font-style: italic;
|
||||
}
|
||||
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
|
||||
/* -- 审批参数展示 -- */
|
||||
.ai-tool-approval-args {
|
||||
padding: 4px 10px 6px; margin-bottom: 6px;
|
||||
border-top: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
|
||||
background: var(--df-bg-elevated, rgba(0, 0, 0, 0.03));
|
||||
background: var(--df-bg);
|
||||
}
|
||||
.ai-tool-arg { display: flex; gap: 8px; padding: 2px 0; font-size: 11px; line-height: 1.5; }
|
||||
.ai-tool-arg-key { flex-shrink: 0; min-width: 64px; color: var(--df-text-secondary); font-family: var(--df-font-mono); }
|
||||
.ai-tool-arg-val { flex: 1; word-break: break-all; white-space: pre-wrap; font-family: var(--df-font-mono); color: var(--df-text-primary); }
|
||||
.ai-tool-arg-val { flex: 1; word-break: break-all; white-space: pre-wrap; font-family: var(--df-font-mono); color: var(--df-text); }
|
||||
.ai-tool-approval-reason {
|
||||
padding: 4px 10px 6px; margin-bottom: 6px; font-size: 11px;
|
||||
color: var(--df-warning, #d97706); background: var(--df-warning-bg, rgba(217, 119, 6, 0.08));
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
color: var(--df-warning); background: var(--df-warning-bg);
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
|
||||
/* 审批挂起时长提示(>2分钟橙色,>5分钟红色) */
|
||||
.ai-tool-wait-time {
|
||||
padding: 3px 10px; margin-bottom: 6px; font-size: 11px;
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text-dim, #888);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
color: var(--df-text-dim);
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.ai-tool-wait-time.is-long {
|
||||
color: var(--df-warning, #d97706);
|
||||
color: var(--df-warning);
|
||||
animation: pulseGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
.ai-tool-wait-time.is-urgent {
|
||||
color: var(--df-danger, #e5484d);
|
||||
color: var(--df-danger);
|
||||
animation: pulseGlow 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* write_file 审批 diff 预览区(红删绿增行级) */
|
||||
/* write_file 审批 diff 预览区(红删绿增行级);
|
||||
基类 .df-diff-pre(+code)/.df-diff-line 消费 global.css 共享定义,此处仅留行配色变体 */
|
||||
.ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; }
|
||||
.ai-tool-diff-pre {
|
||||
margin: 0; padding: 6px 0; max-height: 240px; overflow: auto;
|
||||
font-family: var(--df-font-mono); font-size: 11px; line-height: 1.5; background: var(--df-bg-card);
|
||||
}
|
||||
.ai-tool-diff-pre code { display: block; white-space: pre; }
|
||||
.ai-tool-diff-line { display: block; padding: 0 10px; white-space: pre-wrap; word-break: break-all; }
|
||||
.ai-tool-diff-line--add { color: var(--df-success, #3ddb9c); background: var(--df-success-bg, rgba(61, 219, 160, 0.08)); }
|
||||
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
|
||||
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
|
||||
.df-diff-line--add { color: var(--df-success); background: var(--df-success-bg); }
|
||||
.df-diff-line--del { color: var(--df-danger); background: var(--df-danger-bg); }
|
||||
.df-diff-line--ctx { color: var(--df-text-dim); }
|
||||
|
||||
/* -- 审批按钮 -- */
|
||||
.ai-tool-actions { display: flex; gap: 6px; padding: 0 10px 8px; }
|
||||
@@ -566,11 +591,11 @@ const visibleArgs = computed(() => {
|
||||
.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);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
}
|
||||
.ai-tool-btn--session:hover { background: var(--df-accent, #7b6ff0); color: #fff; border-color: var(--df-accent, #7b6ff0); }
|
||||
.ai-tool-btn--session:hover { background: var(--df-accent); color: #fff; border-color: var(--df-accent); }
|
||||
/* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */
|
||||
.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); }
|
||||
@@ -587,15 +612,14 @@ const visibleArgs = computed(() => {
|
||||
.ai-tool-btn-icon :deep(svg) { display: block; }
|
||||
.ai-tool-btn-spinner {
|
||||
width: 12px; height: 12px; border: 1.5px solid currentColor; border-top-color: transparent;
|
||||
border-radius: 50%; display: inline-block; flex-shrink: 0; animation: aiToolBtnSpin 0.7s linear infinite;
|
||||
border-radius: 50%; display: inline-block; flex-shrink: 0; animation: df-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes aiToolBtnSpin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* -- 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
|
||||
.ai-tool-toast {
|
||||
position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
|
||||
padding: 6px 12px; border-radius: var(--df-radius-sm); font-size: 11px; z-index: 100;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3); max-width: 90%; overflow-wrap: anywhere; pointer-events: none;
|
||||
box-shadow: var(--df-shadow-sm); max-width: 90%; overflow-wrap: anywhere; pointer-events: none;
|
||||
}
|
||||
.ai-tool-toast--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||||
.ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
@@ -605,15 +629,13 @@ const visibleArgs = computed(() => {
|
||||
.ai-tool-card[data-risk='high'] { border-left: 2px solid var(--df-danger); }
|
||||
.ai-tool-card[data-risk='medium'] { border-left: 2px solid var(--df-warning); }
|
||||
|
||||
/* -- 风险徽章(审批区首行,High 橙红底/Medium 黄底,10px 大写字距) -- */
|
||||
/* -- 风险徽章(审批区首行):wave2 迁 .df-badge 透明彩底(danger/warning 变体接管配色),
|
||||
此处只留布局(独立成行+外边距) -- */
|
||||
.ai-tool-risk-badge {
|
||||
display: inline-flex; align-items: center; align-self: flex-start;
|
||||
padding: 2px 8px; margin: 6px 10px 0;
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.5px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
align-self: flex-start;
|
||||
margin: 6px 10px 0;
|
||||
font-weight: 600; letter-spacing: 0.5px;
|
||||
}
|
||||
.ai-tool-risk-badge.is-high { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent); }
|
||||
.ai-tool-risk-badge.is-medium { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent); }
|
||||
|
||||
/* 审批区改纵向 flex(徽章需独立成行,原块级布局不变) */
|
||||
.ai-tool-approval { display: flex; flex-direction: column; }
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<button class="ai-btn ai-btn--sm ai-btn--danger" :disabled="batchProcessing" @click="$emit('batch-approve', 'reject')">
|
||||
{{ $t('aiTool.rejectAll') }}
|
||||
</button>
|
||||
<!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果 -->
|
||||
<span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint">
|
||||
<!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果(wave2 迁 .df-badge danger) -->
|
||||
<span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint df-badge df-badge--danger">
|
||||
{{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -26,8 +26,8 @@
|
||||
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
|
||||
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
|
||||
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
|
||||
<!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(红字计数) -->
|
||||
<span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</span>
|
||||
<!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(wave2 迁 .df-badge danger) -->
|
||||
<span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail df-badge df-badge--danger">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</span>
|
||||
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
|
||||
</div>
|
||||
<ToolCard
|
||||
@@ -451,23 +451,17 @@ function groupIcon(name: string): string {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* -- 批量操作栏 -- */
|
||||
/* -- 批量操作栏(wave2:白透明底残留改 token,浅色主题成立) -- */
|
||||
.ai-batch-approve {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: rgba(255,255,255,0.02);
|
||||
background: var(--df-bg-card);
|
||||
}
|
||||
/* 批量批准将二次确认的 High 风险项数徽标(橙红警示,不抢按钮焦点) */
|
||||
/* 批量批准将二次确认的 High 风险项数徽标:配色由 df-badge--danger 接管,此处只留布局 */
|
||||
.ai-batch-high-risk-hint {
|
||||
font-size: 10px;
|
||||
color: var(--df-danger);
|
||||
background: var(--df-danger-bg);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||
border-radius: var(--df-radius-sm);
|
||||
padding: 2px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* .ai-btn 基类:批量审批按钮(历史重构遗漏定义,补全治本) */
|
||||
@@ -512,16 +506,13 @@ function groupIcon(name: string): string {
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-tool-group-header:hover {
|
||||
background: rgba(255,255,255,0.03);
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
/* R7 组头失败徽标:失败计数红字,margin-left auto 推到 chevron 之前右对齐 */
|
||||
/* R7 组头失败徽标:配色由 df-badge--danger 接管,此处只留布局(右对齐推到 chevron 前) */
|
||||
.ai-tool-group-fail {
|
||||
margin-left: auto;
|
||||
margin-right: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--df-danger);
|
||||
opacity: 0.9;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-tool-group-icon {
|
||||
|
||||
@@ -120,12 +120,12 @@
|
||||
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
|
||||
<!-- R12(2026-10-05):覆盖写入的行级 diff 回看(后端 diff 字段,新建/base64 为 null 不渲染) -->
|
||||
<div v-if="resultDiffLines.length" class="ai-tool-write-diff">
|
||||
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
||||
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
||||
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式)
|
||||
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 df-diff-pre/--add/--del/--ctx 样式)
|
||||
后端 generate_diff 产完整 diff,result.diff 字段此前被丢弃,用户无法验证改了什么 → 此分支渲染 diff -->
|
||||
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
|
||||
<div class="ai-tool-file-bar">
|
||||
@@ -136,7 +136,7 @@
|
||||
</div>
|
||||
<div v-if="parsed?.warning" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ parsed.warning }}</div>
|
||||
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
|
||||
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
||||
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
||||
<!-- R12a:长 diff 展开入口(默认预览 120 行,点开全量) -->
|
||||
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
|
||||
</div>
|
||||
@@ -468,7 +468,7 @@ watch(() => props.tc.status, (s) => {
|
||||
}
|
||||
.ai-tool-grep-match {
|
||||
padding: 3px 12px;
|
||||
border-bottom: 0.5px solid rgba(255,255,255,0.03);
|
||||
border-bottom: 0.5px solid var(--df-hover);
|
||||
}
|
||||
.ai-tool-grep-match:last-child {
|
||||
border-bottom: none;
|
||||
@@ -511,7 +511,7 @@ watch(() => props.tc.status, (s) => {
|
||||
word-break: break-all;
|
||||
background: rgba(61,219,160,0.05);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--df-radius-xs, 3px);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.ai-tool-grep-context {
|
||||
margin: 2px 0 0;
|
||||
@@ -521,8 +521,8 @@ watch(() => props.tc.status, (s) => {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-radius: var(--df-radius-xs, 3px);
|
||||
background: var(--df-hover);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.ai-tool-grep-context code {
|
||||
display: block;
|
||||
@@ -533,7 +533,7 @@ watch(() => props.tc.status, (s) => {
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255,255,255,0.04);
|
||||
background: var(--df-hover);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
padding: 2px 8px;
|
||||
@@ -586,13 +586,13 @@ watch(() => props.tc.status, (s) => {
|
||||
.ai-tool-dir-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 6px;
|
||||
padding: 3px 12px;
|
||||
font-size: 11px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ai-tool-dir-entry:hover {
|
||||
background: rgba(255,255,255,0.03);
|
||||
background: var(--df-hover);
|
||||
}
|
||||
.ai-tool-dir-entry--directory .ai-tool-dir-name {
|
||||
font-weight: 500;
|
||||
@@ -650,8 +650,8 @@ watch(() => props.tc.status, (s) => {
|
||||
padding: 4px 10px 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--df-warning, #d97706);
|
||||
background: var(--df-warning-bg, rgba(217, 119, 6, 0.08));
|
||||
color: var(--df-warning);
|
||||
background: var(--df-warning-bg);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
}
|
||||
.ai-tool-approval-diff {
|
||||
@@ -660,29 +660,11 @@ watch(() => props.tc.status, (s) => {
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-tool-diff-pre {
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
background: var(--df-bg-card);
|
||||
}
|
||||
.ai-tool-diff-pre code {
|
||||
display: block;
|
||||
white-space: pre;
|
||||
}
|
||||
.ai-tool-diff-line {
|
||||
display: block;
|
||||
padding: 0 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ai-tool-diff-line--add { color: var(--df-success, #3ddb9c); background: var(--df-success-bg, rgba(61, 219, 160, 0.08)); }
|
||||
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
|
||||
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
|
||||
/* diff 基类 .df-diff-pre(+code)/.df-diff-line 消费 global.css 共享定义(2026-10-18 整洁波
|
||||
双轨合一,与 ToolCard.vue 审批 diff 同源),此处仅留行配色变体 */
|
||||
.df-diff-line--add { color: var(--df-success); background: var(--df-success-bg); }
|
||||
.df-diff-line--del { color: var(--df-danger); background: var(--df-danger-bg); }
|
||||
.df-diff-line--ctx { color: var(--df-text-dim); }
|
||||
|
||||
/* -- Generic Result --
|
||||
max-height 100px→200px:通用结果常含 JSON/结构化文本,100px 仅约 5 行,默认滚动条挤压可读性;
|
||||
@@ -741,12 +723,12 @@ watch(() => props.tc.status, (s) => {
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-tool-cmd-toggle:hover {
|
||||
color: var(--df-text);
|
||||
background: var(--df-bg-hover, rgba(255,255,255,0.06));
|
||||
background: var(--df-hover);
|
||||
}
|
||||
.ai-tool-cmd-output {
|
||||
margin: 0;
|
||||
@@ -803,8 +785,8 @@ watch(() => props.tc.status, (s) => {
|
||||
}
|
||||
.ai-tool-http-trunc-warn {
|
||||
padding: 4px 10px;
|
||||
font-size: 10.5px;
|
||||
color: var(--df-warning, #f0c75e);
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
@@ -828,10 +810,10 @@ watch(() => props.tc.status, (s) => {
|
||||
}
|
||||
.ai-tool-fetch-mode--static,
|
||||
.ai-tool-fetch-mode--obscura {
|
||||
color: var(--df-success, #3ddb9c);
|
||||
color: var(--df-success);
|
||||
}
|
||||
.ai-tool-fetch-mode--static_fallback {
|
||||
color: var(--df-warning, #f0c75e);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-tool-fetch-title {
|
||||
padding: 4px 10px 2px;
|
||||
@@ -845,8 +827,8 @@ watch(() => props.tc.status, (s) => {
|
||||
.ai-tool-obscura-hint {
|
||||
margin: 4px 10px 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--df-warning-bg, rgba(240,199,94,0.08));
|
||||
border: 0.5px solid var(--df-warning, #f0c75e);
|
||||
background: var(--df-warning-bg);
|
||||
border: 0.5px solid var(--df-warning);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -854,8 +836,8 @@ watch(() => props.tc.status, (s) => {
|
||||
}
|
||||
.ai-tool-obscura-hint-text {
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 10.5px;
|
||||
color: var(--df-warning, #f0c75e);
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -870,7 +852,7 @@ watch(() => props.tc.status, (s) => {
|
||||
font-weight: 500;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border: none;
|
||||
background: var(--df-accent, #5b8def);
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-family: var(--df-font-sans);
|
||||
@@ -891,5 +873,5 @@ watch(() => props.tc.status, (s) => {
|
||||
padding: 3px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-secondary); }
|
||||
.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-card); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
|
||||
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
|
||||
单轮问答 agentRound 恒 0,不显示(避免误显)。
|
||||
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
|
||||
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
|
||||
完成聚合语义由 MessageList 的执行轨迹卡承接。
|
||||
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。
|
||||
C4b 拆分自 AiChat.vue(纯展示,store 单例直读,零行为变更)。 -->
|
||||
<div v-if="showAgenticProgress" class="ai-agentic-progress">
|
||||
<span class="ai-agentic-progress-dot"></span>
|
||||
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { collectTurnStats, turnMessages } from '../../composables/ai/useTurnTrace'
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。
|
||||
const showAgenticProgress = computed(() =>
|
||||
store.state.streaming && store.state.agentRound > 0,
|
||||
)
|
||||
// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。
|
||||
// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议);
|
||||
// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。
|
||||
// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。
|
||||
const agenticProgressText = computed(() => {
|
||||
const pending = store.state.pendingApprovals.length
|
||||
const total = collectTurnStats(turnMessages(store.state.messages)).total
|
||||
return pending > 0
|
||||
? t('ai.agenticProgress', { round: store.state.agentRound, total, pending })
|
||||
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, total })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ═══ Agentic 循环进度条(样式随区块自 AiChat.vue 迁入,零视觉变更) ═══ */
|
||||
.ai-agentic-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
padding: 3px 8px;
|
||||
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.ai-agentic-progress-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
animation: ai-agentic-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.ai-agentic-progress-text {
|
||||
font-family: var(--df-font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@keyframes ai-agentic-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<!-- ═══ 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目)
|
||||
C4b 拆分自 AiChat.vue 队列区(模板 + L2 发送韧性 + V7 排队时钟 + 队列编辑/回填,零行为变更;
|
||||
store 单例共享,confirm 自持一份 useConfirm + ConfirmDialog——同 ChatInput/ConversationSidebar
|
||||
模式:useConfirm 非单例,子组件发起的 confirm 须本组件渲染) ═══ -->
|
||||
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
|
||||
<div class="ai-queue-head">
|
||||
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
|
||||
<div class="ai-queue-actions">
|
||||
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
|
||||
<button class="ai-queue-clear" @click="handleClearQueue">{{ $t('aiChat.clearQueue') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-queue-list">
|
||||
<div v-for="(item, idx) in activeQueue" :key="idx" class="ai-queue-item">
|
||||
<!-- 编辑态只编 text:inline input,回车/失焦写回,ESC 取消 -->
|
||||
<template v-if="editingQueueIdx === idx">
|
||||
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
||||
<input
|
||||
v-model="editingQueueText"
|
||||
class="ai-queue-edit-input"
|
||||
type="text"
|
||||
@keydown="onQueueEditKeydown"
|
||||
@blur="saveEditQueued"
|
||||
/>
|
||||
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')" :aria-label="$t('aiChat.queueSave')">✓</button>
|
||||
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')" :aria-label="$t('aiChat.queueCancel')">✕</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
||||
<span class="ai-queue-text" :title="item.text">{{ item.text }}</span>
|
||||
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
|
||||
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
|
||||
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
|
||||
<button class="ai-queue-btn" @click="backfillQueued(idx)" :title="$t('aiChat.queueBackfill')">{{ $t('aiChat.queueBackfill') }}</button>
|
||||
<!-- 编辑按钮 -->
|
||||
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
|
||||
<!-- 立即发送(打断当前+发本条) -->
|
||||
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
|
||||
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')" :aria-label="$t('common.cancel')">×</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 确认弹层(强制发送/清空/回填覆盖二次确认;同 ChatInput 自持模式) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="confirmState.dangerLabel"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import { useConfirm } from '../../composables/useConfirm'
|
||||
import type { SkillInfo } from '../../api/types'
|
||||
|
||||
// 回填链路跨组件:输入框 ref(chatInputRef)在父,本组件经 props 读草稿态(hasUnsentInput)、
|
||||
// 经 emit 写草稿(fill-draft),由父转发 ChatInput——共享函数留主文件 props/emits 传(拆分纪律)。
|
||||
const props = defineProps<{
|
||||
/** 父侧 ChatInput.hasUnsentInput 转发(回填覆盖未发送草稿前的 confirm 守卫) */
|
||||
hasUnsentInput: () => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 队列项回填:父转发 ChatInput.fillDraft(text, skill) 写入输入框草稿 */
|
||||
(e: 'fill-draft', text: string, skill: SkillInfo | null): void
|
||||
}>()
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── 确认弹层(useConfirm 非单例,子组件自持一份 + 自渲染 ConfirmDialog,同 ChatInput 先例) ──
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。
|
||||
* 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示
|
||||
* 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */
|
||||
const activeQueue = computed(() =>
|
||||
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
|
||||
)
|
||||
|
||||
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
|
||||
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
|
||||
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
|
||||
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
|
||||
const nowTick = ref(Date.now())
|
||||
let queueTickTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。
|
||||
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed
|
||||
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick
|
||||
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
|
||||
const queueTimedOut = computed(() => {
|
||||
void nowTick.value
|
||||
return store.isQueueTimedOut(store.state.activeConversationId)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => activeQueue.value.length > 0,
|
||||
(waiting) => {
|
||||
if (waiting && queueTickTimer === null) {
|
||||
nowTick.value = Date.now()
|
||||
queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000)
|
||||
} else if (!waiting && queueTickTimer !== null) {
|
||||
clearInterval(queueTickTimer)
|
||||
queueTickTimer = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */
|
||||
function formatWaitTime(totalSeconds: number): string {
|
||||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */
|
||||
function queueItemWait(item: { enqueuedAt: number }): string {
|
||||
return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000)))
|
||||
}
|
||||
|
||||
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
|
||||
async function handleForceSend() {
|
||||
await store.tryForceSend(confirmDialog, store.state.activeConversationId)
|
||||
}
|
||||
|
||||
/** UI-4(看板 943)清空队列:二次确认后全清当前活跃会话排队消息。
|
||||
* 一键误触会静默丢掉全部排队消息且不可恢复,复用 ConfirmDialog 先例
|
||||
* (同 tryForceSend 的 confirmFn 注入形态),不引 window.confirm(仓风格)。 */
|
||||
async function handleClearQueue() {
|
||||
const convId = store.state.activeConversationId
|
||||
if (!convId) return
|
||||
const confirmed = await confirmDialog(t('aiChat.clearQueueConfirm'))
|
||||
if (confirmed) store.clearQueue(convId)
|
||||
}
|
||||
|
||||
// ── 队列消息编辑(只编 text)+ 立即发送(插队)──
|
||||
|
||||
/** 当前正在编辑的队列项 index;null=非编辑态 */
|
||||
const editingQueueIdx = ref<number | null>(null)
|
||||
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
|
||||
const editingQueueText = ref('')
|
||||
|
||||
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue
|
||||
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
|
||||
// 新会话同下标队列项上,切会话即重置(原随父 watch activeConversationId,拆分后本组件自管)。
|
||||
watch(() => store.state.activeConversationId, () => {
|
||||
if (editingQueueIdx.value !== null) cancelEditQueued()
|
||||
})
|
||||
|
||||
/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */
|
||||
function startEditQueued(idx: number) {
|
||||
const item = activeQueue.value[idx]
|
||||
if (!item) return
|
||||
editingQueueIdx.value = idx
|
||||
editingQueueText.value = item.text
|
||||
}
|
||||
|
||||
/** 取消编辑:还原非编辑态,不写回 */
|
||||
function cancelEditQueued() {
|
||||
editingQueueIdx.value = null
|
||||
editingQueueText.value = ''
|
||||
}
|
||||
|
||||
/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
|
||||
* 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫,
|
||||
* 收敛到本函数统一判。 */
|
||||
function onQueueEditKeydown(e: KeyboardEvent) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
saveEditQueued()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditQueued()
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
|
||||
function saveEditQueued() {
|
||||
if (editingQueueIdx.value === null) return
|
||||
store.editQueued(editingQueueIdx.value, editingQueueText.value)
|
||||
editingQueueIdx.value = null
|
||||
editingQueueText.value = ''
|
||||
}
|
||||
|
||||
/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */
|
||||
async function handleSendQueuedNow(idx: number) {
|
||||
await store.sendQueuedNow(idx)
|
||||
}
|
||||
|
||||
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
|
||||
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
|
||||
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
|
||||
* 与队列原地编辑只保 text 同一取舍。 */
|
||||
async function backfillQueued(idx: number) {
|
||||
const item = activeQueue.value[idx]
|
||||
if (!item) return
|
||||
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
|
||||
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
|
||||
if (props.hasUnsentInput()) {
|
||||
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return
|
||||
}
|
||||
const skill = item.skill
|
||||
? store.state.skills.find(s => s.name === item.skill) ?? null
|
||||
: null
|
||||
store.cancelQueued(idx)
|
||||
emit('fill-draft', item.text, skill)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
|
||||
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ═══ 待发送队列(样式随区块自 AiChat.vue 迁入,零视觉变更) ═══ */
|
||||
.ai-queue {
|
||||
margin-bottom: 6px;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-queue--timeout {
|
||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
}
|
||||
.ai-queue-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ai-queue-title { font-size: 11px; color: var(--df-text-dim); font-weight: 600; }
|
||||
.ai-queue--timeout .ai-queue-title { color: var(--df-warning); }
|
||||
.ai-queue-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.ai-queue-force {
|
||||
background: var(--df-warning); border: none; color: #000; cursor: pointer;
|
||||
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: calc(var(--df-radius) - 2px);
|
||||
}
|
||||
.ai-queue-force:hover { filter: brightness(1.1); }
|
||||
.ai-queue-clear { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 11px; padding: 0; }
|
||||
.ai-queue-clear:hover { color: var(--df-text); }
|
||||
.ai-queue-list { display: flex; flex-direction: column; gap: 3px; }
|
||||
.ai-queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
background: var(--df-bg-card);
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-queue-skill { color: var(--df-accent); font-weight: 600; flex-shrink: 0; }
|
||||
/* V7 排队时钟:每项已等待时长 */
|
||||
.ai-queue-wait { color: var(--df-text-dim); font-size: 10px; flex-shrink: 0; opacity: 0.7; }
|
||||
.ai-queue-text {
|
||||
color: var(--df-text);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-queue-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; }
|
||||
.ai-queue-x:hover { color: var(--df-danger); }
|
||||
/* 队列项编辑/立即发送按钮 + 编辑态 input */
|
||||
.ai-queue-btn {
|
||||
background: none; border: none; color: var(--df-text-dim); cursor: pointer;
|
||||
font-size: 11px; line-height: 1; padding: 0 4px; flex-shrink: 0;
|
||||
}
|
||||
.ai-queue-btn:hover { color: var(--df-accent); }
|
||||
.ai-queue-edit-input {
|
||||
flex: 1; min-width: 0; font-size: 12px; padding: 1px 4px;
|
||||
background: var(--df-bg-card); color: var(--df-text);
|
||||
border: 0.5px solid var(--df-accent);
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
outline: none;
|
||||
}
|
||||
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||
.ai-queue-edit-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
|
||||
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts)。
|
||||
C4b 拆分自 AiChat.vue(visible 状态留父——onGlobalKeydown Esc 仲裁需读写,开/关经 props/emits) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="ai-shortcuts-mask" @click.self="emit('close')">
|
||||
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
|
||||
<div class="ai-shortcuts-head">
|
||||
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
|
||||
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
<ul class="ai-shortcuts-list">
|
||||
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
|
||||
<span class="ai-shortcuts-key">{{ s.key }}</span>
|
||||
<span class="ai-shortcuts-act">{{ s.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
/** 面板显隐(状态留父:TopBar emit show-shortcuts 置 true,onGlobalKeydown Esc 分支仲裁) */
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 键位表 shortcuts 与父 AiChat onGlobalKeydown 同族——键区调整时两边同步,防速查与真实键位漂移。
|
||||
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略)。
|
||||
const shortcuts = computed(() => [
|
||||
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
|
||||
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
|
||||
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
|
||||
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
|
||||
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
|
||||
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
|
||||
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
|
||||
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关;样式随区块自 AiChat.vue 迁入) ═══ */
|
||||
.ai-shortcuts-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ai-shortcuts-panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg, 8px);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 48px);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.ai-shortcuts-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ai-shortcuts-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-shortcuts-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--df-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-card); }
|
||||
.ai-shortcuts-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-shortcuts-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-shortcuts-key {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-shortcuts-act { color: var(--df-text-secondary); }
|
||||
</style>
|
||||
@@ -108,7 +108,9 @@ function onDismiss() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态 */
|
||||
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态
|
||||
wave2(2026-10-18):danger 系透明彩底范式(待审批=需用户处置的阻断信号)——
|
||||
弹层底 bg-card+边框+10px 圆角,边框 color-mix 30%+左缘 3px 强调,标题同色 12px/600 */
|
||||
.approval-overlay {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
@@ -121,10 +123,10 @@ function onDismiss() {
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
background: var(--df-bg-card);
|
||||
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 var(--df-accent-soft);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||
border-left: 3px solid var(--df-danger);
|
||||
border-radius: var(--df-radius-lg);
|
||||
box-shadow: var(--df-shadow-md), 0 0 0 1px color-mix(in srgb, var(--df-danger) 12%, transparent);
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s var(--df-ease), border-color 0.18s var(--df-ease), box-shadow 0.18s var(--df-ease);
|
||||
user-select: none;
|
||||
@@ -132,8 +134,8 @@ function onDismiss() {
|
||||
|
||||
.approval-overlay:hover {
|
||||
transform: translateX(-2px);
|
||||
border-color: var(--df-accent);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--df-accent);
|
||||
border-color: color-mix(in srgb, var(--df-danger) 55%, transparent);
|
||||
box-shadow: var(--df-shadow-md), 0 0 0 2px color-mix(in srgb, var(--df-danger) 45%, transparent);
|
||||
}
|
||||
|
||||
.approval-overlay-icon {
|
||||
@@ -144,8 +146,8 @@ function onDismiss() {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||
color: var(--df-danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -172,15 +174,15 @@ function onDismiss() {
|
||||
}
|
||||
|
||||
.approval-overlay-title {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
color: var(--df-danger);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.approval-overlay-desc {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -190,8 +192,8 @@ function onDismiss() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: var(--df-h-icon-btn);
|
||||
height: var(--df-h-icon-btn);
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
@@ -202,7 +204,7 @@ function onDismiss() {
|
||||
}
|
||||
|
||||
.approval-overlay-close:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ onMounted(async () => {
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 8px 0;
|
||||
background: var(--df-bg-secondary);
|
||||
background: var(--df-bg-card);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ci-header {
|
||||
|
||||
+226
-106
@@ -45,81 +45,86 @@
|
||||
@select="selectMention"
|
||||
@update:mention-index="mentionIndex = $event"
|
||||
/>
|
||||
<!-- 2026-08-06:📎 图片选择按钮(tauri 原生对话框选图 → 已配置上传端点则上传换 URL,否则本地 base64 引用) -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.pickImage')"
|
||||
:aria-label="$t('aiChat.pickImage')"
|
||||
@click="onPickImage"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</button>
|
||||
<!-- I9(2026-10-05):文本/代码文件附件入口——选文件读内容以 ```<file> 块插入草稿,
|
||||
走既有文本发送链,零协议改动;大小/行数上限防刷屏 -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.pickFileTitle')"
|
||||
:aria-label="$t('aiChat.pickFileTitle')"
|
||||
@click="onPickTextFile"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
||||
</button>
|
||||
<!-- 定时/待发箱入口(设计 2026-10-10 §3.4):icon-only ⏰ 开 OutboxDrawer
|
||||
(组件内 Teleport 到 body,不占输入区布局;抽屉组件标签在模板尾部) -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.outboxEntry')"
|
||||
:aria-label="$t('aiChat.outboxEntry')"
|
||||
@click="outboxOpen = true"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</button>
|
||||
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
|
||||
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
||||
<button
|
||||
v-if="stopBtnState === 'stopping'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
|
||||
:title="$t('aiChat.stopping')"
|
||||
:aria-label="$t('aiChat.stopping')"
|
||||
disabled
|
||||
>
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'retry'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
|
||||
:title="$t('aiChat.stopFailedRetry')"
|
||||
:aria-label="$t('aiChat.stopFailedRetry')"
|
||||
@click="store.stopChat"
|
||||
>
|
||||
<svg aria-hidden="true" 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 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'stop'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop"
|
||||
:title="$t('aiChat.stopGenerating')"
|
||||
:aria-label="$t('aiChat.stopGenerating')"
|
||||
@click="store.stopChat"
|
||||
>
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="ai-send-btn"
|
||||
:class="{ 'ai-send-btn--active': canSend }"
|
||||
:disabled="!canSend"
|
||||
:aria-label="$t('aiChat.queueSendNow')"
|
||||
@click="handleSend"
|
||||
>
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
<!-- 2026-10-18 wave2: 工具行(workbench .inp-row 范式)——textarea 下方分隔线,
|
||||
左侧功能钮 + 右侧发送/停止钮;纯布局重排,按钮类名/事件/禁用判定零变化 -->
|
||||
<div class="ai-input-row">
|
||||
<!-- 2026-08-06:📎 图片选择按钮(tauri 原生对话框选图 → 已配置上传端点则上传换 URL,否则本地 base64 引用) -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.pickImage')"
|
||||
:aria-label="$t('aiChat.pickImage')"
|
||||
@click="onPickImage"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</button>
|
||||
<!-- I9(2026-10-05):文本/代码文件附件入口——选文件读内容以 ```<file> 块插入草稿,
|
||||
走既有文本发送链,零协议改动;大小/行数上限防刷屏 -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.pickFileTitle')"
|
||||
:aria-label="$t('aiChat.pickFileTitle')"
|
||||
@click="onPickTextFile"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
||||
</button>
|
||||
<!-- 定时/待发箱入口(设计 2026-10-10 §3.4):icon-only ⏰ 开 OutboxDrawer
|
||||
(组件内 Teleport 到 body,不占输入区布局;抽屉组件标签在模板尾部) -->
|
||||
<button
|
||||
type="button"
|
||||
class="ai-img-pick-btn"
|
||||
:title="$t('aiChat.outboxEntry')"
|
||||
:aria-label="$t('aiChat.outboxEntry')"
|
||||
@click="outboxOpen = true"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</button>
|
||||
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
|
||||
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
||||
<button
|
||||
v-if="stopBtnState === 'stopping'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
|
||||
:title="$t('aiChat.stopping')"
|
||||
:aria-label="$t('aiChat.stopping')"
|
||||
disabled
|
||||
>
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'retry'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
|
||||
:title="$t('aiChat.stopFailedRetry')"
|
||||
:aria-label="$t('aiChat.stopFailedRetry')"
|
||||
@click="store.stopChat"
|
||||
>
|
||||
<svg aria-hidden="true" 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 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="stopBtnState === 'stop'"
|
||||
type="button"
|
||||
class="ai-send-btn ai-send-btn--stop"
|
||||
:title="$t('aiChat.stopGenerating')"
|
||||
:aria-label="$t('aiChat.stopGenerating')"
|
||||
@click="store.stopChat"
|
||||
>
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="ai-send-btn"
|
||||
:class="{ 'ai-send-btn--active': canSend }"
|
||||
:disabled="!canSend"
|
||||
:title="$t('aiChat.send')"
|
||||
:aria-label="$t('aiChat.send')"
|
||||
@click="handleSend"
|
||||
>
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) -->
|
||||
<EnrichmentPanel
|
||||
@@ -132,6 +137,12 @@
|
||||
/>
|
||||
<!-- 定时/待发箱抽屉(⏰ 入口;Teleport 到 body 渲染,此处仅挂组件) -->
|
||||
<OutboxDrawer :open="outboxOpen" @close="outboxOpen = false" />
|
||||
<!-- F2-4(走查-信息UX 面二):示例卡覆盖未发送草稿的二次确认弹层(组件自持,见 setup 注释) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -145,7 +156,7 @@
|
||||
// - 出错 emit('error', { msg, type }) → 父 showToast(单一 toast 源)
|
||||
// - 示例问题卡片在父(空状态区),父调 expose sendExamplePrompt 触发本组件发送
|
||||
// store 单例共享:useAiStore(sendMessage/editMessage/stopChat)、useProjectStore(@ 联想源)
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { ref, computed, nextTick, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { useProjectStore } from '../../stores/project'
|
||||
@@ -162,6 +173,8 @@ import ImageInput from './ImageInput.vue'
|
||||
import MentionPopover from './MentionPopover.vue'
|
||||
import EnrichmentPanel from './EnrichmentPanel.vue'
|
||||
import OutboxDrawer from './OutboxDrawer.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import { useConfirm } from '../../composables/useConfirm'
|
||||
import { taskApi } from '../../api/task'
|
||||
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
||||
import type { MentionItem } from './MentionItem'
|
||||
@@ -1078,6 +1091,8 @@ async function handleSend() {
|
||||
// 生成中不再拦截:sendMessage 会把消息排入待发送队列,完成后自动续发
|
||||
// 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填
|
||||
inputText.value = ''
|
||||
// F2-2:草稿已消费,立即落盘删项(不等防抖——快速关窗 300ms 内会残留已发送稿)
|
||||
persistDraftNow()
|
||||
pendingSkill.value = null
|
||||
pendingImages.value = []
|
||||
// Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步)
|
||||
@@ -1113,12 +1128,20 @@ async function handleSend() {
|
||||
emit('sent')
|
||||
}
|
||||
|
||||
/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致) */
|
||||
/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致)。
|
||||
* F2-4(走查-信息UX 面二):输入框已有未发送草稿(文本/图片/技能 chip 任一)时先确认,
|
||||
* 取消则原样保留草稿——对齐父级 backfillQueued 的覆盖确认语义,不再静默替换。 */
|
||||
async function sendExamplePrompt(i18nKey: string) {
|
||||
if (hasUnsentInput() && !(await confirmDialog(t('aiChat.exampleOverwriteConfirm')))) return
|
||||
inputText.value = t(i18nKey)
|
||||
await handleSend()
|
||||
}
|
||||
|
||||
// ── F2-4:覆盖确认弹层(仓风格不引 window.confirm;AiChat 的 confirmDialog 状态机在父级
|
||||
// 经模板绑定,子组件复用需父接线 → 本组件自持一份 useConfirm + ConfirmDialog,
|
||||
// 行为/样式与父级 backfillQueued / clearQueue 确认同范式) ──
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
// ── 父调用 expose(父 startEdit/cancelEdit/beforeunload 调用) ──
|
||||
|
||||
/** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */
|
||||
@@ -1140,7 +1163,9 @@ function startEditFocus(msg: AiMessage): void {
|
||||
const len = el.value.length
|
||||
el.setSelectionRange(len, len)
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
|
||||
// F2-3(走查-信息UX):编辑态回填高度复用 INPUT_MAX_HEIGHT,与 autoResize 同源;
|
||||
// 原硬编码 120 使长草稿进编辑态首屏被压矮,任一按键才弹回 200
|
||||
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1155,6 +1180,8 @@ function clearInput(): void {
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
if (inputEl.value) inputEl.value.style.height = 'auto'
|
||||
// F2-2:清空即落盘(取消编辑不应残留旧稿到下次重启)
|
||||
persistDraftNow()
|
||||
}
|
||||
|
||||
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
|
||||
@@ -1184,12 +1211,78 @@ function focus(): void {
|
||||
}
|
||||
|
||||
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
|
||||
// 草稿真相源在 aiShared.convDrafts(内存 Map,不落 localStorage)。父组件 AiChat 的
|
||||
// 会话内真相源在 aiShared.convDrafts(内存 Map);F2-2(走查-信息UX 面二)追加落盘镜像:
|
||||
// 键入过程防抖写 localStorage,重启/换装后经回退链恢复。父组件 AiChat 的
|
||||
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入,
|
||||
// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。
|
||||
|
||||
// F2-2:per-conv 草稿 localStorage 镜像(df-ai-* 键约定;单 JSON 对象,LRU 限 20 条,
|
||||
// 空串收敛删项——与内存 Map 同语义)。读写全程 try/catch:持久化是尽力而为,
|
||||
// 解析失败/配额满不阻断输入主链。
|
||||
const DRAFTS_KEY = 'df-ai-conv-drafts'
|
||||
const DRAFTS_MAX = 20
|
||||
|
||||
function readDraftsStore(): Record<string, string> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem(DRAFTS_KEY) || '{}')
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, string>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function persistDraft(convId: string, text: string): void {
|
||||
const all = readDraftsStore()
|
||||
if (text) {
|
||||
// 删后重插把该会话挪到键序末尾,Object 键序=写入序,溢出时从头淘汰即 LRU
|
||||
delete all[convId]
|
||||
all[convId] = text
|
||||
const keys = Object.keys(all)
|
||||
for (let i = 0; i < keys.length - DRAFTS_MAX; i++) delete all[keys[i]]
|
||||
} else {
|
||||
delete all[convId]
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(DRAFTS_KEY, JSON.stringify(all))
|
||||
} catch {
|
||||
// 配额满等:放弃本次落盘,内存草稿不受影响
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖落盘:逐键写盘不必要,300ms 静默期合并;发送/清空/切会话/卸载走立即落盘收口
|
||||
let draftPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function persistDraftNow(): void {
|
||||
if (draftPersistTimer) {
|
||||
clearTimeout(draftPersistTimer)
|
||||
draftPersistTimer = null
|
||||
}
|
||||
const convId = store.state.activeConversationId
|
||||
if (convId) persistDraft(convId, inputText.value)
|
||||
}
|
||||
|
||||
watch(inputText, () => {
|
||||
if (draftPersistTimer) clearTimeout(draftPersistTimer)
|
||||
draftPersistTimer = setTimeout(persistDraftNow, 300)
|
||||
})
|
||||
|
||||
// F2-2:卸载(面板收起/关窗)前冲刷在途防抖,防最后 300ms 键入的草稿丢失
|
||||
onBeforeUnmount(() => {
|
||||
if (draftPersistTimer) persistDraftNow()
|
||||
})
|
||||
|
||||
watch(() => store.state.activeConversationId, (nv, ov) => {
|
||||
if (ov) setConvDraft(ov, inputText.value)
|
||||
// immediate:挂载时活跃会话已就位(分离窗/恢复会话直开)也走恢复链,不只在切换时
|
||||
if (ov) {
|
||||
setConvDraft(ov, inputText.value)
|
||||
persistDraft(ov, inputText.value)
|
||||
}
|
||||
inputText.value = getConvDraft(nv)
|
||||
// 内存 Map 无记录时回退落盘稿(重启后 Map 为空;同会话内 Map 恒比落盘新,优先)
|
||||
if (!inputText.value && nv) {
|
||||
const persisted = readDraftsStore()[nv]
|
||||
if (typeof persisted === 'string') inputText.value = persisted
|
||||
}
|
||||
// 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位
|
||||
// chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息
|
||||
pendingMentionSpans.value = []
|
||||
@@ -1205,7 +1298,7 @@ watch(() => store.state.activeConversationId, (nv, ov) => {
|
||||
mentionStart.value = -1
|
||||
autoResize()
|
||||
})
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
// ── 脉络卡「带入上下文」外部投递:以输入框实时内容为基底追加(草稿 Map 不含键入
|
||||
// 过程,直接写 Map 会丢当前输入),一次性消费防重放;追加后聚焦输入框由用户决断 ──
|
||||
@@ -1259,32 +1352,33 @@ defineExpose({
|
||||
零视觉变更。原 AiChat scoped 不渗透子组件根,输入框/技能联想/发送按钮元素失配 → 本组件自持。
|
||||
CSS 变量(--df-*)由根级 global.css 提供,共享同一份。.ai-input-area 容器留父级 AiChat scoped。) ═══ -->
|
||||
<style scoped>
|
||||
/* 2026-10-18 wave2: 输入盒迁移 workbench .inp-box 范式——bg-card 底+0.5px 边框+9px 圆角,
|
||||
focus-within 边框转 info;纵向布局让全宽图片预览行自然换行(替代原 flex-wrap 手法) */
|
||||
.ai-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
/* F-260614-05 Phase 2b: flex-wrap 让全宽的图片预览行强制 textarea+按钮换行到下一行 */
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-direction: column;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 6px 8px;
|
||||
border-radius: 9px;
|
||||
padding: 9px 12px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
/* F-260614-05 Phase 2b: 图片预览样式已提取到 styles/components.css 全局(ImageInput.vue 共享) */
|
||||
|
||||
.ai-input-wrap:focus-within {
|
||||
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
border-color: var(--df-info);
|
||||
}
|
||||
|
||||
/* ── 技能 chip / 联想浮层样式已提取到 styles/components.css 全局(SkillMention.vue + MentionPopover.vue 共享)── */
|
||||
|
||||
/* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */
|
||||
|
||||
/* textarea 透明无边框占满输入盒首行(workbench .inp-box textarea 范式);
|
||||
autosize 高度参数(min/height/max-height)为行为契约,禁动 */
|
||||
.ai-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
@@ -1301,6 +1395,19 @@ defineExpose({
|
||||
.ai-input::placeholder {
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
|
||||
/* 工具行(workbench .inp-row 范式):textarea 下方 --df-border 分隔线,左功能钮+右发送钮 */
|
||||
.ai-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
/* 发送钮(workbench .isend 范式):info→accent 135° 渐变+深色 icon+600 字重;
|
||||
禁用态 opacity .4(禁用判定逻辑不动)。原 --active 实心紫规则并入基类,类名绑定保留无害 */
|
||||
.ai-send-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1309,38 +1416,37 @@ defineExpose({
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius);
|
||||
background: var(--df-bg-raised);
|
||||
color: var(--df-text-dim);
|
||||
background: linear-gradient(135deg, var(--df-info), var(--df-accent));
|
||||
color: color-mix(in srgb, var(--df-bg) 70%, #000);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition: filter 0.15s, opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
.ai-send-btn:not(:disabled):hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
/* 功能钮统一 28px 高 bg-card 边框钮(workbench 工具行左侧范式),hover 边框亮 */
|
||||
.ai-img-pick-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
background: transparent;
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.ai-img-pick-btn:hover {
|
||||
background: var(--df-bg-raised);
|
||||
background: var(--df-bg-card-hover);
|
||||
border-color: var(--df-border-strong);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-send-btn--active {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.ai-send-btn--active:hover {
|
||||
background: var(--df-accent-hover);
|
||||
}
|
||||
.ai-send-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
@@ -1364,20 +1470,34 @@ defineExpose({
|
||||
.ai-send-btn--retry:hover {
|
||||
background: #d63a3f;
|
||||
}
|
||||
/* 命令提示 chip(workbench .icmd 范式):mono 10px --df-text-dim */
|
||||
.ai-input-hint {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
|
||||
|
||||
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
|
||||
.ai-img-warn-chip {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* ── 2026-10-18 wave2: 草稿/mention 弹层对齐 workbench 弹层范式 ──
|
||||
SkillMention/MentionPopover 共用 styles/components.css 的 .ai-skill-* 全局基类(禁碰),
|
||||
经 :deep 在此集中覆写一份(两子组件弹层同源,避免各自 scoped 重复):
|
||||
8px 圆角+浅阴影;选中项 bg-card-hover+左 2px 强调边(inset 阴影实现,零布局位移) */
|
||||
.ai-input-wrap :deep(.ai-skill-popover) {
|
||||
border-radius: var(--df-radius-md);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
}
|
||||
.ai-input-wrap :deep(.ai-skill-item--active) {
|
||||
background: var(--df-bg-card-hover);
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -104,16 +104,16 @@ watch(
|
||||
|
||||
<style scoped>
|
||||
.conflict-resolver {
|
||||
border: 1px solid var(--df-warning, #ffc107);
|
||||
border: 1px solid var(--df-warning);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 8px 0;
|
||||
background: var(--df-bg-secondary);
|
||||
background: var(--df-bg-card);
|
||||
}
|
||||
.conflict-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--df-warning, #ffc107);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.conflict-item {
|
||||
border-top: 1px solid var(--df-border);
|
||||
@@ -153,8 +153,8 @@ watch(
|
||||
padding: 2px 6px;
|
||||
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; }
|
||||
.badge--file { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.badge--semantic { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.conflict-file code { font-size: 12px; color: var(--df-text); }
|
||||
.conflict-diffs {
|
||||
display: grid;
|
||||
|
||||
@@ -125,8 +125,10 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* wave2(2026-10-18):旧 token 全量迁 --df-*;统一透明彩底卡范式,accent 系(脉络=系统功能信号)
|
||||
——边框 color-mix 30% + 底 6% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
|
||||
.ctx-briefs {
|
||||
border-bottom: 1px solid var(--border, #e0e0e0);
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -138,29 +140,36 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||
padding: 3px 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-secondary, #666);
|
||||
cursor: pointer;
|
||||
color: var(--df-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-briefs-count {
|
||||
background: var(--accent-soft, rgba(64, 128, 255, 0.15));
|
||||
color: var(--accent, #4080ff);
|
||||
border-radius: 8px;
|
||||
background: var(--df-accent-soft);
|
||||
color: var(--df-accent);
|
||||
border-radius: var(--df-radius-md);
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.ctx-briefs-body {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 4px;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ctx-briefs-empty {
|
||||
color: var(--text-tertiary, #999);
|
||||
color: var(--df-text-dim);
|
||||
padding: 6px 2px;
|
||||
}
|
||||
.ctx-briefs-project {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
color: var(--df-text);
|
||||
font-size: 12px;
|
||||
padding: 5px 2px 2px;
|
||||
}
|
||||
.ctx-briefs-focus-dot {
|
||||
@@ -169,12 +178,12 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||
height: 5px;
|
||||
margin-right: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent, #4080ff);
|
||||
background: var(--df-accent);
|
||||
vertical-align: 2px;
|
||||
}
|
||||
.ctx-briefs-item {
|
||||
padding: 4px 2px;
|
||||
border-top: 1px dashed var(--border, #eee);
|
||||
border-top: 0.5px dashed var(--df-border);
|
||||
}
|
||||
.ctx-briefs-item-head {
|
||||
display: flex;
|
||||
@@ -183,7 +192,7 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||
}
|
||||
.ctx-briefs-item-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #333);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ctx-briefs-item-link {
|
||||
padding: 0;
|
||||
@@ -194,29 +203,31 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-briefs-item-link:hover {
|
||||
color: var(--accent, #4080ff);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.ctx-briefs-item-time {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #999);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.ctx-briefs-item-summary {
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 12px;
|
||||
margin: 2px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ctx-briefs-bring {
|
||||
border: 1px solid var(--border, #ddd);
|
||||
background: none;
|
||||
color: var(--accent, #4080ff);
|
||||
border-radius: 4px;
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||
background: transparent;
|
||||
color: var(--df-accent);
|
||||
border-radius: var(--df-radius-sm);
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-briefs-bring:hover {
|
||||
background: var(--accent-soft, rgba(64, 128, 255, 0.1));
|
||||
background: var(--df-accent-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,6 +52,18 @@
|
||||
@keydown="onItemKeydown($event, conv.id, true)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 左侧 7px 状态点。字段源 store.isGenerating(conv_state
|
||||
generating/stopping/compressed) / pendingCountOf(审批挂起) / loop_state='parked',
|
||||
无 error 字段故不映射;优先级 generating > 待审批 > parked > 灰 -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
|
||||
双击入口对齐分组视图,菜单入口供键盘用户(P2-6) -->
|
||||
<input
|
||||
@@ -70,7 +82,6 @@
|
||||
@dblclick.stop="startRename(conv)"
|
||||
>
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
@@ -145,6 +156,16 @@
|
||||
@keydown="onItemKeydown($event, conv.id, true)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 左侧 7px 状态点(映射源同搜索态块注) -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<input
|
||||
v-if="editingConvId === conv.id"
|
||||
:ref="setEditEl"
|
||||
@@ -168,8 +189,6 @@
|
||||
class="ai-conv-item-title-badge"
|
||||
width="9" height="9" 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>
|
||||
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
@@ -225,6 +244,8 @@
|
||||
@keydown.space.prevent="onRecover(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2: 本组数据源 ai_list_recoverable(loop_state='parked' 完备真相),整组恒 parked→静态琥珀点 -->
|
||||
<span class="df-dot ai-conv-status ai-conv-status--paused" aria-hidden="true"></span>
|
||||
<span class="ai-conv-item-title">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span class="ai-conv-recover-badge">⏸</span>
|
||||
@@ -260,9 +281,18 @@
|
||||
@keydown="onItemKeydown($event, conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 归档项同款状态点(归档为静息态,默认落灰) -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="ai-conv-item-title" :title="conv.title || ''">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
@@ -296,6 +326,17 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- F4-1: 会话列表分页——底部「加载更多」续拉下一页(首屏 50/组,归档组同口径放开) -->
|
||||
<div
|
||||
v-if="convListPaging.hasMore"
|
||||
class="ai-conv-load-more"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onLoadMore"
|
||||
@keydown.enter.prevent="onLoadMore"
|
||||
@keydown.space.prevent="onLoadMore"
|
||||
>{{ convListPaging.loading ? t('common.loading') : t('gitChanges.loadMore') }}</div>
|
||||
|
||||
<div v-if="store.state.conversations.length === 0" class="ai-conv-empty">
|
||||
{{ t('aiChat.emptyConversation') }}
|
||||
</div>
|
||||
@@ -338,7 +379,8 @@ import { convPendingApprovals } from '../../composables/ai/aiShared'
|
||||
// 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制)
|
||||
import { getConvBoundary, fetchConvBoundary } from '../../composables/ai/useTaskBoundary'
|
||||
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
|
||||
import { convOpFailNotice } from '../../composables/ai/useAiConversations'
|
||||
// F4-1: 会话列表分页游标(reactive,绑「加载更多」显隐/loading)+ 续拉/拉满动作
|
||||
import { convOpFailNotice, convListPaging, loadMoreConversations, loadAllConversations } from '../../composables/ai/useAiConversations'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { formatRelative } from '../../utils/time'
|
||||
import type { AiConversationSummary } from '../../api/types'
|
||||
@@ -662,6 +704,28 @@ const searchResults = computed<AiConversationSummary[] | null>(
|
||||
() => store.filteredConversations.value,
|
||||
)
|
||||
|
||||
// ── F4-1: 会话列表分页(加载更多按钮 + 搜索全量兜底) ──
|
||||
|
||||
/** 「加载更多」:续拉下一页(失败/无新增静默,按钮 loading 态由 convListPaging 驱动) */
|
||||
function onLoadMore(): void {
|
||||
void loadMoreConversations()
|
||||
}
|
||||
|
||||
// 搜索输入即拉满:搜索过滤(stores.filteredConversations)跑在已加载列表上,未拉满时
|
||||
// 第 51 条起的会话搜不到(硬 50 截断=搜索失灵)——输入非空且还有未拉页时一次拉尽,
|
||||
// 之后搜索自然覆盖全量;已拉满(hasMore=false)零开销不触发。
|
||||
watch(() => store.state.searchQuery, (q) => {
|
||||
if (q.trim() && convListPaging.hasMore && !convListPaging.loading) {
|
||||
void loadAllConversations()
|
||||
}
|
||||
})
|
||||
|
||||
// 加载更多/搜索拉满追加的新会话补拉边界徽标镜像(A10 空洞随列表加深扩大;
|
||||
// hydrate 对已入镜像的会话跳过,常规刷新时零 IPC)
|
||||
watch(() => store.state.conversations.length, () => {
|
||||
void hydrateBoundaryBadges()
|
||||
})
|
||||
|
||||
// ── 侧栏宽度拖拽 ──
|
||||
const resizeState = { startX: 0, startWidth: 0 }
|
||||
function onResizeMove(e: MouseEvent) {
|
||||
@@ -756,7 +820,7 @@ defineExpose({
|
||||
transition: background 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-sidebar-resize-handle:hover {
|
||||
background: var(--df-accent, #4f8cff);
|
||||
background: var(--df-accent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* 拖拽中全局锁 cursor + 禁选中文本(挂在 body 上,跨元素生效) */
|
||||
@@ -778,13 +842,21 @@ body.ai-sidebar-resizing * {
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* UX-06 §3.1: 对话搜索框(紧贴 header 下,样式跟随周围输入控件) */
|
||||
/* UX-06 §3.1: 对话搜索框(wave2 u-agent .sb-search 范式:容器成框,卡底+focus 边框 info) */
|
||||
.ai-conv-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
margin: 8px 10px;
|
||||
padding: 0 8px;
|
||||
height: 30px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
transition: border-color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-conv-search:focus-within {
|
||||
border-color: var(--df-info);
|
||||
}
|
||||
.ai-conv-search-icon {
|
||||
flex: none;
|
||||
@@ -800,8 +872,8 @@ body.ai-sidebar-resizing * {
|
||||
font-size: 12px;
|
||||
color: var(--df-text);
|
||||
}
|
||||
/* 聚焦可见环:无边框输入聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||
.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
/* 聚焦可见环:容器已随 :focus-within 变 info 边框,键盘环同色补强(scoped 特异性须盖全局兜底) */
|
||||
.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-info); outline-offset: 1px; }
|
||||
.ai-conv-search-input::placeholder {
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
@@ -819,12 +891,15 @@ body.ai-sidebar-resizing * {
|
||||
.ai-conv-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
padding: 4px 6px; /* u-agent .sb-list 范式;滚动条走 global ::-webkit-scrollbar token 体系 */
|
||||
}
|
||||
/* wave2 u-agent 范式: 分组头 10px uppercase + letter-spacing 1px + dim */
|
||||
.ai-conv-group-title {
|
||||
padding: 8px 8px 3px;
|
||||
font-size: 11px;
|
||||
padding: 10px 8px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--df-text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
@@ -851,7 +926,10 @@ body.ai-sidebar-resizing * {
|
||||
.ai-conv-group-count {
|
||||
margin-left: 2px;
|
||||
font-weight: 400;
|
||||
opacity: 0.7;
|
||||
font-family: var(--df-font-mono); /* mono 计数(df-mono 范式),upper/lsp 回退保证数字不散 */
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ai-conv-group-empty {
|
||||
padding: 2px 8px 4px 20px;
|
||||
@@ -859,40 +937,47 @@ body.ai-sidebar-resizing * {
|
||||
color: var(--df-text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
/* wave2 u-agent .cv 范式: 单行条目,8px 级密度;hover/active 同走灰阶卡底(GitHub 两级同底、
|
||||
active 由左 2px accent 实线区分),深浅主题同 token 自动适配 */
|
||||
.ai-conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
padding: 8px;
|
||||
border-radius: var(--df-radius);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-conv-item:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
.ai-conv-item--active {
|
||||
background: var(--df-sidebar-active);
|
||||
background: var(--df-bg-card-hover);
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
|
||||
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
|
||||
.ai-conv-item--switching {
|
||||
background: var(--df-sidebar-active);
|
||||
background: var(--df-bg-card-hover);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ai-conv-item--archived .ai-conv-item-title {
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* wave2: 行内布局对齐 .cv-top([状态点][标题省略][时间]) */
|
||||
.ai-conv-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-conv-item-title {
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
white-space: nowrap;
|
||||
@@ -902,7 +987,6 @@ body.ai-sidebar-resizing * {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* UX-2025-20 §7.4: AI 生成标题淡入(0.3s opacity + transform 过渡) */
|
||||
.ai-conv-item-title--flash {
|
||||
@@ -917,34 +1001,35 @@ body.ai-sidebar-resizing * {
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.ai-conv-item-edit {
|
||||
flex: 1 1 0; /* wave2: main 已行化,输入框按 flex 填充(min-width:0 防 input 固有宽撑破窄侧栏) */
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
font-size: 11px;
|
||||
color: var(--df-text);
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--df-accent);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg, #fff);
|
||||
background: var(--df-bg);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||
.ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
/* wave2 u-agent 范式: 时间/计数元数据 mono 10px dim(.df-mono 同款视觉) */
|
||||
.ai-conv-item-time {
|
||||
font-size: 9px;
|
||||
flex: none;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* 多会话并发生成指示器(会话项标题行内脉冲点,多会话可同时显示) */
|
||||
.ai-conv-gen-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
animation: ai-conv-gen-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
/* wave2 u-agent .st 状态点变体: 公共 .df-dot/.df-dot--run/.df-dot--stop 管造型与绿/灰底,
|
||||
此处只补动效与缺位色(全 token): run 绿+glow+脉冲(global df-pulse)、danger 待审批红、paused 停靠琥珀 */
|
||||
.ai-conv-status--run { box-shadow: 0 0 6px var(--df-success); animation: df-pulse 1.5s infinite; }
|
||||
.ai-conv-status--danger { background: var(--df-danger); }
|
||||
.ai-conv-status--paused { background: var(--df-warning); }
|
||||
@keyframes ai-conv-gen-pulse {
|
||||
/* 供 .ai-conv-recover-dot 分组级脉冲用(原行内 gen-dot 已由状态点接管) */
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
@@ -955,7 +1040,7 @@ body.ai-sidebar-resizing * {
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
background: var(--df-warning, #f0c75e);
|
||||
background: var(--df-warning);
|
||||
animation: ai-conv-gen-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.ai-conv-item--recoverable {
|
||||
@@ -966,7 +1051,7 @@ body.ai-sidebar-resizing * {
|
||||
margin-left: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--df-warning, #f0c75e);
|
||||
color: var(--df-warning);
|
||||
cursor: help;
|
||||
}
|
||||
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
|
||||
@@ -975,7 +1060,7 @@ body.ai-sidebar-resizing * {
|
||||
height: 12px;
|
||||
padding: 0 3px;
|
||||
border-radius: 6px;
|
||||
background: var(--df-warning, #f0c75e);
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
@@ -1032,9 +1117,13 @@ body.ai-sidebar-resizing * {
|
||||
color: var(--df-text);
|
||||
}
|
||||
/* 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。
|
||||
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。 */
|
||||
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。
|
||||
wave2: 选中态同占左缘,置顶线降为 45% 透明 accent 让位,选中实线优先 */
|
||||
.ai-conv-item--pinned {
|
||||
box-shadow: inset 2px 0 0 var(--df-accent, var(--df-text));
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--df-accent) 45%, transparent);
|
||||
}
|
||||
.ai-conv-item--active.ai-conv-item--pinned {
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
/* ── 「更多」操作菜单(置顶/归档/删除)──
|
||||
「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。
|
||||
@@ -1056,8 +1145,8 @@ body.ai-sidebar-resizing * {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: var(--df-bg, #fff);
|
||||
border: 1px solid var(--df-border, #e2e2e2);
|
||||
background: var(--df-bg);
|
||||
border: 1px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
@@ -1088,6 +1177,21 @@ body.ai-sidebar-resizing * {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* F4-1: 列表底部「加载更多」(轻量文字行,hover 同列表项灰阶卡底范式) */
|
||||
.ai-conv-load-more {
|
||||
margin: 4px 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.ai-conv-load-more:hover {
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* UX走查 P2-7: 会话操作失败就近 toast(仅 error 一型)。视觉对齐 AiChat .ai-toast,
|
||||
但 AiChat 版是 scoped 样式渗透不进来,故自持一份;侧栏窄,max-width 收 85% */
|
||||
@@ -1102,7 +1206,7 @@ body.ai-sidebar-resizing * {
|
||||
z-index: 30; /* 高于操作菜单(20),菜单展开时失败反馈不被盖 */
|
||||
max-width: 85%;
|
||||
overflow-wrap: anywhere;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
border: 0.5px solid var(--df-danger);
|
||||
|
||||
@@ -154,12 +154,12 @@ watch(() => visibleDirAuths.value.map(d => d.id).join('|'), async () => {
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-dir-auth-text {
|
||||
font-size: 11.5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-dir-auth-hint {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
word-break: break-all;
|
||||
|
||||
@@ -34,7 +34,7 @@ const examplePrompts = [
|
||||
<div class="ai-empty">
|
||||
<template v-if="!hasProvider">
|
||||
<div class="ai-empty-icon">
|
||||
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
</div>
|
||||
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
|
||||
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
|
||||
@@ -46,7 +46,7 @@ const examplePrompts = [
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="ai-empty-icon">
|
||||
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||
</div>
|
||||
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
|
||||
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
|
||||
@@ -73,7 +73,8 @@ const examplePrompts = [
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.ai-empty-icon { margin-bottom: 4px; opacity: 0.4; }
|
||||
/* wave2:空态图标统一 dim 色(经 currentColor 接管 stroke),引导文字 secondary 层级 */
|
||||
.ai-empty-icon { margin-bottom: 4px; opacity: 0.4; color: var(--df-text-dim); }
|
||||
.ai-empty-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -81,7 +82,7 @@ const examplePrompts = [
|
||||
}
|
||||
.ai-empty-sub {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
color: var(--df-text-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -117,7 +118,7 @@ const examplePrompts = [
|
||||
border-radius: var(--df-radius);
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 11.5px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -74,30 +74,30 @@ function handleSelectOption(opt: string): void {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 复用 MaxRoundsCard 样式基调(warning 色系,求助属"需用户介入"信号,视觉一致) */
|
||||
/* wave2(2026-10-18):统一透明彩底卡范式,danger 系(断路器熔断=最高警示级)——
|
||||
边框 color-mix 30% + 底 8% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
|
||||
.ai-help-required {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-bottom: 6px;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--df-danger) 8%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-help-required-text {
|
||||
font-size: 11.5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-help-required-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
word-break: break-all;
|
||||
/* M7:context 含错误堆栈,长栈撑高卡片。等宽字体利堆栈行对齐;max-height + overflow-y:auto
|
||||
短内容自动不显滚动条,长堆栈滚动扫看(比折叠实用,无需展开按钮)。 */
|
||||
font-family: var(--df-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
font-family: var(--df-font-mono);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -109,15 +109,15 @@ function handleSelectOption(opt: string): void {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ai-help-required-btn {
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||
color: var(--df-danger);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
transition: filter 0.15s var(--df-ease);
|
||||
transition: background 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-help-required-btn:hover { filter: brightness(1.1); }
|
||||
.ai-help-required-btn:hover { background: color-mix(in srgb, var(--df-danger) 16%, transparent); }
|
||||
</style>
|
||||
|
||||
@@ -84,7 +84,7 @@ const emit = defineEmits<{
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 类型标签小色块(项目/任务区分) */
|
||||
/* 类型标签小色块(项目/任务区分)。2026-10-18: 原底色引用未定义 token 静默失效,修正为 bg-card-hover */
|
||||
.ai-mention-item-type {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 5px;
|
||||
@@ -92,7 +92,7 @@ const emit = defineEmits<{
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
background: var(--df-bg-hover);
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.ai-mention-item-type--project {
|
||||
|
||||
@@ -149,29 +149,34 @@ const imageParts = computed(() => msgImageParts(props.msg))
|
||||
align-items: flex-start;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
/* 头像通用 */
|
||||
/* 头像通用 — workbench .av 范式:24px 圆角方块渐变,用户 warning→danger 系,
|
||||
字色随主题底色反转保证渐变上对比;内部字符 9px 700(当前为 svg 图标,字号字重为字符兜底) */
|
||||
.ai-msg-avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--df-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ai-msg-avatar--user {
|
||||
background: var(--df-info-bg, rgba(100,181,246,0.15));
|
||||
color: var(--df-info, #64b5f6);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--df-warning) 70%, var(--df-danger)), var(--df-danger));
|
||||
color: var(--df-bg);
|
||||
}
|
||||
/* 气泡通用 */
|
||||
/* 气泡通用 — workbench 聊天范式:用户气泡=bg-card 底+0.5px 边框+9px 圆角。
|
||||
9px 圆角与 9px 13px 内距均为 u-agent workbench 移植值,勿规整 */
|
||||
.ai-msg-bubble {
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 9px;
|
||||
padding: 9px 13px;
|
||||
word-break: break-word;
|
||||
max-width: 80%;
|
||||
}
|
||||
.ai-msg-bubble--user {
|
||||
background: var(--df-info-bg, rgba(100,181,246,0.1));
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
color: var(--df-text);
|
||||
}
|
||||
/* chip */
|
||||
@@ -181,13 +186,13 @@ const imageParts = computed(() => msgImageParts(props.msg))
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
margin: 0 2px;
|
||||
background: var(--df-accent-bg, rgba(108,99,255,0.1));
|
||||
color: var(--df-accent, #6c63ff);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
|
||||
.ai-msg-chip--skill {
|
||||
background: var(--df-accent-bg, rgba(108,99,255,0.18));
|
||||
color: var(--df-accent, #6c63ff);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 图片 */
|
||||
|
||||
+144
-165
@@ -45,7 +45,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// ── 消息任务关联(有的消息有,有的没):从 toolCalls[].args 派生 ──
|
||||
// 数据源:AiToolCallInfo.args 已携带工具入参,任务工具的 task_id 就在其中
|
||||
@@ -676,23 +676,28 @@ const _decoCache = new Map<string, string>()
|
||||
const DECO_CACHE_LIMIT = 800
|
||||
function decorateCodeHtml(raw: string): string {
|
||||
if (!raw || !raw.includes('<pre><code')) return raw
|
||||
const hit = _decoCache.get(raw)
|
||||
// F1-1:复制按钮文案走 i18n(v-html 渲染层拿不到 $t,经参数注入);locale 入缓存键,
|
||||
// 否则切语言后命中旧缓存,按钮文案停留在旧语言。locale 读取同时建立响应式依赖,
|
||||
// 切语言时调用方(computed/渲染函数)重算拿到新语言装饰结果。
|
||||
const cacheKey = locale.value + '\u0000' + raw
|
||||
const hit = _decoCache.get(cacheKey)
|
||||
if (hit !== undefined) {
|
||||
// LRU:命中即置为最近使用(delete+set 移到 Map 末尾),淘汰时优先保留热项
|
||||
_decoCache.delete(raw)
|
||||
_decoCache.set(raw, hit)
|
||||
_decoCache.delete(cacheKey)
|
||||
_decoCache.set(cacheKey, hit)
|
||||
return hit
|
||||
}
|
||||
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
|
||||
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界。
|
||||
// R4 语言角标:code class 形如 hljs language-ts,捕获 language-xxx 输出到 .md-code-block
|
||||
// 的 data-lang,CSS ::after 显示;无语言(裸块/auto 猜测无 language- 类)不显角标。
|
||||
const copyLabel = t('aiChat.copyCode')
|
||||
const out = raw.replace(
|
||||
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
||||
(_m: string, attrs: string, body: string) => {
|
||||
const lang = /language-([a-z0-9+#.-]+)/i.exec(attrs)?.[1] ?? ''
|
||||
const langAttr = lang ? ` data-lang="${lang}"` : ''
|
||||
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">复制</button><pre><code${attrs}>${body}</code></pre></div>`
|
||||
return `<div class="md-code-block"${langAttr}><button type="button" class="md-code-copy">${copyLabel}</button><pre><code${attrs}>${body}</code></pre></div>`
|
||||
},
|
||||
)
|
||||
// 有界 LRU 淘汰:超限删最久未用项(Map 首项)后插入,替代原整体 clear
|
||||
@@ -700,7 +705,7 @@ function decorateCodeHtml(raw: string): string {
|
||||
const oldest = _decoCache.keys().next().value
|
||||
if (oldest !== undefined) _decoCache.delete(oldest)
|
||||
}
|
||||
_decoCache.set(raw, out)
|
||||
_decoCache.set(cacheKey, out)
|
||||
return out
|
||||
}
|
||||
/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
|
||||
@@ -744,7 +749,7 @@ async function onMdCodeCopyClick(e: MouseEvent): Promise<void> {
|
||||
}
|
||||
// 成功态短暂反馈(直接改按钮 DOM;下次 v-html 重渲染自然还原,无需状态机)
|
||||
const prev = btn.textContent
|
||||
btn.textContent = ok ? '已复制' : '复制失败'
|
||||
btn.textContent = ok ? t('aiChat.copied') : t('aiChat.copyFailed')
|
||||
btn.classList.add(ok ? 'is-ok' : 'is-err')
|
||||
setTimeout(() => {
|
||||
btn.textContent = prev
|
||||
@@ -924,13 +929,12 @@ function collapsedPreviewHtml(msg: AiMessage): string {
|
||||
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
|
||||
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
|
||||
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
|
||||
// 文案硬编码中文:i18n locale 文件本批锁定(他代理并行追加,不新增键),
|
||||
// 展开态仍复用既有 aiChat.collapse 键。
|
||||
// 文案走 i18n(F1-1 补键);展开态仍复用既有 aiChat.collapse 键。
|
||||
const CHAR_PER_TOKEN_EST = 2.5
|
||||
function collapsedLongLabel(msg: AiMessage): string {
|
||||
const folded = Math.max(0, (msg.content?.length ?? 0) - LONG_MSG_THRESHOLD)
|
||||
const tokens = Math.max(1, Math.round(folded / CHAR_PER_TOKEN_EST))
|
||||
return `已折叠 ${folded} 字符 / 约 ${tokens} tokens,点击展开`
|
||||
return t('aiChat.foldedSummary', { chars: folded, tokens })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1226,7 +1230,13 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
bucket = []
|
||||
if (msgs.length === 0) return
|
||||
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
|
||||
const executing = isViewingGenerating.value && msgs[msgs.length - 1].id === lastStoreId
|
||||
// F1-3(10-17 走查):执行中判定补充「桶内存在 running/pending_approval 工具」。
|
||||
// 仅靠「桶末=store 末条」时,生成中注入补充消息会在 store 末尾 push 本地 user 气泡,
|
||||
// 判定瞬间失配 → 执行桶被误判为完成态,降级成默认折叠 trace 卡,正在跑/待审批的
|
||||
// 工具卡跌进折叠体不可见(下一轮 assistant push 才自愈)。桶内工具仍活跃即说明该轮
|
||||
// 未收敛,保持 step 形态内联可见;工具全部收敛后回到末条比对,历史桶不受影响(BUG-261001)。
|
||||
const hasLiveTool = msgs.some(m => (m.toolCalls ?? []).some(tc => tc.status === 'running' || tc.status === 'pending_approval'))
|
||||
const executing = isViewingGenerating.value && (msgs[msgs.length - 1].id === lastStoreId || hasLiveTool)
|
||||
// 961③: 轮号=当前发起计数(与轨迹卡是否产出解耦,单消息桶不吞号)
|
||||
const round = currentRound
|
||||
const built = buildBucket(msgs, round, executing, lastTs)
|
||||
@@ -1566,7 +1576,7 @@ watch(() => store.state.activeConversationId, (newId, oldId) => {
|
||||
onBeforeUnmount(() => {
|
||||
cancelPendingRaf()
|
||||
document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
|
||||
document.removeEventListener('keydown', closeAgentTraceOnEsc)
|
||||
document.removeEventListener('keydown', closeOverlaysOnEsc)
|
||||
})
|
||||
|
||||
// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部)
|
||||
@@ -1575,11 +1585,14 @@ function closeTokenPopoverOnOutsideClick(): void {
|
||||
}
|
||||
document.addEventListener('click', closeTokenPopoverOnOutsideClick)
|
||||
|
||||
// 子代理轨迹浮层 Esc 关闭(与遮罩点击/X 同为关闭出口;仅浮层开着时消费,不干扰其他快捷键)
|
||||
function closeAgentTraceOnEsc(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && agentPeekVisible.value) closeAgentTrace()
|
||||
// 浮层 Esc 关闭:子代理轨迹浮层 + token 详情 popover(F6-3:后者原先仅外点关闭,
|
||||
// 键盘用户开了面板只能点别处)。两者同时开着时逐层关(先浮层后 popover),Esc 不冒泡旁路。
|
||||
function closeOverlaysOnEsc(e: KeyboardEvent): void {
|
||||
if (e.key !== 'Escape') return
|
||||
if (agentPeekVisible.value) closeAgentTrace()
|
||||
else if (tokenPopoverMsgId.value !== null) tokenPopoverMsgId.value = null
|
||||
}
|
||||
document.addEventListener('keydown', closeAgentTraceOnEsc)
|
||||
document.addEventListener('keydown', closeOverlaysOnEsc)
|
||||
|
||||
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
|
||||
// 多次调用安全,确保子组件挂载即预热)。
|
||||
@@ -2141,7 +2154,7 @@ defineExpose({
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
z-index: 15;
|
||||
transition: color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
|
||||
}
|
||||
@@ -2161,7 +2174,7 @@ defineExpose({
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-warning, #e8a33d);
|
||||
background: var(--df-warning);
|
||||
animation: ai-btb-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ai-btb-pulse {
|
||||
@@ -2172,14 +2185,8 @@ defineExpose({
|
||||
/* -- Empty State 样式已随 EmptyState.vue 提取 -- */
|
||||
|
||||
/* -- Message Layout -- */
|
||||
.ai-msg-user {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
flex-direction: row-reverse; /* 头像在右:DOM 仍 avatar 在先,视觉反转成【气泡】【头像】靠右 */
|
||||
margin-top: -3px; /* user 与上方消息间距略收(全局 gap 14 → 视觉 11),更紧凑 */
|
||||
}
|
||||
/* 用户消息行 .ai-msg-user 随 MessageItem.vue 抽离(自带 scoped,本组件 scoped 不渗透其内部),
|
||||
原重复定义删除(2026-10-18 整洁波,死样式验证:模板零引用+scoped 隔离永不命中) */
|
||||
.ai-msg-ai {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -2197,7 +2204,8 @@ defineExpose({
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* -- Avatar -- */
|
||||
/* -- Avatar -- workbench .av 范式:24px 圆角方块渐变(用户 warning→danger 系 / AI info→accent 系),
|
||||
字色随主题底色反转保证渐变上对比;内部字符 9px 700(当前为 svg 图标,字号字重为字符兜底) */
|
||||
.ai-msg-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -2206,41 +2214,44 @@ defineExpose({
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ai-msg-avatar--user {
|
||||
background: var(--df-info-bg);
|
||||
color: var(--df-info);
|
||||
}
|
||||
/* .ai-msg-avatar--user 随 MessageItem.vue 抽离(自带 scoped) */
|
||||
.ai-msg-avatar--ai {
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--df-info) 70%, var(--df-accent)), var(--df-accent));
|
||||
color: var(--df-bg);
|
||||
}
|
||||
|
||||
/* -- Bubble -- */
|
||||
/* -- Bubble -- workbench 聊天范式:用户=bg-card 底+0.5px 边框+9px 圆角;AI=无气泡(透明底直接排版)。
|
||||
9px 圆角与 9px 13px 内距均为 u-agent workbench 移植值,勿规整 */
|
||||
.ai-msg-bubble {
|
||||
position: relative;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-width: var(--df-msg-max-width);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 9px 13px;
|
||||
border-radius: 9px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ai-msg-bubble--user {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: var(--df-radius-sm);
|
||||
}
|
||||
/* 用户气泡 .ai-msg-bubble--user 随 MessageItem.vue 抽离(自带 scoped,原「同范式兜底」
|
||||
实为 scoped 隔离永不命中的死规则,2026-10-18 整洁波删除) */
|
||||
.ai-msg-bubble--ai {
|
||||
background: var(--df-bg-card);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--df-text);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-bottom-left-radius: var(--df-radius-sm);
|
||||
}
|
||||
/* 错误气泡对齐 workbench .blocked-card 范式:30% 红边框+6% 红底+12px 字;
|
||||
AI 无气泡后本卡自含 padding/边框/圆角(排在 --ai 后覆盖) */
|
||||
.ai-msg-bubble--error {
|
||||
background: var(--df-danger-bg);
|
||||
padding: 10px 13px;
|
||||
border: 1px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||
border-radius: 9px;
|
||||
background: color-mix(in srgb, var(--df-danger) 6%, transparent);
|
||||
color: var(--df-danger);
|
||||
border-color: rgba(240,101,101,0.3);
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
/* 修正 的 display:block 方案——display:block 让 table 失去表格
|
||||
布局上下文,tr/td 脱离 table 上下文塌成块级堆叠丢列对齐(用户截图反馈测试报告四列表格错乱)。
|
||||
@@ -2255,8 +2266,8 @@ defineExpose({
|
||||
overflow-x: auto;
|
||||
}
|
||||
/* M1:超长 assistant 文本折叠——max-height 截断 + 底部渐变遮罩 + 展开按钮。
|
||||
背景 var(--df-bg-card) 对齐 .ai-msg-bubble--ai(L876):渐变淡出到气泡背景,
|
||||
视觉上内容「沉入」气泡而非被硬切。overflow:hidden 覆盖 .ai-md 的 overflow-x:auto。 */
|
||||
AI 气泡已透明(workbench 范式):渐变淡出到页面背景 --df-bg,视觉上内容「沉入」页面
|
||||
而非被硬切。overflow:hidden 覆盖 .ai-md 的 overflow-x:auto。 */
|
||||
.ai-msg-bubble--collapsed {
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
@@ -2269,7 +2280,7 @@ defineExpose({
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(transparent, var(--df-bg-card));
|
||||
background: linear-gradient(transparent, var(--df-bg));
|
||||
pointer-events: none; /* 让点击穿透到下方展开按钮 */
|
||||
}
|
||||
/* 展开按钮:气泡底部居中,置于渐变遮罩之上 */
|
||||
@@ -2293,10 +2304,12 @@ defineExpose({
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
|
||||
/* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:灰 #b0b0b0 / 11px /
|
||||
/* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:弱化灰字 / 11px /
|
||||
等宽 / max-height 80px 裁剪):assistant content 为纯 JSON 工具结果时弱化为折叠块,
|
||||
不渲染 markdown 大气泡刷屏。默认 max-height 80px + overflow-y:auto 可滚动,
|
||||
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。 ── */
|
||||
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。
|
||||
F1-2:灰字由硬编码 #b0b0b0 改 --df-text-dim——硬编码值在浅色主题白底上
|
||||
对比度约 2.6:1 不可读;token 双主题各有值,弱化语义不变。 ── */
|
||||
.ai-msg-json {
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
@@ -2307,7 +2320,7 @@ defineExpose({
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #b0b0b0;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -2325,27 +2338,8 @@ defineExpose({
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ── Input Augmentation: 用户气泡内 mention chip(圆角徽标,复用 ChatInput .ai-skill-chip-name 风格) ──
|
||||
用户气泡背景 = --df-accent(主题强调色,如深色蓝/紫),chip 用半透明白底+深字 保证对比度;
|
||||
按 kind 区分颜色变体(project/task/idea/skill),与 @ mention 浮层 .ai-mention-item-type--* 视觉呼应。 */
|
||||
.ai-msg-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
margin: 0 1px;
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* kind 颜色变体(用更亮的底色区分,但保持白字可读) */
|
||||
.ai-msg-chip--project { background: rgba(255, 255, 255, 0.32); }
|
||||
.ai-msg-chip--task { background: rgba(255, 255, 255, 0.26); }
|
||||
.ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); }
|
||||
.ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); }
|
||||
/* ── mention chip(.ai-msg-chip 族)随 MessageItem.vue 抽离(自带 scoped,含 --skill 变体;
|
||||
此处原 kind 变体因 scoped 隔离永不命中,2026-10-18 整洁波删除) ── */
|
||||
|
||||
/* ── UX 密度改造:居中时间分隔线(相邻消息间隔 ≥30 分钟或跨天;
|
||||
per-block 相对时间漂移无价值已移除,精确时刻在消息容器 title) ── */
|
||||
@@ -2368,10 +2362,13 @@ defineExpose({
|
||||
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
|
||||
}
|
||||
|
||||
/* ── 最终回答卡:左 2px 品牌色强调边条 + 背景微抬升(与轨迹内思考 pill 明确分层) ── */
|
||||
/* ── 最终回答卡:左 2px 品牌色强调边条 + 背景微抬升(与轨迹内思考 pill 明确分层);
|
||||
AI 无气泡后本卡自含 padding 保持卡感(普通 AI 消息仍无气泡) ── */
|
||||
.ai-msg-bubble--answer {
|
||||
border-left: 2px solid var(--df-accent);
|
||||
background: color-mix(in srgb, var(--df-accent) 4%, var(--df-bg-card));
|
||||
border-radius: var(--df-radius);
|
||||
background: color-mix(in srgb, var(--df-accent) 4%, transparent);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* ── 轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转) ── */
|
||||
@@ -2415,8 +2412,8 @@ defineExpose({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: var(--df-h-icon-btn);
|
||||
height: var(--df-h-icon-btn);
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
@@ -2426,14 +2423,13 @@ defineExpose({
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-msg-user:hover .ai-copy-btn,
|
||||
.ai-msg-ai:hover .ai-copy-btn--ai {
|
||||
opacity: 1;
|
||||
}
|
||||
.ai-copy-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
/* 图片复制按钮排在文本复制按钮左侧(right 让位 22px 按钮 + 4px 间距) */
|
||||
/* 图片复制按钮排在文本复制按钮左侧(right 让位 26px 按钮 + 4px 间距) */
|
||||
.ai-copy-btn--image {
|
||||
right: 30px;
|
||||
}
|
||||
@@ -2443,6 +2439,23 @@ defineExpose({
|
||||
.ai-md :deep(.md-code-block) {
|
||||
position: relative;
|
||||
}
|
||||
/* workbench .code 范式:比 --df-bg 更深一档的底 + 边框 + md 圆角 + mono 11px,
|
||||
覆盖全局 ai-md.css 的 .ai-md pre(现有 hljs github-dark 语法高亮色不动)。
|
||||
浅色主题降为 8% 黑:45% 全量在浅底上会压垮正文/高亮对比,违背双主题成立铁律。 */
|
||||
.ai-md :deep(pre) {
|
||||
background: color-mix(in srgb, var(--df-bg), #000 45%);
|
||||
border: 1px solid var(--df-border);
|
||||
border-radius: var(--df-radius-md);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-md :deep(pre code) {
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
[data-theme='light'] .ai-md :deep(pre) {
|
||||
background: color-mix(in srgb, var(--df-bg), #000 8%);
|
||||
}
|
||||
.ai-md :deep(.md-code-copy) {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -2468,8 +2481,8 @@ defineExpose({
|
||||
}
|
||||
.ai-md :deep(.md-code-copy.is-ok) {
|
||||
opacity: 1;
|
||||
color: var(--df-success, #4caf50);
|
||||
border-color: var(--df-success, #4caf50);
|
||||
color: var(--df-success);
|
||||
border-color: var(--df-success);
|
||||
}
|
||||
.ai-md :deep(.md-code-copy.is-err) {
|
||||
opacity: 1;
|
||||
@@ -2519,7 +2532,7 @@ defineExpose({
|
||||
/* Fatal 错误(不可重试)的提示文案——替代隐藏的「重试」按钮,
|
||||
说明为何无重试入口,引导走「去设置」。 */
|
||||
.ai-msg-not-retryable {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
font-style: italic;
|
||||
align-self: center;
|
||||
@@ -2528,12 +2541,12 @@ defineExpose({
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 7px;
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
@@ -2565,11 +2578,11 @@ defineExpose({
|
||||
margin-top: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: color-mix(in srgb, var(--df-warning, #ffc107) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
||||
width: fit-content;
|
||||
}
|
||||
.ai-msg-truncated-text {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
@@ -2600,7 +2613,7 @@ defineExpose({
|
||||
padding-top: 6px;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -2618,8 +2631,9 @@ defineExpose({
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
/* workbench .round-meta 范式:元数据 hover 变 --df-info */
|
||||
.ai-token-usage-trigger:hover {
|
||||
color: var(--df-text);
|
||||
color: var(--df-info);
|
||||
opacity: 1;
|
||||
}
|
||||
.ai-token-sep {
|
||||
@@ -2627,7 +2641,7 @@ defineExpose({
|
||||
}
|
||||
/* G4.3:估算值标注(估算非真实,弱化 dim + warning 色调,不喧宾夺主) */
|
||||
.ai-token-est {
|
||||
color: var(--df-warning, #f0c75e);
|
||||
color: var(--df-warning);
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */
|
||||
@@ -2638,7 +2652,7 @@ defineExpose({
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
min-width: 200px;
|
||||
background: var(--df-bg-card, var(--df-bg));
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
@@ -2678,23 +2692,7 @@ defineExpose({
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 用户气泡内的图片(多模态消息渲染) */
|
||||
.ai-msg-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.ai-msg-image {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: var(--df-radius);
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: var(--df-bg);
|
||||
}
|
||||
/* 用户气泡内图片(.ai-msg-images/.ai-msg-image)随 MessageItem.vue 抽离(自带 scoped) */
|
||||
|
||||
/* ═══ 压缩摘要 system 块(compressed 原文已展开,摘要置顶提示发给 AI 的是它) ═══
|
||||
用户诉求:压缩只后端省 token,界面不藏消息。原文 compressed 照常 user/ai 气泡展开,
|
||||
@@ -2773,7 +2771,7 @@ defineExpose({
|
||||
.ai-trace-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
@@ -2806,6 +2804,11 @@ defineExpose({
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* workbench .round-meta 范式:轮次/时间/token 小元数据 hover 变 --df-info(折叠交互不动) */
|
||||
.ai-trace-head:hover .ai-trace-agg,
|
||||
.ai-trace-head:hover .ai-trace-tokens {
|
||||
color: var(--df-info);
|
||||
}
|
||||
/* 任务关联 chip(仅含任务工具调用的轮次渲染):accent 弱化底 + hover 提亮,点击跳任务 */
|
||||
.ai-trace-task-chip {
|
||||
flex-shrink: 0;
|
||||
@@ -2826,7 +2829,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
/* ── 轨迹展开态 rail 面板:左轨竖线贯通,思考 pill 与工具卡图标锚定其上(git-graph 式轮次结构)。
|
||||
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。 ── */
|
||||
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。
|
||||
workbench .tr 范式:1px 连线用 --df-border。 ── */
|
||||
.ai-trace-body {
|
||||
position: relative;
|
||||
padding: 2px 10px 8px 0;
|
||||
@@ -2838,7 +2842,7 @@ defineExpose({
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, var(--df-text-dim) 22%, transparent);
|
||||
background: var(--df-border);
|
||||
}
|
||||
.ai-trace-round {
|
||||
position: relative;
|
||||
@@ -2852,10 +2856,10 @@ defineExpose({
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
padding: 3px 8px 3px 38px;
|
||||
padding: 3px 8px 3px var(--df-icon-gutter);
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
@@ -2872,16 +2876,16 @@ defineExpose({
|
||||
}
|
||||
.ai-think-ic {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--df-accent-bg);
|
||||
background: color-mix(in srgb, var(--df-accent) 15%, transparent);
|
||||
color: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -2896,14 +2900,14 @@ defineExpose({
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.75;
|
||||
margin-left: auto;
|
||||
}
|
||||
/* 展开的完整思考段落:保持既有卡片样式(气泡卡同底),挂在 pill 下方缩进对齐正文 */
|
||||
.ai-think-full {
|
||||
margin: 2px 0 6px 38px;
|
||||
margin: 2px 0 6px var(--df-icon-gutter);
|
||||
padding: 8px 12px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
@@ -2916,30 +2920,14 @@ defineExpose({
|
||||
}
|
||||
|
||||
/* 执行中态轨迹卡(骨架与完成态完全同构,仅色/态微差):accent 底 + 脉冲点替代 chevron,
|
||||
step→trace 切换时卡片高度/内边距一致,只 patch 内部文字 → 无跳变 */
|
||||
step→trace 切换时卡片高度/内边距一致,只 patch 内部文字 → 无跳变。
|
||||
(.ai-trace-toggle--running/.ai-step-line/.ai-step-text 旧单行摘要骨架已废:step 卡
|
||||
改用 trace-card 同构骨架,模板零引用,2026-10-18 整洁波删除) */
|
||||
.ai-trace-card--running {
|
||||
border-style: solid;
|
||||
border-color: color-mix(in srgb, var(--df-accent) 28%, transparent);
|
||||
background: color-mix(in srgb, var(--df-accent) 7%, transparent);
|
||||
}
|
||||
.ai-trace-toggle--running {
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
|
||||
.ai-step-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: var(--df-msg-max-width);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--df-radius);
|
||||
background: color-mix(in srgb, var(--df-accent) 7%, transparent);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
.ai-step-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -2948,13 +2936,6 @@ defineExpose({
|
||||
flex-shrink: 0;
|
||||
animation: ai-step-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.ai-step-text {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@keyframes ai-step-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
@@ -2969,7 +2950,7 @@ defineExpose({
|
||||
max-width: var(--df-msg-max-width);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--df-radius);
|
||||
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
|
||||
background: color-mix(in srgb, var(--df-warning) 8%, transparent);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
user-select: none;
|
||||
@@ -2978,13 +2959,13 @@ defineExpose({
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-warning, #f0c75e);
|
||||
background: var(--df-warning);
|
||||
flex-shrink: 0;
|
||||
animation: ai-step-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.ai-retry-text {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -3018,10 +2999,10 @@ defineExpose({
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-fin--warn {
|
||||
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
|
||||
background: color-mix(in srgb, var(--df-warning) 8%, transparent);
|
||||
}
|
||||
.ai-agent-fin--warn .ai-agent-fin-dot {
|
||||
background: var(--df-warning, #f0c75e);
|
||||
background: var(--df-warning);
|
||||
}
|
||||
.ai-agent-fin--err {
|
||||
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||
@@ -3044,7 +3025,7 @@ defineExpose({
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-fin--warn .ai-agent-fin-status {
|
||||
color: var(--df-warning, #f0c75e);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-agent-fin--err .ai-agent-fin-status {
|
||||
color: var(--df-danger);
|
||||
@@ -3057,7 +3038,8 @@ defineExpose({
|
||||
.ai-agent-fin-meta {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* 被拒工具 chip(P2-1):琥珀警示层与 truncated 同色系;限宽防长清单挤掉摘要行 */
|
||||
.ai-agent-fin-rejected {
|
||||
@@ -3068,9 +3050,9 @@ defineExpose({
|
||||
white-space: nowrap;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
background: color-mix(in srgb, var(--df-warning, #f0c75e) 16%, transparent);
|
||||
color: var(--df-warning, #f0c75e);
|
||||
font-size: 10px;
|
||||
background: color-mix(in srgb, var(--df-warning) 16%, transparent);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
/* 无轨迹(启动失败)卡:禁点视觉,hover 不提亮(cursor 维持默认) */
|
||||
.ai-agent-fin--notrace {
|
||||
@@ -3080,7 +3062,7 @@ defineExpose({
|
||||
.ai-agent-fin--notrace:hover {
|
||||
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
|
||||
}
|
||||
/* peek 拉取中的加载微标(挂在具体卡右端) */
|
||||
/* peek 拉取中的加载微标(挂在具体卡右端);旋转动画消费 global.css @keyframes df-spin */
|
||||
.ai-agent-fin-spin {
|
||||
flex-shrink: 0;
|
||||
width: 10px;
|
||||
@@ -3088,10 +3070,7 @@ defineExpose({
|
||||
border: 1.5px solid color-mix(in srgb, var(--df-text-secondary) 35%, transparent);
|
||||
border-top-color: var(--df-text-secondary);
|
||||
border-radius: 50%;
|
||||
animation: ai-agent-fin-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes ai-agent-fin-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
animation: df-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */
|
||||
@@ -3112,7 +3091,7 @@ defineExpose({
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border-strong);
|
||||
border-radius: var(--df-radius-lg);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--df-shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-agent-peek-head {
|
||||
@@ -3124,7 +3103,7 @@ defineExpose({
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-peek-title {
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
}
|
||||
@@ -3169,7 +3148,7 @@ defineExpose({
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
@@ -3193,7 +3172,7 @@ defineExpose({
|
||||
.ai-agent-peek-msg--tool .ai-agent-peek-text,
|
||||
.ai-agent-peek-msg--system .ai-agent-peek-text {
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 11.5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
/* 浮层头身份标识行:置于标题下方,窄容器内单行省略 */
|
||||
.ai-agent-peek-head-text {
|
||||
@@ -3214,7 +3193,7 @@ defineExpose({
|
||||
.ai-agent-peek-agent {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-info);
|
||||
background: var(--df-info-bg);
|
||||
border-radius: var(--df-radius-sm);
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
// OutboxDrawer:定时/待发消息统一列表(跨会话),支持 改期/立即发送/取消 + 底部新建。
|
||||
// 挂载点在 ChatInput.vue(⏰ 入口按钮旁,Teleport 自脱离输入区布局);
|
||||
// 后端 ai_outbox_* 命令未接线时 invoke 静默降级(useAiOutbox 内),抽屉空态可用。
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiOutbox, type AiOutboxItem } from '../../composables/ai/useAiOutbox'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
@@ -96,6 +96,20 @@ watch(() => props.open, (v) => {
|
||||
if (v) void refresh()
|
||||
})
|
||||
|
||||
// F6-2(走查-信息UX 面六):Esc 关抽屉(与遮罩点击/✕ 同出口,键盘可达)。
|
||||
// 监听挂 document:AiChat 全局 Esc 仲裁在 window 层,document 先于 window 收到冒泡,
|
||||
// 此处消费即收口(stopPropagation),防「关抽屉的同时误关分离窗」一次按键双效果;
|
||||
// IME 组词中的 Esc(229/isComposing)归输入法,不关抽屉(仓内三处同款守卫)。
|
||||
function onEscClose(e: KeyboardEvent): void {
|
||||
if (!props.open || e.key !== 'Escape') return
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
emit('close')
|
||||
}
|
||||
document.addEventListener('keydown', onEscClose)
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onEscClose))
|
||||
|
||||
// ── 列表展示 ──
|
||||
|
||||
// 排序:活跃在前(sending→pending→failed),终态沉底;同级按 due_at 升序(尽快项 due null 排最前)
|
||||
@@ -230,7 +244,8 @@ async function onCreate(): Promise<void> {
|
||||
.outbox-panel {
|
||||
width: min(420px, 92vw);
|
||||
height: 100%;
|
||||
background: var(--df-bg-card);
|
||||
/* wave2:抽屉底 bg-raised(比 bg-card 抬一级,与遮罩分层) */
|
||||
background: var(--df-bg-raised);
|
||||
border-left: 0.5px solid var(--df-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -259,7 +274,7 @@ async function onCreate(): Promise<void> {
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.outbox-close:hover {
|
||||
background: var(--df-bg-raised);
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.outbox-list {
|
||||
@@ -309,8 +324,8 @@ async function onCreate(): Promise<void> {
|
||||
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
}
|
||||
.outbox-badge--failed {
|
||||
color: #e5484d;
|
||||
border-color: color-mix(in srgb, #e5484d 40%, transparent);
|
||||
color: var(--df-danger);
|
||||
border-color: color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||
}
|
||||
.outbox-text {
|
||||
font-size: 12px;
|
||||
@@ -336,7 +351,7 @@ async function onCreate(): Promise<void> {
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.outbox-act:hover:not(:disabled) {
|
||||
background: var(--df-bg-raised);
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.outbox-act:disabled {
|
||||
@@ -353,12 +368,12 @@ async function onCreate(): Promise<void> {
|
||||
color: #fff;
|
||||
}
|
||||
.outbox-act--danger:hover:not(:disabled) {
|
||||
color: #e5484d;
|
||||
border-color: color-mix(in srgb, #e5484d 40%, transparent);
|
||||
color: var(--df-danger);
|
||||
border-color: color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||
}
|
||||
.outbox-dt {
|
||||
border: 0.5px solid var(--df-border);
|
||||
background: var(--df-bg-raised);
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text);
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
@@ -382,7 +397,7 @@ async function onCreate(): Promise<void> {
|
||||
box-sizing: border-box;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
background: var(--df-bg-raised);
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text);
|
||||
font-size: 12px;
|
||||
font-family: var(--df-font-sans);
|
||||
|
||||
@@ -276,7 +276,8 @@ const echoSummary = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力) */
|
||||
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力)
|
||||
wave2(2026-10-18):info 系透明彩底范式(授权边界=信息/配置信号,非警示) */
|
||||
.ai-boundary {
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
@@ -292,7 +293,7 @@ const echoSummary = computed(() => {
|
||||
color: var(--df-text-dim);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border-radius: var(--df-radius-sm);
|
||||
max-width: 100%;
|
||||
}
|
||||
.ai-boundary-toggle:hover { color: var(--df-text); }
|
||||
@@ -301,26 +302,29 @@ const echoSummary = computed(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-boundary-chevron.is-open { transform: rotate(90deg); }
|
||||
.ai-boundary-toggle-label { font-weight: 600; }
|
||||
/* 标题同色(info)12px 600 */
|
||||
.ai-boundary-toggle-label { font-weight: 600; font-size: 12px; color: var(--df-info); }
|
||||
.ai-boundary-echo {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--df-accent);
|
||||
color: var(--df-info);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-boundary-body {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: color-mix(in srgb, var(--df-accent) 5%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--df-info) 6%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-info) 30%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-boundary-locked {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-boundary-loadfail {
|
||||
@@ -328,7 +332,7 @@ const echoSummary = computed(() => {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-boundary-row {
|
||||
@@ -350,7 +354,7 @@ const echoSummary = computed(() => {
|
||||
color: var(--df-text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-boundary-unlimited input { accent-color: var(--df-accent); margin: 0; }
|
||||
.ai-boundary-unlimited input { accent-color: var(--df-info); margin: 0; }
|
||||
.ai-boundary-unlimited:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
|
||||
.ai-boundary-input {
|
||||
flex: 1;
|
||||
@@ -363,7 +367,7 @@ const echoSummary = computed(() => {
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
outline: none;
|
||||
}
|
||||
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-accent) 40%, transparent); }
|
||||
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-info) 40%, transparent); }
|
||||
.ai-boundary-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.ai-boundary-input--num { flex: 0 0 90px; }
|
||||
.ai-boundary-mini-btn {
|
||||
@@ -413,10 +417,11 @@ const echoSummary = computed(() => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
/* 一次授权钮:info 系透明底描边钮(对应色操作钮范式) */
|
||||
.ai-boundary-confirm {
|
||||
border: none;
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-info) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--df-info) 10%, transparent);
|
||||
color: var(--df-info);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
@@ -424,7 +429,7 @@ const echoSummary = computed(() => {
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
transition: background 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-boundary-confirm:hover:not(:disabled) { background: var(--df-accent-hover); }
|
||||
.ai-boundary-confirm:hover:not(:disabled) { background: color-mix(in srgb, var(--df-info) 18%, transparent); }
|
||||
.ai-boundary-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.ai-boundary-clear {
|
||||
border: 0.5px solid var(--df-border);
|
||||
@@ -442,7 +447,7 @@ const echoSummary = computed(() => {
|
||||
.ai-boundary-field.is-error span { color: var(--df-danger); }
|
||||
/* A11: 脏编辑提示条(切会话保留编辑时的可见说明) */
|
||||
.ai-boundary-dirty {
|
||||
font-size: 10.5px;
|
||||
font-size: 10px;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,15 +99,16 @@ async function handleStopArchive(): Promise<void> {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 复用 HelpRequiredCard 样式基调(warning 色系,停靠属"需用户拍板"信号,视觉一致) */
|
||||
/* wave2(2026-10-18):统一透明彩底卡范式,danger 系(非正常终局停靠=需用户拍板信号)——
|
||||
边框 color-mix 30% + 底 8% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
|
||||
.ai-task-parked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-bottom: 6px;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--df-danger) 8%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-task-parked-head {
|
||||
@@ -115,37 +116,39 @@ async function handleStopArchive(): Promise<void> {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
/* stop_kind 标签:透明 danger 描边 chip;fatal 连续失败重入耗尽=实底警示级最高 */
|
||||
.ai-task-parked-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
background: color-mix(in srgb, var(--df-danger) 12%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||
color: var(--df-danger);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
.ai-task-parked-tag--fatal {
|
||||
background: var(--df-danger);
|
||||
border-color: var(--df-danger);
|
||||
color: #fff;
|
||||
}
|
||||
.ai-task-parked-title {
|
||||
font-size: 11.5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-task-parked-reason {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* preserved 行与 reason 同色系但更弱一级:保证信息在,不与真实原因抢视觉权重 */
|
||||
.ai-task-parked-preserved {
|
||||
font-size: 10.5px;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.8;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-task-parked-actions {
|
||||
display: flex;
|
||||
@@ -155,17 +158,17 @@ async function handleStopArchive(): Promise<void> {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ai-task-parked-btn {
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||
color: var(--df-danger);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
transition: filter 0.15s var(--df-ease);
|
||||
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-task-parked-btn:hover { filter: brightness(1.1); }
|
||||
.ai-task-parked-btn:hover { background: color-mix(in srgb, var(--df-danger) 16%, transparent); }
|
||||
.ai-task-parked-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.ai-task-parked-btn--muted {
|
||||
background: transparent;
|
||||
|
||||
+109
-78
@@ -14,7 +14,7 @@
|
||||
<button
|
||||
v-if="pendingApprovalCount > 0"
|
||||
type="button"
|
||||
class="ai-approval-badge"
|
||||
class="ai-approval-badge df-badge df-badge--danger"
|
||||
:title="$t('aiChat.pendingApprovalHint')"
|
||||
@click="emit('scroll-to-pending')"
|
||||
>
|
||||
@@ -24,7 +24,7 @@
|
||||
<button
|
||||
v-if="unattendedStatus?.breaker_tripped"
|
||||
type="button"
|
||||
class="ai-unattended-badge ai-unattended-badge--tripped"
|
||||
class="ai-unattended-badge df-badge df-badge--danger"
|
||||
:title="$t('aiChat.unattendedResetHint')"
|
||||
@click="resetUnattendedBreaker"
|
||||
>
|
||||
@@ -33,7 +33,7 @@
|
||||
<button
|
||||
v-else-if="unattendedStatus?.enabled"
|
||||
type="button"
|
||||
class="ai-unattended-badge"
|
||||
class="ai-unattended-badge df-badge df-badge--accent"
|
||||
:title="$t('aiChat.unattendedBadgeHint')"
|
||||
@click="jumpToUnattendedSettings"
|
||||
>
|
||||
@@ -50,7 +50,7 @@
|
||||
:title="store.state.providers.length > 1 ? $t('aiChat.providerSwitchSetsDefault') : ''"
|
||||
@click="cycleProvider"
|
||||
>
|
||||
<span class="provider-dot" :class="'provider-dot--' + providerStatus"></span>
|
||||
<span class="df-dot provider-dot" :class="'provider-dot--' + providerStatus"></span>
|
||||
<span class="provider-name">{{ activeProviderName }}</span>
|
||||
</button>
|
||||
<div class="ai-provider-bar ai-provider-empty" v-else>
|
||||
@@ -182,7 +182,7 @@
|
||||
</div>
|
||||
<!-- #14 桌面任务卡:后台在途任务(多会话并发生成中,点击切回) -->
|
||||
<div v-if="runningTasks.length || recoverableTasks.length" class="ai-running-tasks-inline">
|
||||
<button type="button" class="ai-running-tasks-badge ai-tool-badge" :class="{ 'is-active': runningTasksExpanded }" @click="togglePanel('running')" :title="$t('aiChat.runningTasks')">
|
||||
<button type="button" class="ai-running-tasks-badge ai-tool-badge df-badge df-badge--info" :class="{ 'is-active': runningTasksExpanded }" @click="togglePanel('running')" :title="$t('aiChat.runningTasks')">
|
||||
<span class="ai-running-tasks-pulse"></span>
|
||||
{{ runningTasks.length }}
|
||||
</button>
|
||||
@@ -236,6 +236,8 @@
|
||||
<span class="ai-history-text">{{ h.text }}</span>
|
||||
<span class="ai-history-time">{{ h.time }}</span>
|
||||
</div>
|
||||
<!-- F6-1:目标消息在折叠段内未渲染,面板不收 + 行内提示 -->
|
||||
<div v-if="locateMissHint" class="ai-locate-miss">{{ locateMissHint }}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -251,6 +253,8 @@
|
||||
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ summaryMsgs.length - i }}</span>
|
||||
<p class="ai-summary-preview">{{ s.text }}</p>
|
||||
</div>
|
||||
<!-- F6-1:摘要项同享定位未命中提示(压缩段折叠时 system 消息同样不在 DOM) -->
|
||||
<div v-if="locateMissHint" class="ai-locate-miss">{{ locateMissHint }}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -413,6 +417,7 @@ onMounted(() => { void refreshUnattended() })
|
||||
onBeforeUnmount(() => {
|
||||
unattendedDisposed = true
|
||||
if (unattendedTimer) { clearTimeout(unattendedTimer); unattendedTimer = null }
|
||||
if (locateMissTimer) { clearTimeout(locateMissTimer); locateMissTimer = null }
|
||||
})
|
||||
|
||||
// ══ 更多菜单 popout 状态(上下文管理操作收纳)══
|
||||
@@ -768,8 +773,23 @@ const summaryMsgs = computed<SummaryItem[]>(() => msgDerived.value.summaries)
|
||||
const historyExpanded = ref(false)
|
||||
|
||||
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
|
||||
// F6-1(走查-信息UX 面六):目标消息在折叠段(已归档/已压缩)内时,折叠态不渲染
|
||||
// [data-msg-id] 元素,querySelector 必空。原实现先收面板再查 DOM,未命中即静默无反应;
|
||||
// 现改为面板保持展开 + 行内提示引导展开分隔条。展开折叠段的状态在 MessageList 内持有,
|
||||
// 本组件不可达,提示是最小闭环(不跨组件撬状态)。
|
||||
const locateMissHint = ref('')
|
||||
let locateMissTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function showLocateMiss(): void {
|
||||
locateMissHint.value = t('aiChat.historyLocateInSegment')
|
||||
if (locateMissTimer) clearTimeout(locateMissTimer)
|
||||
locateMissTimer = setTimeout(() => {
|
||||
locateMissHint.value = ''
|
||||
locateMissTimer = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function scrollToMessage(msgId: string) {
|
||||
historyExpanded.value = false
|
||||
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位。
|
||||
// msgId 由后端生成,可能含 CSS 选择器元字符(: . [ ] / 空格 等),
|
||||
// 模板拼 selector 会被选择器解析器当伪类/类名异常 → querySelector 抛错或返回 null。
|
||||
@@ -787,8 +807,13 @@ function scrollToMessage(msgId: string) {
|
||||
el = Array.from(all).find(n => n.getAttribute('data-msg-id') === msgId) ?? null
|
||||
}
|
||||
if (el) {
|
||||
locateMissHint.value = ''
|
||||
historyExpanded.value = false
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
return
|
||||
}
|
||||
// 未命中:不收面板,提示替代静默
|
||||
showLocateMiss()
|
||||
}
|
||||
|
||||
/** 目标/历史下拉容器 DOM 引用(用于点击外部检测) */
|
||||
@@ -824,6 +849,7 @@ const contextExpanded = ref(false)
|
||||
|
||||
/** 弹出面板互斥:goals/history/summary/context/running 五个 badge 面板,点一个收起其他,再点当前切换。 */
|
||||
function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'running') {
|
||||
locateMissHint.value = ''
|
||||
const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded, running: runningTasksExpanded }
|
||||
const willOpen = !refs[panel].value
|
||||
goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = runningTasksExpanded.value = false
|
||||
@@ -965,56 +991,37 @@ onBeforeUnmount(() => {
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡;div→button 后归零默认样式) */
|
||||
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡)
|
||||
2026-10-18 wave2:迁 .df-badge--danger 透明彩底徽章(色由全局公共类接管,此处只留布局) */
|
||||
.ai-approval-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background: var(--df-warning-bg);
|
||||
color: var(--df-warning);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s var(--df-ease);
|
||||
font-family: inherit;
|
||||
appearance: none;
|
||||
/* review ③:div→button 后 UA outset 边框需显式归零(appearance:none 不含 border) */
|
||||
border: none;
|
||||
}
|
||||
.ai-approval-badge:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-approval-badge:focus-visible {
|
||||
outline: 1.5px solid var(--df-accent, #4a9eff);
|
||||
outline: 1.5px solid var(--df-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
/* 挂机引擎态徽章(开启=accent 弱底;熔断=红底红字,点击复位) */
|
||||
/* 挂机引擎态徽章(开启=accent / 熔断=danger,均由 df-badge 变体接管,此处只留布局) */
|
||||
.ai-unattended-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
appearance: none;
|
||||
border: none;
|
||||
transition: opacity 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-unattended-badge:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-unattended-badge--tripped {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
border: 0.5px solid var(--df-danger);
|
||||
}
|
||||
|
||||
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
|
||||
.ai-model-picker {
|
||||
@@ -1053,25 +1060,28 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
/* ── 图标按钮(共享工具类,header-actions 内清空/压缩/最大化/分离/关闭等)── */
|
||||
/* ── 图标按钮(共享工具类,header-actions 内清空/压缩/最大化/分离/关闭等)
|
||||
2026-10-18 wave2:统一 workbench .hd-btn 范式——bg-card 底+边框+md 圆角+hover 边框亮 ── */
|
||||
.ai-btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-md);
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-btn-icon:hover {
|
||||
background: var(--df-bg-card);
|
||||
border-color: var(--df-border-strong);
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-btn-icon--active {
|
||||
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
@@ -1080,7 +1090,8 @@ onBeforeUnmount(() => {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-btn-icon:disabled:hover {
|
||||
background: transparent;
|
||||
border-color: var(--df-border);
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* ── 更多菜单 popout(清空/压缩上下文等低频操作收纳;CSS 原缺失致打开样式错乱)── */
|
||||
@@ -1094,7 +1105,7 @@ onBeforeUnmount(() => {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1113,16 +1124,13 @@ onBeforeUnmount(() => {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-more-item:hover:not(:disabled) { background: var(--df-bg-hover, rgba(0,0,0,0.06)); color: var(--df-text); }
|
||||
.ai-more-item:hover:not(:disabled) { background: var(--df-bg-card-hover); color: var(--df-text); }
|
||||
.ai-more-item:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
/* Transition:淡入 + 下滑 */
|
||||
.ai-more-popout-enter-active, .ai-more-popout-leave-active { transition: opacity .15s, transform .15s; }
|
||||
.ai-more-popout-enter-from, .ai-more-popout-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
/* 压缩中 spinner 图标旋转动画(@keyframes ai-btn-spin 在父级 AiChat.vue 全局定义,
|
||||
scoped 不重命名 @keyframes,此处按名引用) */
|
||||
.ai-btn-spinner {
|
||||
animation: ai-btn-spin 0.9s linear infinite;
|
||||
}
|
||||
/* 压缩中 spinner:.ai-btn-spinner(含 ai-btn-spin 旋转动画)由 components.css 全局定义,
|
||||
原本地重复 animation 声明删除(2026-10-18 整洁波,逐字重复零视觉变更) */
|
||||
/* 底部工具区(标题左 + 工具右) */
|
||||
.ai-bottom-tools {
|
||||
display: flex;
|
||||
@@ -1192,6 +1200,10 @@ onBeforeUnmount(() => {
|
||||
.ai-task-progress--warn .ai-task-progress-dot {
|
||||
animation-duration: 3.2s;
|
||||
}
|
||||
/* 警示档(静默>10min)状态点转琥珀:给判断材料,不自动判死 */
|
||||
.ai-task-progress--warn .ai-task-progress-dot {
|
||||
background: var(--df-warning);
|
||||
}
|
||||
.ai-task-progress-stage {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
@@ -1200,39 +1212,45 @@ onBeforeUnmount(() => {
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--df-warning) 70%, var(--df-text-secondary));
|
||||
}
|
||||
/* ═══ Provider Bar (header 中间区;div→button 后归零默认样式) ═══ */
|
||||
/* ═══ Provider Bar (header 中间区)
|
||||
2026-10-18 wave2:对齐 workbench .session-status pill 范式——bg-card 底+边框+8px 圆角+12px 字,
|
||||
状态点迁 .df-dot(7px 尺寸由全局公共类提供);div→button 后归零默认样式 ═══ */
|
||||
.ai-provider-bar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-md);
|
||||
padding: 4px 12px;
|
||||
transition: border-color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-provider-bar--switchable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-provider-bar--switchable:hover {
|
||||
color: var(--df-text-primary);
|
||||
border-color: var(--df-border-strong);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-provider-bar--switchable:focus-visible {
|
||||
outline: 1.5px solid var(--df-accent, #4a9eff);
|
||||
outline: 1.5px solid var(--df-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
border-radius: var(--df-radius-md);
|
||||
}
|
||||
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError) */
|
||||
.provider-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--df-text-dim); flex-shrink: 0; transition: background .2s; }
|
||||
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError);
|
||||
尺寸/圆角走全局 .df-dot,此处只管色 */
|
||||
.provider-dot { flex-shrink: 0; transition: background .2s; background: var(--df-text-dim); }
|
||||
.provider-dot--ok { background: var(--df-success); }
|
||||
.provider-dot--error { background: var(--df-danger); }
|
||||
.provider-name {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text-dim);
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-provider-empty {
|
||||
background: var(--df-danger-bg);
|
||||
@@ -1265,7 +1283,7 @@ onBeforeUnmount(() => {
|
||||
appearance: none;
|
||||
}
|
||||
.ai-tool-badge:focus-visible {
|
||||
outline: 1.5px solid var(--df-accent, #4a9eff);
|
||||
outline: 1.5px solid var(--df-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
|
||||
@@ -1291,11 +1309,17 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
/* wave2:后台会话徽章迁 .df-badge--info(透明彩底 info 系);scoped 需显式覆写压过 .ai-tool-badge 灰底 */
|
||||
.ai-running-tasks-badge.df-badge--info {
|
||||
background: var(--df-info-bg);
|
||||
color: var(--df-info);
|
||||
border-color: color-mix(in srgb, var(--df-info) 30%, transparent);
|
||||
}
|
||||
.ai-running-tasks-pulse {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-success, #34c77b);
|
||||
background: var(--df-success);
|
||||
animation: ai-running-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ai-running-pulse {
|
||||
@@ -1311,9 +1335,9 @@ onBeforeUnmount(() => {
|
||||
max-width: 400px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--df-bg);
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 4px 0;
|
||||
}
|
||||
@@ -1331,14 +1355,14 @@ onBeforeUnmount(() => {
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-running-task-item:hover {
|
||||
background: var(--df-hover, rgba(128,128,128,0.08));
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
.ai-running-task-dot {
|
||||
flex-shrink: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-success, #34c77b);
|
||||
background: var(--df-success);
|
||||
}
|
||||
.ai-running-task-title {
|
||||
flex: 1;
|
||||
@@ -1349,7 +1373,7 @@ onBeforeUnmount(() => {
|
||||
.ai-running-task-round {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary, #888);
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
/* V49 可恢复分区:琥珀点+「可恢复」徽标,与进行中(绿)区分 */
|
||||
.ai-running-tasks-sep {
|
||||
@@ -1358,7 +1382,7 @@ onBeforeUnmount(() => {
|
||||
background: var(--df-border);
|
||||
}
|
||||
.ai-running-task-dot--parked {
|
||||
background: var(--df-warning, #e6a23c);
|
||||
background: var(--df-warning);
|
||||
}
|
||||
/* B4 parked 行改双钮布局(切回 | 恢复),恢复钮琥珀描边醒目 */
|
||||
.ai-running-task-item--parked {
|
||||
@@ -1385,21 +1409,21 @@ onBeforeUnmount(() => {
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-running-task-main:hover {
|
||||
background: var(--df-hover, rgba(128,128,128,0.08));
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
.ai-running-task-recover {
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--df-warning, #e6a23c);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--df-warning);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--df-warning, #e6a23c);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-running-task-recover:hover {
|
||||
background: var(--df-warning, #e6a23c);
|
||||
background: var(--df-warning);
|
||||
color: #fff;
|
||||
}
|
||||
.ai-history-inline-list {
|
||||
@@ -1411,9 +1435,9 @@ onBeforeUnmount(() => {
|
||||
max-width: 400px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--df-bg);
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 4px 0;
|
||||
margin-top: 2px;
|
||||
@@ -1454,6 +1478,13 @@ onBeforeUnmount(() => {
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
/* F6-1:历史/摘要定位未命中提示(目标消息在折叠段内,引导展开分隔条) */
|
||||
.ai-locate-miss {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
|
||||
/* ═══ 上下文摘要(底部) ═══ */
|
||||
.ai-summary-inline {
|
||||
@@ -1470,9 +1501,9 @@ onBeforeUnmount(() => {
|
||||
max-width: 420px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
background: var(--df-bg);
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 6px 0;
|
||||
margin-top: 2px;
|
||||
@@ -1518,9 +1549,9 @@ onBeforeUnmount(() => {
|
||||
z-index: 100;
|
||||
min-width: 280px;
|
||||
max-width: 420px;
|
||||
background: var(--df-bg);
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -1571,9 +1602,9 @@ onBeforeUnmount(() => {
|
||||
z-index: 100;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
background: var(--df-bg, #fff);
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 4px 0;
|
||||
margin-top: 2px;
|
||||
@@ -1601,7 +1632,7 @@ onBeforeUnmount(() => {
|
||||
.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);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.provider-hint {
|
||||
font-size: 11px;
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
<template>
|
||||
<!-- ═══ 会话列表体(拆自 ConversationSidebar.vue C4a,零行为变更) ═══ -->
|
||||
<!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC -->
|
||||
<template v-if="searchResults">
|
||||
<div
|
||||
v-for="conv in searchResults"
|
||||
:key="'s-' + conv.id"
|
||||
class="ai-conv-item"
|
||||
:class="{
|
||||
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
||||
'ai-conv-item--switching': conv.id === pendingSwitchId,
|
||||
'ai-conv-item--archived': conv.archived,
|
||||
'ai-conv-item--pinned': conv.pinned,
|
||||
}"
|
||||
tabindex="0"
|
||||
@click="onSelectSearchResult(conv.id)"
|
||||
@keydown="onItemKeydown($event, conv.id, true)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 左侧 7px 状态点。字段源 store.isGenerating(conv_state
|
||||
generating/stopping/compressed) / pendingCountOf(审批挂起) / loop_state='parked',
|
||||
无 error 字段故不映射;优先级 generating > 待审批 > parked > 灰 -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
|
||||
双击入口对齐分组视图,菜单入口供键盘用户(P2-6) -->
|
||||
<input
|
||||
v-if="editingConvId === conv.id"
|
||||
:ref="setEditEl"
|
||||
v-model="editText"
|
||||
class="ai-conv-item-edit"
|
||||
@keydown="onRenameKeydown($event, conv.id)"
|
||||
@blur="commitRename(conv.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="ai-conv-item-title"
|
||||
:title="conv.title || t('aiChat.doubleClickToRename')"
|
||||
@dblclick.stop="startRename(conv)"
|
||||
>
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
|
||||
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="ai-conv-item-actions">
|
||||
<div class="ai-conv-item-more">
|
||||
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
||||
</button>
|
||||
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
{{ t('aiChat.renameConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
||||
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, !conv.archived); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
||||
{{ conv.archived ? t('aiChat.unarchive') : t('aiChat.archive') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="searchResults.length === 0" class="ai-conv-empty">
|
||||
{{ t('aiChat.searchEmpty') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 非搜索态:原时间分组(活跃) + 归档分组 -->
|
||||
<template v-else>
|
||||
<!-- 活跃对话:按 今天/昨天/更早 分组 -->
|
||||
<template v-for="group in groupedActive" :key="'g-' + group.key">
|
||||
<div
|
||||
class="ai-conv-group-title ai-conv-group-title--fold"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="!store.state.foldedGroups[group.key]"
|
||||
@click="store.toggleGroupFold(group.key)"
|
||||
@keydown.enter.prevent="store.toggleGroupFold(group.key)"
|
||||
@keydown.space.prevent="store.toggleGroupFold(group.key)"
|
||||
>
|
||||
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.foldedGroups[group.key] }">▸</span>
|
||||
{{ group.label }}
|
||||
<span class="ai-conv-group-count">({{ group.items.length }})</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="group.key === 'today' && !group.items.length"
|
||||
v-show="!store.state.foldedGroups[group.key]"
|
||||
class="ai-conv-group-empty"
|
||||
>{{ t('aiChat.groupEmpty') }}</div>
|
||||
<div
|
||||
v-for="conv in group.items"
|
||||
v-show="!store.state.foldedGroups[group.key]"
|
||||
:key="conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
tabindex="0"
|
||||
@click="beginSwitch(conv.id)"
|
||||
@keydown="onItemKeydown($event, conv.id, true)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 左侧 7px 状态点(映射源同搜索态块注) -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<input
|
||||
v-if="editingConvId === conv.id"
|
||||
:ref="setEditEl"
|
||||
v-model="editText"
|
||||
class="ai-conv-item-edit"
|
||||
@keydown="onRenameKeydown($event, conv.id)"
|
||||
@blur="commitRename(conv.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="ai-conv-item-title"
|
||||
:class="{ 'ai-conv-item-title--flash': titleFlash && conv.id === store.state.activeConversationId }"
|
||||
@dblclick.stop="startRename(conv)"
|
||||
:title="conv.title || t('aiChat.doubleClickToRename')"
|
||||
>
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<!-- UX-2025-20 §7.4:AI 生成标题标识小图标(标题非空非占位时显示) -->
|
||||
<svg aria-hidden="true"
|
||||
v-if="conv.title && titleFlash && conv.id === store.state.activeConversationId"
|
||||
class="ai-conv-item-title-badge"
|
||||
width="9" height="9" 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>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
|
||||
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="ai-conv-item-actions">
|
||||
<div class="ai-conv-item-more">
|
||||
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
||||
</button>
|
||||
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
||||
{{ t('aiChat.renameConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
||||
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, true); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
||||
{{ t('aiChat.archive') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- G5 可恢复分组(loop_state='parked',断点续跑闭环读侧;置于活跃分组后、归档前) -->
|
||||
<template v-if="recoverable.length">
|
||||
<div class="ai-conv-group-title">
|
||||
<span class="ai-conv-recover-dot" aria-hidden="true"></span>
|
||||
{{ t('aiChat.recoverableCount', { n: recoverable.length }) }}
|
||||
</div>
|
||||
<div
|
||||
v-for="conv in recoverable"
|
||||
:key="'r-' + conv.id"
|
||||
class="ai-conv-item ai-conv-item--recoverable"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
|
||||
tabindex="0"
|
||||
:title="t('aiChat.recoverableHint')"
|
||||
@click="onRecover(conv.id)"
|
||||
@keydown.enter.prevent="onRecover(conv.id)"
|
||||
@keydown.space.prevent="onRecover(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2: 本组数据源 ai_list_recoverable(loop_state='parked' 完备真相),整组恒 parked→静态琥珀点 -->
|
||||
<span class="df-dot ai-conv-status ai-conv-status--paused" aria-hidden="true"></span>
|
||||
<span class="ai-conv-item-title">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span class="ai-conv-recover-badge">⏸</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 归档分组(可折叠) -->
|
||||
<template v-if="archivedConvs.length">
|
||||
<div
|
||||
class="ai-conv-group-title ai-conv-group-title--fold"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="!store.state.archivedCollapsed"
|
||||
@click="store.toggleArchivedFold()"
|
||||
@keydown.enter.prevent="store.toggleArchivedFold()"
|
||||
@keydown.space.prevent="store.toggleArchivedFold()"
|
||||
>
|
||||
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.archivedCollapsed }">▸</span>
|
||||
{{ t('aiChat.archivedCount', { n: archivedConvs.length }) }}
|
||||
</div>
|
||||
<div
|
||||
v-for="conv in archivedConvs"
|
||||
v-show="!store.state.archivedCollapsed"
|
||||
:key="'a-' + conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item ai-conv-item--archived"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
tabindex="0"
|
||||
@click="beginSwitch(conv.id)"
|
||||
@keydown="onItemKeydown($event, conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<!-- wave2 u-agent 范式: 归档项同款状态点(归档为静息态,默认落灰) -->
|
||||
<span
|
||||
class="df-dot ai-conv-status"
|
||||
:class="store.isGenerating(conv.id) ? 'df-dot--run ai-conv-status--run'
|
||||
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
|
||||
: conv.loop_state === 'parked' ? 'ai-conv-status--paused'
|
||||
: 'df-dot--stop'"
|
||||
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="ai-conv-item-title" :title="conv.title || ''">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
<!-- 无人值守边界徽标:该会话已授权任务边界(镜像渐进填充);点击随会话切回显 -->
|
||||
<span v-if="hasBoundary(conv.id)" class="ai-conv-boundary-badge" :title="t('ai.taskBoundary.badge')">
|
||||
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="ai-conv-item-actions">
|
||||
<div class="ai-conv-item-more">
|
||||
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
||||
</button>
|
||||
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
||||
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, false); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg>
|
||||
{{ t('aiChat.unarchive') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- F4-1: 会话列表分页——底部「加载更多」续拉下一页(首屏 50/组,归档组同口径放开) -->
|
||||
<div
|
||||
v-if="convListPaging.hasMore"
|
||||
class="ai-conv-load-more"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onLoadMore"
|
||||
@keydown.enter.prevent="onLoadMore"
|
||||
@keydown.space.prevent="onLoadMore"
|
||||
>{{ convListPaging.loading ? t('common.loading') : t('gitChanges.loadMore') }}</div>
|
||||
|
||||
<div v-if="store.state.conversations.length === 0" class="ai-conv-empty">
|
||||
{{ t('aiChat.emptyConversation') }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 会话列表体 — 拆自 ConversationSidebar.vue(C4a 大文件拆分,零行为变更)。
|
||||
*
|
||||
* 职责:侧栏列表两态渲染(搜索态平铺 / 时间分组+可恢复+归档)及项级自治状态:
|
||||
* 重命名(editingConvId/editText)、更多操作菜单(actionsOpenId + document 级外部点击/Esc 关闭)、
|
||||
* 切换过渡(pendingSwitchId)、项 DOM ref 收集(M3 展开折叠组后 scrollIntoView)、
|
||||
* 标题生成淡入(titleFlash)、时间分组归桶(groupedActive/archivedConvs)。
|
||||
*
|
||||
* 跨文件依赖(策略同侧栏第一批:store 共享走 useAiStore,不 props 传 store):
|
||||
* - props.pendingCountOf:V1 待审批计数(主文件 computed + 2.5s 轮询兜底锚定,函数透传保响应式);
|
||||
* - props.recoverable:G5 可恢复清单(主文件 onMounted 拉取,侧栏开合不重复 IPC),恢复动作 emit('recover');
|
||||
* - emit('confirm-delete'):删除需父级 ConfirmDialog,侧栏主文件继续上抛 AiChat。
|
||||
* 边界徽标只读消费 getConvBoundary(reactive Map,模板内读取可追踪),直接 import 同源 composable。
|
||||
*/
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../../stores/ai'
|
||||
import { getConvBoundary } from '../../../composables/ai/useTaskBoundary'
|
||||
import { convListPaging, loadMoreConversations } from '../../../composables/ai/useAiConversations'
|
||||
import { formatRelative } from '../../../utils/time'
|
||||
import type { AiConversationSummary } from '../../../api/types'
|
||||
|
||||
/** G5 可恢复会话条目(主文件数据源透传,字段口径同 aiApi.listRecoverable) */
|
||||
interface RecoverableConv { id: string; title: string | null; model: string | null; updated_at: string }
|
||||
|
||||
defineProps<{
|
||||
/** V1 跨会话待审批计数(主文件 computed:三响应式源锚定 + 轮询兜底;函数透传,模板调用可追踪) */
|
||||
pendingCountOf: (convId: string) => number
|
||||
/** G5 可恢复分组数据(主文件拉取与防重,本组件纯渲染 + emit 恢复动作) */
|
||||
recoverable: RecoverableConv[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 删除对话(需二次确认),上抛侧栏主文件继续交 AiChat 弹 ConfirmDialog */
|
||||
(e: 'confirm-delete', id: string): void
|
||||
/** G5 恢复 parked 会话,主文件持 recoveringId 防重 + toast 反馈 */
|
||||
(e: 'recover', id: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useAiStore()
|
||||
|
||||
// ── 标题生成淡入(UX-2025-20 §7.4):监听当前对话标题从空→非空触发 ──
|
||||
const titleFlash = ref(false)
|
||||
let _titleFlashTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const activeConv = computed(() =>
|
||||
store.state.conversations.find(c => c.id === store.state.activeConversationId) || null,
|
||||
)
|
||||
watch(
|
||||
() => activeConv.value?.title,
|
||||
(newTitle, oldTitle) => {
|
||||
// 标题从空→非空才触发淡入,其他变化不闪
|
||||
if (!newTitle || oldTitle) return
|
||||
titleFlash.value = true
|
||||
if (_titleFlashTimer) clearTimeout(_titleFlashTimer)
|
||||
_titleFlashTimer = setTimeout(() => { titleFlash.value = false }, 320)
|
||||
},
|
||||
)
|
||||
|
||||
// ── 对话项 inline 重命名 ──
|
||||
const editingConvId = ref<string | null>(null)
|
||||
const editText = ref('')
|
||||
let editEl: HTMLInputElement | null = null
|
||||
function setEditEl(el: Element | { $el?: Element } | null): void {
|
||||
editEl = (el as HTMLInputElement) || null
|
||||
}
|
||||
|
||||
function startRename(conv: { id: string; title: string | null }) {
|
||||
editingConvId.value = conv.id
|
||||
editText.value = conv.title || ''
|
||||
nextTick(() => {
|
||||
editEl?.focus()
|
||||
editEl?.select()
|
||||
})
|
||||
}
|
||||
|
||||
async function commitRename(id: string) {
|
||||
const title = editText.value.trim()
|
||||
if (title && editingConvId.value === id) {
|
||||
await store.renameConversation(id, title)
|
||||
}
|
||||
editingConvId.value = null
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingConvId.value = null
|
||||
}
|
||||
|
||||
/** P1-1 重命名框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
|
||||
* 不当重命名提交/取消处理,防标题被半截拼音覆盖。原 keyup.enter/.esc 改 keydown 统一挂守卫:
|
||||
* 组词上屏的回车到 keyup 阶段 isComposing 已复位守不住,keydown 才拦得到。 */
|
||||
function onRenameKeydown(e: KeyboardEvent, convId: string) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitRename(convId)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelRename()
|
||||
}
|
||||
}
|
||||
|
||||
// UX走查 P2-6: 会话项键盘可达(tabindex=0)。Enter/Space 等价点击选中;仅焦点落在本项 div
|
||||
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发。
|
||||
// renamable=true 的会话项(搜索结果/时间分组,即双击标题可重命名的项)额外支持 F2 键盘重命名,
|
||||
// 作为 .ai-conv-item-title 双击入口的键盘等价(文件管理器惯例;归档项不支持重命名故不传该标志)。
|
||||
function onItemKeydown(e: KeyboardEvent, convId: string, renamable = false) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
beginSwitch(convId)
|
||||
} else if (renamable && e.key === 'F2') {
|
||||
e.preventDefault()
|
||||
const conv = store.state.conversations.find(c => c.id === convId)
|
||||
if (conv) startRename(conv)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
|
||||
const actionsOpenId = ref<string | null>(null)
|
||||
|
||||
function toggleActionsMenu(id: string) {
|
||||
actionsOpenId.value = actionsOpenId.value === id ? null : id
|
||||
}
|
||||
|
||||
function closeActionsMenu() {
|
||||
actionsOpenId.value = null
|
||||
}
|
||||
|
||||
// 点击外部关闭更多操作菜单
|
||||
function onActionsOutsideClick(e: MouseEvent) {
|
||||
if (!actionsOpenId.value) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target?.closest('.ai-conv-item-more')) return
|
||||
actionsOpenId.value = null
|
||||
}
|
||||
|
||||
// UX走查 P2-6: 操作菜单原仅外部点击可关,键盘用户开菜单后无 Esc 逃逸;IME 守卫防组词取消误关
|
||||
function onActionsEscClose(e: KeyboardEvent) {
|
||||
if (!actionsOpenId.value) return
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.key === 'Escape') actionsOpenId.value = null
|
||||
}
|
||||
|
||||
// ── 会话切换过渡态(UX走查 2026-10-03 P1):点击即预高亮目标项(乐观),IPC 返回后随
|
||||
// activeConversationId 落定接管高亮;失败/过期时 finally 清 pending,高亮自动回滚到原活跃项。
|
||||
// 治:大历史会话 IPC 数百 ms 期间点击项不高亮、原高亮不动、无任何进行时反馈,快速连点像失灵。
|
||||
const pendingSwitchId = ref<string | null>(null)
|
||||
function beginSwitch(id: string) {
|
||||
// 已活跃会话:switchConversation 内部同判短路,无过渡期,不挂 pending
|
||||
if (id === store.state.activeConversationId) {
|
||||
void store.switchConversation(id)
|
||||
return
|
||||
}
|
||||
pendingSwitchId.value = id
|
||||
void store.switchConversation(id).finally(() => {
|
||||
// 仅当 pending 仍是本次点击的 id 才清:快速连点 B 覆盖 A 后,A 的迟到返回不得清掉 B 的过渡态
|
||||
if (pendingSwitchId.value === id) pendingSwitchId.value = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
||||
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
|
||||
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
|
||||
*/
|
||||
function onSelectSearchResult(id: string) {
|
||||
// 走 beginSwitch 挂切换过渡态(乐观预高亮),与分组视图点击口径一致
|
||||
beginSwitch(id)
|
||||
store.state.searchQuery = ''
|
||||
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
|
||||
void nextTick(() => ensureActiveVisible())
|
||||
}
|
||||
|
||||
// ── M3:活跃会话项 DOM ref 收集(id → el),用于展开折叠组后 scrollIntoView ──
|
||||
// Vue 3 函数 ref:每项 :ref="(el) => setItemEl(id, el)"。卸载时 el 为 null,删 key 防 Map 泄漏。
|
||||
const itemEls = new Map<string, Element>()
|
||||
function setItemEl(id: string, el: Element | ComponentPublicInstance | null): void {
|
||||
if (!el) {
|
||||
itemEls.delete(id)
|
||||
return
|
||||
}
|
||||
// 组件实例取 $el,但本项是普通 div(el 即 Element)
|
||||
itemEls.set(id, el as Element)
|
||||
}
|
||||
|
||||
/**
|
||||
* M3:确保当前活跃会话在分组视图可见。若它落在折叠的时间分组(今天/昨天/更早)或折叠的归档分组,
|
||||
* 展开该组让 v-show 渲染出来,再 scrollIntoView。用于搜索点选、切对话后选中态不被折叠吞掉。
|
||||
*/
|
||||
function ensureActiveVisible(): void {
|
||||
const id = store.state.activeConversationId
|
||||
if (!id) return
|
||||
const conv = store.state.conversations.find(c => c.id === id)
|
||||
if (!conv) return
|
||||
// 先按归档态/时间桶决定应展开哪个组折叠态
|
||||
if (conv.archived) {
|
||||
if (store.state.archivedCollapsed) store.state.archivedCollapsed = false
|
||||
} else {
|
||||
const bucket = timeBucket(conv.updated_at)
|
||||
if (store.state.foldedGroups[bucket]) store.state.foldedGroups[bucket] = false
|
||||
}
|
||||
// 折叠态翻转后 v-show 需一帧才渲染,再 nextTick 取 el 滚动
|
||||
void nextTick(() => {
|
||||
const el = itemEls.get(id)
|
||||
if (el) {
|
||||
;(el as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 该会话是否已授权无人值守边界(徽标显隐)。直接读 reactive Map,渲染效应自动追踪增删 */
|
||||
function hasBoundary(convId: string): boolean {
|
||||
return getConvBoundary(convId) !== null
|
||||
}
|
||||
|
||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||
const bucketLabels = computed<Record<string, string>>(() => ({
|
||||
today: t('aiChat.today'),
|
||||
yesterday: t('aiChat.yesterday'),
|
||||
earlier: t('aiChat.earlier'),
|
||||
}))
|
||||
|
||||
function timeBucket(ts: string): 'today' | 'yesterday' | 'earlier' {
|
||||
const t = parseInt(ts, 10)
|
||||
if (!Number.isFinite(t)) return 'earlier'
|
||||
const d = new Date(t)
|
||||
const now = new Date()
|
||||
const sameDay = d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate()
|
||||
if (sameDay) return 'today'
|
||||
const yest = new Date(now)
|
||||
yest.setDate(now.getDate() - 1)
|
||||
if (d.getFullYear() === yest.getFullYear()
|
||||
&& d.getMonth() === yest.getMonth()
|
||||
&& d.getDate() === yest.getDate()) return 'yesterday'
|
||||
return 'earlier'
|
||||
}
|
||||
|
||||
const groupedActive = computed(() => {
|
||||
const map = new Map<string, AiConversationSummary[]>()
|
||||
for (const c of store.state.conversations) {
|
||||
if (c.archived) continue
|
||||
const b = timeBucket(c.updated_at)
|
||||
if (!map.has(b)) map.set(b, [])
|
||||
map.get(b)!.push(c)
|
||||
}
|
||||
for (const items of map.values()) {
|
||||
items.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|
||||
|| Number(b.updated_at) - Number(a.updated_at))
|
||||
}
|
||||
const groups: { key: string; label: string; items: AiConversationSummary[] }[] = []
|
||||
for (const key of ['today', 'yesterday', 'earlier']) {
|
||||
const items = map.get(key) || []
|
||||
if (key === 'today' || items.length) groups.push({ key, label: bucketLabels.value[key], items })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const archivedConvs = computed(() =>
|
||||
store.state.conversations
|
||||
.filter(c => c.archived)
|
||||
.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|
||||
|| Number(b.updated_at) - Number(a.updated_at)),
|
||||
)
|
||||
|
||||
const searchResults = computed<AiConversationSummary[] | null>(
|
||||
() => store.filteredConversations.value,
|
||||
)
|
||||
|
||||
// ── F4-1: 会话列表分页(加载更多按钮 + 搜索全量兜底) ──
|
||||
|
||||
/** 「加载更多」:续拉下一页(失败/无新增静默,按钮 loading 态由 convListPaging 驱动) */
|
||||
function onLoadMore(): void {
|
||||
void loadMoreConversations()
|
||||
}
|
||||
|
||||
/** G5 恢复动作透传主文件(主文件持 recoveringId 防重与 toast 反馈),模板零改动 */
|
||||
function onRecover(id: string) {
|
||||
emit('recover', id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 点击外部关闭更多操作菜单
|
||||
document.addEventListener('click', onActionsOutsideClick)
|
||||
// 操作菜单 Esc 关闭(P2-6,与外部点击同生命周期)
|
||||
document.addEventListener('keydown', onActionsEscClose)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onActionsOutsideClick)
|
||||
document.removeEventListener('keydown', onActionsEscClose)
|
||||
// 释放标题淡入计时器
|
||||
if (_titleFlashTimer) { clearTimeout(_titleFlashTimer); _titleFlashTimer = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ═══ 会话列表体 scoped 样式(拆自 ConversationSidebar.vue,零视觉变更;列表滚动容器
|
||||
.ai-conv-sidebar/.ai-conv-list 等壳层样式留主文件,本组件只持项/分组/菜单/徽标层) ═══ -->
|
||||
<style scoped>
|
||||
/* wave2 u-agent 范式: 分组头 10px uppercase + letter-spacing 1px + dim */
|
||||
.ai-conv-group-title {
|
||||
padding: 10px 8px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--df-text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.ai-conv-group-title--fold {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.ai-conv-group-title--fold:hover {
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-conv-group-arrow {
|
||||
display: inline-block;
|
||||
transition: transform 0.15s;
|
||||
font-size: 9px;
|
||||
}
|
||||
.ai-conv-group-arrow.is-folded {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
.ai-conv-group-arrow:not(.is-folded) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.ai-conv-group-count {
|
||||
margin-left: 2px;
|
||||
font-weight: 400;
|
||||
font-family: var(--df-font-mono); /* mono 计数(df-mono 范式),upper/lsp 回退保证数字不散 */
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ai-conv-group-empty {
|
||||
padding: 2px 8px 4px 20px;
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
/* wave2 u-agent .cv 范式: 单行条目,8px 级密度;hover/active 同走灰阶卡底(GitHub 两级同底、
|
||||
active 由左 2px accent 实线区分),深浅主题同 token 自动适配 */
|
||||
.ai-conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-radius: var(--df-radius);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-conv-item:hover {
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
.ai-conv-item--active {
|
||||
background: var(--df-bg-card-hover);
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
|
||||
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
|
||||
.ai-conv-item--switching {
|
||||
background: var(--df-bg-card-hover);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ai-conv-item--archived .ai-conv-item-title {
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* wave2: 行内布局对齐 .cv-top([状态点][标题省略][时间]) */
|
||||
.ai-conv-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-conv-item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
/* UX-2025-20 §7.4: AI 生成标题淡入(0.3s opacity + transform 过渡) */
|
||||
.ai-conv-item-title--flash {
|
||||
animation: ai-title-flash 0.3s var(--df-ease);
|
||||
}
|
||||
.ai-conv-item-title-badge {
|
||||
flex-shrink: 0;
|
||||
color: var(--df-success);
|
||||
}
|
||||
@keyframes ai-title-flash {
|
||||
from { opacity: 0; transform: translateX(-3px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.ai-conv-item-edit {
|
||||
flex: 1 1 0; /* wave2: main 已行化,输入框按 flex 填充(min-width:0 防 input 固有宽撑破窄侧栏) */
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
font-size: 11px;
|
||||
color: var(--df-text);
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--df-accent);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||
.ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
/* wave2 u-agent 范式: 时间/计数元数据 mono 10px dim(.df-mono 同款视觉) */
|
||||
.ai-conv-item-time {
|
||||
flex: none;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* wave2 u-agent .st 状态点变体: 公共 .df-dot/.df-dot--run/.df-dot--stop 管造型与绿/灰底,
|
||||
此处只补动效与缺位色(全 token): run 绿+glow+脉冲(global df-pulse)、danger 待审批红、paused 停靠琥珀 */
|
||||
.ai-conv-status--run { box-shadow: 0 0 6px var(--df-success); animation: df-pulse 1.5s infinite; }
|
||||
.ai-conv-status--danger { background: var(--df-danger); }
|
||||
.ai-conv-status--paused { background: var(--df-warning); }
|
||||
@keyframes ai-conv-gen-pulse {
|
||||
/* 供 .ai-conv-recover-dot 分组级脉冲用(原行内 gen-dot 已由状态点接管) */
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
/* G5 可恢复分组(parked 会话,amber 系提示「需注意/可继续」语义;⏸ 徽标+分组标题小圆点) */
|
||||
.ai-conv-recover-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
background: var(--df-warning);
|
||||
animation: ai-conv-gen-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.ai-conv-item--recoverable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-conv-recover-badge {
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--df-warning);
|
||||
cursor: help;
|
||||
}
|
||||
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
|
||||
.ai-conv-approve-badge { flex-shrink: 0;
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
padding: 0 3px;
|
||||
border-radius: 6px;
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 12px;
|
||||
text-align: center;
|
||||
cursor: help;
|
||||
}
|
||||
/* 无人值守边界徽标(盾形,accent 色系与「已授权」语义一致;纯标记不计数) */
|
||||
.ai-conv-boundary-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
|
||||
color: var(--df-accent);
|
||||
cursor: help;
|
||||
}
|
||||
.ai-conv-item-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-conv-item:hover .ai-conv-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
/* UX走查 P2-6: 键盘 Tab 进会话项(项自身 tabindex=0,或焦点在项内按钮/输入框)时同样浮出
|
||||
操作按钮——原仅 :hover 渲染,键盘用户永远够不到「...」菜单触发点 */
|
||||
.ai-conv-item:focus-within .ai-conv-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
/* 菜单展开时强制 actions 可见(鼠标移到菜单上 item 不再 hover,防菜单消失) */
|
||||
.ai-conv-item:has(.ai-conv-actions-menu) .ai-conv-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
.ai-conv-item-act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-conv-item-act:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
/* 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。
|
||||
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。
|
||||
wave2: 选中态同占左缘,置顶线降为 45% 透明 accent 让位,选中实线优先 */
|
||||
.ai-conv-item--pinned {
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--df-accent) 45%, transparent);
|
||||
}
|
||||
.ai-conv-item--active.ai-conv-item--pinned {
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
/* ── 「更多」操作菜单(置顶/归档/删除)──
|
||||
「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。
|
||||
菜单绝对定位浮于 item 下方右侧,窄侧栏避免溢出。 */
|
||||
.ai-conv-item-more {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-conv-actions-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 2px;
|
||||
z-index: 20;
|
||||
min-width: 104px;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: var(--df-bg);
|
||||
border: 1px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.ai-conv-actions-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--df-text);
|
||||
cursor: pointer;
|
||||
border-radius: var(--df-radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-conv-actions-opt:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
}
|
||||
.ai-conv-actions-opt--danger:hover {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-conv-empty {
|
||||
padding: 16px 8px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* F4-1: 列表底部「加载更多」(轻量文字行,hover 同列表项灰阶卡底范式) */
|
||||
.ai-conv-load-more {
|
||||
margin: 4px 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.ai-conv-load-more:hover {
|
||||
background: var(--df-bg-card-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
</style>
|
||||
@@ -114,7 +114,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="eval-report eval-report--muted">
|
||||
<button class="btn-evaluate" :disabled="evaluating" @click="$emit('evaluate')">
|
||||
<button class="btn btn-primary" :disabled="evaluating" @click="$emit('evaluate')">
|
||||
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
|
||||
</button>
|
||||
<!-- P1-⑥ 重试样式:错误提示分两行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) -->
|
||||
@@ -690,7 +690,7 @@ watch(() => props.idea.id, loadHistory)
|
||||
gap: 12px;
|
||||
margin-bottom: var(--df-gap-head);
|
||||
}
|
||||
.detail-title { font-size: 20px; font-weight: 500; color: var(--df-text); flex: 1; min-width: 0; }
|
||||
.detail-title { font-size: var(--df-fs-title); font-weight: 500; color: var(--df-text); flex: 1; min-width: 0; }
|
||||
/* P0-② 标题行右侧操作组:立项按钮 + 状态 badge 一行排布 */
|
||||
.detail-header-actions {
|
||||
display: flex;
|
||||
@@ -752,7 +752,7 @@ watch(() => props.idea.id, loadHistory)
|
||||
padding: 14px 16px;
|
||||
background: var(--df-accent-bg);
|
||||
border-left: 3px solid var(--df-accent);
|
||||
border-radius: 0 8px 8px 0;
|
||||
border-radius: 0 var(--df-radius-md) var(--df-radius-md) 0;
|
||||
}
|
||||
/* 弱化态(空数据/加载中占位,原 inline opacity:0.5 统一为 class) */
|
||||
.eval-report--muted { opacity: 0.5; }
|
||||
@@ -764,47 +764,48 @@ watch(() => props.idea.id, loadHistory)
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 13px;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.debate-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 13px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.debate-column {
|
||||
padding: 1rem;
|
||||
padding: 13px;
|
||||
border-radius: var(--df-radius);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 正反方论据栏底/边走语义 token(双主题自适应) */
|
||||
.debate-column.positive {
|
||||
background: rgba(61, 219, 160, 0.08);
|
||||
border: 0.5px solid rgba(61, 219, 160, 0.2);
|
||||
background: var(--df-success-bg);
|
||||
border: 0.5px solid var(--df-success-bd);
|
||||
}
|
||||
|
||||
.debate-column.negative {
|
||||
background: rgba(240, 101, 101, 0.08);
|
||||
border: 0.5px solid rgba(240, 101, 101, 0.2);
|
||||
background: var(--df-danger-bg);
|
||||
border: 0.5px solid var(--df-danger-bd);
|
||||
}
|
||||
|
||||
.debate-column h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.5rem;
|
||||
margin: 0 0 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.confidence-bar {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--df-radius-xs);
|
||||
margin-bottom: 0.25rem;
|
||||
margin-bottom: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -820,13 +821,13 @@ watch(() => props.idea.id, loadHistory)
|
||||
.confidence-text {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.debate-column .thesis {
|
||||
font-size: 12px;
|
||||
color: var(--df-text);
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 7px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -839,16 +840,16 @@ watch(() => props.idea.id, loadHistory)
|
||||
.debate-column li {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
margin-bottom: 3px;
|
||||
padding-left: 7px;
|
||||
}
|
||||
|
||||
.analyst-conclusion {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 13px;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
/* assessment-badge 系列已提取到 styles/components.css 全局 */
|
||||
@@ -857,39 +858,39 @@ watch(() => props.idea.id, loadHistory)
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
margin: 0.5rem 0;
|
||||
margin: 7px 0;
|
||||
}
|
||||
|
||||
.net-sentiment {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
font-size: var(--df-fs-ctrl);
|
||||
padding: var(--df-pad-badge);
|
||||
border-radius: var(--df-radius-xs);
|
||||
display: inline-block;
|
||||
margin: 0.5rem 0;
|
||||
margin: 7px 0;
|
||||
}
|
||||
|
||||
.net-sentiment.positive { background: rgba(61, 219, 160, 0.15); color: var(--df-success); }
|
||||
.net-sentiment.negative { background: rgba(240, 101, 101, 0.15); color: var(--df-danger); }
|
||||
.net-sentiment.positive { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.net-sentiment.negative { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.net-sentiment.neutral { background: var(--df-bg-raised); color: var(--df-text-secondary); }
|
||||
|
||||
.summary {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0.5rem 0;
|
||||
margin: 7px 0;
|
||||
}
|
||||
|
||||
.action-recommendations {
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
padding: 1rem;
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.action-recommendations h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.5rem;
|
||||
margin: 0 0 7px;
|
||||
}
|
||||
|
||||
.action-recommendations ul {
|
||||
@@ -901,12 +902,12 @@ watch(() => props.idea.id, loadHistory)
|
||||
.action-recommendations li {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
margin-bottom: 3px;
|
||||
padding-left: 7px;
|
||||
}
|
||||
|
||||
/* P0-① 详细论据折叠切换条 */
|
||||
.debate-toggle-row { margin-bottom: 0.75rem; }
|
||||
.debate-toggle-row { margin-bottom: 10px; }
|
||||
.debate-toggle-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -924,23 +925,10 @@ watch(() => props.idea.id, loadHistory)
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.debate-toggle-arrow.is-open { transform: rotate(180deg); }
|
||||
.debate-detail { margin-top: 0.5rem; }
|
||||
.debate-detail .debate-container { margin-bottom: 0.75rem; }
|
||||
.debate-detail { margin-top: 7px; }
|
||||
.debate-detail .debate-container { margin-bottom: 10px; }
|
||||
|
||||
.btn-evaluate {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-evaluate:hover {
|
||||
background: var(--df-accent-hover);
|
||||
}
|
||||
/* 「开始评估」按钮并轨全局 .btn .btn-primary(30px 主档),本地副本删除 */
|
||||
|
||||
/* P1-⑥ 重试样式:错误提示与重试按钮分行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) */
|
||||
.eval-error {
|
||||
@@ -952,11 +940,11 @@ watch(() => props.idea.id, loadHistory)
|
||||
.btn-retry { margin-top: 4px; }
|
||||
|
||||
.eval-mode-tag {
|
||||
font-size: 11px;
|
||||
font-size: var(--df-fs-aux);
|
||||
font-weight: 400;
|
||||
padding: 1px 8px;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(255, 217, 61, 0.15);
|
||||
background: var(--df-warning-bg);
|
||||
color: var(--df-warning);
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
@@ -991,26 +979,26 @@ watch(() => props.idea.id, loadHistory)
|
||||
font-size: 12px;
|
||||
}
|
||||
.version-badge {
|
||||
font-size: 11px;
|
||||
font-size: var(--df-fs-aux);
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
padding: var(--df-pad-badge);
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: var(--df-accent-soft);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.latest-tag {
|
||||
font-size: 10px;
|
||||
font-size: var(--df-fs-meta);
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(100, 255, 218, 0.15);
|
||||
background: var(--df-success-bg);
|
||||
color: var(--df-success);
|
||||
}
|
||||
.history-eval-mode {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
font-size: var(--df-fs-aux);
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(255, 217, 61, 0.12);
|
||||
background: var(--df-warning-bg);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.history-score {
|
||||
@@ -1046,7 +1034,7 @@ watch(() => props.idea.id, loadHistory)
|
||||
/* ===== 标签 ===== */
|
||||
.tag-list { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tag {
|
||||
font-size: 12px; padding: 4px 10px;
|
||||
font-size: var(--df-fs-ctrl); padding: var(--df-pad-badge);
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
@@ -1055,7 +1043,7 @@ watch(() => props.idea.id, loadHistory)
|
||||
/* ===== 关联灵感 ===== */
|
||||
.related-list { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.related-chip {
|
||||
font-size: 12px; padding: 4px 10px;
|
||||
font-size: var(--df-fs-ctrl); padding: var(--df-pad-badge);
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
|
||||
@@ -400,13 +400,14 @@ watch(renderedContent, () => {
|
||||
|
||||
/* 状态徽章 / 置信度 */
|
||||
.kn-card-status { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: var(--df-radius-xs); white-space: nowrap; }
|
||||
.st-candidate { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.st-pending_review { background: rgba(108,159,255,0.15); color: var(--df-info); }
|
||||
.st-published { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.st-archived { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.conf-high { background: rgba(100,255,218,0.15); color: var(--df-success); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
.conf-medium { background: rgba(255,217,61,0.15); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
.conf-low { background: rgba(255,107,107,0.15); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
/* 状态/置信度徽章底统一走语义 token(双主题自适应,与 Knowledge.vue 同规) */
|
||||
.st-candidate { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.st-pending_review { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.st-published { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.st-archived { background: var(--df-neutral-bg); color: var(--df-text-dim); }
|
||||
.conf-high { background: var(--df-success-bg); color: var(--df-success); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
.conf-medium { background: var(--df-warning-bg); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
.conf-low { background: var(--df-danger-bg); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
|
||||
/* 溯源 */
|
||||
.section-title { font-size: 13px; font-weight: 500; color: var(--df-text); margin-bottom: 10px; }
|
||||
@@ -439,7 +440,7 @@ watch(renderedContent, () => {
|
||||
}
|
||||
.detail-content.content-collapsed::after {
|
||||
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 28px;
|
||||
background: linear-gradient(to bottom, transparent, var(--df-bg-panel, var(--df-bg-card)));
|
||||
background: linear-gradient(to bottom, transparent, var(--df-bg-card));
|
||||
pointer-events: none;
|
||||
}
|
||||
.detail-content { position: relative; }
|
||||
|
||||
@@ -94,7 +94,7 @@ const knowledgeStore = useKnowledgeStore()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// ── 侧边栏收缩状态(持久化到 appSettings,小窗口自动收缩) ──
|
||||
// 展开 192px(文字+图标) / 收缩 56px(仅图标,hover tooltip 显示文字)
|
||||
// 展开 220px(文字+图标) / 收缩 56px(仅图标,hover tooltip 显示文字)
|
||||
// 优先级:用户手动设置 > 小窗口自动收缩(≤768px)
|
||||
const COLLAPSE_THRESHOLD = 768
|
||||
// useSetting 响应式读(loadAll 异步填充缓存后 watch 自动同步刷新)——
|
||||
@@ -172,8 +172,8 @@ const secondaryNav = [
|
||||
|
||||
<style scoped>
|
||||
.df-sidebar {
|
||||
width: 192px;
|
||||
min-width: 192px;
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
background: var(--df-sidebar-bg);
|
||||
border-right: 0.5px solid var(--df-border);
|
||||
display: flex;
|
||||
@@ -219,8 +219,8 @@ const secondaryNav = [
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255,255,255,0.04);
|
||||
padding: 1px 6px;
|
||||
background: var(--df-hover);
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@ const secondaryNav = [
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius);
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
@@ -360,8 +360,8 @@ const secondaryNav = [
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255,255,255,0.04);
|
||||
padding: 1px 5px;
|
||||
background: var(--df-hover);
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ async function onInstall() {
|
||||
color: var(--df-accent);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
.update-badge-btn:hover {
|
||||
@@ -193,7 +193,7 @@ async function onInstall() {
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
background: var(--df-bg-card);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
}
|
||||
.update-pop-head {
|
||||
font-size: 13px;
|
||||
@@ -213,11 +213,12 @@ async function onInstall() {
|
||||
.update-pop-progress {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
/* 进度条轨道:原 --df-bg-hover 为未定义幽灵 token(靠回退侥幸渲染),改中性底 */
|
||||
.update-pop-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--df-bg-hover, rgba(128, 128, 128, 0.2));
|
||||
background: var(--df-neutral-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.update-pop-fill {
|
||||
@@ -234,10 +235,10 @@ async function onInstall() {
|
||||
.update-pop-error {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--df-radius-md, 6px);
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
border-radius: var(--df-radius-md);
|
||||
background: var(--df-danger-bg);
|
||||
font-size: 12px;
|
||||
color: var(--df-danger, #dc3545);
|
||||
color: var(--df-danger);
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
<!-- M31: 审批失败反馈(store approveHumanApproval 内部 catch 吞错,失败时 pendingApproval 不清空)
|
||||
对话框保持开启,用户可看到错误并重试 -->
|
||||
<div v-if="toast.visible" class="ad-toast" :class="'ad-toast-' + toast.type">{{ toast.msg }}</div>
|
||||
<div v-if="toast.visible" class="df-toast df-toast--bottom" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -162,19 +162,7 @@ watch(() => pendingApproval.value, (newApproval) => {
|
||||
</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; }
|
||||
/* M31: 审批失败 toast(样式走全局 .df-toast 族+--bottom 底部变体;
|
||||
scoped 仅保留 z-index 覆写——本组件浮在弹层(z≈10000)之上,toast 须更高) */
|
||||
.df-toast { z-index: 10001; }
|
||||
</style>
|
||||
|
||||
@@ -442,7 +442,7 @@ function bindEdgeDeleteButtons() {
|
||||
graph.on('edge:mouseleave', onEdgeLeave)
|
||||
}
|
||||
|
||||
/** 依赖类型 → 边颜色 */
|
||||
/** 依赖类型 → 边颜色(X6 画布演示色板·品牌/演示豁免,不入 token) */
|
||||
function depTypeColor(depType: string): string {
|
||||
switch (depType) {
|
||||
case 'api': return '#5378e8'
|
||||
@@ -490,7 +490,7 @@ onBeforeUnmount(() => {
|
||||
height: 100%;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg, 8px);
|
||||
border-radius: var(--df-radius-lg);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
@@ -593,11 +593,12 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
box-shadow: var(--df-shadow-md);
|
||||
transition: background 0.15s;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* hover 由 danger 派生加深,替换孤色 */
|
||||
.dep-edge-delete-btn:hover {
|
||||
background: #c24040;
|
||||
background: color-mix(in srgb, var(--df-danger) 75%, #000);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -488,11 +488,8 @@ onUnmounted(() => { _unlistenDataChanged?.() })
|
||||
async function onScanSubmodules() {
|
||||
moduleDropdownOpen.value = false
|
||||
try {
|
||||
const n = await moduleApi.scanProjectModules(props.projectId)
|
||||
await moduleApi.scanProjectModules(props.projectId)
|
||||
await loadModules()
|
||||
if (n > 0) {
|
||||
console.log('[FileExplorer] 扫描到新工程:', n)
|
||||
}
|
||||
} catch (e) {
|
||||
Message.error(t('common.unknownError'))
|
||||
console.error('[FileExplorer] 扫描子仓库失败:', e)
|
||||
@@ -594,7 +591,7 @@ async function onRemoveModule() {
|
||||
max-height: 100%;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg, 8px);
|
||||
border-radius: var(--df-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -625,7 +622,7 @@ async function onRemoveModule() {
|
||||
padding: 5px 10px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border-radius: var(--df-radius-sm);
|
||||
color: var(--df-text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -651,12 +648,11 @@ async function onRemoveModule() {
|
||||
max-width: 320px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.25);
|
||||
border-radius: var(--df-radius-sm);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
z-index: 20;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.module-dropdown-item {
|
||||
@@ -665,10 +661,10 @@ async function onRemoveModule() {
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 0.5px solid rgba(255,255,255,0.04);
|
||||
border-bottom: 0.5px solid var(--df-hover);
|
||||
}
|
||||
.module-dropdown-item:last-child { border-bottom: none; }
|
||||
.module-dropdown-item:hover { background: rgba(255,255,255,0.04); }
|
||||
.module-dropdown-item:hover { background: var(--df-hover); }
|
||||
.module-dropdown-item.active {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-left: 2px solid var(--df-accent);
|
||||
@@ -778,7 +774,7 @@ async function onRemoveModule() {
|
||||
|
||||
.view-tab:hover {
|
||||
color: var(--df-text);
|
||||
background: rgba(255,255,255,0.03);
|
||||
background: var(--df-hover);
|
||||
}
|
||||
|
||||
.view-tab.active {
|
||||
@@ -819,13 +815,6 @@ async function onRemoveModule() {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.explorer-changes .gc-body {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tree-loading {
|
||||
@@ -867,8 +856,8 @@ async function onRemoveModule() {
|
||||
.tree-loading .spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--df-border, #444);
|
||||
border-top-color: var(--df-accent, #3a8);
|
||||
border: 2px solid var(--df-border);
|
||||
border-top-color: var(--df-accent);
|
||||
border-radius: 50%;
|
||||
animation: df-spin 0.8s linear infinite;
|
||||
}
|
||||
@@ -913,20 +902,14 @@ async function onRemoveModule() {
|
||||
}
|
||||
}
|
||||
|
||||
/* spinner(刷新按钮内) */
|
||||
/* spinner(刷新按钮内);df-spin keyframes 消费 global.css 全局定义(原本地重复定义已删) */
|
||||
.spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1.5px solid var(--df-border, #444);
|
||||
border-top-color: var(--df-accent, #3a8);
|
||||
border: 1.5px solid var(--df-border);
|
||||
border-top-color: var(--df-accent);
|
||||
border-radius: 50%;
|
||||
animation: df-spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes df-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
import { ref, watch, computed, onUnmounted, nextTick } from 'vue'
|
||||
import { moduleApi, type OutlineSymbol } from '@/api/module'
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
import { useMarkdown, useRendered } from '@/composables/useMarkdown'
|
||||
import { useRendered } from '@/composables/useMarkdown'
|
||||
import { escapeHtml } from '@/utils/html'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -184,7 +184,6 @@ const props = defineProps<{
|
||||
externalDiff?: string
|
||||
}>()
|
||||
|
||||
const { } = useMarkdown()
|
||||
const content = ref('')
|
||||
const htmlContent = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -469,12 +468,12 @@ const outlineGroups = computed(() => {
|
||||
.map((g) => ({ key: g, labelKey: GROUP_LABEL_KEY[g], items: buckets.get(g)! }))
|
||||
})
|
||||
|
||||
/** 点击符号滚动到对应行:偏移 = 代码区 padding-top 14px + (line-1) × 行高(12.5px × 1.55)。 */
|
||||
/** 点击符号滚动到对应行:偏移 = 代码区 padding-top 14px + (line-1) × 行高(12px × 1.55)。 */
|
||||
function gotoOutlineLine(line: number) {
|
||||
activeOutlineLine.value = line
|
||||
const el = codeScrollRef.value
|
||||
if (!el) return
|
||||
const lineHeight = 12.5 * 1.55
|
||||
const lineHeight = 12 * 1.55
|
||||
const topPad = 14 // 对齐 .preview-code / .preview-line-numbers 的 padding-top
|
||||
el.scrollTo({ top: Math.max(0, topPad + (line - 1) * lineHeight), behavior: 'smooth' })
|
||||
}
|
||||
@@ -732,11 +731,6 @@ onUnmounted(() => {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.binary-icon {
|
||||
font-size: 40px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.binary-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
@@ -748,7 +742,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: var(--df-hover);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
min-width: 280px;
|
||||
@@ -771,7 +765,7 @@ onUnmounted(() => {
|
||||
.binary-meta-value {
|
||||
color: var(--df-text-secondary);
|
||||
font-family: var(--df-font-mono, Consolas, monospace);
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -785,7 +779,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.preview-error { color: var(--df-danger); }
|
||||
.binary-icon { font-size: 32px; opacity: 0.5; }
|
||||
.binary-icon { font-size: 32px; opacity: 0.5; } /* 2026-10-18 合并重复定义:40px/0.8 旧版被本行完全覆盖,已删 */
|
||||
|
||||
.preview-image {
|
||||
display: flex;
|
||||
@@ -799,7 +793,7 @@ onUnmounted(() => {
|
||||
.preview-image img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
|
||||
/* Markdown 视图:独立滚动容器(preview-body 是 overflow:hidden,不设 overflow 会被裁剪无法滚) */
|
||||
@@ -814,7 +808,7 @@ onUnmounted(() => {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, 'Courier New', monospace);
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
white-space: pre;
|
||||
overflow: visible;
|
||||
@@ -838,7 +832,7 @@ onUnmounted(() => {
|
||||
padding: 14px 0;
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--df-hover);
|
||||
border-right: 0.5px solid var(--df-border);
|
||||
user-select: none;
|
||||
position: sticky;
|
||||
@@ -850,7 +844,7 @@ onUnmounted(() => {
|
||||
.preview-line-num {
|
||||
padding: 0 10px 0 8px;
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.5;
|
||||
@@ -859,24 +853,25 @@ onUnmounted(() => {
|
||||
.preview-line-num.is-active {
|
||||
color: var(--df-accent);
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--df-hover);
|
||||
}
|
||||
|
||||
/* Diff 视图 — 在自身区域滚动,不溢出到父级 */
|
||||
/* Diff 视图 — 在自身区域滚动,不溢出到父级(2026-10-18 合并文末重复定义) */
|
||||
.preview-diff {
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--df-border, #444);
|
||||
border-top-color: var(--df-accent, #3a8);
|
||||
border: 2px solid var(--df-border);
|
||||
border-top-color: var(--df-accent);
|
||||
border-radius: 50%;
|
||||
animation: df-spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
@@ -890,7 +885,7 @@ onUnmounted(() => {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
cursor: pointer;
|
||||
@@ -927,7 +922,7 @@ onUnmounted(() => {
|
||||
padding: 4px 9px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: var(--df-hover);
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -958,7 +953,7 @@ onUnmounted(() => {
|
||||
background: rgba(20, 22, 34, 0.95);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
box-shadow: var(--df-shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1031,7 +1026,7 @@ onUnmounted(() => {
|
||||
gap: 6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
color: var(--df-text-secondary);
|
||||
@@ -1039,7 +1034,7 @@ onUnmounted(() => {
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.outline-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--df-text); }
|
||||
.outline-item:hover { background: var(--df-hover); color: var(--df-text); }
|
||||
|
||||
.outline-item.is-active {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
@@ -1047,7 +1042,7 @@ onUnmounted(() => {
|
||||
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||
}
|
||||
|
||||
/* 分组徽章配色:类型蓝 / 函数绿 / 变量橙 / 模块紫 */
|
||||
/* 分组徽章配色:类型蓝 / 函数绿 / 变量橙 / 模块紫(语法色板·演示豁免,不入 token) */
|
||||
.outline-item.grp-type .outline-kind { color: #4FC3F7; }
|
||||
.outline-item.grp-fn .outline-kind { color: #81C784; }
|
||||
.outline-item.grp-var .outline-kind { color: #FFB74D; }
|
||||
@@ -1066,6 +1061,7 @@ onUnmounted(() => {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outline-line {
|
||||
@@ -1076,15 +1072,6 @@ onUnmounted(() => {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Diff 视图 */
|
||||
.preview-diff {
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: flex;
|
||||
padding: 0 8px;
|
||||
@@ -1115,6 +1102,7 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Diff 行底(差异色·演示豁免,与 git 语义 token 并行的语法高亮族) */
|
||||
.diff-add {
|
||||
background: rgba(40, 160, 70, 0.12);
|
||||
}
|
||||
@@ -1137,7 +1125,7 @@ onUnmounted(() => {
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
@keyframes df-spin { to { transform: rotate(360deg); } }
|
||||
/* df-spin keyframes 消费 global.css 全局定义(原本地同名重复定义已删,2026-10-18 整洁波) */
|
||||
</style>
|
||||
|
||||
<!-- highlight.js github-dark 主题(全局,非 scoped) -->
|
||||
|
||||
@@ -384,16 +384,16 @@ watch(
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
border-radius: var(--df-radius-sm);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.tree-row:hover {
|
||||
background: var(--df-bg-card, rgba(255, 255, 255, 0.04));
|
||||
background: var(--df-bg-card);
|
||||
}
|
||||
|
||||
.tree-row-file.is-selected {
|
||||
background: var(--df-accent, #3a8);
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -415,8 +415,8 @@ watch(
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 1.5px solid var(--df-border, #444);
|
||||
border-top-color: var(--df-accent, #3a8);
|
||||
border: 1.5px solid var(--df-border);
|
||||
border-top-color: var(--df-accent);
|
||||
border-radius: 50%;
|
||||
animation: df-spin 0.8s linear infinite;
|
||||
vertical-align: middle;
|
||||
@@ -544,19 +544,14 @@ watch(
|
||||
color: #e05050;
|
||||
}
|
||||
|
||||
/* spinner;df-spin keyframes 消费 global.css 全局定义(原本地重复定义已删,2026-10-18 整洁波) */
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid var(--df-border, #444);
|
||||
border-top-color: var(--df-accent, #3a8);
|
||||
border: 1.5px solid var(--df-border);
|
||||
border-top-color: var(--df-accent);
|
||||
border-radius: 50%;
|
||||
animation: df-spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes df-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -422,7 +422,6 @@ async function loadStatus() {
|
||||
const showBranchPicker = ref(false)
|
||||
const branchList = ref<{ name: string; is_current: boolean }[]>([])
|
||||
const branchLoading = ref(false)
|
||||
const currentBranch = ref('')
|
||||
|
||||
async function onSwitchBranch() {
|
||||
if (showBranchPicker.value) {
|
||||
@@ -435,7 +434,6 @@ async function onSwitchBranch() {
|
||||
try {
|
||||
const res = await moduleApi.listBranches(props.moduleId)
|
||||
branchList.value = res.branches
|
||||
currentBranch.value = res.current
|
||||
} catch (e) {
|
||||
branchList.value = []
|
||||
actionError.value = String(e)
|
||||
@@ -607,7 +605,7 @@ watch(() => props.refreshKey, () => {
|
||||
}
|
||||
.branch-switch-btn:hover {
|
||||
color: var(--df-text);
|
||||
background: rgba(255,255,255,0.05);
|
||||
background: var(--df-hover);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
@@ -620,14 +618,14 @@ watch(() => props.refreshKey, () => {
|
||||
.gc-tab.active { color: var(--df-text); border-bottom-color: var(--df-accent); }
|
||||
.gc-tab:hover { color: var(--df-text); }
|
||||
|
||||
.gc-body { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; scrollbar-width: thin; }
|
||||
.gc-body { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; }
|
||||
.gc-changes { min-height: 0; }
|
||||
|
||||
/* 分组 */
|
||||
.gc-group-header {
|
||||
padding: 6px 14px; font-size: 11px; font-weight: 600;
|
||||
color: var(--df-text-dim); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
background: rgba(255,255,255,0.02); border-bottom: 0.5px solid var(--df-border);
|
||||
background: var(--df-hover); border-bottom: 0.5px solid var(--df-border);
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
}
|
||||
|
||||
@@ -645,8 +643,8 @@ watch(() => props.refreshKey, () => {
|
||||
.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;
|
||||
background: var(--df-hover); border: 0.5px solid var(--df-border);
|
||||
border-radius: 4px; height: var(--df-h-input); padding: 0 8px;
|
||||
color: var(--df-text); font-size: 12px; outline: none;
|
||||
}
|
||||
.gc-search-input:focus { border-color: var(--df-accent); }
|
||||
@@ -658,13 +656,13 @@ watch(() => props.refreshKey, () => {
|
||||
.gc-search-clear:hover { color: var(--df-text); }
|
||||
|
||||
/* 提交列表 */
|
||||
.gc-history-list { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; }
|
||||
.gc-history-list { flex: 1; min-height: 0; overflow-y: auto; }
|
||||
.gc-commit-row {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
padding: 6px 14px; border-bottom: 0.5px solid rgba(255,255,255,0.03);
|
||||
padding: 6px 14px; border-bottom: 0.5px solid var(--df-hover);
|
||||
cursor: pointer; transition: background 0.1s;
|
||||
}
|
||||
.gc-commit-row:hover { background: rgba(255,255,255,0.04); }
|
||||
.gc-commit-row:hover { background: var(--df-hover); }
|
||||
.gc-commit-row.selected { background: rgba(255,255,255,0.06); }
|
||||
.gc-commit-line1 {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
@@ -696,7 +694,7 @@ watch(() => props.refreshKey, () => {
|
||||
}
|
||||
.gc-commit-detail-header {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 8px 14px; background: rgba(255,255,255,0.03);
|
||||
padding: 8px 14px; background: var(--df-hover);
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.gc-commit-detail-hash {
|
||||
@@ -720,14 +718,14 @@ watch(() => props.refreshKey, () => {
|
||||
/* 提交的文件列表 */
|
||||
.gc-commit-files {
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
overflow-y: auto; max-height: 140px; scrollbar-width: thin; flex-shrink: 0;
|
||||
overflow-y: auto; max-height: 140px; flex-shrink: 0;
|
||||
}
|
||||
.gc-commit-file-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 5px 14px 5px 28px; cursor: pointer; transition: background 0.1s;
|
||||
border-bottom: 0.5px solid rgba(255,255,255,0.02);
|
||||
border-bottom: 0.5px solid var(--df-hover);
|
||||
}
|
||||
.gc-commit-file-row:hover { background: rgba(255,255,255,0.04); }
|
||||
.gc-commit-file-row:hover { background: var(--df-hover); }
|
||||
.gc-commit-file-row.active { background: rgba(255,255,255,0.06); }
|
||||
|
||||
/* 滑出动画 */
|
||||
@@ -757,7 +755,7 @@ watch(() => props.refreshKey, () => {
|
||||
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:hover { background: var(--df-hover); }
|
||||
.gc-file-row.selected { background: rgba(255,255,255,0.06); }
|
||||
|
||||
/* 加载更多 */
|
||||
@@ -766,7 +764,7 @@ watch(() => props.refreshKey, () => {
|
||||
color: var(--df-accent); font-size: 12px;
|
||||
transition: background 0.1s; flex-shrink: 0;
|
||||
}
|
||||
.gc-load-more:hover { background: rgba(255,255,255,0.04); }
|
||||
.gc-load-more:hover { background: var(--df-hover); }
|
||||
|
||||
/* 分支列表弹窗 */
|
||||
.gc-branch-picker {
|
||||
@@ -790,7 +788,6 @@ watch(() => props.refreshKey, () => {
|
||||
.gc-branch-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.gc-branch-item {
|
||||
display: flex;
|
||||
@@ -827,13 +824,9 @@ watch(() => props.refreshKey, () => {
|
||||
.gc-skeleton-row {
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
|
||||
background: linear-gradient(90deg, var(--df-hover) 0%, rgba(255,255,255,0.08) 50%, var(--df-hover) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: gc-shimmer 1.5s infinite linear;
|
||||
}
|
||||
@keyframes gc-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
animation: df-shimmer 1.5s infinite linear; /* 消费 global.css df-shimmer(原本地 gc-shimmer 重复定义已删) */
|
||||
}
|
||||
|
||||
/* 通用 */
|
||||
@@ -853,24 +846,5 @@ watch(() => props.refreshKey, () => {
|
||||
border: 2px solid var(--df-border); border-top-color: var(--df-accent);
|
||||
border-radius: 50%; animation: df-spin 0.8s linear infinite; display: inline-block;
|
||||
}
|
||||
@keyframes df-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 滚动条对齐全局(变更/历史提交列表与全局统一样式) */
|
||||
.git-changes ::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.git-changes ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
.git-changes ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.git-changes ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
/* df-spin keyframes 消费 global.css 全局定义(原本地同名重复定义已删,2026-10-18 整洁波) */
|
||||
</style>
|
||||
|
||||
@@ -242,13 +242,9 @@ onBeforeUnmount(() => window.removeEventListener('resize', onWinResize))
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
|
||||
background: linear-gradient(90deg, var(--df-hover) 0%, rgba(255,255,255,0.08) 50%, var(--df-hover) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: pc-shimmer 1.5s infinite linear;
|
||||
}
|
||||
@keyframes pc-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
animation: df-shimmer 1.5s infinite linear; /* 消费 global.css df-shimmer(原本地 pc-shimmer 重复定义已删) */
|
||||
}
|
||||
|
||||
.card-top-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
@@ -91,8 +91,8 @@
|
||||
<div class="update-progress-text">{{ progressText }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="installError" class="update-error">{{ installError }}</div>
|
||||
<!-- 错误提示:视觉走全局 .df-note--danger -->
|
||||
<div v-if="installError" class="df-note df-note--danger update-error">{{ installError }}</div>
|
||||
|
||||
<div class="update-card-actions">
|
||||
<button
|
||||
@@ -373,11 +373,12 @@ async function onIntervalChange() {
|
||||
.update-progress {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
/* 进度条轨道:原 --df-bg-hover 为未定义幽灵 token(靠回退侥幸渲染),改中性底 */
|
||||
.update-progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--df-bg-hover, rgba(128, 128, 128, 0.2));
|
||||
background: var(--df-neutral-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.update-progress-fill {
|
||||
@@ -391,14 +392,9 @@ async function onIntervalChange() {
|
||||
font-family: var(--df-font-mono, monospace);
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* 安装错误提示 */
|
||||
/* 安装错误提示:底色/边/圆角/字号走全局 .df-note--danger(token 直引无回退),此处仅留排版 */
|
||||
.update-error {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--df-radius-md, 6px);
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
font-size: 12px;
|
||||
color: var(--df-danger, #dc3545);
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -86,11 +86,12 @@
|
||||
</div>
|
||||
|
||||
<!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死「删除」不适合「确认接管」) -->
|
||||
<Transition name="confirm">
|
||||
<!-- 按钮行/过渡消费 global.css .df-confirm-actions + df-confirm(2026-10-18 整洁波并轨) -->
|
||||
<Transition name="df-confirm">
|
||||
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
|
||||
<div class="confirm-box">
|
||||
<div class="confirm-msg">{{ confirmState.msg }}</div>
|
||||
<div class="confirm-actions">
|
||||
<div class="df-confirm-actions">
|
||||
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
|
||||
</div>
|
||||
@@ -252,8 +253,9 @@ function onApprovalTimeoutChange() {
|
||||
.setting-select/.setting-textarea + SettingRow(封装 .setting-row/.setting-info/.setting-label/
|
||||
.setting-desc/.setting-control)。本 Section 仅保留 all 二次确认弹层特有 scoped 样式。
|
||||
|
||||
F-#97 all 二次确认弹层:复用 Settings.vue 壳层 confirm 弹层同款样式(mask/box/msg/
|
||||
actions/transition),此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
|
||||
F-#97 all 二次确认弹层:mask/box/msg 本地特有样式;actions 按钮行与过渡已并轨
|
||||
global.css .df-confirm-actions + df-confirm 过渡(2026-10-18 整洁波双轨合一)。
|
||||
此处独立持有 useConfirm 状态(不与壳层共用,壳层按钮写死「删除」)。 */
|
||||
.data-dir-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -284,8 +286,6 @@ function onApprovalTimeoutChange() {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.prompt-row .setting-info { align-items: flex-start; }
|
||||
.prompt-row .setting-control { align-items: flex-start; }
|
||||
/* select 类设置行(自定义布局,不复用 SettingRow):label 与 select 同一行靠左顶对齐,
|
||||
desc 换行到整行下方。
|
||||
┌───────────────────────────────┐
|
||||
@@ -326,12 +326,7 @@ function onApprovalTimeoutChange() {
|
||||
padding: 20px;
|
||||
min-width: 280px;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
box-shadow: var(--df-shadow-md);
|
||||
}
|
||||
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
|
||||
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.btn-danger { background: var(--df-danger); color: #fff; }
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
|
||||
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -87,12 +87,13 @@
|
||||
</section>
|
||||
|
||||
<!-- 「信任所有目录」二次确认弹层(同 AdvancedSection 的 all 接管确认:自渲染独立弹层,
|
||||
msg 拼标题+正文,confirm-msg 用 pre-line 保留换行) -->
|
||||
<Transition name="confirm">
|
||||
msg 拼标题+正文,confirm-msg 用 pre-line 保留换行;
|
||||
按钮行/过渡消费 global.css .df-confirm-actions + df-confirm,2026-10-18 整洁波并轨) -->
|
||||
<Transition name="df-confirm">
|
||||
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
|
||||
<div class="confirm-box">
|
||||
<div class="confirm-msg">{{ confirmState.msg }}</div>
|
||||
<div class="confirm-actions">
|
||||
<div class="df-confirm-actions">
|
||||
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
|
||||
</div>
|
||||
@@ -296,8 +297,10 @@ onMounted(load)
|
||||
.script-safety-desc {
|
||||
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
|
||||
}
|
||||
/* 名单输入定宽 280px + max-width 窄窗收缩(原 min-width:280px 在窄列不可收缩致横向溢出) */
|
||||
.script-safety-input {
|
||||
min-width: 280px;
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
font-family: var(--df-font-mono, monospace);
|
||||
}
|
||||
.script-safety-note {
|
||||
@@ -321,12 +324,7 @@ onMounted(load)
|
||||
padding: 20px;
|
||||
min-width: 280px;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
box-shadow: var(--df-shadow-md);
|
||||
}
|
||||
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
|
||||
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.btn-danger { background: var(--df-danger); color: #fff; }
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
|
||||
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -263,13 +263,10 @@ onMounted(loadConnections)
|
||||
<style scoped>
|
||||
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/setting-input
|
||||
已迁移到 settings.css 全局。
|
||||
本 scoped 仅保留 ConnectionPanel 特有样式:connection-card/conn 系列/type 系列/form 系列/
|
||||
btn-link 及 form-field 内输入控件布局覆盖等。 */
|
||||
本 scoped 仅保留 ConnectionPanel 特有样式:connection-card/conn 系列/type 系列/form 系列
|
||||
及 form-field 内输入控件布局覆盖等。 */
|
||||
|
||||
/* ===== 链接按钮(本面板特有,未进全局) ===== */
|
||||
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
|
||||
.btn-link:hover { text-decoration: underline; }
|
||||
.btn-link-danger { color: var(--df-danger); }
|
||||
/* 链接按钮 .btn-link/.btn-link-danger 已并轨 global.css(样式大重构去重) */
|
||||
|
||||
/* ===== 标题组(panel-header 内 标题+badge 一组,与右侧按钮 space-between) ===== */
|
||||
.panel-title-group { display: flex; align-items: center; gap: 8px; }
|
||||
@@ -307,10 +304,10 @@ onMounted(loadConnections)
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.type-mysql { background: rgba(100,181,246,0.15); color: var(--df-info); }
|
||||
.type-mysql { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.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); }
|
||||
.type-redis { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.type-mongo { background: var(--df-success-bg); color: var(--df-success); }
|
||||
|
||||
.conn-host, .conn-user {
|
||||
font-size: 12px;
|
||||
@@ -318,7 +315,7 @@ onMounted(loadConnections)
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.conn-user {
|
||||
background: rgba(90,99,128,0.2);
|
||||
background: var(--df-neutral-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
|
||||
@@ -142,9 +142,11 @@ async function save() {
|
||||
<style scoped>
|
||||
/* panel/panel-header/btn 系列/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
|
||||
.feishu-settings { display: flex; flex-direction: column; gap: 4px; }
|
||||
.feishu-input { width: 280px; }
|
||||
/* 定宽输入补 max-width,窄窗收缩防横向溢出 */
|
||||
.feishu-input { width: 280px; max-width: 100%; }
|
||||
.feishu-whitelist {
|
||||
width: 320px;
|
||||
max-width: 100%;
|
||||
min-height: 64px;
|
||||
resize: vertical;
|
||||
font-family: var(--df-font-mono, monospace);
|
||||
@@ -153,7 +155,7 @@ async function save() {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--df-text-tertiary, #888);
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
</select>
|
||||
</SettingRow>
|
||||
<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-input" :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; max-width:100%" />
|
||||
</SettingRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -5,55 +5,9 @@
|
||||
<h2>{{ $t('settings.panelAiProvider') }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">{{ $t('settings.addProvider') }}</button>
|
||||
</div>
|
||||
<div class="provider-list" v-if="aiProviders.length > 0">
|
||||
<div class="provider-card" :class="{ 'provider-card--default': p.is_default, 'provider-card--disabled': p.enabled === false }" v-for="p in aiProviders" :key="p.id">
|
||||
<div class="provider-top">
|
||||
<div class="provider-info">
|
||||
<span class="provider-name">{{ p.name }}</span>
|
||||
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</span>
|
||||
<span v-if="p.enabled === false" class="pool-badge">{{ $t('settings.badgePoolDisabled') }}</span>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
|
||||
<button class="btn-link" @click="openProviderForm(p)">{{ $t('common.edit') }}</button>
|
||||
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">{{ $t('common.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-detail">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ $t('settings.detailBaseUrl') }}</span>
|
||||
<span class="detail-value">{{ p.base_url }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ $t('settings.detailModel') }}</span>
|
||||
<span class="detail-value">{{ p.default_model }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
|
||||
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
|
||||
</div>
|
||||
<!-- F-260614-04c 负载均衡池:enabled toggle + weight 即时调 IPC,落库即重建 caps -->
|
||||
<div class="detail-row detail-row--pool">
|
||||
<span class="detail-label" :title="$t('settings.descPoolEnabled')">{{ $t('settings.detailPoolEnabled') }}</span>
|
||||
<div class="detail-value detail-value--control">
|
||||
<label class="toggle toggle--sm" :title="$t('settings.descPoolEnabled')">
|
||||
<input type="checkbox" :checked="p.enabled !== false" @change="onPoolToggle(p, ($event.target as HTMLInputElement).checked)" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-row detail-row--pool" v-if="p.enabled !== false">
|
||||
<span class="detail-label" :title="$t('settings.descPoolWeight')">{{ $t('settings.detailPoolWeight') }}</span>
|
||||
<div class="detail-value detail-value--control">
|
||||
<input type="number" class="setting-input setting-input--sm" min="0" max="100"
|
||||
:value="p.weight ?? 50"
|
||||
@change="onPoolWeightChange(p, ($event.target as HTMLInputElement).valueAsNumber)" />
|
||||
<span class="pool-weight-hint">0-100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 卡片列表 + 负载均衡池控件拆至 provider-panel/provider-list.vue(C4c) -->
|
||||
<ProviderList v-if="aiProviders.length > 0" ref="providerListRef" :providers="aiProviders"
|
||||
@edit="openProviderForm" @delete="deleteProvider" @set-default="setDefaultProvider" @toast="relayToast" />
|
||||
<div class="empty-hint" v-else>{{ $t('settings.emptyProvider') }}</div>
|
||||
</section>
|
||||
|
||||
@@ -127,8 +81,8 @@
|
||||
</div>
|
||||
<!-- P0-2: 保存失败持久错误横幅(常驻表单内,非自隐 toast)。
|
||||
含分类建议:keyring/迁移失败 → 查系统钥匙串权限;其它 → 通用重试。
|
||||
用户看到错误同时表单保持编辑态,改完点保存即可重试。 -->
|
||||
<div v-if="providerForm.saveError" class="save-error-banner">
|
||||
用户看到错误同时表单保持编辑态,改完点保存即可重试。视觉走全局 .df-note--danger。 -->
|
||||
<div v-if="providerForm.saveError" class="df-note df-note--danger">
|
||||
<pre class="save-error-msg">{{ providerForm.saveError }}</pre>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
@@ -140,59 +94,10 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 拉取到的模型列表(F-01 阶段6)═══ -->
|
||||
<!-- ═══ 拉取到的模型列表(F-01 阶段6;表格+标签标注拆至 provider-panel/model-table.vue)═══ -->
|
||||
<div class="form-field form-field--full" v-if="providerForm.models.length > 0">
|
||||
<label class="form-label">{{ $t('settings.modelListTitle', { count: providerForm.models.length }) }}</label>
|
||||
<table class="model-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-enabled">{{ $t('settings.colEnabled') }}</th>
|
||||
<th class="col-model">{{ $t('settings.colModel') }}</th>
|
||||
<th class="col-ability">{{ $t('settings.colAbility') }}</th>
|
||||
<th class="col-weight">{{ $t('settings.labelWeight') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in providerForm.models" :key="m.model_id">
|
||||
<td class="col-enabled">
|
||||
<label class="toggle toggle--sm">
|
||||
<input type="checkbox" v-model="m.enabled" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="col-model">
|
||||
<span class="model-id">{{ m.model_id }}</span>
|
||||
</td>
|
||||
<td class="col-ability">
|
||||
<!-- 能力/模态标签(可编辑:点 × 删除;点 + 添加;手动改后标为「手动」) -->
|
||||
<div class="model-tags">
|
||||
<span v-for="md in m.modalities" v-show="!redundantTags.modalities.has(md)" :key="'md-' + md"
|
||||
class="tag tag-modality" :title="$t('settings.tagTooltipModality')">
|
||||
{{ $t('settings.tagModality.' + md) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeModality(m, md)" @keydown.enter.prevent="removeModality(m, md)" @keydown.space.prevent="removeModality(m, md)">×</span>
|
||||
</span>
|
||||
<span v-for="cap in m.capabilities" v-show="!redundantTags.capabilities.has(cap)" :key="'cap-' + cap"
|
||||
class="tag tag-capability" :title="$t('settings.tagTooltipCapability')">
|
||||
{{ $t('settings.tagCapability.' + cap) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeCapability(m, cap)" @keydown.enter.prevent="removeCapability(m, cap)" @keydown.space.prevent="removeCapability(m, cap)">×</span>
|
||||
</span>
|
||||
<span v-if="m.probe_source && m.probe_source !== 'user_set'" v-show="!redundantTags.sources.has(m.probe_source)"
|
||||
class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
|
||||
<span v-if="m.probe_source === 'user_set'" class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.user_set') }}</span>
|
||||
<span class="tag-add" role="button" tabindex="0" :title="$t('settings.addTagTitle')" :aria-label="$t('settings.addTagTitle')" @click.stop="startAddTag(m)" @keydown.enter.prevent="startAddTag(m)" @keydown.space.prevent="startAddTag(m)">+</span>
|
||||
</div>
|
||||
<!-- 添加能力内联菜单(点+展开;选未有的能力/模态即添加) -->
|
||||
<div v-if="addingTagModel === m.model_id" class="add-tag-menu">
|
||||
<span v-for="opt in addableTags(m)" :key="opt.kind + '-' + opt.key"
|
||||
class="tag" :class="opt.kind === 'modality' ? 'tag-modality' : 'tag-capability'"
|
||||
@click.stop="addTag(m, opt.key, opt.kind)">{{ opt.label }}</span>
|
||||
<span v-if="addableTags(m).length === 0" class="add-tag-empty">{{ $t('settings.addTagNone') }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-weight">
|
||||
<input type="number" class="setting-input setting-input--sm" min="0" max="100" v-model.number="m.weight" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ModelTable :models="providerForm.models" />
|
||||
<div class="model-list-hint">{{ $t('settings.modelListHint') }}</div>
|
||||
<!-- M32: 模型列表 enabled/weight 改了未保存时显提示(关闭/切走将丢弃) -->
|
||||
<div v-if="isFormDirty" class="model-list-unsaved-hint">{{ $t('settings.modelListUnsavedHint') }}</div>
|
||||
@@ -205,11 +110,17 @@
|
||||
import { reactive, ref, computed, onMounted, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { aiApi } from '@/api'
|
||||
import type { AiProviderConfig, ModelConfig, Modality, Capability, ProbeSource } from '@/api/types'
|
||||
import type { AiProviderConfig, ModelConfig } from '@/api/types'
|
||||
import { PROVIDER_PRESETS, CUSTOM_PRESET_ID, findPreset } from './providerPresets'
|
||||
import ProviderList from './provider-panel/provider-list.vue'
|
||||
import ModelTable from './provider-panel/model-table.vue'
|
||||
|
||||
// ============================================================
|
||||
// AI 提供商域 — 真实后端 CRUD + c 负载均衡池(enabled / weight)
|
||||
// C4c 拆分:卡片列表+池控件 → provider-panel/provider-list.vue;
|
||||
// 模型表格+能力标签标注 → provider-panel/model-table.vue。
|
||||
// 本件保留:表单状态机(校验红框/温度与权重钳制/快照脏判)、保存/测试连接/预设回填、
|
||||
// 列表 CRUD 与 loadProviders 闭环、对壳 defineExpose 契约。
|
||||
// ============================================================
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -286,37 +197,7 @@ const providerForm = reactive({
|
||||
savedSnapshot: '' as string,
|
||||
})
|
||||
|
||||
/**
|
||||
* 冗余标签判定:全模型共有的 modality/capability/probe_source 无区分度,
|
||||
* 每行都渲染会刷屏(如全部命中预设表 → 全是「预设」;对话模型默认 ToolUse → 全是「工具调用」)。
|
||||
* 计算「所有模型都含」的值集合,模板渲染时跳过 → 只显示有区分度的标签。
|
||||
*/
|
||||
const redundantTags = computed(() => {
|
||||
const models = providerForm.models
|
||||
// SA-6: 去冗余的前提是「模型间对比」——仅 1 个模型时它的所有标签都满足「全模型共有」,
|
||||
// 无脑隐藏会把唯一模型的能力标签全抹掉(行内空白,用户看不到该模型能力)。
|
||||
// 故 ≥2 个模型起才去冗余;0/1 个一律全显(sources 分支本就 <2 直接放行,此处对齐)。
|
||||
if (models.length < 2) return { modalities: new Set<string>(), capabilities: new Set<string>(), sources: new Set<string>() }
|
||||
const common = (pick: (m: ModelConfig) => string[]) => {
|
||||
const sets = models.map(m => new Set(pick(m)))
|
||||
const first = sets[0]
|
||||
const result = new Set<string>()
|
||||
for (const v of first) {
|
||||
if (sets.every(s => s.has(v))) result.add(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return {
|
||||
modalities: common(m => m.modalities),
|
||||
capabilities: common(m => m.capabilities),
|
||||
// 探测来源:全模型相同的值也冗余(如全 PresetTable)
|
||||
sources: (() => {
|
||||
const vals = models.map(m => m.probe_source).filter((v): v is ProbeSource => !!v)
|
||||
if (vals.length < 2) return new Set<string>()
|
||||
return new Set([...new Set(vals)].filter(v => vals.every(x => x === v)))
|
||||
})(),
|
||||
}
|
||||
})
|
||||
// (冗余标签判定 redundantTags 随模型表格拆至 provider-panel/model-table.vue)
|
||||
|
||||
/**
|
||||
* L19/M32: 表单脏判定——当前表单状态与已落库快照不一致即脏。
|
||||
@@ -349,59 +230,7 @@ function formBusinessSnapshot(): string {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 模型能力手动标注(表格内编辑 tags;改动即标 UserSet,手动优先)
|
||||
// ============================================================
|
||||
|
||||
/** 当前展开「添加能力」菜单的模型名;null=无展开 */
|
||||
const addingTagModel = ref<string | null>(null)
|
||||
|
||||
/** 可添加的模态(与 tagModality 对齐;text 是对话基础模态,允许删但一般不主动加) */
|
||||
const ADDABLE_MODALITIES: Modality[] = ['text', 'vision', 'audio', 'video']
|
||||
/** 可添加的能力 */
|
||||
const ADDABLE_CAPABILITIES: Capability[] = ['tool_use', 'embedding', 'code_gen']
|
||||
|
||||
/** 某模型可添加的可选 tag(模态 + 能力,排除已选的) */
|
||||
function addableTags(m: ModelConfig): { key: string; kind: 'modality' | 'capability'; label: string }[] {
|
||||
const mods = ADDABLE_MODALITIES
|
||||
.filter(x => !m.modalities.includes(x))
|
||||
.map(x => ({ key: x, kind: 'modality' as const, label: t('settings.tagModality.' + x) }))
|
||||
const caps = ADDABLE_CAPABILITIES
|
||||
.filter(x => !m.capabilities.includes(x))
|
||||
.map(x => ({ key: x, kind: 'capability' as const, label: t('settings.tagCapability.' + x) }))
|
||||
return [...mods, ...caps]
|
||||
}
|
||||
|
||||
/** 标记模型为「手动标注」(probe_source=user_set),下次探测不覆盖 */
|
||||
function markManual(m: ModelConfig) {
|
||||
m.probe_source = 'user_set'
|
||||
addingTagModel.value = null
|
||||
}
|
||||
|
||||
function removeModality(m: ModelConfig, md: Modality) {
|
||||
m.modalities = m.modalities.filter(x => x !== md)
|
||||
markManual(m)
|
||||
}
|
||||
|
||||
function removeCapability(m: ModelConfig, cap: Capability) {
|
||||
m.capabilities = m.capabilities.filter(x => x !== cap)
|
||||
markManual(m)
|
||||
}
|
||||
|
||||
function addTag(m: ModelConfig, key: string, kind: 'modality' | 'capability') {
|
||||
if (kind === 'modality') {
|
||||
const md = key as Modality
|
||||
if (!m.modalities.includes(md)) m.modalities = [...m.modalities, md]
|
||||
} else {
|
||||
const cap = key as Capability
|
||||
if (!m.capabilities.includes(cap)) m.capabilities = [...m.capabilities, cap]
|
||||
}
|
||||
markManual(m)
|
||||
}
|
||||
|
||||
function startAddTag(m: ModelConfig) {
|
||||
addingTagModel.value = addingTagModel.value === m.model_id ? null : m.model_id
|
||||
}
|
||||
// (模型能力手动标注 tag 编辑块随模型表格拆至 provider-panel/model-table.vue)
|
||||
|
||||
/**
|
||||
* L19: 关闭表单入口——脏表单二次确认,清表单后关闭。
|
||||
@@ -677,64 +506,15 @@ async function setDefaultProvider(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// c: 负载均衡池可编辑层(enabled / weight)
|
||||
// 卡片即时调 IPC(对齐 syncConcurrencyConfig 模式);落库后后端 reload_provider_caps
|
||||
// 重建 per_provider caps,下条消息即按新配置 acquire。
|
||||
// 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 c 轻量 UPDATE 路径冲突)。
|
||||
// ============================================================
|
||||
// 计时器按 provider id 分键:单例计时器下 300ms 内先后改两家权重,B 的变更会清掉
|
||||
// A 尚未落库的保存,先改的一方静默丢失(走查 P2-8)。
|
||||
const _poolTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
// (负载均衡池 enabled/weight 即时 IPC 块随卡片列表拆至 provider-panel/provider-list.vue)
|
||||
|
||||
/**
|
||||
* toggle 即时生效(开关语义=立即入/出池,无 debounce 必要;但失败需回滚视图)。
|
||||
* 乐观更新本地数组,失败 revert + toast。
|
||||
*/
|
||||
async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
|
||||
const prev = p.enabled
|
||||
p.enabled = enabled
|
||||
try {
|
||||
// 防御钳制:p.weight 理论上已被 onPoolWeightChange 钳过,此处兜底防脏数据直传 IPC
|
||||
const w = Math.min(100, Math.max(0, p.weight ?? 50))
|
||||
await aiApi.updateProviderPool(p.id, enabled, w)
|
||||
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
|
||||
} catch (e) {
|
||||
p.enabled = prev // revert
|
||||
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
|
||||
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
|
||||
}
|
||||
/** 子件 toast 上抛中继(池控件 IPC 结果反馈) */
|
||||
function relayToast(msg: string, type: 'error' | 'warning' | 'info' | 'success') {
|
||||
emit('toast', msg, type)
|
||||
}
|
||||
|
||||
/**
|
||||
* weight 变更 debounce 300ms(防快速连续输入触发频繁 IPC),clamp [0,100] 对齐后端。
|
||||
* 双 clamp:前端 clamp 视觉输入 + 后端 command 再 clamp(防御性)。
|
||||
*/
|
||||
function onPoolWeightChange(p: AiProviderConfig, raw: number) {
|
||||
// NaN 兜底(清空输入):回退 50,防 IPC 传 NaN 后端 serde u32 失败
|
||||
const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50))
|
||||
const prev = p.weight
|
||||
p.weight = clamped
|
||||
// 仅重置同一 provider 的待发计时;跨 provider 互不干扰
|
||||
const prevTimer = _poolTimers.get(p.id)
|
||||
if (prevTimer) clearTimeout(prevTimer)
|
||||
_poolTimers.set(p.id, setTimeout(async () => {
|
||||
_poolTimers.delete(p.id)
|
||||
try {
|
||||
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
|
||||
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
|
||||
} catch (e) {
|
||||
p.weight = prev // revert
|
||||
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
|
||||
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
|
||||
}
|
||||
}, 300))
|
||||
}
|
||||
|
||||
function maskKey(key: string): string {
|
||||
if (!key || key.length < 8) return '••••••••'
|
||||
return key.slice(0, 4) + '••••••••' + key.slice(-4)
|
||||
}
|
||||
/** 卡片列表子件引用(池 debounce 计时器下沉子件,卸载清理经此委托) */
|
||||
const providerListRef = useTemplateRef<InstanceType<typeof ProviderList>>('providerListRef')
|
||||
|
||||
/** 暴露给 shell:loadProviders 供外部主动刷新 / Knowledge 读 aiProviders / 卸载清 timer。
|
||||
* L19: isFormDirty + closeProviderForm 暴露,供外部(如导航切走前)查询/触发拦截。
|
||||
@@ -744,7 +524,7 @@ defineExpose({
|
||||
loadProviders,
|
||||
isFormDirty,
|
||||
closeProviderForm,
|
||||
clearPoolTimer() { for (const t of _poolTimers.values()) clearTimeout(t); _poolTimers.clear() },
|
||||
clearPoolTimer() { providerListRef.value?.clearPoolTimer() },
|
||||
})
|
||||
|
||||
// 阶段3 UX 重构:本面板改为 ai-model 类懒挂载,不再由 Settings.vue onMounted 调用,
|
||||
@@ -754,92 +534,24 @@ onMounted(loadProviders)
|
||||
|
||||
<style scoped>
|
||||
/* 阶段1/3 UX 重构:panel/panel-header/empty-hint/btn 系列/setting-select/
|
||||
setting-input/toggle/toggle-slider 已迁移到 settings.css 全局。
|
||||
本 scoped 仅保留 ProviderPanel 特有样式:provider-card/default-badge/
|
||||
pool-badge/detail 系列/form 系列/btn-link/toggle--sm 及输入控件布局覆盖等。
|
||||
setting-input/toggle/toggle-slider 已迁移到 settings.css 全局,btn-link 已并轨 global.css。
|
||||
C4c 拆分:provider 卡片/池控件样式随 provider-list.vue、模型表格/标签样式随
|
||||
model-table.vue 下沉子件;本 scoped 仅保留表单区样式(form 系列/红框/错误横幅/
|
||||
模型列表区容器与提示行)。.setting-input--sm/.toggle--sm 为两子件共用,各持一份。
|
||||
注:注释列举类名时勿写星斜杠序列(会提前闭合 CSS 注释)。 */
|
||||
|
||||
/* ===== 链接按钮(本面板特有,未进全局) ===== */
|
||||
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
|
||||
.btn-link:hover { text-decoration: underline; }
|
||||
.btn-link-danger { color: var(--df-danger); }
|
||||
|
||||
/* ===== Provider 卡片 ===== */
|
||||
.provider-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.provider-card {
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.provider-card--default {
|
||||
border-color: var(--df-accent);
|
||||
box-shadow: inset 3px 0 0 var(--df-accent);
|
||||
}
|
||||
.provider-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.provider-info { display: flex; align-items: center; gap: 8px; }
|
||||
.provider-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.default-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(100,255,218,0.15);
|
||||
color: var(--df-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
.provider-actions { display: flex; gap: 4px; }
|
||||
|
||||
.provider-detail { display: flex; flex-direction: column; gap: 6px; }
|
||||
.detail-row { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
min-width: 60px;
|
||||
}
|
||||
.detail-value {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.detail-value.mask { color: var(--df-text-dim); letter-spacing: 1px; }
|
||||
|
||||
/* Provider 卡片 disabled 态(c:enabled=false 视觉弱化) */
|
||||
.provider-card--disabled { opacity: 0.6; }
|
||||
.pool-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(255,107,107,0.15);
|
||||
color: var(--df-danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 负载均衡池行(toggle / weight 控件) */
|
||||
.detail-row--pool { align-items: center; }
|
||||
.detail-value--control { display: flex; align-items: center; gap: 8px; font-family: var(--df-font-mono); }
|
||||
.pool-weight-hint { font-size: 11px; color: var(--df-text-dim); }
|
||||
|
||||
/* 表单(本面板特有输入控件样式;.setting-select 走全局) */
|
||||
.form-grid { display: flex; flex-direction: column; gap: 14px; }
|
||||
/* 表单(本面板特有输入控件样式;.setting-select 走全局)。
|
||||
--form-label-w 为标签列宽单一来源:hint 缩进用 calc(label 列宽+行 gap) 对齐 input 位,
|
||||
免手算魔法数。 */
|
||||
.form-grid { --form-label-w: 72px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.form-field { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.form-label { flex: 0 0 72px; font-size: 12px; font-weight: 500; color: var(--df-text-secondary); text-align: right; }
|
||||
.form-label { flex: 0 0 var(--form-label-w); font-size: 12px; font-weight: 500; color: var(--df-text-secondary); text-align: right; }
|
||||
/* 字段级辅助说明(默认模型提示等):弱化色 + 小字,与 label/input 区分层级。
|
||||
横向布局下占满整行换到 label+input 下方。 */
|
||||
.form-field-hint { flex-basis: 100%; margin-left: 82px; font-size: 11px; color: var(--df-text-dim); line-height: 1.5; }
|
||||
横向布局下占满整行换到 label+input 下方,缩进与 input 左缘结构对齐。 */
|
||||
.form-field-hint { flex-basis: 100%; margin-left: calc(var(--form-label-w) + 10px); font-size: 11px; color: var(--df-text-dim); line-height: 1.5; }
|
||||
.form-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
|
||||
|
||||
/* 保存失败持久错误横幅(常驻 form-actions 上方,醒目于自隐 toast) */
|
||||
.save-error-banner {
|
||||
background: var(--df-danger-bg);
|
||||
border: 0.5px solid var(--df-danger);
|
||||
border-radius: var(--df-radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
/* 保存失败持久错误横幅:视觉(底色/边/圆角/内距)走全局 .df-note--danger,此处仅留内部排版 */
|
||||
.save-error-msg {
|
||||
margin: 0;
|
||||
font-family: var(--df-font-sans);
|
||||
@@ -860,12 +572,6 @@ onMounted(loadProviders)
|
||||
/* 走查 P3-10:校验缺失字段红框(@input 即清,用户开始修正就不再红) */
|
||||
.setting-input--invalid { border-color: var(--df-danger) !important; }
|
||||
.setting-input--invalid:focus { border-color: var(--df-danger) !important; box-shadow: 0 0 0 1px var(--df-danger); }
|
||||
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; padding: 4px 8px; font-size: 12px; }
|
||||
|
||||
/* Toggle 小号变体(本面板负载均衡池用;.toggle 基础走全局) */
|
||||
.toggle--sm { width: 32px; height: 18px; }
|
||||
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
|
||||
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
|
||||
|
||||
/* M32: 模型列表有未保存调整时的提示(弱 warning 色,贴合 model-list-hint 之下) */
|
||||
.model-list-unsaved-hint {
|
||||
@@ -874,86 +580,6 @@ onMounted(loadProviders)
|
||||
line-height: 1.5;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
|
||||
/* ===== 模型列表(表格形式:表头 + 数据行) =====
|
||||
原 model-list/model-row 等无任何样式定义(裸渲染),本批补齐并改为表格。
|
||||
风格对齐 provider-card(浅底 / 圆角 / 0.5px 边框)。 */
|
||||
.model-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
}
|
||||
.model-table th,
|
||||
.model-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.model-table thead th {
|
||||
background: var(--df-bg);
|
||||
font-weight: 500;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 11px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-table tbody tr { border-bottom: 0.5px solid var(--df-border); }
|
||||
.model-table tbody tr:last-child { border-bottom: none; }
|
||||
.model-table tbody tr:hover { background: var(--df-accent-bg); }
|
||||
/* 列宽:启用窄、模型/能力弹性、权重窄 */
|
||||
.model-table .col-enabled { width: 56px; text-align: center; }
|
||||
.model-table .col-weight { width: 110px; }
|
||||
.model-table .col-model { min-width: 140px; }
|
||||
.col-enabled .toggle--sm { display: inline-block; }
|
||||
.model-id {
|
||||
font-size: 13px;
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
/* 标签列:小圆角 chip,三类语义色区分 */
|
||||
.model-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-modality { background: rgba(100,181,246,0.15); color: var(--df-info); }
|
||||
.tag-capability { background: rgba(108,99,255,0.15); color: var(--df-accent); }
|
||||
.tag-source { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
/* 可编辑 tag:×删除按钮(悬停显红)+ 添加按钮 */
|
||||
.tag-remove { margin-left: 3px; cursor: pointer; opacity: 0.6; font-weight: 700; }
|
||||
.tag-remove:hover { opacity: 1; color: var(--df-danger); }
|
||||
.tag-add {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--df-border);
|
||||
color: var(--df-text-dim);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tag-add:hover { border-color: var(--df-accent); color: var(--df-accent); }
|
||||
/* 添加能力内联菜单(点+展开):浅底容器包可选 tag */
|
||||
.add-tag-menu {
|
||||
margin-top: 4px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.add-tag-menu .tag { cursor: pointer; }
|
||||
.add-tag-menu .tag:hover { filter: brightness(1.2); }
|
||||
.add-tag-empty { font-size: 10px; color: var(--df-text-dim); padding: 2px 4px; }
|
||||
/* 权重输入框:表格内固定窄宽 */
|
||||
.model-table .setting-input--sm { width: 64px; }
|
||||
.model-list-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -115,10 +115,12 @@ async function save() {
|
||||
<style scoped>
|
||||
/* panel/panel-header/btn 系列/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
|
||||
.uclaw-settings { display: flex; flex-direction: column; gap: 4px; }
|
||||
.uclaw-input { width: 320px; font-family: var(--df-font-mono, monospace); }
|
||||
/* 定宽输入补 max-width,窄窗收缩防横向溢出 */
|
||||
.uclaw-input { width: 320px; max-width: 100%; font-family: var(--df-font-mono, monospace); }
|
||||
/* 原 var(--df-text-tertiary) 为幽灵 token(全 styles 未定义,渲染成继承色),改用已定义的弱化色 */
|
||||
.uclaw-hint {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-tertiary);
|
||||
color: var(--df-text-dim);
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
white-space: pre-line;
|
||||
|
||||
@@ -145,7 +145,8 @@ function onPollSecsChange(raw: string) {
|
||||
<style scoped>
|
||||
/* panel/panel-header/toggle/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
|
||||
.unattended-settings { display: flex; flex-direction: column; gap: 4px; }
|
||||
.unattended-input { width: 280px; }
|
||||
/* 定宽输入补 max-width,窄窗收缩防横向溢出(--num 变体继承同款收缩) */
|
||||
.unattended-input { width: 280px; max-width: 100%; }
|
||||
.unattended-input--num { width: 110px; }
|
||||
.unattended-radios { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.unattended-radio {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<!-- 拉取到的模型列表表格(F-01 阶段6)。自 ProviderPanel 拆出(C4c 纯搬迁):
|
||||
表格渲染 + 能力/模态标签手动标注(改动即标 UserSet)自治在本件。
|
||||
models 为主文件 providerForm.models 的引用,行内 v-model/标签增删直接改
|
||||
item 字段(共享响应式对象),脏态由主文件 isFormDirty 统一比对。 -->
|
||||
<table class="model-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-enabled">{{ $t('settings.colEnabled') }}</th>
|
||||
<th class="col-model">{{ $t('settings.colModel') }}</th>
|
||||
<th class="col-ability">{{ $t('settings.colAbility') }}</th>
|
||||
<th class="col-weight">{{ $t('settings.labelWeight') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in models" :key="m.model_id">
|
||||
<td class="col-enabled">
|
||||
<label class="toggle toggle--sm">
|
||||
<input type="checkbox" v-model="m.enabled" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="col-model">
|
||||
<span class="model-id">{{ m.model_id }}</span>
|
||||
</td>
|
||||
<td class="col-ability">
|
||||
<!-- 能力/模态标签(可编辑:点 × 删除;点 + 添加;手动改后标为「手动」) -->
|
||||
<div class="model-tags">
|
||||
<span v-for="md in m.modalities" v-show="!redundantTags.modalities.has(md)" :key="'md-' + md"
|
||||
class="tag tag-modality" :title="$t('settings.tagTooltipModality')">
|
||||
{{ $t('settings.tagModality.' + md) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeModality(m, md)" @keydown.enter.prevent="removeModality(m, md)" @keydown.space.prevent="removeModality(m, md)">×</span>
|
||||
</span>
|
||||
<span v-for="cap in m.capabilities" v-show="!redundantTags.capabilities.has(cap)" :key="'cap-' + cap"
|
||||
class="tag tag-capability" :title="$t('settings.tagTooltipCapability')">
|
||||
{{ $t('settings.tagCapability.' + cap) }}<span class="tag-remove" role="button" tabindex="0" :aria-label="$t('common.delete')" @click.stop="removeCapability(m, cap)" @keydown.enter.prevent="removeCapability(m, cap)" @keydown.space.prevent="removeCapability(m, cap)">×</span>
|
||||
</span>
|
||||
<span v-if="m.probe_source && m.probe_source !== 'user_set'" v-show="!redundantTags.sources.has(m.probe_source)"
|
||||
class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.' + m.probe_source) }}</span>
|
||||
<span v-if="m.probe_source === 'user_set'" class="tag tag-source" :title="$t('settings.tagTooltipSource')">{{ $t('settings.tagSource.user_set') }}</span>
|
||||
<span class="tag-add" role="button" tabindex="0" :title="$t('settings.addTagTitle')" :aria-label="$t('settings.addTagTitle')" @click.stop="startAddTag(m)" @keydown.enter.prevent="startAddTag(m)" @keydown.space.prevent="startAddTag(m)">+</span>
|
||||
</div>
|
||||
<!-- 添加能力内联菜单(点+展开;选未有的能力/模态即添加) -->
|
||||
<div v-if="addingTagModel === m.model_id" class="add-tag-menu">
|
||||
<span v-for="opt in addableTags(m)" :key="opt.kind + '-' + opt.key"
|
||||
class="tag" :class="opt.kind === 'modality' ? 'tag-modality' : 'tag-capability'"
|
||||
@click.stop="addTag(m, opt.key, opt.kind)">{{ opt.label }}</span>
|
||||
<span v-if="addableTags(m).length === 0" class="add-tag-empty">{{ $t('settings.addTagNone') }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-weight">
|
||||
<input type="number" class="setting-input setting-input--sm" min="0" max="100" v-model.number="m.weight" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { ModelConfig, Modality, Capability, ProbeSource } from '@/api/types'
|
||||
|
||||
// ============================================================
|
||||
// 模型能力手动标注(表格内编辑 tags;改动即标 UserSet,手动优先)
|
||||
// —— 原 ProviderPanel 整块搬迁(C4c 纯搬迁,共享函数不新造)。
|
||||
// ============================================================
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
/** 主文件 providerForm.models 引用(行内直改 item 字段,保存闭环留主文件 saveProvider) */
|
||||
models: ModelConfig[]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 冗余标签判定:全模型共有的 modality/capability/probe_source 无区分度,
|
||||
* 每行都渲染会刷屏(如全部命中预设表 → 全是「预设」;对话模型默认 ToolUse → 全是「工具调用」)。
|
||||
* 计算「所有模型都含」的值集合,模板渲染时跳过 → 只显示有区分度的标签。
|
||||
*/
|
||||
const redundantTags = computed(() => {
|
||||
const models = props.models
|
||||
// SA-6: 去冗余的前提是「模型间对比」——仅 1 个模型时它的所有标签都满足「全模型共有」,
|
||||
// 无脑隐藏会把唯一模型的能力标签全抹掉(行内空白,用户看不到该模型能力)。
|
||||
// 故 ≥2 个模型起才去冗余;0/1 个一律全显(sources 分支本就 <2 直接放行,此处对齐)。
|
||||
if (models.length < 2) return { modalities: new Set<string>(), capabilities: new Set<string>(), sources: new Set<string>() }
|
||||
const common = (pick: (m: ModelConfig) => string[]) => {
|
||||
const sets = models.map(m => new Set(pick(m)))
|
||||
const first = sets[0]
|
||||
const result = new Set<string>()
|
||||
for (const v of first) {
|
||||
if (sets.every(s => s.has(v))) result.add(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return {
|
||||
modalities: common(m => m.modalities),
|
||||
capabilities: common(m => m.capabilities),
|
||||
// 探测来源:全模型相同的值也冗余(如全 PresetTable)
|
||||
sources: (() => {
|
||||
const vals = models.map(m => m.probe_source).filter((v): v is ProbeSource => !!v)
|
||||
if (vals.length < 2) return new Set<string>()
|
||||
return new Set([...new Set(vals)].filter(v => vals.every(x => x === v)))
|
||||
})(),
|
||||
}
|
||||
})
|
||||
|
||||
/** 当前展开「添加能力」菜单的模型名;null=无展开 */
|
||||
const addingTagModel = ref<string | null>(null)
|
||||
|
||||
/** 可添加的模态(与 tagModality 对齐;text 是对话基础模态,允许删但一般不主动加) */
|
||||
const ADDABLE_MODALITIES: Modality[] = ['text', 'vision', 'audio', 'video']
|
||||
/** 可添加的能力 */
|
||||
const ADDABLE_CAPABILITIES: Capability[] = ['tool_use', 'embedding', 'code_gen']
|
||||
|
||||
/** 某模型可添加的可选 tag(模态 + 能力,排除已选的) */
|
||||
function addableTags(m: ModelConfig): { key: string; kind: 'modality' | 'capability'; label: string }[] {
|
||||
const mods = ADDABLE_MODALITIES
|
||||
.filter(x => !m.modalities.includes(x))
|
||||
.map(x => ({ key: x, kind: 'modality' as const, label: t('settings.tagModality.' + x) }))
|
||||
const caps = ADDABLE_CAPABILITIES
|
||||
.filter(x => !m.capabilities.includes(x))
|
||||
.map(x => ({ key: x, kind: 'capability' as const, label: t('settings.tagCapability.' + x) }))
|
||||
return [...mods, ...caps]
|
||||
}
|
||||
|
||||
/** 标记模型为「手动标注」(probe_source=user_set),下次探测不覆盖 */
|
||||
function markManual(m: ModelConfig) {
|
||||
m.probe_source = 'user_set'
|
||||
addingTagModel.value = null
|
||||
}
|
||||
|
||||
function removeModality(m: ModelConfig, md: Modality) {
|
||||
m.modalities = m.modalities.filter(x => x !== md)
|
||||
markManual(m)
|
||||
}
|
||||
|
||||
function removeCapability(m: ModelConfig, cap: Capability) {
|
||||
m.capabilities = m.capabilities.filter(x => x !== cap)
|
||||
markManual(m)
|
||||
}
|
||||
|
||||
function addTag(m: ModelConfig, key: string, kind: 'modality' | 'capability') {
|
||||
if (kind === 'modality') {
|
||||
const md = key as Modality
|
||||
if (!m.modalities.includes(md)) m.modalities = [...m.modalities, md]
|
||||
} else {
|
||||
const cap = key as Capability
|
||||
if (!m.capabilities.includes(cap)) m.capabilities = [...m.capabilities, cap]
|
||||
}
|
||||
markManual(m)
|
||||
}
|
||||
|
||||
function startAddTag(m: ModelConfig) {
|
||||
addingTagModel.value = addingTagModel.value === m.model_id ? null : m.model_id
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自 ProviderPanel scoped 拆出(C4c 纯搬迁):模型表格/标签样式随区块走;
|
||||
.setting-input--sm 与 .toggle--sm 为本件与 provider-list 子件共用,两处各持一份
|
||||
(scoped 不跨组件,全局化去重留待后续统一处理)。 */
|
||||
|
||||
/* ===== 模型列表(表格形式:表头 + 数据行) =====
|
||||
原 model-list/model-row 等无任何样式定义(裸渲染),本批补齐并改为表格。
|
||||
风格对齐 provider-card(浅底 / 圆角 / 0.5px 边框)。 */
|
||||
.model-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
}
|
||||
.model-table th,
|
||||
.model-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.model-table thead th {
|
||||
background: var(--df-bg);
|
||||
font-weight: 500;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 11px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-table tbody tr { border-bottom: 0.5px solid var(--df-border); }
|
||||
.model-table tbody tr:last-child { border-bottom: none; }
|
||||
.model-table tbody tr:hover { background: var(--df-accent-bg); }
|
||||
/* 列宽:启用窄、模型/能力弹性、权重窄 */
|
||||
.model-table .col-enabled { width: 56px; text-align: center; }
|
||||
.model-table .col-weight { width: 110px; }
|
||||
.model-table .col-model { min-width: 140px; }
|
||||
.col-enabled .toggle--sm { display: inline-block; }
|
||||
.model-id {
|
||||
font-size: 13px;
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
/* 标签列:小圆角 chip,三类语义色区分 */
|
||||
.model-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-modality { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.tag-capability { background: var(--df-accent-soft); color: var(--df-accent); }
|
||||
.tag-source { background: var(--df-neutral-bg); color: var(--df-text-dim); }
|
||||
/* 可编辑 tag:×删除按钮(悬停显红)+ 添加按钮 */
|
||||
.tag-remove { margin-left: 3px; cursor: pointer; opacity: 0.6; font-weight: 700; }
|
||||
.tag-remove:hover { opacity: 1; color: var(--df-danger); }
|
||||
.tag-add {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--df-border);
|
||||
color: var(--df-text-dim);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tag-add:hover { border-color: var(--df-accent); color: var(--df-accent); }
|
||||
/* 添加能力内联菜单(点+展开):浅底容器包可选 tag */
|
||||
.add-tag-menu {
|
||||
margin-top: 4px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.add-tag-menu .tag { cursor: pointer; }
|
||||
.add-tag-menu .tag:hover { filter: brightness(1.2); }
|
||||
.add-tag-empty { font-size: 10px; color: var(--df-text-dim); padding: 2px 4px; }
|
||||
/* 权重输入框:表格内固定窄宽 */
|
||||
.model-table .setting-input--sm { width: 64px; }
|
||||
|
||||
/* 行内窄输入(权重等):定高 26px 档(--df-h-input),与 .setting-input 主体高度齐平 */
|
||||
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; height: var(--df-h-input); padding: 4px 8px; font-size: 12px; }
|
||||
|
||||
/* Toggle 小号变体(表格启用开关用;.toggle 基础走全局) */
|
||||
.toggle--sm { width: 32px; height: 18px; }
|
||||
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
|
||||
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<!-- provider 卡片列表。自 ProviderPanel 拆出(C4c 纯搬迁):
|
||||
卡片渲染 + 负载均衡池控件(enabled/weight 即时 IPC)自治在本件,
|
||||
增删改/设默认经 emit 上抛主文件(共享 loadProviders 闭环)。 -->
|
||||
<div class="provider-list">
|
||||
<div class="provider-card" :class="{ 'provider-card--default': p.is_default, 'provider-card--disabled': p.enabled === false }" v-for="p in providers" :key="p.id">
|
||||
<div class="provider-top">
|
||||
<div class="provider-info">
|
||||
<span class="provider-name">{{ p.name }}</span>
|
||||
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</span>
|
||||
<span v-if="p.enabled === false" class="pool-badge">{{ $t('settings.badgePoolDisabled') }}</span>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<button class="btn-link" @click="emit('set-default', p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
|
||||
<button class="btn-link" @click="emit('edit', p)">{{ $t('common.edit') }}</button>
|
||||
<button class="btn-link btn-link-danger" @click="emit('delete', p.id)">{{ $t('common.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-detail">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ $t('settings.detailBaseUrl') }}</span>
|
||||
<span class="detail-value">{{ p.base_url }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ $t('settings.detailModel') }}</span>
|
||||
<span class="detail-value">{{ p.default_model }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
|
||||
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
|
||||
</div>
|
||||
<!-- F-260614-04c 负载均衡池:enabled toggle + weight 即时调 IPC,落库即重建 caps -->
|
||||
<div class="detail-row detail-row--pool">
|
||||
<span class="detail-label" :title="$t('settings.descPoolEnabled')">{{ $t('settings.detailPoolEnabled') }}</span>
|
||||
<div class="detail-value detail-value--control">
|
||||
<label class="toggle toggle--sm" :title="$t('settings.descPoolEnabled')">
|
||||
<input type="checkbox" :checked="p.enabled !== false" @change="onPoolToggle(p, ($event.target as HTMLInputElement).checked)" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-row detail-row--pool" v-if="p.enabled !== false">
|
||||
<span class="detail-label" :title="$t('settings.descPoolWeight')">{{ $t('settings.detailPoolWeight') }}</span>
|
||||
<div class="detail-value detail-value--control">
|
||||
<input type="number" class="setting-input setting-input--sm" min="0" max="100"
|
||||
:value="p.weight ?? 50"
|
||||
@change="onPoolWeightChange(p, ($event.target as HTMLInputElement).valueAsNumber)" />
|
||||
<span class="pool-weight-hint">0-100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { aiApi } from '@/api'
|
||||
import type { AiProviderConfig } from '@/api/types'
|
||||
|
||||
// ============================================================
|
||||
// c: 负载均衡池可编辑层(enabled / weight)——原 ProviderPanel 整块搬迁
|
||||
// 卡片即时调 IPC(对齐 syncConcurrencyConfig 模式);落库后后端 reload_provider_caps
|
||||
// 重建 per_provider caps,下条消息即按新配置 acquire。
|
||||
// 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 c 轻量 UPDATE 路径冲突)。
|
||||
// ============================================================
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
/** 主文件 aiProviders 引用;池控件直接改 item 字段(共享响应式对象,乐观更新),列表整体替换仍由主文件驱动 */
|
||||
providers: AiProviderConfig[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit', p: AiProviderConfig): void
|
||||
(e: 'delete', id: string): void
|
||||
(e: 'set-default', id: string): void
|
||||
(e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void
|
||||
}>()
|
||||
|
||||
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
|
||||
|
||||
// 计时器按 provider id 分键:单例计时器下 300ms 内先后改两家权重,B 的变更会清掉
|
||||
// A 尚未落库的保存,先改的一方静默丢失(走查 P2-8)。
|
||||
const _poolTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/**
|
||||
* toggle 即时生效(开关语义=立即入/出池,无 debounce 必要;但失败需回滚视图)。
|
||||
* 乐观更新本地数组,失败 revert + toast。
|
||||
*/
|
||||
async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
|
||||
const prev = p.enabled
|
||||
p.enabled = enabled
|
||||
try {
|
||||
// 防御钳制:p.weight 理论上已被 onPoolWeightChange 钳过,此处兜底防脏数据直传 IPC
|
||||
const w = Math.min(100, Math.max(0, p.weight ?? 50))
|
||||
await aiApi.updateProviderPool(p.id, enabled, w)
|
||||
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
|
||||
} catch (e) {
|
||||
p.enabled = prev // revert
|
||||
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
|
||||
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* weight 变更 debounce 300ms(防快速连续输入触发频繁 IPC),clamp [0,100] 对齐后端。
|
||||
* 双 clamp:前端 clamp 视觉输入 + 后端 command 再 clamp(防御性)。
|
||||
*/
|
||||
function onPoolWeightChange(p: AiProviderConfig, raw: number) {
|
||||
// NaN 兜底(清空输入):回退 50,防 IPC 传 NaN 后端 serde u32 失败
|
||||
const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50))
|
||||
const prev = p.weight
|
||||
p.weight = clamped
|
||||
// 仅重置同一 provider 的待发计时;跨 provider 互不干扰
|
||||
const prevTimer = _poolTimers.get(p.id)
|
||||
if (prevTimer) clearTimeout(prevTimer)
|
||||
_poolTimers.set(p.id, setTimeout(async () => {
|
||||
_poolTimers.delete(p.id)
|
||||
try {
|
||||
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
|
||||
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
|
||||
} catch (e) {
|
||||
p.weight = prev // revert
|
||||
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
|
||||
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
|
||||
}
|
||||
}, 300))
|
||||
}
|
||||
|
||||
function maskKey(key: string): string {
|
||||
if (!key || key.length < 8) return '••••••••'
|
||||
return key.slice(0, 4) + '••••••••' + key.slice(-4)
|
||||
}
|
||||
|
||||
/** 主文件 defineExpose.clearPoolTimer 委托到此处(壳卸载清 timer 的对外契约不变)。 */
|
||||
defineExpose({
|
||||
clearPoolTimer() { for (const t of _poolTimers.values()) clearTimeout(t); _poolTimers.clear() },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自 ProviderPanel scoped 拆出(C4c 纯搬迁):provider 卡片/池控件样式随区块走;
|
||||
.setting-input--sm 与 .toggle--sm 为本件与 model-table 子件共用,两处各持一份
|
||||
(scoped 不跨组件,全局化去重留待后续统一处理)。 */
|
||||
|
||||
/* ===== Provider 卡片 ===== */
|
||||
.provider-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.provider-card {
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.provider-card--default {
|
||||
border-color: var(--df-accent);
|
||||
box-shadow: inset 3px 0 0 var(--df-accent);
|
||||
}
|
||||
.provider-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.provider-info { display: flex; align-items: center; gap: 8px; }
|
||||
.provider-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.default-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: var(--df-success-bg);
|
||||
color: var(--df-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
.provider-actions { display: flex; gap: 4px; }
|
||||
|
||||
.provider-detail { display: flex; flex-direction: column; gap: 6px; }
|
||||
.detail-row { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
min-width: 60px;
|
||||
}
|
||||
.detail-value {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.detail-value.mask { color: var(--df-text-dim); letter-spacing: 1px; }
|
||||
|
||||
/* Provider 卡片 disabled 态(c:enabled=false 视觉弱化) */
|
||||
.provider-card--disabled { opacity: 0.6; }
|
||||
.pool-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 负载均衡池行(toggle / weight 控件) */
|
||||
.detail-row--pool { align-items: center; }
|
||||
.detail-value--control { display: flex; align-items: center; gap: 8px; font-family: var(--df-font-mono); }
|
||||
.pool-weight-hint { font-size: 11px; color: var(--df-text-dim); }
|
||||
|
||||
/* 行内窄输入(权重等):定高 26px 档(--df-h-input),与 .setting-input 主体高度齐平 */
|
||||
.setting-input--sm { flex: 0 0 auto; min-width: 0; width: 70px; height: var(--df-h-input); padding: 4px 8px; font-size: 12px; }
|
||||
|
||||
/* Toggle 小号变体(本面板负载均衡池用;.toggle 基础走全局) */
|
||||
.toggle--sm { width: 32px; height: 18px; }
|
||||
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
|
||||
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
|
||||
</style>
|
||||
@@ -120,15 +120,8 @@
|
||||
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
|
||||
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #fff; }
|
||||
|
||||
/* ===== 按钮(原 Settings.vue,列表型 Panel 共用) ===== */
|
||||
.btn { padding: 8px 16px; border: none; border-radius: var(--df-radius-sm); font-size: 13px; cursor: pointer; transition: background 0.15s, color 0.15s; }
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
/* ===== 按钮 =====
|
||||
样式大重构 R0(2026-10-18):.btn 族副本已删除,并轨 global.css 唯一基类(高度档 --df-h-btn)。 */
|
||||
|
||||
/* ===== 空提示(列表型 Panel 共用) ===== */
|
||||
.empty-hint { color: var(--df-text-dim); font-size: 13px; padding: 16px 0; text-align: center; }
|
||||
|
||||
@@ -214,50 +214,50 @@ function cancelEdit() {
|
||||
<style scoped>
|
||||
.wf-dag {
|
||||
margin-top: 8px; padding: 8px 12px;
|
||||
background: var(--df-bg-card, #fafafa);
|
||||
background: var(--df-bg-card);
|
||||
border-radius: var(--df-radius-sm, 4px); font-size: 12px;
|
||||
}
|
||||
.wf-dag--empty { color: var(--df-text-dim, #888); }
|
||||
.wf-dag--empty { color: var(--df-text-dim); }
|
||||
.wf-dag-title { margin: 0 0 8px; font-size: 13px; font-weight: 600; }
|
||||
|
||||
.wf-dag-layers { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.wf-dag-layer { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||
.wf-dag-layer-label { font-size: 10px; color: var(--df-text-dim, #888); align-self: flex-start; }
|
||||
.wf-dag-layer-label { font-size: 10px; color: var(--df-text-dim); align-self: flex-start; }
|
||||
.wf-dag-layer-nodes {
|
||||
display: flex; flex-wrap: wrap; justify-content: center; gap: 6px;
|
||||
}
|
||||
.wf-dag-layer-arrow { font-size: 16px; color: var(--df-text-dim, #888); line-height: 1; }
|
||||
.wf-dag-layer-arrow { font-size: 16px; color: var(--df-text-dim); line-height: 1; }
|
||||
|
||||
.wf-dag-node {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: var(--df-bg, #fff);
|
||||
border: 1px solid var(--df-border, #ddd);
|
||||
background: var(--df-bg);
|
||||
border: 1px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
transition: border-color 0.3s, background 0.3s;
|
||||
}
|
||||
.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: 4px;
|
||||
font-size: 10px; color: var(--df-text-dim);
|
||||
background: var(--df-bg-card); padding: 0 4px; border-radius: 4px;
|
||||
}
|
||||
.wf-dag-node-status { font-size: 11px; }
|
||||
.wf-dag-node-status-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--df-text-dim, #ccc);
|
||||
background: var(--df-text-dim);
|
||||
}
|
||||
.wf-dag-node--running {
|
||||
border-color: var(--df-info, #4a90e2);
|
||||
background: rgba(74,144,226,0.05);
|
||||
border-color: var(--df-info);
|
||||
background: var(--df-info-bg);
|
||||
}
|
||||
.wf-dag-node--running .wf-dag-node-status-dot {
|
||||
background: var(--df-info, #4a90e2);
|
||||
background: var(--df-info);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
.wf-dag-node--completed { border-color: var(--df-success, #3ddba0); }
|
||||
.wf-dag-node--completed .wf-dag-node-status-dot { background: var(--df-success, #3ddba0); }
|
||||
.wf-dag-node--failed { border-color: var(--df-danger, #e5484d); background: rgba(229,72,77,0.05); }
|
||||
.wf-dag-node--failed .wf-dag-node-status-dot { background: var(--df-danger, #e5484d); }
|
||||
.wf-dag-node--completed { border-color: var(--df-success); }
|
||||
.wf-dag-node--completed .wf-dag-node-status-dot { background: var(--df-success); }
|
||||
.wf-dag-node--failed { border-color: var(--df-danger); background: var(--df-danger-bg); }
|
||||
.wf-dag-node--failed .wf-dag-node-status-dot { background: var(--df-danger); }
|
||||
.wf-dag-node--skipped { opacity: 0.5; }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
@@ -270,28 +270,29 @@ function cancelEdit() {
|
||||
.wf-dag-outedge {
|
||||
display: inline-flex; align-items: center; gap: 4px; font-size: 10px;
|
||||
}
|
||||
.wf-dag-outedge-arrow { color: var(--df-text-primary); white-space: nowrap; }
|
||||
/* 箭头色:原 --df-text-primary 为未定义幽灵 token(声明失效),改主文字色 */
|
||||
.wf-dag-outedge-arrow { color: var(--df-text); white-space: nowrap; }
|
||||
|
||||
/* 条件标签(共享:出边展示 + 编辑列表) */
|
||||
.wf-dag-cond-label {
|
||||
font-family: var(--df-font-mono); font-size: 10px; padding: 0 4px;
|
||||
background: rgba(74,144,226,0.08); border-radius: 2px; color: var(--df-info, #4a90e2);
|
||||
background: var(--df-info-bg); border-radius: 2px; color: var(--df-info);
|
||||
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.wf-dag-cond-label--uncond { background: transparent; color: var(--df-text-dim, #aaa); }
|
||||
.wf-dag-cond-label--uncond { background: transparent; color: var(--df-text-dim); }
|
||||
.wf-dag-cond-label--editable {
|
||||
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); }
|
||||
.wf-dag-cond-label--editable:hover { background: color-mix(in srgb, var(--df-info) 18%, transparent); }
|
||||
.wf-dag-cond-label--uncond.wf-dag-cond-label--editable:hover { background: var(--df-bg-card); }
|
||||
|
||||
/* 边条件编辑列表 */
|
||||
.wf-dag-edges-detail { margin-top: 8px; }
|
||||
.wf-dag-edges-summary { cursor: pointer; font-size: 11px; color: var(--df-info, #4a90e2); }
|
||||
.wf-dag-edges-summary { cursor: pointer; font-size: 11px; color: var(--df-info); }
|
||||
.wf-dag-edges { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
|
||||
.wf-dag-edge { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; }
|
||||
.wf-dag-edge-arrow { color: var(--df-text-primary); white-space: nowrap; }
|
||||
.wf-dag-edge-arrow { color: var(--df-text); 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: 4px;
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 12px;
|
||||
padding: 1px 5px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
/* 2026-10-18 配色收口:白叠层字面量改文字色派生,浅色主题自动转黑叠层 */
|
||||
background: color-mix(in srgb, var(--df-text) 6%, transparent);
|
||||
border-radius: var(--df-radius-sm);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
|
||||
+105
-29
@@ -27,7 +27,7 @@
|
||||
.error-banner {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255,107,107,0.12); border: 0.5px solid var(--df-danger);
|
||||
background: var(--df-danger-bg); border: 0.5px solid var(--df-danger);
|
||||
border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px;
|
||||
}
|
||||
.error-text { word-break: break-word; }
|
||||
@@ -39,32 +39,36 @@
|
||||
|
||||
/* — 灵感状态标签:ideas 列表(Ideas.vue) + 灵感详情(IdeaDetail.vue) 共享 —
|
||||
原 Ideas.vue / IdeaDetail.vue <style scoped> 两处逐字一致(灵感专属 5 态色系,
|
||||
与 Tasks/AuditLog 的 status-tag 语义不同,独立提取避免混淆)。 */
|
||||
与 Tasks/AuditLog 的 status-tag 语义不同,独立提取避免混淆)。
|
||||
2026-10-18 配色收口:圆角/内距对齐徽章档(xs+pad-badge,AuditLog 同档),
|
||||
显色改语义 token 三件套(底 *-bg/边 1px *-bd/字同色),清除 Material 并行色板;
|
||||
中性档(draft/archived)走 neutral-bg+text-dim 不加边。 */
|
||||
.status-tag {
|
||||
font-size: 11px; font-weight: 500; padding: 4px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 11px; font-weight: 500; padding: var(--df-pad-badge);
|
||||
border-radius: var(--df-radius-xs);
|
||||
flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
.status-draft { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-pending_review { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-approved { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); }
|
||||
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
.status-draft { background: var(--df-neutral-bg); color: var(--df-text-dim); }
|
||||
.status-pending_review { background: var(--df-info-bg); color: var(--df-info); border: 1px solid var(--df-info-bd); }
|
||||
.status-approved { background: var(--df-success-bg); color: var(--df-success); border: 1px solid var(--df-success-bd); }
|
||||
.status-promoted { background: var(--df-accent-bg); color: var(--df-accent); border: 1px solid var(--df-accent-bd); }
|
||||
.status-rejected { background: var(--df-danger-bg); color: var(--df-danger); border: 1px solid var(--df-danger-bd); }
|
||||
/* 归档态(归档箱内才有):中性灰,与 draft 同系;此前列表/详情渲染 .status-archived 无对应色 → 徽章悬空 */
|
||||
.status-archived { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-archived { background: var(--df-neutral-bg); color: var(--df-text-dim); }
|
||||
|
||||
/* — 任务状态徽章色(7 态):Tasks 列表 + TaskDetail/ProjectDetail 详情共用 —
|
||||
WK-6:原仅 TaskDetail/ProjectDetail scoped 定义,Tasks.vue 列表拿不到 → 裸文本。
|
||||
提取全局对齐三视图;配色以 TaskDetail.vue(7 态最全)为准,尺寸/圆角统一走上方 .status-tag。
|
||||
(TaskDetail 的 scoped 同名类特异度更高会覆盖,但值与全局一致,无视觉差异) */
|
||||
.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-progress { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-review { background: rgba(255,217,61,0.2); color: var(--df-warning); }
|
||||
.status-testing { background: rgba(255,152,0,0.2); color: #ff9800; }
|
||||
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-blocked { background: rgba(255,107,107,0.12); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
.status-deferred { background: rgba(255,193,7,0.15); color: #f0a030; }
|
||||
/* — 任务状态徽章色(8 态):Tasks 列表 + TaskDetail/ProjectDetail 详情共用 —
|
||||
原 TaskDetail/ProjectDetail scoped 副本已删,此处为唯一真值。
|
||||
2026-10-18 配色收口:Material 并行色板(hex/rgba)全清,改语义 token 三件套;
|
||||
testing(#ff9800)/deferred(#ffc107)原 amber/orange 系按配色审计归警告族;
|
||||
review/testing/deferred 三态同走 warning,靠徽章文字区分。 */
|
||||
.status-todo { background: var(--df-neutral-bg); color: var(--df-text-dim); }
|
||||
.status-progress { background: var(--df-info-bg); color: var(--df-info); border: 1px solid var(--df-info-bd); }
|
||||
.status-review { background: var(--df-warning-bg); color: var(--df-warning); border: 1px solid var(--df-warning-bd); }
|
||||
.status-testing { background: var(--df-warning-bg); color: var(--df-warning); border: 1px solid var(--df-warning-bd); }
|
||||
.status-done { background: var(--df-success-bg); color: var(--df-success); border: 1px solid var(--df-success-bd); }
|
||||
.status-blocked { background: var(--df-danger-bg); color: var(--df-danger); border: 1px solid var(--df-danger-bd); }
|
||||
.status-abandoned { background: var(--df-danger-bg); color: var(--df-danger); border: 1px solid var(--df-danger-bd); }
|
||||
.status-deferred { background: var(--df-warning-bg); color: var(--df-warning); border: 1px solid var(--df-warning-bd); }
|
||||
|
||||
/* — 多维评分条:IdeaDetail.vue + ProjectDetail.vue 共享 —
|
||||
原 IdeaDetail.vue L770-808 / ProjectDetail.vue L678-692 两处逐字一致
|
||||
@@ -181,7 +185,7 @@
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(108, 99, 255, 0.08);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-text-secondary);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
@@ -261,7 +265,7 @@
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--df-shadow-md);
|
||||
z-index: 20;
|
||||
}
|
||||
.ai-skill-item {
|
||||
@@ -374,11 +378,83 @@
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* 压缩中 spinner 图标旋转动画(原 AiChat.vue <style scoped> 抽取;
|
||||
TopBar.vue .ai-btn-spinner 按名引用,Vue 不对 @keyframes scoped 故全局可见) */
|
||||
TopBar.vue .ai-btn-spinner 按名引用,Vue 不对 @keyframes scoped 故全局可见;
|
||||
2026-10-18 动画并入 global.css df-spin 单一来源) */
|
||||
.ai-btn-spinner {
|
||||
animation: ai-btn-spin 0.9s linear infinite;
|
||||
animation: df-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes ai-btn-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
样式大重构 R0 公共类(2026-10-18 新增)
|
||||
来源:u-agent 管理台范式(D:\Downloads\u-agent),hairline 网格/发丝线行/染色两档
|
||||
染色统一走 token:底 --df-*-bg(12%)+ 边 --df-*-bd(30%)+ 同色字
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* — .df-kv: hairline 网格档案卡(gap:1px 以边框色作发丝分隔线) — */
|
||||
.df-kv {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
background: var(--df-border);
|
||||
border: 1px solid var(--df-border);
|
||||
border-radius: var(--df-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.df-kv-i {
|
||||
background: var(--df-bg-raised);
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.df-kv-k {
|
||||
font-size: var(--df-fs-micro);
|
||||
text-transform: uppercase;
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.df-kv-v {
|
||||
font-size: var(--df-fs-aux);
|
||||
font-family: var(--df-font-mono);
|
||||
margin-top: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* — .df-row: 发丝线行(末行无线) — */
|
||||
.df-row {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--df-border);
|
||||
}
|
||||
.df-row:last-child { border-bottom: none; }
|
||||
|
||||
/* — .df-note: 通知条(info/warning/danger 染色) — */
|
||||
.df-note {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--df-radius-md);
|
||||
font-size: var(--df-fs-ctrl);
|
||||
}
|
||||
.df-note--info { background: var(--df-info-bg); border: 1px solid var(--df-info-bd); color: var(--df-info); }
|
||||
.df-note--warning { background: var(--df-warning-bg); border: 1px solid var(--df-warning-bd); color: var(--df-warning); }
|
||||
.df-note--danger { background: var(--df-danger-bg); border: 1px solid var(--df-danger-bd); color: var(--df-danger); }
|
||||
|
||||
/* — .df-pill: 状态胶囊(全圆 + 染色,体系同 .df-note) — */
|
||||
.df-pill {
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: var(--df-fs-meta);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.df-pill--success { background: var(--df-success-bg); border: 1px solid var(--df-success-bd); color: var(--df-success); }
|
||||
.df-pill--warning { background: var(--df-warning-bg); border: 1px solid var(--df-warning-bd); color: var(--df-warning); }
|
||||
.df-pill--danger { background: var(--df-danger-bg); border: 1px solid var(--df-danger-bd); color: var(--df-danger); }
|
||||
.df-pill--info { background: var(--df-info-bg); border: 1px solid var(--df-info-bd); color: var(--df-info); }
|
||||
|
||||
/* — .df-tab: 下划线 tab(激活 2px accent 底线) — */
|
||||
.df-tab {
|
||||
padding: 6px 10px;
|
||||
font-size: var(--df-fs-ctrl);
|
||||
color: var(--df-text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.df-tab--active {
|
||||
color: var(--df-text);
|
||||
border-bottom-color: var(--df-accent);
|
||||
}
|
||||
|
||||
+114
-14
@@ -40,6 +40,35 @@
|
||||
--df-info: #5eaff0;
|
||||
--df-info-bg: rgba(94,175,240,0.12);
|
||||
|
||||
/* — 染色边框档(30% 边,与 .df-badge 边框同值;底 12% 由 --df-*-bg 承担)—
|
||||
消费方直接引用 token,禁写 rgba 字面量/回退值 */
|
||||
--df-success-bd: rgba(61,219,160,0.3);
|
||||
--df-warning-bd: rgba(240,199,94,0.3);
|
||||
--df-danger-bd: rgba(240,101,101,0.3);
|
||||
--df-info-bd: rgba(94,175,240,0.3);
|
||||
--df-accent-bd: rgba(123,111,240,0.3);
|
||||
|
||||
/* — 字号谱(纯整数,对齐 u-agent 13px 范式;token 必注入,消费方禁写回退值)— */
|
||||
--df-fs-micro: 9px; /* 小节头/tag/计数 */
|
||||
--df-fs-meta: 10px; /* 徽章/元数据 */
|
||||
--df-fs-aux: 11px; /* 辅助文字/列表次级 */
|
||||
--df-fs-ctrl: 12px; /* 控件文字/小按钮 */
|
||||
--df-fs-body: 13px; /* 正文基准 */
|
||||
--df-fs-title: 16px; /* 页头标题 */
|
||||
|
||||
/* — 控件高度档 — */
|
||||
--df-h-btn: 30px; /* 主按钮(.btn 合一目标) */
|
||||
--df-h-input: 26px; /* 行内输入/搜索 */
|
||||
--df-h-modal-input: 30px; /* 弹窗输入 */
|
||||
--df-h-icon-btn: 26px; /* 图标钮统一档 */
|
||||
|
||||
/* — 微内距暗刻度(chip/badge 族)— */
|
||||
--df-pad-chip: 1px 6px;
|
||||
--df-pad-badge: 3px 8px;
|
||||
|
||||
/* — 图标槽 — */
|
||||
--df-icon-gutter: 42px; /* ToolCard 图标槽(38/42 魔法数归一) */
|
||||
|
||||
/* git 变更状态色(added/modified/deleted/untracked) */
|
||||
--df-git-added: #4caf50;
|
||||
--df-git-modified: #f0a020;
|
||||
@@ -74,6 +103,13 @@
|
||||
|
||||
/* — Transition — */
|
||||
--df-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* — 叠层/中性/阴影(配色收口 2026-10-18:白叠层 64 处/中性徽章灰/阴影散档收敛入 token)— */
|
||||
--df-hover: rgba(255,255,255,0.04); /* 行 hover 叠层 */
|
||||
--df-zebra: rgba(255,255,255,0.015); /* 斑马纹 */
|
||||
--df-neutral-bg: rgba(90,99,128,0.2); /* 中性徽章底(draft/todo/archived/risk-low) */
|
||||
--df-shadow-sm: 0 2px 8px rgba(0,0,0,0.2);
|
||||
--df-shadow-md: 0 4px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* ═══ 亮色主题 ═══ */
|
||||
@@ -104,6 +140,13 @@
|
||||
--df-info: #0984e3;
|
||||
--df-info-bg: rgba(9,132,227,0.10);
|
||||
|
||||
/* 染色边框档(浅色对应值,同 alpha) */
|
||||
--df-success-bd: rgba(0,184,148,0.3);
|
||||
--df-warning-bd: rgba(225,112,85,0.3);
|
||||
--df-danger-bd: rgba(214,48,49,0.3);
|
||||
--df-info-bd: rgba(9,132,227,0.3);
|
||||
--df-accent-bd: rgba(108,92,231,0.3);
|
||||
|
||||
/* git 变更状态色(added/modified/deleted/untracked) */
|
||||
--df-git-added: #4caf50;
|
||||
--df-git-modified: #f0a020;
|
||||
@@ -115,6 +158,13 @@
|
||||
|
||||
--df-scrollbar: rgba(0,0,0,0.2);
|
||||
--df-scrollbar-hover: rgba(0,0,0,0.3);
|
||||
|
||||
/* 叠层/中性/阴影浅色档(配色收口 2026-10-18) */
|
||||
--df-hover: rgba(0,0,0,0.04);
|
||||
--df-zebra: rgba(0,0,0,0.012);
|
||||
--df-neutral-bg: rgba(90,99,128,0.12);
|
||||
--df-shadow-sm: 0 2px 8px rgba(0,0,0,0.08);
|
||||
--df-shadow-md: 0 4px 16px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@@ -124,7 +174,7 @@ html, body, #app {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 14px;
|
||||
font-size: 13px; /* 基准字号(样式大重构 2026-10-18:14→13,对齐 u-agent 高密度范式) */
|
||||
font-weight: 400;
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
@@ -134,6 +184,38 @@ html, body, #app {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ═══ 2026-10-18 整洁性波:跨文件共享样式抽取(共享才抽取原则) ═══
|
||||
来源: dup-style-scan.json(49 条跨文件重复规则),按文件族消费波替换引用 */
|
||||
|
||||
/* — Toast 族(双轨合一:AiChat/ApprovalPopup 的 .ai-toast-* 与 Knowledge/Settings 的 .toast-*) — */
|
||||
.df-toast {
|
||||
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
|
||||
padding: 8px 16px; border-radius: var(--df-radius); font-size: var(--df-fs-ctrl);
|
||||
z-index: 300; box-shadow: var(--df-shadow-md);
|
||||
}
|
||||
.df-toast--success { background: var(--df-success-bg); color: var(--df-success); border: 1px solid var(--df-success-bd); }
|
||||
.df-toast--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 1px solid var(--df-warning-bd); }
|
||||
.df-toast--danger { background: var(--df-danger-bg); color: var(--df-danger); border: 1px solid var(--df-danger-bd); }
|
||||
.df-toast--info { background: var(--df-accent-bg); color: var(--df-accent); border: 1px solid var(--df-accent-bd); }
|
||||
/* 底部就近反馈变体(Projects/Tasks/ApprovalDialog 操作类 toast,保留底部形态) */
|
||||
.df-toast--bottom { position: fixed; bottom: 24px; top: auto; }
|
||||
.df-toast-enter-active, .df-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.df-toast-enter-from, .df-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
||||
|
||||
/* — 通用动画 keyframes(各文件重复定义收敛) — */
|
||||
@keyframes df-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes df-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
|
||||
/* — 确认弹层按钮行(ConfirmDialog/AdvancedSection/AllowedDirsPanel 共享) — */
|
||||
.df-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.df-confirm-enter-active, .df-confirm-leave-active { transition: opacity 0.15s; }
|
||||
.df-confirm-enter-from, .df-confirm-leave-to { opacity: 0; }
|
||||
|
||||
/* — 工具 diff 展示块(ToolCard/ToolResultBody 共享) — */
|
||||
.df-diff-pre { margin: 0; padding: 6px 0; max-height: 240px; overflow: auto; font-family: var(--df-font-mono); font-size: var(--df-fs-aux); line-height: 1.6; background: var(--df-bg); }
|
||||
.df-diff-pre code { display: block; white-space: pre; }
|
||||
.df-diff-line { display: block; padding: 0 10px; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
/* — Scrollbar — */
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
@@ -207,22 +289,23 @@ html, body, #app {
|
||||
class 名与各页 template 保持一致,零侵入
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* — Page Header(功能内页统一:24px 标题 + 紧凑下间距)— */
|
||||
/* — Page Header(全站统一紧凑单行,对齐 u-agent 46px 顶栏范式,样式大重构 2026-10-18)— */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
height: 46px;
|
||||
margin-bottom: var(--df-gap-head);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
.page-header h1 { font-size: var(--df-fs-title); font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* — Button — */
|
||||
/* — Button(唯一基类,高度档 --df-h-btn;settings.css 副本已删除并轨,样式大重构 2026-10-18)— */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
padding: 8px 16px; /* 有效高度 30px,对齐 --df-h-btn */
|
||||
line-height: 1;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
@@ -238,9 +321,16 @@ html, body, #app {
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.btn-accent { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid rgba(61,219,160,0.3); }
|
||||
/* 并轨自 settings.css 副本:ghost 禁用态(原 global 缺此条) */
|
||||
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-accent { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success-bd); }
|
||||
.btn-accent:hover { background: rgba(61,219,160,0.22); }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
/* 2026-10-18 整洁波收敛:实底危险钮(AdvancedSection/AllowedDirsPanel 两处重复抽取;
|
||||
Projects 页描边款 btn-danger 属本页特有,保留其本地覆盖) */
|
||||
.btn-danger { background: var(--df-danger); color: #fff; }
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
|
||||
.btn-link:hover { text-decoration: underline; }
|
||||
.btn-link-danger { color: var(--df-danger); }
|
||||
@@ -348,16 +438,16 @@ html, body, #app {
|
||||
font-family: var(--df-font-mono); font-size: 10px; padding: 3px 8px;
|
||||
border-radius: var(--df-radius-sm); display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.df-badge--success { background: var(--df-success-bg); color: var(--df-success); border: 1px solid rgba(61,219,160,0.3); }
|
||||
.df-badge--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 1px solid rgba(240,199,94,0.3); }
|
||||
.df-badge--danger { background: var(--df-danger-bg); color: var(--df-danger); border: 1px solid rgba(240,101,101,0.3); }
|
||||
.df-badge--info { background: var(--df-info-bg); color: var(--df-info); border: 1px solid rgba(94,175,240,0.3); }
|
||||
.df-badge--accent { background: var(--df-accent-soft); color: var(--df-accent); border: 1px solid rgba(123,111,240,0.3); }
|
||||
.df-badge--success { background: var(--df-success-bg); color: var(--df-success); border: 1px solid var(--df-success-bd); }
|
||||
.df-badge--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 1px solid var(--df-warning-bd); }
|
||||
.df-badge--danger { background: var(--df-danger-bg); color: var(--df-danger); border: 1px solid var(--df-danger-bd); }
|
||||
.df-badge--info { background: var(--df-info-bg); color: var(--df-info); border: 1px solid var(--df-info-bd); }
|
||||
.df-badge--accent { background: var(--df-accent-soft); color: var(--df-accent); border: 1px solid var(--df-accent-bd); }
|
||||
|
||||
/* — 标签(tag,列表项内联小标签) — */
|
||||
.df-tag {
|
||||
font-size: 9px; font-family: var(--df-font-mono); padding: 1px 5px;
|
||||
border-radius: 3px; background: var(--df-bg-card-hover); color: var(--df-text-secondary);
|
||||
border-radius: var(--df-radius-xs); background: var(--df-bg-card-hover); color: var(--df-text-secondary);
|
||||
}
|
||||
.df-tag--info { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.df-tag--accent { background: var(--df-accent-bg); color: var(--df-accent); }
|
||||
@@ -375,3 +465,13 @@ html, body, #app {
|
||||
border-radius: var(--df-radius); font-size: 11px; color: var(--df-text-secondary);
|
||||
}
|
||||
.df-hstat b { font-family: var(--df-font-mono); font-size: 12px; color: var(--df-text); }
|
||||
|
||||
/* — 小节头(样式大重构 2026-10-18 新增,对标 u-agent .grp/.sec-h 范式) — */
|
||||
.df-sec-h {
|
||||
font-size: var(--df-fs-meta);
|
||||
font-weight: 600;
|
||||
font-family: var(--df-font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
|
||||
+25
-48
@@ -129,9 +129,10 @@
|
||||
{{ $t('aiChat.popupOpenPanel') }}
|
||||
</button>
|
||||
|
||||
<!-- P1-e-d:审批失败 toast(窗口内自包含 useToast;独立 WebviewWindow 无根 toast,自管渲染) -->
|
||||
<Transition name="ai-toast">
|
||||
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
|
||||
<!-- P1-e-d:审批失败 toast(窗口内自包含 useToast;独立 WebviewWindow 无根 toast,自管渲染);
|
||||
样式消费全局 .df-toast 族,error 映射 danger,定位经 scoped 覆写让出 header -->
|
||||
<Transition name="df-toast">
|
||||
<div v-if="toast.visible" class="df-toast" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
|
||||
</Transition>
|
||||
|
||||
<!-- M5:path 类 deny 二次确认(拒绝不可逆,与 ToolCard 同款;独立窗口本地 ConfirmDialog,
|
||||
@@ -527,11 +528,11 @@ 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 var(--df-accent-soft);
|
||||
box-shadow: var(--df-shadow-md), 0 0 0 1px var(--df-accent-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
.popup-shell[data-dragging="true"] {
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 0 2px var(--df-accent);
|
||||
box-shadow: var(--df-shadow-md), 0 0 0 2px var(--df-accent);
|
||||
}
|
||||
|
||||
/* 顶部拖动条 */
|
||||
@@ -541,7 +542,7 @@ function onDragEnd() { isDragging.value = false }
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: linear-gradient(135deg, var(--df-accent-bg), transparent);
|
||||
border-bottom: 0.5px solid var(--df-border, rgba(123, 111, 240, 0.2));
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -632,9 +633,9 @@ function onDragEnd() { isDragging.value = false }
|
||||
transition: background 0.15s var(--df-ease);
|
||||
}
|
||||
.popup-item--path { border-left-color: var(--df-accent); }
|
||||
.popup-item--risk { border-left-color: var(--df-warning, #f0a020); }
|
||||
.popup-item--risk { border-left-color: var(--df-warning); }
|
||||
.popup-item:hover {
|
||||
background: var(--df-sidebar-active, rgba(123, 111, 240, 0.12));
|
||||
background: var(--df-sidebar-active);
|
||||
}
|
||||
|
||||
.popup-item-header {
|
||||
@@ -666,8 +667,8 @@ function onDragEnd() { isDragging.value = false }
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.popup-item-tag--risk {
|
||||
background: rgba(240, 160, 32, 0.18);
|
||||
color: var(--df-warning, #f0a020);
|
||||
background: var(--df-warning-bg);
|
||||
color: var(--df-warning);
|
||||
}
|
||||
|
||||
.popup-item-reason {
|
||||
@@ -695,16 +696,16 @@ function onDragEnd() { isDragging.value = false }
|
||||
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);
|
||||
color: var(--df-text-dim);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg-card);
|
||||
}
|
||||
.popup-item-wait.is-long {
|
||||
color: var(--df-warning, #d97706);
|
||||
color: var(--df-warning);
|
||||
animation: pulseGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
.popup-item-wait.is-urgent {
|
||||
color: var(--df-danger, #e5484d);
|
||||
color: var(--df-danger);
|
||||
animation: pulseGlow 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -741,7 +742,7 @@ function onDragEnd() { isDragging.value = false }
|
||||
height: 13px;
|
||||
padding: 0 3px;
|
||||
margin-left: 1px;
|
||||
border-radius: var(--df-radius-xs, 3px);
|
||||
border-radius: var(--df-radius-xs);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
@@ -754,7 +755,7 @@ function onDragEnd() { isDragging.value = false }
|
||||
color: #fff;
|
||||
}
|
||||
.popup-btn--approve:hover:not(:disabled) {
|
||||
background: var(--df-accent-hover, #6558d8);
|
||||
background: var(--df-accent-hover);
|
||||
}
|
||||
/* M4:session 按钮(弱填充态,介于 approve 强填充与 always 描边之间,视觉层级 once>session>always) */
|
||||
.popup-btn--session {
|
||||
@@ -776,11 +777,11 @@ function onDragEnd() { isDragging.value = false }
|
||||
.popup-btn--reject {
|
||||
background: var(--df-sidebar-hover);
|
||||
color: var(--df-text-dim);
|
||||
border: 0.5px solid var(--df-border, rgba(255, 255, 255, 0.1));
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
.popup-btn--reject:hover:not(:disabled) {
|
||||
background: rgba(255, 80, 80, 0.15);
|
||||
color: var(--df-danger, #e5484d);
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.popup-btn-spinner {
|
||||
width: 10px;
|
||||
@@ -800,7 +801,7 @@ function onDragEnd() { isDragging.value = false }
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-top: 0.5px solid var(--df-border, rgba(123, 111, 240, 0.2));
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
font-size: 12px;
|
||||
@@ -813,31 +814,7 @@ function onDragEnd() { isDragging.value = false }
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes df-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* P1-e-d:审批失败 toast(自包含 useToast;独立窗口无根 toast 自管渲染。fixed 相对本窗口视口,
|
||||
top 让出 header,不遮挡审批按钮区。样式对齐 AiChat .ai-toast 同款,scoped 防冲突) */
|
||||
.ai-toast {
|
||||
position: fixed;
|
||||
top: 44px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
max-width: 80%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||||
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
||||
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
|
||||
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
||||
/* P1-e-d:审批失败 toast:消费 global.css .df-toast 四色族+过渡(2026-10-18 整洁波并轨),
|
||||
仅覆写浮窗上下文定位——top 让出拖动条 header,窄窗(380px)限宽防长错误文案溢出窗口 */
|
||||
.df-toast { top: 44px; max-width: 80%; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
+23
-40
@@ -12,11 +12,11 @@
|
||||
|
||||
<!-- Tab 导航 -->
|
||||
<nav class="detail-tabs audit-tabs">
|
||||
<button class="tab-btn" :class="{ 'tab-active': activeTab === 'audit' }" type="button" @click="switchTab('audit')">
|
||||
<button class="df-tab" :class="{ 'df-tab--active': activeTab === 'audit' }" type="button" @click="switchTab('audit')">
|
||||
<svg aria-hidden="true" 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')">
|
||||
<button class="df-tab" :class="{ 'df-tab--active': activeTab === 'conversations' }" type="button" @click="switchTab('conversations')">
|
||||
<svg aria-hidden="true" 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>
|
||||
@@ -91,11 +91,11 @@
|
||||
<td colspan="7">
|
||||
<div class="audit-detail">
|
||||
<div class="detail-section">
|
||||
<span class="detail-label">{{ t('auditLog.col.args') }}</span>
|
||||
<span class="df-sec-h">{{ t('auditLog.col.args') }}</span>
|
||||
<pre class="detail-pre">{{ r.arguments_brief || '—' }}</pre>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<span class="detail-label">{{ t('auditLog.col.result') }}</span>
|
||||
<span class="df-sec-h">{{ t('auditLog.col.result') }}</span>
|
||||
<!-- 外置句柄(D2a):显「结果已外置」+ 展开经 IPC 读回原文全文 -->
|
||||
<template v-if="offloadHandle">
|
||||
<OffloadedResultBar
|
||||
@@ -512,12 +512,9 @@ onMounted(() => {
|
||||
/* ===== 加载/错误状态 ===== */
|
||||
.audit-loading { display: flex; align-items: center; justify-content: center; padding: 40px; }
|
||||
.audit-error { color: var(--df-danger); }
|
||||
.audit-spinner { animation: audit-spin 0.9s linear infinite; }
|
||||
/* 消费 global.css df-spin(原本地 audit-spin 重复定义已删,2026-10-18 整洁波) */
|
||||
.audit-spinner { animation: df-spin 0.9s linear infinite; }
|
||||
.audit-spinner--lg { color: var(--df-text-dim); }
|
||||
@keyframes audit-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== 表格 ===== */
|
||||
.audit-table-wrap {
|
||||
@@ -550,9 +547,9 @@ onMounted(() => {
|
||||
vertical-align: top;
|
||||
}
|
||||
.audit-table tbody tr:last-child td { border-bottom: none; }
|
||||
.audit-table tbody tr:hover td { background: var(--df-bg-hover, rgba(255,255,255,0.03)); }
|
||||
.audit-table tbody tr:hover td { background: var(--df-hover); }
|
||||
/* 展开/hover 行高亮由 .audit-row:hover/.expanded 管理,避免选择器冲突 */
|
||||
.audit-table tbody tr.audit-row:hover td { background: var(--df-bg-hover, rgba(255,255,255,0.03)); }
|
||||
.audit-table tbody tr.audit-row:hover td { background: var(--df-hover); }
|
||||
|
||||
.col-time { min-width: 120px; }
|
||||
.col-tool { min-width: 130px; }
|
||||
@@ -573,14 +570,14 @@ onMounted(() => {
|
||||
|
||||
/* 可点击行 */
|
||||
.audit-row { cursor: pointer; transition: background 0.12s; }
|
||||
.audit-row:hover td { background: var(--df-bg-hover, rgba(255,255,255,0.03)); }
|
||||
.audit-row.expanded td { background: var(--df-bg-hover, rgba(255,255,255,0.04)); }
|
||||
.audit-row:hover td { background: var(--df-hover); }
|
||||
.audit-row.expanded td { background: var(--df-hover); }
|
||||
|
||||
/* 展开详情行 */
|
||||
.audit-detail-row td { padding: 0 !important; border-bottom: 0.5px solid var(--df-border); }
|
||||
.audit-detail { padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.detail-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.detail-label { font-size: 11px; color: var(--df-text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
/* 小节头走公共 .df-sec-h */
|
||||
.detail-pre {
|
||||
margin: 0; padding: 8px 10px;
|
||||
background: var(--df-bg);
|
||||
@@ -604,7 +601,7 @@ onMounted(() => {
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
background: var(--df-accent-bg);
|
||||
padding: 1px 6px;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
@@ -621,7 +618,7 @@ onMounted(() => {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: var(--df-warning-bg);
|
||||
color: var(--df-warning);
|
||||
@@ -636,7 +633,7 @@ onMounted(() => {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.risk-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.risk-low { background: var(--df-neutral-bg); color: var(--df-text-dim); }
|
||||
.risk-medium { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.risk-high { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
|
||||
@@ -645,7 +642,7 @@ onMounted(() => {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 3px 10px;
|
||||
padding: var(--df-pad-badge);
|
||||
border-radius: var(--df-radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -655,7 +652,7 @@ onMounted(() => {
|
||||
.status-executing { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.status-completed { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.status-failed { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.status-interrupted { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-interrupted { background: var(--df-neutral-bg); color: var(--df-text-dim); }
|
||||
|
||||
/* 决策者 tag */
|
||||
.decided-tag {
|
||||
@@ -667,7 +664,7 @@ onMounted(() => {
|
||||
white-space: nowrap; /* 窄列防 CJK 逐字竖排(对齐 .status-tag) */
|
||||
}
|
||||
.decided-human { background: var(--df-accent-bg); color: var(--df-accent); }
|
||||
.decided-auto { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.decided-auto { background: var(--df-neutral-bg); color: var(--df-text-dim); }
|
||||
.decided-none { color: var(--df-text-dim); }
|
||||
|
||||
/* ===== 分页 ===== */
|
||||
@@ -691,29 +688,15 @@ onMounted(() => {
|
||||
|
||||
/* empty-state 已提取到 global.css 全局 */
|
||||
|
||||
/* ===== Tab 导航(对齐 ProjectDetail 同款交互) ===== */
|
||||
/* ===== Tab 导航(公共 .df-tab 下划线范式,此处仅补底线叠齐/hover) ===== */
|
||||
.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: color 0.15s, border-color 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);
|
||||
}
|
||||
.audit-tabs .df-tab { margin-bottom: -0.5px; }
|
||||
.audit-tabs .df-tab:hover { color: var(--df-text); }
|
||||
|
||||
/* ===== 对话历史 ===== */
|
||||
.conv-split {
|
||||
@@ -782,9 +765,9 @@ onMounted(() => {
|
||||
}
|
||||
.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;
|
||||
font-size: 10px; padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(90,99,128,0.2); color: var(--df-text-dim);
|
||||
background: var(--df-neutral-bg); 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; }
|
||||
@@ -818,7 +801,7 @@ onMounted(() => {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-warning-bg);
|
||||
border: 0.5px solid rgba(240,199,94,0.2);
|
||||
border: 0.5px solid var(--df-warning-bd);
|
||||
}
|
||||
.conv-msg-toolcall-head {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 4px;
|
||||
|
||||
+7
-25
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- ═══ Header ═══ -->
|
||||
<header class="dash-header">
|
||||
<!-- ═══ Header(全局 .page-header 46px 紧凑单行,标题/间距不再自造) ═══ -->
|
||||
<header class="page-header">
|
||||
<div class="dash-header-left">
|
||||
<h1 class="dash-title">{{ $t('dashboard.title') }}</h1>
|
||||
<h1>{{ $t('dashboard.title') }}</h1>
|
||||
<span class="dash-subtitle">{{ $t('dashboard.subtitle') }}</span>
|
||||
</div>
|
||||
<div class="dash-header-right">
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost btn-sm dash-refresh-btn" @click="refresh" :disabled="loading" :title="$t('dashboard.refresh')" :aria-label="$t('dashboard.refresh')">
|
||||
<svg aria-hidden="true" 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 aria-hidden="true" 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>
|
||||
@@ -111,20 +111,7 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.dashboard { padding: 16px 20px 20px; }
|
||||
|
||||
/* ═══ Header ═══ */
|
||||
.dash-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dash-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.4px;
|
||||
color: var(--df-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
/* ═══ Header(容器/标题走全局 .page-header,此处仅保留副标题装饰) ═══ */
|
||||
.dash-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
@@ -140,14 +127,9 @@ onMounted(() => {
|
||||
background: var(--df-success);
|
||||
display: inline-block;
|
||||
}
|
||||
.dash-header-right { display: flex; gap: 6px; align-items: center; }
|
||||
|
||||
/* 刷新按钮图标旋转动画(加载中反馈) */
|
||||
.dash-spinner { animation: dash-spin 0.9s linear infinite; }
|
||||
@keyframes dash-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
/* 刷新按钮图标旋转动画(加载中反馈);消费 global.css df-spin(原本地 dash-spin 重复定义已删) */
|
||||
.dash-spinner { animation: df-spin 0.9s linear infinite; }
|
||||
|
||||
/* ═══ Grid Layout ═══ */
|
||||
.dash-grid {
|
||||
|
||||
+4
-9
@@ -606,12 +606,6 @@ watch(() => route.params.id, (id) => {
|
||||
.idea-card:hover { background: var(--df-accent-bg); }
|
||||
.idea-card.selected { background: var(--df-accent-soft); border: 0.5px solid var(--df-accent); }
|
||||
|
||||
.idea-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.idea-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.idea-score {
|
||||
@@ -620,9 +614,10 @@ watch(() => route.params.id, (id) => {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.score-high { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.score-mid { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.score-low { background: rgba(255,107,107,0.15); color: var(--df-danger); }
|
||||
/* 评分徽章底统一走语义 token(双主题自适应) */
|
||||
.score-high { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.score-mid { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.score-low { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
|
||||
.idea-desc-preview { font-size: 12px; color: var(--df-text-dim); margin-bottom: 8px; line-height: 1.4; }
|
||||
|
||||
|
||||
+23
-38
@@ -8,9 +8,9 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ⑤ 操作反馈 toast(保存/发布/拒绝/归档/创建成功 + 保存失败)-->
|
||||
<Transition name="toast">
|
||||
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
|
||||
<!-- ⑤ 操作反馈 toast(保存/发布/拒绝/归档/创建成功 + 保存失败);样式消费全局 .df-toast 族,error 映射 danger -->
|
||||
<Transition name="df-toast">
|
||||
<div v-if="toast.visible" class="df-toast" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 错误条:消费 store.error(样式全局 .error-banner,仅间距用页面变量) -->
|
||||
@@ -21,20 +21,20 @@
|
||||
|
||||
<!-- 顶层 Tab: 知识库 / 审核收件箱 -->
|
||||
<div class="top-tabs">
|
||||
<button class="top-tab" :class="{ active: topTab === 'library' }" @click="switchTopTab('library')">
|
||||
<button class="df-tab" :class="{ 'df-tab--active': topTab === 'library' }" @click="switchTopTab('library')">
|
||||
{{ $t('knowledge.tab.library') }}
|
||||
</button>
|
||||
<button class="top-tab" :class="{ active: topTab === 'inbox' }" @click="switchTopTab('inbox')">
|
||||
<button class="df-tab" :class="{ 'df-tab--active': topTab === 'inbox' }" @click="switchTopTab('inbox')">
|
||||
{{ $t('knowledge.tab.inbox') }}
|
||||
<span v-if="store.candidates.length" class="inbox-badge">{{ store.candidates.length }}</span>
|
||||
</button>
|
||||
<!-- 归档箱(2026-10-05 归档功能尾巴):低频可见可恢复,与回收站/正式库分池 -->
|
||||
<button class="top-tab" :class="{ active: topTab === 'archived' }" @click="switchTopTab('archived')">
|
||||
<button class="df-tab" :class="{ 'df-tab--active': topTab === 'archived' }" @click="switchTopTab('archived')">
|
||||
🗄️ {{ $t('knowledge.tab.archived') }}
|
||||
<span v-if="store.archived.length" class="inbox-badge">{{ store.archived.length }}</span>
|
||||
</button>
|
||||
<!-- 拒绝池(2026-10-05 分池):审核否决独立可见,与归档分池 -->
|
||||
<button class="top-tab" :class="{ active: topTab === 'rejected' }" @click="switchTopTab('rejected')">
|
||||
<button class="df-tab" :class="{ 'df-tab--active': topTab === 'rejected' }" @click="switchTopTab('rejected')">
|
||||
🚫 {{ $t('knowledge.tab.rejected') }}
|
||||
<span v-if="store.rejected.length" class="inbox-badge">{{ store.rejected.length }}</span>
|
||||
</button>
|
||||
@@ -487,35 +487,19 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.knowledge { padding: 16px 20px 20px; height: 100%; display: flex; flex-direction: column; container-type: inline-size; }
|
||||
|
||||
/* ===== 顶部 Toast 提示(⑤ 操作反馈,对齐 Settings.vue)===== */
|
||||
.toast {
|
||||
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 1000; padding: 8px 16px; border-radius: var(--df-radius-sm);
|
||||
font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||
.toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||||
.toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
||||
.toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
|
||||
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
||||
/* 顶部 Toast 提示:已消费 global.css .df-toast 四色族+过渡(2026-10-18 整洁波并轨) */
|
||||
|
||||
/* page-header / btn / modal 等已提取到 global.css 全局 */
|
||||
|
||||
/* ===== 错误条样式已提取到 styles/components.css 全局(.error-banner) ===== */
|
||||
|
||||
/* ===== 顶层 Tab ===== */
|
||||
/* ===== 顶层 Tab(公共 .df-tab 下划线范式,此处仅补本页布局/hover) ===== */
|
||||
.top-tabs { display: flex; gap: 4px; margin-bottom: var(--df-gap-page); border-bottom: 0.5px solid var(--df-border); }
|
||||
.top-tab {
|
||||
padding: 8px 16px; border: none; background: transparent; color: var(--df-text-secondary);
|
||||
font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; transition: color 0.15s, border-color 0.15s;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.top-tab:hover { color: var(--df-text); }
|
||||
.top-tab.active { color: var(--df-accent); border-bottom-color: var(--df-accent); }
|
||||
.top-tabs .df-tab { display: flex; align-items: center; gap: 6px; }
|
||||
.top-tabs .df-tab:hover { color: var(--df-text); }
|
||||
.inbox-badge {
|
||||
font-size: 11px; background: var(--df-danger); color: #fff;
|
||||
padding: 1px 6px; border-radius: var(--df-radius); min-width: 16px; text-align: center;
|
||||
padding: var(--df-pad-chip); border-radius: var(--df-radius); min-width: 16px; text-align: center;
|
||||
}
|
||||
|
||||
/* ===== 左右分栏 ===== */
|
||||
@@ -524,7 +508,7 @@ onMounted(() => {
|
||||
flex: 1; min-height: 0;
|
||||
}
|
||||
.kn-list-panel, .kn-detail-panel {
|
||||
background: var(--df-bg-panel, var(--df-bg-card));
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
overflow: hidden; display: flex; flex-direction: column;
|
||||
@@ -564,7 +548,7 @@ onMounted(() => {
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.kn-card-meta { display: flex; gap: 8px; align-items: center; font-size: 11px; color: var(--df-text-dim); flex-wrap: wrap; }
|
||||
.meta-kind { background: rgba(90,99,128,0.2); padding: 1px 6px; border-radius: var(--df-radius-sm); }
|
||||
.meta-kind { background: var(--df-neutral-bg); padding: var(--df-pad-chip); border-radius: var(--df-radius-sm); }
|
||||
.meta-reuse { color: var(--df-info); }
|
||||
.list-empty { text-align: center; padding: 40px 0; color: var(--df-text-dim); font-size: 13px; }
|
||||
|
||||
@@ -574,14 +558,15 @@ onMounted(() => {
|
||||
|
||||
/* 状态徽章 / 置信度(列表卡片复用,详情由子组件 scoped 自带) */
|
||||
.kn-card-status { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: var(--df-radius-xs); white-space: nowrap; }
|
||||
.st-candidate { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.st-pending_review { background: rgba(108,159,255,0.15); color: var(--df-info); }
|
||||
.st-published { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.st-archived { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.st-rejected { background: rgba(240,84,110,0.15); color: var(--df-danger); }
|
||||
.conf-high { background: rgba(100,255,218,0.15); color: var(--df-success); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
.conf-medium { background: rgba(255,217,61,0.15); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
.conf-low { background: rgba(255,107,107,0.15); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||
/* 状态/置信度徽章底统一走语义 token(双主题自适应) */
|
||||
.st-candidate { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.st-pending_review { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.st-published { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.st-archived { background: var(--df-neutral-bg); color: var(--df-text-dim); }
|
||||
.st-rejected { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.conf-high { background: var(--df-success-bg); color: var(--df-success); padding: var(--df-pad-chip); border-radius: var(--df-radius-xs); }
|
||||
.conf-medium { background: var(--df-warning-bg); color: var(--df-warning); padding: var(--df-pad-chip); border-radius: var(--df-radius-xs); }
|
||||
.conf-low { background: var(--df-danger-bg); color: var(--df-danger); padding: var(--df-pad-chip); border-radius: var(--df-radius-xs); }
|
||||
|
||||
/* form-row / form-input / form-select / form-textarea 已随新建/编辑弹层随 KnowledgeDetail 子组件迁移,此处仅保留本壳所需特有覆盖。 */
|
||||
.form-row { margin-bottom: 12px; }
|
||||
|
||||
+57
-57
@@ -66,24 +66,24 @@
|
||||
<!-- Tab 导航(概览 / 文件浏览器) -->
|
||||
<nav class="detail-tabs">
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-active': activeTab === 'overview' }"
|
||||
class="df-tab"
|
||||
:class="{ 'df-tab--active': activeTab === 'overview' }"
|
||||
type="button"
|
||||
@click="setActiveTab('overview')"
|
||||
>
|
||||
{{ $t('projectDetail.tabOverview') }}
|
||||
</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-active': activeTab === 'files' }"
|
||||
class="df-tab"
|
||||
:class="{ 'df-tab--active': activeTab === 'files' }"
|
||||
type="button"
|
||||
@click="setActiveTab('files')"
|
||||
>
|
||||
{{ $t('fileExplorer.tabTitle') }}
|
||||
</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-active': activeTab === 'graph' }"
|
||||
class="df-tab"
|
||||
:class="{ 'df-tab--active': activeTab === 'graph' }"
|
||||
type="button"
|
||||
@click="setActiveTab('graph')"
|
||||
>
|
||||
@@ -177,7 +177,7 @@
|
||||
|
||||
<!-- 工程列表(hover 显行内操作) -->
|
||||
<div v-else class="module-list-overview">
|
||||
<div v-for="m in modules" :key="m.id" class="module-row-overview">
|
||||
<div v-for="m in modules" :key="m.id" class="module-row-overview df-row">
|
||||
<div class="module-row-head">
|
||||
<span class="module-name-overview">{{ m.name }}</span>
|
||||
<span v-if="moduleStatusOf(m) === 'archived'" class="module-status-badge module-status-archived">{{ $t('projectDetail.moduleStatusArchived') }}</span>
|
||||
@@ -1335,17 +1335,13 @@ onBeforeUnmount(() => {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 发丝线分隔行走全局 .df-row(8px 0 + border-bottom + 末行无线),此处只管行内布局 */
|
||||
.module-row-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--df-border, rgba(0, 0, 0, 0.06));
|
||||
min-width: 0;
|
||||
}
|
||||
.module-row-overview:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.module-row-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -1387,30 +1383,31 @@ onBeforeUnmount(() => {
|
||||
gap: 4px;
|
||||
}
|
||||
.module-stack-overview .tech-tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
font-size: var(--df-fs-aux);
|
||||
padding: var(--df-pad-chip);
|
||||
}
|
||||
.module-status-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--df-fs-aux);
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 模块状态徽章底统一语义 token(双主题自适应) */
|
||||
.module-status-active {
|
||||
color: var(--df-success, #16a34a);
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
color: var(--df-success);
|
||||
background: var(--df-success-bg);
|
||||
}
|
||||
.module-status-archived {
|
||||
color: var(--df-text-dim, #888);
|
||||
background: rgba(136, 136, 136, 0.12);
|
||||
color: var(--df-text-dim);
|
||||
background: var(--df-neutral-bg);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-lg);
|
||||
font-size: 11px;
|
||||
padding: var(--df-pad-badge);
|
||||
border-radius: var(--df-radius-xs);
|
||||
font-size: var(--df-fs-aux);
|
||||
font-weight: 500;
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
@@ -1499,30 +1496,26 @@ onBeforeUnmount(() => {
|
||||
.project-detail { padding: 16px 20px 20px; display: flex; flex-direction: column; height: 100%; min-height: 0; max-height: 100%; overflow: hidden; }
|
||||
|
||||
/* ===== Tab 导航(Batch 10 文件浏览器) ===== */
|
||||
/* 按钮走全局 .df-tab/.df-tab--active(下划线范式),此处仅归零 button 默认样式;
|
||||
不动 border-bottom,激活底线交由全局 --active 染色 */
|
||||
.detail-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
.detail-tabs .df-tab {
|
||||
background: none;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
font-family: inherit;
|
||||
margin-bottom: -0.5px; /* 压盖容器发丝线,激活底线与容器边线重合 */
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
margin-bottom: -0.5px;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
.detail-tabs .df-tab:hover {
|
||||
color: var(--df-text);
|
||||
}
|
||||
.tab-btn.tab-active {
|
||||
color: var(--df-text);
|
||||
border-bottom-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* 文件浏览器容器(弹性填充,内部 FileExplorer 自管布局) */
|
||||
.file-explorer-wrap {
|
||||
@@ -1536,9 +1529,9 @@ onBeforeUnmount(() => {
|
||||
/* .back-link / .back-link:hover 已提取到 styles/global.css 全局 */
|
||||
|
||||
.stage-badge {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--df-radius-lg);
|
||||
font-size: var(--df-fs-ctrl);
|
||||
padding: var(--df-pad-badge);
|
||||
border-radius: var(--df-radius-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* 阶段色全走语义 token(对齐全站约定:releasing/completed=success,见 ActiveProjectsPanel dot-release) */
|
||||
@@ -1554,6 +1547,13 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* .btn 系列(btn/btn-primary/btn-ghost/btn-sm/btn-danger)已提取到 styles/global.css 全局 */
|
||||
|
||||
/* 同步/扫描/保存按钮内 spinner(此前本页无 .spinner 定义致加载态不可见;消费全局 df-spin) */
|
||||
.spinner {
|
||||
width: 12px; height: 12px;
|
||||
border: 1.5px solid var(--df-border); border-top-color: var(--df-accent);
|
||||
border-radius: 50%; animation: df-spin 0.8s linear infinite; display: inline-block;
|
||||
}
|
||||
|
||||
/* ===== 概览布局(P1-g+ 问题10②:三栏拥挤 → 两栏 + 日志下方)=====
|
||||
.detail-overview 整体滚动;内部 .detail-grid 两栏(项目信息 + 任务列表),
|
||||
.workflow-log-panel 跨整宽放下方。无日志记录时折叠为摘要不占整栏。 */
|
||||
@@ -1602,12 +1602,12 @@ onBeforeUnmount(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
padding: var(--df-pad-badge);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 10px;
|
||||
font-size: var(--df-fs-meta);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
@@ -1624,13 +1624,13 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
/* 状态色类已全局化至 components.css,scoped 不重复定义 */
|
||||
|
||||
/* A2[PD-P1-3]:工作流日志「全局事件」标注(次要小字标签) */
|
||||
/* A2[PD-P1-3]:工作流日志「全局事件」标注(次要小字标签,背景走中性徽章 token) */
|
||||
.global-event-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: var(--df-fs-meta);
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(136, 136, 136, 0.12);
|
||||
background: var(--df-neutral-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1674,7 +1674,7 @@ onBeforeUnmount(() => {
|
||||
.task-card:last-child { border-bottom: none; }
|
||||
.task-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.task-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.task-status { font-size: 11px; padding: 4px 10px; border-radius: var(--df-radius-sm); }
|
||||
.task-status { font-size: var(--df-fs-aux); padding: var(--df-pad-badge); border-radius: var(--df-radius-xs); }
|
||||
/* 状态色类已全局化至 components.css;此处仅保留本视图特有的进度覆盖 */
|
||||
.status-progress { background: var(--df-accent-soft); color: var(--df-accent); }
|
||||
|
||||
@@ -1686,12 +1686,12 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.branch-icon { color: var(--df-accent); font-size: 13px; }
|
||||
.branch-name {
|
||||
font-size: 12px;
|
||||
font-size: var(--df-fs-ctrl);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--df-text-secondary);
|
||||
background: var(--df-accent-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.task-meta {
|
||||
display: flex;
|
||||
@@ -1711,7 +1711,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 0.5px solid rgba(42,42,74,0.5);
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.log-item:last-child { border-bottom: none; }
|
||||
.log-time { color: var(--df-text-dim); min-width: 64px; }
|
||||
@@ -1719,9 +1719,9 @@ onBeforeUnmount(() => {
|
||||
min-width: 42px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: var(--df-fs-meta);
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
text-align: center;
|
||||
}
|
||||
.log-info .log-level { color: var(--df-info); }
|
||||
|
||||
+8
-38
@@ -206,8 +206,8 @@
|
||||
<!-- 确认弹层(删除/彻底删除,替代原生 window.confirm) -->
|
||||
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
|
||||
|
||||
<!-- 批量导入结果 toast -->
|
||||
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
|
||||
<!-- 批量导入结果 toast(全局 .df-toast 族+底部变体;error 映射 danger) -->
|
||||
<div v-if="toast.visible" class="df-toast df-toast--bottom" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -524,8 +524,8 @@ async function runImport() {
|
||||
|
||||
/* page-header / btn / modal 系列已提取到 global.css 全局 */
|
||||
/* 本页特有:.btn-danger(全局尚未提供)、.btn:disabled(全局仅 .btn-primary:disabled) */
|
||||
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid rgba(240,101,101,0.4); }
|
||||
.btn-danger:hover { background: rgba(240,101,101,0.12); }
|
||||
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid var(--df-danger-bd); }
|
||||
.btn-danger:hover { background: var(--df-danger-bg); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* ===== 卡片网格 ===== */
|
||||
@@ -565,27 +565,7 @@ async function runImport() {
|
||||
color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ===== 进度条 ===== */
|
||||
.card-progress { margin-bottom: 16px; }
|
||||
.progress-label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.progress-stage { font-size: 12px; color: var(--df-text-dim); }
|
||||
.progress-pct { font-size: 12px; font-weight: 500; color: var(--df-text); }
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--df-accent);
|
||||
border-radius: var(--df-radius-sm);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
/* 进度条族(.card-progress/.progress-*)已随卡片渲染迁至 ProjectCard.vue,本页残留定义已删(2026-10-18 整洁波) */
|
||||
|
||||
/* ===== 技术栈标签:基础样式已收敛至全局 components.css(DRY 收口 ),
|
||||
创建/导入模态与本页 ProjectCard 渲染的标签均走全局 .tech-tag/.card-tags。 ===== */
|
||||
@@ -606,7 +586,7 @@ async function runImport() {
|
||||
.error-banner {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 8px 12px; margin-bottom: var(--df-gap-page);
|
||||
background: rgba(255,107,107,0.12); border: 0.5px solid var(--df-danger);
|
||||
background: var(--df-danger-bg); border: 0.5px solid var(--df-danger);
|
||||
border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px;
|
||||
}
|
||||
.error-text { word-break: break-word; }
|
||||
@@ -653,20 +633,10 @@ async function runImport() {
|
||||
.import-table .col-path { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--df-font-mono, monospace); font-size: 11px; }
|
||||
.row-disabled { opacity: 0.5; }
|
||||
.imp-name { font-weight: 500; color: var(--df-text); }
|
||||
.mono-tag { margin-left: 6px; font-size: 10px; padding: 1px 6px; border-radius: var(--df-radius-xs); background: rgba(255,217,61,0.15); color: var(--df-warning); border: 0.5px solid var(--df-border); }
|
||||
.mono-tag { margin-left: 6px; font-size: 10px; padding: var(--df-pad-chip); border-radius: var(--df-radius-xs); background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-border); }
|
||||
.bound-mark { color: var(--df-success); font-weight: 600; }
|
||||
.selection-count { margin-right: auto; font-size: 12px; color: var(--df-text-dim); }
|
||||
.import-box .modal-actions { align-items: center; }
|
||||
|
||||
/* ===== Toast ===== */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
padding: 10px 18px; border-radius: var(--df-radius-sm);
|
||||
font-size: 13px; z-index: 200; max-width: 80vw;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
.toast-info { background: var(--df-accent); color: #fff; }
|
||||
.toast-error { background: var(--df-danger); color: #fff; }
|
||||
/* ToastType 新增 'success' 兜底样式(本视图当前未用,补全避免裸类) */
|
||||
.toast-success { background: var(--df-success); color: #fff; }
|
||||
/* toast 样式已统一到全局 global.css .df-toast 族(+--bottom 底部变体) */
|
||||
</style>
|
||||
|
||||
+4
-22
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="settings">
|
||||
<Transition name="toast">
|
||||
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
|
||||
<Transition name="df-toast">
|
||||
<div v-if="toast.visible" class="df-toast" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
|
||||
</Transition>
|
||||
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="$t('common.delete')" @result="answerConfirm" />
|
||||
<!-- 页面头部 -->
|
||||
@@ -313,25 +313,7 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.settings { padding: 16px 20px 20px; }
|
||||
|
||||
/* ===== 顶部 Toast 提示 ===== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||
.toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||||
.toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
||||
/* P0-2: 保存成功明确反馈(绿色),与 info(中性强调)区分 */
|
||||
.toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
|
||||
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
||||
/* 顶部 Toast 提示:已消费 global.css .df-toast 四色族+过渡(2026-10-18 整洁波并轨) */
|
||||
|
||||
/* 确认弹层已替换为 ConfirmDialog 组件(不再需要自建 CSS) */
|
||||
|
||||
@@ -354,7 +336,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
/* .btn / .btn-primary / .btn-ghost / .btn-sm 已迁移到 settings.css 全局,
|
||||
本壳的 toast/confirm 内联按钮沿用全局基础样式;此处仅保留全局未定义的 .btn-danger。 */
|
||||
本壳无特有按钮样式(.btn-danger 由 Projects/AdvancedSection 等使用处各自定义)。 */
|
||||
|
||||
/* ===== 窄屏响应式:nav 折叠为顶部横向 tab(由 SettingsNav is-horizontal 渲染) ===== */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
+21
-24
@@ -123,12 +123,12 @@
|
||||
<!-- 基础分支(空值不显行) -->
|
||||
<div v-if="task.base_branch" class="info-item">
|
||||
<span class="label">{{ $t('taskDetail.baseBranch') }}</span>
|
||||
<span class="value mono">{{ task.base_branch }}</span>
|
||||
<span class="value df-mono">{{ task.base_branch }}</span>
|
||||
</div>
|
||||
<!-- 工作流定义(空值不显行) -->
|
||||
<div v-if="task.workflow_def_id" class="info-item">
|
||||
<span class="label">{{ $t('taskDetail.workflowDef') }}</span>
|
||||
<span class="value mono">{{ task.workflow_def_id }}</span>
|
||||
<span class="value df-mono">{{ task.workflow_def_id }}</span>
|
||||
</div>
|
||||
<!-- 创建/更新时间:最底小字(Problem4 ⑥) -->
|
||||
<div class="info-item timestamps">
|
||||
@@ -835,21 +835,18 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Problem4 ① 吸顶 header(推进按钮始终可见) */
|
||||
/* Problem4 ① 吸顶 header(推进按钮始终可见)
|
||||
高度/对齐交给全局 .page-header(46px 定高),此处仅保留吸顶所需的
|
||||
背景铺满/出血负边距/底部分割线,不再自造 padding 堆高度 */
|
||||
.task-detail-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--df-bg);
|
||||
/* 留底部细分割线,与全局 page-header margin-bottom 协同(0.5px 边框规范) */
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
/* 顶负 padding 抵消 .task-detail 的 16px 顶 padding,吸顶贴顶 */
|
||||
/* 负 margin 抵消 .task-detail 的内边距,吸顶时背景全宽贴顶 */
|
||||
margin: -16px -20px 0;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-top: 16px;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
/* header-actions 内多个推进按钮 + refresh 排列(对齐全局 .header-actions gap:10px) */
|
||||
.task-detail-header .header-actions { flex-wrap: wrap; justify-content: flex-end; }
|
||||
@@ -869,12 +866,12 @@ onBeforeUnmount(() => {
|
||||
.wf-progress-hint { color: var(--df-success); }
|
||||
.wf-progress-hint-fail { color: var(--df-danger); }
|
||||
|
||||
/* F-04 review 轮次徽章 */
|
||||
/* F-04 review 轮次徽章(背景走 warning 语义 token) */
|
||||
.review-rounds-badge {
|
||||
font-size: 11px;
|
||||
font-size: var(--df-fs-aux);
|
||||
color: var(--df-warning);
|
||||
background: rgba(255,217,61,0.12);
|
||||
padding: 3px 8px;
|
||||
background: var(--df-warning-bg);
|
||||
padding: var(--df-pad-badge);
|
||||
border-radius: var(--df-radius-xs);
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
@@ -884,8 +881,8 @@ onBeforeUnmount(() => {
|
||||
/* 状态色类已全局化至 components.css(scoped 删除,消除漂移) */
|
||||
|
||||
.priority-badge {
|
||||
font-size: 10px; font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
font-size: var(--df-fs-meta); font-weight: 500;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
/* 四档统一 color-mix 派生自语义 token(对齐 Tasks.vue 列表同款,主题切换语义不变) */
|
||||
@@ -977,16 +974,16 @@ onBeforeUnmount(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
font-size: var(--df-fs-ctrl);
|
||||
color: var(--df-text-secondary);
|
||||
background: var(--df-accent-bg);
|
||||
padding: 2px 8px;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
font-family: monospace;
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.branch-icon { color: var(--df-accent); }
|
||||
|
||||
.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
||||
/* mono 数据字走全局 .df-mono(模板类名已并轨,本地副本删除) */
|
||||
|
||||
.empty-hint {
|
||||
text-align: center; padding: 24px 12px;
|
||||
@@ -1086,7 +1083,7 @@ onBeforeUnmount(() => {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.25);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
z-index: 20;
|
||||
padding: 6px 0;
|
||||
}
|
||||
@@ -1111,7 +1108,7 @@ onBeforeUnmount(() => {
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.quick-menu-item:hover { background: rgba(255,255,255,0.06); }
|
||||
.quick-menu-item:hover { background: var(--df-hover); }
|
||||
.quick-menu-divider {
|
||||
height: 0.5px;
|
||||
background: var(--df-border);
|
||||
@@ -1152,9 +1149,9 @@ onBeforeUnmount(() => {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* 窄屏 header-actions 内推进按钮可能换行,允许并压缩间距 */
|
||||
/* 窄屏 header-actions 内推进按钮可能换行,允许并压缩间距(定高改自适应防溢出) */
|
||||
@media (max-width: 720px) {
|
||||
.task-detail-header { flex-wrap: wrap; }
|
||||
.task-detail-header { flex-wrap: wrap; height: auto; padding-top: 8px; padding-bottom: 8px; }
|
||||
.task-detail-header .header-left { flex: 1 1 100%; margin-bottom: 8px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+30
-45
@@ -151,14 +151,14 @@
|
||||
<button class="task-quick-btn" :title="$t('tasks.quickActions')" :aria-label="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
|
||||
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||
<div class="df-sec-h quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||
<button v-for="s in quickStatusesFor(row.task)" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)">
|
||||
<span>{{ s.icon }}</span>{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="quick-menu-divider"></div>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||||
<div class="df-sec-h quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||||
<button v-for="p in quickPriorities" :key="p.value" class="quick-menu-item" @click="quickPriority(row.task.id, p.value)">
|
||||
<span :class="p.cls">●</span>{{ p.label }}
|
||||
</button>
|
||||
@@ -208,14 +208,14 @@
|
||||
<button class="task-quick-btn" :title="$t('tasks.quickActions')" :aria-label="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(child.id)">⚙️</button>
|
||||
<div v-if="quickMenuId === child.id" class="quick-menu" @click.stop>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||
<div class="df-sec-h quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||
<button v-for="s in quickStatusesFor(child)" :key="s.key" class="quick-menu-item" @click="quickAdvance(child.id, s.key)">
|
||||
<span>{{ s.icon }}</span>{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="quick-menu-divider"></div>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||||
<div class="df-sec-h quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||||
<button v-for="p in quickPriorities" :key="p.value" class="quick-menu-item" @click="quickPriority(child.id, p.value)">
|
||||
<span :class="p.cls">●</span>{{ p.label }}
|
||||
</button>
|
||||
@@ -256,14 +256,14 @@
|
||||
<button class="task-quick-btn" :title="$t('tasks.quickActions')" :aria-label="$t('tasks.quickActions')" @click.stop="toggleQuickMenu(row.task.id)">⚙️</button>
|
||||
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||
<div class="df-sec-h quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
|
||||
<button v-for="s in quickStatusesFor(row.task)" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)">
|
||||
<span>{{ s.icon }}</span>{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="quick-menu-divider"></div>
|
||||
<div class="quick-menu-section">
|
||||
<div class="quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||||
<div class="df-sec-h quick-menu-label">{{ $t('tasks.quickPriority') }}</div>
|
||||
<button v-for="p in quickPriorities" :key="p.value" class="quick-menu-item" @click="quickPriority(row.task.id, p.value)">
|
||||
<span :class="p.cls">●</span>{{ p.label }}
|
||||
</button>
|
||||
@@ -405,8 +405,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷操作失败 toast(WK-13,对齐 Projects.vue 操作反馈) -->
|
||||
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
|
||||
<!-- 快捷操作失败 toast(WK-13,对齐 Projects.vue 操作反馈;全局 .df-toast 族+底部变体) -->
|
||||
<div v-if="toast.visible" class="df-toast df-toast--bottom" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
|
||||
|
||||
<!-- 确认弹层(删除任务,替代原生 window.confirm) -->
|
||||
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
|
||||
@@ -1041,8 +1041,8 @@ onUnmounted(() => {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-danger-bg, rgba(220, 38, 38, 0.08));
|
||||
color: var(--df-danger, #dc2626);
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -1132,14 +1132,13 @@ onUnmounted(() => {
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kanban-col-icon { font-size: 13px; }
|
||||
.kanban-col-name { font-size: 13px; font-weight: 500; color: var(--df-text); flex: 1; }
|
||||
.kanban-col-count {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.kanban-col-body {
|
||||
flex: 1;
|
||||
@@ -1169,7 +1168,7 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
.task-card:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); border-color: var(--df-accent); }
|
||||
.task-card:hover { box-shadow: var(--df-shadow-sm); border-color: var(--df-accent); }
|
||||
.task-card.sortable-ghost { opacity: 0.5; }
|
||||
/* 父卡禁拖:去掉抓取光标、左侧描边提示这是聚合容器卡 */
|
||||
.task-card--parent { cursor: default; border-left: 2px solid var(--df-accent); }
|
||||
@@ -1195,9 +1194,9 @@ onUnmounted(() => {
|
||||
.parent-tag {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--df-hover);
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1207,8 +1206,8 @@ onUnmounted(() => {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
border: 0.5px solid var(--df-border);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
/* page-header / page-header h1 / header-actions 已提取到全局 global.css */
|
||||
@@ -1269,7 +1268,7 @@ onUnmounted(() => {
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 11px;
|
||||
@@ -1330,8 +1329,8 @@ onUnmounted(() => {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255,255,255,0.06);
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
/* 任务条目(紧凑) */
|
||||
@@ -1344,7 +1343,7 @@ onUnmounted(() => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 0.5px solid rgba(255,255,255,0.03);
|
||||
border-bottom: 0.5px solid var(--df-hover);
|
||||
transition: background 0.1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1372,7 +1371,7 @@ onUnmounted(() => {
|
||||
.priority-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1446,8 +1445,8 @@ onUnmounted(() => {
|
||||
font-weight: 600;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255,255,255,0.06);
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
padding: var(--df-pad-chip);
|
||||
border-radius: var(--df-radius-xs);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1488,18 +1487,13 @@ onUnmounted(() => {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.25);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
z-index: 20;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.quick-menu-section { padding: 4px 0; }
|
||||
.quick-menu-label {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
padding: 2px 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
/* 小节头公共类 .df-sec-h,此处仅补菜单内缩进 */
|
||||
.quick-menu-label { padding: 2px 12px; }
|
||||
.quick-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1513,7 +1507,7 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.quick-menu-item:hover { background: rgba(255,255,255,0.06); }
|
||||
.quick-menu-item:hover { background: var(--df-hover); }
|
||||
.quick-menu-divider {
|
||||
height: 0.5px;
|
||||
background: var(--df-border);
|
||||
@@ -1521,16 +1515,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.quick-menu-danger { color: var(--df-danger); }
|
||||
|
||||
/* ===== 快捷操作失败 toast(WK-13,对齐 Projects.vue 操作反馈) ===== */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
padding: 10px 18px; border-radius: var(--df-radius-sm);
|
||||
font-size: 13px; z-index: 200; max-width: 80vw;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
.toast-info { background: var(--df-accent); color: #fff; }
|
||||
.toast-error { background: var(--df-danger); color: #fff; }
|
||||
.toast-success { background: var(--df-success); color: #fff; }
|
||||
/* 快捷操作失败 toast 样式已统一到全局 global.css .df-toast 族(+--bottom 底部变体) */
|
||||
|
||||
/* 空态 */
|
||||
/* empty-state 已提取到全局 global.css */
|
||||
|
||||
Reference in New Issue
Block a user