Files
frpc-console/static/app.js
T

420 lines
13 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("proxies");
// ===== 全局配置 =====
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;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/import/toml', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token.value,
// 不要设置 Content-Type,浏览器会自动加上 boundary
},
body: formData,
});
const data = await res.json();
if (data.code === 0) {
alert(data.msg);
await loadAllData();
} else {
alert('导入失败: ' + data.msg);
}
} catch (err) {
alert('网络错误: ' + 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");