feat: redesign frontend UI with MoonShadow-style dark theme
Build and Deploy / build (push) Successful in 2m42s
Build and Deploy / deploy (push) Successful in 8s

- Update color scheme to purple/blue gradient accents
- Add modern card layouts with hover effects
- Implement token display with copy button
- Add stats dashboard on home page
- Improve install page with SQLite/MySQL toggle
- Add feature cards for supported functions
- Improve login/register pages with centered layout
- Add admin page with user management table
- Add responsive grid layouts
- Add fade-in animations
This commit is contained in:
2026-07-17 01:48:01 +00:00
parent bd8db9ba27
commit 7bc8c74d5e
8 changed files with 1118 additions and 438 deletions
+35
View File
@@ -0,0 +1,35 @@
import { useState } from 'react'
interface CopyButtonProps {
text: string
label?: string
}
export default function CopyButton({ text, label = '复制' }: CopyButtonProps) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
className={`copy-btn ${copied ? 'copied' : ''}`}
style={{
padding: '6px 12px',
background: copied ? 'var(--accent-green)' : 'var(--accent-purple)',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
transition: 'background 0.2s'
}}
>
{copied ? '已复制' : label}
</button>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
interface LayoutProps {
children: ReactNode
token: string | null
onLogout: () => void
}
export default function Layout({ children, token, onLogout }: LayoutProps) {
const location = useLocation()
const navItems = [
{ path: '/', label: '控制台', icon: '📊' },
{ path: '/test', label: '测试', icon: '🧪' },
{ path: '/docs', label: '文档', icon: '📚' },
]
if (!token) return null
return (
<div className="container">
<header className="header">
<div className="header-title">
<div className="logo">AC</div>
<h1>AntiCaptcha</h1>
</div>
<div className="header-actions">
<Link to="/admin" className="button button-secondary button-sm">
</Link>
<button onClick={onLogout} className="button button-ghost button-sm">
退
</button>
</div>
</header>
<nav className="flex gap-2 mb-6">
{navItems.map(item => (
<Link
key={item.path}
to={item.path}
className={`button button-sm ${location.pathname === item.path ? '' : 'button-ghost'}`}
>
<span>{item.icon}</span>
{item.label}
</Link>
))}
</nav>
<main className="fade-in">
{children}
</main>
</div>
)
}
+578 -65
View File
@@ -1,88 +1,194 @@
:root {
--bg-primary: #0A0A0F;
--bg-secondary: #12121A;
--bg-tertiary: #1A1A24;
--bg-card: #15151F;
--text-primary: #E4E4E7;
--text-secondary: #A1A1AA;
--text-muted: #52525B;
--accent-purple: #8B5CF6;
--accent-blue: #3B82F6;
--accent-cyan: #06B6D4;
--accent-green: #10B981;
--accent-orange: #F59E0B;
--border: #27272A;
--border-hover: #3F3F46;
--error: #EF4444;
--success: #22C55E;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
:root { html {
--primary: #3b82f6; font-size: 16px;
--primary-dark: #2563eb;
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border: #334155;
--border-hover: #475569;
--error: #ef4444;
--success: #22c55e;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Layout */
.container { .container {
max-width: 1200px; max-width: 1280px;
margin: 0 auto;
padding: 24px;
min-height: 100vh;
}
.container-sm {
max-width: 480px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
} }
.card { /* Header */
background: var(--bg-secondary); .header {
border: 1px solid var(--border); display: flex;
border-radius: 8px; justify-content: space-between;
padding: 24px; align-items: center;
margin-bottom: 16px; padding: 16px 0;
margin-bottom: 32px;
border-bottom: 1px solid var(--border);
} }
.input { .header-title {
width: 100%; display: flex;
padding: 12px 16px; align-items: center;
background: var(--bg-tertiary); gap: 12px;
border: 1px solid var(--border); }
border-radius: 6px;
.header-title h1 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 12px;
}
/* Logo */
.logo {
width: 32px;
height: 32px;
background: linear-gradient(135deg, var(--accent-purple), var(--accent-blue));
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 14px; font-size: 14px;
outline: none; }
/* Cards */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.input:focus { .card:hover {
border-color: var(--primary); border-color: var(--border-hover);
} }
.button { .card-header {
padding: 10px 20px; display: flex;
background: var(--primary); align-items: center;
color: white; gap: 12px;
border: none; margin-bottom: 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
} }
.button:hover { .card-icon {
background: var(--primary-dark); width: 40px;
} height: 40px;
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button-secondary {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-purple);
} }
.button-secondary:hover { .card-title {
background: var(--border); font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.card-desc {
font-size: 13px;
color: var(--text-secondary);
}
/* Grid */
.grid {
display: grid;
gap: 16px;
}
.grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-cols-3 {
grid-template-columns: repeat(3, 1fr);
}
.grid-cols-4 {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 1024px) {
.grid-cols-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.grid-cols-2,
.grid-cols-3,
.grid-cols-4 {
grid-template-columns: 1fr;
}
}
/* Forms */
.form-group {
margin-bottom: 20px;
} }
.label { .label {
@@ -93,10 +199,183 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
} }
.form-group { .input {
margin-bottom: 20px; width: 100%;
padding: 12px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
} }
.input:focus {
border-color: var(--accent-purple);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
}
.input::placeholder {
color: var(--text-muted);
}
.input-error {
border-color: var(--error);
}
.input-error:focus {
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
/* Buttons */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
background: var(--accent-purple);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.button:hover {
background: #7C3AED;
transform: translateY(-1px);
}
.button:active {
transform: translateY(0);
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.button-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.button-secondary:hover {
background: var(--border);
}
.button-ghost {
background: transparent;
color: var(--text-secondary);
}
.button-ghost:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.button-sm {
padding: 6px 12px;
font-size: 13px;
}
.button-lg {
padding: 14px 28px;
font-size: 15px;
}
.button-block {
width: 100%;
}
/* API Box */
.api-box {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
overflow-x: auto;
}
.api-box code {
color: var(--text-primary);
line-height: 1.8;
}
.api-box .method {
color: var(--accent-green);
font-weight: 600;
}
.api-box .path {
color: var(--accent-cyan);
}
.api-box .desc {
color: var(--text-muted);
}
/* Token Display */
.token-display {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
}
.token-display .token {
flex: 1;
color: var(--text-primary);
word-break: break-all;
}
.token-display .copy-btn {
padding: 6px 12px;
background: var(--accent-purple);
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.token-display .copy-btn:hover {
background: #7C3AED;
}
/* Stats */
.stat {
text-align: center;
padding: 20px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
}
/* Messages */
.error { .error {
color: var(--error); color: var(--error);
font-size: 13px; font-size: 13px;
@@ -109,10 +388,51 @@ body {
margin-top: 8px; margin-top: 8px;
} }
/* Utility */
.text-center { .text-center {
text-align: center; text-align: center;
} }
.text-secondary {
color: var(--text-secondary);
}
.text-muted {
color: var(--text-muted);
}
.text-sm {
font-size: 13px;
}
.text-lg {
font-size: 18px;
}
.mb-2 {
margin-bottom: 8px;
}
.mb-4 {
margin-bottom: 16px;
}
.mb-6 {
margin-bottom: 24px;
}
.mt-2 {
margin-top: 8px;
}
.mt-4 {
margin-top: 16px;
}
.mt-6 {
margin-top: 24px;
}
.flex { .flex {
display: flex; display: flex;
} }
@@ -129,6 +449,10 @@ body {
justify-content: space-between; justify-content: space-between;
} }
.justify-center {
justify-content: center;
}
.gap-2 { .gap-2 {
gap: 8px; gap: 8px;
} }
@@ -137,24 +461,213 @@ body {
gap: 16px; gap: 16px;
} }
.mt-4 { .gap-6 {
margin-top: 16px; gap: 24px;
} }
.mb-4 { /* Animations */
margin-bottom: 16px; @keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.grid { .fade-in {
display: grid; animation: fadeIn 0.3s ease-out;
} }
.grid-cols-2 { /* Feature Cards */
grid-template-columns: repeat(2, 1fr); .feature-card {
display: flex;
flex-direction: column;
gap: 12px;
} }
@media (max-width: 768px) { .feature-icon {
.grid-cols-2 { width: 48px;
grid-template-columns: 1fr; height: 48px;
background: var(--bg-tertiary);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-purple);
}
.feature-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.feature-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
/* Links */
a {
color: var(--accent-purple);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: #A78BFA;
}
/* Code */
code {
font-family: 'JetBrains Mono', monospace;
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* Tables */
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
td {
font-size: 14px;
color: var(--text-primary);
}
tr:hover td {
background: var(--bg-tertiary);
}
/* Tags */
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
background: var(--bg-tertiary);
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.tag-purple {
background: rgba(139, 92, 246, 0.1);
color: var(--accent-purple);
}
.tag-green {
background: rgba(16, 185, 129, 0.1);
color: var(--accent-green);
}
.tag-blue {
background: rgba(59, 130, 246, 0.1);
color: var(--accent-blue);
}
.tag-orange {
background: rgba(245, 158, 11, 0.1);
color: var(--accent-orange);
}
/* Dividers */
.divider {
height: 1px;
background: var(--border);
margin: 24px 0;
}
/* Page Title */
.page-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.page-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 32px;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 48px 24px;
}
.empty-state-icon {
width: 64px;
height: 64px;
margin: 0 auto 16px;
background: var(--bg-tertiary);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.empty-state-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.empty-state-desc {
font-size: 14px;
color: var(--text-secondary);
}
/* Loading */
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
color: var(--text-muted);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent-purple);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
} }
} }
+82 -66
View File
@@ -1,101 +1,117 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
interface Props { interface Props {
token: string token: string
} }
export default function AdminPage({ token }: Props) { interface User {
const [points, setPoints] = useState(1000) id: number
const [codes, setCodes] = useState<any[]>([]) username: string
const [loading, setLoading] = useState(false) role: string
balance: number
const generateCode = async () => { created_at: string
setLoading(true)
try {
const res = await fetch('/api/admin/generate_code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ points })
})
if (res.ok) {
loadCodes()
} }
export default function AdminPage({ token }: Props) {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
fetchUsers()
}, [token])
const fetchUsers = async () => {
try {
const res = await fetch('/api/admin/users', {
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await res.json()
if (res.ok) {
setUsers(data.users || [])
} else {
setError(data.error || '获取用户列表失败')
}
} catch {
setError('网络错误')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const loadCodes = async () => { const handleLogout = () => {
const res = await fetch('/api/admin/regcodes', { localStorage.removeItem('token')
headers: { 'Authorization': `Bearer ${token}` } window.location.href = '/login'
})
if (res.ok) {
setCodes(await res.json())
}
} }
return ( return (
<div className="container"> <div className="container">
<h1 className="mb-4"></h1> <header className="header">
<div className="header-title">
<div className="card"> <div className="logo">AC</div>
<h2 className="mb-4"></h2> <h1>AntiCaptcha </h1>
<div className="flex gap-4 items-center">
<div className="form-group" style={{ flex: 1 }}>
<label className="label"></label>
<input
type="number"
className="input"
value={points}
onChange={e => setPoints(Number(e.target.value))}
/>
</div> </div>
<div className="header-actions">
<button <Link to="/" className="button button-secondary button-sm">
className="button"
onClick={generateCode} </Link>
disabled={loading} <button onClick={handleLogout} className="button button-ghost button-sm">
> 退
{loading ? '生成中...' : '生成注册码'}
</button> </button>
</div> </div>
</header>
<h2 className="page-title"></h2>
<p className="page-subtitle"></p>
{loading ? (
<div className="loading">
<div className="spinner"></div>
...
</div> </div>
) : error ? (
<div className="card"> <div className="card">
<h2 className="mb-4"></h2> <div className="error">{error}</div>
</div>
{codes.length === 0 ? ( ) : users.length === 0 ? (
<p style={{ color: 'var(--text-muted)' }}></p> <div className="card empty-state">
<div className="empty-state-icon">👤</div>
<div className="empty-state-title"></div>
<div className="empty-state-desc"></div>
</div>
) : ( ) : (
<div style={{ overflowX: 'auto' }}> <div className="card">
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <div className="table-container">
<table>
<thead> <thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}> <tr>
<th style={{ padding: '12px', textAlign: 'left' }}></th> <th>ID</th>
<th style={{ padding: '12px', textAlign: 'left' }}></th> <th></th>
<th style={{ padding: '12px', textAlign: 'left' }}></th> <th></th>
<th></th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{codes.map((code: any) => ( {users.map(user => (
<tr key={code.id} style={{ borderBottom: '1px solid var(--border)' }}> <tr key={user.id}>
<td style={{ padding: '12px' }}>{code.code}</td> <td>{user.id}</td>
<td style={{ padding: '12px' }}>{code.points}</td> <td>{user.username}</td>
<td style={{ padding: '12px' }}> <td>
{code.is_used ? '已使用' : '未使用'} <span className={`tag ${user.role === 'admin' ? 'tag-purple' : 'tag-blue'}`}>
{user.role}
</span>
</td> </td>
<td>{user.balance.toLocaleString()}</td>
<td className="text-muted">{new Date(user.created_at).toLocaleDateString()}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</div> </div>
)}
</div> </div>
) )
} }
+139 -21
View File
@@ -1,47 +1,165 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import CopyButton from '../components/CopyButton'
interface Props { interface Props {
token: string token: string
} }
interface UserInfo {
username: string
role: string
balance: number
}
export default function HomePage({ token }: Props) { export default function HomePage({ token }: Props) {
const [user, setUser] = useState<UserInfo | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// 获取用户信息
fetch('/api/user/info', {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(res => res.json())
.then(data => {
if (data.username) {
setUser(data)
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [token])
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('token') localStorage.removeItem('token')
window.location.href = '/login' window.location.href = '/login'
} }
const apiEndpoints = [
{ method: 'POST', path: '/api/ocr', desc: 'OCR 文字识别' },
{ method: 'POST', path: '/api/slider/match', desc: '滑块缺口匹配' },
{ method: 'POST', path: '/api/compare/similarity', desc: '图片相似度对比' },
{ method: 'POST', path: '/api/rotate/single/rotate', desc: '单图旋转验证码' },
{ method: 'POST', path: '/api/detection/icon', desc: '图标检测' },
{ method: 'POST', path: '/api/double-rotate', desc: '双图旋转验证码' },
]
return ( return (
<div className="container"> <div className="container">
<div className="flex justify-between items-center mb-4"> <header className="header">
<h1>AntiCaptcha </h1> <div className="header-title">
<div className="flex gap-2"> <div className="logo">AC</div>
<Link to="/admin" className="button button-secondary"></Link> <h1>AntiCaptcha</h1>
<button onClick={handleLogout} className="button button-secondary">退</button>
</div> </div>
<div className="header-actions">
<Link to="/admin" className="button button-secondary button-sm">
</Link>
<button onClick={handleLogout} className="button button-ghost button-sm">
退
</button>
</div> </div>
</header>
{/* Stats */}
<div className="grid grid-cols-4 mb-6">
<div className="card"> <div className="card">
<h2 className="mb-4">API 使</h2> <div className="stat">
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}> <div className="stat-value">{user?.balance?.toLocaleString() || '-'}</div>
使 Authorization: Bearer {token.substring(0, 20)}... <div className="stat-label"></div>
</p> </div>
</div>
<div className="card">
<div className="stat">
<div className="stat-value">6</div>
<div className="stat-label">API </div>
</div>
</div>
<div className="card">
<div className="stat">
<div className="stat-value">99.9%</div>
<div className="stat-label"></div>
</div>
</div>
<div className="card">
<div className="stat">
<div className="stat-value">&lt;100ms</div>
<div className="stat-label"></div>
</div>
</div>
</div>
<div style={{ background: 'var(--bg-tertiary)', padding: '16px', borderRadius: '8px' }}> {/* Token Display */}
<code style={{ color: 'var(--text-primary)' }}> <div className="card mb-6">
POST /api/ocr - OCR <br/> <div className="card-header">
POST /api/slider/match - <br/> <div className="card-icon">🔑</div>
POST /api/compare/similarity - <br/> <div>
POST /api/rotate/single/rotate - <br/> <div className="card-title">API Token</div>
POST /api/detection/icon - <br/> <div className="card-desc"> Token </div>
</div>
</div>
<div className="token-display">
<span className="token">{token.substring(0, 20)}...{token.substring(token.length - 20)}</span>
<CopyButton text={token} />
</div>
</div>
{/* API Endpoints */}
<div className="card">
<div className="card-header">
<div className="card-icon">📡</div>
<div>
<div className="card-title">API </div>
<div className="card-desc">使</div>
</div>
</div>
<div className="api-box">
<code>
{apiEndpoints.map((api, i) => (
<div key={i} style={{ marginBottom: '8px' }}>
<span className="method">{api.method}</span>{' '}
<span className="path">{api.path}</span>{' '}
<span className="desc">// {api.desc}</span>
</div>
))}
</code> </code>
</div> </div>
</div> </div>
<div className="card"> {/* Features */}
<h2 className="mb-4"></h2> <h2 className="text-lg mb-4 mt-6"></h2>
<p style={{ color: 'var(--text-secondary)' }}> <div className="grid grid-cols-3 mb-6">
<div className="card feature-card">
</p> <div className="feature-icon">📝</div>
<div className="feature-title">OCR </div>
<div className="feature-desc"></div>
</div>
<div className="card feature-card">
<div className="feature-icon">🎯</div>
<div className="feature-title"></div>
<div className="feature-desc"></div>
</div>
<div className="card feature-card">
<div className="feature-icon">🔄</div>
<div className="feature-title"></div>
<div className="feature-desc">/</div>
</div>
<div className="card feature-card">
<div className="feature-icon">🔍</div>
<div className="feature-title"></div>
<div className="feature-desc"></div>
</div>
<div className="card feature-card">
<div className="feature-icon">🖼</div>
<div className="feature-title"></div>
<div className="feature-desc"></div>
</div>
<div className="card feature-card">
<div className="feature-icon">🔢</div>
<div className="feature-title"></div>
<div className="feature-desc"></div>
</div>
</div> </div>
</div> </div>
) )
+158 -239
View File
@@ -5,36 +5,32 @@ interface Props {
} }
export default function InstallPage({ onInstall }: Props) { export default function InstallPage({ onInstall }: Props) {
const [step, setStep] = useState(1) const [dbType, setDbType] = useState<'sqlite' | 'mysql'>('sqlite')
const [loading, setLoading] = useState(false) const [sqlitePath, setSqlitePath] = useState('./data/app.db')
const [mysqlHost, setMysqlHost] = useState('localhost:3306')
const [mysqlUser, setMysqlUser] = useState('root')
const [mysqlPass, setMysqlPass] = useState('')
const [mysqlDb, setMysqlDb] = useState('anticaptcha')
const [redisEnabled, setRedisEnabled] = useState(false)
const [redisHost, setRedisHost] = useState('localhost:6379')
const [redisPass, setRedisPass] = useState('')
const [adminUser, setAdminUser] = useState('admin')
const [adminPass, setAdminPass] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [config, setConfig] = useState({ const handleSubmit = async (e: React.FormEvent) => {
database: { e.preventDefault()
type: 'sqlite',
host: 'localhost',
port: 3306,
user: 'root',
password: '',
database: 'anticaptcha',
sqlite: { path: './data/app.db' }
},
redis: {
enabled: false,
host: 'localhost',
port: 6379,
password: '',
db: 0
},
admin: {
username: 'admin',
password: ''
}
})
const handleSubmit = async () => {
setLoading(true)
setError('') setError('')
setLoading(true)
const config = {
database: dbType === 'sqlite'
? { type: 'sqlite', sqlite: { path: sqlitePath } }
: { type: 'mysql', mysql: { host: mysqlHost, user: mysqlUser, password: mysqlPass, database: mysqlDb } },
redis: { enabled: redisEnabled, host: redisHost, password: redisPass },
admin: { username: adminUser, password: adminPass }
}
try { try {
const res = await fetch('/api/install', { const res = await fetch('/api/install', {
@@ -45,274 +41,197 @@ export default function InstallPage({ onInstall }: Props) {
const data = await res.json() const data = await res.json()
if (!res.ok) { if (res.ok) {
setError(data.error || '安装失败')
return
}
onInstall() onInstall()
window.location.href = '/' setTimeout(() => {
window.location.href = '/login'
}, 500)
} else {
setError(data.error || '安装失败')
}
} catch { } catch {
setError('网络错误') setError('网络错误,请重试')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
return ( return (
<div className="container" style={{ maxWidth: '600px', marginTop: '50px' }}> <div className="container-sm">
<div className="card"> <div className="text-center mb-6" style={{ paddingTop: '40px' }}>
<h1 style={{ marginBottom: '8px' }}>AntiCaptcha </h1> <div className="logo" style={{ width: '56px', height: '56px', fontSize: '24px', margin: '0 auto 16px' }}>
<p style={{ color: 'var(--text-muted)', marginBottom: '32px' }}> AC
{step} / 3 </div>
</p> <h1 className="page-title">AntiCaptcha </h1>
<p className="text-secondary">使</p>
</div>
{step === 1 && ( <div className="card fade-in">
<> <form onSubmit={handleSubmit}>
<h2 className="mb-4"></h2> {/* Database Config */}
<h3 className="text-lg mb-4"></h3>
<div className="form-group"> <div className="form-group">
<label className="label"></label> <label className="label"></label>
<select <div className="flex gap-2">
className="input" <button
value={config.database.type} type="button"
onChange={e => setConfig({ className={`button button-sm ${dbType === 'sqlite' ? '' : 'button-ghost'}`}
...config, onClick={() => setDbType('sqlite')}
database: { ...config.database, type: e.target.value }
})}
> >
<option value="sqlite">SQLite</option> SQLite
<option value="mysql">MySQL</option> </button>
</select> <button
type="button"
className={`button button-sm ${dbType === 'mysql' ? '' : 'button-ghost'}`}
onClick={() => setDbType('mysql')}
>
MySQL
</button>
</div>
</div> </div>
{config.database.type === 'sqlite' ? ( {dbType === 'sqlite' ? (
<div className="form-group"> <div className="form-group">
<label className="label"></label> <label className="label"></label>
<input <input
type="text" type="text"
className="input" className="input"
value={config.database.sqlite.path} value={sqlitePath}
onChange={e => setConfig({ onChange={e => setSqlitePath(e.target.value)}
...config, placeholder="./data/app.db"
database: {
...config.database,
sqlite: { path: e.target.value }
}
})}
/> />
</div> </div>
) : ( ) : (
<> <>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="form-group"> <div className="form-group">
<label className="label"></label> <label className="label"></label>
<input <input
type="text" type="text"
className="input" className="input"
value={config.database.host} value={mysqlHost}
onChange={e => setConfig({ onChange={e => setMysqlHost(e.target.value)}
...config, placeholder="localhost:3306"
database: { ...config.database, host: e.target.value }
})}
/> />
</div> </div>
<div className="form-group">
<label className="label"></label>
<input
type="number"
className="input"
value={config.database.port}
onChange={e => setConfig({
...config,
database: { ...config.database, port: Number(e.target.value) }
})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="form-group">
<label className="label"></label>
<input
type="text"
className="input"
value={config.database.user}
onChange={e => setConfig({
...config,
database: { ...config.database, user: e.target.value }
})}
/>
</div>
<div className="form-group">
<label className="label"></label>
<input
type="password"
className="input"
value={config.database.password}
onChange={e => setConfig({
...config,
database: { ...config.database, password: e.target.value }
})}
/>
</div>
</div>
<div className="form-group"> <div className="form-group">
<label className="label"></label> <label className="label"></label>
<input <input
type="text" type="text"
className="input" className="input"
value={config.database.database} value={mysqlDb}
onChange={e => setConfig({ onChange={e => setMysqlDb(e.target.value)}
...config, placeholder="anticaptcha"
database: { ...config.database, database: e.target.value }
})}
/> />
</div> </div>
</>
)}
<button className="button" onClick={() => setStep(2)}>
</button>
</>
)}
{step === 2 && (
<>
<h2 className="mb-4">Redis </h2>
<div className="form-group">
<label className="flex gap-2 items-center">
<input
type="checkbox"
checked={config.redis.enabled}
onChange={e => setConfig({
...config,
redis: { ...config.redis, enabled: e.target.checked }
})}
/>
Redis
</label>
</div> </div>
{config.redis.enabled && (
<>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="form-group">
<label className="label"></label>
<input
type="text"
className="input"
value={config.redis.host}
onChange={e => setConfig({
...config,
redis: { ...config.redis, host: e.target.value }
})}
/>
</div>
<div className="form-group">
<label className="label"></label>
<input
type="number"
className="input"
value={config.redis.port}
onChange={e => setConfig({
...config,
redis: { ...config.redis, port: Number(e.target.value) }
})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="form-group">
<label className="label"></label>
<input
type="password"
className="input"
value={config.redis.password}
onChange={e => setConfig({
...config,
redis: { ...config.redis, password: e.target.value }
})}
/>
</div>
<div className="form-group">
<label className="label"></label>
<input
type="number"
className="input"
value={config.redis.db}
onChange={e => setConfig({
...config,
redis: { ...config.redis, db: Number(e.target.value) }
})}
/>
</div>
</div>
</>
)}
<div className="flex gap-2">
<button className="button button-secondary" onClick={() => setStep(1)}>
</button>
<button className="button" onClick={() => setStep(3)}>
</button>
</div>
</>
)}
{step === 3 && (
<>
<h2 className="mb-4"></h2>
<div className="form-group"> <div className="form-group">
<label className="label"></label> <label className="label"></label>
<input <input
type="text" type="text"
className="input" className="input"
value={config.admin.username} value={mysqlUser}
onChange={e => setConfig({ onChange={e => setMysqlUser(e.target.value)}
...config, placeholder="root"
admin: { ...config.admin, username: e.target.value }
})}
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label"></label> <label className="label"></label>
<input <input
type="password" type="password"
className="input" className="input"
value={config.admin.password} value={mysqlPass}
onChange={e => setConfig({ onChange={e => setMysqlPass(e.target.value)}
...config, placeholder="••••••••"
admin: { ...config.admin, password: e.target.value }
})}
placeholder="请输入管理员密码"
/> />
</div> </div>
{error && <div className="error">{error}</div>}
<div className="flex gap-2">
<button className="button button-secondary" onClick={() => setStep(2)}>
</button>
<button
className="button"
onClick={handleSubmit}
disabled={loading}
>
{loading ? '安装中...' : '完成安装'}
</button>
</div> </div>
</> </>
)} )}
<div className="divider"></div>
{/* Redis Config */}
<h3 className="text-lg mb-4">
Redis
<span className="tag tag-purple ml-2"></span>
</h3>
<div className="form-group">
<label className="flex items-center gap-2" style={{ cursor: 'pointer' }}>
<input
type="checkbox"
checked={redisEnabled}
onChange={e => setRedisEnabled(e.target.checked)}
style={{ width: '16px', height: '16px' }}
/>
<span className="text-secondary"> Redis </span>
</label>
</div>
{redisEnabled && (
<div className="grid grid-cols-2 gap-4">
<div className="form-group">
<label className="label"></label>
<input
type="text"
className="input"
value={redisHost}
onChange={e => setRedisHost(e.target.value)}
placeholder="localhost:6379"
/>
</div>
<div className="form-group">
<label className="label"></label>
<input
type="password"
className="input"
value={redisPass}
onChange={e => setRedisPass(e.target.value)}
placeholder="••••••••"
/>
</div>
</div>
)}
<div className="divider"></div>
{/* Admin Config */}
<h3 className="text-lg mb-4"></h3>
<div className="grid grid-cols-2 gap-4">
<div className="form-group">
<label className="label"></label>
<input
type="text"
className="input"
value={adminUser}
onChange={e => setAdminUser(e.target.value)}
placeholder="admin"
required
/>
</div>
<div className="form-group">
<label className="label"></label>
<input
type="password"
className="input"
value={adminPass}
onChange={e => setAdminPass(e.target.value)}
placeholder="请输入密码"
required
/>
</div>
</div>
{error && <div className="error mb-4">{error}</div>}
<button type="submit" className="button button-block button-lg mt-4" disabled={loading}>
{loading ? '安装中...' : '开始安装'}
</button>
</form>
</div> </div>
</div> </div>
) )
+22 -16
View File
@@ -1,12 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { Link } from 'react-router-dom'
interface Props { interface Props {
onLogin: (token: string) => void onLogin: (token: string) => void
} }
export default function LoginPage({ onLogin }: Props) { export default function LoginPage({ onLogin }: Props) {
const navigate = useNavigate()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -21,30 +20,36 @@ export default function LoginPage({ onLogin }: Props) {
const res = await fetch('/api/login', { const res = await fetch('/api/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password })
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { if (res.ok && data.access_token) {
setError(data.error || '登录失败')
return
}
localStorage.setItem('token', data.access_token) localStorage.setItem('token', data.access_token)
onLogin(data.access_token) onLogin(data.access_token)
navigate('/') } else {
setError(data.error || '登录失败')
}
} catch { } catch {
setError('网络错误') setError('网络错误,请重试')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
return ( return (
<div className="container" style={{ maxWidth: '400px', marginTop: '100px' }}> <div className="container-sm">
<div className="card"> <div className="text-center mb-6" style={{ paddingTop: '80px' }}>
<h1 style={{ marginBottom: '24px', textAlign: 'center' }}></h1> <div className="logo" style={{ width: '48px', height: '48px', fontSize: '20px', margin: '0 auto 16px' }}>
AC
</div>
<h1 className="page-title">AntiCaptcha</h1>
<p className="text-secondary"></p>
</div>
<div className="card fade-in">
<h2 className="text-lg mb-4 text-center"></h2>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-group"> <div className="form-group">
@@ -71,15 +76,16 @@ export default function LoginPage({ onLogin }: Props) {
/> />
</div> </div>
{error && <div className="error">{error}</div>} {error && <div className="error mb-4">{error}</div>}
<button type="submit" className="button" style={{ width: '100%' }} disabled={loading}> <button type="submit" className="button button-block button-lg" disabled={loading}>
{loading ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}
</button> </button>
</form> </form>
<div className="text-center mt-4"> <div className="text-center mt-4">
<a href="/register" style={{ color: 'var(--primary)' }}></a> <span className="text-secondary"></span>{' '}
<Link to="/register"></Link>
</div> </div>
</div> </div>
</div> </div>
+38 -21
View File
@@ -1,9 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom'
export default function RegisterPage() { export default function RegisterPage() {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [code, setCode] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -12,35 +13,50 @@ export default function RegisterPage() {
e.preventDefault() e.preventDefault()
setError('') setError('')
setSuccess('') setSuccess('')
if (password !== confirmPassword) {
setError('两次密码不一致')
return
}
setLoading(true) setLoading(true)
try { try {
const res = await fetch('/api/register', { const res = await fetch('/api/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, registration_code: code }), body: JSON.stringify({ username, password })
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { if (res.ok) {
setError(data.error || '注册失败')
return
}
setSuccess('注册成功,请登录') setSuccess('注册成功,请登录')
setTimeout(() => window.location.href = '/login', 2000) setTimeout(() => {
window.location.href = '/login'
}, 1500)
} else {
setError(data.error || '注册失败')
}
} catch { } catch {
setError('网络错误') setError('网络错误,请重试')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
return ( return (
<div className="container" style={{ maxWidth: '400px', marginTop: '100px' }}> <div className="container-sm">
<div className="card"> <div className="text-center mb-6" style={{ paddingTop: '80px' }}>
<h1 style={{ marginBottom: '24px', textAlign: 'center' }}></h1> <div className="logo" style={{ width: '48px', height: '48px', fontSize: '20px', margin: '0 auto 16px' }}>
AC
</div>
<h1 className="page-title">AntiCaptcha</h1>
<p className="text-secondary"></p>
</div>
<div className="card fade-in">
<h2 className="text-lg mb-4 text-center"></h2>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-group"> <div className="form-group">
@@ -68,27 +84,28 @@ export default function RegisterPage() {
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="label"></label> <label className="label"></label>
<input <input
type="text" type="password"
className="input" className="input"
value={code} value={confirmPassword}
onChange={e => setCode(e.target.value)} onChange={e => setConfirmPassword(e.target.value)}
placeholder="请输入注册码" placeholder="请再次输入密码"
required required
/> />
</div> </div>
{error && <div className="error">{error}</div>} {error && <div className="error mb-4">{error}</div>}
{success && <div className="success">{success}</div>} {success && <div className="success mb-4">{success}</div>}
<button type="submit" className="button" style={{ width: '100%' }} disabled={loading}> <button type="submit" className="button button-block button-lg" disabled={loading}>
{loading ? '注册中...' : '注册'} {loading ? '注册中...' : '注册'}
</button> </button>
</form> </form>
<div className="text-center mt-4"> <div className="text-center mt-4">
<a href="/login" style={{ color: 'var(--primary)' }}></a> <span className="text-secondary"></span>{' '}
<Link to="/login"></Link>
</div> </div>
</div> </div>
</div> </div>