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:
@ -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>
|
||||
|
||||
@ -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 ---------- */
|
||||
|
||||
Reference in New Issue
Block a user