UI/细节调整

This commit is contained in:
zhongluofeng
2026-09-22 19:10:16 +08:00
parent b018abd922
commit bccd21fde5
135 changed files with 11393 additions and 5104 deletions
@@ -0,0 +1,471 @@
<script setup lang="ts">
/**
* 命令历史 popover(终端页控制栏的「历史」按钮)。
*
* # 这个组件是「按钮 + 浮层」的整体
*
* 触发器、浮层内容、快捷键用的开合状态都封在这里,控制栏只摆一个标签即可。
* 分开的代价是:填入后自动收起、Esc 关闭这些动作都要跨组件传意图 ——
* 变成「父组件管开、子组件管关」,而它们读写的本来就是同一份状态。
*
* # 与旧「记录」页版本的区别
*
* 逻辑照搬原「记录」页的命令历史面板(搜索即过滤、选中、双击填入、收藏、清空),
* 但**形态**从整页变成 380px 宽的浮层:去掉了页头工具条、分区开关、底部三行
* 提示(换成一行)与整页的筛选行排版。之所以能有这个自信,是因为历史的使用
* 模式本来就是「快速找回一条命令」——用户想看的是列表,不是页面。
*
* # 为什么「填入不执行」为默认,且单击不填入
*
* 历史里躺着用户过去敲过的所有命令,包括 `rm -rf`、`DROP TABLE`。
* 因此:单击只**选中**(便于键盘导航与读清楚),双击或回车才**填入**命令行,
* 执行一律要显式点「执行」按钮。
*
* 「单击不填入」还有一层实际考虑:填入是把文本写进当前 PTY 的命令行,
* 用户可能正在敲一条命令(命令行上有半截输入),单击即填入会把那半截冲掉或拼在
* 一起。选中不动手,填入是明确的意图表达。
*
* # 关闭时机
*
* 填入成功后立即收起浮层:用户的下一步是看着终端按回车,浮层挡在那里没有价值。
* 浮层开合状态由 `useRecordPopovers` 的模块级 ref 持有 —— 快捷键
* Ctrl+Shift+H)与这里的触发按钮共用的就是它。
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
ArrowDownToLine,
Clock,
Copy,
FolderOpen,
History,
Play,
Search,
Star,
Trash2
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useTerminalStore } from '@/stores/terminalStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { StateBlock } from '@/components/common'
import { historyPopoverOpen, toggleHistoryPopover } from '../useRecordPopovers'
import type { CommandHistoryItem, SessionInfo } from '@/types/terminal'
const props = defineProps<{
/** 当前会话(把命令填给它;为空时动作不可用) */
sessionId: string | null
session: SessionInfo | undefined
}>()
const store = useTerminalStore()
// ===== 筛选 =====
const keyword = ref('')
const hostFilter = ref('')
const favoritedOnly = ref(false)
/** 来源下拉的「全部」哨兵值:reka 的 SelectItem 不接受空串 value */
const ALL_SOURCES = '__all_sources__'
/** 选中项(键盘导航用) */
const selectedId = ref<number | null>(null)
const items = computed(() => store.historyPage.items)
const total = computed(() => store.historyPage.total)
/** 当前会话的来源 id(「仅本会话」快捷筛选的判据) */
const currentHostId = computed(() => props.session?.targetId ?? '')
/**
* 加载。
*
* `force` 为真时连带刷新来源列表 —— 只在浮层首次打开时做(来源集合变化很慢,
* 每敲一个字都拉一遍纯属浪费)。
*/
async function reload(force = false) {
try {
await store.loadHistory({
keyword: keyword.value,
hostId: hostFilter.value,
favoritedOnly: favoritedOnly.value
})
if (force) await store.loadHistorySources()
} catch (e) {
toast.error(`加载历史失败:${String(e)}`)
}
}
onMounted(async () => {
await reload(true)
})
// 输入即过滤,200ms 防抖:让每个字符都发一次 IPC 只会把通道占满
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch([keyword, favoritedOnly], () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => void reload(), 200)
})
// 来源筛选是离散选择,无需防抖
watch(hostFilter, () => void reload())
// 卸载时清掉未触发的防抖,否则浮层关掉后还会发一次无主查询
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer)
})
// ===== 操作 =====
/** 把命令填入会话命令行(`submit` 为真则直接执行) */
async function useCommand(item: CommandHistoryItem, submit = false) {
if (!props.sessionId) {
toast.info('当前没有活跃会话')
return
}
try {
const r = await store.runHistory(props.sessionId, item.command, submit)
if (r.ok) {
if (submit) toast.success(r.message)
else toggleHistoryPopover(false)
} else {
toast.error(r.message)
}
} catch (e) {
toast.error(`操作失败:${String(e)}`)
}
}
async function copyCommand(item: CommandHistoryItem) {
try {
await navigator.clipboard.writeText(item.command)
toast.success('已复制到剪贴板')
} catch (e) {
toast.error(`复制失败:${String(e)}`)
}
}
async function toggleFavorite(item: CommandHistoryItem) {
try {
await store.toggleHistoryFavorite(item.id)
} catch (e) {
toast.error(`操作失败:${String(e)}`)
}
}
async function removeItem(item: CommandHistoryItem) {
try {
const r = await store.deleteHistory(item.id)
if (!r.ok) toast.info(r.message)
if (selectedId.value === item.id) selectedId.value = null
} catch (e) {
toast.error(`删除失败:${String(e)}`)
}
}
async function clearAll() {
// 二次确认,默认只清非收藏 —— 破坏性操作的默认值应当是最保守的那个。
// 用 window.confirm 而不是嵌套 Dialog:这是浮层里的浮层,
// 嵌一个 reka Dialog 会带来焦点陷阱与 z-index 的两难(原实现同款处理)。
const ok = window.confirm(
'确定清空命令历史吗?\n\n收藏的记录会保留(如需连同收藏一起清空,请先取消收藏)。'
)
if (!ok) return
try {
const r = await store.clearHistory(true)
toast.success(r.message)
} catch (e) {
toast.error(`清空失败:${String(e)}`)
}
}
// ===== 键盘导航 =====
/**
* ↑↓ 移动选中项,Enter 填入。
*
* 焦点落在行内按钮/下拉上时让路(它们自己要用方向键与 Enter),
* 搜索框不让路:在搜索框里按 ↑↓ 选条目、Enter 填入是刻意保留的操作方式。
*/
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
toggleHistoryPopover(false)
return
}
const target = e.target as HTMLElement | null
if (target?.closest('button,[data-slot="select-trigger"],select,textarea')) return
if (items.value.length === 0) return
const idx = items.value.findIndex(i => i.id === selectedId.value)
if (e.key === 'ArrowDown') {
e.preventDefault()
const next = idx < 0 ? 0 : Math.min(items.value.length - 1, idx + 1)
selectedId.value = items.value[next].id
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const next = idx <= 0 ? 0 : idx - 1
selectedId.value = items.value[next].id
} else if (e.key === 'Enter') {
e.preventDefault()
const item = items.value.find(i => i.id === selectedId.value)
if (item) void useCommand(item, false)
}
}
// ===== 展示辅助 =====
/** 相对时间(「3 分钟前」比精确时间戳更有信息量) */
function relTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 60_000) return '刚刚'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
if (diff < 2_592_000_000) return `${Math.floor(diff / 86_400_000)} 天前`
return new Date(ts).toLocaleDateString('zh-CN')
}
/** 目录只显示最后一级(完整路径会挤走命令本身,悬停可看全) */
function cwdTail(cwd: string): string {
if (!cwd) return ''
const parts = cwd.split(/[\\/]/).filter(Boolean)
return parts.length > 0 ? parts[parts.length - 1] : cwd
}
/** 退出码非 0(用户找的常常正是「刚才那条报错的命令」) */
function isFailed(item: CommandHistoryItem): boolean {
return item.exitCode !== null && item.exitCode !== 0
}
/** 是否处于筛选态(决定空态文案:是「没匹配」还是「还没有历史」) */
const isFiltered = computed(() => !!keyword.value || !!hostFilter.value || favoritedOnly.value)
</script>
<template>
<!--
触发器 + 浮层一体Tooltip 锚在包裹 div Popover 在自己的子树里用 as-child
两条 as-child 链互不相干更多菜单同款处理浮层展开期间禁用提示
鼠标此时还停在按钮上提示会压在浮层内容上
-->
<Tooltip :disabled="historyPopoverOpen">
<TooltipTrigger as-child>
<div class="inline-flex shrink-0">
<Popover v-model:open="historyPopoverOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon-sm" class="shrink-0">
<History class="size-3.5" />
</Button>
</PopoverTrigger>
<!-- 内容自带内边距让开 popover 默认的 w-72 / p-4 -->
<PopoverContent align="start" class="w-auto p-0">
<!-- 键盘事件挂在内容根上 / Esc 在浮层内任意位置都应生效 -->
<div class="w-[380px]" @keydown="onKeydown">
<!-- ===== 搜索 + 来源 ===== -->
<div class="flex items-center gap-2 p-2.5 border-b border-border">
<div class="relative flex-1 min-w-0">
<Search
class="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input v-model="keyword" placeholder="搜索命令…" class="h-7 pl-7 text-xs" autofocus />
</div>
<!-- 只有一个来源时下拉没有筛选意义纯属噪音 -->
<Select
v-if="store.historySources.length > 1"
:model-value="hostFilter || ALL_SOURCES"
@update:model-value="v => (hostFilter = String(v) === ALL_SOURCES ? '' : String(v))"
>
<SelectTrigger size="sm" class="w-[124px] shrink-0 text-xs">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<!-- reka 的 SelectItem 不接受空串值,用哨兵值代表「不筛选」 -->
<SelectItem :value="ALL_SOURCES">全部来源</SelectItem>
<SelectItem v-for="s in store.historySources" :key="s.hostId" :value="s.hostId">
{{ s.hostName }}{{ s.count }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- ===== 快捷筛选 ===== -->
<div class="flex items-center gap-1.5 px-2.5 py-2 text-xs">
<Button
size="sm"
class="h-6 px-2 text-[11px]"
:variant="hostFilter === '' && !favoritedOnly ? 'default' : 'outline'"
@click="hostFilter = ''; favoritedOnly = false"
>
全部
</Button>
<Button
v-if="currentHostId"
size="sm"
class="h-6 px-2 text-[11px]"
:variant="hostFilter === currentHostId ? 'default' : 'outline'"
:title="`只看本会话(${store.sessionLabel(session)}`"
@click="hostFilter = currentHostId; favoritedOnly = false"
>
仅本会话
</Button>
<Button
size="sm"
class="h-6 px-2 text-[11px]"
:variant="favoritedOnly ? 'default' : 'outline'"
@click="favoritedOnly = !favoritedOnly"
>
<Star class="size-3" />收藏
</Button>
<div class="flex-1" />
<span class="shrink-0 text-[10px] text-muted-foreground">{{ total }} 条</span>
</div>
<!-- ===== 列表 ===== -->
<ScrollArea class="h-[300px] border-t border-border">
<StateBlock
v-if="store.historyLoading && items.length === 0"
variant="loading"
:min-height="180"
/>
<StateBlock
v-else-if="items.length === 0"
variant="empty"
:title="isFiltered ? '没有匹配的命令' : '还没有命令历史'"
:description="
isFiltered ? undefined : '命令由 shell 集成 hook 上报,新开会话执行命令后即可在此查看。'
"
:min-height="180"
/>
<div v-else class="divide-y divide-divider">
<div
v-for="item in items"
:key="item.id"
class="group flex items-start gap-1.5 px-2.5 py-1.5 cursor-pointer transition-colors"
:class="selectedId === item.id ? 'bg-primary-soft' : 'hover:bg-hover'"
@click="selectedId = item.id"
@dblclick="useCommand(item, false)"
>
<!-- 收藏(不参与容量淘汰,因此可以随手留下想留的命令) -->
<button
class="shrink-0 mt-0.5 size-6 flex items-center justify-center transition-colors"
:class="
item.favorited ? 'text-warning' : 'text-muted-foreground/40 hover:text-warning'
"
:title="item.favorited ? '取消收藏' : '收藏(不参与容量淘汰)'"
@click.stop="toggleFavorite(item)"
>
<Star class="size-3.5" :fill="item.favorited ? 'currentColor' : 'none'" />
</button>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<code
class="text-[11px] font-mono truncate"
:class="isFailed(item) ? 'text-danger' : 'text-foreground'"
:title="item.command"
>
{{ item.command }}
</code>
<!-- 执行次数:>1 说明是反复用到的命令,值得优先看 -->
<span
v-if="item.count > 1"
class="shrink-0 text-[10px] text-muted-foreground"
:title="`执行过 ${item.count}`"
>
×{{ item.count }}
</span>
</div>
<div class="flex items-center gap-2 mt-0.5 text-[10px] text-muted-foreground">
<span class="inline-flex items-center gap-0.5 shrink-0">
<Clock class="size-2.5" />{{ relTime(item.ts) }}
</span>
<span
v-if="item.cwd"
class="inline-flex items-center gap-0.5 min-w-0"
:title="item.cwd"
>
<FolderOpen class="size-2.5 shrink-0" />
<span class="truncate max-w-[80px]">{{ cwdTail(item.cwd) }}</span>
</span>
<span class="truncate max-w-[90px]">{{ item.hostName }}</span>
<span v-if="isFailed(item)" class="text-danger shrink-0">
退出码 {{ item.exitCode }}
</span>
</div>
</div>
<!-- 行操作:悬停出现,避免列表视觉噪音 -->
<div
class="shrink-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<Button
variant="ghost"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-foreground"
title="填入命令行不执行"
@click.stop="useCommand(item, false)"
>
<ArrowDownToLine class="size-3" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-foreground"
title="填入并执行"
@click.stop="useCommand(item, true)"
>
<Play class="size-3" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-foreground"
title="复制"
@click.stop="copyCommand(item)"
>
<Copy class="size-3" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-danger"
title="删除这条"
@click.stop="removeItem(item)"
>
<Trash2 class="size-3" />
</Button>
</div>
</div>
</div>
</ScrollArea>
<!-- ===== 底部:操作提示 + 清空 ===== -->
<div
class="flex items-center gap-2 px-2.5 py-1.5 border-t border-border text-[10px] text-muted-foreground"
>
<span class="truncate">双击 / Enter 填入(不执行)</span>
<div class="flex-1" />
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-[11px] text-muted-foreground hover:text-destructive"
title="清空历史保留收藏"
@click="clearAll"
>
<Trash2 class="size-3" />清空
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">命令历史Ctrl+Shift+H</TooltipContent>
</Tooltip>
</template>