feat: support local avatar uploads
This commit is contained in:
+77
-17
@@ -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>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('本地上传头像')}</div>
|
||||
<div className="profile-avatar-upload">
|
||||
<input
|
||||
value={avatarUrl}
|
||||
onChange={(e) => {
|
||||
setAvatarUrl(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder={t('https://... 或 /avatar.png')}
|
||||
style={inputStyle}
|
||||
ref={fileInputRef}
|
||||
id={avatarInputId}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
onChange={handleAvatarFileChange}
|
||||
/>
|
||||
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--color-text-muted)' }}>
|
||||
{t('留空时使用本地生成头像,不会上传文件。')}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -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}.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user