diff --git a/python-api/config/points-config.yaml b/python-api/config/points-config.yaml index dec8ae8..fefc61d 100644 --- a/python-api/config/points-config.yaml +++ b/python-api/config/points-config.yaml @@ -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 } # ── 免费业务(不扣积分)─────────────────────────────── diff --git a/tauri-app/src/components/Layout/Sidebar.css b/tauri-app/src/components/Layout/Sidebar.css index bc3aa32..ac01d80 100644 --- a/tauri-app/src/components/Layout/Sidebar.css +++ b/tauri-app/src/components/Layout/Sidebar.css @@ -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; +} + diff --git a/tauri-app/src/components/Layout/Sidebar.tsx b/tauri-app/src/components/Layout/Sidebar.tsx index 387736b..bfb6943 100644 --- a/tauri-app/src/components/Layout/Sidebar.tsx +++ b/tauri-app/src/components/Layout/Sidebar.tsx @@ -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>( - new Set(['content-management', 'settings']) + new Set(['content-management']) ); + const [showUserMenu, setShowUserMenu] = useState(false); + const menuRef = useRef(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} )} -
-
onNavigate('profile')} title="个人中心"> +
+
onNavigate('profile')} + title="查看账户" + > + + + + {balance} 积分 +
+
setShowUserMenu(!showUserMenu)} + title="菜单" + > avatar {authUser?.nickname || '用户'}
+ + +
+ {showUserMenu && ( +
+ {userMenuItems.map((item) => ( + + ))} +
+ +
+ )}
); diff --git a/tauri-app/src/pages/Profile/Profile.tsx b/tauri-app/src/pages/Profile/Profile.tsx index a54b9f1..c2e302d 100644 --- a/tauri-app/src/pages/Profile/Profile.tsx +++ b/tauri-app/src/pages/Profile/Profile.tsx @@ -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 = { - recharge: '充值', - consume: '消费', - expire: '过期', - refund: '退款', +const SOURCE_TYPE_LABELS: Record = { + 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(null); const [balance, setBalance] = useState(null); - const [recentTx, setRecentTx] = useState([]); const [todayConsumed, setTodayConsumed] = useState(0); - const [loading, setLoading] = useState(true); const [showRechargeModal, setShowRechargeModal] = useState(false); + const [showPricingModal, setShowPricingModal] = useState(false); + const [pricingRules, setPricingRules] = useState([]); + 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('/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() { > 充值明细 +
- {/* 最近记录表格 */} + {/* 功能入口列表 */}
-
-

最近记录

- -
+

更多

- - - - - - - - - - - - - {loading ? ( - - - - ) : recentTx.length === 0 ? ( - - - - ) : ( - recentTx.map((tx) => ( - - - - - - - - - )) - )} - -
类型变动积分变动前余额变动后余额说明时间
- 加载中... -
- 暂无记录 -
- - {TYPE_LABELS[tx.type] || tx.type} - - - {tx.type === 'recharge' ? '+' : '-'}{tx.amount} - {tx.balanceBefore}{tx.balanceAfter} - {tx.description || '-'} - {formatTxTime(tx.createdAt)}
+ {[ + { label: '使用明细', icon: , onClick: () => navigate('usage-detail') }, + { label: '产品定价', icon: , onClick: handleOpenPricing }, + { label: '系统设置', icon: , onClick: () => navigate('system-update') }, + { label: '关于我们', icon: , onClick: () => navigate('about-us') }, + ].map((item, index, arr) => ( + + ))}
@@ -375,6 +364,221 @@ export default function Profile() { onClose={() => setShowRechargeModal(false)} onRechargeSuccess={handleRechargeSuccess} /> + + setShowPricingModal(false)} + width="600px" + > + {pricingLoading ? ( +
+ 加载中... +
+ ) : ( +
+ {/* 表格区域 */} +
+ {/* 表头 */} +
+ 功能名称 + 计费方式 + 积分消耗 +
+ {/* 表格行 */} + {pricingRules + .filter((rule) => rule.mode !== 'free') + .map((rule, index, arr) => ( +
+ + {SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType} + + + + {rule.mode === 'fixed' ? '固定' : '按时长'} + + + + {rule.mode === 'fixed' + ? `${rule.points} 积分/次` + : `${rule.unit} ${rule.pointsPerUnit} 积分`} + +
+ ))} + {pricingRules.filter((rule) => rule.mode !== 'free').length === 0 && ( +
+ 暂无计费项目 +
+ )} +
+ + {/* 按时长计费细则 */} + {pricingRules.some((r) => r.mode === 'duration') && ( +
+
+ + + + + 按时长计费细则 +
+
+ {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 ( +
+ + {SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType} + + + 按生成{contentType}的实际时长计费,{rule.unit}消耗 {rule.pointsPerUnit} 积分,不足{unitNum}秒按{unitNum}秒计。 + {rule.sourceType === 'video' ? '使用系统素材每个空镜额外消耗 2 积分。' : ''} + +
+ ); + })} +
+
+ )} + + {/* 说明区域 */} +
+
+ + + + + + 说明 +
+
    +
  • 固定计费:每次使用消耗固定积分,与输入内容长度无关。
  • +
  • 按时长计费:根据实际生成内容的时长按单位消耗。
  • +
+
+
+ )} +
); } diff --git a/tauri-app/src/pages/VideoGeneration/_components/GenerationControls.tsx b/tauri-app/src/pages/VideoGeneration/_components/GenerationControls.tsx index 027d5c5..4fc7d79 100644 --- a/tauri-app/src/pages/VideoGeneration/_components/GenerationControls.tsx +++ b/tauri-app/src/pages/VideoGeneration/_components/GenerationControls.tsx @@ -1,3 +1,4 @@ +import { usePointStore } from '../../../store'; interface GenerationControlsProps { composedVideoPath: string | null; @@ -21,6 +22,7 @@ const GenerationControls: React.FC = ({ onGenerate, onPreview, }) => { + const balance = usePointStore((s) => s.balance); const generateIcon = ( @@ -45,7 +47,7 @@ const GenerationControls: React.FC = ({ disabled={isDisabled} > {refreshIcon} - 重新生成(消耗 {estimatedVideoPoints} 积分) + 重新生成(消耗 {estimatedVideoPoints} 积分 · 剩余 {balance}) );