This repository has been archived on 2026-06-21. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MetaLab/templates/MetaLab-2026/html/settings/index.html
Victor_Jay d5d33c3ace fix: 修复登录重复创建会话 + 备注输入框不可交互
- login.js 增加防重复提交锁,请求期间禁用按钮
- Manager.Create 增加幂等检查,同用户同IP同UA 10秒内复用已有会话
- 移除当前设备备注输入框的 readonly 限制,改为 data-current 标记
- 修复 readonly 状态下输入框完全透明无法点击的问题
2026-05-31 00:28:00 +08:00

800 lines
35 KiB
HTML
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.

{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="settings-toast" id="settingsToast"></div>
<div class="settings-layout">
<aside class="settings-sidebar">
<nav class="settings-nav">
<a href="/settings/profile"
class="settings-nav-item {{if eq .ActiveTab "profile"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/>
</svg>
个人资料
</a>
<a href="/settings/account"
class="settings-nav-item {{if eq .ActiveTab "account"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
<circle cx="12" cy="16" r="1"/>
</svg>
账号信息
</a>
<a href="/settings/sessions"
class="settings-nav-item {{if eq .ActiveTab "sessions"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
登录管理
</a>
</nav>
</aside>
<main class="settings-main">
{{if eq .ActiveTab "profile"}}
<!-- 个人资料 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>个人资料</h2>
<p class="settings-card-desc">你的公开信息和基本资料</p>
</div>
{{if .PendingTypes}}
<div class="settings-audit-notice">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>
{{range $i, $t := .PendingTypes}}{{if $i}}、{{end}}{{index $.AuditTypes $t}}{{end}}审核中
</span>
</div>
{{end}}
<div class="settings-card-body">
<div class="info-row">
<span class="info-label">用户名</span>
<span class="info-value">
<input type="text" id="usernameInput" class="settings-text-input" value="{{.User.Username}}" maxlength="16" autocomplete="off">
<span class="char-counter" id="usernameCounter"></span>
</span>
</div>
<div class="info-row">
<span class="info-label">头像</span>
<span class="info-value">
<div class="avatar-upload-wrap" id="avatarWrap">
{{if .User.Avatar}}
<img src="{{.User.Avatar}}?v={{.User.UpdatedAt.Unix}}" alt="avatar" class="avatar-preview" id="avatarPreview">
{{else}}
<div class="avatar-placeholder" id="avatarPreview">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg>
</div>
{{end}}
<span class="avatar-hint">点击更换</span>
</div>
<input type="file" id="avatarInput" class="avatar-input-hidden" accept="image/jpeg,image/png,image/webp">
</span>
</div>
<div class="info-row">
<span class="info-label">个性签名</span>
<span class="info-value">
<textarea id="bioInput" class="settings-textarea" maxlength="128" placeholder="写一句话介绍自己...">{{.User.Bio}}</textarea>
<div class="char-counter" id="bioCounter"></div>
</span>
</div>
<div class="info-row">
<span class="info-label">角色</span>
<span class="info-value">
<span class="role-tag role-{{.User.Role}}">{{.RoleName}}</span>
</span>
</div>
<div class="settings-card-footer">
<button id="saveProfileBtn" class="settings-save-btn">保存</button>
</div>
</div>
</div>
{{else if eq .ActiveTab "sessions"}}
<!-- 登录管理 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>登录管理</h2>
<p class="settings-card-desc">查看和管理你的登录设备,保护账号安全</p>
</div>
<div class="settings-card-body">
<div class="session-list" id="sessionList">
{{range .Sessions}}
<div class="session-item{{if .IsCurrent}} session-current{{end}}" data-sid="{{.ID}}">
<div class="session-icon">
{{if eq .DeviceInfo.Device "Mobile"}}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
{{else if eq .DeviceInfo.Device "Tablet"}}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><rect x="4" y="2" width="16" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
{{else}}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
{{end}}
</div>
<div class="session-info">
<div class="session-title">
<span class="session-browser">{{.DeviceInfo.Browser}}</span>
<span class="session-os">/ {{.DeviceInfo.OS}}</span>
{{if .IsCurrent}}<span class="session-badge">当前设备</span>{{end}}
</div>
<div class="session-meta">
<span class="session-ip">IP: {{.IP}}</span>
<span class="session-time">登录于 {{.CreatedAt.Format "2006-01-02 15:04"}}</span>
<span class="session-time">最后活动 {{.LastAccess.Format "2006-01-02 15:04"}}</span>
</div>
<div class="session-remark-row">
<input type="text" class="session-remark-input" value="{{.Remark}}" placeholder="添加备注(如 我的笔记本)" maxlength="32" data-sid="{{.ID}}" data-current="{{.IsCurrent}}">
</div>
</div>
<div class="session-action">
{{if not .IsCurrent}}
<button class="session-kick-btn" data-sid="{{.ID}}">踢出</button>
{{end}}
</div>
</div>
{{end}}
{{if not .Sessions}}
<div class="session-empty">暂无活跃的登录会话</div>
{{end}}
</div>
</div>
<div class="settings-card-footer">
<button id="kickOthersBtn" class="settings-danger-btn">一键踢出其他设备</button>
<span class="form-hint">将强制退出除当前设备外的所有登录会话</span>
</div>
</div>
{{else}}
<!-- 账号信息 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>账号信息</h2>
<p class="settings-card-desc">账号安全和基本状态</p>
</div>
<div class="settings-card-body">
<div class="info-row">
<span class="info-label">用户 ID</span>
<span class="info-value">{{.User.ID}}</span>
</div>
<div class="info-row">
<span class="info-label">邮箱</span>
<span class="info-value">{{.User.Email}}</span>
</div>
<div class="info-row">
<span class="info-label">账号状态</span>
<span class="info-value">
<span class="status-badge status-{{.User.Status}}">{{.StatusName}}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">注册时间</span>
<span class="info-value">{{.User.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
</div>
{{if .User.LastLoginAt}}
<div class="info-row">
<span class="info-label">上次登录</span>
<span class="info-value">{{.User.LastLoginAt.Format "2006-01-02 15:04:05"}}</span>
</div>
{{end}}
<div class="info-row">
<span class="info-label">最后更新</span>
<span class="info-value">{{.User.UpdatedAt.Format "2006-01-02 15:04:05"}}</span>
</div>
</div>
</div>
<!-- 修改密码 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>修改密码</h2>
<p class="settings-card-desc">修改密码后需重新登录</p>
</div>
<div class="settings-card-body">
<div class="form-group">
<label class="form-label" for="currentPassword">当前密码</label>
<input type="password" id="currentPassword" class="settings-text-input" placeholder="输入当前密码" autocomplete="current-password">
</div>
<div class="form-group">
<label class="form-label" for="newPassword">新密码</label>
<input type="password" id="newPassword" class="settings-text-input" placeholder="至少 8 位,包含字母和数字" autocomplete="new-password">
</div>
<div class="settings-card-footer">
<button id="changePwdBtn" class="settings-save-btn">修改密码</button>
<span class="form-hint">修改成功后需重新登录</span>
</div>
</div>
</div>
<!-- 注销账号 -->
<div class="settings-card settings-card-danger">
<div class="settings-card-header">
<h2>注销账号</h2>
<p class="settings-card-desc">账号将在 7 天后正式注销,期间可随时重新登录恢复</p>
</div>
<div class="settings-card-body">
<div class="form-group">
<label class="form-label" for="deletePassword">确认密码</label>
<input type="password" id="deletePassword" class="settings-text-input" placeholder="输入密码确认" autocomplete="current-password">
</div>
<div class="form-group">
<label class="form-label" for="deleteReason">注销原因(选填)</label>
<textarea id="deleteReason" class="settings-textarea" maxlength="500" placeholder="可以告诉我们为什么要离开吗?"></textarea>
</div>
<div class="settings-card-footer">
<button id="deleteAccountBtn" class="settings-danger-btn">确认注销</button>
<span class="form-hint">此操作不可逆,请谨慎处理</span>
</div>
</div>
</div>
{{end}}
</main>
</div>
{{template "layout/footer.html" .}}
<!-- 头像裁切弹窗 -->
<div class="crop-overlay" id="cropOverlay">
<div class="crop-dialog">
<div class="crop-header">调整裁切范围</div>
<div class="crop-stage" id="cropStage">
<img id="cropImageEl" draggable="false">
<div class="crop-mask" id="cropMask"></div>
<div class="crop-frame" id="cropFrame">
<div class="crop-grid"></div>
</div>
</div>
<div class="crop-footer">
<button class="crop-btn crop-btn-cancel" id="cropCancelBtn">取消</button>
<button class="crop-btn crop-btn-confirm" id="cropConfirmBtn">确认</button>
</div>
</div>
</div>
<script src="/static/js/common.js"></script>
<script>
(function () {
'use strict';
var toastEl = document.getElementById('settingsToast');
var toastTimer = null;
function showSettingsToast(msg, isError) {
if (!toastEl) return;
clearTimeout(toastTimer);
toastEl.textContent = msg;
toastEl.className = 'settings-toast' + (isError ? ' error' : '');
void toastEl.offsetWidth;
toastEl.classList.add('show');
toastTimer = setTimeout(function () {
toastEl.classList.remove('show');
}, 5000);
}
// ===== 个人资料 Tab =====
(function () {
var usernameInput = document.getElementById('usernameInput');
var bioInput = document.getElementById('bioInput');
var btn = document.getElementById('saveProfileBtn');
if (!usernameInput || !bioInput || !btn) return;
// ---- 字符计数器 ----
var usernameCounter = document.getElementById('usernameCounter');
var bioCounterEl = document.getElementById('bioCounter');
function updateCounters() {
if (usernameCounter) {
usernameCounter.textContent = usernameInput.value.length + '/16';
}
if (bioCounterEl) {
bioCounterEl.textContent = bioInput.value.length + '/128';
}
}
usernameInput.addEventListener('input', updateCounters);
bioInput.addEventListener('input', updateCounters);
updateCounters();
// ---- 原始值(用于判断是否变更) ----
var origUsername = usernameInput.value.trim();
var origBio = bioInput.value;
// 与后端一致的合法字符集:中文、英文大小写、数字、下划线、连字符
var usernameRe = /^[\u4e00-\u9fffa-zA-Z0-9_-]+$/;
btn.addEventListener('click', function () {
var username = usernameInput.value.trim();
var bio = bioInput.value;
// 用户名校验
if (!username) {
showSettingsToast('用户名不能为空', true);
return;
}
if (username.length > 16) {
showSettingsToast('用户名不能超过 16 个字符', true);
return;
}
if (!usernameRe.test(username)) {
showSettingsToast('用户名仅支持中英文、数字、下划线、连字符', true);
return;
}
// 个性签名校验
if (bio.length > 128) {
showSettingsToast('个性签名不能超过 128 个字符', true);
return;
}
// 无变更判断
if (username === origUsername && bio === origBio) {
showSettingsToast('内容未变更,无需保存');
return;
}
btn.disabled = true;
btn.textContent = '保存中...';
var xhr = new XMLHttpRequest();
xhr.open('PUT', '/api/settings/profile', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
btn.disabled = false;
btn.textContent = '保存';
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast(res.message || '个人资料已更新');
origUsername = username;
origBio = bio;
} else {
showSettingsToast(res.message || '保存失败', true);
}
} catch (e) {
showSettingsToast('保存失败', true);
}
};
xhr.send(JSON.stringify({ username: username, bio: bio }));
});
// ---- 头像裁切上传 ----
var avatarWrap = document.getElementById('avatarWrap');
var avatarInput = document.getElementById('avatarInput');
var avatarPreview = document.getElementById('avatarPreview');
var cropOverlay = document.getElementById('cropOverlay');
var cropStage = document.getElementById('cropStage');
var cropImageEl = document.getElementById('cropImageEl');
var cropFrame = document.getElementById('cropFrame');
var cropMask = document.getElementById('cropMask');
var cropCancelBtn = document.getElementById('cropCancelBtn');
var cropConfirmBtn = document.getElementById('cropConfirmBtn');
var pendingFile = null;
var imgNW, imgNH; // 原图像素尺寸
var imgDW, imgDH; // 图片缩放后显示尺寸zoom=1 基准)
var zoom = 1; // 图片缩放系数 1.0 ~ 3.0
var panX = 0, panY = 0; // 图片平移偏移量
var fSize, fLeft, fTop; // 裁切框的固定屏幕像素:边长、左、顶
// 更新裁切框固定参数(图片加载时计算一次,之后不变)
function initFrame() {
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
fSize = Math.min(imgDW, imgDH); // 短边长度填满可见区域
fLeft = Math.round((sw - fSize) / 2);
fTop = Math.round((sh - fSize) / 2);
}
// 约束 panX/panY确保裁切框始终在图片范围内
function clampPan() {
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
var iw = imgDW * zoom, ih = imgDH * zoom;
var cx = Math.round((sw - iw) / 2); // 图片居中时的基准偏移
var cy = Math.round((sh - ih) / 2);
panX = Math.max(fLeft + fSize - cx - iw, Math.min(panX, fLeft - cx));
panY = Math.max(fTop + fSize - cy - ih, Math.min(panY, fTop - cy));
}
function renderCrop() {
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
var iw = imgDW * zoom, ih = imgDH * zoom;
var ox = Math.round((sw - iw) / 2) + panX;
var oy = Math.round((sh - ih) / 2) + panY;
cropImageEl.style.width = iw + 'px';
cropImageEl.style.height = ih + 'px';
cropImageEl.style.left = ox + 'px';
cropImageEl.style.top = oy + 'px';
cropFrame.style.left = fLeft + 'px';
cropFrame.style.top = fTop + 'px';
cropFrame.style.width = fSize + 'px';
cropFrame.style.height = fSize + 'px';
cropMask.style.left = fLeft + 'px';
cropMask.style.top = fTop + 'px';
cropMask.style.width = fSize + 'px';
cropMask.style.height = fSize + 'px';
}
function openCrop(file) {
var reader = new FileReader();
reader.onload = function (e) {
cropImageEl.src = e.target.result;
cropImageEl.onload = function () {
imgNW = cropImageEl.naturalWidth;
imgNH = cropImageEl.naturalHeight;
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
var fit = Math.min(sw / imgNW, sh / imgNH);
imgDW = Math.round(imgNW * fit);
imgDH = Math.round(imgNH * fit);
zoom = 1;
panX = 0;
panY = 0;
initFrame();
renderCrop();
};
};
reader.readAsDataURL(file);
cropOverlay.classList.add('active');
}
function closeCrop() {
cropOverlay.classList.remove('active');
avatarInput.value = '';
pendingFile = null;
}
function getCropParams() {
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
var iw = imgDW * zoom, ih = imgDH * zoom;
var ox = Math.round((sw - iw) / 2) + panX;
var oy = Math.round((sh - ih) / 2) + panY;
var cx = (fLeft + fSize / 2 - ox) / zoom;
var cy = (fTop + fSize / 2 - oy) / zoom;
var cs = fSize / zoom;
var sx = imgNW / imgDW, sy = imgNH / imgDH;
return {
x: Math.round((cx - cs / 2) * sx),
y: Math.round((cy - cs / 2) * sy),
size: Math.round(cs * sx)
};
}
// ---- 图片平移 ----
var panning = false, panStartX = 0, panStartY = 0, panInitX = 0, panInitY = 0;
function startPan(clientX, clientY) {
panning = true;
panStartX = clientX;
panStartY = clientY;
panInitX = panX;
panInitY = panY;
cropStage.style.cursor = 'grabbing';
}
function movePan(clientX, clientY) {
panX = panInitX + (clientX - panStartX);
panY = panInitY + (clientY - panStartY);
clampPan();
renderCrop();
}
cropStage.addEventListener('mousedown', function (e) {
e.preventDefault();
startPan(e.clientX, e.clientY);
});
document.addEventListener('mousemove', function (e) {
if (!panning) return;
movePan(e.clientX, e.clientY);
});
document.addEventListener('mouseup', function () {
panning = false;
cropStage.style.cursor = 'grab';
});
cropStage.addEventListener('touchstart', function (e) {
e.preventDefault();
var t = e.touches[0];
startPan(t.clientX, t.clientY);
});
document.addEventListener('touchmove', function (e) {
if (!panning) return;
var t = e.touches[0];
movePan(t.clientX, t.clientY);
}, { passive: true });
document.addEventListener('touchend', function () {
panning = false;
cropStage.style.cursor = 'grab';
});
cropStage.addEventListener('wheel', function (e) {
e.preventDefault();
if (panning) return;
var newZoom = Math.max(1, Math.min(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1), 3));
if (newZoom === zoom) return;
zoom = newZoom;
clampPan();
renderCrop();
}, { passive: false });
cropCancelBtn.addEventListener('click', closeCrop);
cropOverlay.addEventListener('click', function (e) {
if (e.target === cropOverlay) closeCrop();
});
cropConfirmBtn.addEventListener('click', function () {
if (!pendingFile) return;
var params = getCropParams();
var formData = new FormData();
formData.append('avatar', pendingFile);
formData.append('crop_x', params.x);
formData.append('crop_y', params.y);
formData.append('crop_size', params.size);
cropConfirmBtn.disabled = true;
cropConfirmBtn.textContent = '处理中...';
showSettingsToast('上传中...');
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/settings/avatar', true);
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
cropConfirmBtn.disabled = false;
cropConfirmBtn.textContent = '确认';
closeCrop();
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast(res.message || '头像已更新');
if (res.data && res.data.url && avatarPreview.tagName === 'IMG') {
avatarPreview.src = res.data.url + '?t=' + Date.now();
}
} else {
showSettingsToast(res.message || '上传失败', true);
}
} catch (err) {
showSettingsToast('上传失败', true);
}
};
xhr.send(formData);
});
if (avatarWrap && avatarInput) {
avatarWrap.addEventListener('click', function () {
avatarInput.click();
});
avatarInput.addEventListener('change', function () {
var file = avatarInput.files[0];
if (!file) return;
pendingFile = file;
openCrop(file);
});
}
})(); // 结束个人资料 Tab 子 IIFE
// ---- 修改密码 ----
var currentPwd = document.getElementById('currentPassword');
var newPwd = document.getElementById('newPassword');
var changePwdBtn = document.getElementById('changePwdBtn');
if (currentPwd && newPwd && changePwdBtn) {
changePwdBtn.addEventListener('click', function () {
if (!currentPwd.value) {
showSettingsToast('请输入当前密码', true);
return;
}
if (!newPwd.value || !/[a-zA-Z]/.test(newPwd.value) || !/\d/.test(newPwd.value) || newPwd.value.length < 8) {
showSettingsToast('新密码至少 8 位,包含字母和数字', true);
return;
}
changePwdBtn.disabled = true;
changePwdBtn.textContent = '修改中...';
var xhr = new XMLHttpRequest();
xhr.open('PUT', '/api/settings/password', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
changePwdBtn.disabled = false;
changePwdBtn.textContent = '修改密码';
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast('密码已修改,请重新登录');
currentPwd.value = '';
newPwd.value = '';
setTimeout(function () {
window.location.href = '/';
}, 2000);
} else {
showSettingsToast(res.message || '修改失败', true);
}
} catch (e) {
showSettingsToast('修改失败', true);
}
};
xhr.send(JSON.stringify({
current_password: currentPwd.value,
new_password: newPwd.value
}));
});
}
// ---- 注销账号 ----
var deletePwd = document.getElementById('deletePassword');
var deleteReason = document.getElementById('deleteReason');
var deleteAccountBtn = document.getElementById('deleteAccountBtn');
if (deletePwd && deleteAccountBtn) {
deleteAccountBtn.addEventListener('click', function () {
if (!deletePwd.value) {
showSettingsToast('请输入密码确认', true);
return;
}
if (!confirm('确定要注销账号吗?\n\n注销后 7 天内重新登录可自动恢复,期满后将永久删除。')) {
return;
}
deleteAccountBtn.disabled = true;
deleteAccountBtn.textContent = '注销中...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/settings/delete-account', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
deleteAccountBtn.disabled = false;
deleteAccountBtn.textContent = '确认注销';
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast(res.message || '账号已注销');
setTimeout(function () {
window.location.href = '/';
}, 2000);
} else {
showSettingsToast(res.message || '操作失败', true);
}
} catch (e) {
showSettingsToast('操作失败', true);
}
};
xhr.send(JSON.stringify({
password: deletePwd.value,
reason: deleteReason ? deleteReason.value : ''
}));
});
}
// ===== 登录管理 Tab =====
(function () {
var kickBtns = document.querySelectorAll('.session-kick-btn');
var kickOthersBtn = document.getElementById('kickOthersBtn');
var remarkInputs = document.querySelectorAll('.session-remark-input');
if (!kickBtns.length && !kickOthersBtn && !remarkInputs.length) return;
// ---- 踢出单个设备 ----
kickBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
var sid = btn.getAttribute('data-sid');
if (!sid) return;
if (!confirm('确定要踢出该设备吗?')) return;
btn.disabled = true;
btn.textContent = '踢出中...';
var xhr = new XMLHttpRequest();
xhr.open('DELETE', '/api/settings/sessions/' + encodeURIComponent(sid), true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
btn.disabled = false;
btn.textContent = '踢出';
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast(res.message || '已踢出该设备');
var item = btn.closest('.session-item');
if (item) {
item.style.transition = 'opacity .3s ease, transform .3s ease';
item.style.opacity = '0';
item.style.transform = 'translateX(20px)';
setTimeout(function () { item.remove(); }, 300);
}
} else {
showSettingsToast(res.message || '操作失败', true);
}
} catch (e) {
showSettingsToast('操作失败', true);
}
};
xhr.send();
});
});
// ---- 一键踢出其他设备 ----
if (kickOthersBtn) {
kickOthersBtn.addEventListener('click', function () {
var otherCount = document.querySelectorAll('.session-item:not(.session-current)').length;
if (otherCount === 0) {
showSettingsToast('没有其他在线设备');
return;
}
if (!confirm('确定要踢出所有其他设备(共 ' + otherCount + ' 台)吗?')) return;
kickOthersBtn.disabled = true;
kickOthersBtn.textContent = '处理中...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/settings/sessions/destroy-others', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
kickOthersBtn.disabled = false;
kickOthersBtn.textContent = '一键踢出其他设备';
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast(res.message || '已踢出所有其他设备');
var others = document.querySelectorAll('.session-item:not(.session-current)');
others.forEach(function (item, i) {
setTimeout(function () {
item.style.transition = 'opacity .3s ease, transform .3s ease';
item.style.opacity = '0';
item.style.transform = 'translateX(20px)';
setTimeout(function () { item.remove(); }, 300);
}, i * 100);
});
} else {
showSettingsToast(res.message || '操作失败', true);
}
} catch (e) {
showSettingsToast('操作失败', true);
}
};
xhr.send();
});
}
// ---- 备注保存 ----
var remarkTimers = {};
remarkInputs.forEach(function (input) {
input.addEventListener('blur', function () {
var sid = input.getAttribute('data-sid');
var remark = input.value.trim();
if (!sid) return;
saveRemark(sid, remark);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
input.blur();
}
});
});
function saveRemark(sid, remark) {
if (remarkTimers[sid]) clearTimeout(remarkTimers[sid]);
remarkTimers[sid] = setTimeout(function () {
var xhr = new XMLHttpRequest();
xhr.open('PUT', '/api/settings/sessions/' + encodeURIComponent(sid) + '/remark', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
try {
var res = JSON.parse(xhr.responseText);
if (res.success) {
showSettingsToast('备注已保存');
} else {
showSettingsToast(res.message || '保存失败', true);
}
} catch (e) {
showSettingsToast('保存失败', true);
}
};
xhr.send(JSON.stringify({ remark: remark }));
}, 500);
}
})();
})();
</script>
</body>
</html>