Merge pull request #105 from Sagit-chu/opencode/neon-rocket

fix: 修复备份
This commit is contained in:
sagit
2026-02-13 15:33:25 +08:00
committed by GitHub
7 changed files with 676 additions and 431 deletions
+3 -3
View File
@@ -1185,6 +1185,7 @@ func (h *Handler) backupExport(w http.ResponseWriter, r *http.Request) {
type backupImportRequest struct { type backupImportRequest struct {
Types []string `json:"types"` Types []string `json:"types"`
sqlite.BackupData
} }
func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) { func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
@@ -1210,13 +1211,12 @@ func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
return return
} }
var backup sqlite.BackupData if req.BackupData.Version == "" {
if err := decodeJSON(r.Body, &backup); err != nil {
response.WriteJSON(w, response.Err(500, "备份数据格式错误")) response.WriteJSON(w, response.Err(500, "备份数据格式错误"))
return return
} }
result, err := h.repo.Import(&backup, req.Types) result, err := h.repo.Import(&req.BackupData, req.Types)
if err != nil { if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入失败: %v", err))) response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入失败: %v", err)))
return return
+1 -1
View File
@@ -40,7 +40,7 @@
"@heroui/system": "2.4.19", "@heroui/system": "2.4.19",
"@heroui/table": "^2.2.24", "@heroui/table": "^2.2.24",
"@heroui/tabs": "^2.2.27", "@heroui/tabs": "^2.2.27",
"@heroui/theme": "2.4.19", "@heroui/theme": "2.4.24",
"@heroui/use-theme": "2.1.10", "@heroui/use-theme": "2.1.10",
"@marsidev/react-turnstile": "^1.1.0", "@marsidev/react-turnstile": "^1.1.0",
"@nextui-org/system": "^2.4.6", "@nextui-org/system": "^2.4.6",
+28 -18
View File
@@ -1,3 +1,5 @@
import axios from "axios";
import Network from "./network"; import Network from "./network";
// 登陆相关接口 // 登陆相关接口
@@ -42,9 +44,17 @@ export const checkNodeStatus = (nodeId?: number) => {
}; };
export const upgradeNode = (id: number, version?: string) => export const upgradeNode = (id: number, version?: string) =>
Network.post("/node/upgrade", { id, version: version || "" }, { timeout: 5 * 60 * 1000 }); Network.post(
"/node/upgrade",
{ id, version: version || "" },
{ timeout: 5 * 60 * 1000 },
);
export const batchUpgradeNodes = (ids: number[], version?: string) => export const batchUpgradeNodes = (ids: number[], version?: string) =>
Network.post("/node/batch-upgrade", { ids, version: version || "" }, { timeout: 15 * 60 * 1000 }); Network.post(
"/node/batch-upgrade",
{ ids, version: version || "" },
{ timeout: 15 * 60 * 1000 },
);
export const getNodeReleases = () => Network.post("/node/releases"); export const getNodeReleases = () => Network.post("/node/releases");
export const rollbackNode = (id: number) => export const rollbackNode = (id: number) =>
Network.post("/node/rollback", { id }); Network.post("/node/rollback", { id });
@@ -224,12 +234,8 @@ export const resetPeerShareFlow = (id: number) =>
Network.post("/federation/share/reset-flow", { id }); Network.post("/federation/share/reset-flow", { id });
export const getPeerRemoteUsageList = () => export const getPeerRemoteUsageList = () =>
Network.post("/federation/share/remote-usage/list"); Network.post("/federation/share/remote-usage/list");
export const importRemoteNode = (data: { export const importRemoteNode = (data: { remoteUrl: string; token: string }) =>
remoteUrl: string; Network.post("/federation/node/import", data);
token: string;
}) => Network.post("/federation/node/import", data);
import axios from "axios";
export interface BackupTypes { export interface BackupTypes {
users?: boolean; users?: boolean;
@@ -248,18 +254,24 @@ export const exportBackup = async (types: string[] = []) => {
const token = window.localStorage.getItem("token"); const token = window.localStorage.getItem("token");
const baseURL = axios.defaults.baseURL || "/api/v1/"; const baseURL = axios.defaults.baseURL || "/api/v1/";
const response = await axios.post(`${baseURL}/backup/export`, { types }, { const response = await axios.post(
headers: { `${baseURL}/backup/export`,
Authorization: token, { types },
"Content-Type": "application/json", {
headers: {
Authorization: token,
"Content-Type": "application/json",
},
responseType: "blob",
}, },
responseType: "blob", );
});
const url = window.URL.createObjectURL(new Blob([response.data])); const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, ""); const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
link.setAttribute("download", `backup_${timestamp}.json`); link.setAttribute("download", `backup_${timestamp}.json`);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
@@ -267,7 +279,5 @@ export const exportBackup = async (types: string[] = []) => {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}; };
export const importBackup = (data: { export const importBackup = (data: { types: string[]; [key: string]: any }) =>
types: string[]; Network.post("/backup/import", data);
[key: string]: any;
}) => Network.post("/backup/import", data);
+10 -10
View File
@@ -342,6 +342,7 @@ export default function ConfigPage() {
const handleExport = async () => { const handleExport = async () => {
if (exportTypes.length === 0) { if (exportTypes.length === 0) {
toast.error("请至少选择一种数据类型"); toast.error("请至少选择一种数据类型");
return; return;
} }
setExporting(true); setExporting(true);
@@ -357,10 +358,12 @@ export default function ConfigPage() {
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
if (importTypes.length === 0) { if (importTypes.length === 0) {
toast.error("请先选择要导入的数据类型"); toast.error("请先选择要导入的数据类型");
return; return;
} }
@@ -514,11 +517,11 @@ export default function ConfigPage() {
</p> </p>
<CheckboxGroup <CheckboxGroup
label="选择导出内容"
orientation="horizontal"
classNames={{ classNames={{
wrapper: "gap-4", wrapper: "gap-4",
}} }}
label="选择导出内容"
orientation="horizontal"
value={exportTypes} value={exportTypes}
onValueChange={(values) => setExportTypes(values as string[])} onValueChange={(values) => setExportTypes(values as string[])}
> >
@@ -561,10 +564,7 @@ export default function ConfigPage() {
> >
全选 全选
</Button> </Button>
<Button <Button variant="flat" onPress={() => setExportTypes([])}>
variant="flat"
onPress={() => setExportTypes([])}
>
清空 清空
</Button> </Button>
</div> </div>
@@ -580,11 +580,11 @@ export default function ConfigPage() {
</p> </p>
<CheckboxGroup <CheckboxGroup
label="选择导入内容"
orientation="horizontal"
classNames={{ classNames={{
wrapper: "gap-4", wrapper: "gap-4",
}} }}
label="选择导入内容"
orientation="horizontal"
value={importTypes} value={importTypes}
onValueChange={(values) => setImportTypes(values as string[])} onValueChange={(values) => setImportTypes(values as string[])}
> >
@@ -601,18 +601,18 @@ export default function ConfigPage() {
</CheckboxGroup> </CheckboxGroup>
<input <input
type="file"
ref={fileInputRef} ref={fileInputRef}
accept=".json" accept=".json"
className="hidden" className="hidden"
type="file"
onChange={handleFileChange} onChange={handleFileChange}
/> />
<div className="flex gap-3"> <div className="flex gap-3">
<Button <Button
color="primary" color="primary"
variant="flat"
isLoading={importing} isLoading={importing}
variant="flat"
onPress={() => fileInputRef.current?.click()} onPress={() => fileInputRef.current?.click()}
> >
{importing ? "导入中..." : "选择文件导入"} {importing ? "导入中..." : "选择文件导入"}
+22 -20
View File
@@ -83,8 +83,9 @@ export default function IndexPage() {
if (response.code !== 0) { if (response.code !== 0) {
toast.error(response.msg || "登录失败"); toast.error(response.msg || "登录失败");
if (showCaptcha) { if (showCaptcha) {
setForm((prev) => ({ ...prev, captchaId: "" })); setForm((prev) => ({ ...prev, captchaId: "" }));
} }
return; return;
} }
@@ -138,11 +139,7 @@ export default function IndexPage() {
} else { } else {
const configResp = await getConfigByName("cloudflare_site_key"); const configResp = await getConfigByName("cloudflare_site_key");
if ( if (configResp.code === 0 && configResp.data && configResp.data.value) {
configResp.code === 0 &&
configResp.data &&
configResp.data.value
) {
setSiteKey(configResp.data.value); setSiteKey(configResp.data.value);
setShowCaptcha(true); setShowCaptcha(true);
} else { } else {
@@ -243,6 +240,8 @@ export default function IndexPage() {
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */} {/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
<div <div
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter" className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter"
role="button"
tabIndex={0}
onClick={() => { onClick={() => {
setShowCaptcha(false); setShowCaptcha(false);
setLoading(false); setLoading(false);
@@ -253,32 +252,35 @@ export default function IndexPage() {
setLoading(false); setLoading(false);
} }
}} }}
role="button"
tabIndex={0}
/> />
{/* 验证码容器 */} {/* 验证码容器 */}
<div className="mb-4 relative z-50 bg-white dark:bg-zinc-900 p-6 rounded-lg shadow-xl"> <div className="mb-4 relative z-50 bg-white dark:bg-zinc-900 p-6 rounded-lg shadow-xl">
<div className="mb-4 text-center text-sm font-medium text-gray-700 dark:text-gray-200">请完成安全验证</div> <div className="mb-4 text-center text-sm font-medium text-gray-700 dark:text-gray-200">
请完成安全验证
</div>
<div className="flex justify-center"> <div className="flex justify-center">
<Turnstile <Turnstile
siteKey={siteKey} options={{
onSuccess={(token) => { theme: (document.documentElement.classList.contains(
setForm((prev) => ({ ...prev, captchaId: token })); "dark",
void performLogin(token); ) ||
document.documentElement.getAttribute("data-theme") ===
"dark" ||
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light") as "light" | "dark" | "auto",
}} }}
siteKey={siteKey}
onError={() => { onError={() => {
toast.error("验证失败,请刷新重试"); toast.error("验证失败,请刷新重试");
setLoading(false); setLoading(false);
}} }}
onExpire={() => { onExpire={() => {
setForm((prev) => ({ ...prev, captchaId: "" })); setForm((prev) => ({ ...prev, captchaId: "" }));
}} }}
options={{ onSuccess={(token) => {
theme: (document.documentElement.classList.contains("dark") || setForm((prev) => ({ ...prev, captchaId: token }));
document.documentElement.getAttribute("data-theme") === "dark" || void performLogin(token);
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light") as "light" | "dark" | "auto"
}} }}
/> />
</div> </div>
+378 -316
View File
@@ -125,7 +125,12 @@ const SortableItem = ({
}; };
return ( return (
<div ref={setNodeRef} style={style} {...attributes} className="overflow-hidden"> <div
ref={setNodeRef}
style={style}
{...attributes}
className="overflow-hidden"
>
{children(listeners)} {children(listeners)}
</div> </div>
); );
@@ -174,13 +179,26 @@ export default function NodePage() {
// 升级相关状态 // 升级相关状态
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">("single"); const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">(
const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState<number | null>(null); "single",
const [releases, setReleases] = useState<Array<{ version: string; name: string; publishedAt: string; prerelease: boolean }>>([]); );
const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState<number | null>(
null,
);
const [releases, setReleases] = useState<
Array<{
version: string;
name: string;
publishedAt: string;
prerelease: boolean;
}>
>([]);
const [releasesLoading, setReleasesLoading] = useState(false); const [releasesLoading, setReleasesLoading] = useState(false);
const [selectedVersion, setSelectedVersion] = useState(""); const [selectedVersion, setSelectedVersion] = useState("");
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false); const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
const [upgradeProgress, setUpgradeProgress] = useState<Record<number, { stage: string; percent: number; message: string }>>({}); const [upgradeProgress, setUpgradeProgress] = useState<
Record<number, { stage: string; percent: number; message: string }>
>({});
const websocketRef = useRef<WebSocket | null>(null); const websocketRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null); const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -237,7 +255,11 @@ export default function NodePage() {
const nodesData: Node[] = (res.data || []).map((node: any) => ({ const nodesData: Node[] = (res.data || []).map((node: any) => ({
...node, ...node,
inx: node.inx ?? 0, inx: node.inx ?? 0,
connectionStatus: node.syncError ? "offline" : node.status === 1 ? "online" : "offline", connectionStatus: node.syncError
? "offline"
: node.status === 1
? "online"
: "offline",
syncError: node.syncError || undefined, syncError: node.syncError || undefined,
systemInfo: null, systemInfo: null,
copyLoading: false, copyLoading: false,
@@ -444,7 +466,11 @@ export default function NodePage() {
); );
} else if (type === "upgrade_progress") { } else if (type === "upgrade_progress") {
try { try {
const progressData = typeof messageData === "string" ? JSON.parse(messageData) : messageData; const progressData =
typeof messageData === "string"
? JSON.parse(messageData)
: messageData;
if (progressData?.data) { if (progressData?.data) {
setUpgradeProgress((prev) => ({ setUpgradeProgress((prev) => ({
...prev, ...prev,
@@ -803,10 +829,11 @@ export default function NodePage() {
} }
}; };
// 打开版本选择弹窗 // 打开版本选择弹窗
const openUpgradeModal = async (target: "single" | "batch", nodeId?: number) => { const openUpgradeModal = async (
target: "single" | "batch",
nodeId?: number,
) => {
setUpgradeTarget(target); setUpgradeTarget(target);
setUpgradeTargetNodeId(nodeId || null); setUpgradeTargetNodeId(nodeId || null);
setSelectedVersion(""); setSelectedVersion("");
@@ -814,6 +841,7 @@ export default function NodePage() {
setReleasesLoading(true); setReleasesLoading(true);
try { try {
const res = await getNodeReleases(); const res = await getNodeReleases();
if (res.code === 0 && Array.isArray(res.data)) { if (res.code === 0 && Array.isArray(res.data)) {
setReleases(res.data); setReleases(res.data);
} else { } else {
@@ -829,16 +857,21 @@ export default function NodePage() {
// 确认升级(从版本弹窗) // 确认升级(从版本弹窗)
const handleConfirmUpgrade = async () => { const handleConfirmUpgrade = async () => {
const version = selectedVersion || undefined; const version = selectedVersion || undefined;
if (upgradeTarget === "single" && upgradeTargetNodeId) { if (upgradeTarget === "single" && upgradeTargetNodeId) {
setUpgradeModalOpen(false); setUpgradeModalOpen(false);
// Find the node // Find the node
const node = nodeList.find((n) => n.id === upgradeTargetNodeId); const node = nodeList.find((n) => n.id === upgradeTargetNodeId);
if (!node) return; if (!node) return;
setNodeList((prev) => setNodeList((prev) =>
prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n)), prev.map((n) =>
n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,
),
); );
try { try {
const res = await upgradeNode(upgradeTargetNodeId, version); const res = await upgradeNode(upgradeTargetNodeId, version);
if (res.code === 0) { if (res.code === 0) {
toast.success(`节点升级命令已发送,节点将自动重启`); toast.success(`节点升级命令已发送,节点将自动重启`);
} else { } else {
@@ -848,7 +881,9 @@ export default function NodePage() {
toast.error("网络错误,请重试"); toast.error("网络错误,请重试");
} finally { } finally {
setNodeList((prev) => setNodeList((prev) =>
prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n)), prev.map((n) =>
n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,
),
); );
} }
} else if (upgradeTarget === "batch") { } else if (upgradeTarget === "batch") {
@@ -856,6 +891,7 @@ export default function NodePage() {
setUpgradeModalOpen(false); setUpgradeModalOpen(false);
try { try {
const res = await batchUpgradeNodes(Array.from(selectedIds), version); const res = await batchUpgradeNodes(Array.from(selectedIds), version);
if (res.code === 0) { if (res.code === 0) {
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`); toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
} else { } else {
@@ -876,6 +912,7 @@ export default function NodePage() {
); );
try { try {
const res = await rollbackNode(node.id); const res = await rollbackNode(node.id);
if (res.code === 0) { if (res.code === 0) {
toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`); toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);
} else { } else {
@@ -885,7 +922,9 @@ export default function NodePage() {
toast.error("网络错误,请重试"); toast.error("网络错误,请重试");
} finally { } finally {
setNodeList((prev) => setNodeList((prev) =>
prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: false } : n)), prev.map((n) =>
n.id === node.id ? { ...n, rollbackLoading: false } : n,
),
); );
} }
}; };
@@ -1251,332 +1290,349 @@ export default function NodePage() {
return ( return (
<SortableItem key={node.id} id={node.id}> <SortableItem key={node.id} id={node.id}>
{(listeners) => ( {(listeners) => (
<Card <Card
key={node.id} key={node.id}
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden" className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
> >
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex justify-between items-start w-full"> <div className="flex justify-between items-start w-full">
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
{selectMode && ( {selectMode && (
<Checkbox <Checkbox
isSelected={selectedIds.has(node.id)} isSelected={selectedIds.has(node.id)}
onValueChange={() => toggleSelect(node.id)} onValueChange={() => toggleSelect(node.id)}
/> />
)} )}
<h3 className="font-semibold text-foreground truncate text-sm"> <h3 className="font-semibold text-foreground truncate text-sm">
{node.name} {node.name}
</h3> </h3>
</div>
<div className="flex items-center gap-1.5 ml-2">
<div
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
{...listeners}
style={{ touchAction: "none" }}
title="拖拽排序"
>
<svg
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> </div>
{isRemoteNode && ( <div className="flex items-center gap-1.5 ml-2">
<div
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
{...listeners}
style={{ touchAction: "none" }}
title="拖拽排序"
>
<svg
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>
{isRemoteNode && (
<Chip
className="text-xs"
color="secondary"
size="sm"
variant="flat"
>
远程
</Chip>
)}
<Chip <Chip
className="text-xs" className="text-xs"
color="secondary" color={
node.connectionStatus === "online"
? "success"
: "danger"
}
size="sm" size="sm"
variant="flat" variant="flat"
> >
远程 {node.connectionStatus === "online"
? "在线"
: "离线"}
</Chip> </Chip>
)}
<Chip
className="text-xs"
color={
node.connectionStatus === "online"
? "success"
: "danger"
}
size="sm"
variant="flat"
>
{node.connectionStatus === "online"
? "在线"
: "离线"}
</Chip>
</div>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3">
{isRemoteNode && node.syncError && (
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
{node.syncError === "provider_share_deleted"
? "提供方已删除该分享"
: node.syncError === "provider_share_disabled"
? "提供方已禁用该分享"
: node.syncError === "provider_share_expired"
? "提供方分享已过期"
: `远程同步失败: ${node.syncError}`}
</div>
)}
{/* 基础信息 */}
<div className="space-y-2 mb-4">
<div className="flex justify-between items-center text-sm min-w-0">
<span className="text-default-600 flex-shrink-0">
IP
</span>
<div className="text-right text-xs min-w-0 flex-1 ml-2">
{node.serverIpV4?.trim() ||
node.serverIpV6?.trim() ? (
<div className="space-y-0.5">
{node.serverIpV4?.trim() && (
<span
className="font-mono truncate block"
title={node.serverIpV4.trim()}
>
{node.serverIpV4.trim()}
</span>
)}
{node.serverIpV6?.trim() && (
<span
className="font-mono truncate block"
title={node.serverIpV6.trim()}
>
{node.serverIpV6.trim()}
</span>
)}
</div>
) : (
<span
className="font-mono truncate block"
title={node.serverIp.trim()}
>
{node.serverIp.trim()}
</span>
)}
</div> </div>
</div> </div>
</CardHeader>
<CardBody className="pt-0 pb-3">
{isRemoteNode && node.syncError && (
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
{node.syncError === "provider_share_deleted"
? "提供方已删除该分享"
: node.syncError === "provider_share_disabled"
? "提供方已禁用该分享"
: node.syncError === "provider_share_expired"
? "提供方分享已过期"
: `远程同步失败: ${node.syncError}`}
</div>
)}
{/* 基础信息 */}
<div className="space-y-2 mb-4">
<div className="flex justify-between items-center text-sm min-w-0">
<span className="text-default-600 flex-shrink-0">
IP
</span>
<div className="text-right text-xs min-w-0 flex-1 ml-2">
{node.serverIpV4?.trim() ||
node.serverIpV6?.trim() ? (
<div className="space-y-0.5">
{node.serverIpV4?.trim() && (
<span
className="font-mono truncate block"
title={node.serverIpV4.trim()}
>
{node.serverIpV4.trim()}
</span>
)}
{node.serverIpV6?.trim() && (
<span
className="font-mono truncate block"
title={node.serverIpV6.trim()}
>
{node.serverIpV6.trim()}
</span>
)}
</div>
) : (
<span
className="font-mono truncate block"
title={node.serverIp.trim()}
>
{node.serverIp.trim()}
</span>
)}
</div>
</div>
{!isRemoteNode && (
<>
<div className="flex justify-between text-sm">
<span className="text-default-600">版本</span>
<span className="text-xs">
{node.version || "未知"}
</span>
</div>
{upgradeProgress[node.id] &&
upgradeProgress[node.id].percent < 100 && (
<div className="mt-1">
<Progress
showValueLabel
aria-label="升级进度"
color="warning"
label={upgradeProgress[node.id].message}
size="sm"
value={upgradeProgress[node.id].percent}
/>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-default-600">
开机时间
</span>
<span className="text-xs">
{node.connectionStatus === "online" &&
node.systemInfo
? formatUptime(node.systemInfo.uptime)
: "-"}
</span>
</div>
</>
)}
</div>
{!isRemoteNode && ( {!isRemoteNode && (
<> <>
<div className="flex justify-between text-sm"> {/* 系统监控 */}
<span className="text-default-600">版本</span> <div className="space-y-3 mb-4">
<span className="text-xs"> <div className="grid grid-cols-2 gap-3">
{node.version || "未知"} <div>
</span> <div className="flex justify-between text-xs mb-1">
</div> <span>CPU</span>
{upgradeProgress[node.id] && upgradeProgress[node.id].percent < 100 && ( <span className="font-mono">
<div className="mt-1"> {node.connectionStatus === "online" &&
<Progress node.systemInfo
aria-label="升级进度" ? `${node.systemInfo.cpuUsage.toFixed(1)}%`
color="warning" : "-"}
label={upgradeProgress[node.id].message} </span>
showValueLabel </div>
size="sm" <Progress
value={upgradeProgress[node.id].percent} aria-label="CPU使用率"
/> color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0,
node.connectionStatus !== "online",
)}
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0
}
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>内存</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
: "-"}
</span>
</div>
<Progress
aria-label="内存使用率"
color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0,
node.connectionStatus !== "online",
)}
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
上传
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(
node.systemInfo.uploadSpeed,
)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
下载
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(
node.systemInfo.downloadSpeed,
)
: "-"}
</div>
</div>
</div>
{/* 流量统计 */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
↑ 上行流量
</div>
<div className="font-mono text-primary-700 dark:text-primary-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(
node.systemInfo.uploadTraffic,
)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
<div className="text-success-600 dark:text-success-400 mb-0.5">
↓ 下行流量
</div>
<div className="font-mono text-success-700 dark:text-success-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(
node.systemInfo.downloadTraffic,
)
: "-"}
</div>
</div>
</div> </div>
)}
<div className="flex justify-between text-sm">
<span className="text-default-600">开机时间</span>
<span className="text-xs">
{node.connectionStatus === "online" &&
node.systemInfo
? formatUptime(node.systemInfo.uptime)
: "-"}
</span>
</div> </div>
</> </>
)} )}
</div>
{!isRemoteNode && ( {/* 操作按钮 */}
<> <div className="space-y-1.5">
{/* 系统监控 */}
<div className="space-y-3 mb-4">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>CPU</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
: "-"}
</span>
</div>
<Progress
aria-label="CPU使用率"
color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0,
node.connectionStatus !== "online",
)}
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.cpuUsage
: 0
}
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>内存</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
: "-"}
</span>
</div>
<Progress
aria-label="内存使用率"
color={getProgressColor(
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0,
node.connectionStatus !== "online",
)}
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
? node.systemInfo.memoryUsage
: 0
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
上传
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(node.systemInfo.uploadSpeed)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">
下载
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
? formatSpeed(node.systemInfo.downloadSpeed)
: "-"}
</div>
</div>
</div>
{/* 流量统计 */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
↑ 上行流量
</div>
<div className="font-mono text-primary-700 dark:text-primary-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(node.systemInfo.uploadTraffic)
: "-"}
</div>
</div>
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
<div className="text-success-600 dark:text-success-400 mb-0.5">
↓ 下行流量
</div>
<div className="font-mono text-success-700 dark:text-success-300">
{node.connectionStatus === "online" &&
node.systemInfo
? formatTraffic(
node.systemInfo.downloadTraffic,
)
: "-"}
</div>
</div>
</div>
</div>
</>
)}
{/* 操作按钮 */}
<div className="space-y-1.5">
{!isRemoteNode && (
<div className="grid grid-cols-3 gap-1.5">
<Button
className="min-h-8"
color="success"
isLoading={node.copyLoading}
size="sm"
variant="flat"
onPress={() => handleCopyInstallCommand(node)}
>
安装
</Button>
<Button
className="min-h-8"
color="warning"
isDisabled={node.connectionStatus !== "online"}
isLoading={node.upgradeLoading}
size="sm"
variant="flat"
onPress={() => openUpgradeModal("single", node.id)}
>
升级
</Button>
<Button
className="min-h-8"
color="secondary"
isDisabled={node.connectionStatus !== "online"}
isLoading={node.rollbackLoading}
size="sm"
variant="flat"
onPress={() => handleRollbackNode(node)}
>
回退
</Button>
</div>
)}
<div className={`grid gap-1.5 ${isRemoteNode ? "grid-cols-1" : "grid-cols-2"}`}>
{!isRemoteNode && ( {!isRemoteNode && (
<div className="grid grid-cols-3 gap-1.5">
<Button
className="min-h-8"
color="success"
isLoading={node.copyLoading}
size="sm"
variant="flat"
onPress={() => handleCopyInstallCommand(node)}
>
安装
</Button>
<Button
className="min-h-8"
color="warning"
isDisabled={
node.connectionStatus !== "online"
}
isLoading={node.upgradeLoading}
size="sm"
variant="flat"
onPress={() =>
openUpgradeModal("single", node.id)
}
>
升级
</Button>
<Button
className="min-h-8"
color="secondary"
isDisabled={
node.connectionStatus !== "online"
}
isLoading={node.rollbackLoading}
size="sm"
variant="flat"
onPress={() => handleRollbackNode(node)}
>
回退
</Button>
</div>
)}
<div
className={`grid gap-1.5 ${isRemoteNode ? "grid-cols-1" : "grid-cols-2"}`}
>
{!isRemoteNode && (
<Button
className="min-h-8"
color="primary"
size="sm"
variant="flat"
onPress={() => handleEdit(node)}
>
编辑
</Button>
)}
<Button <Button
className="min-h-8" className="min-h-8"
color="primary" color="danger"
size="sm" size="sm"
variant="flat" variant="flat"
onPress={() => handleEdit(node)} onPress={() => handleDelete(node)}
> >
编辑 删除
</Button> </Button>
)} </div>
<Button
className="min-h-8"
color="danger"
size="sm"
variant="flat"
onPress={() => handleDelete(node)}
>
删除
</Button>
</div> </div>
</div> </CardBody>
</CardBody> </Card>
</Card> )}
)} </SortableItem>
</SortableItem>
); );
})} })}
</div> </div>
@@ -2041,6 +2097,7 @@ export default function NodePage() {
selectedKeys={selectedVersion ? [selectedVersion] : []} selectedKeys={selectedVersion ? [selectedVersion] : []}
onSelectionChange={(keys) => { onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as string; const selected = Array.from(keys)[0] as string;
setSelectedVersion(selected || ""); setSelectedVersion(selected || "");
}} }}
> >
@@ -2053,7 +2110,12 @@ export default function NodePage() {
? new Date(r.publishedAt).toLocaleDateString() ? new Date(r.publishedAt).toLocaleDateString()
: ""} : ""}
{r.prerelease && ( {r.prerelease && (
<Chip className="ml-1" color="warning" size="sm" variant="flat"> <Chip
className="ml-1"
color="warning"
size="sm"
variant="flat"
>
预览 预览
</Chip> </Chip>
)} )}
+229 -58
View File
@@ -12,6 +12,7 @@ import {
} from "@heroui/modal"; } from "@heroui/modal";
import { Select, SelectItem } from "@heroui/select"; import { Select, SelectItem } from "@heroui/select";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { import {
getNodeList, getNodeList,
createPeerShare, createPeerShare,
@@ -128,6 +129,7 @@ export default function PanelSharingPage() {
setLoading(true); setLoading(true);
try { try {
const res = await getPeerShareList(); const res = await getPeerShareList();
if (res.code === 0) { if (res.code === 0) {
setShares(res.data || []); setShares(res.data || []);
} else { } else {
@@ -141,10 +143,12 @@ export default function PanelSharingPage() {
const loadNodes = useCallback(async () => { const loadNodes = useCallback(async () => {
try { try {
const res = await getNodeList(); const res = await getNodeList();
if (res.code === 0) { if (res.code === 0) {
const localNodes: Node[] = (res.data || []).filter( const localNodes: Node[] = (res.data || []).filter(
(node: Node) => (node?.isRemote ?? 0) !== 1, (node: Node) => (node?.isRemote ?? 0) !== 1,
); );
setNodes(localNodes); setNodes(localNodes);
setShareForm((prev) => { setShareForm((prev) => {
if (!prev.nodeId) { if (!prev.nodeId) {
@@ -153,6 +157,7 @@ export default function PanelSharingPage() {
const hasSelectedNode = localNodes.some( const hasSelectedNode = localNodes.some(
(node: Node) => String(node.id) === prev.nodeId, (node: Node) => String(node.id) === prev.nodeId,
); );
return hasSelectedNode ? prev : { ...prev, nodeId: "" }; return hasSelectedNode ? prev : { ...prev, nodeId: "" };
}); });
} }
@@ -165,6 +170,7 @@ export default function PanelSharingPage() {
setRemoteUsageLoading(true); setRemoteUsageLoading(true);
try { try {
const res = await getPeerRemoteUsageList(); const res = await getPeerRemoteUsageList();
if (res.code === 0) { if (res.code === 0) {
setRemoteUsageNodes(res.data || []); setRemoteUsageNodes(res.data || []);
} else { } else {
@@ -179,6 +185,7 @@ export default function PanelSharingPage() {
if (selectedTab === "my-shares") { if (selectedTab === "my-shares") {
loadShares(); loadShares();
loadNodes(); loadNodes();
return; return;
} }
if (selectedTab === "remote-nodes") { if (selectedTab === "remote-nodes") {
@@ -189,15 +196,19 @@ export default function PanelSharingPage() {
const handleCreateShare = async () => { const handleCreateShare = async () => {
if (!shareForm.name || !shareForm.nodeId) { if (!shareForm.name || !shareForm.nodeId) {
toast.error("请填写必要信息"); toast.error("请填写必要信息");
return; return;
} }
const nodeId = parseInt(shareForm.nodeId, 10); const nodeId = parseInt(shareForm.nodeId, 10);
if (Number.isNaN(nodeId) || !nodes.some((node) => node.id === nodeId)) { if (Number.isNaN(nodeId) || !nodes.some((node) => node.id === nodeId)) {
toast.error("仅可选择本地节点"); toast.error("仅可选择本地节点");
return; return;
} }
if (shareForm.maxBandwidth < 0) { if (shareForm.maxBandwidth < 0) {
toast.error("流量上限不能为负数"); toast.error("流量上限不能为负数");
return; return;
} }
try { try {
@@ -213,6 +224,7 @@ export default function PanelSharingPage() {
allowedDomains: shareForm.allowedDomains, allowedDomains: shareForm.allowedDomains,
allowedIps: shareForm.allowedIps, allowedIps: shareForm.allowedIps,
}); });
if (res.code === 0) { if (res.code === 0) {
toast.success("创建成功"); toast.success("创建成功");
setCreateShareOpen(false); setCreateShareOpen(false);
@@ -228,6 +240,7 @@ export default function PanelSharingPage() {
const handleDeleteShare = async (id: number) => { const handleDeleteShare = async (id: number) => {
try { try {
const res = await deletePeerShare(id); const res = await deletePeerShare(id);
if (res.code === 0) { if (res.code === 0) {
toast.success("删除成功"); toast.success("删除成功");
loadShares(); loadShares();
@@ -242,6 +255,7 @@ export default function PanelSharingPage() {
const handleResetShareFlow = async (id: number) => { const handleResetShareFlow = async (id: number) => {
try { try {
const res = await resetPeerShareFlow(id); const res = await resetPeerShareFlow(id);
if (res.code === 0) { if (res.code === 0) {
toast.success("共享流量已重置"); toast.success("共享流量已重置");
loadShares(); loadShares();
@@ -257,7 +271,10 @@ export default function PanelSharingPage() {
setEditForm({ setEditForm({
id: share.id, id: share.id,
name: share.name, name: share.name,
maxBandwidth: share.maxBandwidth > 0 ? Math.round(share.maxBandwidth / (1024 * 1024 * 1024)) : 0, maxBandwidth:
share.maxBandwidth > 0
? Math.round(share.maxBandwidth / (1024 * 1024 * 1024))
: 0,
expiryTime: share.expiryTime, expiryTime: share.expiryTime,
portRangeStart: share.portRangeStart, portRangeStart: share.portRangeStart,
portRangeEnd: share.portRangeEnd, portRangeEnd: share.portRangeEnd,
@@ -270,10 +287,12 @@ export default function PanelSharingPage() {
const handleEditShare = async () => { const handleEditShare = async () => {
if (!editForm.name) { if (!editForm.name) {
toast.error("名称不能为空"); toast.error("名称不能为空");
return; return;
} }
if (editForm.maxBandwidth < 0) { if (editForm.maxBandwidth < 0) {
toast.error("流量上限不能为负数"); toast.error("流量上限不能为负数");
return; return;
} }
try { try {
@@ -287,6 +306,7 @@ export default function PanelSharingPage() {
allowedDomains: editForm.allowedDomains, allowedDomains: editForm.allowedDomains,
allowedIps: editForm.allowedIps, allowedIps: editForm.allowedIps,
}); });
if (res.code === 0) { if (res.code === 0) {
toast.success("编辑成功"); toast.success("编辑成功");
setEditShareOpen(false); setEditShareOpen(false);
@@ -302,11 +322,13 @@ export default function PanelSharingPage() {
const handleImportNode = async () => { const handleImportNode = async () => {
if (!importForm.remoteUrl || !importForm.token) { if (!importForm.remoteUrl || !importForm.token) {
toast.error("请填写完整信息"); toast.error("请填写完整信息");
return; return;
} }
try { try {
// Automatically add http/https if missing // Automatically add http/https if missing
let url = importForm.remoteUrl.trim(); let url = importForm.remoteUrl.trim();
if (!url.startsWith("http")) { if (!url.startsWith("http")) {
url = "http://" + url; url = "http://" + url;
} }
@@ -315,6 +337,7 @@ export default function PanelSharingPage() {
remoteUrl: url, remoteUrl: url,
token: importForm.token.trim(), token: importForm.token.trim(),
}); });
if (res.code === 0) { if (res.code === 0) {
toast.success("导入成功,请前往节点列表查看"); toast.success("导入成功,请前往节点列表查看");
setImportNodeOpen(false); setImportNodeOpen(false);
@@ -341,6 +364,7 @@ export default function PanelSharingPage() {
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB"; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
if (bytes < 1024 * 1024 * 1024) if (bytes < 1024 * 1024 * 1024)
return (bytes / (1024 * 1024)).toFixed(2) + " MB"; return (bytes / (1024 * 1024)).toFixed(2) + " MB";
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
}; };
@@ -351,6 +375,7 @@ export default function PanelSharingPage() {
if (chainType === 3) { if (chainType === 3) {
return "出口节点"; return "出口节点";
} }
return "未知链路"; return "未知链路";
}; };
@@ -370,7 +395,10 @@ export default function PanelSharingPage() {
<Card> <Card>
<CardBody> <CardBody>
<div className="mb-4"> <div className="mb-4">
<Button color="primary" onPress={() => setCreateShareOpen(true)}> <Button
color="primary"
onPress={() => setCreateShareOpen(true)}
>
创建分享 创建分享
</Button> </Button>
</div> </div>
@@ -382,7 +410,10 @@ export default function PanelSharingPage() {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{shares.map((share) => ( {shares.map((share) => (
<Card key={share.id} className="border border-divider shadow-sm"> <Card
key={share.id}
className="border border-divider shadow-sm"
>
<CardHeader className="flex justify-between"> <CardHeader className="flex justify-between">
<h3 className="font-bold">{share.name}</h3> <h3 className="font-bold">{share.name}</h3>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -400,29 +431,67 @@ export default function PanelSharingPage() {
> >
重置流量 重置流量
</Button> </Button>
<Button size="sm" color="danger" variant="flat" onPress={() => handleDeleteShare(share.id)}>删除</Button> <Button
color="danger"
size="sm"
variant="flat"
onPress={() => handleDeleteShare(share.id)}
>
删除
</Button>
</div> </div>
</CardHeader> </CardHeader>
<CardBody className="text-sm space-y-2"> <CardBody className="text-sm space-y-2">
<p>端口范围: {share.portRangeStart} - {share.portRangeEnd}</p> <p>
<p>流量上限: {share.maxBandwidth > 0 ? formatFlowGB(share.maxBandwidth) : "不限制"}</p> 端口范围: {share.portRangeStart} -{" "}
{share.portRangeEnd}
</p>
<p>
流量上限:{" "}
{share.maxBandwidth > 0
? formatFlowGB(share.maxBandwidth)
: "不限制"}
</p>
<p>当前流量: {formatFlowGB(share.currentFlow || 0)}</p> <p>当前流量: {formatFlowGB(share.currentFlow || 0)}</p>
<p>远程占用端口: {share.usedPorts && share.usedPorts.length > 0 ? share.usedPorts.join(", ") : "暂无"}</p> <p>
{share.usedPortDetails && share.usedPortDetails.length > 0 && ( 远程占用端口:{" "}
<div className="flex flex-wrap gap-2"> {share.usedPorts && share.usedPorts.length > 0
{share.usedPortDetails.map((item) => ( ? share.usedPorts.join(", ")
<span key={item.runtimeId} className="text-xs rounded-full px-2 py-1 bg-default-100"> : "暂无"}
{item.port} / {item.role || "reserved"} </p>
</span> {share.usedPortDetails &&
))} share.usedPortDetails.length > 0 && (
</div> <div className="flex flex-wrap gap-2">
{share.usedPortDetails.map((item) => (
<span
key={item.runtimeId}
className="text-xs rounded-full px-2 py-1 bg-default-100"
>
{item.port} / {item.role || "reserved"}
</span>
))}
</div>
)}
{share.allowedDomains && (
<p>允许域名: {share.allowedDomains}</p>
)} )}
{share.allowedDomains && <p>允许域名: {share.allowedDomains}</p>} {share.allowedIps && (
{share.allowedIps && <p>允许API IP: {share.allowedIps}</p>} <p>允许API IP: {share.allowedIps}</p>
<p>过期时间: {share.expiryTime === 0 ? "永久" : new Date(share.expiryTime).toLocaleDateString()}</p> )}
<p>
过期时间:{" "}
{share.expiryTime === 0
? "永久"
: new Date(share.expiryTime).toLocaleDateString()}
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Input readOnly size="sm" value={share.token} /> <Input readOnly size="sm" value={share.token} />
<Button size="sm" onPress={() => copyToken(share.token)}>复制</Button> <Button
size="sm"
onPress={() => copyToken(share.token)}
>
复制
</Button>
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
@@ -436,7 +505,10 @@ export default function PanelSharingPage() {
<Card> <Card>
<CardBody> <CardBody>
<div className="mb-4"> <div className="mb-4">
<Button color="secondary" onPress={() => setImportNodeOpen(true)}> <Button
color="secondary"
onPress={() => setImportNodeOpen(true)}
>
导入远程节点 导入远程节点
</Button> </Button>
</div> </div>
@@ -446,15 +518,22 @@ export default function PanelSharingPage() {
) : remoteUsageNodes.length === 0 ? ( ) : remoteUsageNodes.length === 0 ? (
<div className="text-center py-10 text-gray-500"> <div className="text-center py-10 text-gray-500">
<p>暂无远程节点占用记录。</p> <p>暂无远程节点占用记录。</p>
<p className="mt-2">导入远程节点并创建隧道后,这里会显示远端端口占用情况。</p> <p className="mt-2">
导入远程节点并创建隧道后,这里会显示远端端口占用情况。
</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{remoteUsageNodes.map((node) => ( {remoteUsageNodes.map((node) => (
<Card key={node.nodeId} className="border border-divider shadow-sm"> <Card
key={node.nodeId}
className="border border-divider shadow-sm"
>
<CardHeader className="flex justify-between"> <CardHeader className="flex justify-between">
<h3 className="font-bold">{node.nodeName}</h3> <h3 className="font-bold">{node.nodeName}</h3>
<span className="text-xs text-default-500">绑定 {node.activeBindingNum || 0}</span> <span className="text-xs text-default-500">
绑定 {node.activeBindingNum || 0}
</span>
</CardHeader> </CardHeader>
<CardBody className="text-sm space-y-2"> <CardBody className="text-sm space-y-2">
{node.syncError && ( {node.syncError && (
@@ -470,18 +549,40 @@ export default function PanelSharingPage() {
)} )}
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>} {node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
<p>共享ID: {node.shareId || "-"}</p> <p>共享ID: {node.shareId || "-"}</p>
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p> <p>
<p>共享流量: {node.maxBandwidth > 0 ? `${formatFlowGB(node.currentFlow || 0)} / ${formatFlowGB(node.maxBandwidth)}` : `${formatFlowGB(node.currentFlow || 0)} / 不限制`}</p> 端口范围:{" "}
<p>远端占用端口: {node.usedPorts && node.usedPorts.length > 0 ? node.usedPorts.join(", ") : "暂无"}</p> {node.portRangeStart > 0 && node.portRangeEnd > 0
? `${node.portRangeStart} - ${node.portRangeEnd}`
: "-"}
</p>
<p>
共享流量:{" "}
{node.maxBandwidth > 0
? `${formatFlowGB(node.currentFlow || 0)} / ${formatFlowGB(node.maxBandwidth)}`
: `${formatFlowGB(node.currentFlow || 0)} / 不限制`}
</p>
<p>
远端占用端口:{" "}
{node.usedPorts && node.usedPorts.length > 0
? node.usedPorts.join(", ")
: "暂无"}
</p>
{node.bindings && node.bindings.length > 0 && ( {node.bindings && node.bindings.length > 0 && (
<div className="space-y-1 pt-1"> <div className="space-y-1 pt-1">
{node.bindings.map((binding) => ( {node.bindings.map((binding) => (
<p key={binding.bindingId} className="text-xs text-default-600"> <p
隧道 {binding.tunnelName || `#${binding.tunnelId}`} key={binding.bindingId}
className="text-xs text-default-600"
>
隧道{" "}
{binding.tunnelName || `#${binding.tunnelId}`}
{" · "} {" · "}
端口 {binding.allocatedPort} 端口 {binding.allocatedPort}
{" · "} {" · "}
{formatChainType(binding.chainType, binding.hopInx)} {formatChainType(
binding.chainType,
binding.hopInx,
)}
</p> </p>
))} ))}
</div> </div>
@@ -505,13 +606,17 @@ export default function PanelSharingPage() {
label="名称" label="名称"
placeholder="备注名称" placeholder="备注名称"
value={shareForm.name} value={shareForm.name}
onChange={(e) => setShareForm({ ...shareForm, name: e.target.value })} onChange={(e) =>
setShareForm({ ...shareForm, name: e.target.value })
}
/> />
<Select <Select
label="选择节点" label="选择节点"
placeholder="选择要分享的本地节点" placeholder="选择要分享的本地节点"
selectedKeys={shareForm.nodeId ? [shareForm.nodeId] : []} selectedKeys={shareForm.nodeId ? [shareForm.nodeId] : []}
onChange={(e) => setShareForm({ ...shareForm, nodeId: e.target.value })} onChange={(e) =>
setShareForm({ ...shareForm, nodeId: e.target.value })
}
> >
{nodes.map((node) => ( {nodes.map((node) => (
<SelectItem key={node.id} textValue={node.name}> <SelectItem key={node.id} textValue={node.name}>
@@ -524,47 +629,73 @@ export default function PanelSharingPage() {
label="起始端口" label="起始端口"
type="number" type="number"
value={shareForm.portRangeStart.toString()} value={shareForm.portRangeStart.toString()}
onChange={(e) => setShareForm({ ...shareForm, portRangeStart: parseInt(e.target.value) })} onChange={(e) =>
setShareForm({
...shareForm,
portRangeStart: parseInt(e.target.value),
})
}
/> />
<Input <Input
label="结束端口" label="结束端口"
type="number" type="number"
value={shareForm.portRangeEnd.toString()} value={shareForm.portRangeEnd.toString()}
onChange={(e) => setShareForm({ ...shareForm, portRangeEnd: parseInt(e.target.value) })} onChange={(e) =>
setShareForm({
...shareForm,
portRangeEnd: parseInt(e.target.value),
})
}
/> />
</div> </div>
<Input <Input
description="0 表示永久"
label="有效期 (天)" label="有效期 (天)"
type="number" type="number"
description="0 表示永久"
value={shareForm.expiryDays.toString()} value={shareForm.expiryDays.toString()}
onChange={(e) => setShareForm({ ...shareForm, expiryDays: parseInt(e.target.value) })} onChange={(e) =>
setShareForm({
...shareForm,
expiryDays: parseInt(e.target.value),
})
}
/> />
<Input <Input
description="0 表示不限流量"
label="流量上限 (GB)" label="流量上限 (GB)"
type="number" type="number"
description="0 表示不限流量"
value={shareForm.maxBandwidth.toString()} value={shareForm.maxBandwidth.toString()}
onChange={(e) => setShareForm({ ...shareForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })} onChange={(e) =>
setShareForm({
...shareForm,
maxBandwidth: parseInt(e.target.value, 10) || 0,
})
}
/> />
<Input <Input
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
label="允许的域名 (可选)" label="允许的域名 (可选)"
placeholder="example.com, panel.test.com" placeholder="example.com, panel.test.com"
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
value={shareForm.allowedDomains} value={shareForm.allowedDomains}
onChange={(e) => setShareForm({ ...shareForm, allowedDomains: e.target.value })} onChange={(e) =>
setShareForm({ ...shareForm, allowedDomains: e.target.value })
}
/> />
<Input <Input
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
label="允许的API IP (可选)" label="允许的API IP (可选)"
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24" placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
value={shareForm.allowedIps} value={shareForm.allowedIps}
onChange={(e) => setShareForm({ ...shareForm, allowedIps: e.target.value })} onChange={(e) =>
setShareForm({ ...shareForm, allowedIps: e.target.value })
}
/> />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button onPress={() => setCreateShareOpen(false)}>取消</Button> <Button onPress={() => setCreateShareOpen(false)}>取消</Button>
<Button color="primary" onPress={handleCreateShare}>创建</Button> <Button color="primary" onPress={handleCreateShare}>
创建
</Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -578,54 +709,88 @@ export default function PanelSharingPage() {
label="名称" label="名称"
placeholder="备注名称" placeholder="备注名称"
value={editForm.name} value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} onChange={(e) =>
setEditForm({ ...editForm, name: e.target.value })
}
/> />
<div className="flex gap-4"> <div className="flex gap-4">
<Input <Input
label="起始端口" label="起始端口"
type="number" type="number"
value={editForm.portRangeStart.toString()} value={editForm.portRangeStart.toString()}
onChange={(e) => setEditForm({ ...editForm, portRangeStart: parseInt(e.target.value) || 0 })} onChange={(e) =>
setEditForm({
...editForm,
portRangeStart: parseInt(e.target.value) || 0,
})
}
/> />
<Input <Input
label="结束端口" label="结束端口"
type="number" type="number"
value={editForm.portRangeEnd.toString()} value={editForm.portRangeEnd.toString()}
onChange={(e) => setEditForm({ ...editForm, portRangeEnd: parseInt(e.target.value) || 0 })} onChange={(e) =>
setEditForm({
...editForm,
portRangeEnd: parseInt(e.target.value) || 0,
})
}
/> />
</div> </div>
<Input <Input
description="0 表示不限流量"
label="流量上限 (GB)" label="流量上限 (GB)"
type="number" type="number"
description="0 表示不限流量"
value={editForm.maxBandwidth.toString()} value={editForm.maxBandwidth.toString()}
onChange={(e) => setEditForm({ ...editForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })} onChange={(e) =>
setEditForm({
...editForm,
maxBandwidth: parseInt(e.target.value, 10) || 0,
})
}
/> />
<Input <Input
description="留空或清除表示永久有效"
label="过期时间" label="过期时间"
type="datetime-local" type="datetime-local"
description="留空或清除表示永久有效" value={
value={editForm.expiryTime > 0 ? new Date(editForm.expiryTime).toISOString().slice(0, 16) : ""} editForm.expiryTime > 0
onChange={(e) => setEditForm({ ...editForm, expiryTime: e.target.value ? new Date(e.target.value).getTime() : 0 })} ? new Date(editForm.expiryTime).toISOString().slice(0, 16)
: ""
}
onChange={(e) =>
setEditForm({
...editForm,
expiryTime: e.target.value
? new Date(e.target.value).getTime()
: 0,
})
}
/> />
<Input <Input
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
label="允许的域名 (可选)" label="允许的域名 (可选)"
placeholder="example.com, panel.test.com" placeholder="example.com, panel.test.com"
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
value={editForm.allowedDomains} value={editForm.allowedDomains}
onChange={(e) => setEditForm({ ...editForm, allowedDomains: e.target.value })} onChange={(e) =>
setEditForm({ ...editForm, allowedDomains: e.target.value })
}
/> />
<Input <Input
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
label="允许的API IP (可选)" label="允许的API IP (可选)"
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24" placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
value={editForm.allowedIps} value={editForm.allowedIps}
onChange={(e) => setEditForm({ ...editForm, allowedIps: e.target.value })} onChange={(e) =>
setEditForm({ ...editForm, allowedIps: e.target.value })
}
/> />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button onPress={() => setEditShareOpen(false)}>取消</Button> <Button onPress={() => setEditShareOpen(false)}>取消</Button>
<Button color="primary" onPress={handleEditShare}>保存</Button> <Button color="primary" onPress={handleEditShare}>
保存
</Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -639,18 +804,24 @@ export default function PanelSharingPage() {
label="远程面板地址" label="远程面板地址"
placeholder="http://panel.example.com:8088" placeholder="http://panel.example.com:8088"
value={importForm.remoteUrl} value={importForm.remoteUrl}
onChange={(e) => setImportForm({ ...importForm, remoteUrl: e.target.value })} onChange={(e) =>
setImportForm({ ...importForm, remoteUrl: e.target.value })
}
/> />
<Input <Input
label="Token" label="Token"
placeholder="Bearer Token" placeholder="Bearer Token"
value={importForm.token} value={importForm.token}
onChange={(e) => setImportForm({ ...importForm, token: e.target.value })} onChange={(e) =>
setImportForm({ ...importForm, token: e.target.value })
}
/> />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button onPress={() => setImportNodeOpen(false)}>取消</Button> <Button onPress={() => setImportNodeOpen(false)}>取消</Button>
<Button color="secondary" onPress={handleImportNode}>导入</Button> <Button color="secondary" onPress={handleImportNode}>
导入
</Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>