mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-14 16:54:24 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4eaecb289b | |||
| 98a9e5c666 | |||
| d244920dd4 | |||
| 77e4387b35 | |||
| 7a40ddb1ef | |||
| d33814e18c | |||
| cf51b305b0 | |||
| 9ffeb83753 |
@@ -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/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface ForwardDiagnosisEntry {
|
||||
success: boolean;
|
||||
success?: boolean;
|
||||
diagnosing?: boolean;
|
||||
description: string;
|
||||
nodeName: string;
|
||||
nodeId: string;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface DiagnosisEntry {
|
||||
success: boolean;
|
||||
success?: boolean;
|
||||
diagnosing?: boolean;
|
||||
description: string;
|
||||
nodeName: string;
|
||||
nodeId: string;
|
||||
|
||||
Reference in New Issue
Block a user