de7a6b734f
- 统一版本号管理(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 构建时准备)
331 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|