Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
@@ -324,9 +324,9 @@ async def _seed_data():
|
|||||||
|
|
||||||
default_menus = [
|
default_menus = [
|
||||||
("/projects", "我的项目", "HomeOutlined", 0, "frontend"),
|
("/projects", "我的项目", "HomeOutlined", 0, "frontend"),
|
||||||
("/records", "生成记录", "PlayCircleOutlined", 1, "frontend"),
|
("/conversation", "AI创作", "StarOutlined", 1, "frontend"),
|
||||||
("/credits", "积分中心", "WalletOutlined", 2, "frontend"),
|
("/records", "项目记录", "PlayCircleOutlined", 2, "frontend"),
|
||||||
("/conversation", "ai对话", "StarOutlined", 3, "frontend"),
|
("/generated", "素材云", "StarOutlined", 3, "frontend"),
|
||||||
]
|
]
|
||||||
for path, label, icon, order, target in default_menus:
|
for path, label, icon, order, target in default_menus:
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class RechargeRequest(BaseModel):
|
class RechargeRequest(BaseModel):
|
||||||
@@ -14,5 +15,7 @@ class PaymentOrderOut(BaseModel):
|
|||||||
payment_method: str
|
payment_method: str
|
||||||
status: str
|
status: str
|
||||||
qr_url: str | None = None # Alipay QR code URL (transient, not persisted)
|
qr_url: str | None = None # Alipay QR code URL (transient, not persisted)
|
||||||
|
created_at: datetime
|
||||||
|
paid_at: datetime | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
Generated
+8
-8
@@ -8,7 +8,7 @@
|
|||||||
"name": "video-gen-app",
|
"name": "video-gen-app",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.2.2",
|
"@ant-design/icons": "^6.2.5",
|
||||||
"antd": "^6.3.7",
|
"antd": "^6.3.7",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"plyr-react": "^6.0.0",
|
"plyr-react": "^6.0.0",
|
||||||
@@ -103,14 +103,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ant-design/icons": {
|
"node_modules/@ant-design/icons": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.5.tgz",
|
||||||
"integrity": "sha512-zlJtE7AMbG12TeYVPhtBXwNpFInNy8mjLzcIm+0BPw16/b8ODG87YJ1G37VIF5VFscdgfsf6EweAFPTobu/3iQ==",
|
"integrity": "sha512-0hKtoKqTjGFOndUyJLJmC9Cg6k4rEO7rLo6xmgbNJH+/ZX1C57RVals2v1j1knHl9n7Q+sBOveTvn931wLOCKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^8.0.1",
|
"@ant-design/colors": "^8.0.1",
|
||||||
"@ant-design/icons-svg": "^4.4.2",
|
"@ant-design/icons-svg": "^4.4.2",
|
||||||
"@rc-component/util": "^1.10.1",
|
"@rc-component/util": "^1.11.0",
|
||||||
"clsx": "^2.1.1"
|
"clsx": "^2.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1430,9 +1430,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rc-component/util": {
|
"node_modules/@rc-component/util": {
|
||||||
"version": "1.10.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.11.1.tgz",
|
||||||
"integrity": "sha512-q++9S6rUa5Idb/xIBNz6jtvumw5+O5YV5V0g4iK9mn9jWs4oGJheE3ZN1kAnE723AXyaD8v95yeOASmdk8Jnng==",
|
"integrity": "sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-mobile": "^5.0.0",
|
"is-mobile": "^5.0.0",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.2.2",
|
"@ant-design/icons": "^6.2.5",
|
||||||
"antd": "^6.3.7",
|
"antd": "^6.3.7",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"plyr-react": "^6.0.0",
|
"plyr-react": "^6.0.0",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import ProjectsPage from './pages/ProjectsPage';
|
|||||||
import GeneratePage from './pages/GeneratePage';
|
import GeneratePage from './pages/GeneratePage';
|
||||||
import RecordsPage from './pages/RecordsPage';
|
import RecordsPage from './pages/RecordsPage';
|
||||||
import CreditsPage from './pages/CreditsPage';
|
import CreditsPage from './pages/CreditsPage';
|
||||||
|
import CreditRecordsPage from './pages/CreditRecordsPage';
|
||||||
|
import OrderRecordsPage from './pages/OrderRecordsPage';
|
||||||
|
import MessagesPage from './pages/MessagesPage';
|
||||||
|
import UserCenterPage from './pages/UserCenterPage';
|
||||||
import GenerateConver from './pages/GenerateConver';
|
import GenerateConver from './pages/GenerateConver';
|
||||||
import InitialReplication from './pages/InitialReplication';
|
import InitialReplication from './pages/InitialReplication';
|
||||||
import InitialInfo from './pages/InitialInfo';
|
import InitialInfo from './pages/InitialInfo';
|
||||||
@@ -94,6 +98,10 @@ const App = () => {
|
|||||||
<Route path="projects/:projectId/generate" element={<GeneratePage />} />
|
<Route path="projects/:projectId/generate" element={<GeneratePage />} />
|
||||||
<Route path="records" element={<RecordsPage />} />
|
<Route path="records" element={<RecordsPage />} />
|
||||||
<Route path="credits" element={<CreditsPage />} />
|
<Route path="credits" element={<CreditsPage />} />
|
||||||
|
<Route path="credit-records" element={<UserCenterPage />} />
|
||||||
|
<Route path="order-records" element={<UserCenterPage />} />
|
||||||
|
<Route path="user-center" element={<UserCenterPage />} />
|
||||||
|
<Route path="messages" element={<MessagesPage />} />
|
||||||
<Route path="conversation" element={<GenerateConver />} />
|
<Route path="conversation" element={<GenerateConver />} />
|
||||||
<Route path="initial" element={<InitialReplication />} />
|
<Route path="initial" element={<InitialReplication />} />
|
||||||
<Route path="initial/:creatID/initialinfo" element={<InitialInfo />} />
|
<Route path="initial/:creatID/initialinfo" element={<InitialInfo />} />
|
||||||
|
|||||||
@@ -24,10 +24,39 @@ import {
|
|||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
WechatOutlined,
|
WechatOutlined,
|
||||||
AlipayCircleOutlined,
|
AlipayCircleOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
StarOutlined,
|
||||||
|
HeartOutlined,
|
||||||
|
CameraOutlined,
|
||||||
|
CalculatorOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
FireOutlined,
|
||||||
|
CloudOutlined,
|
||||||
|
SmileOutlined,
|
||||||
|
TrophyOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
BulbOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
AudioOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
PhoneOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
ShoppingCartOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
PieChartOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
|
SecurityScanOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../store/useAuthStore';
|
import { useAuthStore } from '../../store/useAuthStore';
|
||||||
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
|
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount } from '../../api';
|
||||||
import NotificationPopup from '../NotificationPopup';
|
import NotificationPopup from '../NotificationPopup';
|
||||||
|
|
||||||
interface MenuConfig {
|
interface MenuConfig {
|
||||||
@@ -46,18 +75,44 @@ interface MenuConfig {
|
|||||||
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
HomeOutlined: <HomeOutlined />,
|
HomeOutlined: <HomeOutlined />,
|
||||||
DashboardOutlined:<DashboardOutlined/>,
|
DashboardOutlined: <DashboardOutlined />,
|
||||||
CodeOutlined:<CodeOutlined/>,
|
CodeOutlined: <CodeOutlined />,
|
||||||
PlayCircleOutlined: <PlayCircleOutlined />,
|
PlayCircleOutlined: <PlayCircleOutlined />,
|
||||||
WalletOutlined: <WalletOutlined />,
|
WalletOutlined: <WalletOutlined />,
|
||||||
SettingOutlined: <LockOutlined />,
|
SettingOutlined: <SettingOutlined />,
|
||||||
BellOutlined: <GiftOutlined />,
|
BellOutlined: <BellOutlined />,
|
||||||
UserOutlined: <UserOutlined />,
|
UserOutlined: <UserOutlined />,
|
||||||
AppstoreOutlined: <FireFilled />,
|
AppstoreOutlined: <AppstoreOutlined />,
|
||||||
FileTextOutlined: <FireFilled />,
|
FileTextOutlined: <FileTextOutlined />,
|
||||||
StarOutlined: <StarFilled />,
|
StarOutlined: <StarOutlined />,
|
||||||
HeartOutlined: <FireFilled />,
|
HeartOutlined: <HeartOutlined />,
|
||||||
CameraOutlined: <PlayCircleOutlined />,
|
CameraOutlined: <CameraOutlined />,
|
||||||
|
RobotOutlined: <PlayCircleOutlined />,
|
||||||
|
CalculatorOutlined: <CalculatorOutlined />,
|
||||||
|
DollarOutlined: <DollarOutlined />,
|
||||||
|
GiftOutlined: <GiftOutlined />,
|
||||||
|
ThunderboltOutlined: <ThunderboltOutlined />,
|
||||||
|
FireOutlined: <FireOutlined />,
|
||||||
|
CloudOutlined: <CloudOutlined />,
|
||||||
|
SmileOutlined: <SmileOutlined />,
|
||||||
|
TrophyOutlined: <TrophyOutlined />,
|
||||||
|
RocketOutlined: <RocketOutlined />,
|
||||||
|
BulbOutlined: <BulbOutlined />,
|
||||||
|
PictureOutlined: <PictureOutlined />,
|
||||||
|
VideoCameraOutlined: <VideoCameraOutlined />,
|
||||||
|
AudioOutlined: <AudioOutlined />,
|
||||||
|
MailOutlined: <MailOutlined />,
|
||||||
|
PhoneOutlined: <PhoneOutlined />,
|
||||||
|
GlobalOutlined: <GlobalOutlined />,
|
||||||
|
ShoppingCartOutlined: <ShoppingCartOutlined />,
|
||||||
|
TeamOutlined: <TeamOutlined />,
|
||||||
|
BarChartOutlined: <BarChartOutlined />,
|
||||||
|
PieChartOutlined: <PieChartOutlined />,
|
||||||
|
LineChartOutlined: <LineChartOutlined />,
|
||||||
|
SecurityScanOutlined: <SecurityScanOutlined />,
|
||||||
|
ApiOutlined: <ApiOutlined />,
|
||||||
|
DatabaseOutlined: <DatabaseOutlined />,
|
||||||
|
CloudServerOutlined: <CloudServerOutlined />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXPANDED_W = 240;
|
const EXPANDED_W = 240;
|
||||||
@@ -83,8 +138,6 @@ const AppLayout: React.FC = () => {
|
|||||||
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
||||||
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
|
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||||
const [msgModalOpen, setMsgModalOpen] = useState(false);
|
|
||||||
const [allNotifications, setAllNotifications] = useState<any[]>([]);
|
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [siteName, setSiteName] = useState('VideoGen.AI');
|
const [siteName, setSiteName] = useState('VideoGen.AI');
|
||||||
const [siteLogo, setSiteLogo] = useState('');
|
const [siteLogo, setSiteLogo] = useState('');
|
||||||
@@ -118,10 +171,9 @@ const AppLayout: React.FC = () => {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadNotifications = () => {
|
const loadUnreadCount = () => {
|
||||||
getNotifications().then(data => {
|
getUnreadCount().then(count => {
|
||||||
setAllNotifications(data);
|
setUnreadCount(count);
|
||||||
setUnreadCount(data.filter((n: any) => !(n.isRead ?? n.is_read)).length);
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,7 +256,7 @@ const AppLayout: React.FC = () => {
|
|||||||
if (data.alipay) setPaymentMethod('alipay');
|
if (data.alipay) setPaymentMethod('alipay');
|
||||||
else if (data.wechat) setPaymentMethod('wechat');
|
else if (data.wechat) setPaymentMethod('wechat');
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
loadNotifications();
|
loadUnreadCount();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
|
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
|
||||||
@@ -212,7 +264,12 @@ const AppLayout: React.FC = () => {
|
|||||||
|
|
||||||
const userMenuItems = [
|
const userMenuItems = [
|
||||||
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
|
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
|
||||||
{ key: 'credits', icon: <WalletOutlined />, label: `积分余额: ${user?.credits ?? 0}`, disabled: true },
|
{ key: 'credits', icon: <WalletOutlined />, label: `可用积分: ${user?.credits ?? 0}`, disabled: true },
|
||||||
|
{ type: 'divider' as const },
|
||||||
|
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
|
||||||
|
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
|
||||||
|
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
|
||||||
|
// { key: 'recharge', icon: <PlusCircleOutlined />, label: '充值积分' },
|
||||||
{ type: 'divider' as const },
|
{ type: 'divider' as const },
|
||||||
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
||||||
{ type: 'divider' as const },
|
{ type: 'divider' as const },
|
||||||
@@ -222,6 +279,10 @@ const AppLayout: React.FC = () => {
|
|||||||
const handleUserMenuClick = ({ key }: { key: string }) => {
|
const handleUserMenuClick = ({ key }: { key: string }) => {
|
||||||
if (key === 'logout') { logout(); navigate('/login'); }
|
if (key === 'logout') { logout(); navigate('/login'); }
|
||||||
else if (key === 'changePwd') { setPwdModalOpen(true); }
|
else if (key === 'changePwd') { setPwdModalOpen(true); }
|
||||||
|
else if (key === 'messages') { navigate('/messages'); }
|
||||||
|
else if (key === 'recharge') { setRechargeModalOpen(true); }
|
||||||
|
else if (key === 'myCredits') { navigate('/user-center?tab=credits'); }
|
||||||
|
else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangePwd = async () => {
|
const handleChangePwd = async () => {
|
||||||
@@ -347,45 +408,6 @@ const AppLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Credits pill */}
|
|
||||||
<div onClick={() => navigate('/credits')} style={{
|
|
||||||
margin: collapsed ? '16px auto 4px' : '16px 14px 4px',
|
|
||||||
padding: collapsed ? '10px' : '12px 14px',
|
|
||||||
borderRadius: 12,
|
|
||||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.12))',
|
|
||||||
border: '1px solid rgba(99,102,241,0.15)', cursor: 'pointer', transition: 'all 0.25s',
|
|
||||||
textAlign: collapsed ? 'center' : 'left',
|
|
||||||
}}>
|
|
||||||
{collapsed ? (
|
|
||||||
<WalletOutlined style={{ color: '#818cf8', fontSize: 18 }} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
|
||||||
<WalletOutlined style={{ color: '#818cf8', fontSize: 13 }} />
|
|
||||||
<span style={{ color: 'rgba(203,213,225,0.6)', fontSize: 12 }}>可用积分</span>
|
|
||||||
</div>
|
|
||||||
<span style={{ color: '#fff', fontSize: 22, fontWeight: 800 }}>{user?.credits ?? 0}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recharge button - opens modal */}
|
|
||||||
<div onClick={() => setRechargeModalOpen(true)} style={{
|
|
||||||
margin: collapsed ? '8px auto 4px' : '8px 14px 4px',
|
|
||||||
padding: collapsed ? '10px' : '10px 14px',
|
|
||||||
borderRadius: 12, cursor: 'pointer', textAlign: 'center',
|
|
||||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))',
|
|
||||||
border: '1px solid rgba(99,102,241,0.25)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.3), rgba(139,92,246,0.3))'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))'; }}
|
|
||||||
>
|
|
||||||
<PlusOutlined style={{ color: '#818cf8', fontSize: 13 }} />
|
|
||||||
{!collapsed && <span style={{ color: '#818cf8', fontSize: 13, fontWeight: 600 }}>充值积分</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Menu */}
|
{/* Menu */}
|
||||||
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
|
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
@@ -415,13 +437,13 @@ const AppLayout: React.FC = () => {
|
|||||||
gap: collapsed ? 0 : 10,
|
gap: collapsed ? 0 : 10,
|
||||||
padding: collapsed ? '10px 0' : depth > 0 ? '8px 14px 8px 32px' : '10px 14px',
|
padding: collapsed ? '10px 0' : depth > 0 ? '8px 14px 8px 32px' : '10px 14px',
|
||||||
borderRadius: 10, margin: '2px 0', cursor: 'pointer',
|
borderRadius: 10, margin: '2px 0', cursor: 'pointer',
|
||||||
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
|
fontSize: depth > 0 ? 12 : 13, fontWeight: isActive ? 600 : 400,
|
||||||
color: isActive ? '#fff' : 'rgba(148,163,184,0.75)',
|
color: isActive ? '#fff' : 'rgba(148,163,184,0.75)',
|
||||||
background: isActive ? 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))' : 'transparent',
|
background: isActive ? 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))' : 'transparent',
|
||||||
border: isActive ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
|
border: isActive ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: depth > 0 ? 15 : 17, flexShrink: 0 }}>{menuIcon}</span>
|
<span style={{ fontSize: depth > 0 ? 14 : 16, flexShrink: 0 }}>{menuIcon}</span>
|
||||||
{!collapsed && <span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>}
|
{!collapsed && <span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -461,31 +483,23 @@ const AppLayout: React.FC = () => {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message button */}
|
{/* Recharge button - opens modal */}
|
||||||
<div onClick={() => { setMsgModalOpen(true); loadNotifications(); }} style={{
|
<div onClick={() => setRechargeModalOpen(true)} style={{
|
||||||
margin: collapsed ? '4px auto' : '4px 14px',
|
margin: collapsed ? '4px auto' : '4px 14px',
|
||||||
padding: collapsed ? '10px' : '10px 14px',
|
padding: collapsed ? '10px' : '10px 14px',
|
||||||
borderRadius: 12, cursor: 'pointer',
|
borderRadius: 12, cursor: 'pointer',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
|
display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
gap: collapsed ? 0 : 10,
|
gap: collapsed ? 0 : 10,
|
||||||
color: 'rgba(148,163,184,0.75)', fontSize: 14,
|
color: '#818cf8', fontSize: 13, fontWeight: 600,
|
||||||
background: 'rgba(255,255,255,0.03)',
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))',
|
||||||
|
border: '1px solid rgba(99,102,241,0.25)',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.3), rgba(139,92,246,0.3))'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.03)'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))'; }}
|
||||||
>
|
>
|
||||||
<span style={{ position: 'relative' }}>
|
<PlusOutlined style={{ fontSize: 14 }} />
|
||||||
<BellOutlined style={{ fontSize: 16 }} />
|
{!collapsed && <span>充值积分</span>}
|
||||||
{unreadCount > 0 && (
|
|
||||||
<span style={{
|
|
||||||
position: 'absolute', top: -6, right: -8,
|
|
||||||
background: '#ef4444', color: '#fff', fontSize: 10, fontWeight: 700,
|
|
||||||
borderRadius: 10, padding: '0 5px', lineHeight: '16px', minWidth: 16, textAlign: 'center',
|
|
||||||
}}>{unreadCount > 99 ? '99+' : unreadCount}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{!collapsed && <span>消息中心</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User block at bottom-left */}
|
{/* User block at bottom-left */}
|
||||||
@@ -509,7 +523,7 @@ const AppLayout: React.FC = () => {
|
|||||||
<div style={{ color: '#e2e8f0', fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<div style={{ color: '#e2e8f0', fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{user?.username}
|
{user?.username}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: 'rgba(148,163,184,0.5)', fontSize: 11 }}>查看账号</div>
|
<div style={{ color: 'rgba(148,163,184,0.5)', fontSize: 11 }}>可用积分:{user?.credits || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -894,117 +908,6 @@ const AppLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Message Center Modal */}
|
|
||||||
<Modal
|
|
||||||
open={msgModalOpen}
|
|
||||||
onCancel={() => { setMsgModalOpen(false); }}
|
|
||||||
footer={null} width={520}
|
|
||||||
closable={false}
|
|
||||||
styles={{
|
|
||||||
body: { padding: 0, borderRadius: 16, overflow: 'hidden' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{
|
|
||||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
|
||||||
padding: '20px 24px',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 36, height: 36, borderRadius: 10,
|
|
||||||
background: 'rgba(255,255,255,0.2)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
}}>
|
|
||||||
<BellOutlined style={{ fontSize: 18, color: '#fff' }} />
|
|
||||||
</div>
|
|
||||||
<span style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>消息中心</span>
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<span style={{
|
|
||||||
background: 'rgba(255,255,255,0.25)', color: '#fff',
|
|
||||||
fontSize: 11, fontWeight: 600, borderRadius: 10,
|
|
||||||
padding: '2px 10px', backdropFilter: 'blur(4px)',
|
|
||||||
}}>{unreadCount} 条未读</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button type="text" icon={<CloseOutlined style={{ color: 'rgba(255,255,255,0.8)', fontSize: 16 }} />}
|
|
||||||
onClick={() => setMsgModalOpen(false)}
|
|
||||||
style={{ color: '#fff' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* List */}
|
|
||||||
<div style={{ maxHeight: 460, overflow: 'auto', padding: '12px 16px 16px' }}>
|
|
||||||
{allNotifications.length === 0 ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
|
||||||
<BellOutlined style={{ fontSize: 40, color: '#cbd5e1', marginBottom: 12 }} />
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: 14 }}>暂无消息</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
allNotifications.map((n: any) => {
|
|
||||||
const isRead = n.isRead ?? n.is_read;
|
|
||||||
const typeConfig: Record<string, { color: string; bg: string; label: string }> = {
|
|
||||||
system: { color: '#6366f1', bg: 'rgba(99,102,241,0.1)', label: '系统' },
|
|
||||||
credit: { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)', label: '积分' },
|
|
||||||
promo: { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)', label: '活动' },
|
|
||||||
};
|
|
||||||
const tc = typeConfig[n.type] || typeConfig.system;
|
|
||||||
return (
|
|
||||||
<div key={n.id} style={{
|
|
||||||
padding: '16px', borderRadius: 12, marginBottom: 8,
|
|
||||||
background: isRead ? '#fff' : 'linear-gradient(135deg, rgba(99,102,241,0.03), rgba(139,92,246,0.03))',
|
|
||||||
border: isRead ? '1px solid #f0f0f5' : '1px solid rgba(99,102,241,0.18)',
|
|
||||||
cursor: isRead ? 'default' : 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
position: 'relative',
|
|
||||||
}} onClick={async () => {
|
|
||||||
if (!isRead) {
|
|
||||||
await markNotificationRead(n.id);
|
|
||||||
loadNotifications();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.04)'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
|
||||||
{/* Type icon */}
|
|
||||||
<div style={{
|
|
||||||
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
|
||||||
background: tc.bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
<BellOutlined style={{ color: tc.color, fontSize: 15 }} />
|
|
||||||
</div>
|
|
||||||
{/* Content */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
{!isRead && <span style={{
|
|
||||||
width: 7, height: 7, borderRadius: '50%',
|
|
||||||
background: '#6366f1', flexShrink: 0,
|
|
||||||
boxShadow: '0 0 6px rgba(99,102,241,0.4)',
|
|
||||||
}} />}
|
|
||||||
<span style={{ fontWeight: 600, fontSize: 14, color: '#1e293b' }}>{n.title}</span>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 11, color: '#94a3b8', flexShrink: 0 }}>
|
|
||||||
{n.createdAt ? n.createdAt.replace('T', ' ').slice(0, 16) : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 13, color: '#64748b', lineHeight: 1.7,
|
|
||||||
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
|
||||||
}}>{n.content}</div>
|
|
||||||
<Tag color={tc.color} style={{ marginTop: 8, borderRadius: 6, border: 'none', fontSize: 11, padding: '1px 8px' }}>
|
|
||||||
{tc.label}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<NotificationPopup />
|
<NotificationPopup />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const consumptionTypeConfig = {
|
|||||||
|
|
||||||
// 表头配置
|
// 表头配置
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '序号', dataIndex: 'index', key: 'index', width: 80, fixed: 'left', render: (text: number) => <span style={{ color: '#94a3b8' }}>{text}</span> },
|
{ title: '序号', dataIndex: 'index', key: 'index', width: 80, fixed: 'left' as const, render: (text: number) => <span style={{ color: '#94a3b8' }}>{text}</span> },
|
||||||
{ title: '消耗ID', dataIndex: 'id', key: 'id', ellipsis: true, render: (text: string) => <span style={{ fontWeight: 500, color: '#1e293b' }}>{text}</span> },
|
{ title: '消耗ID', dataIndex: 'id', key: 'id', ellipsis: true, render: (text: string) => <span style={{ fontWeight: 500, color: '#1e293b' }}>{text}</span> },
|
||||||
{ title: '授权ID', dataIndex: 'authorizationId', key: 'authorizationId', ellipsis: true },
|
{ title: '授权ID', dataIndex: 'authorizationId', key: 'authorizationId', ellipsis: true },
|
||||||
{
|
{
|
||||||
@@ -52,7 +52,7 @@ const columns = [
|
|||||||
render: (text: number) => <span style={{ color: '#ef4444', fontWeight: 500 }}>{text} 元</span>
|
render: (text: number) => <span style={{ color: '#ef4444', fontWeight: 500 }}>{text} 元</span>
|
||||||
},
|
},
|
||||||
{ title: '消耗描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
{ title: '消耗描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
||||||
{ title: '消耗时间', dataIndex: 'createdAt', key: 'createdAt', width: 160, fixed: 'right' },
|
{ title: '消耗时间', dataIndex: 'createdAt', key: 'createdAt', width: 160, fixed: 'right' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ConsumePage: React.FC = () => {
|
const ConsumePage: React.FC = () => {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Table, Tag, Empty, Spin, Typography, Row, Col } from 'antd';
|
||||||
|
import { WalletOutlined, PlusCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||||
|
import { getCredits } from '../api';
|
||||||
|
|
||||||
|
const CreditRecordsPage: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [records, setRecords] = useState<any[]>([]);
|
||||||
|
const [credits, setCredits] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getCredits();
|
||||||
|
setRecords(data.records || []);
|
||||||
|
setCredits(data.credits || 0);
|
||||||
|
} catch {
|
||||||
|
setRecords([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '变动类型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
width: 120,
|
||||||
|
render: (text: string) => {
|
||||||
|
const typeConfig: Record<string, { color: string; label: string; bg: string }> = {
|
||||||
|
recharge: { color: '#10b981', label: '充值', bg: 'rgba(16,185,129,0.1)' },
|
||||||
|
consume: { color: '#ef4444', label: '消费', bg: 'rgba(239,68,68,0.1)' },
|
||||||
|
admin: { color: '#f59e0b', label: '管理员调整', bg: 'rgba(245,158,11,0.1)' },
|
||||||
|
refund: { color: '#8b5cf6', label: '退款', bg: 'rgba(139,92,246,0.1)' },
|
||||||
|
};
|
||||||
|
const config = typeConfig[text] || { color: '#64748b', label: text, bg: 'rgba(100,116,139,0.1)' };
|
||||||
|
return (
|
||||||
|
<Tag color={config.color} style={{ background: config.bg, fontWeight: 500, padding: '4px 12px' }}>
|
||||||
|
{config.label}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '描述',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '变动积分',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
key: 'amount',
|
||||||
|
width: 120,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (amount: number) => (
|
||||||
|
<Typography.Text strong style={{ fontWeight: 700, color: amount > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
|
||||||
|
{amount > 0 ? '+' : ''}{amount}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record: any) => {
|
||||||
|
const createdAt = record.created_at || record.createdAt || record.create_time || record.createTime;
|
||||||
|
return createdAt ? new Date(createdAt).toLocaleString('zh-CN') : '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<div style={{
|
||||||
|
borderRadius: 16, padding: 24,
|
||||||
|
background: 'linear-gradient(135deg, #f59e0b 0%, #ea580c 100%)',
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ position: 'absolute', right: -20, top: -20, width: 120, height: 120, borderRadius: '50%', background: 'rgba(255,255,255,0.1)' }} />
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||||
|
<WalletOutlined style={{ color: 'rgba(255,255,255,0.8)', fontSize: 16 }} />
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}>可用积分</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: 38, fontWeight: 800, lineHeight: 1 }}>
|
||||||
|
{credits.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8}>
|
||||||
|
<div style={{
|
||||||
|
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(16,185,129,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<PlusCircleOutlined style={{ color: '#10b981', fontSize: 14 }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#94a3b8', fontSize: 14 }}>累计获得</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#10b981', fontSize: 28, fontWeight: 800 }}>
|
||||||
|
{records.filter((r: any) => r.amount > 0).reduce((sum: number, r: any) => sum + r.amount, 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8}>
|
||||||
|
<div style={{
|
||||||
|
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(239,68,68,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ThunderboltOutlined style={{ color: '#ef4444', fontSize: 14 }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#94a3b8', fontSize: 14 }}>累计消耗</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#ef4444', fontSize: 28, fontWeight: 800 }}>
|
||||||
|
{records.filter((r: any) => r.amount < 0).reduce((sum: number, r: any) => sum + Math.abs(r.amount), 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<ThunderboltOutlined style={{ color: '#f59e0b', fontSize: 16 }} />
|
||||||
|
<Typography.Text strong style={{ fontSize: 16 }}>积分明细</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{records.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description={<span style={{ color: '#94a3b8' }}>暂无积分变动记录</span>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
dataSource={records}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={{ pageSize: 10, size: 'small' }}
|
||||||
|
bordered={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreditRecordsPage;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Table, Tag, Empty, Spin, Typography, Button } from 'antd';
|
||||||
|
import { BellOutlined, SettingOutlined, WalletOutlined, GiftOutlined } from '@ant-design/icons';
|
||||||
|
import { getNotifications, markNotificationRead } from '../api';
|
||||||
|
|
||||||
|
const MessagesPage: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [notifications, setNotifications] = useState<any[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getNotifications();
|
||||||
|
setNotifications(data || []);
|
||||||
|
setUnreadCount(data.filter((n: any) => !(n.isRead ?? n.is_read)).length);
|
||||||
|
} catch {
|
||||||
|
setNotifications([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkRead = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await markNotificationRead(id);
|
||||||
|
loadData();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
width: 100,
|
||||||
|
render: (text: string) => {
|
||||||
|
const typeConfig: Record<string, { color: string; label: string; bg: string; icon: React.ReactNode }> = {
|
||||||
|
system: { color: '#6366f1', label: '系统', bg: 'rgba(99,102,241,0.1)', icon: <SettingOutlined style={{ fontSize: 14 }} /> },
|
||||||
|
credit: { color: '#f59e0b', label: '积分', bg: 'rgba(245,158,11,0.1)', icon: <WalletOutlined style={{ fontSize: 14 }} /> },
|
||||||
|
promo: { color: '#8b5cf6', label: '活动', bg: 'rgba(139,92,246,0.1)', icon: <GiftOutlined style={{ fontSize: 14 }} /> },
|
||||||
|
};
|
||||||
|
const config = typeConfig[text] || typeConfig.system;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: config.color }}>{config.icon}</span>
|
||||||
|
<span style={{ fontSize: 13, color: config.color, fontWeight: 500 }}>{config.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '内容',
|
||||||
|
dataIndex: 'content',
|
||||||
|
key: 'content',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'isRead',
|
||||||
|
key: 'isRead',
|
||||||
|
width: 80,
|
||||||
|
render: (text: boolean, record: any) => {
|
||||||
|
const isRead = text ?? record.is_read;
|
||||||
|
return isRead ? (
|
||||||
|
<Tag color="gray" style={{ background: 'rgba(148,163,184,0.1)' }}>已读</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="purple" style={{ background: 'rgba(139,92,246,0.1)' }}>未读</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 160,
|
||||||
|
render: (text: string) => {
|
||||||
|
if (!text) return '-';
|
||||||
|
const date = new Date(text);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
if (hours < 1) return '刚刚';
|
||||||
|
if (hours < 24) return `${hours}小时前`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) return `${days}天前`;
|
||||||
|
return date.toLocaleDateString('zh-CN');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record: any) => {
|
||||||
|
const isRead = record.isRead ?? record.is_read;
|
||||||
|
return !isRead ? (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleMarkRead(record.id)}
|
||||||
|
style={{ color: '#6366f1' }}
|
||||||
|
>
|
||||||
|
标记已读
|
||||||
|
</Button>
|
||||||
|
) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<BellOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||||
|
<Typography.Text strong style={{ fontSize: 16 }}>消息中心</Typography.Text>
|
||||||
|
<span style={{ color: '#94a3b8', fontSize: 13 }}>未读 {unreadCount} 条</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description={<span style={{ color: '#94a3b8' }}>暂无消息</span>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
dataSource={notifications}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={{ pageSize: 10, size: 'small' }}
|
||||||
|
bordered={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessagesPage;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Table, Tag, Empty, Spin, Typography } from 'antd';
|
||||||
|
import { FileTextOutlined, AlipayCircleOutlined, WechatOutlined } from '@ant-design/icons';
|
||||||
|
import { getPaymentOrders } from '../api';
|
||||||
|
|
||||||
|
const OrderRecordsPage: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [orders, setOrders] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getPaymentOrders();
|
||||||
|
setOrders(data || []);
|
||||||
|
} catch {
|
||||||
|
setOrders([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '订单号',
|
||||||
|
key: 'orderNo',
|
||||||
|
width: 200,
|
||||||
|
render: (_, record: any) => {
|
||||||
|
const orderNo = record.order_no || record.orderNo || record.id;
|
||||||
|
return (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: 13, color: '#64748b' }}>
|
||||||
|
{orderNo}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付方式',
|
||||||
|
key: 'paymentMethod',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record: any) => {
|
||||||
|
const method = record.payment_method || record.paymentMethod || 'wechat';
|
||||||
|
const methodConfig: Record<string, { icon: React.ReactNode; label: string; color: string }> = {
|
||||||
|
alipay: { icon: <AlipayCircleOutlined style={{ fontSize: 16 }} />, label: '支付宝', color: '#1677ff' },
|
||||||
|
wechat: { icon: <WechatOutlined style={{ fontSize: 16 }} />, label: '微信支付', color: '#07c160' },
|
||||||
|
};
|
||||||
|
const config = methodConfig[method] || methodConfig.wechat;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: config.color }}>{config.icon}</span>
|
||||||
|
<span style={{ fontSize: 13, color: '#374151' }}>{config.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
key: 'amount',
|
||||||
|
width: 100,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (_, record: any) => {
|
||||||
|
const amount = record.amount || record.total_amount || 0;
|
||||||
|
return (
|
||||||
|
<Typography.Text strong style={{ fontWeight: 700, fontSize: 14, color: '#1e293b' }}>
|
||||||
|
¥{amount}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '获得积分',
|
||||||
|
key: 'credits',
|
||||||
|
width: 100,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_, record: any) => {
|
||||||
|
const credits = record.credits || record.credit_amount || 0;
|
||||||
|
return (
|
||||||
|
<Typography.Text strong style={{ fontWeight: 600, fontSize: 14, color: '#f59e0b' }}>
|
||||||
|
{credits}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record: any) => {
|
||||||
|
const status = record.status || 'pending';
|
||||||
|
const statusConfig: Record<string, { color: string; label: string; bg: string }> = {
|
||||||
|
pending: { color: '#f59e0b', label: '待支付', bg: 'rgba(245,158,11,0.1)' },
|
||||||
|
paid: { color: '#10b981', label: '已支付', bg: 'rgba(16,185,129,0.1)' },
|
||||||
|
refunded: { color: '#94a3b8', label: '已退款', bg: 'rgba(148,163,184,0.1)' },
|
||||||
|
failed: { color: '#ef4444', label: '支付失败', bg: 'rgba(239,68,68,0.1)' },
|
||||||
|
cancelled: { color: '#64748b', label: '已取消', bg: 'rgba(100,116,139,0.1)' },
|
||||||
|
processing: { color: '#0ea5e9', label: '处理中', bg: 'rgba(14,165,233,0.1)' },
|
||||||
|
};
|
||||||
|
const config = statusConfig[status] || { color: '#64748b', label: status, bg: 'rgba(100,116,139,0.1)' };
|
||||||
|
return (
|
||||||
|
<Tag color={config.color} style={{ background: config.bg, fontWeight: 500, padding: '4px 12px' }}>
|
||||||
|
{config.label}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record: any) => {
|
||||||
|
const createdAt = record.created_at || record.createdAt;
|
||||||
|
return createdAt ? new Date(createdAt).toLocaleString('zh-CN') : '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付时间',
|
||||||
|
key: 'paidAt',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record: any) => {
|
||||||
|
const paidAt = record.paid_at || record.paidAt;
|
||||||
|
return paidAt ? new Date(paidAt).toLocaleString('zh-CN') : '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<FileTextOutlined style={{ color: '#0ea5e9', fontSize: 16 }} />
|
||||||
|
<Typography.Text strong style={{ fontSize: 16 }}>订单记录</Typography.Text>
|
||||||
|
<span style={{ color: '#94a3b8', fontSize: 13 }}>共 {orders.length} 条记录</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description={<span style={{ color: '#94a3b8' }}>暂无订单记录</span>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
dataSource={orders}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="order_no"
|
||||||
|
pagination={{ pageSize: 10, size: 'small' }}
|
||||||
|
bordered={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrderRecordsPage;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Tabs } from 'antd';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import CreditRecordsPage from './CreditRecordsPage';
|
||||||
|
import OrderRecordsPage from './OrderRecordsPage';
|
||||||
|
|
||||||
|
const UserCenterPage: React.FC = () => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [activeTab, setActiveTab] = useState('credits');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tab = searchParams.get('tab') || 'credits';
|
||||||
|
if (tab === 'orders') {
|
||||||
|
setActiveTab('orders');
|
||||||
|
} else {
|
||||||
|
setActiveTab('credits');
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: 'credits',
|
||||||
|
label: '积分明细',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'orders',
|
||||||
|
label: '订单记录',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={tabs}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
{activeTab === 'credits' && <CreditRecordsPage />}
|
||||||
|
{activeTab === 'orders' && <OrderRecordsPage />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserCenterPage;
|
||||||
Reference in New Issue
Block a user