feat(settings): 个人资料编辑 + 头像上传/裁切 + 自定义裁切弹窗

**新增功能:**

用户名编辑:输入框替换静态文本,白名单验证(中文/英文/数字/下划线/连字符),前端计数器(n/16),utf8对齐PG VARCHAR,XSS防控。

个性签名编辑:Textarea,128字符上限,实时计数器。

头像上传管线:校验→解码→裁切→CatmullRom缩放→WebP二分编码≤100KB→原子写入(.tmp→os.Rename)。限制5MB,128-3840px,JPEG/PNG/WebP。输出512x512 WebP。文件名 {uid}_{timestamp}.webp。清理旧头像。

自定义裁切弹窗:浅色主题,固定裁切框+图片平移/缩放(1×-3×滚轮),box-shadow遮罩,三等分网格。坐标映射pan/zoom→原图像素→subImage。

CSP修复:img-src允许data:URI(FileReader预览)。

**文件变更:**
修改: README, docs/{development,structure}.md, go.{mod,sum}, cmd/server/main.go, controller/settings, middleware/security, router, templates/settings/{index.html,css}
新增: internal/service/avatar_service.go, docs/settings.md
This commit is contained in:
2026-05-27 01:03:16 +08:00
parent debaa17127
commit c3dec09dae
13 changed files with 923 additions and 17 deletions

View File

@ -46,11 +46,17 @@
<div class="info-row">
<span class="info-label">头像</span>
<span class="info-value">
{{if .User.Avatar}}
<img src="{{.User.Avatar}}" alt="avatar" class="avatar-preview">
{{else}}
<span class="info-muted">未设置</span>
{{end}}
<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">
@ -109,6 +115,24 @@
{{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 () {
@ -214,6 +238,228 @@
};
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);
// 图片左边界必须在框左边之左cx + panX <= fLeft → panX <= fLeft - cx
// 图片右边界必须在框右边之右cx + panX + iw >= fLeft + fSize → panX >= fLeft + fSize - cx - iw
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;
// 裁切框中心在 zoom=1 图片坐标中的位置
var cx = (fLeft + fSize / 2 - ox) / zoom;
var cy = (fTop + fSize / 2 - oy) / zoom;
var cs = fSize / zoom; // zoom=1 坐标中的裁切边长
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)
};
}
// ---- 图片平移(点击拖拽 stage 任意位置) ----
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('头像已更新');
if (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);
});
}
})();
</script>
</body>

View File

@ -261,13 +261,179 @@ body {
border-radius: 4px;
}
/* ---------- Avatar Preview ---------- */
/* ---------- Avatar Upload ---------- */
.avatar-upload-wrap {
position: relative;
display: inline-flex;
align-items: center;
gap: .7rem;
cursor: pointer;
user-select: none;
}
.avatar-upload-wrap:hover .avatar-hint {
opacity: 1;
}
.avatar-upload-wrap:hover .avatar-preview,
.avatar-upload-wrap:hover .avatar-placeholder {
opacity: .7;
border-color: var(--color-accent);
}
.avatar-preview {
width: 40px;
height: 40px;
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--color-border);
transition: opacity .15s ease, border-color .15s ease;
}
.avatar-placeholder {
width: 56px;
height: 56px;
border-radius: 50%;
background: #f0f0f0;
border: 2px dashed var(--color-border);
display: flex;
align-items: center;
justify-content: center;
color: #bbb;
transition: opacity .15s ease, border-color .15s ease;
}
.avatar-hint {
font-size: .8rem;
color: var(--color-accent);
font-weight: 500;
opacity: 0;
transition: opacity .15s ease;
}
.avatar-input-hidden {
display: none;
}
/* ---------- Crop Modal ---------- */
.crop-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .4);
z-index: 2000;
align-items: center;
justify-content: center;
}
.crop-overlay.active {
display: flex;
}
.crop-dialog {
background: var(--color-surface);
border-radius: 12px;
overflow: hidden;
max-width: 94vw;
max-height: 94vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, .15);
}
.crop-header {
padding: .75rem 1.25rem;
font-size: .9rem;
color: var(--color-primary);
font-weight: 600;
text-align: center;
border-bottom: 1px solid var(--color-border);
}
.crop-stage {
position: relative;
width: min(85vw, 480px);
height: min(85vw, 480px);
overflow: hidden;
background: #edf0f4;
cursor: grab;
}
.crop-stage img {
position: absolute;
max-width: none;
user-select: none;
pointer-events: none;
}
/* 裁切框外的半透明暗色遮罩 */
.crop-mask {
position: absolute;
pointer-events: none;
border-radius: 2px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, .45);
}
/* 裁切框白色边框(纯视觉,不可交互) */
.crop-frame {
position: absolute;
pointer-events: none;
border: 2px solid #fff;
border-radius: 2px;
box-sizing: border-box;
}
/* 网格参考线 */
.crop-grid {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(to right, rgba(255, 255, 255, .3) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, .3) 1px, transparent 1px);
background-size: 33.33% 33.33%;
}
.crop-footer {
display: flex;
gap: .75rem;
padding: .75rem 1rem;
border-top: 1px solid var(--color-border);
justify-content: flex-end;
}
.crop-btn {
padding: .45rem 1.25rem;
font-size: .85rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: background .15s ease, opacity .15s ease;
}
.crop-btn:disabled {
opacity: .5;
cursor: not-allowed;
}
.crop-btn-cancel {
background: #f0f0f0;
color: var(--color-text-light);
}
.crop-btn-cancel:hover {
background: #e0e0e0;
}
.crop-btn-confirm {
background: var(--color-accent);
color: #fff;
}
.crop-btn-confirm:hover {
background: #2980b9;
}
/* ---------- Role Tag ---------- */