712 lines
22 KiB
JavaScript
712 lines
22 KiB
JavaScript
// app.js - 普通脚本(非模块)
|
|
|
|
const { createApp, ref, reactive, computed, onMounted, nextTick, watch } = 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.example.com",
|
|
serverPort: 7000,
|
|
token: "CHANGE_ME",
|
|
logLevel: "info",
|
|
logMaxDays: 3,
|
|
tcpMux: true,
|
|
tcpMuxKeepalive: 30,
|
|
heartbeatInterval: 15,
|
|
heartbeatTimeout: 70,
|
|
poolCount: 8,
|
|
wireProtocolV2: false,
|
|
};
|
|
|
|
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. Logs
|
|
// ============================================================
|
|
async function fetchLogsApi() {
|
|
try {
|
|
const data = await apiFetch("/frpc/log");
|
|
if (data.code === 0) {
|
|
return { lines: data.data.lines || [], total: data.data.total || 0, error: data.data.error || "" };
|
|
}
|
|
return { lines: [], total: 0, error: "加载失败" };
|
|
} catch (e) {
|
|
return { lines: [], total: 0, error: "网络错误" };
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 7. 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("");
|
|
const showToken = ref(false);
|
|
|
|
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;
|
|
}
|
|
|
|
// ============================================================
|
|
// 8. 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 passwordChange = reactive({
|
|
oldPassword: "",
|
|
newPassword: "",
|
|
confirmPassword: "",
|
|
});
|
|
const passwordChangeError = ref("");
|
|
const passwordChangeSuccess = ref("");
|
|
|
|
// ---- 日志 ----
|
|
const logLines = ref([]);
|
|
const logTotal = ref(0);
|
|
const logError = ref("");
|
|
const logLastUpdate = ref("");
|
|
const logAutoScroll = ref(true);
|
|
let logTimer = null;
|
|
let logFetching = false;
|
|
|
|
// ---- 初始化 ----
|
|
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();
|
|
}
|
|
});
|
|
|
|
// ---- Tab 切换监听日志轮询 ----
|
|
watch(activeTab, (newTab) => {
|
|
if (newTab === "logs") {
|
|
startLogPolling();
|
|
} else {
|
|
stopLogPolling();
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
// ---- 日志轮询 ----
|
|
async function fetchLogs() {
|
|
if (logFetching) return;
|
|
logFetching = true;
|
|
try {
|
|
const result = await fetchLogsApi();
|
|
logLines.value = result.lines;
|
|
logTotal.value = result.total;
|
|
logError.value = result.error;
|
|
logLastUpdate.value = new Date().toLocaleTimeString();
|
|
if (logAutoScroll.value) {
|
|
await nextTick();
|
|
const container = document.getElementById("logContainer");
|
|
if (container) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
logError.value = "网络错误";
|
|
} finally {
|
|
logFetching = false;
|
|
}
|
|
}
|
|
|
|
function startLogPolling() {
|
|
if (logTimer) return;
|
|
fetchLogs();
|
|
logTimer = setInterval(fetchLogs, 8000);
|
|
}
|
|
|
|
function stopLogPolling() {
|
|
if (logTimer) {
|
|
clearInterval(logTimer);
|
|
logTimer = null;
|
|
}
|
|
}
|
|
|
|
function refreshLogs() {
|
|
fetchLogs();
|
|
}
|
|
|
|
function scrollLogToBottom() {
|
|
const container = document.getElementById("logContainer");
|
|
if (container) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}
|
|
|
|
// ---- 登录 ----
|
|
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);
|
|
stopLogPolling();
|
|
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);
|
|
}
|
|
};
|
|
|
|
// ---- 导入导出 ----
|
|
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)
|
|
);
|
|
|
|
const showDefaultTip = computed(() => {
|
|
// 如果 serverAddr 还是默认值,或者为空,显示警告
|
|
const addr = globalConfig.serverAddr;
|
|
return !addr || addr === "frp.example.com" || addr.trim() === "";
|
|
});
|
|
|
|
// ---- 返回 ----
|
|
return {
|
|
loggedIn,
|
|
loading,
|
|
loginError,
|
|
loginForm,
|
|
doLogin,
|
|
doLogout,
|
|
|
|
isFirstRun,
|
|
registerForm,
|
|
registerError,
|
|
registering,
|
|
doRegister,
|
|
|
|
activeTab,
|
|
globalConfig,
|
|
saveConfig,
|
|
|
|
proxies,
|
|
filteredProxies,
|
|
toggleProxy,
|
|
removeProxy,
|
|
frpcRunning,
|
|
|
|
dialogVisible,
|
|
dialogMode,
|
|
dialogForm,
|
|
openAddDialog,
|
|
openEditDialog,
|
|
closeDialog,
|
|
confirmDialog,
|
|
|
|
searchKeyword,
|
|
transitioning,
|
|
contentVisible,
|
|
|
|
triggerImport,
|
|
handleImport,
|
|
exportToml,
|
|
|
|
passwordChange,
|
|
passwordChangeError,
|
|
passwordChangeSuccess,
|
|
changePassword,
|
|
|
|
showDefaultTip,
|
|
|
|
// 日志相关
|
|
logLines,
|
|
logTotal,
|
|
logError,
|
|
logLastUpdate,
|
|
logAutoScroll,
|
|
refreshLogs,
|
|
scrollLogToBottom,
|
|
|
|
// Token 显示切换
|
|
showToken,
|
|
};
|
|
},
|
|
});
|
|
|
|
app.mount("#app"); |