Compare commits

..

8 Commits

Author SHA1 Message Date
sagit 4eaecb289b fix(config): expose forward compact mode switch in settings (#241)
## Summary
- Add `forward_compact_mode` to the `/config` settings item list so the
compact-mode switch is visible in the main settings page.
- Include `forward_compact_mode` in initial config cache keys to keep
switch state consistent on load.

## Verification
- `npm run build` (vite-frontend)
2026-03-02 21:37:10 +08:00
sagitchu 98a9e5c666 fix(config): expose forward compact mode switch in settings 2026-03-02 21:36:33 +08:00
sagit d244920dd4 feat(forward): add global compact mode with alpha8 list layout (#240)
## Summary
- Add a global forward compact mode toggle in settings, persisted via
`config` key `forward_compact_mode`.
- Make forward page read and react to this global setting in real time
through a browser event.
- In compact mode, render forward list using the 2.1.6-alpha8 style
(single grouped table / global direct card grid) while keeping non-list
interactions unchanged.

## Verification
- Installed frontend dependencies with `npm install`.
- Built frontend successfully with `npm run build`.
2026-03-02 21:11:39 +08:00
sagitchu 77e4387b35 feat(forward): add global compact mode with alpha8 list layout 2026-03-02 21:10:56 +08:00
sagit 7a40ddb1ef fix(diagnosis): tighten timeout handling and clarify timeout messaging (#233)
## Summary
- shorten per-item diagnosis command timeout from 2 minutes to 30
seconds while keeping overall request timeout at 2 minutes
- centralize timeout messages in backend constants and apply consistent
timeout fallback handling in diagnosis result assembly
- update frontend forward/tunnel diagnosis timeout copy to clearly
explain single-item and overall timeout limits

## Notes
- includes workspace tool config files under `.claude/` and `.entire/`
as part of this commit
2026-03-01 18:39:19 +08:00
sagitchu d33814e18c fix(diagnosis): tighten timeout handling and clarify timeout messaging 2026-03-01 18:37:53 +08:00
sagit cf51b305b0 fix(diagnosis): prevent progress stream blocking and refine tunnel type chips (#230)
* fix(diagnosis): avoid result channel deadlock in progress stream

Close the diagnosis result channel asynchronously after workers complete so progress can stream without blocking, and improve tunnel card type chip contrast for clearer protocol distinction.

* feat(diagnosis): stream pending items and render in-progress states

Pre-populate diagnosis stream with pending targets so tunnel and forward dialogs can show per-item diagnosing status immediately. Update result typing and UI states to distinguish in-progress, success, and failure rows/cards consistently.
2026-03-01 14:49:38 +08:00
sagit 9ffeb83753 fix(forward): align table columns and fix card layout (#228) 2026-03-01 11:14:12 +08:00
10 changed files with 895 additions and 134 deletions
+84
View File
@@ -0,0 +1,84 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code post-task"
}
]
},
{
"matcher": "TodoWrite",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code post-todo"
}
]
}
],
"PreToolUse": [
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code pre-task"
}
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code session-end"
}
]
}
],
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code session-start"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code stop"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code user-prompt-submit"
}
]
}
]
},
"permissions": {
"deny": [
"Read(./.entire/metadata/**)"
]
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"enabled": true,
"telemetry": false
}
@@ -58,12 +58,63 @@ type diagnosisProgress struct {
type diagnosisItemEmitter func(index int, item map[string]interface{}, progress diagnosisProgress)
func (h *Handler) buildDiagnosisStreamStartItems(workItems []diagnosisWorkItem) []map[string]interface{} {
if len(workItems) == 0 {
return []map[string]interface{}{}
}
nodeCache := map[int64]*nodeRecord{}
items := make([]map[string]interface{}, 0, len(workItems))
for _, workItem := range workItems {
targetIP := strings.TrimSpace(workItem.targetIP)
targetPort := workItem.targetPort
if workItem.hasChainHop {
fromNode, _ := h.cachedNode(nodeCache, workItem.fromNodeID)
targetNode, err := h.cachedNode(nodeCache, workItem.toNode.NodeID)
if err == nil {
resolvedIP, resolvedPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, workItem.toNode.Port, workItem.ipPreference)
if resolveErr == nil {
targetIP = resolvedIP
targetPort = resolvedPort
}
}
}
if targetPort <= 0 {
targetPort = 443
}
nodeName := fmt.Sprintf("node_%d", workItem.fromNodeID)
if node, err := h.cachedNode(nodeCache, workItem.fromNodeID); err == nil && strings.TrimSpace(node.Name) != "" {
nodeName = node.Name
}
item := map[string]interface{}{
"success": false,
"diagnosing": true,
"description": workItem.description,
"nodeName": nodeName,
"nodeId": strconv.FormatInt(workItem.fromNodeID, 10),
"targetIp": targetIP,
"targetPort": targetPort,
"message": "诊断中...",
}
for key, value := range workItem.metadata {
item[key] = value
}
items = append(items, item)
}
return items
}
const diagnosisMaxConcurrency = 8
const (
defaultNodeCommandTimeout = 6 * time.Second
diagnosisCommandTimeout = 2 * time.Minute
diagnosisRequestTimeout = 2 * time.Minute
defaultNodeCommandTimeout = 6 * time.Second
diagnosisCommandTimeout = 30 * time.Second
diagnosisRequestTimeout = 2 * time.Minute
diagnosisCommandTimeoutMsg = "诊断超时(30秒)"
diagnosisRequestTimeoutMsg = "诊断超时(2分钟)"
)
func (h *Handler) resolveForwardAccess(r *http.Request, forwardID int64) (*forwardRecord, int64, int, error) {
@@ -799,15 +850,15 @@ func resolveDiagnosisTargets(remoteAddr string) ([]diagnosisTarget, error) {
func diagnosisContextMessage(ctx context.Context) string {
if ctx == nil {
return "诊断超时(2分钟)"
return diagnosisRequestTimeoutMsg
}
switch ctx.Err() {
case context.DeadlineExceeded:
return "诊断超时(2分钟)"
return diagnosisRequestTimeoutMsg
case context.Canceled:
return "诊断已取消"
default:
return "诊断超时(2分钟)"
return diagnosisRequestTimeoutMsg
}
}
@@ -846,7 +897,7 @@ func newDiagnosisTimeoutItem(workItem diagnosisWorkItem, message string) map[str
item := newDiagnosisResultItem(workItem.fromNodeID, workItem.targetIP, targetPort, workItem.description, workItem.metadata)
item["success"] = false
if strings.TrimSpace(message) == "" {
message = "诊断超时(2分钟)"
message = diagnosisCommandTimeoutMsg
}
item["message"] = message
return item
@@ -923,8 +974,10 @@ enqueueLoop:
}
}
close(jobs)
wg.Wait()
close(resultCh)
go func() {
wg.Wait()
close(resultCh)
}()
progress := diagnosisProgress{Total: len(workItems)}
for result := range resultCh {
@@ -942,7 +995,7 @@ enqueueLoop:
for i := range results {
if results[i] == nil {
results[i] = newDiagnosisTimeoutItem(workItems[i], "诊断超时(2分钟)")
results[i] = newDiagnosisTimeoutItem(workItems[i], diagnosisCommandTimeoutMsg)
}
}
return results
@@ -81,6 +81,7 @@ func (h *Handler) streamDiagnosisRuntime(ctx context.Context, cancel context.Can
payload := map[string]interface{}{
"total": len(workItems),
"timestamp": time.Now().UnixMilli(),
"items": h.buildDiagnosisStreamStartItems(workItems),
}
for key, value := range startPayload {
payload[key] = value
+7
View File
@@ -117,6 +117,12 @@ const CONFIG_ITEMS: ConfigItem[] = [
description: "用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存",
type: "input",
},
{
key: "forward_compact_mode",
label: "转发页面精简模式",
description: "开启后,转发页面列表使用 2.1.6-alpha8 样式(全局配置)",
type: "switch",
},
{
key: "captcha_enabled",
label: "启用验证码",
@@ -167,6 +173,7 @@ const getInitialConfigs = (): Record<string, string> => {
"captcha_enabled",
"cloudflare_site_key",
"cloudflare_secret_key",
"forward_compact_mode",
"ip",
"panel_domain",
"app_logo",
+548 -75
View File
@@ -63,6 +63,7 @@ import {
resumeForwardService,
diagnoseForward,
updateForwardOrder,
getConfigByName,
} from "@/api";
import {
type ForwardAddressItem,
@@ -145,6 +146,20 @@ interface ForwardTunnelGroup {
const UNKNOWN_FORWARD_USER_NAME = "未知用户";
const UNCATEGORIZED_FORWARD_TUNNEL_NAME = "未分类";
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
const FORWARD_COMPACT_MODE_EVENT = "forwardCompactModeChanged";
const FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS = "min-w-[1320px]";
const FORWARD_GROUPED_TABLE_COLUMN_CLASS = {
select: "w-14",
drag: "w-10 pl-4",
name: "w-[200px]",
inbound: "w-[280px]",
target: "w-[280px]",
strategy: "w-[100px]",
totalFlow: "w-[120px]",
status: "w-[100px]",
actions: "w-[144px] text-right",
} as const;
const normalizeForwardUserName = (userName?: string): string => {
const normalized = (userName || UNKNOWN_FORWARD_USER_NAME).trim();
@@ -193,6 +208,7 @@ export default function ForwardPage() {
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [compactMode, setCompactMode] = useState(false);
// 显示模式状态 - 从localStorage读取,默认为平铺显示
const [viewMode, setViewMode] = useState<"grouped" | "direct">(() => {
@@ -296,6 +312,43 @@ export default function ForwardPage() {
};
}, []);
useEffect(() => {
const loadForwardCompactMode = async () => {
try {
const response = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
const enabled =
response.code === 0 &&
typeof response.data?.value === "string" &&
response.data.value === "true";
setCompactMode(enabled);
} catch {
setCompactMode(false);
}
};
const handleCompactModeChanged = (event: Event) => {
const customEvent = event as CustomEvent<{ enabled?: boolean }>;
if (typeof customEvent.detail?.enabled === "boolean") {
setCompactMode(customEvent.detail.enabled);
}
};
loadForwardCompactMode();
window.addEventListener(
FORWARD_COMPACT_MODE_EVENT,
handleCompactModeChanged,
);
return () => {
window.removeEventListener(
FORWARD_COMPACT_MODE_EVENT,
handleCompactModeChanged,
);
};
}, []);
const parseShareIdFromTunnelName = (tunnelName: string): number | null => {
const normalized = (tunnelName || "").trim();
@@ -878,10 +931,13 @@ export default function ForwardPage() {
? payload.forwardName
: forward.name;
const startTotal = Number(payload.total);
const startItems = Array.isArray(payload.items)
? (payload.items as ForwardDiagnosisResult["results"])
: [];
setDiagnosisResult((prev) => ({
forwardName: startForwardName,
timestamp: Date.now(),
results: prev?.results || [],
results: startItems.length > 0 ? startItems : prev?.results || [],
}));
if (Number.isFinite(startTotal) && startTotal >= 0) {
setDiagnosisProgress((prev) => ({
@@ -907,9 +963,15 @@ export default function ForwardPage() {
);
if (existingIndex >= 0) {
nextResults[existingIndex] = result;
nextResults[existingIndex] = {
...result,
diagnosing: false,
};
} else {
nextResults.push(result);
nextResults.push({
...result,
diagnosing: false,
});
}
return {
...base,
@@ -982,7 +1044,7 @@ export default function ForwardPage() {
toast.error(streamErrorMessage);
}
if (streamResult.timedOut) {
toast.error("诊断达到2分钟超时,已返回当前结果");
toast.error("诊断超时(单条30秒 / 整体2分钟),已返回当前结果");
}
} catch {
if (abortController.signal.aborted) {
@@ -1351,9 +1413,14 @@ export default function ForwardPage() {
);
const overTunnelGroupKey = buildForwardTunnelGroupKey(overForward?.tunnelName);
// 仅允许在同一用户+隧道分组内拖拽,避免混排
if (activeUserId !== overUserId || activeTunnelGroupKey !== overTunnelGroupKey) {
return;
// 非精简模式仅允许在同一用户+隧道分组内拖拽,避免混排
if (!compactMode) {
if (
activeUserId !== overUserId ||
activeTunnelGroupKey !== overTunnelGroupKey
) {
return;
}
}
const oldIndex = forwardOrder.indexOf(activeId);
@@ -1752,12 +1819,19 @@ export default function ForwardPage() {
return groups;
}, [orderedForwards, isAdmin, tokenUserId]);
const sortedForwards = useMemo(
() =>
groupedForwards.flatMap((group) =>
group.tunnels.flatMap((tunnel) => tunnel.items),
),
[groupedForwards],
const sortedForwards = useMemo(() => {
if (compactMode) {
return orderedForwards;
}
return groupedForwards.flatMap((group) =>
group.tunnels.flatMap((tunnel) => tunnel.items),
);
}, [compactMode, orderedForwards, groupedForwards]);
const sortableForwardIds = useMemo(
() => sortedForwards.map((f) => f.id).filter((id) => id > 0),
[sortedForwards],
);
// 可拖拽的转发卡片组件
@@ -1785,7 +1859,7 @@ export default function ForwardPage() {
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<div ref={setNodeRef} className="h-full" style={style} {...attributes}>
{renderForwardCard(forward, listeners)}
</div>
);
@@ -1878,6 +1952,213 @@ export default function ForwardPage() {
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<TableRow key={forward.id} ref={setNodeRef} style={style}>
{selectMode && (
<TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.select}>
<Checkbox
isSelected={selectedIds.has(forward.id)}
onValueChange={() => toggleSelect(forward.id)}
/>
</TableCell>
)}
<TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag}>
<div
className="cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors"
{...attributes}
{...listeners}
title="拖拽排序"
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
</svg>
</div>
</TableCell>
<TableCell
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.name} whitespace-nowrap font-semibold text-foreground`}
>
{forward.name}
</TableCell>
<TableCell
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.inbound} max-w-[280px]`}
>
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${
hasMultipleAddresses(forward.inIp)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatInAddress(forward.inIp, forward.inPort)}
type="button"
onClick={() =>
showAddressModal(forward.inIp, forward.inPort, "入口端口")
}
>
{formatInAddress(forward.inIp, forward.inPort)}
</button>
</TableCell>
<TableCell
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.target} max-w-[280px]`}
>
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${
hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-200 hover:shadow-sm"
: ""
}`}
title={formatRemoteAddress(forward.remoteAddr)}
type="button"
onClick={() =>
showAddressModal(forward.remoteAddr, null, "目标地址")
}
>
{formatRemoteAddress(forward.remoteAddr)}
</button>
</TableCell>
<TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.strategy}>
<Chip
className="text-xs font-medium"
color={strategyDisplay.color as any}
size="sm"
variant="flat"
>
{strategyDisplay.text}
</Chip>
</TableCell>
<TableCell
className={`${FORWARD_GROUPED_TABLE_COLUMN_CLASS.totalFlow} whitespace-nowrap`}
>
<span className="text-sm font-medium text-default-600 font-mono">
{formatFlow(getForwardDisplayFlow(forward))}
</span>
</TableCell>
<TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.status}>
<div className="flex items-center gap-2.5 whitespace-nowrap">
<Switch
color="success"
isDisabled={forward.status !== 1 && forward.status !== 0}
isSelected={forward.serviceRunning}
size="sm"
onValueChange={() => handleServiceToggle(forward)}
/>
</div>
</TableCell>
<TableCell className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.actions}>
<div className="flex justify-end gap-2">
<Button
isIconOnly
className="bg-primary/10 text-primary hover:bg-primary/20"
size="sm"
title="编辑"
onPress={() => handleEdit(forward)}
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
<Button
isIconOnly
className="bg-warning/10 text-warning hover:bg-warning/20"
size="sm"
title="诊断"
onPress={() => handleDiagnose(forward)}
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
<Button
isIconOnly
className="bg-danger/10 text-danger hover:bg-danger/20"
size="sm"
title="删除"
onPress={() => handleDelete(forward)}
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
</div>
</TableCell>
</TableRow>
);
};
const SortableCompactTableRow = ({
forward,
selectMode,
selectedIds,
toggleSelect,
getStrategyDisplay,
formatInAddress,
formatRemoteAddress,
handleServiceToggle,
handleEdit,
handleDelete,
handleDiagnose,
showAddressModal,
hasMultipleAddresses,
formatFlow,
}: any) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: forward.id });
const style = {
transform: transform
? CSS.Transform.toString({
...transform,
x: Math.round(transform.x),
y: Math.round(transform.y),
})
: undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
backgroundColor: isDragging ? "var(--nextui-default-100)" : undefined,
};
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<TableRow key={forward.id} ref={setNodeRef} style={style}>
{selectMode && (
@@ -1905,9 +2186,25 @@ export default function ForwardPage() {
</svg>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<span className="text-sm font-medium text-default-700">
{forward.userName || "未知用户"}
</span>
</TableCell>
<TableCell className="whitespace-nowrap font-semibold text-foreground">
{forward.name}
</TableCell>
<TableCell className="whitespace-nowrap">
<Chip
className="border-none bg-secondary/10 px-2"
color="secondary"
size="sm"
>
<span className="font-medium text-secondary-700">
{forward.tunnelName}
</span>
</Chip>
</TableCell>
<TableCell className="max-w-[220px]">
<button
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${
@@ -2045,7 +2342,7 @@ export default function ForwardPage() {
return (
<Card
key={forward.id}
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
className="group h-full flex flex-col shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
>
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
@@ -2104,8 +2401,8 @@ export default function ForwardPage() {
</div>
</CardHeader>
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
<div className="space-y-2">
<CardBody className="flex flex-1 flex-col pt-0 pb-3 md:pt-0 md:pb-3">
<div className="space-y-2 flex-1">
{/* 地址信息 */}
<div className="space-y-1">
<button
@@ -2542,7 +2839,107 @@ export default function ForwardPage() {
</div>
{/* 根据显示模式渲染不同内容 */}
{viewMode === "grouped" ? (
{compactMode ? (
viewMode === "grouped" ? (
sortedForwards.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
>
<Table
aria-label="全部转发列表"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
{selectMode && <TableColumn className="w-14">选择</TableColumn>}
<TableColumn className="w-10 pl-4" />
<TableColumn>用户</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn>
<TableColumn>总流量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
{(forward) => (
<SortableContext
key={forward.id}
items={sortableForwardIds}
strategy={verticalListSortingStrategy}
>
<SortableCompactTableRow
formatFlow={formatFlow}
formatInAddress={formatInAddress}
formatRemoteAddress={formatRemoteAddress}
forward={forward}
getStrategyDisplay={getStrategyDisplay}
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
handleEdit={handleEdit}
handleServiceToggle={handleServiceToggle}
hasMultipleAddresses={hasMultipleAddresses}
selectMode={selectMode}
selectedIds={selectedIds}
showAddressModal={showAddressModal}
toggleSelect={toggleSelect}
/>
</SortableContext>
)}
</TableBody>
</Table>
</DndContext>
</div>
) : (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无转发配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何转发配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)
) : sortedForwards.length > 0 ? (
<DndContext
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}}
>
<SortableContext items={sortableForwardIds} strategy={rectSortingStrategy}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedForwards.map((forward) =>
forward && forward.id ? (
<SortableForwardCard key={forward.id} forward={forward} />
) : null,
)}
</div>
</SortableContext>
</DndContext>
) : (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无转发配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何转发配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)
) : viewMode === "grouped" ? (
sortedForwards.length > 0 ? (
<div className="space-y-4">
{groupedForwards.map((group) => {
@@ -2601,6 +2998,7 @@ export default function ForwardPage() {
>
<Table
aria-label={`${group.userName}-${tunnel.tunnelName}转发列表`}
className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
@@ -2609,16 +3007,60 @@ export default function ForwardPage() {
>
<TableHeader>
{selectMode && (
<TableColumn className="w-14">选择</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.select
}
>
选择
</TableColumn>
)}
<TableColumn className="w-10 pl-4" />
<TableColumn>名称</TableColumn>
<TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn>
<TableColumn>总流量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn>
<TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.drag}
/>
<TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.name}
>
名称
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.inbound
}
>
入口
</TableColumn>
<TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.target}
>
目标
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.strategy
}
>
策略
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.totalFlow
}
>
总流量
</TableColumn>
<TableColumn
className={FORWARD_GROUPED_TABLE_COLUMN_CLASS.status}
>
状态
</TableColumn>
<TableColumn
className={
FORWARD_GROUPED_TABLE_COLUMN_CLASS.actions
}
>
操作
</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无转发配置"
@@ -2660,7 +3102,6 @@ export default function ForwardPage() {
})}
</div>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
@@ -2672,8 +3113,7 @@ export default function ForwardPage() {
</CardBody>
</Card>
)
) : /* 直接显示模式 */
sortedForwards.length > 0 ? (
) : sortedForwards.length > 0 ? (
<div className="space-y-5">
{groupedForwards.map((group) => {
const isSelfGroup =
@@ -2697,7 +3137,7 @@ export default function ForwardPage() {
<Chip color="primary" size="sm" variant="flat">
管理员本人
</Chip>
)}
)}
</div>
<span className="text-xs text-default-600">
{groupForwardCount} 条转发
@@ -2728,7 +3168,7 @@ export default function ForwardPage() {
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={() => {}} // 添加空的 onDragStart 处理器
onDragStart={() => {}}
>
<SortableContext
items={tunnelSortableForwardIds}
@@ -2755,7 +3195,6 @@ export default function ForwardPage() {
})}
</div>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
@@ -3395,15 +3834,7 @@ export default function ForwardPage() {
)}
</ModalHeader>
<ModalBody className="bg-content1">
{diagnosisLoading &&
(!diagnosisResult || diagnosisResult.results.length === 0) ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在诊断...</span>
</div>
</div>
) : diagnosisResult ? (
{diagnosisResult ? (
<div className="space-y-4">
{diagnosisLoading && (
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
@@ -3425,7 +3856,7 @@ export default function ForwardPage() {
{diagnosisProgress.timedOut && (
<Alert
color="warning"
description="诊断已达到2分钟超时,以下为当前已完成结果。"
description="诊断超时(单条30秒 / 整体2分钟),以下为当前已完成结果。"
title="诊断超时"
variant="flat"
/>
@@ -3534,6 +3965,10 @@ export default function ForwardPage() {
</thead>
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
{results.map((result, index) => {
const isDiagnosing = Boolean(
result.diagnosing,
);
const isSuccess = result.success === true;
const quality =
getForwardDiagnosisQualityDisplay(
result.averageTime,
@@ -3544,22 +3979,28 @@ export default function ForwardPage() {
<tr
key={index}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
result.success
isDiagnosing
? "bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
{isDiagnosing ? (
<Spinner size="sm" />
) : (
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
isSuccess
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{isSuccess ? "✓" : "✗"}
</span>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-foreground truncate">
{result.description}
@@ -3574,18 +4015,24 @@ export default function ForwardPage() {
<td className="px-3 py-2 text-center">
<Chip
color={
result.success
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{result.success ? "成功" : "失败"}
{isDiagnosing
? "诊断中"
: isSuccess
? "成功"
: "失败"}
</Chip>
</td>
<td className="px-3 py-2 text-center">
{result.success ? (
{isSuccess ? (
<span className="font-semibold text-primary">
{result.averageTime?.toFixed(0)}
</span>
@@ -3596,7 +4043,7 @@ export default function ForwardPage() {
)}
</td>
<td className="px-3 py-2 text-center">
{result.success ? (
{isSuccess ? (
<span
className={`font-semibold ${
(result.packetLoss || 0) > 0
@@ -3613,7 +4060,7 @@ export default function ForwardPage() {
)}
</td>
<td className="px-3 py-2 text-center">
{result.success && quality ? (
{isSuccess && quality ? (
<Chip
className="text-xs whitespace-nowrap"
color={quality.color as any}
@@ -3707,6 +4154,8 @@ export default function ForwardPage() {
</h3>
</div>
{results.map((result, index) => {
const isDiagnosing = Boolean(result.diagnosing);
const isSuccess = result.success === true;
const quality =
getForwardDiagnosisQualityDisplay(
result.averageTime,
@@ -3717,21 +4166,27 @@ export default function ForwardPage() {
<div
key={index}
className={`border rounded-lg p-3 ${
result.success
isDiagnosing
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
>
<div className="flex items-start gap-2 mb-2">
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
{isDiagnosing ? (
<Spinner size="sm" />
) : (
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
isSuccess
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{isSuccess ? "✓" : "✗"}
</span>
)}
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-foreground break-words">
{result.description}
@@ -3743,16 +4198,24 @@ export default function ForwardPage() {
<Chip
className="flex-shrink-0"
color={
result.success ? "success" : "danger"
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{result.success ? "成功" : "失败"}
{isDiagnosing
? "诊断中"
: isSuccess
? "成功"
: "失败"}
</Chip>
</div>
{result.success ? (
{isSuccess ? (
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
<div className="text-center">
<div className="text-lg font-bold text-primary">
@@ -3796,8 +4259,16 @@ export default function ForwardPage() {
</div>
) : (
<div className="mt-2 pt-2 border-t border-divider">
<div className="text-xs text-danger">
{result.message || "连接失败"}
<div
className={`text-xs ${
isDiagnosing
? "text-warning"
: "text-danger"
}`}
>
{isDiagnosing
? result.message || "诊断中..."
: result.message || "连接失败"}
</div>
</div>
)}
@@ -3838,14 +4309,16 @@ export default function ForwardPage() {
</div>
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
{diagnosisResult.results.some((r) => !r.success) && (
{diagnosisResult.results.some(
(r) => r.success === false && !r.diagnosing,
) && (
<div className="space-y-2 hidden md:block">
<h4 className="text-sm font-semibold text-danger">
失败详情
</h4>
<div className="space-y-2">
{diagnosisResult.results
.filter((r) => !r.success)
.filter((r) => r.success === false && !r.diagnosing)
.map((result, index) => (
<Alert
key={index}
+2 -1
View File
@@ -1,5 +1,6 @@
export interface ForwardDiagnosisEntry {
success: boolean;
success?: boolean;
diagnosing?: boolean;
description: string;
nodeName: string;
nodeId: string;
+90
View File
@@ -6,12 +6,15 @@ import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { reinitializeBaseURL } from "@/api/network";
import { getConfigByName, updateConfig } from "@/api";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
setUpdateReleaseChannel,
} from "@/utils/version-update";
import { isAdmin } from "@/utils/auth";
import {
getPanelAddresses,
savePanelAddress,
@@ -26,6 +29,8 @@ interface PanelAddress {
inx: boolean;
}
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
export const SettingsPage = () => {
const navigate = useNavigate();
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
@@ -34,6 +39,11 @@ export const SettingsPage = () => {
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const [forwardCompactMode, setForwardCompactMode] = useState(false);
const [forwardCompactModeSaving, setForwardCompactModeSaving] =
useState(false);
const admin = isAdmin();
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
setPanelAddresses(newAddress);
@@ -86,8 +96,57 @@ export const SettingsPage = () => {
// 页面加载时获取数据
useEffect(() => {
loadPanelAddresses();
loadForwardCompactMode();
}, []);
const loadForwardCompactMode = async () => {
try {
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
const enabled =
res.code === 0 &&
typeof res.data?.value === "string" &&
res.data.value === "true";
setForwardCompactMode(enabled);
} catch {
setForwardCompactMode(false);
}
};
const handleForwardCompactModeChange = async (enabled: boolean) => {
if (!admin || forwardCompactModeSaving) {
return;
}
const previous = forwardCompactMode;
setForwardCompactMode(enabled);
setForwardCompactModeSaving(true);
try {
const response = await updateConfig(
FORWARD_COMPACT_MODE_CONFIG_KEY,
enabled ? "true" : "false",
);
if (response.code === 0) {
toast.success(`转发页面精简模式已${enabled ? "开启" : "关闭"}`);
window.dispatchEvent(
new CustomEvent("forwardCompactModeChanged", {
detail: { enabled },
}),
);
} else {
setForwardCompactMode(previous);
toast.error(response.msg || "保存精简模式失败");
}
} catch {
setForwardCompactMode(previous);
toast.error("保存精简模式失败");
} finally {
setForwardCompactModeSaving(false);
}
};
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
@@ -164,6 +223,37 @@ export const SettingsPage = () => {
</CardBody>
</Card>
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
显示设置
</h2>
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
转发页面精简模式
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后,转发页面列表使用 2.1.6-alpha8 样式。
</p>
</div>
<Switch
color="primary"
isDisabled={!admin || forwardCompactModeSaving}
isSelected={forwardCompactMode}
onValueChange={handleForwardCompactModeChange}
/>
</div>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div>
</CardBody>
</Card>
{/* 添加新地址 */}
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
+94 -47
View File
@@ -490,11 +490,14 @@ export default function TunnelPage() {
? "端口转发"
: "隧道转发";
const startTotal = Number(payload.total);
const startItems = Array.isArray(payload.items)
? (payload.items as DiagnosisResult["results"])
: [];
setDiagnosisResult((prev) => ({
tunnelName: startTunnelName,
tunnelType: startTunnelType,
timestamp: Date.now(),
results: prev?.results || [],
results: startItems.length > 0 ? startItems : prev?.results || [],
}));
if (Number.isFinite(startTotal) && startTotal >= 0) {
setDiagnosisProgress((prev) => ({
@@ -521,9 +524,15 @@ export default function TunnelPage() {
);
if (existingIndex >= 0) {
nextResults[existingIndex] = result;
nextResults[existingIndex] = {
...result,
diagnosing: false,
};
} else {
nextResults.push(result);
nextResults.push({
...result,
diagnosing: false,
});
}
return {
...base,
@@ -596,7 +605,7 @@ export default function TunnelPage() {
toast.error(streamErrorMessage);
}
if (streamResult.timedOut) {
toast.error("诊断达到2分钟超时,已返回当前结果");
toast.error("诊断超时(单条30秒 / 整体2分钟),已返回当前结果");
}
} catch {
if (abortController.signal.aborted) {
@@ -970,6 +979,10 @@ export default function TunnelPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{sortedTunnels.map((tunnel) => {
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
const tunnelTypeChipClassName =
tunnel.type === 1
? "text-xs bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
: "text-xs bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
return (
<SortableItem key={tunnel.id} id={tunnel.id}>
@@ -993,7 +1006,7 @@ export default function TunnelPage() {
</h3>
<div className="flex items-center gap-1.5 mt-1">
<Chip
className="text-xs"
className={tunnelTypeChipClassName}
color={typeDisplay.color as any}
size="sm"
variant="flat"
@@ -2106,15 +2119,7 @@ export default function TunnelPage() {
)}
</ModalHeader>
<ModalBody className="bg-content1">
{diagnosisLoading &&
(!diagnosisResult || diagnosisResult.results.length === 0) ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在诊断...</span>
</div>
</div>
) : diagnosisResult ? (
{diagnosisResult ? (
<div className="space-y-4">
{diagnosisLoading && (
<div className="flex items-center justify-between rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
@@ -2136,7 +2141,7 @@ export default function TunnelPage() {
{diagnosisProgress.timedOut && (
<Alert
color="warning"
description="诊断已达到2分钟超时,以下为当前已完成结果。"
description="诊断超时(单条30秒 / 整体2分钟),以下为当前已完成结果。"
title="诊断超时"
variant="flat"
/>
@@ -2245,6 +2250,10 @@ export default function TunnelPage() {
</thead>
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
{results.map((result, index) => {
const isDiagnosing = Boolean(
result.diagnosing,
);
const isSuccess = result.success === true;
const quality = getDiagnosisQualityDisplay(
result.averageTime,
result.packetLoss,
@@ -2254,22 +2263,28 @@ export default function TunnelPage() {
<tr
key={index}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
result.success
isDiagnosing
? "bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
{isDiagnosing ? (
<Spinner size="sm" />
) : (
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
isSuccess
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{isSuccess ? "✓" : "✗"}
</span>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-foreground truncate">
{result.description}
@@ -2284,18 +2299,24 @@ export default function TunnelPage() {
<td className="px-3 py-2 text-center">
<Chip
color={
result.success
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{result.success ? "成功" : "失败"}
{isDiagnosing
? "诊断中"
: isSuccess
? "成功"
: "失败"}
</Chip>
</td>
<td className="px-3 py-2 text-center">
{result.success ? (
{isSuccess ? (
<span className="font-semibold text-primary">
{result.averageTime?.toFixed(0)}
</span>
@@ -2306,7 +2327,7 @@ export default function TunnelPage() {
)}
</td>
<td className="px-3 py-2 text-center">
{result.success ? (
{isSuccess ? (
<span
className={`font-semibold ${
(result.packetLoss || 0) > 0
@@ -2323,7 +2344,7 @@ export default function TunnelPage() {
)}
</td>
<td className="px-3 py-2 text-center">
{result.success && quality ? (
{isSuccess && quality ? (
<Chip
className="text-xs whitespace-nowrap"
color={quality.color as any}
@@ -2417,6 +2438,8 @@ export default function TunnelPage() {
</h3>
</div>
{results.map((result, index) => {
const isDiagnosing = Boolean(result.diagnosing);
const isSuccess = result.success === true;
const quality = getDiagnosisQualityDisplay(
result.averageTime,
result.packetLoss,
@@ -2426,21 +2449,27 @@ export default function TunnelPage() {
<div
key={index}
className={`border rounded-lg p-3 ${
result.success
isDiagnosing
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
: isSuccess
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
>
<div className="flex items-start gap-2 mb-2">
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
{isDiagnosing ? (
<Spinner size="sm" />
) : (
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
isSuccess
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{isSuccess ? "✓" : "✗"}
</span>
)}
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-foreground break-words">
{result.description}
@@ -2452,16 +2481,24 @@ export default function TunnelPage() {
<Chip
className="flex-shrink-0"
color={
result.success ? "success" : "danger"
isDiagnosing
? "warning"
: isSuccess
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{result.success ? "成功" : "失败"}
{isDiagnosing
? "诊断中"
: isSuccess
? "成功"
: "失败"}
</Chip>
</div>
{result.success ? (
{isSuccess ? (
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
<div className="text-center">
<div className="text-lg font-bold text-primary">
@@ -2505,8 +2542,16 @@ export default function TunnelPage() {
</div>
) : (
<div className="mt-2 pt-2 border-t border-divider">
<div className="text-xs text-danger">
{result.message || "连接失败"}
<div
className={`text-xs ${
isDiagnosing
? "text-warning"
: "text-danger"
}`}
>
{isDiagnosing
? result.message || "诊断中..."
: result.message || "连接失败"}
</div>
</div>
)}
@@ -2547,14 +2592,16 @@ export default function TunnelPage() {
</div>
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
{diagnosisResult.results.some((r) => !r.success) && (
{diagnosisResult.results.some(
(r) => r.success === false && !r.diagnosing,
) && (
<div className="space-y-2 hidden md:block">
<h4 className="text-sm font-semibold text-danger">
失败详情
</h4>
<div className="space-y-2">
{diagnosisResult.results
.filter((r) => !r.success)
.filter((r) => r.success === false && !r.diagnosing)
.map((result, index) => (
<Alert
key={index}
+2 -1
View File
@@ -1,5 +1,6 @@
export interface DiagnosisEntry {
success: boolean;
success?: boolean;
diagnosing?: boolean;
description: string;
nodeName: string;
nodeId: string;