feat: support local avatar uploads

This commit is contained in:
boos
2026-06-06 20:52:34 +08:00
parent d800ed1f9f
commit d6bf7352a0
4 changed files with 199 additions and 19 deletions
+79 -19
View File
@@ -1,4 +1,4 @@
import React, { Suspense, lazy, useState, useEffect, useRef } from 'react';
import React, { Suspense, lazy, useState, useEffect, useRef, useId } from 'react';
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom';
import { gsap } from 'gsap';
import { ToastProvider, useToast } from './components/Toast.js';
@@ -137,6 +137,32 @@ function normalizeAvatarUrl(value: unknown): string {
return '';
}
async function fileToLocalAvatarDataUrl(file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
throw new Error('头像文件必须是图片格式');
}
if (file.size > 5 * 1024 * 1024) {
throw new Error('头像图片不能超过 5MB');
}
const bitmap = await createImageBitmap(file);
try {
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('当前浏览器无法处理头像图片');
const scale = Math.max(size / bitmap.width, size / bitmap.height);
const width = bitmap.width * scale;
const height = bitmap.height * scale;
ctx.clearRect(0, 0, size, size);
ctx.drawImage(bitmap, (size - width) / 2, (size - height) / 2, width, height);
return canvas.toDataURL('image/webp', 0.82);
} finally {
bitmap.close();
}
}
function defaultProfile(name: string): UserProfile {
const avatarSeed = createRandomAvatarSeed();
return { name, avatarSeed, avatarStyle: pickAvatarStyle(avatarSeed) };
@@ -247,10 +273,13 @@ function UserProfileModal({
onSave: (nextProfile: UserProfile) => void;
t: (text: string) => string;
}) {
const avatarInputId = useId();
const fileInputRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState(profile.name);
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
const [avatarUrl, setAvatarUrl] = useState(profile.avatarUrl || '');
const [avatarUploadName, setAvatarUploadName] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (!open) return;
@@ -258,6 +287,7 @@ function UserProfileModal({
setAvatarSeed(profile.avatarSeed);
setAvatarStyle(profile.avatarStyle);
setAvatarUrl(profile.avatarUrl || '');
setAvatarUploadName('');
setError('');
}, [open, profile]);
const inputStyle: React.CSSProperties = {
@@ -275,6 +305,27 @@ function UserProfileModal({
setAvatarSeed(nextSeed);
setAvatarStyle(pickAvatarStyle(nextSeed));
setAvatarUrl('');
setAvatarUploadName('');
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleAvatarFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setError('');
const dataUrl = await fileToLocalAvatarDataUrl(file);
setAvatarUrl(dataUrl);
setAvatarUploadName(file.name);
} catch (uploadError: any) {
setError(t(uploadError?.message || '头像上传失败'));
event.target.value = '';
}
};
const handleClearAvatarImage = () => {
setAvatarUrl('');
setAvatarUploadName('');
setError('');
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleSubmit = () => {
const normalizedName = name.trim();
@@ -288,7 +339,7 @@ function UserProfileModal({
}
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
if (avatarUrl.trim() && !normalizedAvatarUrl) {
setError(t('头像地址需要是 http(s)、站内路径或 data:image'));
setError(t('头像图片读取失败,请重新上传'));
return;
}
onSave({
@@ -322,14 +373,16 @@ function UserProfileModal({
</>
)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
<div className="profile-avatar-preview-row">
<LocalAvatar
className="topbar-avatar"
className="profile-avatar-preview"
profile={previewProfile}
label={previewProfile.name}
style={{ width: 40, height: 40, fontSize: 14 }}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
<div>
<strong>{t('头像实时预览')}</strong>
<span>{t('会同步显示在右上角和侧边栏。')}</span>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div>
@@ -344,18 +397,25 @@ function UserProfileModal({
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('自定义头像地址')}</div>
<input
value={avatarUrl}
onChange={(e) => {
setAvatarUrl(e.target.value);
setError('');
}}
placeholder={t('https://... 或 /avatar.png')}
style={inputStyle}
/>
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--color-text-muted)' }}>
{t('留空时使用本地生成头像,不会上传文件。')}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('本地上传头像')}</div>
<div className="profile-avatar-upload">
<input
ref={fileInputRef}
id={avatarInputId}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onChange={handleAvatarFileChange}
/>
<label htmlFor={avatarInputId}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M8 8l4-4m0 0l4 4m-4-4v12" />
</svg>
<span>{t('选择图片')}</span>
</label>
<div>
<strong>{avatarUploadName || (avatarUrl ? t('已选择本地头像') : t('未选择文件'))}</strong>
<span>{t('图片会压缩后保存在本机浏览器,不上传服务器。')}</span>
</div>
</div>
</div>
<div>
@@ -367,7 +427,7 @@ function UserProfileModal({
{t('换一个随机头像')}
</button>
{avatarUrl.trim() && (
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => setAvatarUrl('')}>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleClearAvatarImage}>
{t('清除图片头像')}
</button>
)}
+13
View File
@@ -180,6 +180,18 @@ export const zhToEnSupplemental: Record<string, string> = {
'个模型': 'models',
'个人信息': 'personal information',
'个人信息已保存': 'Personal information has been saved',
'头像实时预览': 'Avatar live preview',
'会同步显示在右上角和侧边栏。': 'It will appear in the top-right menu and sidebar.',
'本地上传头像': 'Upload local avatar',
'选择图片': 'Choose image',
'已选择本地头像': 'Local avatar selected',
'未选择文件': 'No file selected',
'图片会压缩后保存在本机浏览器,不上传服务器。': 'The image is compressed and saved in this browser, not uploaded to the server.',
'头像文件必须是图片格式': 'Avatar file must be an image',
'头像图片不能超过 5MB': 'Avatar image must be under 5MB',
'当前浏览器无法处理头像图片': 'This browser cannot process the avatar image',
'头像上传失败': 'Avatar upload failed',
'头像图片读取失败,请重新上传': 'Avatar image could not be read. Please upload it again.',
'个站点': 'sites',
'个账号': 'accounts',
'跟随系统': 'Follow the system',
@@ -205,6 +217,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'缓存清理后重建完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'Reconstruction is completed after cache cleaning: new route ${rebuild.createdRoutes}, new channel ${rebuild.createdChannels}, removed channel ${rebuild.removedChannels}',
'缓存已清理,重建路由已开始执行': 'The cache has been cleared and routing reconstruction has started.',
'缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)': 'Cache cleared (model cache ${res.deletedModelAvailability || 0} items)',
'清除图片头像': 'Clear image avatar',
'换一个随机头像': 'Change a random avatar',
'恢复待处理的请求快照。': 'Restore a pending request snapshot.',
'恢复任务 ${restored.pendingJobId}。': 'Recovery task ${restored.pendingJobId}.',
+14
View File
@@ -37,10 +37,24 @@ const zhToEn: Record<string, string> = {
'用户名最多 24 个字符': 'Username can be at most 24 characters',
'个人信息': 'Profile',
'右上角头像实时预览': 'Top-right avatar live preview',
'头像实时预览': 'Avatar live preview',
'会同步显示在右上角和侧边栏。': 'It will appear in the top-right menu and sidebar.',
'用户名': 'Username',
'例如:小王': 'e.g. Alex',
'本地上传头像': 'Upload Local Avatar',
'选择图片': 'Choose Image',
'已选择本地头像': 'Local avatar selected',
'未选择文件': 'No file selected',
'图片会压缩后保存在本机浏览器,不上传服务器。': 'The image is compressed and saved in this browser, not uploaded to the server.',
'头像文件必须是图片格式': 'Avatar file must be an image',
'头像图片不能超过 5MB': 'Avatar image must be under 5MB',
'当前浏览器无法处理头像图片': 'This browser cannot process the avatar image',
'头像上传失败': 'Avatar upload failed',
'头像图片读取失败,请重新上传': 'Avatar image could not be read. Please upload it again.',
'头像(Dicebear 随机) · 风格:': 'Avatar (Dicebear Random) · Style:',
'头像 · 风格:': 'Avatar · Style: ',
'换一个随机头像': 'Randomize Avatar',
'清除图片头像': 'Clear Image Avatar',
'取消': 'Cancel',
'保存': 'Save',
'关闭': 'Close',
+93
View File
@@ -12294,6 +12294,99 @@ textarea:focus {
background: var(--color-primary);
}
.profile-avatar-preview-row {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 10px;
border: 1px solid var(--console-line);
border-radius: 16px;
background:
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 76%, var(--color-bg)));
}
.profile-avatar-preview {
width: 52px;
height: 52px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 18px;
color: #fff;
font-size: 16px;
font-weight: 840;
line-height: 1;
text-transform: uppercase;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38);
}
.profile-avatar-preview img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.profile-avatar-preview-row strong,
.profile-avatar-upload strong {
display: block;
color: var(--color-text-primary);
font-size: 13px;
font-weight: 820;
}
.profile-avatar-preview-row span,
.profile-avatar-upload span {
display: block;
margin-top: 3px;
color: var(--color-text-muted);
font-size: 11.5px;
line-height: 1.55;
}
.profile-avatar-upload {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 12px;
border: 1px dashed color-mix(in srgb, var(--color-primary) 26%, var(--console-line-strong));
border-radius: 16px;
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-bg-card));
}
.profile-avatar-upload input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.profile-avatar-upload label {
min-height: 38px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, var(--console-line));
border-radius: 999px;
color: var(--color-primary);
background: var(--color-bg-card);
cursor: pointer;
font-size: 13px;
font-weight: 780;
transition: transform 0.16s ease, border-color 0.16s ease, background-color 0.16s ease;
}
.profile-avatar-upload label:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--color-primary) 48%, var(--console-line));
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card));
}
.workspace-thumb,
.picture-agent-image-frame,
.picture-agent-history img {