Files
frpc-console/static/app.js
T

615 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// app.js - 普通脚本(非模块)
const { createApp, ref, reactive, computed, onMounted } = Vue;
// ============================================================
// 1. 基础 API
// ============================================================
const API_BASE = "/api";
function getToken() {
return localStorage.getItem("frpc_token") || "";
}
function getHeaders() {
return {
"Content-Type": "application/json",
Authorization: "Bearer " + getToken(),
};
}
async function apiFetch(endpoint, options = {}) {
const url = API_BASE + endpoint;
const headers =
options.body instanceof FormData
? { Authorization: "Bearer " + getToken() }
: getHeaders();
const res = await fetch(url, {
...options,
headers: { ...headers, ...(options.headers || {}) },
});
return res.json();
}
// ============================================================
// 2. Auth
// ============================================================
function parseJwtExpire(token) {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return payload.exp * 1000;
} catch {
return Date.now() + 7 * 24 * 60 * 60 * 1000;
}
}
function saveAuthState(token, username) {
const expire = parseJwtExpire(token);
localStorage.setItem("frpc_token", token);
localStorage.setItem("frpc_token_expire", String(expire));
localStorage.setItem("frpc_username", username);
return expire;
}
function loadAuthState() {
const token = localStorage.getItem("frpc_token");
const expire = parseInt(localStorage.getItem("frpc_token_expire") || "0", 10);
const username = localStorage.getItem("frpc_username") || "";
if (token && expire > Date.now()) {
return { token, expire, username, valid: true };
}
clearAuthState();
return { token: "", expire: 0, username: "", valid: false };
}
function clearAuthState() {
localStorage.removeItem("frpc_token");
localStorage.removeItem("frpc_token_expire");
localStorage.removeItem("frpc_username");
}
async function login(username, password) {
const data = await apiFetch("/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
if (data.code === 0) {
const token = data.data.token;
const expire = saveAuthState(token, username);
return { success: true, token, expire };
}
return { success: false, error: data.msg || "登录失败" };
}
// ============================================================
// 3. Config
// ============================================================
const defaultConfig = {
serverAddr: "frp.whitetop.xyz",
serverPort: 9358,
token: "",
logLevel: "info",
logMaxDays: 3,
tcpMux: true,
tcpMuxKeepalive: 30,
heartbeatInterval: 15,
heartbeatTimeout: 70,
poolCount: 8,
};
async function loadConfig() {
try {
const data = await apiFetch("/config");
if (data.code === 0) {
return { ...defaultConfig, ...data.data };
}
} catch (e) {
console.warn("加载配置失败", e);
}
return { ...defaultConfig };
}
async function saveConfigApi(config) {
const data = await apiFetch("/config", {
method: "PUT",
body: JSON.stringify(config),
});
return data;
}
// ============================================================
// 4. Proxies
// ============================================================
async function loadProxies() {
try {
const data = await apiFetch("/proxies");
if (data.code === 0) {
return data.data || [];
}
} catch (e) {
console.warn("加载隧道失败", e);
}
return [];
}
async function createProxy(proxy) {
const data = await apiFetch("/proxy", {
method: "POST",
body: JSON.stringify(proxy),
});
return data;
}
async function updateProxy(proxy) {
const data = await apiFetch("/proxy/" + proxy.id, {
method: "PUT",
body: JSON.stringify(proxy),
});
return data;
}
async function deleteProxy(id) {
const data = await apiFetch("/proxy/" + id, {
method: "DELETE",
});
return data;
}
function filterProxies(list, keyword) {
if (!keyword) return list;
const kw = keyword.toLowerCase();
return list.filter(
(p) =>
p.name.toLowerCase().includes(kw) ||
p.localIP.includes(kw) ||
String(p.remotePort).includes(kw)
);
}
// ============================================================
// 5. Frpc
// ============================================================
async function getFrpcStatus() {
try {
const data = await apiFetch("/frpc/status");
if (data.code === 0) {
return data.data.running || false;
}
} catch (e) {
console.warn("获取 frpc 状态失败", e);
}
return false;
}
// ============================================================
// 6. UI State
// ============================================================
const activeTab = ref("proxies");
const dialogVisible = ref(false);
const dialogMode = ref("add");
const dialogForm = ref({
id: null,
name: "",
type: "tcp",
localIP: "",
localPort: 0,
remotePort: 0,
});
const searchKeyword = ref("");
function openAddDialog() {
dialogMode.value = "add";
dialogForm.value = {
id: null,
name: "",
type: "tcp",
localIP: "",
localPort: 0,
remotePort: 0,
};
dialogVisible.value = true;
}
function openEditDialog(proxy) {
dialogMode.value = "edit";
dialogForm.value = { ...proxy };
dialogVisible.value = true;
}
function closeDialog() {
dialogVisible.value = false;
}
// ============================================================
// 7. Vue App
// ============================================================
const app = createApp({
setup() {
// ---- 登录态 ----
const loggedIn = ref(false);
const loading = ref(false);
const loginError = ref("");
const loginForm = reactive({ username: "", password: "" });
const token = ref("");
const tokenExpireTime = ref(0);
const transitioning = ref(false);
const contentVisible = ref(false);
let expireTimer = null;
// ---- 注册 ----
const isFirstRun = ref(false);
const registerForm = reactive({
username: "",
password: "",
confirmPassword: "",
});
const registerError = ref("");
const registering = ref(false);
// ---- 数据 ----
const globalConfig = reactive({});
const proxies = ref([]);
const frpcRunning = ref(false);
const showDetail = ref(false);
// ---- 修改密码 ----
const passwordChange = reactive({
oldPassword: "",
newPassword: "",
confirmPassword: "",
});
const passwordChangeError = ref("");
const passwordChangeSuccess = ref("");
// ---- 初始化 ----
onMounted(() => {
const saved = loadAuthState();
if (saved.valid) {
token.value = saved.token;
tokenExpireTime.value = saved.expire;
loginForm.username = saved.username;
loggedIn.value = true;
loadAllData().then(() => {
contentVisible.value = true;
startExpireTimer();
});
} else {
checkUsers();
}
});
async function checkUsers() {
try {
const res = await fetch("/api/check/users");
const data = await res.json();
if (data.code === 0) {
isFirstRun.value = !data.data.hasUsers;
}
} catch (e) {
console.warn("检查用户状态失败", e);
}
}
function startExpireTimer() {
if (expireTimer) clearInterval(expireTimer);
expireTimer = setInterval(() => {
if (Date.now() >= tokenExpireTime.value) {
doLogout();
alert("登录已超时,请重新登录");
}
}, 5000);
}
async function loadAllData() {
const [cfg, list, running] = await Promise.all([
loadConfig(),
loadProxies(),
getFrpcStatus(),
]);
Object.assign(globalConfig, cfg);
proxies.value = list;
frpcRunning.value = running;
}
// ---- 登录 ----
const doLogin = async () => {
if (!loginForm.username || !loginForm.password) {
loginError.value = "请输入用户名和密码";
return;
}
loading.value = true;
loginError.value = "";
const result = await login(loginForm.username, loginForm.password);
if (result.success) {
token.value = result.token;
tokenExpireTime.value = result.expire;
transitioning.value = true;
loggedIn.value = true;
setTimeout(async () => {
transitioning.value = false;
await loadAllData();
loading.value = false;
setTimeout(() => {
contentVisible.value = true;
startExpireTimer();
}, 300);
}, 500);
} else {
loginError.value = result.error;
loading.value = false;
}
};
// ---- 注册 ----
const doRegister = async () => {
if (registerForm.username.length < 5) {
registerError.value = "用户名至少 5 位";
return;
}
if (registerForm.password !== registerForm.confirmPassword) {
registerError.value = "两次输入的密码不一致";
return;
}
if (registerForm.password.length < 8) {
registerError.value = "密码至少 8 位";
return;
}
registering.value = true;
registerError.value = "";
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: registerForm.username,
password: registerForm.password,
}),
});
const data = await res.json();
if (data.code === 0) {
// ✅ 注册成功,不再是首次启动
isFirstRun.value = false;
const token = data.data.token;
const expire = saveAuthState(token, registerForm.username);
token.value = token;
tokenExpireTime.value = expire;
loginForm.username = registerForm.username;
loggedIn.value = true;
transitioning.value = true;
setTimeout(async () => {
transitioning.value = false;
await loadAllData();
setTimeout(() => {
contentVisible.value = true;
startExpireTimer();
}, 300);
}, 500);
} else {
registerError.value = data.msg || "注册失败";
}
} catch (e) {
registerError.value = "网络错误,请重试";
}
registering.value = false;
};
// ---- 退出 ----
const doLogout = () => {
if (expireTimer) clearInterval(expireTimer);
contentVisible.value = false;
loggedIn.value = false;
token.value = "";
tokenExpireTime.value = 0;
loginForm.username = "";
loginForm.password = "";
loading.value = false;
transitioning.value = false;
clearAuthState();
// ✅ 重置注册相关状态
isFirstRun.value = false;
registerForm.username = "";
registerForm.password = "";
registerForm.confirmPassword = "";
registerError.value = "";
registering.value = false;
};
// ---- 修改密码 ----
const changePassword = async () => {
passwordChangeError.value = "";
passwordChangeSuccess.value = "";
if (passwordChange.newPassword !== passwordChange.confirmPassword) {
passwordChangeError.value = "两次输入的密码不一致";
return;
}
if (passwordChange.newPassword.length < 8) {
passwordChangeError.value = "密码至少 8 位";
return;
}
try {
const res = await fetch("/api/user/password", {
method: "PUT",
headers: getHeaders(),
body: JSON.stringify({
oldPassword: passwordChange.oldPassword,
newPassword: passwordChange.newPassword,
}),
});
const data = await res.json();
if (data.code === 0) {
passwordChangeSuccess.value = "密码修改成功!";
passwordChange.oldPassword = "";
passwordChange.newPassword = "";
passwordChange.confirmPassword = "";
} else {
passwordChangeError.value = data.msg || "修改失败";
}
} catch (e) {
passwordChangeError.value = "网络错误,请重试";
}
};
// ---- 保存配置 ----
const saveConfig = async () => {
const result = await saveConfigApi(globalConfig);
if (result.code === 0) {
frpcRunning.value = await getFrpcStatus();
alert("配置已更新并热加载");
} else {
alert("保存失败: " + result.msg);
}
};
// ---- 隧道操作 ----
const toggleProxy = async (p) => {
const result = await updateProxy(p);
if (result.code !== 0) {
alert("更新失败: " + result.msg);
proxies.value = await loadProxies();
} else {
frpcRunning.value = await getFrpcStatus();
}
};
const removeProxy = async (id) => {
if (!confirm("确定要删除这条隧道吗?")) return;
const result = await deleteProxy(id);
if (result.code === 0) {
proxies.value = await loadProxies();
frpcRunning.value = await getFrpcStatus();
} else {
alert("删除失败: " + result.msg);
}
};
const confirmDialog = async () => {
const form = dialogForm.value;
if (!form.name || !form.localIP || !form.localPort || !form.remotePort) {
alert("请填写完整信息");
return;
}
const result =
dialogMode.value === "add"
? await createProxy(form)
: await updateProxy(form);
if (result.code === 0) {
closeDialog();
proxies.value = await loadProxies();
frpcRunning.value = await getFrpcStatus();
} else {
alert("操作失败: " + result.msg);
}
};
// ---- 导入导出(已移入 setup,可访问 loadAllData ----
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 " + getToken() },
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 = "";
};
const exportToml = async () => {
try {
const res = await fetch("/api/export/toml", {
method: "GET",
headers: { Authorization: "Bearer " + getToken() },
});
if (!res.ok) {
const data = await res.json();
alert("导出失败: " + (data.msg || "未知错误"));
return;
}
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "frpc.toml";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
} catch (e) {
alert("导出失败: " + e.message);
}
};
// ---- 计算属性 ----
const filteredProxies = computed(() =>
filterProxies(proxies.value, searchKeyword.value)
);
// ---- 返回 ----
return {
loggedIn,
loading,
loginError,
loginForm,
doLogin,
doLogout,
isFirstRun,
registerForm,
registerError,
registering,
doRegister,
activeTab,
globalConfig,
showDetail,
saveConfig,
proxies,
filteredProxies,
toggleProxy,
removeProxy,
frpcRunning,
dialogVisible,
dialogMode,
dialogForm,
openAddDialog,
openEditDialog,
closeDialog,
confirmDialog,
searchKeyword,
transitioning,
contentVisible,
triggerImport,
handleImport,
exportToml,
passwordChange,
passwordChangeError,
passwordChangeSuccess,
changePassword,
};
},
});
app.mount("#app");