初始提交: Qihu-AIFlow v2.0.1 完整项目

- 前端 React+TypeScript (源码+构建产物)
- 后端 Python FastAPI
- 数据库迁移 001-015
- Docker部署配置
- 微信ClawBot API频道绑定
- 项目文档 README.md
This commit is contained in:
qyyfw committed 2026-07-02 14:40:46 +08:00
commit 78ff261b5a
199 files changed
+69126

No files matched your search

+561
View File
@@ -0,0 +1,561 @@
import React, { useState, useMemo, useEffect } from 'react';
import { adminAPI } from '@/api';
import { mockUsers, mockMembershipPlans } from '@/mock/data';
import type { User, RegisterSettings } from '@/types';
import './Users.css';
// ===== SVG Icons =====
const IconSearch = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
);
const IconEye = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
);
const IconCheck = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const IconX = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
const IconShield = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const IconPhone = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
</svg>
);
const IconMail = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>
</svg>
);
const IconServer = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
);
const IconClose = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
// ===== Helpers =====
const realNameStatusMap: Record<string, { label: string; color: string; bg: string }> = {
verified: { label: '已实名', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
pending: { label: '审核中', color: 'var(--color-warning)', bg: 'var(--color-warning-light)' },
unverified: { label: '未实名', color: 'var(--color-text-tertiary)', bg: 'var(--color-bg-tertiary)' },
};
const statusMap: Record<string, { label: string; color: string; bg: string }> = {
active: { label: '正常', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
banned: { label: '已封禁', color: 'var(--color-error)', bg: 'var(--color-error-light)' },
};
// ===== User Detail Modal =====
interface UserDetailModalProps {
user: User;
onClose: () => void;
}
const UserDetailModal: React.FC<UserDetailModalProps> = ({ user, onClose }) => {
const plan = mockMembershipPlans.find(p => p.id === user.membership?.planId);
const rn = realNameStatusMap[user.realNameStatus || 'unverified'];
return (
<div className="admin-modal-overlay" onClick={onClose}>
<div className="admin-modal users-detail-modal" onClick={e => e.stopPropagation()}>
<div className="admin-modal-header">
<h2>用户详情</h2>
<button className="admin-modal-close" onClick={onClose}><IconClose /></button>
</div>
<div className="admin-modal-body">
<div className="user-detail-top">
<div className="user-detail-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<div className="user-detail-basic">
<h3>{user.username}</h3>
<div className="user-detail-tags">
<span className="user-detail-tag" style={{ color: rn.color, background: rn.bg }}>
<IconShield />
{rn.label}
</span>
<span className="user-detail-tag" style={{ color: statusMap[user.status].color, background: statusMap[user.status].bg }}>
{statusMap[user.status].label}
</span>
<span className="user-detail-tag role-tag">
{user.role === 'admin' ? '管理员' : '普通用户'}
</span>
</div>
</div>
</div>
<div className="user-detail-grid">
<div className="user-detail-item">
<span className="detail-label">用户ID</span>
<span className="detail-value">{user.id}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">邮箱</span>
<span className="detail-value">{user.email}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">手机号</span>
<span className="detail-value">{user.phone || '未绑定'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">实名姓名</span>
<span className="detail-value">{user.realName || '--'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">实名状态</span>
<span className="detail-value" style={{ color: rn.color, fontWeight: 500 }}>{rn.label}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">会员等级</span>
<span className="detail-value">{user.membership?.planName || '免费版'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">会员到期</span>
<span className="detail-value">{user.membership?.expireAt ? new Date(user.membership.expireAt).toLocaleDateString('zh-CN') : '--'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">注册时间</span>
<span className="detail-value">{new Date(user.createdAt).toLocaleString('zh-CN')}</span>
</div>
</div>
</div>
</div>
</div>
);
};
// ===== Main Component =====
const UsersPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [levelFilter, setLevelFilter] = useState<string>('all');
const [realNameFilter, setRealNameFilter] = useState<string>('all');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [localUsers, setLocalUsers] = useState<User[]>(mockUsers);
// Register settings state
const [registerSettings, setRegisterSettings] = useState<RegisterSettings>({
phoneRequired: true,
phoneSmsRequired: true,
emailRequired: true,
emailVerifyRequired: true,
realNameRequired: false,
aliyunAccessKey: '',
aliyunSecretKey: '',
aliyunEndpoint: 'https://cloudauth.aliyuncs.com',
});
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsMsg, setSettingsMsg] = useState<{ type: string; text: string } | null>(null);
// 加载注册设置
useEffect(() => {
loadRegisterSettings();
}, []);
const loadRegisterSettings = async () => {
try {
const res = await adminAPI.getRegisterSettings();
const data = res?.data || res;
if (data && typeof data === 'object') {
setRegisterSettings({
phoneRequired: data.phone_required !== undefined ? data.phone_required : true,
phoneSmsRequired: data.phone_sms_required !== undefined ? data.phone_sms_required : true,
emailRequired: data.email_required !== undefined ? data.email_required : true,
emailVerifyRequired: data.email_verify_required !== undefined ? data.email_verify_required : true,
realNameRequired: data.real_name_required || false,
aliyunAccessKey: data.aliyun_access_key || '',
aliyunSecretKey: data.aliyun_secret_key || '',
aliyunEndpoint: data.aliyun_endpoint || 'https://dysmsapi.aliyuncs.com',
smsSignName: data.sms_sign_name || '',
smsTemplateCode: data.sms_template_code || '',
});
}
} catch (e) {
console.error('加载注册设置失败', e);
}
};
const filteredUsers = useMemo(() => {
let result = localUsers.filter(u => u.role === 'user');
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(u =>
u.username.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.phone && u.phone.includes(q)) ||
(u.realName && u.realName.includes(q))
);
}
if (statusFilter !== 'all') result = result.filter(u => u.status === statusFilter);
if (levelFilter !== 'all') result = result.filter(u => (u.membership?.level ?? 0).toString() === levelFilter);
if (realNameFilter !== 'all') result = result.filter(u => u.realNameStatus === realNameFilter);
return result;
}, [localUsers, searchQuery, statusFilter, levelFilter, realNameFilter]);
const handleToggleSetting = (key: keyof RegisterSettings) => {
setRegisterSettings(prev => ({
...prev,
[key]: !prev[key as keyof RegisterSettings],
}));
};
const handleSettingChange = (key: keyof RegisterSettings, value: string) => {
setRegisterSettings(prev => ({ ...prev, [key]: value }));
};
const handleSaveRegisterSettings = async () => {
setSettingsSaving(true);
setSettingsMsg(null);
try {
await adminAPI.updateRegisterSettings({
phone_required: registerSettings.phoneRequired,
phone_sms_required: registerSettings.phoneSmsRequired,
email_required: registerSettings.emailRequired,
email_verify_required: registerSettings.emailVerifyRequired,
real_name_required: registerSettings.realNameRequired,
aliyun_access_key: registerSettings.aliyunAccessKey,
aliyun_secret_key: registerSettings.aliyunSecretKey,
aliyun_endpoint: registerSettings.aliyunEndpoint,
sms_sign_name: registerSettings.smsSignName,
sms_template_code: registerSettings.smsTemplateCode,
});
setSettingsMsg({ type: 'success', text: '注册设置已保存' });
setTimeout(() => setSettingsMsg(null), 3000);
} catch (e: any) {
setSettingsMsg({ type: 'error', text: e?.message || e?.detail || '保存失败' });
} finally {
setSettingsSaving(false);
}
};
const handleToggleUserStatus = (userId: string) => {
setLocalUsers(prev =>
prev.map(u => u.id === userId ? {
...u,
status: u.status === 'active' ? 'banned' as const : 'active' as const,
} : u)
);
};
return (
<div className="admin-users-page">
<h1 className="admin-page-title">用户管理</h1>
{/* ===== 用户列表 ===== */}
<div className="admin-card">
<div className="admin-card-header">
<h2 className="admin-card-title">用户列表</h2>
<span className="admin-card-count">共 {filteredUsers.length} 个用户</span>
</div>
{/* 搜索和筛选 */}
<div className="users-toolbar">
<div className="users-search">
<IconSearch />
<input
type="text"
className="users-search-input"
placeholder="搜索用户名、邮箱、手机号..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<div className="users-filters">
<select className="users-filter-select" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option value="all">全部状态</option>
<option value="active">正常</option>
<option value="banned">已封禁</option>
</select>
<select className="users-filter-select" value={levelFilter} onChange={e => setLevelFilter(e.target.value)}>
<option value="all">全部等级</option>
<option value="0">免费版</option>
<option value="2">专业版</option>
<option value="3">企业版</option>
</select>
<select className="users-filter-select" value={realNameFilter} onChange={e => setRealNameFilter(e.target.value)}>
<option value="all">全部实名</option>
<option value="verified">已实名</option>
<option value="pending">审核中</option>
<option value="unverified">未实名</option>
</select>
</div>
</div>
{/* 用户表格 */}
<div className="users-table-wrap">
<table className="users-table">
<thead>
<tr>
<th>用户名</th>
<th>邮箱</th>
<th>手机号</th>
<th>实名状态</th>
<th>会员等级</th>
<th>注册时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredUsers.map(u => {
const rn = realNameStatusMap[u.realNameStatus || 'unverified'];
const st = statusMap[u.status || 'active'];
return (
<tr key={u.id}>
<td>
<div className="users-cell-user">
<div className="users-cell-avatar">{u.username.charAt(0).toUpperCase()}</div>
<span>{u.username}</span>
</div>
</td>
<td className="users-cell-mono">{u.email}</td>
<td>{u.phone || '--'}</td>
<td>
<span className="users-badge" style={{ color: rn.color, background: rn.bg }}>
{rn.label}
</span>
</td>
<td>
<span className="users-level-badge">
{u.membership?.planName || '免费版'}
</span>
</td>
<td className="users-cell-date">{new Date(u.createdAt).toLocaleDateString('zh-CN')}</td>
<td>
<span className="users-badge" style={{ color: st.color, background: st.bg }}>
{st.label}
</span>
</td>
<td>
<div className="users-cell-actions">
<button className="users-action-btn" title="查看详情" onClick={() => setSelectedUser(u)}>
<IconEye />
</button>
<button
className={`users-action-btn ${u.status === 'active' ? 'danger' : 'success'}`}
title={u.status === 'active' ? '封禁' : '解封'}
onClick={() => handleToggleUserStatus(u.id)}
>
{u.status === 'active' ? <IconX /> : <IconCheck />}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{filteredUsers.length === 0 && (
<div className="users-empty">无匹配的用户记录</div>
)}
</div>
</div>
{/* ===== 注册用户设置 ===== */}
<div className="admin-card" style={{ marginTop: 'var(--space-2xl)' }}>
<div className="admin-card-header">
<h2 className="admin-card-title">注册用户设置</h2>
<span className="admin-card-subtitle">配置用户注册方式与认证策略</span>
</div>
<div className="register-settings">
{/* 手机号注册 */}
<div className="register-settings-section">
<h3 className="register-section-title">
<IconPhone />
手机号注册
</h3>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>手机号必填</strong>
<span>要求用户注册时填写手机号码</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.phoneRequired}
onChange={() => handleToggleSetting('phoneRequired')}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>短信验证码激活</strong>
<span>注册时发送短信验证码验证手机号</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.phoneSmsRequired}
onChange={() => handleToggleSetting('phoneSmsRequired')}
disabled={!registerSettings.phoneRequired}
/>
<span className="toggle-slider" />
</label>
</div>
</div>
{/* 邮箱注册 */}
<div className="register-settings-section">
<h3 className="register-section-title">
<IconMail />
邮箱注册
</h3>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>邮箱必填</strong>
<span>要求用户注册时填写邮箱地址</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.emailRequired}
onChange={() => handleToggleSetting('emailRequired')}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>邮箱认证激活</strong>
<span>发送验证邮件到用户邮箱完成激活</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.emailVerifyRequired}
onChange={() => handleToggleSetting('emailVerifyRequired')}
disabled={!registerSettings.emailRequired}
/>
<span className="toggle-slider" />
</label>
</div>
</div>
{/* 实名认证 */}
<div className="register-settings-section">
<h3 className="register-section-title">
<IconShield />
实名认证
</h3>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>强制实名认证</strong>
<span>要求用户完成实名认证后方可使用全部功能</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.realNameRequired}
onChange={() => handleToggleSetting('realNameRequired')}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="aliyun-config">
<div className="aliyun-config-header">
<IconServer />
<span>阿里云短信API配置(用于实名认证短信验证)</span>
</div>
<div className="aliyun-config-grid">
<div className="form-group">
<label>AccessKey ID</label>
<input
type="text"
className="users-form-input"
placeholder="请输入阿里云AccessKey ID"
value={registerSettings.aliyunAccessKey || ''}
onChange={e => handleSettingChange('aliyunAccessKey', e.target.value)}
/>
</div>
<div className="form-group">
<label>AccessKey Secret</label>
<input
type="password"
className="users-form-input"
placeholder="请输入阿里云AccessKey Secret"
value={registerSettings.aliyunSecretKey || ''}
onChange={e => handleSettingChange('aliyunSecretKey', e.target.value)}
/>
</div>
<div className="form-group">
<label>短信签名 (SignName)</label>
<input
type="text"
className="users-form-input"
placeholder="阿里云短信控制台中审核通过的签名"
value={registerSettings.smsSignName || ''}
onChange={e => handleSettingChange('smsSignName', e.target.value)}
/>
</div>
<div className="form-group">
<label>短信模板 (TemplateCode)</label>
<input
type="text"
className="users-form-input"
placeholder="SMS_XXXXXXXXXX"
value={registerSettings.smsTemplateCode || ''}
onChange={e => handleSettingChange('smsTemplateCode', e.target.value)}
/>
</div>
</div>
</div>
</div>
{settingsMsg && (
<div style={{
padding: 'var(--space-md) var(--space-lg)',
marginBottom: 'var(--space-lg)',
borderRadius: 'var(--radius-md)',
background: settingsMsg.type === 'success' ? 'var(--color-success-light)' : 'var(--color-error-light)',
color: settingsMsg.type === 'success' ? 'var(--color-success)' : 'var(--color-error)',
fontWeight: 500,
}}>
{settingsMsg.text}
</div>
)}
<button className="users-save-btn" onClick={handleSaveRegisterSettings} disabled={settingsSaving}>
{settingsSaving ? '保存中...' : '保存设置'}
</button>
</div>
</div>
{/* User Detail Modal */}
{selectedUser && (
<UserDetailModal
user={selectedUser}
onClose={() => setSelectedUser(null)}
/>
)}
</div>
);
};
export default UsersPage;