修复节点无法编辑的错误
Some checks failed
build / build (api, amd64, linux) (push) Failing after -50s
build / build (api, arm64, linux) (push) Failing after -52s
build / build (api.exe, amd64, windows) (push) Failing after -51s

This commit is contained in:
CN-JS-HuiBai
2026-04-18 10:31:31 +08:00
parent 6e75b7d7d5
commit 98379b21f4
6 changed files with 275 additions and 130 deletions

View File

@@ -139,8 +139,7 @@ func registerUserRoutes(v1 *gin.RouterGroup) {
user.GET("/real-name-verification/status", handler.PluginRealNameStatus)
user.POST("/real-name-verification/submit", handler.PluginRealNameSubmit)
user.GET("/user-online-devices/get-ip", handler.PluginUserOnlineDevicesGetIP)
user.POST("/user-add-ipv6-subscription/enable", handler.PluginUserAddIPv6Enable)
user.POST("/user-add-ipv6-subscription/sync-password", handler.PluginUserAddIPv6SyncPassword)
// User IPv6 subscription - read-only status check (enable/disable is admin-only)
user.GET("/user-add-ipv6-subscription/check", handler.PluginUserAddIPv6Check)
}
@@ -295,6 +294,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)
}

View File

@@ -173,8 +173,12 @@
const payload = await request(`${cfg.api.onlineDevices}?page=${page}&per_page=20`);
state.devices = normalizeListPayload(payload);
} else if (state.route === "user-ipv6-subscription") {
const payload = await request(`${cfg.api.ipv6Base}/users?page=${page}&per_page=20`);
const [payload, plans] = await Promise.all([
request(`${cfg.api.ipv6Base}/users?page=${page}&per_page=20`),
request(cfg.api.plans)
]);
state.ipv6 = normalizeListPayload(payload);
state.plans = toArray(unwrap(plans));
} else if (state.route === "system-config") {
state.config = unwrap(await request(cfg.api.adminConfig));
}
@@ -337,18 +341,20 @@
}
if (action === "node-copy") {
await adminPost(`${cfg.api.adminBase}/server/manage/copy`, {
id: Number(actionEl.getAttribute("data-id"))
});
const nodeId = Number(actionEl.getAttribute("data-id"));
try {
await adminPost(`${cfg.api.adminBase}/server/manage/copy`, { id: nodeId });
} catch (e) { /* adminPost already shows error */ }
await hydrateRoute();
return;
}
if (action === "node-delete") {
if (confirm("确认删除该节点吗?")) {
await adminPost(`${cfg.api.adminBase}/server/manage/drop`, {
id: Number(actionEl.getAttribute("data-id"))
});
const nodeId = Number(actionEl.getAttribute("data-id"));
try {
await adminPost(`${cfg.api.adminBase}/server/manage/drop`, { id: nodeId });
} catch (e) { /* adminPost already shows error */ }
await hydrateRoute();
}
return;
@@ -356,15 +362,20 @@
if (action === "node-toggle-visible") {
event.preventDefault();
event.stopPropagation();
const nodeId = Number(actionEl.getAttribute("data-id"));
const input = actionEl.querySelector("input");
if (!input) {
return;
}
input.checked = !input.checked;
const newShow = !input.checked;
input.checked = newShow;
try {
await adminPost(`${cfg.api.adminBase}/server/manage/update`, {
id: Number(actionEl.getAttribute("data-id")),
show: input.checked ? 1 : 0
id: nodeId,
show: newShow ? 1 : 0
});
} catch (e) { /* adminPost already shows error */ }
await hydrateRoute();
return;
}
@@ -436,10 +447,20 @@
return;
}
if (action === "ipv6-enable") {
if (action === "ipv6-enable-modal") {
const userId = actionEl.getAttribute("data-user-id");
await adminPost(`${cfg.api.ipv6Base}/enable/${userId}`, {});
const userEmail = actionEl.getAttribute("data-email") || "";
state.modal = { type: "ipv6-enable", data: { userId, email: userEmail } };
render();
return;
}
if (action === "ipv6-disable") {
const userId = actionEl.getAttribute("data-user-id");
if (confirm("确认关闭该用户的 IPv6 子账号?(软禁用,可恢复)")) {
await adminPost(`${cfg.api.ipv6Base}/disable/${userId}`, {});
await hydrateRoute();
}
return;
}
@@ -505,6 +526,14 @@
}
if (!target) {
if (action === "ipv6-enable-save") {
const userId = body.user_id;
const planId = Number(body.plan_id) || 0;
await adminPost(`${cfg.api.ipv6Base}/enable/${userId}`, { plan_id: planId });
state.modal = null;
await hydrateRoute();
return;
}
return;
}
@@ -953,8 +982,21 @@
}
function renderIPv6Manage() {
const rows = state.ipv6.list.map((item) => [
renderRelationChip(item.id, item.shadow_user_id || "-"),
const rows = state.ipv6.list.map((item) => {
const isActive = item.is_active || item.status === "active";
const isDisabled = item.status === "disabled";
const actions = [];
if (isActive) {
actions.push(buttonAction("关闭", "ipv6-disable", null, `data-user-id="${item.id}"`));
actions.push(buttonAction("同步密码", "ipv6-sync-password", null, `data-user-id="${item.id}"`));
} else {
actions.push(buttonAction("开通", "ipv6-enable-modal", null, `data-user-id="${item.id}" data-email="${escapeHtml(item.email || "")}"`));
}
if (isDisabled) {
actions.unshift(buttonAction("重新开通", "ipv6-enable-modal", null, `data-user-id="${item.id}" data-email="${escapeHtml(item.email || "")}"`));
}
return [
`<code>${item.id}</code>`,
[
`<strong>${escapeHtml(item.email || "-")}</strong>`,
`<div class="subtle-text">${escapeHtml(item.ipv6_email || "-")}</div>`
@@ -962,14 +1004,12 @@
escapeHtml(item.plan_name || "-"),
renderStatus(item.status_label || item.status || "-"),
escapeHtml(formatDate(item.updated_at)),
renderActionRow([
buttonAction("开通/同步", "ipv6-enable", null, `data-user-id="${item.id}"`),
buttonAction("同步密码", "ipv6-sync-password", null, `data-user-id="${item.id}"`)
])
]);
renderActionRow(actions)
];
});
return [
wrapTable(["主从关系", "账号", "套餐", "状态", "更新时间", "操作"], rows),
wrapTable(["用户ID", "账号", "IPv6 套餐", "状态", "更新时间", "操作"], rows),
renderPagination(state.ipv6.pagination)
].join("");
}
@@ -1034,6 +1074,8 @@
? renderGroupForm(modal.data)
: modal.type === "route"
? renderRouteForm(modal.data)
: modal.type === "ipv6-enable"
? renderIPv6EnableForm(modal.data)
: "";
return [
@@ -1276,6 +1318,18 @@
return `<div class="toolbar toolbar-spaced"><button class="btn btn-primary" type="submit">${escapeHtml(label)}</button></div>`;
}
function renderIPv6EnableForm(data) {
return [
`<h2>开通 IPv6 子账号</h2>`,
`<p>用户: <strong>${escapeHtml(data.email || data.userId)}</strong></p>`,
'<form data-form="ipv6-enable-save">',
hiddenField("user_id", data.userId),
selectField("IPv6 套餐", "plan_id", state.plans, 0, true),
submitRow("确认开通"),
"</form>"
].join("");
}
function setBusy(value) {
state.busy = value;
render();
@@ -1293,9 +1347,14 @@
}
async function adminPost(url, body) {
try {
const response = await request(url, { method: "POST", body });
show("操作成功", "success");
return response;
} catch (error) {
show(error.message || "操作失败", "error");
throw error;
}
}
async function request(url, options) {
@@ -1382,7 +1441,7 @@
return;
}
if (["id", "group_id", "transfer_enable", "speed_limit", "balance", "plan_id", "device_limit", "server_port", "value"].includes(key)) {
if (["id", "group_id", "transfer_enable", "speed_limit", "balance", "plan_id", "device_limit", "server_port", "value", "user_id"].includes(key)) {
values[key] = rawValue === "" ? null : Number(rawValue);
return;
}
@@ -1492,6 +1551,8 @@
type = "ok";
} else if (/(pending|warn|unverified|eligible|ready)/.test(normalized)) {
type = "warn";
} else if (/(disabled)/.test(normalized)) {
type = "warn";
}
return `<span class="status-pill status-${type}">${escapeHtml(text)}</span>`;
}

