diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
index c69d4e4..7350727 100644
--- a/.gitea/workflows/build.yml
+++ b/.gitea/workflows/build.yml
@@ -57,7 +57,6 @@ jobs:
run: |
cp README.md dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/README.md
cp .env.example dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/.env.example
- cp sqldes dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/sqldes
cp -r frontend/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/frontend/
cp -r docs/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/docs/
cp -r submodule/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/submodule/
diff --git a/cmd/api/main_entry.go b/cmd/api/main_entry.go
index 3d31f47..0e2ad17 100644
--- a/cmd/api/main_entry.go
+++ b/cmd/api/main_entry.go
@@ -295,6 +295,7 @@ func registerAdminRoutesV2(v2 *gin.RouterGroup) {
admin.GET("/user-add-ipv6-subscription/config", handler.AdminIPv6SubscriptionConfigFetch)
admin.POST("/user-add-ipv6-subscription/config", handler.AdminIPv6SubscriptionConfigSave)
admin.POST("/user-add-ipv6-subscription/enable/:userId", handler.AdminIPv6SubscriptionEnable)
+ admin.POST("/user-add-ipv6-subscription/disable/:userId", handler.AdminIPv6SubscriptionDisable)
admin.POST("/user-add-ipv6-subscription/sync-password/:userId", handler.AdminIPv6SubscriptionSyncPassword)
}
diff --git a/frontend/admin/app.js b/frontend/admin/app.js
index e056ef8..6514a27 100644
--- a/frontend/admin/app.js
+++ b/frontend/admin/app.js
@@ -330,6 +330,7 @@
}
if (action === "node-edit") {
+ event.preventDefault();
const item = state.nodes.find((row) => String(row.id) === actionEl.getAttribute("data-id"));
state.modal = { type: "node", data: item || {} };
render();
@@ -337,6 +338,7 @@
}
if (action === "node-copy") {
+ event.preventDefault();
await adminPost(`${cfg.api.adminBase}/server/manage/copy`, {
id: Number(actionEl.getAttribute("data-id"))
});
@@ -345,6 +347,7 @@
}
if (action === "node-delete") {
+ event.preventDefault();
if (confirm("确认删除该节点吗?")) {
await adminPost(`${cfg.api.adminBase}/server/manage/drop`, {
id: Number(actionEl.getAttribute("data-id"))
@@ -354,6 +357,32 @@
return;
}
+ if (action === "ipv6-enable") {
+ const id = actionEl.getAttribute("data-user-id");
+ // Use prompt with plan selection for simplicity in this minimal framework
+ const planNames = state.plans.map(p => `${p.id}: ${p.name}`).join("\n");
+ const planID = prompt(`请输入要分配给该用户的 IPv6 套餐 ID:\n\n${planNames}`, "");
+ if (planID === null) return;
+
+ await adminPost(`${cfg.api.adminBase}/user-add-ipv6-subscription/enable/${id}`, { plan_id: Number(planID) || 0 });
+ await hydrateRoute();
+ return;
+ }
+
+ if (action === "ipv6-disable") {
+ const id = actionEl.getAttribute("data-user-id");
+ if (confirm("确认要关闭该用户的 IPv6 订阅吗?(将封禁从属账号)")) {
+ await adminPost(`${cfg.api.adminBase}/user-add-ipv6-subscription/disable/${id}`, {});
+ await hydrateRoute();
+ }
+ return;
+ }
+
+ if (action === "ipv6-sync-password") {
+ await adminPost(`${cfg.api.adminBase}/user-add-ipv6-subscription/sync-password/${actionEl.getAttribute("data-user-id")}`, {});
+ return;
+ }
+
if (action === "node-toggle-visible") {
event.preventDefault();
const input = actionEl.querySelector("input");
@@ -963,7 +992,8 @@
renderStatus(item.status_label || item.status || "-"),
escapeHtml(formatDate(item.updated_at)),
renderActionRow([
- buttonAction("开通/同步", "ipv6-enable", null, `data-user-id="${item.id}"`),
+ buttonAction("开通/更新", "ipv6-enable", null, `data-user-id="${item.id}"`),
+ buttonAction("关闭/封禁", "ipv6-disable", null, `data-user-id="${item.id}"`),
buttonAction("同步密码", "ipv6-sync-password", null, `data-user-id="${item.id}"`)
])
]);
diff --git a/frontend/theme/Nebula/assets/app.js b/frontend/theme/Nebula/assets/app.js
index 81d4430..390d7ac 100644
--- a/frontend/theme/Nebula/assets/app.js
+++ b/frontend/theme/Nebula/assets/app.js
@@ -202,7 +202,7 @@
return Object.assign({
enabled: true,
status: "unverified",
- status_label: "未认证",
+ status_label: "锟借娋鎭曢渹锟?,
can_submit: true,
real_name: "",
identity_no_masked: "",
@@ -430,7 +430,7 @@
var form = actionEl.closest("form");
var email = form.querySelector("[name='email']").value;
if (!email) {
- showMessage("请输入邮箱地址", "warning");
+ showMessage("闇傜憗锟斤拷浠匡拷铦炲嫍皈憪锟斤拷", "warning");
return;
}
actionEl.disabled = true;
@@ -439,10 +439,10 @@
auth: false,
body: { email: email }
}).then(function () {
- showMessage("验证码已发送,请检查您的邮箱", "success");
+ showMessage("鎾夎锟斤拷锟芥瓏锟界吘锟斤拷锟介渹鐟燂拷锟戒氦锟斤拷锟斤拷铦烇拷", "success");
startCountdown(actionEl, 60);
}).catch(function (error) {
- showMessage(error.message || "获取验证码失败", "error");
+ showMessage(error.message || "锟界憰锟芥拤璜癸拷锟斤拷浠冮煇锟?, "error");
actionEl.disabled = false;
});
return;
@@ -450,7 +450,7 @@
}
function startCountdown(button, seconds) {
- var originalText = "获取验证码";
+ var originalText = "锟界憰锟芥拤璜癸拷锟斤拷";
button.disabled = true;
var current = seconds;
var timer = setInterval(function () {
@@ -517,14 +517,14 @@
// Fallback to text info if link not found or subscription failed
var tags = normalizeServerTags(server.tags);
var info = [
- "节点名称: " + (server.name || "未命名节点"),
- "节点类型: " + (server.type || "未知"),
- "节点倍率: " + (server.rate || 1) + "x",
- "节点状态: " + (server.is_online ? "在线" : "离线"),
- tags.length ? "节点标签: " + tags.join(", ") : ""
+ "锟斤拷锟斤拷婊ㄥ: " + (server.name || "锟借姼穰盯锟芥哗锟斤拷锟?),
+ "锟斤拷锟借潗椁冿拷: " + (server.type || "锟借姲浒?),
+ "锟斤拷锟斤拷婊拷: " + (server.rate || 1) + "x",
+ "锟斤拷锟斤拷鍡嗭拷锟? " + (server.is_online ? "锟藉嚱鐟? : "铦抽鐟?),
+ tags.length ? "锟斤拷锟斤拷锟藉€? " + tags.join(", ") : ""
].filter(Boolean).join("\n");
- copyText(info, "已复制节点基本信息(订阅链接当前不可达)");
+ copyText(info, "鎾岃劊锟斤拷鍡夛拷锟藉鎶咃拷鐮岀笐锟借崝锟介湀锝侊拷锟芥毠穰粬鏁舵瀼锟介姖婊氳櫨棰叉锟?);
}
function showNodeInfoModal(server, clashYaml, standardLink) {
@@ -537,24 +537,24 @@
modal.style.width = "min(540px, 100%)";
modal.innerHTML = [
- '
' + escapeHtml(server.name || "节点详情") + ' ',
+ '' + escapeHtml(server.name || "锟斤拷锟介渹琛岋拷") + ' ',
'',
'
' +
- '节点类型 ' +
+ '锟斤拷锟借潗椁冿拷 ' +
'' + escapeHtml(server.type || "unknown").toUpperCase() + ' ' +
'
',
'
' +
- '结算倍率 ' +
+ '铦忔⒍锟斤拷婊拷 ' +
'' + escapeHtml(String(server.rate || 1)) + 'x ' +
'
',
'
',
- 'Clash 配置预览 (已脱敏) ',
+ '
Clash 锟芥花钄喐锟斤拷 (鎾岃劙锟斤拷锟? ',
'
' + escapeHtml(maskClashYaml(clashYaml)) + ' ',
'
',
''
].join("");
@@ -566,12 +566,12 @@
};
modal.querySelector("#node-info-copy-link").onclick = function() {
- copyText(standardLink, "标准配置链接已复制");
+ copyText(standardLink, "锟斤拷锟斤拷婊ㄨ敪锟芥毠穰粬鎾岃劊锟斤拷锟?);
document.body.removeChild(overlay);
};
modal.querySelector("#node-info-copy-clash").onclick = function() {
- copyText(clashYaml, "Clash 配置已复制");
+ copyText(clashYaml, "Clash 锟芥花钄拰鑴o拷锟斤拷");
document.body.removeChild(overlay);
};
@@ -581,15 +581,15 @@
}
function showCorsHelpModal(subUrl, nodeId, isIpv6) {
- var title = (isIpv6 ? "IPv6 " : "") + "同步订阅数据";
+ var title = (isIpv6 ? "IPv6 " : "") + "锟藉硶閮婇湀锝侊拷锟藉敵鏃?;
showNebulaModal({
title: title,
- content: "由于浏览器跨域 (CORS) 策略限制,无法直接从网页获取节点配置链接。请手动同步一次订阅:\n\n1. 点击下方按钮在浏览器中打开订阅链接\n2. 复制网页显示的全部内容\n3. 返回此处点击“去粘贴”并存入系统",
- confirmText: "打开订阅链接",
- cancelText: "我要手动粘贴",
+ content: "锟芥浌锟界槡璁涳拷锟藉埢妤婏拷锟?(CORS) 铦戰夎&锟金硷拷鍤楀硶锟界槣闁у噿锟戒供锟借澋鐓锯柍锟界憰锟斤拷锟斤拷锟芥花钄拷鏆桂栵拷锟界獔锟借稷啞锟藉硶閮婇姖锟界攬鈭熸仴锟斤拷锟絓n\n1. 锟藉绋姖皈⒉洳拷鍘板兗锟藉喗锟介柅锟借啣閵濆墱锟芥挊锟介湀锝侊拷锟芥毠穰粬\n2. 鎲粴锟借澋鐓锯柍锟芥泟鍏э拷锟斤拷锟藉吀锟芥懓閳緉3. 椁堭オ滐拷鐢囨枃锟斤拷瀛电ì锟金▍抚铦庝亝鏂愶拷鍢ュ儙鎽潈锟借潫椁岋拷",
+ confirmText: "锟芥瀼锟介湀锝侊拷锟芥毠穰粬",
+ cancelText: "锟金革拷锟借稷啞铦庝亝鏂?,
onConfirm: function() {
window.open(subUrl, "_blank");
- showMessage("订阅已在新标签页打开,请复制其内容", "info");
+ showMessage("闇堬絹锟芥拰鑴i妬锟藉敵锟借潙鏆糕柍锟芥瀼锟藉殫璜圭獔鎲粴锟斤拷鍡咃拷鎽帮拷", "info");
},
onCancel: function() {
showManualPasteModal(nodeId, isIpv6);
@@ -609,12 +609,12 @@
var cacheKey = isIpv6 ? "ipv6CachedSubNodes" : "cachedSubNodes";
modal.innerHTML = [
- '
粘贴 ' + (isIpv6 ? "IPv6 " : "") + '订阅内容 ',
- '
请将你刚才复制的订阅源代码粘贴在下方。我们将为您解析并保存。
',
- '
',
+ '
铦庝亝鏂?' + (isIpv6 ? "IPv6 " : "") + '闇堬絹锟斤拷锟芥崋 ',
+ '
闇傜憰锟介浛鎯╋拷锟芥粴锟斤拷鍡ワ拷闇堬絹锟界殲穑偡瑾拷锟斤拷闊愭父閵侀姖皈⒉洳拷锟斤拷闅炵爫锟介姖绠革拷闁拷锟芥挓鍡★拷鎽々锟斤拷
',
+ '
',
''
].join("");
@@ -631,10 +631,10 @@
window.sessionStorage.setItem(storageKey, content);
state[cacheKey] = parseSubscriptionToLines(content);
document.body.removeChild(overlay);
- showMessage("订阅数据同步成功!", "success");
+ showMessage("闇堬絹锟斤拷鍞虫椏锟藉硶閮婏拷穑偧锟藉殫锟?, "success");
if (nodeId) handleCopyNodeInfo(nodeId);
} else {
- showMessage("内容不能为空", "error");
+ showMessage("锟斤拷鎹嗛姖婊╋拷閵濈畤寰?, "error");
}
};
}
@@ -925,14 +925,14 @@
saveIpv6Token(payload.auth_data);
state.ipv6AuthToken = getStoredIpv6Token();
}
- showMessage("IPv6 订阅已开启,正在刷新...", "success");
+ showMessage("IPv6 闇堬絹锟芥拰鑴o拷锟借崝锟界攪锟介妬锟界憻榘?..", "success");
// Try to login to IPv6 account if possible, or just refresh dashboard
// Since we don't have the password here (state doesn't keep it),
// the user might need to sync password first or we assume it was synced during creation.
await loadDashboard(true);
render();
} catch (error) {
- showMessage(error.message || "开启失败", "error");
+ showMessage(error.message || "鎾橈拷锟借嚞浠冮煇锟?, "error");
} finally {
actionEl.disabled = false;
}
@@ -942,9 +942,9 @@
actionEl.disabled = true;
try {
await fetchJson("/api/v1/user/user-add-ipv6-subscription/sync-password", { method: "POST" });
- showMessage("密码已同步到 IPv6 账户", "success");
+ showMessage("鎾栵拷锟芥拰鑴o拷鐢囦簷锟?IPv6 闊愯锟?, "success");
} catch (error) {
- showMessage(error.message || "同步失败", "error");
+ showMessage(error.message || "锟藉硶閮婃啳姊彇", "error");
} finally {
actionEl.disabled = false;
}
@@ -1007,12 +1007,12 @@
message: data.message || ""
}
}).then(function () {
- showMessage("工单已创建", "success");
+ showMessage("鎾屼簷锟芥拰鑴o拷鎾憋拷", "success");
state.selectedTicketId = null;
state.selectedTicket = null;
return loadDashboard(true);
}).then(render).catch(function (error) {
- showMessage(error.message || "工单创建失败", "error");
+ showMessage(error.message || "鎾屼簷锟斤拷馥暒閬f啳姊彇", "error");
render();
}).finally(function () {
if (submitButton) {
@@ -1035,13 +1035,13 @@
state.realNameVerification = verification;
showMessage(
verification && verification.status === "approved"
- ? "实名认证已通过。"
- : "认证信息已提交,请等待审核。",
+ ? "鎽梆Ъ拷闇堟柟锟芥拰鑴わ拷鏈烇拷锟斤拷"
+ : "闇堟柟锟介澖鈯ワ拷鎾岃劔锟介埈姝癸拷闇傜憺锟芥暫锟芥仯锟借┗锟斤拷",
"success"
);
return loadDashboard(true);
}).then(render).catch(function (error) {
- showMessage(error.message || "认证提交失败", "error");
+ showMessage(error.message || "闇堟柟锟斤拷穑偡婕辨啳姊彇", "error");
render();
}).finally(function () {
if (submitButton) {
@@ -1053,7 +1053,7 @@
if (formType === "change-password") {
if (data.new_password !== data.confirm_password) {
- showMessage("两次输入的新密码不一致", "error");
+ showMessage("閵濇枟娲婚⒉鏋忥拷锟斤拷榘垫挅锟斤拷閵濇虎锟斤拷锟?, "error");
if (submitButton) submitButton.disabled = false;
return;
}
@@ -1064,10 +1064,10 @@
new_password: data.new_password || ""
}
}).then(function () {
- showMessage("密码已修改,建议在 IPv6 节点同步新密码", "success");
+ showMessage("鎾栵拷锟芥拰鑴栬€拷灏嶏拷鎾辩畡鎮咃拷锟?IPv6 锟斤拷锟斤拷宄曢儕锟藉暎锟斤拷锟?, "success");
form.reset();
}).catch(function (error) {
- showMessage(error.message || "密码修改失败", "error");
+ showMessage(error.message || "鎾栵拷锟介澖鏍笺嚎鎲鎻?, "error");
}).finally(function () {
if (submitButton) {
submitButton.disabled = false;
@@ -1086,11 +1086,11 @@
password: data.password
}
}).then(function () {
- showMessage("密码已重置,请使用新密码登录", "success");
+ showMessage("鎾栵拷锟芥拰鑴わ拷铦垫锟介渹鐟氳潤锟藉喗榘垫挅锟斤拷锟介锟?, "success");
state.mode = "login";
render();
}).catch(function (error) {
- showMessage(error.message || "重置失败", "error");
+ showMessage(error.message || "锟芥花钄啳姊彇", "error");
}).finally(function () {
if (submitButton) {
submitButton.disabled = false;
@@ -1147,9 +1147,9 @@
function handleResetSecurity(actionEl, isIpv6) {
showNebulaModal({
- title: "安全提醒",
- content: "确定要重置订阅令牌吗?重置令牌后,原本的订阅地址将失效,你需要重新获取并配置订阅。",
- confirmText: "确认重置",
+ title: "鎽板導锟斤拷闉撅拷",
+ content: "铦栨锟介柆锟斤拷铦垫牚鎭ワ拷锟借獦锟斤拷锟藉殫缃革拷铦垫瑾橈拷锟斤拷鍤楋拷锟斤拷绁夛拷闇堬絹锟斤拷鍟o拷鎾狅拷浠冿拷锟斤拷闆筐3囷拷闁拷锟斤拷鍟楃巩锟界鍍庯拷婊ㄨ敪闇堬絹锟斤拷锟?,
+ confirmText: "铦栨牚鎭曪拷婊ㄨ敪",
onConfirm: function () {
actionEl.disabled = true;
fetchJson("/api/v1/user/resetSecurity", { method: "GET", ipv6: !!isIpv6 }).then(function (response) {
@@ -1158,10 +1158,10 @@
if (sub && subscribeUrl) {
sub.subscribe_url = subscribeUrl;
}
- showMessage((isIpv6 ? "IPv6 " : "") + "订阅令牌已重置", "success");
+ showMessage((isIpv6 ? "IPv6 " : "") + "闇堬絹锟介殲鏂わ拷鎾岃劋锟借澋锟?, "success");
render();
}).catch(function (error) {
- showMessage(error.message || "重置失败", "error");
+ showMessage(error.message || "锟芥花钄啳姊彇", "error");
render();
}).finally(function () {
actionEl.disabled = false;
@@ -1230,7 +1230,7 @@
state.selectedTicket = unwrap(response);
render();
}).catch(function (error) {
- showMessage(error.message || "工单详情加载失败", "error");
+ showMessage(error.message || "鎾屼簷锟介渹琛岋拷锟姐皹铦告啳姊彇", "error");
render();
}).finally(function () {
actionEl.disabled = false;
@@ -1312,14 +1312,14 @@
'
',
(theme.config && (theme.config.isRegisterEnabled || state.mode === "forget-password")) ? [
'',
- tabButton("login", "登录", state.mode === "login"),
- (theme.config && theme.config.isRegisterEnabled) ? tabButton("register", "注册", state.mode === "register") : "",
- tabButton("forget-password", "找回密码", state.mode === "forget-password"),
+ tabButton("login", "锟介锟?, state.mode === "login"),
+ (theme.config && theme.config.isRegisterEnabled) ? tabButton("register", "鐦滃吀锟?, state.mode === "register") : "",
+ tabButton("forget-password", "锟芥泬锟芥挅锟斤拷", state.mode === "forget-password"),
"
"
].join("") : [
'',
- tabButton("login", "登录", state.mode === "login"),
- tabButton("forget-password", "找回密码", state.mode === "forget-password"),
+ tabButton("login", "锟介锟?, state.mode === "login"),
+ tabButton("forget-password", "锟芥泬锟芥挅锟斤拷", state.mode === "forget-password"),
"
"
].join(""),
renderAuthPanelHeading(),
@@ -1353,7 +1353,7 @@
'',
renderThemeToggle(),
isLoggedIn
- ? '刷新 退出登录 '
+ ? '锟界憻榘?/button>锟斤拷锟界畤钂堟暥锟?/button>'
: '',
"
",
""
@@ -1436,13 +1436,18 @@
return html;
}
+ function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) {
+ return [
+ '',
+ '',
+
function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) {
return [
'
',
'',
'
Subscription ',
"
" + escapeHtml(theme.title || "Nebula") + " ",
- "
" + escapeHtml((state.subscribe && state.subscribe.plan && state.subscribe.plan.name) || "No active plan") + " · " + escapeHtml((state.user && state.user.email) || "") + "
",
+ "
" + escapeHtml((state.subscribe && state.subscribe.plan && state.subscribe.plan.name) || "No active plan") + " 绻?" + escapeHtml((state.user && state.user.email) || "") + "
",
'
',
dashboardStat(formatTraffic(remainingTraffic), "Remaining traffic"),
dashboardStat(String(overview.online_ip_count || 0), "Current online IPs"),
@@ -1471,11 +1476,11 @@
var limitDisplay = formatLimit(overview.device_limit) + (ipv6Overview ? " / " + formatLimit(ipv6Overview.device_limit) : "");
return [
''
].join("");
@@ -1531,7 +1536,7 @@
content = content.replace(/
',
- sidebarLink("real-name", "\u5b9e\u540d\u8ba4\u8bc1", "\u63d0\u4ea4\u4e0e\u67e5\u770b\u5b9e\u540d\u5ba1\u6838\u72b6\u6001") + '
'
+ sidebarLink("real-name", "实名认证", "提交与查看实名审核状态") + ' '
);
if (innerOnly) return content;
@@ -1579,78 +1584,10 @@
return renderSubscribeSection(extraClass, true);
}
- function renderSubscribeSection(extraClass, isIpv6) {
- extraClass = extraClass || "span-7";
- var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
- var user = isIpv6 ? state.ipv6User : state.user;
- var ipv6Eligibility = state.ipv6Eligibility || {};
- var prefix = isIpv6 ? "IPv6 " : "";
- var copyAction = isIpv6 ? "copy-ipv6-subscribe" : "copy-subscribe";
- var resetAction = isIpv6 ? "reset-ipv6-security" : "reset-security";
-
- if (isIpv6 && !state.ipv6AuthToken) {
- // If IPv6 not enabled, we still want to show the section with an "Enable" button
- // But we need to make sure we don't crash on 'sub' or 'user'
- } else if (isIpv6 && !sub) {
- return "";
- }
-
- var html = [
- '"
- ].join("");
- if (isIpv6 && !state.ipv6AuthToken) {
- if (!ipv6Eligibility.allowed) {
- html = html.replace(
- /