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:
@@ -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 }
|
||||
|
||||
|
||||
# ── 免费业务(不扣积分)───────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user