View File

@@ -761,10 +761,9 @@
const list = payload.list || [];
document.getElementById("thead").innerHTML = `
<tr>
<th>主从关系</th>
<th>用户 ID</th>
<th>主账号</th>
<th>IPv6 账号</th>
<th>套餐</th>
<th>IPv6 套餐</th>
<th>状态</th>
<th>操作</th>
</tr>
@@ -772,26 +771,110 @@
document.getElementById("tbody").innerHTML = list.length
? list
.map(
(item) => `
(item) => {
const isActive = item.is_active || item.status === "active";
const isDisabled = item.status === "disabled";
let actionButtons = "";
if (isActive) {
actionButtons = `
<button class="btn" onclick="ipv6Disable(${item.id})">关闭</button>
<button class="btn" onclick="ipv6SyncPassword(${item.id})">同步密码</button>
`;
} else {
actionButtons = `
<button class="btn btn-primary" onclick="ipv6ShowEnableDialog(${item.id}, '${(item.email || '').replace(/'/g, "\\'")}')">开通</button>
`;
}
if (isDisabled) {
actionButtons = `
<button class="btn btn-primary" onclick="ipv6ShowEnableDialog(${item.id}, '${(item.email || '').replace(/'/g, "\\'")}')">重新开通</button>
` + actionButtons;
}
return `
<tr>
<td>${relationChip(item.id, item.shadow_user_id || "-")}</td>
<td class="mono">${item.id}</td>
<td>${item.email || "-"}</td>
<td class="mono">${item.ipv6_email || "-"}</td>
<td>${item.plan_name || "-"}</td>
<td>${statusBadge(item.status_label || item.status)}</td>
<td>
<div class="actions">
<button class="btn btn-primary" onclick="ipv6Enable(${item.id})">开通并同步</button>
<button class="btn" onclick="ipv6SyncPassword(${item.id})">同步密码</button>
${actionButtons}
</div>
</td>
</tr>`,
</tr>`;
},
)
.join("")
: `<tr><td colspan="6" class="empty">暂无数据</td></tr>`;
: `<tr><td colspan="5" class="empty">暂无数据</td></tr>`;
return payload.pagination || { current: 1, last_page: 1, total: list.length };
}
let ipv6Plans = [];
async function loadIpv6Plans() {
if (ipv6Plans.length > 0) return;
try {
const data = await request(`${apiBase}/plan/fetch`);
ipv6Plans = Array.isArray(data) ? data : (data && data.data ? data.data : []);
} catch (e) {
ipv6Plans = [];
}
}
function ipv6ShowEnableDialog(userId, email) {
loadIpv6Plans().then(() => {
const options = ipv6Plans.map(p => `<option value="${p.id}">${p.name || p.id}</option>`).join("");
const dialogHtml = `
<div id="ipv6-enable-dialog" class="dialog-mask is-open" style="z-index:1000">
<div class="dialog" style="width:min(420px,100%);max-height:none">
<div style="padding:20px">
<h3 style="margin:0 0 8px">开通 IPv6 子账号</h3>
<p style="margin:0 0 16px;color:var(--muted);font-size:14px">用户: <strong>${email || userId}</strong></p>
<label style="display:block;margin-bottom:6px;font-size:13px;font-weight:600">选择 IPv6 套餐</label>
<select id="ipv6-plan-select" class="field" style="width:100%;margin-bottom:20px">
<option value="0">使用默认套餐</option>
${options}
</select>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button class="btn" onclick="ipv6CloseEnableDialog()">取消</button>
<button class="btn btn-primary" onclick="ipv6ConfirmEnable(${userId})">确认开通</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML("beforeend", dialogHtml);
});
}
function ipv6CloseEnableDialog() {
const dialog = document.getElementById("ipv6-enable-dialog");
if (dialog) dialog.remove();
}
async function ipv6ConfirmEnable(userId) {
const select = document.getElementById("ipv6-plan-select");
const planId = select ? Number(select.value) || 0 : 0;
ipv6CloseEnableDialog();
await request(`${apiBase}/user-add-ipv6-subscription/enable/${userId}`, "POST", { plan_id: planId });
await loadData(state.page);
}
async function ipv6Enable(id) {
await request(`${apiBase}/user-add-ipv6-subscription/enable/${id}`, "POST", {});
await loadData(state.page);
}
async function ipv6Disable(id) {
if (!confirm("确认关闭该用户的 IPv6 子账号?(软禁用,可恢复)")) return;
await request(`${apiBase}/user-add-ipv6-subscription/disable/${id}`, "POST", {});
await loadData(state.page);
}
async function ipv6SyncPassword(id) {
await request(`${apiBase}/user-add-ipv6-subscription/sync-password/${id}`, "POST", {});
await loadData(state.page);
}
async function loadData(page = 1) {
const payload = await request(endpoint(page));
const pagination =
@@ -814,16 +897,6 @@
await loadData(state.page);
}
async function ipv6Enable(id) {
await request(`${apiBase}/user-add-ipv6-subscription/enable/${id}`, "POST", {});
await loadData(state.page);
}
async function ipv6SyncPassword(id) {
await request(`${apiBase}/user-add-ipv6-subscription/sync-password/${id}`, "POST", {});
await loadData(state.page);
}
function openMenuDialog() {
document.getElementById("menu-dialog").classList.add("is-open");
document.getElementById("menu-dialog").setAttribute("aria-hidden", "false");

View File

@@ -405,16 +405,6 @@
render();
}
if (action === "enable-ipv6") {
handleEnableIpv6(actionEl);
return;
}
if (action === "sync-ipv6-password") {
handleSyncIpv6Password(actionEl);
return;
}
if (action === "copy-node-info") {
handleCopyNodeInfo(actionEl.getAttribute("data-node-id"));
return;
@@ -916,39 +906,7 @@
}
}
async function handleEnableIpv6(actionEl) {
actionEl.disabled = true;
try {
var response = await fetchJson("/api/v1/user/user-add-ipv6-subscription/enable", { method: "POST" });
var payload = unwrap(response) || {};
if (payload.auth_data) {
saveIpv6Token(payload.auth_data);
state.ipv6AuthToken = getStoredIpv6Token();
}
showMessage("IPv6 订阅已开启,正在刷新...", "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");
} finally {
actionEl.disabled = false;
}
}
async function handleSyncIpv6Password(actionEl) {
actionEl.disabled = true;
try {
await fetchJson("/api/v1/user/user-add-ipv6-subscription/sync-password", { method: "POST" });
showMessage("密码已同步到 IPv6 账户", "success");
} catch (error) {
showMessage(error.message || "同步失败", "error");
} finally {
actionEl.disabled = false;
}
}
async function loadUplinkMetric() {
if (state.authToken) {
@@ -1589,17 +1547,28 @@
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'
// IPv6 not enabled - show guidance to submit a ticket
} else if (isIpv6 && !sub) {
return "";
}
var ipv6FooterHtml = "";
if (isIpv6 && !state.ipv6AuthToken) {
var statusText = ipv6Eligibility.status_label || "未开通";
var guidanceText = "IPv6 订阅需要通过工单由管理员开通,请提交工单申请。";
if (ipv6Eligibility.is_active) {
guidanceText = "您的 IPv6 已开通,如有问题请联系管理员。";
}
ipv6FooterHtml = '<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px">' +
'<div class="empty-state" style="padding:0;text-align:left">' +
'<strong>' + escapeHtml(statusText) + '</strong> — ' + escapeHtml(guidanceText) +
'</div></div>';
}
var html = [
'<article class="section-card glass-card ' + escapeHtml(extraClass) + '">',
'<div class="section-head"><div><span class="tiny-pill">' + prefix + '订阅</span><h3>连接工具</h3></div><div class="toolbar">',
'<button class="btn btn-secondary" data-action="' + copyAction + '">复制链接</button>',
isIpv6 ? '<button class="btn btn-primary" data-action="sync-ipv6-password">同步密码</button>' : '',
"</div></div>",
'<div class="kpi-row">',
!isIpv6 ? kpiBox("套餐", escapeHtml((sub && sub.plan && sub.plan.name) || "暂无套餐")) : "",
@@ -1607,20 +1576,9 @@
kpiBox("重置日", String((sub && sub.reset_day) || "-")),
!isIpv6 ? kpiBox("邮箱", '<span style="font-family:var(--font-mono)">' + escapeHtml((user && user.email) || "-") + "</span>") : "",
"</div>",
(isIpv6 && !state.ipv6AuthToken) ? '<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px"><button class="btn btn-primary btn-block" data-action="enable-ipv6">开启 IPv6 订阅</button></div>' : '',
ipv6FooterHtml,
"</article>"
].join("");
if (isIpv6 && !state.ipv6AuthToken) {
if (!ipv6Eligibility.allowed) {
html = html.replace(
/<div class="card-footer" style="margin-top:16px;border-top:1px solid var\(--border-color\);padding-top:16px"><button class="btn btn-primary btn-block" data-action="enable-ipv6">[\s\S]*?<\/button><\/div>/,
'<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px"><div class="empty-state" style="padding:0;text-align:left">' + escapeHtml(ipv6Eligibility.reason || ipv6Eligibility.status_label || "Current account is not eligible for IPv6 self-service") + '</div></div>'
);
}
if (!ipv6Eligibility.is_active) {
html = html.replace(/<button class="btn btn-primary" data-action="sync-ipv6-password">[\s\S]*?<\/button>/, "");
}
}
return html;
}

