Phase4剩余: 推广+成就系统前后端完成 (promotion + achievement)
- 新增: Achievement.tsx 成就系统前端页面 (644行) - 新增: achievement_admin.py 成就系统后端API (9个端点) - 新增: migration SQL (achievements, user_achievements, achievement_progress_log) - 更新: api/index.ts 添加推广+成就API方法 (23个方法) - 更新: App.tsx 添加 /admin/promotion 和 /admin/achievement 路由 - 更新: AdminLayout.tsx 添加成就系统导航项和图标 - 修复: Promotion.tsx 导入路径 (@/utils/api → @/api) - 前端构建: 472 modules, 11.23s, 成功 - API测试: 全部6个端点返回 code=0
This commit is contained in:
1 parent
7daeae6fc7
commit
0886c26157
8 files changed
+2674
-447
No files matched your search
@@ -0,0 +1,644 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Phase 4 剩余 - 成就系统管理页面
|
||||
* 成就管理、用户进度、排行榜、奖励领取
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
interface Achievement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
category: string;
|
||||
criteria_type: string;
|
||||
criteria_key: string | null;
|
||||
criteria_value: number;
|
||||
reward_type: string;
|
||||
reward_value: number;
|
||||
badge_icon: string | null;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
user_progress?: number;
|
||||
is_completed?: boolean;
|
||||
reward_claimed?: boolean;
|
||||
completed_at?: string | null;
|
||||
}
|
||||
|
||||
interface AchievementStats {
|
||||
total_achievements: number;
|
||||
active_achievements: number;
|
||||
completed_achievements: number;
|
||||
rewards_claimed: number;
|
||||
total_points: number;
|
||||
by_category: Record<string, number>;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
usage: '使用量',
|
||||
social: '社交',
|
||||
content: '内容创作',
|
||||
payment: '付费',
|
||||
special: '特殊',
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
usage: '#6C63FF',
|
||||
social: '#00D4AA',
|
||||
content: '#FFB800',
|
||||
payment: '#FF6B6B',
|
||||
special: '#C084FC',
|
||||
};
|
||||
|
||||
const REWARD_LABELS: Record<string, string> = {
|
||||
points: '积分',
|
||||
badge: '徽章',
|
||||
discount: '折扣',
|
||||
feature: '功能解锁',
|
||||
credits: '额度',
|
||||
};
|
||||
|
||||
const CRITERIA_LABELS: Record<string, string> = {
|
||||
message_count: '消息数量',
|
||||
conversation_count: '对话数量',
|
||||
token_usage: 'Token用量',
|
||||
referral_count: '推荐人数',
|
||||
kb_doc_count: '知识库文档数',
|
||||
agent_count: 'Agent数量',
|
||||
skill_count: '技能数量',
|
||||
payment_amount: '付费金额',
|
||||
days_active: '活跃天数',
|
||||
workflow_count: '工作流数量',
|
||||
};
|
||||
|
||||
// ==================== 主组件 ====================
|
||||
const Achievement: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [stats, setStats] = useState<AchievementStats | null>(null);
|
||||
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
||||
const [userProgress, setUserProgress] = useState<Achievement[]>([]);
|
||||
const [leaderboard, setLeaderboard] = useState<any[]>([]);
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
|
||||
// Modal states
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingAch, setEditingAch] = useState<Achievement | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'usage',
|
||||
criteria_type: 'message_count',
|
||||
criteria_value: 100,
|
||||
reward_type: 'points',
|
||||
reward_value: 100,
|
||||
icon: '',
|
||||
});
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const allRes = await adminAPI.getAchievements('');
|
||||
const items = allRes.data?.items || [];
|
||||
const active = items.filter((a: Achievement) => a.is_active);
|
||||
const completed = items.filter((a: Achievement) => a.is_completed);
|
||||
const claimed = items.filter((a: Achievement) => a.reward_claimed);
|
||||
const totalPoints = items
|
||||
.filter((a: Achievement) => a.is_completed)
|
||||
.reduce((sum: number, a: Achievement) => sum + (a.reward_value || 0), 0);
|
||||
const byCategory: Record<string, number> = {};
|
||||
items.forEach((a: Achievement) => {
|
||||
byCategory[a.category] = (byCategory[a.category] || 0) + 1;
|
||||
});
|
||||
setStats({
|
||||
total_achievements: items.length,
|
||||
active_achievements: active.length,
|
||||
completed_achievements: completed.length,
|
||||
rewards_claimed: claimed.length,
|
||||
total_points: totalPoints,
|
||||
by_category: byCategory,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load stats:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAchievements = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const params = categoryFilter ? `?category=${categoryFilter}` : '';
|
||||
const res = await adminAPI.getAchievements(params);
|
||||
setAchievements(res.data?.items || []);
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [categoryFilter]);
|
||||
|
||||
const loadUserProgress = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getUserAchievements();
|
||||
setUserProgress(res.data?.items || []);
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadLeaderboard = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getAchievementLeaderboard('?period=all');
|
||||
setLeaderboard(res.data?.items || []);
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'list') loadAchievements();
|
||||
else if (activeTab === 'progress') loadUserProgress();
|
||||
else if (activeTab === 'leaderboard') loadLeaderboard();
|
||||
}, [activeTab, loadAchievements, loadUserProgress, loadLeaderboard]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
setError('成就名称不能为空');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = `?name=${encodeURIComponent(formData.name)}&category=${formData.category}&criteria_type=${formData.criteria_type}&criteria_value=${formData.criteria_value}&description=${encodeURIComponent(formData.description)}&reward_type=${formData.reward_type}&reward_value=${formData.reward_value}&icon=${encodeURIComponent(formData.icon)}`;
|
||||
if (editingAch) {
|
||||
const updateParams = `?name=${encodeURIComponent(formData.name)}&description=${encodeURIComponent(formData.description)}&reward_value=${formData.reward_value}`;
|
||||
await adminAPI.updateAchievement(editingAch.id, updateParams);
|
||||
} else {
|
||||
await adminAPI.createAchievement(params);
|
||||
}
|
||||
setShowCreateModal(false);
|
||||
setEditingAch(null);
|
||||
setFormData({
|
||||
name: '', description: '', category: 'usage', criteria_type: 'message_count',
|
||||
criteria_value: 100, reward_type: 'points', reward_value: 100, icon: '',
|
||||
});
|
||||
loadAchievements();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '保存失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这个成就吗?')) return;
|
||||
try {
|
||||
await adminAPI.deleteAchievement(id);
|
||||
loadAchievements();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (ach: Achievement) => {
|
||||
try {
|
||||
const params = `?is_active=${!ach.is_active}`;
|
||||
await adminAPI.updateAchievement(ach.id, params);
|
||||
loadAchievements();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClaim = async (id: string) => {
|
||||
try {
|
||||
await adminAPI.claimAchievementReward(id);
|
||||
loadUserProgress();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '领取失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckProgress = async () => {
|
||||
try {
|
||||
await adminAPI.checkAchievementProgress();
|
||||
loadUserProgress();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '检查失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (ach: Achievement) => {
|
||||
setEditingAch(ach);
|
||||
setFormData({
|
||||
name: ach.name,
|
||||
description: ach.description || '',
|
||||
category: ach.category,
|
||||
criteria_type: ach.criteria_type,
|
||||
criteria_value: ach.criteria_value,
|
||||
reward_type: ach.reward_type,
|
||||
reward_value: ach.reward_value,
|
||||
icon: ach.icon || '',
|
||||
});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const getProgressPercent = (current: number, target: number) => {
|
||||
if (target <= 0) return 0;
|
||||
return Math.min(100, Math.round((current / target) * 100));
|
||||
};
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
return (
|
||||
<div className="admin-page" style={{ padding: '24px' }}>
|
||||
<style>{`
|
||||
.ach-tabs { display: flex; gap: 4px; margin-bottom: 24px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.ach-tab { padding: 10px 20px; cursor: pointer; color: rgba(255,255,255,0.5); font-size: 14px; border-bottom: 2px solid transparent; transition: all 0.2s; background: none; border-top: none; border-left: none; border-right: none; }
|
||||
.ach-tab:hover { color: rgba(255,255,255,0.8); }
|
||||
.ach-tab.active { color: #6C63FF; border-bottom-color: #6C63FF; }
|
||||
.ach-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.ach-stat-card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 20px; }
|
||||
.ach-stat-card .label { font-size: 12px; color: rgba(255,255,255,0.4); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.ach-stat-card .value { font-size: 28px; font-weight: 700; color: #fff; }
|
||||
.ach-cat-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
.ach-cat-bar .cat-label { width: 80px; font-size: 13px; color: rgba(255,255,255,0.6); }
|
||||
.ach-cat-bar .cat-track { flex: 1; height: 8px; background: rgba(255,255,255,0.06); border-radius: 4px; overflow: hidden; }
|
||||
.ach-cat-bar .cat-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
|
||||
.ach-cat-bar .cat-count { width: 30px; text-align: right; font-size: 13px; color: rgba(255,255,255,0.8); font-weight: 600; }
|
||||
.ach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||
.ach-card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 20px; transition: all 0.2s; position: relative; }
|
||||
.ach-card:hover { border-color: rgba(108,99,255,0.3); background: rgba(255,255,255,0.05); }
|
||||
.ach-card.completed { border-color: rgba(0,212,170,0.3); }
|
||||
.ach-card .ach-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; margin-bottom: 12px; }
|
||||
.ach-card .ach-name { font-size: 16px; font-weight: 600; color: #fff; margin-bottom: 4px; }
|
||||
.ach-card .ach-desc { font-size: 13px; color: rgba(255,255,255,0.4); margin-bottom: 12px; line-height: 1.5; }
|
||||
.ach-card .ach-meta { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.ach-badge { padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; }
|
||||
.ach-progress-bar { height: 6px; background: rgba(255,255,255,0.06); border-radius: 3px; overflow: hidden; margin-bottom: 8px; }
|
||||
.ach-progress-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, #6C63FF, #8B80FF); transition: width 0.3s; }
|
||||
.ach-progress-fill.done { background: linear-gradient(90deg, #00D4AA, #00E4B4); }
|
||||
.ach-progress-text { display: flex; justify-content: space-between; font-size: 12px; color: rgba(255,255,255,0.4); }
|
||||
.ach-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.ach-btn { padding: 6px 12px; border-radius: 6px; font-size: 12px; cursor: pointer; border: none; transition: all 0.2s; }
|
||||
.ach-btn-primary { background: #6C63FF; color: #fff; }
|
||||
.ach-btn-primary:hover { background: #5B52E0; }
|
||||
.ach-btn-danger { background: rgba(255,107,107,0.15); color: #FF6B6B; }
|
||||
.ach-btn-danger:hover { background: rgba(255,107,107,0.25); }
|
||||
.ach-btn-success { background: rgba(0,212,170,0.15); color: #00D4AA; }
|
||||
.ach-btn-success:hover { background: rgba(0,212,170,0.25); }
|
||||
.ach-btn-ghost { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.6); }
|
||||
.ach-btn-ghost:hover { background: rgba(255,255,255,0.1); }
|
||||
.ach-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.ach-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.ach-select { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 8px 12px; color: #fff; font-size: 13px; }
|
||||
.ach-select option { background: #1A1A2E; }
|
||||
.ach-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.ach-modal { background: #1E1E36; border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 28px; width: 480px; max-width: 90vw; max-height: 90vh; overflow-y: auto; }
|
||||
.ach-modal h2 { font-size: 20px; color: #fff; margin-bottom: 20px; }
|
||||
.ach-field { margin-bottom: 16px; }
|
||||
.ach-field label { display: block; font-size: 13px; color: rgba(255,255,255,0.5); margin-bottom: 6px; }
|
||||
.ach-field input, .ach-field select, .ach-field textarea { width: 100%; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 10px 12px; color: #fff; font-size: 14px; box-sizing: border-box; }
|
||||
.ach-field input:focus, .ach-field select:focus, .ach-field textarea:focus { outline: none; border-color: #6C63FF; }
|
||||
.ach-field select option { background: #1A1A2E; }
|
||||
.ach-field-row { display: flex; gap: 12px; }
|
||||
.ach-field-row .ach-field { flex: 1; }
|
||||
.ach-empty { text-align: center; padding: 60px 20px; color: rgba(255,255,255,0.3); }
|
||||
.ach-lb-table { width: 100%; border-collapse: collapse; }
|
||||
.ach-lb-table th { text-align: left; padding: 12px 16px; font-size: 12px; color: rgba(255,255,255,0.4); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.ach-lb-table td { padding: 14px 16px; font-size: 14px; color: rgba(255,255,255,0.8); border-bottom: 1px solid rgba(255,255,255,0.04); }
|
||||
.ach-lb-rank { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; font-size: 13px; font-weight: 700; }
|
||||
.ach-lb-rank.gold { background: rgba(255,184,0,0.15); color: #FFB800; }
|
||||
.ach-lb-rank.silver { background: rgba(192,192,192,0.15); color: #C0C0C0; }
|
||||
.ach-lb-rank.bronze { background: rgba(205,127,50,0.15); color: #CD7F32; }
|
||||
.ach-lb-rank.normal { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5); }
|
||||
.ach-error { background: rgba(255,107,107,0.1); border: 1px solid rgba(255,107,107,0.2); border-radius: 8px; padding: 12px 16px; color: #FF6B6B; font-size: 13px; margin-bottom: 16px; }
|
||||
`}</style>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, color: '#fff', margin: 0 }}>成就系统</h1>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{activeTab === 'list' && (
|
||||
<button className="ach-btn ach-btn-primary" onClick={() => { setEditingAch(null); setFormData({ name: '', description: '', category: 'usage', criteria_type: 'message_count', criteria_value: 100, reward_type: 'points', reward_value: 100, icon: '' }); setShowCreateModal(true); }}>
|
||||
+ 创建成就
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'progress' && (
|
||||
<button className="ach-btn ach-btn-ghost" onClick={handleCheckProgress}>检查进度</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="ach-error">{error}</div>}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="ach-tabs">
|
||||
<button className={`ach-tab ${activeTab === 'overview' ? 'active' : ''}`} onClick={() => setActiveTab('overview')}>概览</button>
|
||||
<button className={`ach-tab ${activeTab === 'list' ? 'active' : ''}`} onClick={() => setActiveTab('list')}>成就管理</button>
|
||||
<button className={`ach-tab ${activeTab === 'progress' ? 'active' : ''}`} onClick={() => setActiveTab('progress')}>我的进度</button>
|
||||
<button className={`ach-tab ${activeTab === 'leaderboard' ? 'active' : ''}`} onClick={() => setActiveTab('leaderboard')}>排行榜</button>
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && stats && (
|
||||
<div>
|
||||
<div className="ach-stat-grid">
|
||||
<div className="ach-stat-card">
|
||||
<div className="label">总成就数</div>
|
||||
<div className="value">{stats.total_achievements}</div>
|
||||
</div>
|
||||
<div className="ach-stat-card">
|
||||
<div className="label">已激活</div>
|
||||
<div className="value" style={{ color: '#6C63FF' }}>{stats.active_achievements}</div>
|
||||
</div>
|
||||
<div className="ach-stat-card">
|
||||
<div className="label">已完成</div>
|
||||
<div className="value" style={{ color: '#00D4AA' }}>{stats.completed_achievements}</div>
|
||||
</div>
|
||||
<div className="ach-stat-card">
|
||||
<div className="label">奖励已领取</div>
|
||||
<div className="value" style={{ color: '#FFB800' }}>{stats.rewards_claimed}</div>
|
||||
</div>
|
||||
<div className="ach-stat-card">
|
||||
<div className="label">累计积分</div>
|
||||
<div className="value" style={{ color: '#C084FC' }}>{stats.total_points}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '16px', color: '#fff', marginBottom: '20px' }}>分类分布</h3>
|
||||
{Object.entries(stats.by_category).map(([cat, count]) => {
|
||||
const pct = stats.total_achievements > 0 ? (count / stats.total_achievements) * 100 : 0;
|
||||
return (
|
||||
<div key={cat} className="ach-cat-bar">
|
||||
<span className="cat-label">{CATEGORY_LABELS[cat] || cat}</span>
|
||||
<div className="cat-track">
|
||||
<div className="cat-fill" style={{ width: `${pct}%`, background: CATEGORY_COLORS[cat] || '#6C63FF' }} />
|
||||
</div>
|
||||
<span className="cat-count">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.keys(stats.by_category).length === 0 && (
|
||||
<div className="ach-empty">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List Tab */}
|
||||
{activeTab === 'list' && (
|
||||
<div>
|
||||
<div className="ach-toolbar">
|
||||
<select className="ach-select" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
|
||||
<option value="">全部分类</option>
|
||||
{Object.entries(CATEGORY_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="ach-empty">加载中...</div>
|
||||
) : achievements.length === 0 ? (
|
||||
<div className="ach-empty">暂无成就数据</div>
|
||||
) : (
|
||||
<div className="ach-grid">
|
||||
{achievements.map((ach) => {
|
||||
const progressPct = getProgressPercent(ach.user_progress || 0, ach.criteria_value);
|
||||
const color = CATEGORY_COLORS[ach.category] || '#6C63FF';
|
||||
return (
|
||||
<div key={ach.id} className={`ach-card ${ach.is_completed ? 'completed' : ''}`}>
|
||||
<div className="ach-icon" style={{ background: `${color}20`, color }}>
|
||||
{ach.icon || CATEGORY_LABELS[ach.category]?.[0] || '★'}
|
||||
</div>
|
||||
<div className="ach-name">{ach.name}</div>
|
||||
<div className="ach-desc">{ach.description || '暂无描述'}</div>
|
||||
<div className="ach-meta">
|
||||
<span className="ach-badge" style={{ background: `${color}20`, color }}>{CATEGORY_LABELS[ach.category] || ach.category}</span>
|
||||
<span className="ach-badge" style={{ background: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.6)' }}>
|
||||
{CRITERIA_LABELS[ach.criteria_type] || ach.criteria_type}: {ach.criteria_value}
|
||||
</span>
|
||||
<span className="ach-badge" style={{ background: 'rgba(255,184,0,0.1)', color: '#FFB800' }}>
|
||||
{REWARD_LABELS[ach.reward_type] || ach.reward_type}: {ach.reward_value}
|
||||
</span>
|
||||
{!ach.is_active && (
|
||||
<span className="ach-badge" style={{ background: 'rgba(255,107,107,0.1)', color: '#FF6B6B' }}>已停用</span>
|
||||
)}
|
||||
</div>
|
||||
{ach.user_progress !== undefined && (
|
||||
<>
|
||||
<div className="ach-progress-bar">
|
||||
<div className={`ach-progress-fill ${ach.is_completed ? 'done' : ''}`} style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
<div className="ach-progress-text">
|
||||
<span>{ach.user_progress || 0} / {ach.criteria_value}</span>
|
||||
<span>{progressPct}%</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="ach-actions">
|
||||
<button className="ach-btn ach-btn-ghost" onClick={() => openEdit(ach)}>编辑</button>
|
||||
<button className="ach-btn ach-btn-ghost" onClick={() => handleToggle(ach)}>
|
||||
{ach.is_active ? '停用' : '启用'}
|
||||
</button>
|
||||
<button className="ach-btn ach-btn-danger" onClick={() => handleDelete(ach.id)}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Tab */}
|
||||
{activeTab === 'progress' && (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="ach-empty">加载中...</div>
|
||||
) : userProgress.length === 0 ? (
|
||||
<div className="ach-empty">暂无进度数据</div>
|
||||
) : (
|
||||
<div className="ach-grid">
|
||||
{userProgress.map((ach) => {
|
||||
const progressPct = getProgressPercent(ach.progress || 0, ach.criteria_value);
|
||||
const color = CATEGORY_COLORS[ach.category] || '#6C63FF';
|
||||
return (
|
||||
<div key={ach.id} className={`ach-card ${ach.is_completed ? 'completed' : ''}`}>
|
||||
<div className="ach-icon" style={{ background: `${color}20`, color }}>
|
||||
{ach.icon || CATEGORY_LABELS[ach.category]?.[0] || '★'}
|
||||
</div>
|
||||
<div className="ach-name">{ach.name}</div>
|
||||
<div className="ach-desc">{ach.description || '暂无描述'}</div>
|
||||
<div className="ach-meta">
|
||||
<span className="ach-badge" style={{ background: `${color}20`, color }}>{CATEGORY_LABELS[ach.category] || ach.category}</span>
|
||||
{ach.is_completed && (
|
||||
<span className="ach-badge" style={{ background: 'rgba(0,212,170,0.15)', color: '#00D4AA' }}>已完成</span>
|
||||
)}
|
||||
{ach.reward_claimed && (
|
||||
<span className="ach-badge" style={{ background: 'rgba(255,184,0,0.15)', color: '#FFB800' }}>已领取</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ach-progress-bar">
|
||||
<div className={`ach-progress-fill ${ach.is_completed ? 'done' : ''}`} style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
<div className="ach-progress-text">
|
||||
<span>{ach.progress || 0} / {ach.criteria_value}</span>
|
||||
<span>{progressPct}%</span>
|
||||
</div>
|
||||
{ach.completed_at && (
|
||||
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.3)', marginTop: '8px' }}>
|
||||
完成时间: {new Date(ach.completed_at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
<div className="ach-actions">
|
||||
{ach.is_completed && !ach.reward_claimed && (
|
||||
<button className="ach-btn ach-btn-success" onClick={() => handleClaim(ach.id)}>
|
||||
领取奖励
|
||||
</button>
|
||||
)}
|
||||
{ach.reward_claimed && (
|
||||
<span style={{ fontSize: '12px', color: 'rgba(255,184,0,0.6)' }}>奖励已领取</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leaderboard Tab */}
|
||||
{activeTab === 'leaderboard' && (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="ach-empty">加载中...</div>
|
||||
) : leaderboard.length === 0 ? (
|
||||
<div className="ach-empty">
|
||||
<div style={{ fontSize: '16px', marginBottom: '8px' }}>排行榜暂无数据</div>
|
||||
<div style={{ fontSize: '13px' }}>用户完成成就后将出现在排行榜中</div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="ach-lb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>用户</th>
|
||||
<th>完成成就</th>
|
||||
<th>累计积分</th>
|
||||
<th>最近完成</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leaderboard.map((entry, idx) => (
|
||||
<tr key={entry.user_id || idx}>
|
||||
<td>
|
||||
<span className={`ach-lb-rank ${idx === 0 ? 'gold' : idx === 1 ? 'silver' : idx === 2 ? 'bronze' : 'normal'}`}>
|
||||
{idx + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td>{entry.username || entry.user_id || '匿名用户'}</td>
|
||||
<td>{entry.achievement_count || 0}</td>
|
||||
<td style={{ color: '#FFB800', fontWeight: 600 }}>{entry.total_points || 0}</td>
|
||||
<td style={{ color: 'rgba(255,255,255,0.4)' }}>
|
||||
{entry.last_completed ? new Date(entry.last_completed).toLocaleDateString('zh-CN') : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="ach-modal-overlay" onClick={() => setShowCreateModal(false)}>
|
||||
<div className="ach-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>{editingAch ? '编辑成就' : '创建成就'}</h2>
|
||||
<div className="ach-field">
|
||||
<label>成就名称 *</label>
|
||||
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="例如:对话达人" />
|
||||
</div>
|
||||
<div className="ach-field">
|
||||
<label>描述</label>
|
||||
<textarea rows={2} value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} placeholder="成就描述" />
|
||||
</div>
|
||||
<div className="ach-field-row">
|
||||
<div className="ach-field">
|
||||
<label>分类</label>
|
||||
<select value={formData.category} onChange={(e) => setFormData({ ...formData, category: e.target.value })} disabled={!!editingAch}>
|
||||
{Object.entries(CATEGORY_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="ach-field">
|
||||
<label>图标</label>
|
||||
<input type="text" value={formData.icon} onChange={(e) => setFormData({ ...formData, icon: e.target.value })} placeholder="emoji 或文字" />
|
||||
</div>
|
||||
</div>
|
||||
{!editingAch && (
|
||||
<>
|
||||
<div className="ach-field-row">
|
||||
<div className="ach-field">
|
||||
<label>条件类型</label>
|
||||
<select value={formData.criteria_type} onChange={(e) => setFormData({ ...formData, criteria_type: e.target.value })}>
|
||||
{Object.entries(CRITERIA_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="ach-field">
|
||||
<label>目标值</label>
|
||||
<input type="number" value={formData.criteria_value} onChange={(e) => setFormData({ ...formData, criteria_value: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="ach-field-row">
|
||||
<div className="ach-field">
|
||||
<label>奖励类型</label>
|
||||
<select value={formData.reward_type} onChange={(e) => setFormData({ ...formData, reward_type: e.target.value })}>
|
||||
{Object.entries(REWARD_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="ach-field">
|
||||
<label>奖励数值</label>
|
||||
<input type="number" value={formData.reward_value} onChange={(e) => setFormData({ ...formData, reward_value: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
||||
<button className="ach-btn ach-btn-primary" style={{ flex: 1, padding: '10px' }} onClick={handleSave} disabled={loading}>
|
||||
{editingAch ? '保存修改' : '创建成就'}
|
||||
</button>
|
||||
<button className="ach-btn ach-btn-ghost" style={{ flex: 1, padding: '10px' }} onClick={() => setShowCreateModal(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Achievement;
|
||||
Reference in new issue
Block a user