Files
meijiaka-zy/tauri-app/src/pages/Profile/Profile.tsx
T
小鱼开发 de7a6b734f chore(release): bump to v1.5.15
- 统一版本号管理(VERSION + scripts/bump-version.py)
- 添加 GitLab CI/CD 前端多平台构建配置
- 替换应用图标为品牌 logo
- 清理无效文件(tauri.svg, vite.svg, bg-config.json, audio/presets, .DS_Store)
- 修复 ESLint 错误和全部 warnings
- 清理 console.warn,保留 console.error
- 更新 Cargo.toml 元数据(description + authors)
- 更新 .gitignore(dist/, src-tauri/target/, binaries/)
- authStore appVersion 改为动态获取(getVersion)
- 修复 login 错误处理
- 将 FFmpeg sidecar 二进制移出 Git 跟踪(CI 构建时准备)
2026-05-14 23:32:45 +08:00

331 lines
12 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useNavigation } from '../../contexts/NavigationContext';
import { useAuthStore } from '../../store';
import { client } from '../../api/client';
import { pointsApi, type PointBalance, type PointTransaction } from '../../api/modules/points';
import RechargeModal from '../../components/RechargeModal/RechargeModal';
import '../ContentManagement/ContentManagement.css';
interface UserProfile {
id: string;
mobile: string;
nickname: string | null;
avatar: string;
}
function formatTxTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
const TYPE_LABELS: Record<string, string> = {
recharge: '充值',
consume: '消费',
expire: '过期',
refund: '退款',
};
function maskMobile(mobile: string): string {
if (!mobile || mobile.length !== 11) {return mobile;}
return `${mobile.slice(0, 3)}****${mobile.slice(7)}`;
}
export default function Profile() {
const { navigate } = useNavigation();
const authUser = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const [user, setUser] = useState<UserProfile | null>(null);
const [balance, setBalance] = useState<PointBalance | null>(null);
const [recentTx, setRecentTx] = useState<PointTransaction[]>([]);
const [loading, setLoading] = useState(true);
const [showRechargeModal, setShowRechargeModal] = useState(false);
// 昵称编辑状态
const [nickname, setNickname] = useState('');
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [nickError, setNickError] = useState('');
const loadData = async () => {
setLoading(true);
try {
const [profileData, balanceData] = await Promise.all([
client.get<UserProfile>('/auth/me').catch(() => null),
pointsApi.getBalance().catch(() => null),
]);
if (profileData) {
setUser(profileData);
setNickname(profileData.nickname || '');
}
if (balanceData) {setBalance(balanceData);}
const txData = await pointsApi
.getTransactions({ page: 1, pageSize: 10 })
.catch(() => null);
if (txData) {setRecentTx(txData.items);}
} catch (e) {
console.error('[Profile] 加载数据失败:', e);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleRechargeSuccess = () => {
loadData();
};
const handleSaveNickname = async () => {
const trimmed = nickname.trim();
if (!trimmed) { setNickError('昵称不能为空'); return; }
if (trimmed.length > 20) { setNickError('昵称最多20个字符'); return; }
setNickError('');
setSaving(true);
try {
const data = await client.patch<UserProfile>('/auth/me', { nickname: trimmed });
setUser(data);
setEditing(false);
useAuthStore.setState((state) => ({
user: state.user ? { ...state.user, nickname: trimmed } : null,
}));
} catch (e) {
console.error('[Profile] 修改昵称失败:', e);
setNickError('保存失败,请重试');
} finally {
setSaving(false);
}
};
const handleLogout = async () => {
if (!window.confirm('确定要退出登录吗?')) {return;}
await logout();
window.location.reload();
};
const displayName = user?.nickname || authUser?.nickname || '用户';
const displayAvatar = user?.avatar || authUser?.avatar || '';
const displayMobile = user?.mobile ? maskMobile(user.mobile) : '';
return (
<div className="settings-page">
{/* 个人资料卡片 */}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* 用户区 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', padding: '24px 28px' }}>
<img
src={displayAvatar || '/default-avatar.svg'}
alt="avatar"
className="profile-topbar-avatar"
/>
<div style={{ flex: 1, minWidth: 0 }}>
{editing ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<input
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
maxLength={20}
autoFocus
disabled={saving}
style={{
padding: '4px 10px',
borderRadius: '6px',
border: nickError ? '1px solid #e74c3c' : '1px solid var(--border-color)',
fontSize: '18px',
fontWeight: 600,
color: 'var(--text-primary)',
outline: 'none',
width: '160px',
fontFamily: 'inherit',
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {handleSaveNickname();}
if (e.key === 'Escape') {
setEditing(false);
setNickname(displayName);
setNickError('');
}
}}
onBlur={() => {
const trimmed = nickname.trim();
if (!trimmed || trimmed === displayName) {
setEditing(false);
setNickname(displayName);
setNickError('');
return;
}
handleSaveNickname();
}}
/>
{nickError && (
<span style={{ color: '#e74c3c', fontSize: '12px' }}>
{nickError}
</span>
)}
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '18px', fontWeight: 600, color: 'var(--text-primary)' }}>
{displayName}
</span>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="#36b26a"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
style={{ cursor: 'pointer', flexShrink: 0 }}
onClick={() => setEditing(true)}
>
<title></title>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
</svg>
</div>
)}
{displayMobile && (
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: '2px' }}>
{displayMobile}
</div>
)}
</div>
</div>
<div style={{ height: 1, background: 'var(--border-light)', margin: '0 28px' }} />
{/* 积分区 */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', padding: '24px 28px' }}>
<div>
<div style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '8px' }}>
</div>
<div style={{ fontSize: '40px', fontWeight: 700, color: '#36b26a', lineHeight: 1 }}>
{balance?.balance ?? 0}
</div>
</div>
<button
className="btn btn-primary"
style={{ padding: '10px 28px', fontSize: '14px' }}
onClick={() => setShowRechargeModal(true)}
>
线
</button>
</div>
</div>
{/* 最近记录表格 */}
<div style={{ marginTop: 'var(--spacing-xl)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontSize: 'var(--font-md)', fontWeight: 600 }}></h3>
<button className="btn btn-ghost btn-sm" onClick={() => navigate('usage-detail')}>
</button>
</div>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table className="usage-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
...
</td>
</tr>
) : recentTx.length === 0 ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
</td>
</tr>
) : (
recentTx.map((tx) => (
<tr key={tx.id}>
<td>
<span
className="tx-tag"
style={{
background: tx.type === 'recharge' ? '#e8f5e9' : '#f5f5f5',
color: tx.type === 'recharge' ? '#36b26a' : 'var(--text-secondary)',
border: 'none',
}}
>
{TYPE_LABELS[tx.type] || tx.type}
</span>
</td>
<td style={{ fontWeight: 600 }}>
{tx.type === 'recharge' ? '+' : '-'}{tx.amount}
</td>
<td>{tx.balanceBefore}</td>
<td>{tx.balanceAfter}</td>
<td className="description-cell" title={tx.description || '-'}>
{tx.description || '-'}
</td>
<td>{formatTxTime(tx.createdAt)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 退出登录 — 页面底部 */}
<div style={{ marginTop: 'var(--spacing-xl)' }}>
<button
onClick={handleLogout}
style={{
width: '100%',
padding: '14px',
borderRadius: 'var(--radius-lg)',
border: '1px solid var(--border-light)',
background: 'var(--bg-card)',
fontSize: 'var(--font-sm)',
color: 'var(--text-secondary)',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
(e.target as HTMLButtonElement).style.color = '#e74c3c';
(e.target as HTMLButtonElement).style.borderColor = '#e74c3c';
(e.target as HTMLButtonElement).style.background = '#fdf2f2';
}}
onMouseLeave={(e) => {
(e.target as HTMLButtonElement).style.color = 'var(--text-secondary)';
(e.target as HTMLButtonElement).style.borderColor = 'var(--border-light)';
(e.target as HTMLButtonElement).style.background = 'var(--bg-card)';
}}
>
退
</button>
</div>
<RechargeModal
open={showRechargeModal}
onClose={() => setShowRechargeModal(false)}
onRechargeSuccess={handleRechargeSuccess}
/>
</div>
);
}