441 lines
14 KiB
JavaScript
441 lines
14 KiB
JavaScript
const { createApp, ref, reactive, computed, onMounted } = Vue;
|
|
|
|
const app = createApp({
|
|
setup() {
|
|
// ===== 登录态 =====
|
|
const transitioning = ref(false); // 旋转圈显示
|
|
const contentVisible = ref(false); // 主内容淡入
|
|
const loggedIn = ref(false);
|
|
const loading = ref(false);
|
|
const loginError = ref("");
|
|
const loginForm = reactive({
|
|
username: "",
|
|
password: "",
|
|
});
|
|
|
|
// ===== Token =====
|
|
const token = ref("");
|
|
|
|
// ===== Tab =====
|
|
const activeTab = ref("config");
|
|
|
|
// ===== 全局配置 =====
|
|
const globalConfig = reactive({
|
|
serverAddr: "frp.whitetop.xyz",
|
|
serverPort: 9358,
|
|
token: "Lxh10020328",
|
|
logLevel: "info",
|
|
logMaxDays: 3,
|
|
tcpMux: true,
|
|
tcpMuxKeepalive: 30,
|
|
heartbeatInterval: 15,
|
|
heartbeatTimeout: 70,
|
|
poolCount: 8,
|
|
});
|
|
const showDetail = ref(false);
|
|
|
|
// ===== 隧道列表 =====
|
|
const proxies = ref([]);
|
|
const searchKeyword = ref("");
|
|
|
|
// ===== frpc 状态 =====
|
|
const frpcRunning = ref(false);
|
|
|
|
// ===== 弹窗 =====
|
|
const dialogVisible = ref(false);
|
|
const dialogMode = ref("add"); // 'add' | 'edit'
|
|
const dialogForm = reactive({
|
|
id: null,
|
|
name: "",
|
|
type: "tcp",
|
|
localIP: "",
|
|
localPort: 0,
|
|
remotePort: 0,
|
|
});
|
|
|
|
// ===== 计算属性 =====
|
|
const filteredProxies = computed(() => {
|
|
if (!searchKeyword.value) return proxies.value;
|
|
const kw = searchKeyword.value.toLowerCase();
|
|
return proxies.value.filter(
|
|
(p) =>
|
|
p.name.toLowerCase().includes(kw) ||
|
|
p.localIP.includes(kw) ||
|
|
String(p.remotePort).includes(kw),
|
|
);
|
|
});
|
|
|
|
// ===== API 基础 =====
|
|
const apiHeaders = () => ({
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer " + token.value,
|
|
});
|
|
|
|
const apiFetch = async (url, opts = {}) => {
|
|
const res = await fetch(url, {
|
|
...opts,
|
|
headers: { ...apiHeaders(), ...opts.headers },
|
|
});
|
|
return res.json();
|
|
};
|
|
|
|
// ===== 登录 =====
|
|
const doLogin = async () => {
|
|
// 1. 校验输入
|
|
if (!loginForm.username || !loginForm.password) {
|
|
loginError.value = "请输入用户名和密码";
|
|
return;
|
|
}
|
|
|
|
// 2. 开始登录
|
|
loading.value = true;
|
|
loginError.value = "";
|
|
|
|
try {
|
|
const res = await fetch("/api/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(loginForm),
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.code === 0) {
|
|
// 3. 保存 token
|
|
token.value = data.data.token;
|
|
|
|
// 4. 显示旋转圈
|
|
transitioning.value = true;
|
|
|
|
// 5. 切换登录态 → 触发容器放大动画(0.5s)
|
|
loggedIn.value = true;
|
|
|
|
// 6. 等待放大动画完成(0.5s)
|
|
setTimeout(async () => {
|
|
// 7. 隐藏旋转圈
|
|
transitioning.value = false;
|
|
|
|
// 8. 加载数据
|
|
await loadAllData();
|
|
|
|
// 9. 关闭 loading 状态
|
|
loading.value = false;
|
|
|
|
// 10. 再等 0.3s 让内容淡入
|
|
setTimeout(() => {
|
|
contentVisible.value = true;
|
|
}, 300);
|
|
}, 500); // 与 CSS 动画时间一致
|
|
} else {
|
|
// 登录失败
|
|
loginError.value = data.msg || "登录失败";
|
|
loading.value = false;
|
|
}
|
|
} catch (e) {
|
|
loginError.value = "网络错误,请重试";
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const doLogout = () => {
|
|
contentVisible.value = false; // 先隐藏内容
|
|
loggedIn.value = false;
|
|
token.value = '';
|
|
loginForm.username = '';
|
|
loginForm.password = '';
|
|
loading.value = false;
|
|
transitioning.value = false;
|
|
};
|
|
|
|
// ===== 加载数据 =====
|
|
const loadAllData = async () => {
|
|
await Promise.all([loadConfig(), loadProxies(), loadFrpcStatus()]);
|
|
};
|
|
|
|
const loadConfig = async () => {
|
|
try {
|
|
const data = await apiFetch("/api/config");
|
|
if (data.code === 0) {
|
|
Object.assign(globalConfig, data.data);
|
|
}
|
|
} catch (e) {
|
|
console.warn("加载配置失败", e);
|
|
}
|
|
};
|
|
|
|
const loadProxies = async () => {
|
|
try {
|
|
const data = await apiFetch("/api/proxies");
|
|
if (data.code === 0) {
|
|
proxies.value = data.data || [];
|
|
}
|
|
} catch (e) {
|
|
console.warn("加载隧道列表失败", e);
|
|
}
|
|
};
|
|
|
|
const loadFrpcStatus = async () => {
|
|
try {
|
|
const data = await apiFetch("/api/frpc/status");
|
|
if (data.code === 0) {
|
|
frpcRunning.value = data.data.running || false;
|
|
}
|
|
} catch (e) {
|
|
console.warn("获取 frpc 状态失败", e);
|
|
}
|
|
};
|
|
|
|
// ===== 保存配置 =====
|
|
const saveConfig = async () => {
|
|
try {
|
|
const data = await apiFetch("/api/config", {
|
|
method: "PUT",
|
|
body: JSON.stringify(globalConfig),
|
|
});
|
|
if (data.code === 0) {
|
|
await loadFrpcStatus();
|
|
alert("配置已更新并热加载");
|
|
} else {
|
|
alert("保存失败: " + data.msg);
|
|
}
|
|
} catch (e) {
|
|
alert("网络错误");
|
|
}
|
|
};
|
|
|
|
// ===== 隧道操作 =====
|
|
const toggleProxy = async (p) => {
|
|
try {
|
|
const data = await apiFetch("/api/proxy/" + p.id, {
|
|
method: "PUT",
|
|
body: JSON.stringify(p),
|
|
});
|
|
if (data.code !== 0) {
|
|
alert("更新失败: " + data.msg);
|
|
await loadProxies();
|
|
} else {
|
|
await loadFrpcStatus();
|
|
}
|
|
} catch (e) {
|
|
alert("网络错误");
|
|
await loadProxies();
|
|
}
|
|
};
|
|
|
|
const deleteProxy = async (id) => {
|
|
if (!confirm("确定要删除这条隧道吗?")) return;
|
|
try {
|
|
const data = await apiFetch("/api/proxy/" + id, {
|
|
method: "DELETE",
|
|
});
|
|
if (data.code === 0) {
|
|
await loadProxies();
|
|
await loadFrpcStatus();
|
|
} else {
|
|
alert("删除失败: " + data.msg);
|
|
}
|
|
} catch (e) {
|
|
alert("网络错误");
|
|
}
|
|
};
|
|
|
|
// ===== 弹窗 =====
|
|
const openAddDialog = () => {
|
|
dialogMode.value = "add";
|
|
dialogForm.id = null;
|
|
dialogForm.name = "";
|
|
dialogForm.type = "tcp";
|
|
dialogForm.localIP = "";
|
|
dialogForm.localPort = 0;
|
|
dialogForm.remotePort = 0;
|
|
dialogVisible.value = true;
|
|
};
|
|
|
|
const openEditDialog = (p) => {
|
|
dialogMode.value = "edit";
|
|
Object.assign(dialogForm, p);
|
|
dialogVisible.value = true;
|
|
};
|
|
|
|
const confirmDialog = async () => {
|
|
if (
|
|
!dialogForm.name ||
|
|
!dialogForm.localIP ||
|
|
!dialogForm.localPort ||
|
|
!dialogForm.remotePort
|
|
) {
|
|
alert("请填写完整信息");
|
|
return;
|
|
}
|
|
const url =
|
|
dialogMode.value === "add"
|
|
? "/api/proxy"
|
|
: "/api/proxy/" + dialogForm.id;
|
|
const method = dialogMode.value === "add" ? "POST" : "PUT";
|
|
try {
|
|
const data = await apiFetch(url, {
|
|
method: method,
|
|
body: JSON.stringify(dialogForm),
|
|
});
|
|
if (data.code === 0) {
|
|
dialogVisible.value = false;
|
|
await loadProxies();
|
|
await loadFrpcStatus();
|
|
} else {
|
|
alert("操作失败: " + data.msg);
|
|
}
|
|
} catch (e) {
|
|
alert("网络错误");
|
|
}
|
|
};
|
|
|
|
// ===== 导入 TOML =====
|
|
const triggerImport = () => {
|
|
document.getElementById("tomlFileInput").click();
|
|
};
|
|
|
|
const handleImport = async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
try {
|
|
const text = await file.text();
|
|
const parsed = TOML.parse(text);
|
|
if (!parsed.proxies || !Array.isArray(parsed.proxies)) {
|
|
alert("无效的 TOML 文件:缺少 proxies 数组");
|
|
return;
|
|
}
|
|
// 过滤出有效的隧道条目
|
|
const list = parsed.proxies.filter(
|
|
(p) => p.name && p.localIP && p.localPort && p.remotePort,
|
|
);
|
|
if (list.length === 0) {
|
|
alert("未解析到有效的隧道条目");
|
|
return;
|
|
}
|
|
if (
|
|
!confirm(`解析到 ${list.length} 条隧道,将覆盖当前配置,确认导入?`)
|
|
)
|
|
return;
|
|
|
|
// 批量导入:先清空,再逐条插入(后端未提供批量接口,用循环)
|
|
// 简单做法:先删除所有,再逐个创建
|
|
for (const p of proxies.value) {
|
|
await apiFetch("/api/proxy/" + p.id, { method: "DELETE" });
|
|
}
|
|
for (const p of list) {
|
|
await apiFetch("/api/proxy", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
name: p.name,
|
|
type: p.type || "tcp",
|
|
localIP: p.localIP,
|
|
localPort: p.localPort,
|
|
remotePort: p.remotePort,
|
|
enabled: p.enabled !== undefined ? p.enabled : true,
|
|
}),
|
|
});
|
|
}
|
|
await loadProxies();
|
|
await loadFrpcStatus();
|
|
alert("导入成功!");
|
|
} catch (err) {
|
|
alert("解析 TOML 失败: " + err.message);
|
|
}
|
|
e.target.value = "";
|
|
};
|
|
|
|
// ===== 导出 TOML =====
|
|
const exportToml = async () => {
|
|
try {
|
|
const configData = await apiFetch("/api/config");
|
|
if (configData.code !== 0) {
|
|
alert("获取配置失败");
|
|
return;
|
|
}
|
|
const proxyData = await apiFetch("/api/proxies");
|
|
if (proxyData.code !== 0) {
|
|
alert("获取隧道列表失败");
|
|
return;
|
|
}
|
|
const cfg = configData.data;
|
|
const proxyList = proxyData.data || [];
|
|
|
|
const tomlObj = {
|
|
serverAddr: cfg.serverAddr,
|
|
serverPort: cfg.serverPort,
|
|
auth: { token: cfg.token },
|
|
log: {
|
|
to: "./frpc.log",
|
|
level: cfg.logLevel,
|
|
maxDays: cfg.logMaxDays,
|
|
},
|
|
transport: {
|
|
tcpMux: cfg.tcpMux,
|
|
tcpMuxKeepaliveInterval: cfg.tcpMuxKeepalive,
|
|
heartbeatInterval: cfg.heartbeatInterval,
|
|
heartbeatTimeout: cfg.heartbeatTimeout,
|
|
poolCount: cfg.poolCount,
|
|
},
|
|
proxies: proxyList.map((p) => ({
|
|
name: p.name,
|
|
type: p.type,
|
|
localIP: p.localIP,
|
|
localPort: p.localPort,
|
|
remotePort: p.remotePort,
|
|
})),
|
|
};
|
|
const tomlStr = TOML.stringify(tomlObj);
|
|
const blob = new Blob([tomlStr], { type: "text/plain" });
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = "frpc.toml";
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
} catch (e) {
|
|
alert("导出失败: " + e.message);
|
|
}
|
|
};
|
|
|
|
// ===== 键盘快捷键 =====
|
|
onMounted(() => {
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && dialogVisible.value) {
|
|
dialogVisible.value = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ===== 返回所有 =====
|
|
return {
|
|
loggedIn,
|
|
loading,
|
|
loginError,
|
|
loginForm,
|
|
doLogin,
|
|
doLogout,
|
|
activeTab,
|
|
globalConfig,
|
|
showDetail,
|
|
saveConfig,
|
|
proxies,
|
|
searchKeyword,
|
|
filteredProxies,
|
|
toggleProxy,
|
|
deleteProxy,
|
|
frpcRunning,
|
|
dialogVisible,
|
|
dialogMode,
|
|
dialogForm,
|
|
openAddDialog,
|
|
openEditDialog,
|
|
confirmDialog,
|
|
triggerImport,
|
|
handleImport,
|
|
exportToml,
|
|
transitioning,
|
|
contentVisible,
|
|
};
|
|
},
|
|
});
|
|
|
|
app.mount("#app");
|