View File

@@ -18,6 +18,8 @@ func ipv6StatusPresentation(status string, allowed bool) (string, string, string
switch status {
case "active":
return "active", "IPv6 enabled", ""
case "disabled":
return "disabled", "IPv6 disabled", ""
case "eligible":
return "eligible", "Ready to enable", ""
case "", "not_allowed", "not_eligible", "disallowed":
@@ -233,7 +235,7 @@ func AdminIPv6SubscriptionUsers(c *gin.Context) {
planNameValue := planNames[shadowPlanID]
if hasShadowUser {
planID = intFromPointer(shadowUser.PlanID)
planNameValue = planName(shadowUser.Plan)
planNameValue = firstString(planName(shadowUser.Plan), planNames[planID])
}
shadowUserID := 0
shadowUpdatedAt := int64(0)
@@ -346,6 +348,11 @@ func AdminIPv6SubscriptionEnable(c *gin.Context) {
return
}
var payload struct {
PlanID int `json:"plan_id"`
}
_ = c.ShouldBindJSON(&payload)
var user model.User
if err := database.DB.Preload("Plan").First(&user, userID).Error; err != nil {
Fail(c, 404, "user not found")
@@ -356,14 +363,35 @@ func AdminIPv6SubscriptionEnable(c *gin.Context) {
return
}
if !service.SyncIPv6ShadowAccount(&user) {
Fail(c, 403, "user plan does not support ipv6 subscription")
if !service.SyncIPv6ShadowAccountWithPlan(&user, payload.PlanID) {
Fail(c, 500, "failed to create/sync IPv6 subscription")
return
}
SuccessMessage(c, "IPv6 subscription enabled/synced", true)
}
func AdminIPv6SubscriptionDisable(c *gin.Context) {
userID := parsePositiveInt(c.Param("userId"), 0)
if userID == 0 {
Fail(c, 400, "invalid user id")
return
}
var user model.User
if err := database.DB.First(&user, userID).Error; err != nil {
Fail(c, 404, "user not found")
return
}
if !service.DisableIPv6ShadowAccount(&user) {
Fail(c, 500, "failed to disable IPv6 subscription")
return
}
SuccessMessage(c, "IPv6 subscription disabled", true)
}
func AdminIPv6SubscriptionSyncPassword(c *gin.Context) {
userID := parsePositiveInt(c.Param("userId"), 0)
if userID == 0 {

View File

@@ -74,19 +74,14 @@ func GetPluginConfigBool(code, key string, defaultValue bool) bool {
}
func SyncIPv6ShadowAccount(user *model.User) bool {
if user == nil {
return false
return SyncIPv6ShadowAccountWithPlan(user, 0)
}
var plan *model.Plan
if user.PlanID != nil {
var loadedPlan model.Plan
if err := database.DB.First(&loadedPlan, *user.PlanID).Error; err == nil {
plan = &loadedPlan
}
}
if !PluginUserAllowed(user, plan) {
syncIPv6SubscriptionRecord(user, nil, false, "not_allowed")
// SyncIPv6ShadowAccountWithPlan creates or syncs an IPv6 shadow account.
// If overridePlanID > 0, it overrides the global ipv6_plan_id config for this user.
// This function no longer checks PluginUserAllowed — it is admin-only.
func SyncIPv6ShadowAccountWithPlan(user *model.User, overridePlanID int) bool {
if user == nil {
return false
}
@@ -113,9 +108,13 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
ipv6User.U = 0
ipv6User.D = 0
ipv6User.T = 0
ipv6User.Banned = false
ipv6User.UpdatedAt = now
if planID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_plan_id", "0"), 0); planID > 0 {
// Determine which plan to assign: override > global config
if overridePlanID > 0 {
ipv6User.PlanID = &overridePlanID
} else if planID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_plan_id", "0"), 0); planID > 0 {
ipv6User.PlanID = &planID
}
if groupID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_group_id", "0"), 0); groupID > 0 {
@@ -134,6 +133,32 @@ func SyncIPv6ShadowAccount(user *model.User) bool {
return true
}
// DisableIPv6ShadowAccount soft-disables the IPv6 shadow account for a user.
// It bans the shadow user and marks the subscription as disabled.
func DisableIPv6ShadowAccount(user *model.User) bool {
if user == nil {
return false
}
ipv6Email := IPv6ShadowEmail(user.Email)
var ipv6User model.User
if err := database.DB.Where("email = ? AND parent_id = ?", ipv6Email, user.ID).First(&ipv6User).Error; err != nil {
// No shadow user found, just update subscription record
syncIPv6SubscriptionRecord(user, nil, false, "disabled")
return true
}
// Ban the shadow user (soft disable)
now := time.Now().Unix()
_ = database.DB.Model(&model.User{}).Where("id = ?", ipv6User.ID).Updates(map[string]any{
"banned": true,
"updated_at": now,
}).Error
syncIPv6SubscriptionRecord(user, &ipv6User, false, "disabled")
return true
}
func PluginPlanAllowed(plan *model.Plan) bool {
return PluginUserAllowed(nil, plan)
}