Files
mce/templates/MetaLab-2026/html/settings/index.html

992 lines
44 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>
<a href="/settings/energy"
class="settings-nav-item {{if eq .ActiveTab "energy"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</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}}
{{if not .RememberMe}}<span class="session-badge session-badge-temp">临时会话</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>
{{if not .IsCurrent}}<span class="session-ttl">剩余约 {{formatTTL .TTLMinutes}}</span>{{end}}
</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 if eq .ActiveTab "energy"}}
<!-- 我的域能 -->
<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="energy-summary">
<div class="energy-stat">
<span class="energy-label">域能余额</span>
<span class="energy-value" id="energyBalance">加载中...</span>
</div>
<div class="energy-stat">
<span class="energy-label">经验</span>
<span class="energy-value" id="energyExp">加载中...</span>
</div>
<div class="energy-stat">
<span class="energy-label">等级</span>
<span class="energy-value" id="energyLevel">加载中...</span>
</div>
</div>
<div class="energy-log-list" id="energyLogList">
<div class="energy-log-empty">加载中...</div>
</div>
</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">调整裁切范围 <span class="crop-info" id="cropInfo"></span></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?v={{assetV "/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; // 图片缩放系数
var minZoom = 1; // 最小缩放系数(超大图限制选区不超过 3840px
var panX = 0, panY = 0; // 图片平移偏移量
var fSize, fLeft, fTop; // 裁切框的固定屏幕像素:边长、左、顶
var CROP_MAX_PX = 3840; // 选区最大像素限制
// 更新裁切框固定参数(图片加载时计算一次,之后不变)
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';
// 实时显示选区像素尺寸
var p = getCropParams();
var infoEl = document.getElementById('cropInfo');
if (infoEl) infoEl.textContent = p.size + ' × ' + p.size + ' 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);
// 限制选框最大像素:原图短边超过 3840 时,抬高最小缩放
minZoom = Math.min(imgNW, imgNH) > CROP_MAX_PX
? Math.min(imgNW, imgNH) / CROP_MAX_PX
: 1;
zoom = minZoom;
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(minZoom, 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();
});
// 客户端裁剪 + 压缩Canvas 裁出选区并一步到位缩放到 512px上传体积最小
function cropOnClient(file, x, y, size) {
return new Promise(function (resolve) {
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () {
URL.revokeObjectURL(url);
var out = 512; // 直接缩放到服务端目标输出尺寸
var canvas = document.createElement('canvas');
canvas.width = out;
canvas.height = out;
var ctx = canvas.getContext('2d');
ctx.drawImage(img, x, y, size, size, 0, 0, out, out);
canvas.toBlob(function (blob) {
resolve({
file: new File([blob], 'avatar.jpg', { type: blob.type || 'image/jpeg' }),
cropX: 0,
cropY: 0,
cropSize: out
});
}, 'image/jpeg', 0.88);
};
img.onerror = function () {
URL.revokeObjectURL(url);
// Canvas 处理失败时回退:直接上传原文件 + 原坐标
resolve({ file: file, cropX: x, cropY: y, cropSize: size });
};
img.src = url;
});
}
cropConfirmBtn.addEventListener('click', function () {
if (!pendingFile) return;
var params = getCropParams();
cropConfirmBtn.disabled = true;
cropConfirmBtn.textContent = '处理中...';
showSettingsToast('上传中...');
cropOnClient(pendingFile, params.x, params.y, params.size).then(function (result) {
var formData = new FormData();
formData.append('avatar', result.file, result.file.name);
formData.append('crop_x', result.cropX);
formData.append('crop_y', result.cropY);
formData.append('crop_size', result.cropSize);
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 || '头像已更新');
var newAvatarUrl = res.data && res.data.url && (res.data.url + '?t=' + Date.now());
if (newAvatarUrl && avatarPreview.tagName === 'IMG') {
avatarPreview.src = newAvatarUrl;
}
// 同步更新 NAV 头像
if (newAvatarUrl) {
var navLink = document.querySelector('.nav-avatar-link');
if (navLink) {
var navImg = navLink.querySelector('img.nav-avatar');
if (navImg) {
navImg.src = newAvatarUrl;
navImg.style.display = '';
var fb = navLink.querySelector('.nav-avatar-default');
if (fb) fb.style.display = 'none';
} else {
var fb2 = navLink.querySelector('.nav-avatar-default');
if (fb2) {
var img = document.createElement('img');
img.src = newAvatarUrl;
img.alt = '';
img.className = 'nav-avatar';
img.onerror = function() { this.style.display = 'none'; this.nextElementSibling.style.display = 'flex'; };
navLink.insertBefore(img, fb2);
fb2.style.display = 'none';
}
}
}
}
} else {
showSettingsToast(res.message || '上传失败', true);
}
} catch (err) {
showSettingsToast('上传失败', true);
}
};
xhr.send(formData);
}).catch(function () {
cropConfirmBtn.disabled = false;
cropConfirmBtn.textContent = '确认';
showSettingsToast('处理失败,请重试', true);
});
});
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);
}
})();
// ===== 域能 Tab =====
(function () {
var balanceEl = document.getElementById('energyBalance');
var expEl = document.getElementById('energyExp');
var levelEl = document.getElementById('energyLevel');
var logListEl = document.getElementById('energyLogList');
if (!balanceEl || !logListEl) return;
// 获取域能信息
fetch('/api/energy/info')
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.success && d.data) {
balanceEl.textContent = d.data.energy_display + ' 域能';
} else {
balanceEl.textContent = '获取失败';
}
})
.catch(function () { balanceEl.textContent = '获取失败'; });
// 获取经验和等级
if (expEl && levelEl) {
fetch('/api/level/info')
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.success && d.data) {
expEl.textContent = d.data.exp;
levelEl.textContent = 'Lv' + d.data.level;
} else {
expEl.textContent = '获取失败';
levelEl.textContent = '获取失败';
}
})
.catch(function () {
expEl.textContent = '获取失败';
levelEl.textContent = '获取失败';
});
}
// 获取域能流水
fetch('/api/energy/logs?days=7&page=1&page_size=50')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success || !d.data || !d.data.items || d.data.items.length === 0) {
logListEl.innerHTML = '<div class="energy-log-empty">近7天无变更记录</div>';
return;
}
var html = '';
d.data.items.forEach(function (item) {
var cls = item.amount > 0 ? 'energy-log-plus' : 'energy-log-minus';
var sign = item.amount > 0 ? '+' : '';
html += '<div class="energy-log-item ' + cls + '">'
+ '<div class="energy-log-meta">'
+ '<span class="energy-log-type">' + escapeHTML(item.type_name) + '</span>'
+ '<span class="energy-log-time">' + formatTime(item.created_at) + '</span>'
+ '</div>'
+ '<div class="energy-log-info">'
+ '<span class="energy-log-desc">' + escapeHTML(item.description) + '</span>'
+ '<span class="energy-log-amount">' + sign + item.amount_display + ' 域能</span>'
+ '</div>'
+ '</div>';
});
logListEl.innerHTML = html;
})
.catch(function () {
logListEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
});
function escapeHTML(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function formatTime(isoStr) {
if (!isoStr) return '';
// '2026-06-01T12:39:41+08:00' → '2026-06-01 12:39:41'
return isoStr.replace('T', ' ').replace(/\+.*/, '');
}
})();
})();
</script>
</body>
</html>