Files
qihuaiflow/frontend/src/pages/admin/Promotion.tsx
T

888 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-nocheck
/**
* Phase 4 剩余 - 推广系统管理页面
* 推广活动、分销链接、返佣记录、推广统计、排行榜
*/
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
// ==================== 类型定义 ====================
interface Promotion {
id: number;
title: string;
description: string;
reward_type: string;
reward_value: number;
min_payout: number;
start_date: string;
end_date: string;
status: string;
max_referrals: number;
coupon_id: string;
created_by: number;
creator_name: string;
created_at: string;
updated_at: string;
link_count: number;
total_rewards: number;
}
interface ReferralLink {
id: number;
code: string;
user_id: number;
username: string;
promotion_id: number;
promotion_title: string;
target_url: string;
channel: string;
qr_code_url: string;
total_clicks: number;
total_registrations: number;
total_conversions: number;
status: string;
created_at: string;
}
interface ReferralReward {
id: number;
referral_code: string;
referrer_name: string;
referee_name: string;
order_id: string;
reward_type: string;
reward_amount: number;
reward_points: number;
status: string;
payout_method: string;
paid_at: string;
created_at: string;
}
interface PromotionStats {
total_promotions: number;
active_promotions: number;
total_referral_links: number;
active_referral_links: number;
rewards_by_status: Record<string, { count: number; total: number }>;
total_clicks: number;
total_registrations: number;
total_conversions: number;
total_revenue: number;
total_rewards_paid: number;
}
// ==================== 主组件 ====================
const Promotion: React.FC = () => {
const [activeTab, setActiveTab] = useState('overview');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 数据
const [stats, setStats] = useState<PromotionStats | null>(null);
const [promotions, setPromotions] = useState<Promotion[]>([]);
const [referralLinks, setReferralLinks] = useState<ReferralLink[]>([]);
const [rewards, setRewards] = useState<ReferralReward[]>([]);
const [leaderboard, setLeaderboard] = useState<any[]>([]);
// 分页
const [promoPage, setPromoPage] = useState(1);
const [linkPage, setLinkPage] = useState(1);
const [rewardPage, setRewardPage] = useState(1);
const [totalPromos, setTotalPromos] = useState(0);
const [totalLinks, setTotalLinks] = useState(0);
const [totalRewards, setTotalRewards] = useState(0);
// 弹窗
const [showPromoModal, setShowPromoModal] = useState(false);
const [showLinkModal, setShowLinkModal] = useState(false);
const [editingPromo, setEditingPromo] = useState<Promotion | null>(null);
// 加载概览数据
const loadStats = useCallback(async () => {
try {
const res = await adminAPI.getPromotionStats();
if (res.data) {
setStats(res.data);
}
} catch (err: any) {
console.error('Failed to load stats:', err);
}
}, []);
// 加载推广活动
const loadPromotions = useCallback(async (page: number) => {
setLoading(true);
try {
const res = await adminAPI.getPromotions({ page, page_size: 20 });
if (res.data) {
setPromotions(res.data.items);
setTotalPromos(res.data.total);
setPromoPage(page);
}
} catch (err: any) {
setError(err.message || '加载失败');
} finally {
setLoading(false);
}
}, []);
// 加载分销链接
const loadLinks = useCallback(async (page: number) => {
setLoading(true);
try {
const res = await adminAPI.getReferralLinks({ page, page_size: 20 });
if (res.data) {
setReferralLinks(res.data.items);
setTotalLinks(res.data.total);
setLinkPage(page);
}
} catch (err: any) {
setError(err.message || '加载失败');
} finally {
setLoading(false);
}
}, []);
// 加载返佣记录
const loadRewards = useCallback(async (page: number) => {
setLoading(true);
try {
const res = await adminAPI.getReferralRewards({ page, page_size: 20 });
if (res.data) {
setRewards(res.data.items);
setTotalRewards(res.data.total);
setRewardPage(page);
}
} catch (err: any) {
setError(err.message || '加载失败');
} finally {
setLoading(false);
}
}, []);
// 加载排行榜
const loadLeaderboard = useCallback(async () => {
try {
const res = await adminAPI.getPromotionLeaderboard({ limit: 50 });
if (res.data) {
setLeaderboard(res.data.items || res.data.leaderboard || []);
}
} catch (err: any) {
console.error('Failed to load leaderboard:', err);
}
}, []);
useEffect(() => {
loadStats();
}, [loadStats]);
useEffect(() => {
if (activeTab === 'promotions') {
loadPromotions(1);
} else if (activeTab === 'links') {
loadLinks(1);
} else if (activeTab === 'rewards') {
loadRewards(1);
} else if (activeTab === 'leaderboard') {
loadLeaderboard();
}
}, [activeTab, loadPromotions, loadLinks, loadRewards, loadLeaderboard]);
// 创建/更新推广活动
const handleSavePromo = async (formData: any) => {
setLoading(true);
try {
if (editingPromo) {
await adminAPI.updatePromotion(editingPromo.id, formData);
} else {
await adminAPI.createPromotion(formData);
}
setShowPromoModal(false);
setEditingPromo(null);
loadPromotions(promoPage);
loadStats();
} catch (err: any) {
setError(err.message || '保存失败');
} finally {
setLoading(false);
}
};
// 切换活动状态
const handleTogglePromo = async (id: number) => {
try {
await adminAPI.togglePromotion(id);
loadPromotions(promoPage);
} catch (err: any) {
setError(err.message || '操作失败');
}
};
// 删除活动
const handleDeletePromo = async (id: number) => {
if (!window.confirm('确定删除此推广活动?')) return;
try {
await adminAPI.deletePromotion(id);
loadPromotions(promoPage);
loadStats();
} catch (err: any) {
setError(err.message || '删除失败');
}
};
// 生成分销链接
const handleGenerateLink = async (formData: any) => {
setLoading(true);
try {
await adminAPI.generateReferralLink(formData);
setShowLinkModal(false);
loadLinks(linkPage);
loadStats();
} catch (err: any) {
setError(err.message || '生成失败');
} finally {
setLoading(false);
}
};
// 审核返佣
const handleApproveReward = async (id: number) => {
try {
await adminAPI.approveReward(id);
loadRewards(rewardPage);
} catch (err: any) {
setError(err.message || '操作失败');
}
};
// 标记已打款
const handleMarkPaid = async (id: number) => {
const payout_method = prompt('请输入提现方式(wechat/alipay/bank/balance):');
const payout_account = prompt('请输入提现账号:');
if (!payout_method || !payout_account) return;
try {
await adminAPI.markRewardPaid(id, {
payout_method,
payout_account,
payout_transaction_id: `TXN${Date.now()}`
});
loadRewards(rewardPage);
} catch (err: any) {
setError(err.message || '操作失败');
}
};
// ==================== 渲染 ====================
const renderOverview = () => {
if (!stats) return <div className="loading">加载中...</div>;
// 计算返佣状态分布条的宽度
const totalRewardsCount = Object.values(stats.rewards_by_status || {}).reduce((a: number, b: any) => a + (b.count || 0), 0) || 1;
const statusBars = Object.entries(stats.rewards_by_status || {}).map(([status, data]: [string, any]) => ({
status,
data,
width: Math.min(100, ((data.count || 0) / totalRewardsCount) * 100)
}));
return (
<div className="overview-grid">
<div className="stat-card">
<div className="stat-icon">📢</div>
<div className="stat-content">
<div className="stat-value">{stats.total_promotions}</div>
<div className="stat-label">推广活动总数</div>
</div>
</div>
<div className="stat-card active">
<div className="stat-icon">✅</div>
<div className="stat-content">
<div className="stat-value">{stats.active_promotions}</div>
<div className="stat-label">进行中的活动</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">🔗</div>
<div className="stat-content">
<div className="stat-value">{stats.total_referral_links}</div>
<div className="stat-label">分销链接总数</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">👥</div>
<div className="stat-content">
<div className="stat-value">{stats.total_registrations || 0}</div>
<div className="stat-label">总注册数</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">💰</div>
<div className="stat-content">
<div className="stat-value">¥{(stats.total_revenue || 0).toFixed(2)}</div>
<div className="stat-label">产生收入</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">💸</div>
<div className="stat-content">
<div className="stat-value">¥{(stats.total_rewards_paid || 0).toFixed(2)}</div>
<div className="stat-label">已发返佣</div>
</div>
</div>
{/* 返佣状态分布 */}
{statusBars.length > 0 && (
<div className="chart-card full-width">
<h3>返佣状态分布</h3>
<div className="status-bars">
{statusBars.map(bar => (
<div key={bar.status} className="status-bar-item">
<span className="status-label">
{bar.status === 'pending' ? '待审核' : bar.status === 'approved' ? '已通过' : bar.status === 'paid' ? '已打款' : '已取消'}
</span>
<div className="status-bar">
<div className={`status-bar-fill ${bar.status}`} style={{ width: `${bar.width}%` }}></div>
</div>
<span className="status-count">{bar.data.count} 笔 (¥{bar.data.total.toFixed(2)})</span>
</div>
))}
</div>
</div>
)}
</div>
);
};
const renderPromotions = () => (
<div className="table-container">
<div className="table-header">
<h3>推广活动列表</h3>
<button className="btn-primary" onClick={() => { setEditingPromo(null); setShowPromoModal(true); }}>
+ 创建活动
</button>
</div>
{error && <div className="error-message">{error}</div>}
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th>活动标题</th>
<th>返佣类型</th>
<th>返佣值</th>
<th>状态</th>
<th>链接数</th>
<th>已发返佣</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{promotions.map(promo => (
<tr key={promo.id}>
<td>{promo.id}</td>
<td>
<div className="name-cell">
<div className="name">{promo.title}</div>
{promo.description && <div className="description">{promo.description}</div>}
</div>
</td>
<td>
<span className={`badge ${promo.reward_type}`}>
{promo.reward_type === 'percentage' ? '百分比' : promo.reward_type === 'fixed' ? '固定金额' : '积分'}
</span>
</td>
<td>
{promo.reward_type === 'percentage' ? `${promo.reward_value}%` :
promo.reward_type === 'fixed' ? `¥${promo.reward_value.toFixed(2)}` :
`${promo.reward_value} 分`}
</td>
<td>
<span className={`status-badge ${promo.status}`}>
{promo.status === 'active' ? '进行中' : promo.status === 'inactive' ? '已停用' : '已过期'}
</span>
</td>
<td>{promo.link_count || 0}</td>
<td>¥{(promo.total_rewards || 0).toFixed(2)}</td>
<td>{new Date(promo.created_at).toLocaleDateString()}</td>
<td>
<div className="action-buttons">
<button className="btn-sm" onClick={() => { setEditingPromo(promo); setShowPromoModal(true); }}>编辑</button>
<button className="btn-sm warning" onClick={() => handleTogglePromo(promo.id)}>
{promo.status === 'active' ? '停用' : '启用'}
</button>
<button className="btn-sm danger" onClick={() => handleDeletePromo(promo.id)}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* 分页 */}
<div className="pagination">
<button disabled={promoPage <= 1} onClick={() => loadPromotions(promoPage - 1)}>上一页</button>
<span>第 {promoPage} 页 / 共 {Math.ceil(totalPromos / 20)} 页</span>
<button disabled={promoPage >= Math.ceil(totalPromos / 20)} onClick={() => loadPromotions(promoPage + 1)}>下一页</button>
</div>
</div>
);
const renderLinks = () => (
<div className="table-container">
<div className="table-header">
<h3>分销链接列表</h3>
<button className="btn-primary" onClick={() => setShowLinkModal(true)}>
+ 生成链接
</button>
</div>
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th>推广短码</th>
<th>推广人</th>
<th>关联活动</th>
<th>点击数</th>
<th>注册数</th>
<th>转化数</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{referralLinks.map(link => (
<tr key={link.id}>
<td>{link.id}</td>
<td>
<code className="code">{link.code}</code>
<button className="btn-icon" onClick={() => { navigator.clipboard.writeText(link.code); alert('已复制'); }}>📋</button>
</td>
<td>{link.username}</td>
<td>{link.promotion_title || '-'}</td>
<td>{link.total_clicks || 0}</td>
<td>{link.total_registrations || 0}</td>
<td>{link.total_conversions || 0}</td>
<td>
<span className={`status-badge ${link.status}`}>
{link.status === 'active' ? '活跃' : '停用'}
</span>
</td>
<td>{new Date(link.created_at).toLocaleDateString()}</td>
<td>
<div className="action-buttons">
<button className="btn-sm" onClick={() => {
const url = `${link.target_url}?ref=${link.code}`;
navigator.clipboard.writeText(url);
alert('链接已复制');
}}>复制链接</button>
<button className="btn-sm warning" onClick={async () => {
if (window.confirm('确定重新生成推广短码?')) {
try {
await adminAPI.regenerateReferralCode(link.id);
loadLinks(linkPage);
} catch (err: any) {
setError(err.message || '操作失败');
}
}
}}>重新生成</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
const renderRewards = () => (
<div className="table-container">
<div className="table-header">
<h3>返佣记录</h3>
</div>
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th>推广码</th>
<th>推广人</th>
<th>被推荐人</th>
<th>订单号</th>
<th>返佣类型</th>
<th>返佣金额</th>
<th>状态</th>
<th>打款时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{rewards.map(reward => (
<tr key={reward.id}>
<td>{reward.id}</td>
<td><code className="code">{reward.referral_code}</code></td>
<td>{reward.referrer_name}</td>
<td>{reward.referee_name}</td>
<td>{reward.order_id || '-'}</td>
<td>
<span className="badge">
{reward.reward_type === 'registration' ? '注册奖励' :
reward.reward_type === 'first_purchase' ? '首购返佣' : '续费返佣'}
</span>
</td>
<td>¥{reward.reward_amount.toFixed(2)}</td>
<td>
<span className={`status-badge ${reward.status}`}>
{reward.status === 'pending' ? '待审核' :
reward.status === 'approved' ? '已通过' :
reward.status === 'paid' ? '已打款' : '已取消'}
</span>
</td>
<td>{reward.paid_at ? new Date(reward.paid_at).toLocaleDateString() : '-'}</td>
<td>
<div className="action-buttons">
{reward.status === 'pending' && (
<button className="btn-sm" onClick={() => handleApproveReward(reward.id)}>审核通过</button>
)}
{reward.status === 'approved' && (
<button className="btn-sm warning" onClick={() => handleMarkPaid(reward.id)}>标记已打款</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
const renderLeaderboard = () => (
<div className="table-container">
<div className="table-header">
<h3>推广排行榜</h3>
</div>
<table className="data-table">
<thead>
<tr>
<th>排名</th>
<th>用户</th>
<th>点击数</th>
<th>注册数</th>
<th>转化数</th>
<th>产生收入</th>
<th>获得返佣</th>
</tr>
</thead>
<tbody>
{leaderboard.map((entry, index) => (
<tr key={entry.user_id}>
<td>
<span className={`rank ${index < 3 ? 'top' : ''}`}>
{index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : entry.rank}
</span>
</td>
<td>{entry.username}</td>
<td>{entry.total_clicks || 0}</td>
<td>{entry.total_registrations || 0}</td>
<td>{entry.total_conversions || 0}</td>
<td>¥{(entry.total_revenue || 0).toFixed(2)}</td>
<td>¥{(entry.total_rewards || 0).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
return (
<div className="promotion-page">
<div className="page-header">
<h2>📢 推广系统管理</h2>
<p>管理推广活动、分销链接、返佣记录和推广统计</p>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="tabs">
<button className={`tab ${activeTab === 'overview' ? 'active' : ''}`} onClick={() => setActiveTab('overview')}>
📊 概览
</button>
<button className={`tab ${activeTab === 'promotions' ? 'active' : ''}`} onClick={() => setActiveTab('promotions')}>
📢 推广活动
</button>
<button className={`tab ${activeTab === 'links' ? 'active' : ''}`} onClick={() => setActiveTab('links')}>
🔗 分销链接
</button>
<button className={`tab ${activeTab === 'rewards' ? 'active' : ''}`} onClick={() => setActiveTab('rewards')}>
💰 返佣记录
</button>
<button className={`tab ${activeTab === 'leaderboard' ? 'active' : ''}`} onClick={() => setActiveTab('leaderboard')}>
🏆 排行榜
</button>
</div>
<div className="tab-content">
{activeTab === 'overview' && renderOverview()}
{activeTab === 'promotions' && renderPromotions()}
{activeTab === 'links' && renderLinks()}
{activeTab === 'rewards' && renderRewards()}
{activeTab === 'leaderboard' && renderLeaderboard()}
</div>
{/* 推广活动弹窗 */}
{showPromoModal && (
<PromotionModal
promotion={editingPromo}
onSave={handleSavePromo}
onCancel={() => { setShowPromoModal(false); setEditingPromo(null); }}
/>
)}
{/* 生成链接弹窗 */}
{showLinkModal && (
<LinkModal
onSave={handleGenerateLink}
onCancel={() => setShowLinkModal(false)}
/>
)}
</div>
);
};
// ==================== 推广活动弹窗 ====================
interface PromotionModalProps {
promotion: Promotion | null;
onSave: (data: any) => void;
onCancel: () => void;
}
const PromotionModal: React.FC<PromotionModalProps> = ({ promotion, onSave, onCancel }) => {
const [formData, setFormData] = useState({
title: promotion?.title || '',
description: promotion?.description || '',
reward_type: promotion?.reward_type || 'percentage',
reward_value: promotion?.reward_value || 5,
min_payout: promotion?.min_payout || 10,
start_date: promotion?.start_date ? promotion.start_date.slice(0, 16) : '',
end_date: promotion?.end_date ? promotion.end_date.slice(0, 16) : '',
max_referrals: promotion?.max_referrals || 0,
status: promotion?.status || 'active'
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
return (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">
<h3>{promotion ? '编辑推广活动' : '创建推广活动'}</h3>
<button className="close-btn" onClick={onCancel}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="form-group">
<label>活动标题 *</label>
<input
type="text"
required
value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })}
placeholder="例如:新用户推广计划"
/>
</div>
<div className="form-group">
<label>活动描述</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
placeholder="活动说明..."
rows={3}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>返佣类型 *</label>
<select
value={formData.reward_type}
onChange={e => setFormData({ ...formData, reward_type: e.target.value })}
>
<option value="percentage">百分比</option>
<option value="fixed">固定金额</option>
<option value="points">积分</option>
</select>
</div>
<div className="form-group">
<label>返佣值 *</label>
<input
type="number"
required
step={formData.reward_type === 'percentage' ? 1 : 0.01}
min={0}
value={formData.reward_value}
onChange={e => setFormData({ ...formData, reward_value: parseFloat(e.target.value) })}
placeholder={formData.reward_type === 'percentage' ? '5 表示 5%' : '100 表示 100元'}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>最低提现金额</label>
<input
type="number"
step={0.01}
min={0}
value={formData.min_payout}
onChange={e => setFormData({ ...formData, min_payout: parseFloat(e.target.value) })}
/>
</div>
<div className="form-group">
<label>最大推广人数 (0=无限制)</label>
<input
type="number"
min={0}
value={formData.max_referrals}
onChange={e => setFormData({ ...formData, max_referrals: parseInt(e.target.value) })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>开始时间 *</label>
<input
type="datetime-local"
required
value={formData.start_date}
onChange={e => setFormData({ ...formData, start_date: e.target.value })}
/>
</div>
<div className="form-group">
<label>结束时间 (可选)</label>
<input
type="datetime-local"
value={formData.end_date}
onChange={e => setFormData({ ...formData, end_date: e.target.value })}
/>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={onCancel}>取消</button>
<button type="submit" className="btn-primary">保存</button>
</div>
</form>
</div>
</div>
);
};
// ==================== 生成链接弹窗 ====================
interface LinkModalProps {
onSave: (data: any) => void;
onCancel: () => void;
}
const LinkModal: React.FC<LinkModalProps> = ({ onSave, onCancel }) => {
const [formData, setFormData] = useState({
promotion_id: '',
target_url: 'https://bot.qihuyun.com/register',
channel: 'link'
});
const [promotions, setPromotions] = useState<Promotion[]>([]);
useEffect(() => {
adminAPI.getPromotions({ status: 'active', page: 1, page_size: 100 })
.then(res => {
if (res.data) setPromotions(res.data.items);
})
.catch(err => console.error('Failed to load active promotions:', err));
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
promotion_id: parseInt(formData.promotion_id),
target_url: formData.target_url,
channel: formData.channel
});
};
return (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">
<h3>生成分销链接</h3>
<button className="close-btn" onClick={onCancel}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="form-group">
<label>选择推广活动 *</label>
<select
required
value={formData.promotion_id}
onChange={e => setFormData({ ...formData, promotion_id: e.target.value })}
>
<option value="">-- 请选择 --</option>
{promotions.map(p => (
<option key={p.id} value={p.id}>{p.title}</option>
))}
</select>
</div>
<div className="form-group">
<label>目标URL *</label>
<input
type="url"
required
value={formData.target_url}
onChange={e => setFormData({ ...formData, target_url: e.target.value })}
placeholder="https://bot.qihuyun.com/register"
/>
</div>
<div className="form-group">
<label>推广渠道</label>
<select
value={formData.channel}
onChange={e => setFormData({ ...formData, channel: e.target.value })}
>
<option value="link">链接</option>
<option value="qrcode">二维码</option>
<option value="api">API</option>
</select>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={onCancel}>取消</button>
<button type="submit" className="btn-primary">生成链接</button>
</div>
</form>
</div>
</div>
);
};
export default Promotion;