- profile.go:UpdateProfile 改 JSON 绑定(profileRequest),移除内嵌
头像文件分支(头像统一走 /api/profile/avatar,XSS 链校验保留在
UploadAvatar);错误改 APIError(400 profile_wrong_password/
profile_password_short/profile_email_invalid、404 user_not_found、
500),成功 {ok,redirect:/profile?saved=1}
- main.go:/profile 组旧 POST 移除,/api/profile[/avatar] 分组收敛
- profile.html:主表单改 blogAPI(头像 cropper 流程独立不变),
新增 profileError 错误区
- 测试:TestProfilePasswordMinLength/TestProfileEmailValidation 改
JSON(400+code);TestUpdateProfileAvatarRejectsNonImage 改打
/api/profile/avatar;env 路由同步 /api/profile
- go build/vet/test 全绿
267 lines
12 KiB
HTML
267 lines
12 KiB
HTML
{{define "profile"}}
|
|
{{template "header" .}}
|
|
|
|
<section class="max-w-2xl mx-auto px-4 py-10">
|
|
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{index .Tr "profile_title"}}</h2>
|
|
|
|
{{if .Success}}
|
|
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
|
{{.Success}}
|
|
</div>
|
|
{{end}}
|
|
{{if .Error}}
|
|
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
|
{{.Error}}
|
|
</div>
|
|
{{end}}
|
|
|
|
<!-- Avatar Crop Modal -->
|
|
<div id="avatarCropModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50">
|
|
<div class="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 p-6">
|
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "crop_avatar_title"}}</h3>
|
|
<div class="max-h-64 overflow-hidden mb-4 flex items-center justify-center bg-gray-100">
|
|
<img id="avatarCropImage" src="" alt="crop preview" class="max-w-full block">
|
|
</div>
|
|
<div class="flex gap-3 justify-end">
|
|
<button type="button" id="cropCancelBtn"
|
|
class="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer">
|
|
{{index .Tr "crop_cancel"}}
|
|
</button>
|
|
<button type="button" id="cropConfirmBtn"
|
|
class="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors cursor-pointer">
|
|
{{index .Tr "crop_confirm"}}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="profileError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 hidden"></div>
|
|
|
|
<form id="profileForm" action="/api/profile" method="post" class="space-y-8">
|
|
<!-- Avatar Section -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "profile_avatar"}}</h3>
|
|
<div class="flex items-center gap-5">
|
|
<div id="avatarPreviewContainer" class="w-20 h-20 rounded-full overflow-hidden border-2 border-gray-300 flex items-center justify-center bg-gray-200">
|
|
{{if .Profile.Avatar}}
|
|
<img id="avatarPreviewImg" src="/uploads/avatars/{{.Profile.Avatar}}" alt="avatar" class="w-full h-full object-cover">
|
|
{{else}}
|
|
<svg id="avatarPreviewSvg" class="w-10 h-10 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .66.54 1.2 1.2 1.2h16.8c.66 0 1.2-.54 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>
|
|
</svg>
|
|
{{end}}
|
|
</div>
|
|
<div>
|
|
<label class="cursor-pointer bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm">
|
|
{{index .Tr "profile_change_avatar"}}
|
|
<input type="file" id="avatarFileInput" name="avatar" accept="image/*" class="hidden">
|
|
</label>
|
|
<div id="avatarUploadStatus" class="mt-2 text-sm text-gray-500 hidden"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Basic Info -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
|
<h3 class="text-lg font-semibold text-gray-800">{{index .Tr "profile_title"}}</h3>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_display_name"}}</label>
|
|
<input type="text" name="display_name" value="{{.Profile.DisplayName}}"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_gender"}}</label>
|
|
<select name="gender" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors bg-white">
|
|
<option value="male" {{if eq .Profile.Gender "male"}}selected{{end}}>{{index .Tr "gender_male"}}</option>
|
|
<option value="female" {{if eq .Profile.Gender "female"}}selected{{end}}>{{index .Tr "gender_female"}}</option>
|
|
<option value="other" {{if eq .Profile.Gender "other"}}selected{{end}}>{{index .Tr "gender_other"}}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_birthday"}}</label>
|
|
<input type="date" name="birthday"
|
|
value="{{if .Profile.Birthday}}{{.Profile.Birthday.Format "2006-01-02"}}{{end}}"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_email"}}</label>
|
|
<input type="email" name="email" value="{{.Profile.Email}}"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Password Change -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
|
<h3 class="text-lg font-semibold text-gray-800">{{index .Tr "profile_change_password"}}</h3>
|
|
<p class="text-sm text-gray-500">Leave blank if you don't want to change your password.</p>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_current_password"}}</label>
|
|
<input type="password" name="current_password"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "profile_new_password"}}</label>
|
|
<input type="password" name="new_password"
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Save Button -->
|
|
<button type="submit"
|
|
class="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
|
|
{{index .Tr "profile_save"}}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
|
|
{{template "footer" .}}
|
|
|
|
<script>
|
|
(function() {
|
|
var cropper = null;
|
|
var fileInput = document.getElementById('avatarFileInput');
|
|
var modal = document.getElementById('avatarCropModal');
|
|
var cropImage = document.getElementById('avatarCropImage');
|
|
var cancelBtn = document.getElementById('cropCancelBtn');
|
|
var confirmBtn = document.getElementById('cropConfirmBtn');
|
|
var statusDiv = document.getElementById('avatarUploadStatus');
|
|
var previewContainer = document.getElementById('avatarPreviewContainer');
|
|
|
|
// i18n strings from template
|
|
var i18nCropError = "{{index .Tr "crop_error"}}";
|
|
var i18nCropUploading = "{{index .Tr "crop_uploading"}}";
|
|
var i18nCropSuccess = "{{index .Tr "crop_success"}}";
|
|
var i18nCropConfirm = "{{index .Tr "crop_confirm"}}";
|
|
|
|
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
|
|
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
|
|
|
|
fileInput.addEventListener('change', function() {
|
|
var file = this.files[0];
|
|
if (!file) return;
|
|
|
|
if (!file.type.match(/^image\/(jpeg|png|gif|webp)$/)) {
|
|
showStatus(i18nCropError, 'red');
|
|
this.value = '';
|
|
return;
|
|
}
|
|
|
|
var reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
cropImage.src = e.target.result;
|
|
modal.classList.remove('hidden');
|
|
modal.classList.add('flex');
|
|
|
|
if (cropper) cropper.destroy();
|
|
cropper = new Cropper(cropImage, {
|
|
aspectRatio: 1,
|
|
viewMode: 1,
|
|
autoCropArea: 0.85,
|
|
responsive: true
|
|
});
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
cancelBtn.addEventListener('click', function() {
|
|
if (cropper) { cropper.destroy(); cropper = null; }
|
|
modal.classList.add('hidden');
|
|
modal.classList.remove('flex');
|
|
fileInput.value = '';
|
|
});
|
|
|
|
confirmBtn.addEventListener('click', function() {
|
|
if (!cropper) return;
|
|
|
|
confirmBtn.disabled = true;
|
|
confirmBtn.textContent = i18nCropUploading;
|
|
showStatus(i18nCropUploading, 'gray');
|
|
|
|
var canvas = cropper.getCroppedCanvas({
|
|
width: 256,
|
|
height: 256,
|
|
maxWidth: 512,
|
|
maxHeight: 512,
|
|
imageSmoothingEnabled: true,
|
|
imageSmoothingQuality: 'high'
|
|
});
|
|
|
|
canvas.toBlob(function(blob) {
|
|
var formData = new FormData();
|
|
formData.append('avatar', blob, 'avatar.jpg');
|
|
|
|
fetch('/api/profile/avatar', {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': csrfToken },
|
|
body: formData,
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
if (data.avatar) {
|
|
var ts = Date.now();
|
|
var avatarUrl = '/uploads/avatars/' + data.avatar + '?t=' + ts;
|
|
|
|
// Update profile page preview
|
|
previewContainer.innerHTML =
|
|
'<img src="' + avatarUrl + '" alt="avatar" class="w-full h-full object-cover">';
|
|
|
|
// Update nav bar avatar
|
|
var navAvatarBtn = document.getElementById('avatarBtn');
|
|
if (navAvatarBtn) {
|
|
navAvatarBtn.innerHTML =
|
|
'<img src="' + avatarUrl + '" alt="avatar" class="w-full h-full object-cover">';
|
|
}
|
|
|
|
showStatus(i18nCropSuccess, 'green');
|
|
} else {
|
|
showStatus(data.error || i18nCropError, 'red');
|
|
}
|
|
})
|
|
.catch(function() {
|
|
showStatus(i18nCropError, 'red');
|
|
})
|
|
.finally(function() {
|
|
if (cropper) { cropper.destroy(); cropper = null; }
|
|
modal.classList.add('hidden');
|
|
modal.classList.remove('flex');
|
|
fileInput.value = '';
|
|
confirmBtn.disabled = false;
|
|
confirmBtn.textContent = i18nCropConfirm;
|
|
});
|
|
}, 'image/jpeg', 0.92);
|
|
});
|
|
|
|
function showStatus(msg, color) {
|
|
statusDiv.textContent = msg;
|
|
statusDiv.classList.remove('hidden', 'text-gray-500', 'text-green-600', 'text-red-600');
|
|
if (color === 'green') statusDiv.classList.add('text-green-600');
|
|
else if (color === 'red') statusDiv.classList.add('text-red-600');
|
|
else statusDiv.classList.add('text-gray-500');
|
|
}
|
|
})();
|
|
|
|
// 个人资料主表单:文本字段 + 密码走 JSON API;
|
|
// 头像文件由 cropper 流程独立上传(/api/profile/avatar)。
|
|
(function () {
|
|
var form = document.getElementById('profileForm');
|
|
if (!form) return;
|
|
form.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
var btn = e.submitter || null;
|
|
blogAPI('POST', '/api/profile', blogForm(form, btn)).then(function (r) {
|
|
if (r.ok) { window.location.href = r.redirect || '/profile'; }
|
|
else { blogShowError('profileError', r.error || 'Failed to save profile.'); }
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
{{end}}
|