refactor(Phase 2): extract components + AppHeader

New components:
- PricingModal: standalone product pricing modal with lazy rule loading
- PointsCard: reusable points balance + today consumed + action buttons
- AppHeader: unified page header with title, back button, and right actions

Profile.tsx:
- Use PricingModal + PointsCard (380 lines -> ~200 lines)
- Remove embedded pricing table logic (~80 lines)
- Remove inline points display logic (~40 lines)

Pages updated with AppHeader:
- Profile: '我的账户' (no back button)
- UsageDetail: '积分明细' + back button
- SystemUpdate: '系统更新' + back button
- AboutUs: '关于我们' + back button

CSS:
- Add app-header, app-header-left, app-header-title, app-header-right classes
- Remove margin-bottom from page-back-btn (handled by app-header)
This commit is contained in:
小鱼开发
2026-05-22 12:45:41 +08:00
parent 5386a1dbf4
commit 34d6f671fe
8 changed files with 255 additions and 178 deletions
@@ -0,0 +1,25 @@
interface AppHeaderProps {
title: string;
showBack?: boolean;
onBack?: () => void;
rightActions?: React.ReactNode;
}
export default function AppHeader({ title, showBack, onBack, rightActions }: AppHeaderProps) {
return (
<div className="app-header">
<div className="app-header-left">
{showBack && onBack && (
<button className="page-back-btn" onClick={onBack}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
)}
<h2 className="app-header-title">{title}</h2>
</div>
{rightActions && <div className="app-header-right">{rightActions}</div>}
</div>
);
}
@@ -0,0 +1,48 @@
interface PointsCardProps {
balance: number;
todayConsumed: number;
onRecharge: () => void;
onViewDetail: () => void;
onViewPricing: () => void;
}
export default function PointsCard({
balance,
todayConsumed,
onRecharge,
onViewDetail,
onViewPricing,
}: PointsCardProps) {
return (
<div className="profile-points-section">
<div className="profile-points-grid">
<div className="profile-points-card">
<div className="profile-points-label"></div>
<div className="profile-points-value-row">
<span className="profile-points-value primary">{balance}</span>
<span className="profile-points-unit"></span>
</div>
</div>
<div className="profile-points-card">
<div className="profile-points-label"></div>
<div className="profile-points-value-row">
<span className="profile-points-value danger">{todayConsumed}</span>
<span className="profile-points-unit"></span>
</div>
</div>
</div>
<div className="profile-points-actions-row">
<button className="btn btn-primary btn-sm" onClick={onRecharge}>
</button>
<button className="btn btn-ghost btn-sm" onClick={onViewDetail}>
</button>
<button className="btn btn-ghost btn-sm" onClick={onViewPricing}>
</button>
</div>
</div>
);
}
@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react';
import { pointsApi, type PointRule } from '../../api/modules/points';
import Modal from '../Modal/Modal';
const SOURCE_TYPE_LABELS: Record<string, string> = {
script: '脚本生成',
polish: '文案润色',
title: '标题生成',
tts: '配音合成',
voice_clone: '声音复刻',
video: '视频生成',
compose: '压制成片',
subtitle_burn: '字幕烧录',
cover_design: '封面设计',
caption: '字幕生成',
};
const ClockIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
);
const InfoIcon = () => (
<svg width="16" height="16" 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>
);
interface PricingModalProps {
open: boolean;
onClose: () => void;
}
export default function PricingModal({ open, onClose }: PricingModalProps) {
const [rules, setRules] = useState<PointRule[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || rules.length > 0) return;
setLoading(true);
pointsApi.getRules()
.then(setRules)
.catch((e) => console.error('[PricingModal] 获取积分规则失败:', e))
.finally(() => setLoading(false));
}, [open, rules.length]);
return (
<Modal open={open} onClose={onClose} width="600px">
{loading ? (
<div className="profile-pricing-loading">
...
</div>
) : (
<div className="profile-pricing-body">
{/* 表格 */}
<div className="profile-pricing-table">
<div className="profile-pricing-header">
<span></span>
<span></span>
<span></span>
</div>
{rules
.filter((rule) => rule.mode !== 'free')
.map((rule) => (
<div key={rule.sourceType} className="profile-pricing-row">
<span className="profile-pricing-name">
{SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType}
</span>
<span className="profile-pricing-mode">
<span className={`profile-pricing-tag ${rule.mode}`}>
{rule.mode === 'fixed' ? '固定' : '按时长'}
</span>
</span>
<span className="profile-pricing-points">
{rule.mode === 'fixed'
? `${rule.points} 积分/次`
: `${rule.unit} ${rule.pointsPerUnit} 积分`}
</span>
</div>
))}
{rules.filter((rule) => rule.mode !== 'free').length === 0 && (
<div className="profile-pricing-empty"></div>
)}
</div>
{/* 按时长细则 */}
{rules.some((r) => r.mode === 'duration') && (
<div className="profile-pricing-detail-section">
<div className="profile-pricing-detail-title">
<ClockIcon />
</div>
<div className="profile-pricing-detail-list">
{rules
.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} className="profile-pricing-detail-row">
<strong>{SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType}</strong>
<span>
{contentType}{rule.unit} {rule.pointsPerUnit} {unitNum}{unitNum}
{rule.sourceType === 'video' ? '使用系统素材每个空镜额外消耗 2 积分。' : ''}
</span>
</div>
);
})}
</div>
</div>
)}
{/* 说明 */}
<div className="profile-pricing-info-section">
<div className="profile-pricing-info-title">
<InfoIcon />
</div>
<ul className="profile-pricing-info-list">
<li>使</li>
<li></li>
</ul>
</div>
</div>
)}
</Modal>
);
}
@@ -768,7 +768,6 @@
cursor: pointer;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
margin-bottom: var(--spacing-sm);
}
.page-back-btn:hover {
@@ -780,6 +779,33 @@
flex-shrink: 0;
}
/* ── AppHeader ── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.app-header-left {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.app-header-title {
font-size: var(--font-xl);
font-weight: 600;
margin: 0;
}
.app-header-right {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.profile-edit-wrap {
display: flex;
align-items: center;
+17 -156
View File
@@ -2,9 +2,11 @@ import { useEffect, useState } from 'react';
import { useNavigation } from '../../contexts/NavigationContext';
import { useAuthStore } from '../../store';
import { client } from '../../api/client';
import { pointsApi, type PointBalance, type PointRule } from '../../api/modules/points';
import { pointsApi, type PointBalance } from '../../api/modules/points';
import RechargeModal from '../../components/RechargeModal/RechargeModal';
import Modal from '../../components/Modal/Modal';
import PricingModal from '../../components/PricingModal/PricingModal';
import PointsCard from '../../components/PointsCard/PointsCard';
import AppHeader from '../../components/Layout/AppHeader';
import '../ContentManagement/ContentManagement.css';
interface UserProfile {
@@ -14,19 +16,6 @@ interface UserProfile {
avatar: string;
}
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 {
if (!mobile || mobile.length !== 11) { return mobile; }
return `${mobile.slice(0, 3)}****${mobile.slice(7)}`;
@@ -56,12 +45,6 @@ const ChevronRightIcon = ({ className }: { className?: string }) => (
</svg>
);
const ClockIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
);
const EditIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
@@ -77,8 +60,6 @@ export default function Profile() {
const [todayConsumed, setTodayConsumed] = useState(0);
const [showRechargeModal, setShowRechargeModal] = useState(false);
const [showPricingModal, setShowPricingModal] = useState(false);
const [pricingRules, setPricingRules] = useState<PointRule[]>([]);
const [pricingLoading, setPricingLoading] = useState(false);
// 昵称编辑状态
const [nickname, setNickname] = useState('');
@@ -113,19 +94,8 @@ export default function Profile() {
loadData();
};
const handleOpenPricing = async () => {
const handleOpenPricing = () => {
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 () => {
@@ -165,9 +135,7 @@ export default function Profile() {
return (
<div className="settings-page">
<div className="settings-section">
<h2></h2>
</div>
<AppHeader title="我的账户" />
{/* 个人信息 + 积分 */}
<div className="card profile-card-flat">
@@ -229,42 +197,16 @@ export default function Profile() {
<div className="profile-divider" />
{/* 积分统计 */}
<div className="profile-points-section">
<div className="profile-points-grid">
<div className="profile-points-card">
<div className="profile-points-label"></div>
<div className="profile-points-value-row">
<span className="profile-points-value primary">{balance?.balance ?? 0}</span>
<span className="profile-points-unit"></span>
</div>
</div>
<div className="profile-points-card">
<div className="profile-points-label"></div>
<div className="profile-points-value-row">
<span className="profile-points-value danger">{todayConsumed}</span>
<span className="profile-points-unit"></span>
</div>
</div>
</div>
<div className="profile-points-actions-row">
<button className="btn btn-primary btn-sm" onClick={() => setShowRechargeModal(true)}>
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => {
localStorage.setItem('usage-detail-initial-tab', 'recharge');
navigate('usage-detail');
}}
>
</button>
<button className="btn btn-ghost btn-sm" onClick={handleOpenPricing}>
</button>
</div>
</div>
<PointsCard
balance={balance?.balance ?? 0}
todayConsumed={todayConsumed}
onRecharge={() => setShowRechargeModal(true)}
onViewDetail={() => {
localStorage.setItem('usage-detail-initial-tab', 'recharge');
navigate('usage-detail');
}}
onViewPricing={handleOpenPricing}
/>
</div>
{/* 设置入口 */}
@@ -293,88 +235,7 @@ export default function Profile() {
onRechargeSuccess={handleRechargeSuccess}
/>
<Modal open={showPricingModal} onClose={() => setShowPricingModal(false)} width="600px">
{pricingLoading ? (
<div className="profile-pricing-loading">
...
</div>
) : (
<div className="profile-pricing-body">
{/* 表格 */}
<div className="profile-pricing-table">
<div className="profile-pricing-header">
<span></span>
<span></span>
<span></span>
</div>
{pricingRules
.filter((rule) => rule.mode !== 'free')
.map((rule) => (
<div key={rule.sourceType} className="profile-pricing-row">
<span className="profile-pricing-name">
{SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType}
</span>
<span className="profile-pricing-mode">
<span className={`profile-pricing-tag ${rule.mode}`}>
{rule.mode === 'fixed' ? '固定' : '按时长'}
</span>
</span>
<span className="profile-pricing-points">
{rule.mode === 'fixed'
? `${rule.points} 积分/次`
: `${rule.unit} ${rule.pointsPerUnit} 积分`}
</span>
</div>
))}
{pricingRules.filter((rule) => rule.mode !== 'free').length === 0 && (
<div className="profile-pricing-empty"></div>
)}
</div>
{/* 按时长细则 */}
{pricingRules.some((r) => r.mode === 'duration') && (
<div className="profile-pricing-detail-section">
<div className="profile-pricing-detail-title">
<ClockIcon />
</div>
<div className="profile-pricing-detail-list">
{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} className="profile-pricing-detail-row">
<strong>{SOURCE_TYPE_LABELS[rule.sourceType] || rule.sourceType}</strong>
<span>
{contentType}{rule.unit} {rule.pointsPerUnit} {unitNum}{unitNum}
{rule.sourceType === 'video' ? '使用系统素材每个空镜额外消耗 2 积分。' : ''}
</span>
</div>
);
})}
</div>
</div>
)}
{/* 说明 */}
<div className="profile-pricing-info-section">
<div className="profile-pricing-info-title">
<InfoIcon />
</div>
<ul className="profile-pricing-info-list">
<li>使</li>
<li></li>
</ul>
</div>
</div>
)}
</Modal>
<PricingModal open={showPricingModal} onClose={() => setShowPricingModal(false)} />
</div>
);
}
+2 -7
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigation } from '../../contexts/NavigationContext';
import AppHeader from '../../components/Layout/AppHeader';
import { pointsApi, type PointTransaction } from '../../api/modules/points';
import DateRangePicker from '../../components/DatePicker/DateRangePicker';
import '../ContentManagement/ContentManagement.css';
@@ -365,14 +366,8 @@ export default function UsageDetail() {
return (
<div className="settings-page">
<button className="page-back-btn" onClick={() => navigate('profile')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<AppHeader title="积分明细" showBack onBack={() => navigate('profile')} />
<div className="settings-section">
<h2></h2>
{/* Tab 切换 */}
<div
+2 -7
View File
@@ -1,5 +1,6 @@
import { useState, useRef, useCallback } from 'react';
import '../ContentManagement/ContentManagement.css';
import AppHeader from '../../components/Layout/AppHeader';
import EnvironmentSwitchModal from '../../components/Modal/EnvironmentSwitchModal';
import { useNavigation } from '../../contexts/NavigationContext';
import { saveAppConfig } from '../../api/modules/config';
@@ -56,14 +57,8 @@ export default function AboutUs() {
return (
<div className="settings-page">
<button className="page-back-btn" onClick={() => navigate('profile')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<AppHeader title="关于我们" showBack onBack={() => navigate('profile')} />
<div className="settings-section">
<h2></h2>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div className="settings-row">
<span className="settings-row-label"></span>
@@ -7,6 +7,7 @@
import { useState } from 'react';
import { useNavigation } from '../../contexts/NavigationContext';
import AppHeader from '../../components/Layout/AppHeader';
import { check, type Update, type DownloadEvent } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import '../ContentManagement/ContentManagement.css';
@@ -109,14 +110,8 @@ export default function SystemUpdate() {
return (
<div className="settings-page">
<button className="page-back-btn" onClick={() => navigate('profile')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<AppHeader title="系统更新" showBack onBack={() => navigate('profile')} />
<div className="settings-section">
<h2></h2>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* 当前版本 */}
<div className="settings-row">