- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底 - 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、 conversation-sidebar/ConversationList、settings/provider-panel - 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补 fill-draft父侧转发(重构半成品断链修复) - 验收:vue-tsc EXIT 0
233 lines
6.8 KiB
Vue
233 lines
6.8 KiB
Vue
<template>
|
|
<!-- ═══ 审批待办浮层(屏幕右上角,置顶到所有窗口上面)═══
|
|
设计目的:当 aichat 面板被遮挡/隐藏/最小化,有 pending 审批时,
|
|
用户能立刻看到通知并一键激活 AI 面板处理。
|
|
实现:
|
|
- 监听 store.state.pendingApprovals(由 AiApprovalRequired 事件驱动)
|
|
- 位置 position:fixed top:16px right:16px,z-index 高于面板
|
|
- 窗口本身 alwaysOnTop 时浮层自然置顶到所有桌面窗口上面
|
|
- 点击浮层 → emit 'activate'(App.vue 决定激活主面板/分离窗口)
|
|
- 用户关闭后 60s 内同会话不再弹(防审批流期间反复打扰),新审批追加会再显 -->
|
|
<Transition name="approval-overlay">
|
|
<div
|
|
v-if="visible"
|
|
class="approval-overlay"
|
|
role="alert"
|
|
@click="onActivate"
|
|
>
|
|
<div class="approval-overlay-icon">
|
|
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M9 11l3 3L22 4" />
|
|
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
|
</svg>
|
|
<span v-if="count > 1" class="approval-overlay-badge">{{ count }}</span>
|
|
</div>
|
|
<div class="approval-overlay-body">
|
|
<div class="approval-overlay-title">{{ $t('aiChat.approvalWaitingTitle') }}</div>
|
|
<div class="approval-overlay-desc">{{ $t('aiChat.approvalWaitingDesc') }}</div>
|
|
</div>
|
|
<button
|
|
class="approval-overlay-close"
|
|
:title="$t('common.close')"
|
|
:aria-label="$t('common.close')"
|
|
@click.stop="onDismiss"
|
|
>
|
|
<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>
|
|
</button>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
|
import { useAiStore } from '@/stores/ai'
|
|
|
|
const store = useAiStore()
|
|
const emit = defineEmits<{
|
|
/** 用户点击浮层主体:激活 AI 面板/分离窗口 */
|
|
(e: 'activate'): void
|
|
}>()
|
|
|
|
// 待审批数(0 = 无)
|
|
const count = computed(() => store.state.pendingApprovals.length)
|
|
|
|
// 用户主动 dismiss 后 60s 内不再弹(同会话审批流期反复打扰防护)
|
|
const dismissedAt = ref<number>(0)
|
|
const DISMISS_COOLDOWN_MS = 60_000
|
|
|
|
// L26: 响应式时钟。原 visible 直接读 Date.now()(非响应式),computed 仅在 dismissedAt/count 变化时
|
|
// 重算 → dismiss 满 60s 时无触发源,浮层不会自动重现。now 每秒翻一次驱动重算(60s 冷却期颗粒度足够,
|
|
// 浮层非精确计时器)。仅在 dismissed 时跑定时器节省空转。
|
|
const now = ref(Date.now())
|
|
let _tickTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
function startTick() {
|
|
if (_tickTimer) return
|
|
_tickTimer = setInterval(() => { now.value = Date.now() }, 1000)
|
|
}
|
|
function stopTick() {
|
|
if (_tickTimer) {
|
|
clearInterval(_tickTimer)
|
|
_tickTimer = null
|
|
}
|
|
}
|
|
|
|
// 显隐:有 pending + 未在冷却期(用响应式 now 替代裸 Date.now())
|
|
const visible = computed(() => {
|
|
if (count.value === 0) return false
|
|
if (dismissedAt.value && now.value - dismissedAt.value < DISMISS_COOLDOWN_MS) return false
|
|
return true
|
|
})
|
|
|
|
// dismiss 启动时钟;冷却期到或审批清空时停时钟(避免无意义空转)
|
|
watch(dismissedAt, (d) => {
|
|
if (d > 0) startTick()
|
|
else stopTick()
|
|
})
|
|
|
|
// 审批清空时复位 dismiss(下次有新审批会重新弹)+ 停时钟
|
|
watch(count, (c) => {
|
|
if (c === 0) {
|
|
dismissedAt.value = 0
|
|
stopTick()
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => stopTick())
|
|
|
|
function onActivate() {
|
|
emit('activate')
|
|
}
|
|
|
|
function onDismiss() {
|
|
dismissedAt.value = Date.now()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态
|
|
wave2(2026-10-18):danger 系透明彩底范式(待审批=需用户处置的阻断信号)——
|
|
弹层底 bg-card+边框+10px 圆角,边框 color-mix 30%+左缘 3px 强调,标题同色 12px/600 */
|
|
.approval-overlay {
|
|
position: fixed;
|
|
top: 16px;
|
|
right: 16px;
|
|
z-index: 10000;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px 12px;
|
|
min-width: 260px;
|
|
max-width: 360px;
|
|
background: var(--df-bg-card);
|
|
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;
|
|
}
|
|
|
|
.approval-overlay:hover {
|
|
transform: translateX(-2px);
|
|
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 {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
|
color: var(--df-danger);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.approval-overlay-badge {
|
|
position: absolute;
|
|
top: -4px;
|
|
right: -4px;
|
|
min-width: 18px;
|
|
height: 18px;
|
|
padding: 0 5px;
|
|
border-radius: 8px;
|
|
background: var(--df-danger);
|
|
color: #fff;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
line-height: 18px;
|
|
text-align: center;
|
|
border: 1.5px solid var(--df-bg-card);
|
|
}
|
|
|
|
.approval-overlay-body {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.approval-overlay-title {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--df-danger);
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.approval-overlay-desc {
|
|
font-size: 12px;
|
|
color: var(--df-text-secondary);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.approval-overlay-close {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: var(--df-h-icon-btn);
|
|
height: var(--df-h-icon-btn);
|
|
border: none;
|
|
border-radius: var(--df-radius-sm);
|
|
background: transparent;
|
|
color: var(--df-text-dim);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
|
|
}
|
|
|
|
.approval-overlay-close:hover {
|
|
background: var(--df-bg-card-hover);
|
|
color: var(--df-text);
|
|
}
|
|
|
|
/* 进出动画:从右侧滑入 */
|
|
.approval-overlay-enter-active,
|
|
.approval-overlay-leave-active {
|
|
transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease);
|
|
}
|
|
|
|
.approval-overlay-enter-from,
|
|
.approval-overlay-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(40px);
|
|
}
|
|
|
|
/* 响应式:窄屏占满右侧 */
|
|
@media (max-width: 480px) {
|
|
.approval-overlay {
|
|
top: 8px;
|
|
right: 8px;
|
|
left: 8px;
|
|
max-width: none;
|
|
}
|
|
}
|
|
</style>
|