Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
Vendored
+152
-109
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
@import "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap";:root{--font-sans:"Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--nav-bg:#08080c;--nav-surface:#ffffff0a;--nav-border:#ffffff0f;--nav-text:#e8e8ec;--nav-text-muted:#8b8fa3;--nav-hover:#ffffff14;--nav-active:#ffffff1f}*,:before,:after{box-sizing:border-box}html,body{font-family:var(--font-sans);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;color:#1a1a2e;letter-spacing:-.01em;background:#f8f9fc;margin:0;padding:0}#root{min-height:100vh}
|
||||
Vendored
+2
-2
@@ -28,8 +28,8 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CnO3FgtX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
<script type="module" crossorigin src="/assets/index-CT8OKkEB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+82
@@ -13,6 +13,7 @@
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"xlsx-js-style": "^1.2.0",
|
||||
"zustand": "^5.0.13"
|
||||
@@ -1428,6 +1429,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/exit-on-epipe": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||
@@ -1437,6 +1444,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1761,6 +1774,25 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
@@ -1780,6 +1812,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1841,6 +1879,35 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/quill": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"parchment": "^3.0.0",
|
||||
"quill-delta": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-diff": "^1.3.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isequal": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
@@ -1868,6 +1935,21 @@
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-quill-new": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
|
||||
"integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21",
|
||||
"quill": "~2.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"quill-delta": "^5.1.0",
|
||||
"react": "^16 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"xlsx-js-style": "^1.2.0",
|
||||
"zustand": "^5.0.13"
|
||||
|
||||
@@ -136,13 +136,37 @@ const AdminDashboard: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* 视频参数分布 */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>视频参数分布</Typography.Text>
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={8}>
|
||||
<ChartCard title="分辨率分布" loading={loading}>
|
||||
<PieBarChart data={stats?.videoResolutionUsage || []} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<ChartCard title="画面比例分布" loading={loading}>
|
||||
<PieBarChart data={stats?.videoRatioUsage || []} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<ChartCard title="时长分布" loading={loading}>
|
||||
<PieBarChart data={stats?.videoDurationUsage || []} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 图表卡片 ──
|
||||
const ChartCard: React.FC<{ title: string; loading: boolean; children: React.ReactNode }> = ({ title, loading, children }) => (
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', height: '100%' }}
|
||||
<Card bordered={false} style={{ borderRadius: 16, border: '1px solid #f0f0f5', height: '100%', boxShadow: '0 4px 20px rgba(0,0,0,0.04)', transition: 'box-shadow 0.3s' }}
|
||||
styles={{ body: { padding: '16px' } }}>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>{title}</Typography.Text>
|
||||
@@ -186,19 +210,35 @@ const LineChart: React.FC<{ data: { date: string; module: string; credits: numbe
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 4, borderBottom: '1px solid #f1f5f9', paddingBottom: 4 }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 6, borderBottom: '1px solid #f1f5f9', paddingBottom: 4 }}>
|
||||
{sevenDays.map(d => {
|
||||
const val = dateMap.get(d) || 0;
|
||||
const pct = (val / maxVal) * 100;
|
||||
return (
|
||||
<div key={d} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%', justifyContent: 'flex-end' }}>
|
||||
<div key={d} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%', justifyContent: 'flex-end', position: 'relative' }}
|
||||
onMouseEnter={e => {
|
||||
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
|
||||
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
|
||||
if (bar) { bar.style.transform = 'scaleY(1.08)'; bar.style.filter = 'brightness(1.15) drop-shadow(0 4px 12px rgba(99,102,241,0.4))'; }
|
||||
if (tip) { tip.style.opacity = '1'; }
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
|
||||
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
|
||||
if (bar) { bar.style.transform = 'scaleY(1)'; bar.style.filter = 'none'; }
|
||||
if (tip) { tip.style.opacity = '0'; }
|
||||
}}
|
||||
>
|
||||
<div className="tip" style={{ position: 'absolute', bottom: '100%', marginBottom: 6, background: '#1e293b', color: '#fff', fontSize: 11, padding: '4px 10px', borderRadius: 6, whiteSpace: 'nowrap', opacity: 0, transition: 'opacity 0.2s', pointerEvents: 'none', zIndex: 10 }}>
|
||||
{val > 0 ? `${val.toFixed(0)} 积分` : '无数据'}
|
||||
</div>
|
||||
<span style={{ fontSize: 9, color: '#6366f1', fontWeight: 600, marginBottom: 2 }}>{val > 0 ? val.toFixed(0) : ''}</span>
|
||||
<div style={{ width: '65%', maxWidth: 32, height: `${Math.max(pct, 2)}%`, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: '3px 3px 0 0', minHeight: 3, transition: 'height 0.3s' }} />
|
||||
<div className="bar" style={{ width: '65%', maxWidth: 32, height: `${Math.max(pct, 2)}%`, background: 'linear-gradient(180deg, #818cf8 0%, #6366f1 40%, #4f46e5 100%)', borderRadius: '4px 4px 0 0', minHeight: 4, transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: '0 2px 8px rgba(99,102,241,0.25)' }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||
{sevenDays.map(d => (
|
||||
<div key={d} style={{ flex: 1, textAlign: 'center' }}>
|
||||
<span style={{ fontSize: 9, color: '#94a3b8' }}>{d.slice(5)}</span>
|
||||
@@ -243,9 +283,27 @@ const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0 }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0, filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.08))' }}>
|
||||
<defs>
|
||||
{slices.map((s, i) => (
|
||||
<path key={i} d={s.d} fill={s.color} stroke="#fff" strokeWidth={2} />
|
||||
<linearGradient key={i} id={`pie-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={s.color} stopOpacity={1} />
|
||||
<stop offset="100%" stopColor={s.color} stopOpacity={0.7} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{slices.map((s, i) => (
|
||||
<path key={i} d={s.d} fill={`url(#pie-grad-${i})`} stroke="#fff" strokeWidth={2}
|
||||
onMouseEnter={e => {
|
||||
(e.target as SVGPathElement).style.transform = 'scale(1.06)';
|
||||
(e.target as SVGPathElement).style.filter = 'brightness(1.1) drop-shadow(0 4px 8px rgba(0,0,0,0.2))';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.target as SVGPathElement).style.transform = 'scale(1)';
|
||||
(e.target as SVGPathElement).style.filter = 'none';
|
||||
}}
|
||||
style={{ transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', transformOrigin: `${cx}px ${cy}px`, cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
<circle cx={cx} cy={cy} r={36} fill="#fff" />
|
||||
<text x={cx} y={cy - 4} textAnchor="middle" fontSize={11} fill="#64748b">总计</text>
|
||||
@@ -253,8 +311,11 @@ const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({
|
||||
</svg>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{slices.map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#64748b' }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: s.color, flexShrink: 0 }} />
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#64748b', padding: '3px 6px', borderRadius: 6, transition: 'background-color 0.2s', cursor: 'default' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#f8fafc'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: s.color, flexShrink: 0, boxShadow: `0 2px 4px ${s.color}40` }} />
|
||||
<span>{s.label}</span>
|
||||
<span style={{ fontWeight: 600, color: '#1e293b' }}>{s.val.toFixed(0)}</span>
|
||||
<span style={{ fontSize: 10 }}>{(s.pct * 100).toFixed(1)}%</span>
|
||||
@@ -271,19 +332,26 @@ const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: stri
|
||||
const maxVal = Math.max(...data.map(d => (d as any)[valueKey] || 0), 1);
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto' }}>
|
||||
<div style={{ height: 220, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', paddingRight: 4 }}>
|
||||
{data.map((d, i) => {
|
||||
const label = d.teamName || d.modelName || '-';
|
||||
const val = (d as any)[valueKey] || 0;
|
||||
const pct = (val / maxVal) * 100;
|
||||
const c1 = COLORS[i % COLORS.length];
|
||||
const c2 = COLORS[(i + 1) % COLORS.length];
|
||||
return (
|
||||
<div key={i}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<span style={{ fontSize: 12, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{label}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#1e293b' }}>{val.toLocaleString()}</span>
|
||||
<div key={i} style={{ padding: '4px 8px', borderRadius: 8, transition: 'background-color 0.2s, box-shadow 0.2s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#fafbff'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(99,102,241,0.08)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%', fontWeight: 500 }}>{label}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#1e293b' }}>{val.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ height: 18, background: '#f1f5f9', borderRadius: 6, overflow: 'hidden', boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.06)' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: `linear-gradient(90deg, ${c1}, ${c2})`, borderRadius: 6, transition: 'width 0.35s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: `0 1px 3px ${c1}40`, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '50%', background: 'linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)', borderRadius: '6px 6px 0 0' }} />
|
||||
</div>
|
||||
<div style={{ height: 16, background: '#f1f5f9', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: `linear-gradient(90deg, ${COLORS[i % COLORS.length]}, ${COLORS[(i + 1) % COLORS.length]})`, borderRadius: 4, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -292,7 +360,62 @@ const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: stri
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyChart = () => (
|
||||
// ── 视频参数分布(紧凑饼图+列表)──
|
||||
const PieBarChart: React.FC<{ data: { model: string; label: string; count: number }[] }> = ({ data }) => {
|
||||
if (!data.length) return <EmptyChart />;
|
||||
// 按模型分组
|
||||
const modelMap = new Map<string, { label: string; count: number }[]>();
|
||||
data.forEach(d => {
|
||||
if (!modelMap.has(d.model)) modelMap.set(d.model, []);
|
||||
modelMap.get(d.model)!.push({ label: d.label, count: d.count });
|
||||
});
|
||||
const models = Array.from(modelMap.entries());
|
||||
const total = data.reduce((s, d) => s + d.count, 0) || 1;
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, overflowY: 'auto' }}>
|
||||
{models.map(([model, items], mi) => {
|
||||
const modelTotal = items.reduce((s, it) => s + it.count, 0);
|
||||
return (
|
||||
<div key={model} style={{ marginBottom: 12 }}>
|
||||
{/* 模型名称 + 总数 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: COLORS[mi % COLORS.length] }}>{model}</span>
|
||||
<span style={{ fontSize: 10, color: '#94a2b3' }}>共 {modelTotal} 条 ({((modelTotal / total) * 100).toFixed(1)}%)</span>
|
||||
</div>
|
||||
{/* 各参数 */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{
|
||||
flex: '0 0 calc(50% - 2px)',
|
||||
padding: '4px 8px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #f1f5f9',
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onMouseEnter={e => { Object.assign(e.currentTarget.style, { background: '#fafbff', boxShadow: '0 2px 8px rgba(99,102,241,0.1)', transform: 'translateY(-1px)' }); }}
|
||||
onMouseLeave={e => { Object.assign(e.currentTarget.style, { background: '#f8fafc', boxShadow: 'none', transform: 'translateY(0)' }); }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#475569', fontWeight: 500 }}>{item.label}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#1e293b' }}>{item.count}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: '#e2e8f0', borderRadius: 2, marginTop: 3, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${(item.count / modelTotal) * 100}%`, background: COLORS[mi % COLORS.length], borderRadius: 2, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyChart: React.FC = () => (
|
||||
<div style={{ height: 220, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>
|
||||
暂无数据
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
import {
|
||||
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactQuill from 'react-quill-new';
|
||||
import 'react-quill-new/dist/quill.snow.css';
|
||||
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
@@ -131,6 +133,7 @@ const AdminNotificationManager: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '内容', dataIndex: 'content', ellipsis: true,
|
||||
render: (v: string) => <div style={{ maxWidth: 300 }} dangerouslySetInnerHTML={{ __html: v }} />,
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 80,
|
||||
@@ -210,8 +213,8 @@ const AdminNotificationManager: React.FC = () => {
|
||||
<Input placeholder="请输入消息标题" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="消息内容"
|
||||
rules={[{ required: true, message: '请输入内容' }]}>
|
||||
<Input.TextArea rows={4} placeholder="请输入消息内容" size="large" />
|
||||
rules={[{ required: true, validator: (_, v) => v && v !== '<p><br></p>' ? Promise.resolve() : Promise.reject('请输入内容') }]}>
|
||||
<ReactQuill theme="snow" placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
|
||||
|
||||
@@ -217,6 +217,12 @@ export interface ModelUsageOut {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface VideoParamOut {
|
||||
model: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
totalUsers: number;
|
||||
totalProjects: number;
|
||||
@@ -236,6 +242,9 @@ export interface AdminStats {
|
||||
periodCreditsByModule: DailyCredit[];
|
||||
creditsByTeam: TeamCredit[];
|
||||
modelUsage: ModelUsageOut[];
|
||||
videoResolutionUsage: VideoParamOut[];
|
||||
videoRatioUsage: VideoParamOut[];
|
||||
videoDurationUsage: VideoParamOut[];
|
||||
}
|
||||
|
||||
export interface PaymentStats {
|
||||
|
||||
@@ -36,6 +36,7 @@ from app.schemas.admin import (
|
||||
DailyCreditOut,
|
||||
TeamCreditOut,
|
||||
ModelUsageOut,
|
||||
VideoParamOut,
|
||||
CreateUserRequest,
|
||||
UpdateMenusRequest,
|
||||
ResetPasswordRequest,
|
||||
@@ -1711,6 +1712,15 @@ async def list_operation_logs(
|
||||
}
|
||||
|
||||
|
||||
def _build_param_out(model_map: dict[str, dict[str, int]]) -> list[VideoParamOut]:
|
||||
"""将 {模型: {标签: 数量}} 转为扁平列表,按模型+数量排序。"""
|
||||
result: list[VideoParamOut] = []
|
||||
for model, labels in model_map.items():
|
||||
for label, count in sorted(labels.items(), key=lambda x: -x[1]):
|
||||
result.append(VideoParamOut(model=model, label=label, count=count))
|
||||
return result
|
||||
|
||||
|
||||
# ── Stats ────────────────────────────────────────────────
|
||||
|
||||
@router.get("/stats", response_model=AdminStatsOut)
|
||||
@@ -1950,6 +1960,45 @@ async def get_stats(
|
||||
for row in model_usage_rows
|
||||
]
|
||||
|
||||
# ── 视频分辨率/比例/时长使用分布(按模型分组)
|
||||
_video_gen_q = (
|
||||
select(
|
||||
func.coalesce(CreditRecord.engine_name, '未知').label('model'),
|
||||
ChatGenerationTask.resolution,
|
||||
ChatGenerationTask.aspect_ratio,
|
||||
ChatGenerationTask.duration,
|
||||
)
|
||||
.join(CreditRecord, CreditRecord.related_id == ChatGenerationTask.id)
|
||||
.where(
|
||||
ChatGenerationTask.gen_type == "video",
|
||||
CreditRecord.type == "consume",
|
||||
ChatGenerationTask.created_at >= date_start,
|
||||
ChatGenerationTask.created_at <= date_end,
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
_video_rows = (await db.execute(_video_gen_q)).all()
|
||||
|
||||
_res_map: dict[str, dict[str, int]] = {}
|
||||
_ratio_map: dict[str, dict[str, int]] = {}
|
||||
_dur_map: dict[str, dict[str, int]] = {}
|
||||
for row in _video_rows:
|
||||
model = row.model or '未知'
|
||||
if row.resolution:
|
||||
_res_map.setdefault(model, {})
|
||||
_res_map[model][row.resolution] = _res_map[model].get(row.resolution, 0) + 1
|
||||
if row.aspect_ratio:
|
||||
_ratio_map.setdefault(model, {})
|
||||
_ratio_map[model][row.aspect_ratio] = _ratio_map[model].get(row.aspect_ratio, 0) + 1
|
||||
if row.duration:
|
||||
_k = f"{row.duration}秒"
|
||||
_dur_map.setdefault(model, {})
|
||||
_dur_map[model][_k] = _dur_map[model].get(_k, 0) + 1
|
||||
|
||||
video_resolution_usage = _build_param_out(_res_map)
|
||||
video_ratio_usage = _build_param_out(_ratio_map)
|
||||
video_duration_usage = _build_param_out(_dur_map)
|
||||
|
||||
return AdminStatsOut(
|
||||
total_users=total_users,
|
||||
total_projects=total_projects,
|
||||
@@ -1969,6 +2018,9 @@ async def get_stats(
|
||||
period_credits_by_module=period_credits_by_module,
|
||||
credits_by_team=credits_by_team,
|
||||
model_usage=model_usage,
|
||||
video_resolution_usage=video_resolution_usage,
|
||||
video_ratio_usage=video_ratio_usage,
|
||||
video_duration_usage=video_duration_usage,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -127,6 +127,12 @@ class ModelUsageOut(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class VideoParamOut(BaseModel):
|
||||
model: str
|
||||
label: str
|
||||
count: int
|
||||
|
||||
|
||||
class AdminStatsOut(BaseModel):
|
||||
total_users: int
|
||||
total_projects: int
|
||||
@@ -147,6 +153,9 @@ class AdminStatsOut(BaseModel):
|
||||
period_credits_by_module: list[DailyCreditOut] = []
|
||||
credits_by_team: list[TeamCreditOut] = []
|
||||
model_usage: list[ModelUsageOut] = []
|
||||
video_resolution_usage: list[VideoParamOut] = []
|
||||
video_ratio_usage: list[VideoParamOut] = []
|
||||
video_duration_usage: list[VideoParamOut] = []
|
||||
|
||||
|
||||
class AdminCreditRecordSummaryOut(BaseModel):
|
||||
|
||||
+117
-117
File diff suppressed because one or more lines are too long
-613
File diff suppressed because one or more lines are too long
Vendored
+1
-3
@@ -28,12 +28,10 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CnKJ4EKg.js"></script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-C4uOdbc7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bsz_Xon-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+82
@@ -17,6 +17,7 @@
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"three": "^0.184.0",
|
||||
"uuid": "^14.0.0",
|
||||
@@ -2826,6 +2827,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2833,6 +2840,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -3615,6 +3628,25 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3822,6 +3854,12 @@
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4138,6 +4176,35 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quill": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"parchment": "^3.0.0",
|
||||
"quill-delta": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-diff": "^1.3.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isequal": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rangetouch": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
|
||||
@@ -4189,6 +4256,21 @@
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-quill-new": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
|
||||
"integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21",
|
||||
"quill": "~2.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"quill-delta": "^5.1.0",
|
||||
"react": "^16 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"three": "^0.184.0",
|
||||
"uuid": "^14.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Tag, Button } from 'antd';
|
||||
import { BellOutlined, ThunderboltOutlined, GiftOutlined, StarOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { BellOutlined, ThunderboltOutlined, GiftOutlined, StarOutlined, CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { getNotifications, markNotificationRead } from '../api';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
@@ -105,17 +105,17 @@ const NotificationPopup: React.FC = () => {
|
||||
background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(6px)',
|
||||
}} onClick={handleClose}>
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
width: 420,
|
||||
width: 520,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 25px 80px rgba(0,0,0,0.15)',
|
||||
boxShadow: '0 30px 100px rgba(0,0,0,0.2)',
|
||||
animation: 'notificationPop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
background: '#ffffff',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
background: tc.gradient,
|
||||
padding: '24px 28px 20px',
|
||||
padding: '16px 28px 14px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
@@ -129,70 +129,37 @@ const NotificationPopup: React.FC = () => {
|
||||
background: 'radial-gradient(circle, rgba(255,255,255,0.15) 0%, transparent 60%)',
|
||||
filter: 'blur(30px)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: 30,
|
||||
top: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
filter: 'blur(15px)',
|
||||
}} />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 20,
|
||||
color: '#fff',
|
||||
}}>{tc.icon}</div>
|
||||
<div>
|
||||
<div style={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
fontWeight: 500,
|
||||
}}>新消息通知</div>
|
||||
<div style={{
|
||||
color: '#fff',
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-1px',
|
||||
}}>{current.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Top row: type badge + close */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Tag style={{ background: 'rgba(255,255,255,0.2)', border: 'none', color: '#fff', borderRadius: 6, padding: '2px 10px', fontSize: 11, fontWeight: 500 }}>
|
||||
{tc.icon} {tc.label}
|
||||
</Tag>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: '50%',
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
outline: 'none',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
|
||||
border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 0.2s ease', outline: 'none',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.2)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.1)'; }}
|
||||
>
|
||||
<CloseOutlined style={{ color: '#fff', fontSize: 14 }} />
|
||||
<CloseOutlined style={{ color: '#fff', fontSize: 12 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title + time row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12 }}>
|
||||
<div style={{ color: '#fff', fontSize: 22, fontWeight: 700, letterSpacing: '-0.5px' }}>
|
||||
{current.title}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{current.createdAt ? new Date(current.createdAt).toLocaleString('zh-CN') : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@@ -211,28 +178,47 @@ const NotificationPopup: React.FC = () => {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
lineHeight: 1.7,
|
||||
}}>
|
||||
{current.content}
|
||||
</div>
|
||||
}} dangerouslySetInnerHTML={{ __html: current.content }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{/* Left: navigation with left/right buttons */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Tag
|
||||
color={tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6'}
|
||||
{notifications.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setCurrentIndex(currentIndex > 0 ? currentIndex - 1 : notifications.length - 1); }}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
padding: '3px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
background: '#f1f5f9',
|
||||
color: '#64748b',
|
||||
width: 30, height: 30, borderRadius: '50%',
|
||||
background: '#f1f5f9', border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 0.2s', outline: 'none',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = '#e2e8f0'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = '#f1f5f9'; }}
|
||||
>
|
||||
{tc.label}
|
||||
</Tag>
|
||||
<LeftOutlined style={{ color: '#64748b', fontSize: 12 }} />
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: '#64748b', fontWeight: 500, minWidth: 36, textAlign: 'center' }}>
|
||||
{currentIndex + 1}/{notifications.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setCurrentIndex(currentIndex < notifications.length - 1 ? currentIndex + 1 : 0); }}
|
||||
style={{
|
||||
width: 30, height: 30, borderRadius: '50%',
|
||||
background: '#f1f5f9', border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 0.2s', outline: 'none',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = '#e2e8f0'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = '#f1f5f9'; }}
|
||||
>
|
||||
<RightOutlined style={{ color: '#64748b', fontSize: 12 }} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Right: acknowledge button */}
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleAcknowledge}
|
||||
@@ -261,28 +247,6 @@ const NotificationPopup: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{notifications.length > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 6, marginTop: 20 }}>
|
||||
{notifications.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentIndex(i)}
|
||||
style={{
|
||||
width: i === currentIndex ? 20 : 5,
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
background: i === currentIndex
|
||||
? (tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6')
|
||||
: '#e2e8f0',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
|
||||
@@ -81,6 +81,7 @@ const MessagesPage: React.FC = () => {
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
ellipsis: true,
|
||||
render: (v: string) => <div style={{ maxWidth: 300 }} dangerouslySetInnerHTML={{ __html: v }} />,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
|
||||
Reference in New Issue
Block a user