// profile.jsx — 个人中心 (/profile) const _profileToken = () => window.YiteAPI?.getToken?.() || localStorage.getItem('token') || ''; const _profileHeaders = () => ({ Authorization: 'Bearer ' + _profileToken() }); const _pAPI = { profile: () => fetch('/api/v1/user/profile', { headers: _profileHeaders() }).then(r => r.json()), updateProfile: body => fetch('/api/v1/user/profile', { method: 'PUT', headers: { 'Content-Type': 'application/json', ..._profileHeaders() }, body: JSON.stringify(body) }).then(r => r.json()), uploadAvatar: file => { const fd = new FormData(); fd.append('file', file); return fetch('/api/v1/user/avatar', { method: 'POST', headers: _profileHeaders(), body: fd }).then(r => r.json()); }, changePassword: body => fetch('/api/v1/user/password', { method: 'PUT', headers: { 'Content-Type': 'application/json', ..._profileHeaders() }, body: JSON.stringify(body) }).then(r => r.json()), storage: () => fetch('/api/v1/user/storage', { headers: _profileHeaders() }).then(r => r.json()), stats: () => fetch('/api/v1/user/stats', { headers: _profileHeaders() }).then(r => r.json()), signin: () => fetch('/api/v1/user/signin/history', { headers: _profileHeaders() }).then(r => r.json()), invite: () => fetch('/api/v1/user/invite', { headers: _profileHeaders() }).then(r => r.json()), myTemplates: () => fetch('/api/v1/templates/mine', { headers: _profileHeaders() }).then(r => r.json()), coinRecords: (page = 1) => fetch('/api/v1/user/coin-records?page=' + page + '&page_size=20', { headers: _profileHeaders() }).then(r => r.json()), orderRecords: (page = 1) => fetch('/api/v1/user/order-records?page=' + page + '&page_size=20', { headers: _profileHeaders() }).then(r => r.json()), subscriptions: () => fetch('/api/v1/subscriptions/mine', { headers: _profileHeaders() }).then(r => r.json()), followers: id => fetch('/api/v1/users/' + id + '/profile').then(r => r.json()), }; const _fmtBytes = b => { if (b >= 1073741824) return (b / 1073741824).toFixed(1) + ' GB'; if (b >= 1048576) return (b / 1048576).toFixed(0) + ' MB'; return (b / 1024).toFixed(0) + ' KB'; }; const _fmtCoins = n => n >= 10000 ? (n / 1000).toFixed(1) + 'K' : String(n || 0); const _relTime = iso => { if (!iso) return ''; const d = (Date.now() - new Date(iso).getTime()) / 1000; if (d < 60) return window.t('time.justNow'); if (d < 3600) return Math.floor(d / 60) + window.t('time.minutesAgo'); if (d < 86400) return Math.floor(d / 3600) + window.t('time.hoursAgo'); return new Date(iso).toLocaleDateString(); }; // ─── Storage card ──────────────────────────────────────────────────────────── const StorageCard = ({ data, isMobile }) => { const { t } = useI18n(); if (!data) return null; const pct = Math.min(100, Math.round(data.used_bytes / Math.max(data.total_limit, 1) * 100)); return (
{t('profile.storage')}
{_fmtBytes(data.used_bytes)} / {_fmtBytes(data.total_limit)}
80 ? '#f87171' : 'var(--accent)', transition: 'width .3s' }} />
{t('profile.images')} {_fmtBytes(data.image_bytes || 0)} {t('profile.videos')} {_fmtBytes(data.video_bytes || 0)} {data.file_count || 0} {t('profile.files')}
); }; // ─── Stats card ────────────────────────────────────────────────────────────── const StatsCard = ({ data, isMobile }) => { const { t } = useI18n(); if (!data) return null; return (
{t('profile.stats')}
{data.total_count || 0} {t('profile.totalCreations')}
{t('profile.images')} {data.image_count || 0} {t('profile.videos')} {data.video_count || 0} {t('profile.templates')} {data.template_count || 0}
); }; // ─── Sign-in card ──────────────────────────────────────────────────────────── const SignInCard = ({ data, onSignIn, isMobile }) => { const { t } = useI18n(); if (!data) return null; const config = data.gift_coins_config || [20, 25, 30, 35, 40, 45, 50]; const signed = new Set(data.signed_dates || []); const today = data.today_date || new Date().toISOString().slice(0, 10); const cycleStart = data.cycle_start_date || today; const days = []; for (let i = 0; i < 7; i++) { const d = new Date(cycleStart); d.setDate(d.getDate() + i); const ds = d.toISOString().slice(0, 10); days.push({ date: ds, signed: signed.has(ds), isToday: ds === today, coins: config[i] || 20 }); } return (
{t('profile.dailySignIn')}
{days.map((d, i) => (
d.isToday && !d.signed && onSignIn()} style={{ flex: 1, textAlign: 'center', padding: '8px 0', borderRadius: 6, cursor: d.isToday && !d.signed ? 'pointer' : 'default', background: d.signed ? 'rgba(74,222,128,0.12)' : d.isToday ? 'rgba(34,211,238,0.08)' : 'transparent', border: d.isToday ? '1px solid rgba(34,211,238,0.3)' : '1px solid transparent', }}>
D{i + 1}
+{d.coins}
{d.signed &&
}
))}
{!data.today_signed &&
{t('profile.clickSignIn')}
} {data.today_signed &&
{t('profile.alreadySignedIn')}
}
); }; // ─── Invite card ───────────────────────────────────────────────────────────── const InviteCard = ({ data, isMobile }) => { const { t } = useI18n(); if (!data) return null; const [copied, setCopied] = React.useState(false); const link = window.location.origin + data.invite_link; const handleCopy = () => { navigator.clipboard.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }; return (
{t('profile.inviteFriends')}
{data.invited_count || 0} {t('profile.invited')}
); }; // ─── Account settings modal ────────────────────────────────────────────────── // Password input with show/hide toggle (touch target ≥44px) const PasswordInput = ({ value, onChange, show, onToggleShow, placeholder, autoComplete }) => (
onChange(e.target.value)} placeholder={placeholder} autoComplete={autoComplete} className="yt-input" style={{ width: '100%', height: 44, fontSize: 14, paddingRight: 48 }} />
); const AccountSettingsModal = ({ profile, onClose, onSaved, isMobile }) => { const { t } = useI18n(); const [nickname, setNickname] = React.useState(profile?.nickname || ''); const [bio, setBio] = React.useState(profile?.bio || ''); const [oldPwd, setOldPwd] = React.useState(''); const [newPwd, setNewPwd] = React.useState(''); const [showOldPwd, setShowOldPwd] = React.useState(false); const [showNewPwd, setShowNewPwd] = React.useState(false); const [saving, setSaving] = React.useState(false); const [msg, setMsg] = React.useState(''); const [msgOk, setMsgOk] = React.useState(false); const avatarRef = React.useRef(null); // ESC key dismiss + body scroll lock React.useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); const prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { window.removeEventListener('keydown', onKey); document.body.style.overflow = prevOverflow; }; }, [onClose]); const handleSaveProfile = async () => { setSaving(true); setMsg(''); const res = await _pAPI.updateProfile({ nickname: nickname.trim(), bio: bio.trim() }); if (res.error) { setMsg(res.error); setMsgOk(false); } else { setMsg(t('profile.savingSuccess')); setMsgOk(true); onSaved(res); } setSaving(false); }; const handleUploadAvatar = async e => { const f = e.target.files?.[0]; e.target.value = ''; if (!f) return; setSaving(true); const res = await _pAPI.uploadAvatar(f); if (res.error) { setMsg(res.error); setMsgOk(false); } else { setMsg(t('profile.avatarUpdated')); setMsgOk(true); onSaved(res); } setSaving(false); }; const handleChangePwd = async () => { if (!oldPwd || !newPwd) { setMsg(t('profile.passwordRequired')); setMsgOk(false); return; } setSaving(true); setMsg(''); const res = await _pAPI.changePassword({ old_password: oldPwd, new_password: newPwd }); if (res.error) { setMsg(res.error); setMsgOk(false); } else { setMsg(t('profile.passwordChanged')); setMsgOk(true); setOldPwd(''); setNewPwd(''); } setSaving(false); }; return ReactDOM.createPortal(
{ if (e.target === e.currentTarget) onClose(); }}>
{t('profile.settings')}
{/* Avatar */}
{t('profile.avatar')}
{(profile?.avatar || profile?.avatar_oss_key) ? : (profile?.nickname || '?')[0].toUpperCase()}
{/* Nickname */}
setNickname(e.target.value)} autoComplete="nickname" required className="yt-input" style={{ width: '100%', height: 44, fontSize: 14 }} />
{/* Bio */}
{t('profile.bio')}