- 前端 React+TypeScript (源码+构建产物) - 后端 Python FastAPI - 数据库迁移 001-015 - Docker部署配置 - 微信ClawBot API频道绑定 - 项目文档 README.md
921 lines
35 KiB
TypeScript
921 lines
35 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { profileAPI } from '@/api';
|
|
import { mockOrders, mockMembershipPlans, mockUserCoupons, mockApps, mockOperationLogs } from '@/mock/data';
|
|
import type { Order, UserCoupon, MarketApp, OperationLog } from '@/types';
|
|
import './ProfilePage.css';
|
|
|
|
// ===== Mock API Keys =====
|
|
interface ApiKey {
|
|
id: string;
|
|
prefix: string;
|
|
fullKey: string;
|
|
createdAt: string;
|
|
status: 'active' | 'disabled';
|
|
}
|
|
|
|
const mockApiKeys: ApiKey[] = [
|
|
{ id: 'ak1', prefix: 'sk-aiflow-****a1b2', fullKey: 'sk-aiflow-u1-x8j3a1b2c3d4e5f6g7h8i9j0k1l2', createdAt: '2026-03-15T08:00:00Z', status: 'active' },
|
|
{ id: 'ak2', prefix: 'sk-aiflow-****c3d4', fullKey: 'sk-aiflow-u1-y9k4c3d4e5f6g7h8i9j0k1l2m3n4', createdAt: '2026-04-20T10:00:00Z', status: 'active' },
|
|
{ id: 'ak3', prefix: 'sk-aiflow-****e5f6', fullKey: 'sk-aiflow-u1-z0l5e5f6g7h8i9j0k1l2m3n4o5p6', createdAt: '2026-05-10T14:00:00Z', status: 'disabled' },
|
|
];
|
|
|
|
// ===== SVG Icons =====
|
|
const SvgIcons = {
|
|
// Password
|
|
lock: (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
</svg>
|
|
),
|
|
phone: (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
|
|
</svg>
|
|
),
|
|
mail: (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>
|
|
</svg>
|
|
),
|
|
log: (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
|
|
</svg>
|
|
),
|
|
order: (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="12" y1="17" x2="8" y2="17"/>
|
|
</svg>
|
|
),
|
|
member: (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
|
</svg>
|
|
),
|
|
coupon: (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M20 12V20H4V12"/><path d="M22 7H2v5h20V7z"/><circle cx="12" cy="7" r="2"/><circle cx="12" cy="17" r="2"/><line x1="12" y1="9" x2="12" y2="15"/>
|
|
</svg>
|
|
),
|
|
apps: (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/>
|
|
</svg>
|
|
),
|
|
key: (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
|
</svg>
|
|
),
|
|
copy: (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
</svg>
|
|
),
|
|
check: (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
),
|
|
eye: (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
|
</svg>
|
|
),
|
|
calendar: (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
|
</svg>
|
|
),
|
|
diamond: (
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="18" height="18" rx="4" transform="rotate(45 12 12)"/>
|
|
</svg>
|
|
),
|
|
close: (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
),
|
|
checkCircle: (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
// ===== Tabs =====
|
|
type ProfileTab = 'security' | 'orders' | 'membership' | 'coupons' | 'apps' | 'apikeys';
|
|
|
|
interface TabDef {
|
|
key: ProfileTab;
|
|
label: string;
|
|
icon: JSX.Element;
|
|
}
|
|
|
|
const tabs: TabDef[] = [
|
|
{ key: 'security', label: '账号安全', icon: SvgIcons.lock },
|
|
{ key: 'orders', label: '我的订单', icon: SvgIcons.order },
|
|
{ key: 'membership', label: '我的会员', icon: SvgIcons.member },
|
|
{ key: 'coupons', label: '我的优惠券', icon: SvgIcons.coupon },
|
|
{ key: 'apps', label: '我的已购应用', icon: SvgIcons.apps },
|
|
{ key: 'apikeys', label: 'API密钥管理', icon: SvgIcons.key },
|
|
];
|
|
|
|
// ===== Helper =====
|
|
function formatDate(dateStr: string): string {
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function formatShortDate(dateStr: string): string {
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
}
|
|
|
|
// ===== ProfilePage =====
|
|
const ProfilePage: React.FC = () => {
|
|
const { user } = useAuth();
|
|
const [activeTab, setActiveTab] = useState<ProfileTab>('security');
|
|
|
|
// Security form states
|
|
const [passwordForm, setPasswordForm] = useState({ old: '', newPass: '', confirm: '' });
|
|
const [bindForm, setBindForm] = useState({ type: 'phone' as 'phone' | 'email', value: '' });
|
|
|
|
// Order filter
|
|
const [orderFilter, setOrderFilter] = useState<Order['status'] | 'all'>('all');
|
|
|
|
// Coupon filter
|
|
const [couponFilter, setCouponFilter] = useState<UserCoupon['status'] | 'all'>('all');
|
|
|
|
// API Key
|
|
const [apiKeys, setApiKeys] = useState<ApiKey[]>(mockApiKeys);
|
|
const [showKeyId, setShowKeyId] = useState<string | null>(null);
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
|
|
if (!user) return null;
|
|
|
|
const memberPlan = mockMembershipPlans.find(p => p.id === user.membership?.planId);
|
|
|
|
// User's purchased apps (via mockOrders -> app type orders)
|
|
const purchasedAppOrders = useMemo(() => mockOrders.filter(o => o.userId === 'u1' && o.orderType === 'app' && o.status === 'paid'), []);
|
|
const purchasedApps: MarketApp[] = useMemo(() => {
|
|
return purchasedAppOrders
|
|
.map(o => mockApps.find(a => a.name === o.itemName))
|
|
.filter((a): a is MarketApp => !!a);
|
|
}, [purchasedAppOrders]);
|
|
|
|
// Login logs (recent 5)
|
|
const loginLogs = useMemo(() =>
|
|
mockOperationLogs.filter(l => l.userId === 'u1').slice(0, 5),
|
|
[]);
|
|
|
|
// Filter orders
|
|
const filteredOrders = useMemo(() => {
|
|
const userOrders = mockOrders.filter(o => o.userId === 'u1');
|
|
return orderFilter === 'all' ? userOrders : userOrders.filter(o => o.status === orderFilter);
|
|
}, [orderFilter]);
|
|
|
|
// Filter coupons
|
|
const filteredCoupons = useMemo(() => {
|
|
return couponFilter === 'all' ? mockUserCoupons : mockUserCoupons.filter(c => c.status === couponFilter);
|
|
}, [couponFilter]);
|
|
|
|
const handleCopyKey = (key: ApiKey) => {
|
|
navigator.clipboard.writeText(key.fullKey);
|
|
setCopiedId(key.id);
|
|
setTimeout(() => setCopiedId(null), 2000);
|
|
};
|
|
|
|
const handleGenerateKey = () => {
|
|
const newKey: ApiKey = {
|
|
id: `ak${Date.now()}`,
|
|
prefix: `sk-aiflow-****${Math.random().toString(36).slice(2, 6)}`,
|
|
fullKey: `sk-aiflow-u1-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`,
|
|
createdAt: new Date().toISOString(),
|
|
status: 'active',
|
|
};
|
|
setApiKeys(prev => [newKey, ...prev]);
|
|
};
|
|
|
|
const handleCopyPrefix = (key: ApiKey) => {
|
|
navigator.clipboard.writeText(key.prefix);
|
|
setCopiedId(key.id);
|
|
setTimeout(() => setCopiedId(null), 2000);
|
|
};
|
|
|
|
const handleToggleKeyStatus = (keyId: string) => {
|
|
setApiKeys(prev => prev.map(k =>
|
|
k.id === keyId ? { ...k, status: k.status === 'active' ? 'disabled' as const : 'active' as const } : k
|
|
));
|
|
};
|
|
|
|
const orderStatusLabels: Record<Order['status'] | 'all', string> = {
|
|
all: '全部',
|
|
pending: '待支付',
|
|
paid: '已支付',
|
|
refunded: '已退款',
|
|
};
|
|
|
|
const orderStatusColors: Record<Order['status'], string> = {
|
|
pending: 'var(--color-warning)',
|
|
paid: 'var(--color-success)',
|
|
refunded: 'var(--color-text-tertiary)',
|
|
};
|
|
|
|
const orderTypeLabels: Record<Order['orderType'], string> = {
|
|
membership: '会员',
|
|
app: '应用',
|
|
service: '服务',
|
|
};
|
|
|
|
const couponStatusConfig: Record<UserCoupon['status'], { label: string; color: string; bg: string }> = {
|
|
unused: { label: '未使用', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
|
|
used: { label: '已使用', color: 'var(--color-text-tertiary)', bg: 'var(--color-bg-tertiary)' },
|
|
expired: { label: '已过期', color: 'var(--color-error)', bg: 'var(--color-error-light)' },
|
|
};
|
|
|
|
const getCouponDescription = (coupon: UserCoupon['coupon']) => {
|
|
if (coupon.type === 'fixed') return `满${coupon.minAmount}减${coupon.value}`;
|
|
return `${coupon.value}折券,满${coupon.minAmount}可用`;
|
|
};
|
|
|
|
const getLevelGradient = (level: number) => {
|
|
const gradients: Record<number, string> = {
|
|
0: 'linear-gradient(135deg, #8B919E, #BFC4CE)',
|
|
2: 'linear-gradient(135deg, #5B5FED, #8B8DFF)',
|
|
3: 'linear-gradient(135deg, #F59E0B, #FBBF24)',
|
|
};
|
|
return gradients[level] || gradients[0];
|
|
};
|
|
|
|
return (
|
|
<div className="profile-page">
|
|
{/* Header */}
|
|
<div className="profile-header">
|
|
<div className="profile-user-card">
|
|
<div
|
|
className="profile-avatar"
|
|
style={{ background: getLevelGradient(user.membership?.level ?? 0) }}
|
|
>
|
|
<span>{user.username.charAt(0).toUpperCase()}</span>
|
|
</div>
|
|
<div className="profile-user-info">
|
|
<div className="profile-username-row">
|
|
<h1 className="profile-username">{user.username}</h1>
|
|
{user.membership && (
|
|
<span
|
|
className="profile-level-badge"
|
|
style={{ background: getLevelGradient(user.membership.level) }}
|
|
>
|
|
{user.membership.planName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{user.membership && (
|
|
<p className="profile-expire">
|
|
<span className="profile-expire-icon">{SvgIcons.calendar}</span>
|
|
会员到期:{formatShortDate(user.membership.expireAt)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="profile-body">
|
|
{/* Left Tabs */}
|
|
<nav className="profile-tabs">
|
|
{tabs.map(tab => (
|
|
<button
|
|
key={tab.key}
|
|
className={`profile-tab ${activeTab === tab.key ? 'active' : ''}`}
|
|
onClick={() => setActiveTab(tab.key)}
|
|
>
|
|
<span className="profile-tab-icon">{tab.icon}</span>
|
|
<span className="profile-tab-label">{tab.label}</span>
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{/* Right Content */}
|
|
<div className="profile-content">
|
|
{activeTab === 'security' && (
|
|
<SecurityContent
|
|
passwordForm={passwordForm}
|
|
setPasswordForm={setPasswordForm}
|
|
bindForm={bindForm}
|
|
setBindForm={setBindForm}
|
|
loginLogs={loginLogs}
|
|
/>
|
|
)}
|
|
{activeTab === 'orders' && (
|
|
<OrdersContent
|
|
orders={filteredOrders}
|
|
filter={orderFilter}
|
|
setFilter={setOrderFilter}
|
|
statusLabels={orderStatusLabels}
|
|
statusColors={orderStatusColors}
|
|
typeLabels={orderTypeLabels}
|
|
/>
|
|
)}
|
|
{activeTab === 'membership' && (
|
|
<MembershipContent user={user} memberPlan={memberPlan} />
|
|
)}
|
|
{activeTab === 'coupons' && (
|
|
<CouponsContent
|
|
coupons={filteredCoupons}
|
|
filter={couponFilter}
|
|
setFilter={setCouponFilter}
|
|
getDescription={getCouponDescription}
|
|
statusConfig={couponStatusConfig}
|
|
/>
|
|
)}
|
|
{activeTab === 'apps' && (
|
|
<AppsContent apps={purchasedApps} purchasedOrders={purchasedAppOrders} />
|
|
)}
|
|
{activeTab === 'apikeys' && (
|
|
<ApiKeysContent
|
|
apiKeys={apiKeys}
|
|
showKeyId={showKeyId}
|
|
setShowKeyId={setShowKeyId}
|
|
copiedId={copiedId}
|
|
onCopy={handleCopyKey}
|
|
onCopyPrefix={handleCopyPrefix}
|
|
onGenerate={handleGenerateKey}
|
|
onToggleStatus={handleToggleKeyStatus}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===== Security Content =====
|
|
interface SecurityContentProps {
|
|
passwordForm: { old: string; newPass: string; confirm: string };
|
|
setPasswordForm: React.Dispatch<React.SetStateAction<{ old: string; newPass: string; confirm: string }>>;
|
|
bindForm: { type: 'phone' | 'email'; value: string };
|
|
setBindForm: React.Dispatch<React.SetStateAction<{ type: 'phone' | 'email'; value: string }>>;
|
|
loginLogs: OperationLog[];
|
|
}
|
|
|
|
const SecurityContent: React.FC<SecurityContentProps> = ({ passwordForm, setPasswordForm, bindForm, setBindForm, loginLogs }) => {
|
|
const [pwdMsg, setPwdMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
const [pwdLoading, setPwdLoading] = useState(false);
|
|
|
|
const handleChangePassword = async () => {
|
|
setPwdMsg(null);
|
|
if (!passwordForm.old.trim()) {
|
|
setPwdMsg({ type: 'error', text: '请输入旧密码' });
|
|
return;
|
|
}
|
|
if (!passwordForm.newPass.trim() || passwordForm.newPass.length < 6) {
|
|
setPwdMsg({ type: 'error', text: '新密码长度至少为6位' });
|
|
return;
|
|
}
|
|
if (passwordForm.newPass !== passwordForm.confirm) {
|
|
setPwdMsg({ type: 'error', text: '两次输入的新密码不一致' });
|
|
return;
|
|
}
|
|
setPwdLoading(true);
|
|
try {
|
|
await profileAPI.updatePassword({
|
|
old_password: passwordForm.old,
|
|
new_password: passwordForm.newPass,
|
|
});
|
|
setPwdMsg({ type: 'success', text: '密码修改成功' });
|
|
setPasswordForm({ old: '', newPass: '', confirm: '' });
|
|
} catch (e: any) {
|
|
setPwdMsg({ type: 'error', text: e.message || e.detail || '密码修改失败' });
|
|
} finally {
|
|
setPwdLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="security-content">
|
|
{/* Change Password */}
|
|
<section className="profile-section">
|
|
<h3 className="section-title">
|
|
<span className="section-icon">{SvgIcons.lock}</span>
|
|
修改密码
|
|
</h3>
|
|
{pwdMsg && (
|
|
<div className={`form-message ${pwdMsg.type}`}>
|
|
{pwdMsg.text}
|
|
</div>
|
|
)}
|
|
<div className="form-grid">
|
|
<div className="form-item">
|
|
<label className="form-label-small">旧密码</label>
|
|
<input
|
|
type="password"
|
|
className="form-input"
|
|
placeholder="请输入旧密码"
|
|
value={passwordForm.old}
|
|
onChange={e => setPasswordForm(p => ({ ...p, old: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="form-item">
|
|
<label className="form-label-small">新密码</label>
|
|
<input
|
|
type="password"
|
|
className="form-input"
|
|
placeholder="请输入新密码(至少6位)"
|
|
value={passwordForm.newPass}
|
|
onChange={e => setPasswordForm(p => ({ ...p, newPass: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="form-item">
|
|
<label className="form-label-small">确认新密码</label>
|
|
<input
|
|
type="password"
|
|
className="form-input"
|
|
placeholder="请再次输入新密码"
|
|
value={passwordForm.confirm}
|
|
onChange={e => setPasswordForm(p => ({ ...p, confirm: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="form-item form-item-btn">
|
|
<button className="btn-primary" onClick={handleChangePassword} disabled={pwdLoading}>
|
|
{pwdLoading ? '修改中...' : '修改密码'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Bind Phone/Email */}
|
|
<section className="profile-section">
|
|
<h3 className="section-title">
|
|
<span className="section-icon">{SvgIcons.phone}</span>
|
|
绑定手机 / 邮箱
|
|
</h3>
|
|
<div className="bind-container">
|
|
<div className="bind-type-toggle">
|
|
<button
|
|
className={`bind-type-btn ${bindForm.type === 'phone' ? 'active' : ''}`}
|
|
onClick={() => setBindForm(f => ({ ...f, type: 'phone' }))}
|
|
>
|
|
{SvgIcons.phone}
|
|
<span>手机</span>
|
|
</button>
|
|
<button
|
|
className={`bind-type-btn ${bindForm.type === 'email' ? 'active' : ''}`}
|
|
onClick={() => setBindForm(f => ({ ...f, type: 'email' }))}
|
|
>
|
|
{SvgIcons.mail}
|
|
<span>邮箱</span>
|
|
</button>
|
|
</div>
|
|
<div className="bind-input-row">
|
|
<div className="form-item" style={{ flex: 1 }}>
|
|
<label className="form-label-small">
|
|
{bindForm.type === 'phone' ? '手机号' : '邮箱地址'}
|
|
</label>
|
|
<input
|
|
type={bindForm.type === 'phone' ? 'tel' : 'email'}
|
|
className="form-input"
|
|
placeholder={bindForm.type === 'phone' ? '请输入手机号' : '请输入邮箱地址'}
|
|
value={bindForm.value}
|
|
onChange={e => setBindForm(f => ({ ...f, value: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="form-item form-item-btn">
|
|
<label className="form-label-small"> </label>
|
|
<button className="btn-secondary">获取验证码</button>
|
|
</div>
|
|
</div>
|
|
<div className="form-item form-item-btn">
|
|
<button className="btn-primary">确认绑定</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Login Logs */}
|
|
<section className="profile-section">
|
|
<h3 className="section-title">
|
|
<span className="section-icon">{SvgIcons.log}</span>
|
|
最近登录日志
|
|
</h3>
|
|
<div className="simple-table-wrapper">
|
|
<table className="simple-table">
|
|
<thead>
|
|
<tr>
|
|
<th>操作</th>
|
|
<th>目标</th>
|
|
<th>IP地址</th>
|
|
<th>时间</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loginLogs.map(log => (
|
|
<tr key={log.id}>
|
|
<td>{log.action}</td>
|
|
<td className="td-muted">{log.target}</td>
|
|
<td className="td-mono">{log.ip}</td>
|
|
<td className="td-muted">{formatDate(log.createdAt)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===== Orders Content =====
|
|
interface OrdersContentProps {
|
|
orders: Order[];
|
|
filter: Order['status'] | 'all';
|
|
setFilter: React.Dispatch<React.SetStateAction<Order['status'] | 'all'>>;
|
|
statusLabels: Record<Order['status'] | 'all', string>;
|
|
statusColors: Record<Order['status'], string>;
|
|
typeLabels: Record<Order['orderType'], string>;
|
|
}
|
|
|
|
const OrdersContent: React.FC<OrdersContentProps> = ({ orders, filter, setFilter, statusLabels, statusColors, typeLabels }) => {
|
|
return (
|
|
<div className="tab-panel-content">
|
|
{/* Filter Tabs */}
|
|
<div className="filter-tabs">
|
|
{(Object.keys(statusLabels) as (Order['status'] | 'all')[]).map(key => (
|
|
<button
|
|
key={key}
|
|
className={`filter-tab ${filter === key ? 'active' : ''}`}
|
|
onClick={() => setFilter(key)}
|
|
>
|
|
{statusLabels[key]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="simple-table-wrapper">
|
|
<table className="simple-table">
|
|
<thead>
|
|
<tr>
|
|
<th>订单号</th>
|
|
<th>类型</th>
|
|
<th>商品名</th>
|
|
<th>金额</th>
|
|
<th>支付方式</th>
|
|
<th>状态</th>
|
|
<th>时间</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{orders.map(order => (
|
|
<tr key={order.id}>
|
|
<td className="td-mono">{order.id}</td>
|
|
<td>
|
|
<span className="order-type-tag">{typeLabels[order.orderType]}</span>
|
|
</td>
|
|
<td>{order.itemName}</td>
|
|
<td className="td-price">¥{order.amount.toFixed(2)}</td>
|
|
<td className="td-muted">{order.paymentMethod === 'wechat' ? '微信支付' : '支付宝'}</td>
|
|
<td>
|
|
<span className="status-dot" style={{ backgroundColor: statusColors[order.status] }} />
|
|
{statusLabels[order.status]}
|
|
</td>
|
|
<td className="td-muted">{formatDate(order.createdAt)}</td>
|
|
</tr>
|
|
))}
|
|
{orders.length === 0 && (
|
|
<tr><td colSpan={7} className="td-empty">暂无订单</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===== Membership Content =====
|
|
interface MembershipContentProps {
|
|
user: NonNullable<ReturnType<typeof useAuth>['user']>;
|
|
memberPlan: typeof mockMembershipPlans[0] | undefined;
|
|
}
|
|
|
|
const MembershipContent: React.FC<MembershipContentProps> = ({ user, memberPlan }) => {
|
|
const [autoRenew, setAutoRenew] = useState(user.membership?.autoRenew ?? false);
|
|
|
|
// 使用 memberPlan 或从 user.membership 获取 level,判断是否付费会员
|
|
const effectiveLevel = memberPlan?.level ?? user.membership?.level ?? 0;
|
|
const effectiveName = memberPlan?.name || user.membership?.planName || '免费版';
|
|
const hasPaidMembership = effectiveLevel > 0;
|
|
|
|
return (
|
|
<div className="tab-panel-content">
|
|
<div className="membership-card">
|
|
<div className="membership-card-header">
|
|
<div className="membership-card-icon">
|
|
{SvgIcons.diamond}
|
|
</div>
|
|
<div className="membership-card-title">
|
|
<h2>{effectiveName}</h2>
|
|
{hasPaidMembership && (
|
|
<span className="membership-level-tag">Lv.{effectiveLevel}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{hasPaidMembership && user.membership && (
|
|
<>
|
|
{memberPlan && (
|
|
<div className="membership-stats">
|
|
<div className="membership-stat">
|
|
<span className="stat-label">每日可用Token</span>
|
|
<span className="stat-value">{memberPlan.dailyFreeTokens.toLocaleString()}</span>
|
|
</div>
|
|
<div className="membership-stat">
|
|
<span className="stat-label">会员到期</span>
|
|
<span className="stat-value">{formatShortDate(user.membership.expireAt)}</span>
|
|
</div>
|
|
<div className="membership-stat">
|
|
<span className="stat-label">月费</span>
|
|
<span className="stat-value">¥{memberPlan.price}/月</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!memberPlan && user.membership && (
|
|
<div className="membership-stats">
|
|
<div className="membership-stat">
|
|
<span className="stat-label">会员到期</span>
|
|
<span className="stat-value">{formatShortDate(user.membership.expireAt)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{memberPlan && (
|
|
<div className="membership-features">
|
|
<h4 className="features-title">会员权益</h4>
|
|
<ul className="features-list">
|
|
{memberPlan.features.map((f, i) => (
|
|
<li key={i} className="feature-item">
|
|
<span className="feature-check">{SvgIcons.checkCircle}</span>
|
|
{f}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="membership-auto-renew">
|
|
<span className="auto-renew-label">自动续费</span>
|
|
<label className="toggle-switch">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoRenew}
|
|
onChange={e => setAutoRenew(e.target.checked)}
|
|
/>
|
|
<span className="toggle-slider" />
|
|
</label>
|
|
</div>
|
|
|
|
<div className="membership-actions">
|
|
<button className="btn-primary" onClick={() => { window.location.hash = '#/pricing'; }}>续费会员</button>
|
|
<button className="btn-secondary" onClick={() => { window.location.hash = '#/pricing'; }}>升级会员</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{!hasPaidMembership && (
|
|
<div className="membership-empty">
|
|
<p>您当前使用的是免费版,升级会员享受更多权益</p>
|
|
<button
|
|
className="btn-primary"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
window.location.hash = '#/pricing';
|
|
}}
|
|
>
|
|
开通会员
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===== Coupons Content =====
|
|
interface CouponsContentProps {
|
|
coupons: UserCoupon[];
|
|
filter: UserCoupon['status'] | 'all';
|
|
setFilter: React.Dispatch<React.SetStateAction<UserCoupon['status'] | 'all'>>;
|
|
getDescription: (coupon: UserCoupon['coupon']) => string;
|
|
statusConfig: Record<UserCoupon['status'], { label: string; color: string; bg: string }>;
|
|
}
|
|
|
|
const CouponsContent: React.FC<CouponsContentProps> = ({ coupons, filter, setFilter, getDescription, statusConfig }) => {
|
|
const filterLabels: Record<UserCoupon['status'] | 'all', string> = {
|
|
all: '全部',
|
|
unused: '未使用',
|
|
used: '已使用',
|
|
expired: '已过期',
|
|
};
|
|
|
|
return (
|
|
<div className="tab-panel-content">
|
|
<div className="filter-tabs">
|
|
{(Object.keys(filterLabels) as (UserCoupon['status'] | 'all')[]).map(key => (
|
|
<button
|
|
key={key}
|
|
className={`filter-tab ${filter === key ? 'active' : ''}`}
|
|
onClick={() => setFilter(key)}
|
|
>
|
|
{filterLabels[key]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="coupon-list">
|
|
{coupons.map(uc => {
|
|
const cfg = statusConfig[uc.status];
|
|
return (
|
|
<div key={uc.id} className={`coupon-item ${uc.status !== 'unused' ? 'coupon-disabled' : ''}`}>
|
|
<div className="coupon-left">
|
|
<div className="coupon-code">{uc.coupon.code}</div>
|
|
<div className="coupon-desc">{getDescription(uc.coupon)}</div>
|
|
<div className="coupon-date">
|
|
有效期:{formatShortDate(uc.coupon.validFrom)} ~ {formatShortDate(uc.coupon.validTo)}
|
|
</div>
|
|
</div>
|
|
<div className="coupon-right">
|
|
<span
|
|
className="coupon-status"
|
|
style={{ color: cfg.color, backgroundColor: cfg.bg }}
|
|
>
|
|
{cfg.label}
|
|
</span>
|
|
{uc.status === 'unused' && (
|
|
<button className="btn-coupon-use">去使用</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{coupons.length === 0 && (
|
|
<div className="content-empty">暂无优惠券</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===== Apps Content =====
|
|
interface AppsContentProps {
|
|
apps: MarketApp[];
|
|
purchasedOrders: Order[];
|
|
}
|
|
|
|
const AppsContent: React.FC<AppsContentProps> = ({ apps, purchasedOrders }) => {
|
|
const appTypeLabels: Record<string, string> = {
|
|
skill: '技能',
|
|
agent: '智能体',
|
|
mcp: 'MCP',
|
|
};
|
|
|
|
const findOrderDate = (appName: string) => {
|
|
const order = purchasedOrders.find(o => o.itemName === appName);
|
|
return order ? formatDate(order.createdAt) : '';
|
|
};
|
|
|
|
return (
|
|
<div className="tab-panel-content">
|
|
<div className="apps-grid">
|
|
{apps.map(app => (
|
|
<div key={app.id} className="app-card">
|
|
<div className="app-card-header">
|
|
<div className="app-card-icon">
|
|
<span>{app.icon || app.name.charAt(0)}</span>
|
|
</div>
|
|
<div className="app-card-info">
|
|
<h4 className="app-card-name">{app.name}</h4>
|
|
<div className="app-card-meta">
|
|
<span className="app-type-tag">{appTypeLabels[app.type] || app.type}</span>
|
|
<span className="app-source-tag">{app.source === 'official' ? '官方' : '用户'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p className="app-card-desc">{app.description}</p>
|
|
<div className="app-card-footer">
|
|
<span className="app-purchase-date">
|
|
{SvgIcons.calendar}
|
|
{findOrderDate(app.name)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{apps.length === 0 && (
|
|
<div className="content-empty">暂未购买任何应用</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ===== API Keys Content =====
|
|
interface ApiKeysContentProps {
|
|
apiKeys: ApiKey[];
|
|
showKeyId: string | null;
|
|
setShowKeyId: React.Dispatch<React.SetStateAction<string | null>>;
|
|
copiedId: string | null;
|
|
onCopy: (key: ApiKey) => void;
|
|
onCopyPrefix: (key: ApiKey) => void;
|
|
onGenerate: () => void;
|
|
onToggleStatus: (keyId: string) => void;
|
|
}
|
|
|
|
const ApiKeysContent: React.FC<ApiKeysContentProps> = ({
|
|
apiKeys, showKeyId, setShowKeyId, copiedId, onCopy, onCopyPrefix, onGenerate, onToggleStatus,
|
|
}) => {
|
|
return (
|
|
<div className="tab-panel-content">
|
|
<div className="apikey-header">
|
|
<div className="apikey-info">
|
|
<h3>API密钥</h3>
|
|
<p className="apikey-desc">使用API密钥访问AIFlow接口,请妥善保管您的密钥</p>
|
|
</div>
|
|
<button className="btn-primary" onClick={onGenerate}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
|
</svg>
|
|
生成新API Key
|
|
</button>
|
|
</div>
|
|
|
|
<div className="apikey-list">
|
|
<div className="apikey-list-header">
|
|
<span>密钥前缀</span>
|
|
<span>创建时间</span>
|
|
<span>状态</span>
|
|
<span>操作</span>
|
|
</div>
|
|
{apiKeys.map(key => (
|
|
<div key={key.id} className="apikey-row">
|
|
<div className="apikey-prefix-col">
|
|
<code className="apikey-prefix">{key.prefix}</code>
|
|
{showKeyId === key.id && (
|
|
<div className="apikey-full-reveal">
|
|
<code>{key.fullKey}</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="apikey-date">{formatDate(key.createdAt)}</span>
|
|
<span>
|
|
<span className={`apikey-status ${key.status}`}>
|
|
{key.status === 'active' ? '活跃' : '已禁用'}
|
|
</span>
|
|
</span>
|
|
<div className="apikey-actions">
|
|
<button
|
|
className="apikey-action-btn"
|
|
onClick={() => {
|
|
if (showKeyId === key.id) {
|
|
setShowKeyId(null);
|
|
} else {
|
|
setShowKeyId(key.id);
|
|
onCopyPrefix(key);
|
|
}
|
|
}}
|
|
title={showKeyId === key.id ? '隐藏密钥' : '显示密钥'}
|
|
>
|
|
{SvgIcons.eye}
|
|
</button>
|
|
<button
|
|
className="apikey-action-btn"
|
|
onClick={() => {
|
|
if (showKeyId === key.id || showKeyId === null) {
|
|
onCopy(key);
|
|
} else {
|
|
onCopyPrefix(key);
|
|
}
|
|
}}
|
|
title="复制密钥"
|
|
>
|
|
{copiedId === key.id ? SvgIcons.check : SvgIcons.copy}
|
|
</button>
|
|
<button
|
|
className={`apikey-action-btn ${key.status === 'active' ? 'danger' : ''}`}
|
|
onClick={() => onToggleStatus(key.id)}
|
|
title={key.status === 'active' ? '禁用' : '启用'}
|
|
>
|
|
{key.status === 'active' ? '禁用' : '启用'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProfilePage;
|