// 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 }) => (
);
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 */}
{/* Password */}
{profile?.auth_type === 'email' && (
{t('profile.changePassword')}
setShowOldPwd(v => !v)}
placeholder={t('profile.currentPassword')} autoComplete="current-password" />
setShowNewPwd(v => !v)}
placeholder={t('profile.newPassword')} autoComplete="new-password" />
)}
{/* Account info */}
{profile?.email} · {profile?.auth_type === 'gmail' ? t('profile.googleLogin') : t('profile.emailRegistration')}
{msg &&
{msg}
}
{/* ── 偏好设置 ── */}
{t('profile.preferences')}
{/* Language switcher */}
{[{id: 'en', label: 'EN'}, {id: 'zh', label: '中文'}].map(opt => {
const active = (window.i18n?.getLang?.() || 'en') === opt.id;
return (
);
})}
{/* ── 法律条款 ── */}
{t('profile.legal')}
{[
{ href: '/privacy', label: t('profile.privacy'), sub: t('profile.privacySub') },
{ href: '/terms', label: t('profile.terms'), sub: t('profile.termsSub') },
{ href: '/refund', label: t('profile.refund'), sub: t('profile.refundSub') },
].map((p, i) => (
))}
{/* ── 退出(销毁性操作 — 二次确认)── */}
,
document.body
);
};
// ─── Status badge helpers ────────────────────────────────────────────────────
const ORDER_STATUS_STYLE = {
pending: { bg: 'rgba(251,191,36,0.12)', color: '#fbbf24', labelKey: 'billing.status.pending' },
success: { bg: 'rgba(74,222,128,0.12)', color: '#4ade80', labelKey: 'billing.status.success' },
failed: { bg: 'rgba(248,113,113,0.12)', color: '#f87171', labelKey: 'billing.status.failed' },
abnormal: { bg: 'rgba(167,139,250,0.12)', color: '#a78bfa', labelKey: 'billing.status.abnormal' },
};
const SUB_STATUS_STYLE = {
active: { bg: 'rgba(74,222,128,0.12)', color: '#4ade80', labelKey: 'billing.status.active' },
expired: { bg: 'rgba(180,180,180,0.12)', color: 'var(--fg-3)', labelKey: 'billing.status.expired' },
cancelled: { bg: 'rgba(248,113,113,0.12)', color: '#f87171', labelKey: 'billing.status.cancelled' },
};
const StatusBadge = ({ st, label }) => {
const { t } = useI18n();
if (!st) return null;
return
{label || t(st.labelKey)};
};
const _fmtDate = iso => { if (!iso) return ''; const d = new Date(iso); return d.toLocaleString('en-CA', { hour12: false }).slice(0, 16).replace('T', ' '); };
const _fmtMoney = (a, c) => '$' + Number(a || 0).toFixed(2) + (c && c !== 'USD' ? ' ' + c : '');
// ─── Order record row(订单 / 一次性充值 / 订阅创建订单)────────────────────
const OrderRecordRow = ({ r }) => {
const { t } = useI18n();
const st = ORDER_STATUS_STYLE[r.status] || { bg: 'rgba(180,180,180,0.1)', color: 'var(--fg-3)', labelKey: null };
const kindLabel = r.order_kind === 'coin_recharge' ? t('billing.coinRecharge') : (r.plan_name || t('billing.subOrder'));
const isCoin = r.order_kind === 'coin_recharge';
return (
{kindLabel}
{_fmtDate(r.created_at)}
{r.provider_type && · {r.provider_type}}
{r.coins_granted > 0 && · +{r.coins_granted} {t('profile.coins')}}
{_fmtMoney(r.amount, r.currency)}
);
};
// ─── Subscription row ────────────────────────────────────────────────────────
const SubscriptionRow = ({ s }) => {
const { t } = useI18n();
const st = SUB_STATUS_STYLE[s.status] || { bg: 'rgba(180,180,180,0.1)', color: 'var(--fg-3)', labelKey: null };
return (
{s.plan_type ? s.plan_type.toUpperCase() : t('dashboard.plan')} · {s.duration_days}d
{t('billing.purchased')} {_fmtDate(s.purchased_at)}
· {t('billing.expires')} {_fmtDate(s.expires_at)}
{s.payment_provider && · {s.payment_provider}}
{_fmtMoney(s.paid_amount, s.currency)}
);
};
// ─── Coin record row ─────────────────────────────────────────────────────────
const COIN_SCENE_LABEL = {
recharge: 'billing.scene.recharge',
subscription_bonus: 'billing.scene.subscription_bonus',
unlock_script: 'billing.scene.unlock_script',
admin_grant: 'billing.scene.admin_grant',
custom: 'billing.scene.custom',
};
const CoinRecordRow = ({ r }) => {
const { t } = useI18n();
const isPositive = r.amount > 0;
const label = r.remark || (COIN_SCENE_LABEL[r.scene] ? t(COIN_SCENE_LABEL[r.scene]) : null) || r.scene || t('profile.coinChange');
return (
{label}
{_fmtDate(r.created_at)} {r.balance_after != null ? '· ' + t('billing.balance') + ' ' + r.balance_after : ''}
{isPositive ? '+' : ''}{r.amount}
);
};
// ─── Main screen ──────────────────────────────────────────────────────────────
const DashboardScreen = () => {
const { t } = useI18n();
const { isMobile } = useBreakpoint();
const [profile, setProfile] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [storage, setStorage] = React.useState(null);
const [stats, setStats] = React.useState(null);
const [signin, setSignin] = React.useState(null);
const [invite, setInvite] = React.useState(null);
const [tab, setTab] = React.useState('works');
const [recordTab, setRecordTab] = React.useState('subscriptions'); // subscriptions | orders | coins
const [works, setWorks] = React.useState([]);
const [coinRecords, setCoinRecords] = React.useState([]);
const [orderRecords, setOrderRecords] = React.useState([]);
const [subscriptions, setSubscriptions] = React.useState([]);
const [recordsLoaded, setRecordsLoaded] = React.useState({ subscriptions: false, orders: false, coins: false });
const [showSettings, setShowSettings] = React.useState(false);
const [followers, setFollowers] = React.useState(0);
const [following, setFollowing] = React.useState(0);
const [editingNick, setEditingNick] = React.useState(false);
const [nickInput, setNickInput] = React.useState('');
React.useEffect(() => {
if (!window.YiteAPI?.isLoggedIn?.()) { showLoginModal().then(ok => { if (ok) window.location.reload(); }); return; }
// Check for #settings hash
if (window.location.hash === '#settings') setShowSettings(true);
// Load all data
_pAPI.profile().then(d => { setProfile(d); setLoading(false); window.YiteAPI?.setUser?.(d); }).catch(() => setLoading(false));
_pAPI.storage().then(d => setStorage(d)).catch(() => {});
_pAPI.stats().then(d => setStats(d)).catch(() => {});
_pAPI.signin().then(d => setSignin(d)).catch(() => {});
_pAPI.invite().then(d => setInvite(d)).catch(() => {});
_pAPI.myTemplates().then(d => setWorks(d.items || [])).catch(() => {});
}, []);
// Lazy-load records when records tab is opened
React.useEffect(() => {
if (tab !== 'records') return;
if (recordTab === 'subscriptions' && !recordsLoaded.subscriptions) {
_pAPI.subscriptions().then(d => setSubscriptions(d.items || [])).catch(() => {});
setRecordsLoaded(s => ({ ...s, subscriptions: true }));
} else if (recordTab === 'orders' && !recordsLoaded.orders) {
_pAPI.orderRecords().then(d => setOrderRecords(d.items || [])).catch(() => {});
setRecordsLoaded(s => ({ ...s, orders: true }));
} else if (recordTab === 'coins' && !recordsLoaded.coins) {
_pAPI.coinRecords().then(d => setCoinRecords(d.items || [])).catch(() => {});
setRecordsLoaded(s => ({ ...s, coins: true }));
}
}, [tab, recordTab]);
// Load follower counts
React.useEffect(() => {
if (!profile?.id) return;
_pAPI.followers(profile.id).then(d => { setFollowers(d.followers || 0); setFollowing(d.following || 0); }).catch(() => {});
}, [profile?.id]);
const handleSignIn = async () => {
const d = await _pAPI.signin();
setSignin(d);
_pAPI.profile().then(p => { setProfile(p); window.YiteAPI?.setUser?.(p); });
};
const handleNickSave = async () => {
if (!nickInput.trim()) return;
const res = await _pAPI.updateProfile({ nickname: nickInput.trim() });
if (!res.error) { setProfile(res); window.YiteAPI?.setUser?.(res); }
setEditingNick(false);
};
const handleSettingsSaved = p => { setProfile(p); window.YiteAPI?.setUser?.(p); };
const avatarUrl = profile?.avatar || (profile?.avatar_oss_key ? (window.YiteAPI?.cdnDomain || '') + '/' + profile.avatar_oss_key : '');
const planName = profile?.subscription?.plan_name || 'Free';
if (loading) return
;
return (
{/* ── Header ── */}
{/* Avatar */}
setShowSettings(true)}>
{avatarUrl ?

: (profile?.nickname || '?')[0].toUpperCase()}
e.currentTarget.style.opacity = 1} onMouseLeave={e => e.currentTarget.style.opacity = 0}>
{/* Info */}
{editingNick ? (
setNickInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleNickSave(); if (e.key === 'Escape') setEditingNick(false); }}
className="yt-input" style={{ height: 36, width: 200, fontSize: 16 }} />
) : (
<>
{profile?.nickname || 'User'}
>
)}
{profile?.bio || t('profile.bioPrompt')}
{followers} {t('profile.followers')}
{following} {t('profile.following')}
{_fmtCoins(profile?.coins || 0)} {t('profile.coins')}
{planName}
{/* Actions — 桌面端:settings + payment records + share */}
{!isMobile && (
{t('shared.menu.orders')}
)}
{/* ── Data cards ── */}
{/* ── Mobile-only quick links(紧凑列表样式,副标显示真实数据)── */}
{isMobile && (
{t('profile.quickLinks')}
{(() => {
const assetSub = storage
? t('profile.assetsSubFiles').replace('{size}', _fmtBytes(storage.used_bytes || 0)).replace('{n}', String(storage.file_count || 0))
: t('profile.assetsView');
const items = [
{ icon: 'folder', label: t('profile.assets'), sub: assetSub, href: '/assets' },
{ icon: 'star', label: t('profile.subscription'), sub: profile?.subscription?.plan_name || 'Free', href: '/pricing' },
{ icon: 'coin', label: t('profile.payments'), sub: t('profile.paymentsSub'), href: '/orders' },
{ icon: 'bell', label: t('profile.notifications'), sub: t('profile.notificationsSub'), href: '#' },
{ icon: 'settings', label: t('profile.settingsCard'), sub: t('profile.settingsSub'), href: '#', onClick: () => setShowSettings(true) },
];
return items.map((q, i) => {
const isLast = i === items.length - 1;
const Wrapper = q.onClick ? 'button' : 'a';
return (
{ e.preventDefault(); q.onClick(); } : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 16px',
borderBottom: isLast ? 'none' : '1px solid var(--line)',
background: 'transparent', border: 'none', borderRadius: 0,
textDecoration: 'none', color: 'inherit', fontFamily: 'inherit',
cursor: 'pointer', textAlign: 'left', width: '100%',
}}
>
);
});
})()}
)}
{/* ── Tabs ── */}
{[
{ id: 'works', label: t('profile.myWorks'), count: works.length },
{ id: 'liked', label: t('profile.liked'), locked: true },
{ id: 'saved', label: t('profile.saved'), locked: true },
// records tab 已迁移至独立页 /orders(profile 快捷入口可点击进入)
].map(tb => (
))}
{/* ── Tab content ── */}
{tab === 'works' && (
works.length > 0 ? (
{works.map(t => )}
) :
)}
{tab === 'liked' &&
}
{tab === 'saved' &&
}
{tab === 'records' && (
{/* Sub-tabs: 订阅 / 订单 / 额度流水 */}
{[
{ id: 'subscriptions', label: t('billing.tab.subscriptions') },
{ id: 'orders', label: t('billing.tab.orders') },
{ id: 'coins', label: t('billing.tab.coins') },
].map(rt => (
))}
{/* 订阅 */}
{recordTab === 'subscriptions' && (
subscriptions.length > 0 ? (
{subscriptions.map(s => )}
) :
)}
{/* 订单 */}
{recordTab === 'orders' && (
orderRecords.length > 0 ? (
{orderRecords.map(r => )}
) :
)}
{/* 额度流水 */}
{recordTab === 'coins' && (
coinRecords.length > 0 ? (
{coinRecords.map((r, i) => )}
) :
)}
)}
{/* Settings modal */}
{showSettings && setShowSettings(false)} onSaved={handleSettingsSaved} isMobile={isMobile} />}
);
};
Object.assign(window, { DashboardScreen });