feat: Pro 买断门禁、SFTP 精简与批量多机改造
新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
101d2164b4
commit
fea6d8db04
72 files changed
+3307
-396
No files matched your search
@@ -1,19 +1,73 @@
|
||||
import { Plus, Zap, Clock, Server, ChevronRight } from 'lucide-react'
|
||||
import { Plus, Zap, Clock, Server, ChevronRight, Laptop, Terminal, Monitor, Cast } from 'lucide-react'
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { useSessionStore } from '../../stores'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEntitlementStore } from '../../stores/entitlementStore'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
import LocalShellMenu from '../layout/sidebar/LocalShellMenu'
|
||||
import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShellMenu'
|
||||
import appIconUrl from '../../assets/opsxshell-app-icon.png'
|
||||
import { shellDisplayFromPath } from '../../utils/shellName'
|
||||
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onNewSession?: () => void
|
||||
onQuickConnect?: () => void
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: string }) {
|
||||
return (
|
||||
<kbd className="rounded border border-dark-600/80 bg-dark-900/80 px-1.5 py-0.5 font-mono text-[10px] font-medium text-txt-faint">
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) {
|
||||
const { addTab } = useTabStore()
|
||||
const requirePro = useEntitlementStore((s) => s.requirePro)
|
||||
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const recentSessions = sessions.slice(-6).reverse()
|
||||
const recentSessions = sessions.slice(-8).reverse()
|
||||
const modKey = useMemo(() => modKeyLabel(), [])
|
||||
|
||||
const [shells, setShells] = useState<ShellOption[]>([])
|
||||
const [shellMenuOpen, setShellMenuOpen] = useState(false)
|
||||
const shellMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fallback = () => setShells(fallbackLocalShells() as ShellOption[])
|
||||
void window.opsxApi
|
||||
?.localListShells?.()
|
||||
.then((res) => {
|
||||
if (res?.success && res.shells?.length) setShells(res.shells)
|
||||
else fallback()
|
||||
})
|
||||
.catch(fallback)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shellMenuOpen) return
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (shellMenuRef.current && !shellMenuRef.current.contains(e.target as Node)) {
|
||||
setShellMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [shellMenuOpen])
|
||||
|
||||
const defaultShell = shells.find((s) => s.isDefault) || shells[0]
|
||||
const defaultShellLabel = defaultShell
|
||||
? shellDisplayFromPath(defaultShell.path || defaultShell.name).title
|
||||
: 'Shell'
|
||||
|
||||
const openDefaultLocal = useCallback(() => {
|
||||
openLocalShellTab(
|
||||
defaultShell
|
||||
? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name }
|
||||
: undefined
|
||||
)
|
||||
}, [defaultShell])
|
||||
|
||||
const handleSessionClick = (session: (typeof sessions)[number]) => {
|
||||
addTab({
|
||||
@@ -24,115 +78,204 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center bg-dark-900 overflow-y-auto">
|
||||
<div className="w-full max-w-3xl px-6 py-10 sm:py-12">
|
||||
{/* Hero — 短文案 */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-8 py-12 sm:py-16">
|
||||
<header className="mb-10 flex items-center gap-3.5">
|
||||
<img
|
||||
src={appIconUrl}
|
||||
alt="OpsXShell"
|
||||
width={56}
|
||||
height={56}
|
||||
className="h-14 w-14 flex-shrink-0 rounded-2xl object-cover shadow-lg shadow-black/20"
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-content-primary tracking-tight">OpsXShell</h1>
|
||||
<p className="text-sm text-txt-muted mt-0.5">SSH / SFTP 终端</p>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-content-primary">OpsXShell</h1>
|
||||
<p className="text-xs text-txt-muted">远程终端 · SFTP · 端口转发</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主路径:快速连接 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuickConnect?.()}
|
||||
className="
|
||||
group w-full flex items-center gap-4 p-5 mb-3 rounded-2xl text-left
|
||||
bg-accent-blue hover:bg-blue-500 border border-blue-400/30
|
||||
shadow-lg shadow-accent-blue/10 transition-all duration-150
|
||||
"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-white/15 flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
<section aria-label="开始" className="mb-12">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuickConnect?.()}
|
||||
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-blue/35 hover:bg-dark-800"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
|
||||
<Zap className="h-4 w-4" strokeWidth={2} />
|
||||
</span>
|
||||
<Kbd>{`${modKey}K`}</Kbd>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-content-primary">快速连接</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-txt-muted">临时 SSH,不写入会话库</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewSession?.()}
|
||||
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-green/35 hover:bg-dark-800"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-green/15 text-accent-green">
|
||||
<Plus className="h-4 w-4" strokeWidth={2} />
|
||||
</span>
|
||||
<Kbd>{`${modKey}N`}</Kbd>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-content-primary">新建会话</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-txt-muted">保存主机,侧栏双击连接</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div ref={shellMenuRef} className="relative">
|
||||
<div className="flex h-full overflow-hidden rounded-xl border border-dark-700/60 bg-dark-800/50 transition-colors hover:border-dark-600 hover:bg-dark-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDefaultLocal}
|
||||
className="flex min-w-0 flex-1 flex-col gap-4 p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-content-secondary">
|
||||
<Laptop className="h-4 w-4" strokeWidth={2} />
|
||||
</span>
|
||||
<Terminal className="h-3.5 w-3.5 text-txt-faint" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-content-primary">本地 Shell</div>
|
||||
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">
|
||||
{defaultShellLabel}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="选择 Shell"
|
||||
aria-expanded={shellMenuOpen}
|
||||
onClick={() => setShellMenuOpen((v) => !v)}
|
||||
className="flex w-9 flex-shrink-0 items-center justify-center border-l border-dark-700/60 text-txt-faint transition-colors hover:bg-dark-700/40 hover:text-content-secondary"
|
||||
>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${shellMenuOpen ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{shellMenuOpen && shells.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border border-dark-600/80 bg-dark-800 py-1 shadow-xl">
|
||||
{shells.map((s) => (
|
||||
<button
|
||||
key={s.path}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
openLocalShellTab({ path: s.path, kind: s.kind, name: s.name })
|
||||
setShellMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-dark-700/70"
|
||||
>
|
||||
<span className="w-10 font-mono text-[10px] uppercase text-txt-faint">{s.kind}</span>
|
||||
<span className="flex-1 text-sm text-content-primary">{s.name}</span>
|
||||
{s.isDefault && (
|
||||
<span className="text-[10px] text-txt-faint">默认</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-white text-base">快速连接</div>
|
||||
<div className="text-sm text-blue-100/80 mt-0.5">粘贴 user@host:port,一步连上</div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Pro 即将推出" className="mb-10">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">Pro · 即将推出</h2>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-white/70 group-hover:text-white group-hover:translate-x-0.5 transition-all flex-shrink-0" />
|
||||
</button>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!requirePro('rdp', { comingSoon: true })) return
|
||||
openUpgradeTab('rdp', true)
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-dark-600"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-txt-muted">
|
||||
<Monitor className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-content-secondary">RDP 远程桌面</div>
|
||||
<div className="text-[11px] text-txt-faint">Pro · 即将推出</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!requirePro('vnc', { comingSoon: true })) return
|
||||
openUpgradeTab('vnc', true)
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-dark-600"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-txt-muted">
|
||||
<Cast className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-content-secondary">VNC 远程桌面</div>
|
||||
<div className="text-[11px] text-txt-faint">Pro · 即将推出</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 次路径:保存常用会话 */}
|
||||
<div className="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewSession?.()}
|
||||
className="
|
||||
group flex items-center gap-3 p-4 rounded-2xl text-left
|
||||
bg-dark-800/70 border border-dark-700/50 hover:border-accent-green/40
|
||||
hover:bg-dark-800 transition-all duration-150
|
||||
"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-green/15 border border-accent-green/20 flex items-center justify-center flex-shrink-0 group-hover:bg-accent-green/25">
|
||||
<Plus className="w-5 h-5 text-accent-green" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-content-primary text-sm">新建会话</div>
|
||||
<div className="text-xs text-txt-muted mt-0.5">保存后侧栏双击即开终端</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 本地 Shell — 降级为次要入口 */}
|
||||
<div className="mb-10 opacity-90">
|
||||
<LocalShellMenu />
|
||||
</div>
|
||||
|
||||
{/* 最近会话 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-txt-faint" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-txt-faint">最近会话</h3>
|
||||
</div>
|
||||
<section aria-label="最近会话" className="min-h-0 flex-1">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-txt-faint" />
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
最近会话
|
||||
</h2>
|
||||
{recentSessions.length > 0 && (
|
||||
<span className="text-[11px] text-txt-faint">{recentSessions.length} 个</span>
|
||||
<span className="text-[11px] text-txt-dim">{recentSessions.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recentSessions.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-dark-600/60 bg-dark-800/30 p-6">
|
||||
<EmptyState icon={Server} title="暂无会话" description="创建会话或快速连接开始运维" />
|
||||
<div className="rounded-xl border border-dashed border-dark-700/70 px-4 py-10">
|
||||
<EmptyState
|
||||
icon={Server}
|
||||
title="暂无已保存会话"
|
||||
description="用快速连接试连,或新建会话以便复用"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/40 overflow-hidden divide-y divide-dark-700/40">
|
||||
<ul className="divide-y divide-dark-700/40 overflow-hidden rounded-xl border border-dark-700/50">
|
||||
{recentSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => handleSessionClick(session)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-700/40 transition-colors text-left group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-dark-700 flex items-center justify-center flex-shrink-0 group-hover:bg-accent-blue/20">
|
||||
<Server className="w-4 h-4 text-accent-blue" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary truncate">
|
||||
{session.name}
|
||||
</div>
|
||||
<div className="text-xs text-txt-faint font-mono truncate">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
{session.group && (
|
||||
<span className="ui-chip flex-shrink-0">{session.group}</span>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-txt-faint opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</button>
|
||||
<li key={session.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSessionClick(session)}
|
||||
className="group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors hover:bg-dark-800/80"
|
||||
>
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-dark-800 text-txt-muted group-hover:text-accent-blue">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-content-primary">{session.name}</span>
|
||||
<span className="block truncate font-mono text-[11px] text-txt-faint">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
</span>
|
||||
</span>
|
||||
{session.group ? (
|
||||
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">
|
||||
{session.group}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-txt-dim opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user