refactor: Phase 1 Profile/Settings UX refactoring

- Sidebar: Remove '系统设置' from navItems, add balance badge + user dropdown
  menu in footer (我的账户/使用明细/系统设置/关于我们/退出登录)
- Profile: Remove inline recent transactions table (UsageDetail page exists),
  simplify to info + points + menu entries. Add inline pricing modal.
- GenerationControls: Show current balance alongside point cost in button text
- Points config: Adjust subtitle_burn/cover_design to 5 pts, recharge validity
This commit is contained in:
小鱼开发
2026-05-22 10:38:55 +08:00
parent 574874c856
commit aebc9f6bcc
5 changed files with 483 additions and 114 deletions
+8 -9
View File
@@ -24,10 +24,10 @@ fixed_costs:
voice_clone: 200
# 字幕烧录:将生成的字幕文件烧录到视频中(FFmpeg 合成)
subtitle_burn: 2
subtitle_burn: 5
# 封面设计:根据视频内容自动生成封面图
cover_design: 2
cover_design: 5
# 压制成片:将多个素材片段合并为最终视频(FFmpeg 拼接)
compose: 5
@@ -52,11 +52,11 @@ duration_based_costs:
# 计算依据:中文正常朗读语速约 200~250 字/分钟,取 240 字/分钟:
# 60秒 ÷ 240字 = 0.25 秒/字
# 注意:此为经验值,未经过 Vidu TTS 实测校准。实际时长受标点停顿、
# 数字/英文混合、TTS 角色风格等因素影响,误差约 ±20%。
# 数字/英文混合、TTS 角色风格等因素影响,误差约 ±20%。
# TODO: 收集实测数据后校准此值。
seconds_per_char: 0.25
# ── 视频生成(对口型)──
# ── 视频生成 ──
# 计费公式:max(min_points, ceil(实际视频秒数) × multiplier)
# 说明:秒数先向上取整为整数,再乘以 multiplier。不足 1 秒按 1 秒计算。
# 示例:4.5秒视频 → ceil(4.5) × 1 = 5 积分;0.8秒 → ceil(0.8) × 1 = 1 积分
@@ -66,8 +66,7 @@ duration_based_costs:
# 预估参数(执行业务前检查余额时使用)
estimation:
# 是否直接使用输入素材秒数作为预估上限
# 视频生成时长 = 输入音频/素材时长,因此用 input_seconds 预估最准确。
# 视频生成时长 = 输入音频时长,因此用 input_seconds 预估最准确
# 调用方需传入 input_seconds 参数。
use_input_seconds: true
@@ -77,10 +76,10 @@ duration_based_costs:
# 支持积分赠送:points 为实际到账积分数,amount_rmb 为支付金额(分)。
# label 为空时不显示标签角标。
recharge_options:
- { price: 10000, points: 2000, label: "", validity_days: 180 }
- { price: 10000, points: 2000, label: "", validity_days: 90 }
- { price: 50000, points: 11000, label: "热销", validity_days: 180 }
- { price: 100000, points: 23000, label: "推荐", validity_days: 365 }
- { price: 500000, points: 125000, label: "超值", validity_days: 0 }
- { price: 100000, points: 22500, label: "推荐", validity_days: 180 }
- { price: 500000, points: 120000, label: "超值", validity_days: 365 }
# ── 免费业务(不扣积分)───────────────────────────────
+94 -2
View File
@@ -164,6 +164,30 @@
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
position: relative;
}
.sidebar-balance {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
background: var(--bg-card);
border: 1px solid var(--border-light);
}
.sidebar-balance:hover {
background: var(--bg-hover);
}
.sidebar-balance .balance-text {
font-size: var(--font-sm);
font-weight: 600;
color: var(--primary);
}
.sidebar-user {
@@ -182,8 +206,8 @@
}
.user-avatar {
width: 36px;
height: 36px;
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--primary-gradient);
color: var(--text-inverse);
@@ -199,6 +223,7 @@
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.user-name {
@@ -210,4 +235,71 @@
text-overflow: ellipsis;
}
.user-chevron {
flex-shrink: 0;
transition: transform var(--transition-fast);
color: var(--text-tertiary);
}
.user-chevron.expanded {
transform: rotate(90deg);
}
.user-dropdown-menu {
position: absolute;
bottom: calc(100% + 4px);
left: var(--spacing-md);
right: var(--spacing-md);
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
padding: var(--spacing-xs);
display: flex;
flex-direction: column;
gap: 2px;
z-index: 200;
animation: fadeIn 0.15s ease;
}
.user-dropdown-item {
display: flex;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
border: none;
background: transparent;
cursor: pointer;
font-size: var(--font-sm);
font-family: var(--font-family);
color: var(--text-secondary);
text-align: left;
transition: all var(--transition-fast);
}
.user-dropdown-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.user-dropdown-item.active {
color: var(--primary);
font-weight: 500;
}
.user-dropdown-divider {
height: 1px;
background: var(--border-light);
margin: var(--spacing-xs) 0;
}
.user-dropdown-danger {
color: var(--text-secondary);
}
.user-dropdown-danger:hover {
color: #e74c3c;
background: #fdf2f2;
}
+86 -14
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { createNewProject, useAuthStore } from '../../store';
import { usePointStore } from '../../store';
import { useNavigation } from '../../contexts/NavigationContext';
import './Sidebar.css';
@@ -25,16 +26,6 @@ const navItems: NavItem[] = [
{ id: 'my-works', label: '我的作品' },
],
},
{
id: 'settings',
label: '系统设置',
icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
children: [
{ id: 'system-update', label: '系统更新' },
{ id: 'about-us', label: '关于我们' },
],
},
];
interface SidebarProps {
@@ -42,12 +33,40 @@ interface SidebarProps {
onNavigate: (path: string) => void;
}
const userMenuItems = [
{ id: 'profile', label: '我的账户' },
{ id: 'usage-detail', label: '使用明细' },
{ id: 'system-update', label: '系统设置' },
{ id: 'about-us', label: '关于我们' },
];
export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
const { appEnvironment } = useNavigation();
const authUser = useAuthStore((s) => s.user);
const balance = usePointStore((s) => s.balance);
const fetchBalance = usePointStore((s) => s.fetchBalance);
const [expandedItems, setExpandedItems] = useState<Set<string>>(
new Set(['content-management', 'settings'])
new Set(['content-management'])
);
const [showUserMenu, setShowUserMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// 组件挂载时拉取一次余额
useEffect(() => {
fetchBalance().catch(() => {});
}, [fetchBalance]);
// 点击外部关闭用户菜单
useEffect(() => {
if (!showUserMenu) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowUserMenu(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showUserMenu]);
const toggleExpand = (id: string) => {
setExpandedItems(prev => {
@@ -196,8 +215,22 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
{appEnvironment === 'staging' ? '测试环境' : appEnvironment === 'development' ? '开发环境' : appEnvironment}
</div>
)}
<div className="sidebar-footer">
<div className="sidebar-user" onClick={() => onNavigate('profile')} title="个人中心">
<div className="sidebar-footer" ref={menuRef}>
<div
className="sidebar-balance"
onClick={() => onNavigate('profile')}
title="查看账户"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#36b26a" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
<span className="balance-text">{balance} </span>
</div>
<div
className="sidebar-user"
onClick={() => setShowUserMenu(!showUserMenu)}
title="菜单"
>
<img
src={authUser?.avatar || '/default-avatar.svg'}
alt="avatar"
@@ -207,7 +240,46 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
<div className="user-info">
<span className="user-name">{authUser?.nickname || '用户'}</span>
</div>
<svg
className={`user-chevron ${showUserMenu ? 'expanded' : ''}`}
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
{showUserMenu && (
<div className="user-dropdown-menu">
{userMenuItems.map((item) => (
<button
key={item.id}
className={`user-dropdown-item ${currentPath === item.id ? 'active' : ''}`}
onClick={() => {
setShowUserMenu(false);
onNavigate(item.id);
}}
>
{item.label}
</button>
))}
<div className="user-dropdown-divider" />
<button
className="user-dropdown-item user-dropdown-danger"
onClick={() => {
setShowUserMenu(false);
onNavigate('logout');
}}
>
退
</button>
</div>
)}
</div>
</aside>
);
+291 -87
View File
@@ -2,8 +2,9 @@ import { useEffect, useState } from 'react';
import { useNavigation } from '../../contexts/NavigationContext';
import { useAuthStore } from '../../store';
import { client } from '../../api/client';
import { pointsApi, type PointBalance, type PointTransaction } from '../../api/modules/points';
import { pointsApi, type PointBalance, type PointRule } from '../../api/modules/points';
import RechargeModal from '../../components/RechargeModal/RechargeModal';
import Modal from '../../components/Modal/Modal';
import '../ContentManagement/ContentManagement.css';
interface UserProfile {
@@ -13,22 +14,17 @@ interface UserProfile {
avatar: string;
}
function formatTxTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
const TYPE_LABELS: Record<string, string> = {
recharge: '充值',
consume: '消费',
expire: '过期',
refund: '退款',
const SOURCE_TYPE_LABELS: Record<string, string> = {
script: '脚本生成',
polish: '文案润色',
title: '标题生成',
tts: '配音合成',
voice_clone: '声音复刻',
video: '视频生成',
compose: '压制成片',
subtitle_burn: '字幕烧录',
cover_design: '封面设计',
caption: '字幕生成',
};
function maskMobile(mobile: string): string {
@@ -43,10 +39,11 @@ export default function Profile() {
const [user, setUser] = useState<UserProfile | null>(null);
const [balance, setBalance] = useState<PointBalance | null>(null);
const [recentTx, setRecentTx] = useState<PointTransaction[]>([]);
const [todayConsumed, setTodayConsumed] = useState(0);
const [loading, setLoading] = useState(true);
const [showRechargeModal, setShowRechargeModal] = useState(false);
const [showPricingModal, setShowPricingModal] = useState(false);
const [pricingRules, setPricingRules] = useState<PointRule[]>([]);
const [pricingLoading, setPricingLoading] = useState(false);
// 昵称编辑状态
const [nickname, setNickname] = useState('');
@@ -55,7 +52,6 @@ export default function Profile() {
const [nickError, setNickError] = useState('');
const loadData = async () => {
setLoading(true);
try {
const [profileData, balanceData] = await Promise.all([
client.get<UserProfile>('/auth/me').catch(() => null),
@@ -67,16 +63,10 @@ export default function Profile() {
}
if (balanceData) {setBalance(balanceData);}
const [txData, todayData] = await Promise.all([
pointsApi.getTransactions({ page: 1, pageSize: 10 }).catch(() => null),
pointsApi.getTodayConsumed().catch(() => null),
]);
if (txData) {setRecentTx(txData.items);}
const todayData = await pointsApi.getTodayConsumed().catch(() => null);
if (todayData) {setTodayConsumed(todayData.total);}
} catch (e) {
console.error('[Profile] 加载数据失败:', e);
} finally {
setLoading(false);
}
};
@@ -88,6 +78,21 @@ export default function Profile() {
loadData();
};
const handleOpenPricing = async () => {
setShowPricingModal(true);
if (pricingRules.length === 0) {
setPricingLoading(true);
try {
const rules = await pointsApi.getRules();
setPricingRules(rules);
} catch (e) {
console.error('[Profile] 获取积分规则失败:', e);
} finally {
setPricingLoading(false);
}
}
};
const handleSaveNickname = async () => {
const trimmed = nickname.trim();
if (!trimmed) { setNickError('昵称不能为空'); return; }
@@ -271,72 +276,56 @@ export default function Profile() {
>
</button>
<button
className="btn btn-ghost btn-sm"
style={{ padding: '10px 24px', fontSize: 'var(--font-sm)', whiteSpace: 'nowrap' }}
onClick={handleOpenPricing}
>
</button>
</div>
</div>
</div>
{/* 最近记录表格 */}
{/* 功能入口列表 */}
<div style={{ marginTop: 'var(--spacing-xl)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontSize: 'var(--font-md)', fontWeight: 600 }}></h3>
<button className="btn btn-ghost btn-sm" onClick={() => navigate('usage-detail')}>
</button>
</div>
<h3 style={{ fontSize: 'var(--font-md)', fontWeight: 600, marginBottom: 'var(--spacing-md)' }}></h3>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table className="usage-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
...
</td>
</tr>
) : recentTx.length === 0 ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
</td>
</tr>
) : (
recentTx.map((tx) => (
<tr key={tx.id}>
<td>
<span
className="tx-tag"
style={{
background: tx.type === 'recharge' ? '#e8f5e9' : '#f5f5f5',
color: tx.type === 'recharge' ? '#36b26a' : 'var(--text-secondary)',
border: 'none',
}}
>
{TYPE_LABELS[tx.type] || tx.type}
</span>
</td>
<td style={{ fontWeight: 600 }}>
{tx.type === 'recharge' ? '+' : '-'}{tx.amount}
</td>
<td>{tx.balanceBefore}</td>
<td>{tx.balanceAfter}</td>
<td className="description-cell" title={tx.description || '-'}>
{tx.description || '-'}
</td>
<td>{formatTxTime(tx.createdAt)}</td>
</tr>
))
)}
</tbody>
</table>
{[
{ label: '使用明细', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" 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>, onClick: () => navigate('usage-detail') },
{ label: '产品定价', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>, onClick: handleOpenPricing },
{ label: '系统设置', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>, onClick: () => navigate('system-update') },
{ label: '关于我们', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>, onClick: () => navigate('about-us') },
].map((item, index, arr) => (
<button
key={item.label}
onClick={item.onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
width: '100%',
padding: '16px 24px',
border: 'none',
borderBottom: index < arr.length - 1 ? '1px solid var(--border-light)' : 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: 'var(--font-base)',
fontFamily: 'inherit',
color: 'var(--text-primary)',
textAlign: 'left',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--bg-hover)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
>
<span style={{ fontSize: '18px', lineHeight: 1 }}>{item.icon}</span>
<span style={{ flex: 1 }}>{item.label}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
))}
</div>
</div>
@@ -375,6 +364,221 @@ export default function Profile() {
onClose={() => setShowRechargeModal(false)}
onRechargeSuccess={handleRechargeSuccess}
/>
<Modal
open={showPricingModal}
onClose={() => setShowPricingModal(false)}
width="600px"
>
{pricingLoading ? (
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
...
</div>
) : (
<div style={{ padding: '8px 4px' }}>
{/* 表格区域 */}
<div
style={{
background: 'var(--bg-secondary)',
borderRadius: '12px',
border: '1px solid var(--border-light)',
overflow: 'hidden',
}}
>
{/* 表头 */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 90px 140px',
gap: '12px',
padding: '14px 20px',
background: 'var(--bg-tertiary)',
borderBottom: '1px solid var(--border-light)',
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-secondary)',
}}
>
<span></span>
<span style={{ textAlign: 'center' }}></span>
<span style={{ textAlign: 'right' }}></span>
</div>
{/* 表格行 */}
{pricingRules
.filter((rule) => rule.mode !== 'free')
.map((rule, index, arr) => (
<div
key={rule.sourceType}
style={{
display: 'grid',
gridTemplateColumns: '1fr 90px 140px',
gap: '12px',
padding: '10px 20px',
borderBottom: index < arr.length - 1 ? '1px solid var(--border-light)' : 'none',
alignItems: 'center',
}}
>
<span style={{ fontSize: '14px', fontWeight: 500, color: 'var(--text-primary)' }}>
{SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType}
</span>
<span style={{ textAlign: 'center' }}>
<span
style={{
display: 'inline-block',
fontSize: '12px',
padding: '3px 10px',
borderRadius: '20px',
fontWeight: 500,
background: rule.mode === 'fixed' ? '#e8f4fd' : '#fff3e0',
color: rule.mode === 'fixed' ? '#1976d2' : '#e65100',
}}
>
{rule.mode === 'fixed' ? '固定' : '按时长'}
</span>
</span>
<span
style={{
fontSize: '14px',
fontWeight: 600,
color: '#36b26a',
textAlign: 'right',
}}
>
{rule.mode === 'fixed'
? `${rule.points} 积分/次`
: `${rule.unit} ${rule.pointsPerUnit} 积分`}
</span>
</div>
))}
{pricingRules.filter((rule) => rule.mode !== 'free').length === 0 && (
<div
style={{
textAlign: 'center',
padding: '32px',
color: 'var(--text-secondary)',
fontSize: '14px',
}}
>
</div>
)}
</div>
{/* 按时长计费细则 */}
{pricingRules.some((r) => r.mode === 'duration') && (
<div
style={{
marginTop: '16px',
padding: '16px 20px',
background: '#fff8f0',
borderRadius: '10px',
border: '1px solid #ffe8d0',
}}
>
<div
style={{
fontSize: '13px',
fontWeight: 600,
color: '#d46b08',
marginBottom: '10px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d46b08" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{pricingRules
.filter((r) => r.mode === 'duration')
.map((rule) => {
const contentType =
rule.sourceType === 'tts'
? '音频'
: rule.sourceType === 'video'
? '视频'
: '内容';
const unitNum = (rule.unit?.match(/\d+/) || ['1'])[0];
return (
<div
key={rule.sourceType}
style={{
display: 'flex',
alignItems: 'baseline',
gap: '8px',
fontSize: '13px',
color: 'var(--text-secondary)',
lineHeight: '1.6',
}}
>
<span
style={{
fontWeight: 600,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
}}
>
{SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType}
</span>
<span style={{ whiteSpace: 'pre-line' }}>
{contentType}{rule.unit} {rule.pointsPerUnit} {unitNum}{unitNum}
{rule.sourceType === 'video' ? '使用系统素材每个空镜额外消耗 2 积分。' : ''}
</span>
</div>
);
})}
</div>
</div>
)}
{/* 说明区域 */}
<div
style={{
marginTop: '16px',
padding: '16px 20px',
background: '#f8faf8',
borderRadius: '10px',
border: '1px solid #e8f0e8',
}}
>
<div
style={{
fontSize: '13px',
fontWeight: 600,
color: '#2e7d32',
marginBottom: '10px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#2e7d32" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</div>
<ul
style={{
margin: 0,
paddingLeft: '18px',
fontSize: '13px',
color: 'var(--text-secondary)',
lineHeight: '1.8',
}}
>
<li>使</li>
<li></li>
</ul>
</div>
</div>
)}
</Modal>
</div>
);
}
@@ -1,3 +1,4 @@
import { usePointStore } from '../../../store';
interface GenerationControlsProps {
composedVideoPath: string | null;
@@ -21,6 +22,7 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
onGenerate,
onPreview,
}) => {
const balance = usePointStore((s) => s.balance);
const generateIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3" />
@@ -45,7 +47,7 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
disabled={isDisabled}
>
{refreshIcon}
{estimatedVideoPoints}
{estimatedVideoPoints} · {balance}
</button>
<button
className="btn btn-secondary"
@@ -68,7 +70,7 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
disabled={isDisabled}
>
{generateIcon}
{estimatedVideoPoints}
{estimatedVideoPoints} · {balance}
</button>
</div>
);