UI/细节调整
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user