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 { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { ToastProvider, useToast } from './components/Toast.js';
|
import { ToastProvider, useToast } from './components/Toast.js';
|
||||||
@@ -137,6 +137,32 @@ function normalizeAvatarUrl(value: unknown): string {
|
|||||||
return '';
|
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 {
|
function defaultProfile(name: string): UserProfile {
|
||||||
const avatarSeed = createRandomAvatarSeed();
|
const avatarSeed = createRandomAvatarSeed();
|
||||||
return { name, avatarSeed, avatarStyle: pickAvatarStyle(avatarSeed) };
|
return { name, avatarSeed, avatarStyle: pickAvatarStyle(avatarSeed) };
|
||||||
@@ -247,10 +273,13 @@ function UserProfileModal({
|
|||||||
onSave: (nextProfile: UserProfile) => void;
|
onSave: (nextProfile: UserProfile) => void;
|
||||||
t: (text: string) => string;
|
t: (text: string) => string;
|
||||||
}) {
|
}) {
|
||||||
|
const avatarInputId = useId();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [name, setName] = useState(profile.name);
|
const [name, setName] = useState(profile.name);
|
||||||
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
|
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
|
||||||
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
|
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
|
||||||
const [avatarUrl, setAvatarUrl] = useState(profile.avatarUrl || '');
|
const [avatarUrl, setAvatarUrl] = useState(profile.avatarUrl || '');
|
||||||
|
const [avatarUploadName, setAvatarUploadName] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -258,6 +287,7 @@ function UserProfileModal({
|
|||||||
setAvatarSeed(profile.avatarSeed);
|
setAvatarSeed(profile.avatarSeed);
|
||||||
setAvatarStyle(profile.avatarStyle);
|
setAvatarStyle(profile.avatarStyle);
|
||||||
setAvatarUrl(profile.avatarUrl || '');
|
setAvatarUrl(profile.avatarUrl || '');
|
||||||
|
setAvatarUploadName('');
|
||||||
setError('');
|
setError('');
|
||||||
}, [open, profile]);
|
}, [open, profile]);
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
@@ -275,6 +305,27 @@ function UserProfileModal({
|
|||||||
setAvatarSeed(nextSeed);
|
setAvatarSeed(nextSeed);
|
||||||
setAvatarStyle(pickAvatarStyle(nextSeed));
|
setAvatarStyle(pickAvatarStyle(nextSeed));
|
||||||
setAvatarUrl('');
|
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 handleSubmit = () => {
|
||||||
const normalizedName = name.trim();
|
const normalizedName = name.trim();
|
||||||
@@ -288,7 +339,7 @@ function UserProfileModal({
|
|||||||
}
|
}
|
||||||
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
|
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
|
||||||
if (avatarUrl.trim() && !normalizedAvatarUrl) {
|
if (avatarUrl.trim() && !normalizedAvatarUrl) {
|
||||||
setError(t('头像地址需要是 http(s)、站内路径或 data:image'));
|
setError(t('头像图片读取失败,请重新上传'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave({
|
onSave({
|
||||||
@@ -322,14 +373,16 @@ function UserProfileModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
|
<div className="profile-avatar-preview-row">
|
||||||
<LocalAvatar
|
<LocalAvatar
|
||||||
className="topbar-avatar"
|
className="profile-avatar-preview"
|
||||||
profile={previewProfile}
|
profile={previewProfile}
|
||||||
label={previewProfile.name}
|
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>
|
<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>
|
||||||
@@ -344,18 +397,25 @@ function UserProfileModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
value={avatarUrl}
|
ref={fileInputRef}
|
||||||
onChange={(e) => {
|
id={avatarInputId}
|
||||||
setAvatarUrl(e.target.value);
|
type="file"
|
||||||
setError('');
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
}}
|
onChange={handleAvatarFileChange}
|
||||||
placeholder={t('https://... 或 /avatar.png')}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--color-text-muted)' }}>
|
<label htmlFor={avatarInputId}>
|
||||||
{t('留空时使用本地生成头像,不会上传文件。')}
|
<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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -367,7 +427,7 @@ function UserProfileModal({
|
|||||||
{t('换一个随机头像')}
|
{t('换一个随机头像')}
|
||||||
</button>
|
</button>
|
||||||
{avatarUrl.trim() && (
|
{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('清除图片头像')}
|
{t('清除图片头像')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -180,6 +180,18 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'个模型': 'models',
|
'个模型': 'models',
|
||||||
'个人信息': 'personal information',
|
'个人信息': 'personal information',
|
||||||
'个人信息已保存': 'Personal information has been saved',
|
'个人信息已保存': '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',
|
'个站点': 'sites',
|
||||||
'个账号': 'accounts',
|
'个账号': 'accounts',
|
||||||
'跟随系统': 'Follow the system',
|
'跟随系统': '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}',
|
'缓存清理后重建完成:新增路由 ${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.',
|
'缓存已清理,重建路由已开始执行': 'The cache has been cleared and routing reconstruction has started.',
|
||||||
'缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)': 'Cache cleared (model cache ${res.deletedModelAvailability || 0} items)',
|
'缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)': 'Cache cleared (model cache ${res.deletedModelAvailability || 0} items)',
|
||||||
|
'清除图片头像': 'Clear image avatar',
|
||||||
'换一个随机头像': 'Change a random avatar',
|
'换一个随机头像': 'Change a random avatar',
|
||||||
'恢复待处理的请求快照。': 'Restore a pending request snapshot.',
|
'恢复待处理的请求快照。': 'Restore a pending request snapshot.',
|
||||||
'恢复任务 ${restored.pendingJobId}。': 'Recovery task ${restored.pendingJobId}.',
|
'恢复任务 ${restored.pendingJobId}。': 'Recovery task ${restored.pendingJobId}.',
|
||||||
|
|||||||
@@ -37,10 +37,24 @@ const zhToEn: Record<string, string> = {
|
|||||||
'用户名最多 24 个字符': 'Username can be at most 24 characters',
|
'用户名最多 24 个字符': 'Username can be at most 24 characters',
|
||||||
'个人信息': 'Profile',
|
'个人信息': 'Profile',
|
||||||
'右上角头像实时预览': 'Top-right avatar live preview',
|
'右上角头像实时预览': 'Top-right avatar live preview',
|
||||||
|
'头像实时预览': 'Avatar live preview',
|
||||||
|
'会同步显示在右上角和侧边栏。': 'It will appear in the top-right menu and sidebar.',
|
||||||
'用户名': 'Username',
|
'用户名': 'Username',
|
||||||
'例如:小王': 'e.g. Alex',
|
'例如:小王': '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:',
|
'头像(Dicebear 随机) · 风格:': 'Avatar (Dicebear Random) · Style:',
|
||||||
|
'头像 · 风格:': 'Avatar · Style: ',
|
||||||
'换一个随机头像': 'Randomize Avatar',
|
'换一个随机头像': 'Randomize Avatar',
|
||||||
|
'清除图片头像': 'Clear Image Avatar',
|
||||||
'取消': 'Cancel',
|
'取消': 'Cancel',
|
||||||
'保存': 'Save',
|
'保存': 'Save',
|
||||||
'关闭': 'Close',
|
'关闭': 'Close',
|
||||||
|
|||||||
@@ -12294,6 +12294,99 @@ textarea:focus {
|
|||||||
background: var(--color-primary);
|
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,
|
.workspace-thumb,
|
||||||
.picture-agent-image-frame,
|
.picture-agent-image-frame,
|
||||||
.picture-agent-history img {
|
.picture-agent-history img {
|
||||||
|
|||||||
Reference in New Issue
Block a user