feat: redesign frontend UI with MoonShadow-style dark theme
- 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+584
-71
@@ -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;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--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;
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
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);
|
||||
color: var(--text-primary);
|
||||
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 {
|
||||
max-width: 1200px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container-sm {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--primary);
|
||||
.card:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 20px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.button-secondary:hover {
|
||||
background: var(--border);
|
||||
.card-title {
|
||||
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 {
|
||||
@@ -93,10 +199,183 @@ body {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
.input {
|
||||
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 {
|
||||
color: var(--error);
|
||||
font-size: 13px;
|
||||
@@ -109,10 +388,51 @@ body {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.text-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 {
|
||||
display: flex;
|
||||
}
|
||||
@@ -129,6 +449,10 @@ body {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -137,24 +461,213 @@ body {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
.gap-6 {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Feature Cards */
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
+77
-61
@@ -1,101 +1,117 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface Props {
|
||||
token: string
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
balance: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function AdminPage({ token }: Props) {
|
||||
const [points, setPoints] = useState(1000)
|
||||
const [codes, setCodes] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const generateCode = async () => {
|
||||
setLoading(true)
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [token])
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/generate_code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ points })
|
||||
const res = await fetch('/api/admin/users', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
loadCodes()
|
||||
setUsers(data.users || [])
|
||||
} else {
|
||||
setError(data.error || '获取用户列表失败')
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCodes = async () => {
|
||||
const res = await fetch('/api/admin/regcodes', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
if (res.ok) {
|
||||
setCodes(await res.json())
|
||||
}
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 className="mb-4">管理后台</h1>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="mb-4">生成注册码</h2>
|
||||
|
||||
<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))}
|
||||
/>
|
||||
<header className="header">
|
||||
<div className="header-title">
|
||||
<div className="logo">AC</div>
|
||||
<h1>AntiCaptcha 管理后台</h1>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="button"
|
||||
onClick={generateCode}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '生成中...' : '生成注册码'}
|
||||
<div className="header-actions">
|
||||
<Link to="/" className="button button-secondary button-sm">
|
||||
返回控制台
|
||||
</Link>
|
||||
<button onClick={handleLogout} className="button button-ghost button-sm">
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h2 className="page-title">用户管理</h2>
|
||||
<p className="page-subtitle">管理系统用户和余额</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">
|
||||
<div className="spinner"></div>
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
) : error ? (
|
||||
<div className="card">
|
||||
<h2 className="mb-4">注册码列表</h2>
|
||||
|
||||
{codes.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>暂无注册码,点击上方按钮生成</p>
|
||||
<div className="error">{error}</div>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<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' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<div className="card">
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>注册码</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>积分</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>状态</th>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>余额</th>
|
||||
<th>注册时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{codes.map((code: any) => (
|
||||
<tr key={code.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '12px' }}>{code.code}</td>
|
||||
<td style={{ padding: '12px' }}>{code.points}</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{code.is_used ? '已使用' : '未使用'}
|
||||
{users.map(user => (
|
||||
<tr key={user.id}>
|
||||
<td>{user.id}</td>
|
||||
<td>{user.username}</td>
|
||||
<td>
|
||||
<span className={`tag ${user.role === 'admin' ? 'tag-purple' : 'tag-blue'}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>{user.balance.toLocaleString()}</td>
|
||||
<td className="text-muted">{new Date(user.created_at).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+139
-21
@@ -1,47 +1,165 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import CopyButton from '../components/CopyButton'
|
||||
|
||||
interface Props {
|
||||
token: string
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
username: string
|
||||
role: string
|
||||
balance: number
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
localStorage.removeItem('token')
|
||||
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 (
|
||||
<div className="container">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1>AntiCaptcha 控制台</h1>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/admin" className="button button-secondary">管理后台</Link>
|
||||
<button onClick={handleLogout} className="button button-secondary">退出登录</button>
|
||||
<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={handleLogout} className="button button-ghost button-sm">
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 mb-6">
|
||||
<div className="card">
|
||||
<h2 className="mb-4">API 使用说明</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||
使用以下接口进行验证码识别,需要在请求头中携带 Authorization: Bearer {token.substring(0, 20)}...
|
||||
</p>
|
||||
<div className="stat">
|
||||
<div className="stat-value">{user?.balance?.toLocaleString() || '-'}</div>
|
||||
<div className="stat-label">账户余额</div>
|
||||
</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"><100ms</div>
|
||||
<div className="stat-label">平均响应</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--bg-tertiary)', padding: '16px', borderRadius: '8px' }}>
|
||||
<code style={{ color: 'var(--text-primary)' }}>
|
||||
POST /api/ocr - OCR 文字识别<br/>
|
||||
POST /api/slider/match - 滑块缺口匹配<br/>
|
||||
POST /api/compare/similarity - 图片相似度对比<br/>
|
||||
POST /api/rotate/single/rotate - 单图旋转验证码<br/>
|
||||
POST /api/detection/icon - 图标检测<br/>
|
||||
{/* Token Display */}
|
||||
<div className="card mb-6">
|
||||
<div className="card-header">
|
||||
<div className="card-icon">🔑</div>
|
||||
<div>
|
||||
<div className="card-title">API Token</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="mb-4">账户信息</h2>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>
|
||||
请联系管理员获取更多信息
|
||||
</p>
|
||||
{/* Features */}
|
||||
<h2 className="text-lg mb-4 mt-6">支持功能</h2>
|
||||
<div className="grid grid-cols-3 mb-6">
|
||||
<div className="card feature-card">
|
||||
<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>
|
||||
)
|
||||
|
||||
+158
-239
@@ -5,36 +5,32 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function InstallPage({ onInstall }: Props) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [dbType, setDbType] = useState<'sqlite' | 'mysql'>('sqlite')
|
||||
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 [loading, setLoading] = useState(false)
|
||||
|
||||
const [config, setConfig] = useState({
|
||||
database: {
|
||||
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)
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
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 {
|
||||
const res = await fetch('/api/install', {
|
||||
@@ -45,274 +41,197 @@ export default function InstallPage({ onInstall }: Props) {
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || '安装失败')
|
||||
return
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
onInstall()
|
||||
window.location.href = '/'
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 500)
|
||||
} else {
|
||||
setError(data.error || '安装失败')
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误')
|
||||
setError('网络错误,请重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container" style={{ maxWidth: '600px', marginTop: '50px' }}>
|
||||
<div className="card">
|
||||
<h1 style={{ marginBottom: '8px' }}>AntiCaptcha 安装向导</h1>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '32px' }}>
|
||||
步骤 {step} / 3
|
||||
</p>
|
||||
<div className="container-sm">
|
||||
<div className="text-center mb-6" style={{ paddingTop: '40px' }}>
|
||||
<div className="logo" style={{ width: '56px', height: '56px', fontSize: '24px', margin: '0 auto 16px' }}>
|
||||
AC
|
||||
</div>
|
||||
<h1 className="page-title">AntiCaptcha 安装向导</h1>
|
||||
<p className="text-secondary">首次使用需要进行初始化配置</p>
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<h2 className="mb-4">数据库配置</h2>
|
||||
<div className="card fade-in">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Database Config */}
|
||||
<h3 className="text-lg mb-4">数据库配置</h3>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">数据库类型</label>
|
||||
<select
|
||||
className="input"
|
||||
value={config.database.type}
|
||||
onChange={e => setConfig({
|
||||
...config,
|
||||
database: { ...config.database, type: e.target.value }
|
||||
})}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`button button-sm ${dbType === 'sqlite' ? '' : 'button-ghost'}`}
|
||||
onClick={() => setDbType('sqlite')}
|
||||
>
|
||||
<option value="sqlite">SQLite</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
</select>
|
||||
SQLite
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`button button-sm ${dbType === 'mysql' ? '' : 'button-ghost'}`}
|
||||
onClick={() => setDbType('mysql')}
|
||||
>
|
||||
MySQL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.database.type === 'sqlite' ? (
|
||||
{dbType === 'sqlite' ? (
|
||||
<div className="form-group">
|
||||
<label className="label">数据库文件路径</label>
|
||||
<label className="label">数据库路径</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={config.database.sqlite.path}
|
||||
onChange={e => setConfig({
|
||||
...config,
|
||||
database: {
|
||||
...config.database,
|
||||
sqlite: { path: e.target.value }
|
||||
}
|
||||
})}
|
||||
value={sqlitePath}
|
||||
onChange={e => setSqlitePath(e.target.value)}
|
||||
placeholder="./data/app.db"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="form-group">
|
||||
<label className="label">主机</label>
|
||||
<label className="label">主机地址</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={config.database.host}
|
||||
onChange={e => setConfig({
|
||||
...config,
|
||||
database: { ...config.database, host: e.target.value }
|
||||
})}
|
||||
value={mysqlHost}
|
||||
onChange={e => setMysqlHost(e.target.value)}
|
||||
placeholder="localhost:3306"
|
||||
/>
|
||||
</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">
|
||||
<label className="label">数据库名</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={config.database.database}
|
||||
onChange={e => setConfig({
|
||||
...config,
|
||||
database: { ...config.database, database: e.target.value }
|
||||
})}
|
||||
value={mysqlDb}
|
||||
onChange={e => setMysqlDb(e.target.value)}
|
||||
placeholder="anticaptcha"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{config.redis.enabled && (
|
||||
<>
|
||||
<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">
|
||||
<label className="label">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={config.admin.username}
|
||||
onChange={e => setConfig({
|
||||
...config,
|
||||
admin: { ...config.admin, username: e.target.value }
|
||||
})}
|
||||
value={mysqlUser}
|
||||
onChange={e => setMysqlUser(e.target.value)}
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={config.admin.password}
|
||||
onChange={e => setConfig({
|
||||
...config,
|
||||
admin: { ...config.admin, password: e.target.value }
|
||||
})}
|
||||
placeholder="请输入管理员密码"
|
||||
value={mysqlPass}
|
||||
onChange={e => setMysqlPass(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</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 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>
|
||||
)
|
||||
|
||||
+22
-16
@@ -1,12 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface Props {
|
||||
onLogin: (token: string) => void
|
||||
}
|
||||
|
||||
export default function LoginPage({ onLogin }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -21,30 +20,36 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || '登录失败')
|
||||
return
|
||||
}
|
||||
|
||||
if (res.ok && data.access_token) {
|
||||
localStorage.setItem('token', data.access_token)
|
||||
onLogin(data.access_token)
|
||||
navigate('/')
|
||||
} else {
|
||||
setError(data.error || '登录失败')
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误')
|
||||
setError('网络错误,请重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container" style={{ maxWidth: '400px', marginTop: '100px' }}>
|
||||
<div className="card">
|
||||
<h1 style={{ marginBottom: '24px', textAlign: 'center' }}>登录</h1>
|
||||
<div className="container-sm">
|
||||
<div className="text-center mb-6" style={{ paddingTop: '80px' }}>
|
||||
<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}>
|
||||
<div className="form-group">
|
||||
@@ -71,15 +76,16 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
/>
|
||||
</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 ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
|
||||
+38
-21
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -12,35 +13,50 @@ export default function RegisterPage() {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('两次密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, registration_code: code }),
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || '注册失败')
|
||||
return
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
setSuccess('注册成功,请登录')
|
||||
setTimeout(() => window.location.href = '/login', 2000)
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 1500)
|
||||
} else {
|
||||
setError(data.error || '注册失败')
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误')
|
||||
setError('网络错误,请重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container" style={{ maxWidth: '400px', marginTop: '100px' }}>
|
||||
<div className="card">
|
||||
<h1 style={{ marginBottom: '24px', textAlign: 'center' }}>注册</h1>
|
||||
<div className="container-sm">
|
||||
<div className="text-center mb-6" style={{ paddingTop: '80px' }}>
|
||||
<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}>
|
||||
<div className="form-group">
|
||||
@@ -68,27 +84,28 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">注册码</label>
|
||||
<label className="label">确认密码</label>
|
||||
<input
|
||||
type="text"
|
||||
type="password"
|
||||
className="input"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
placeholder="请输入注册码"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder="请再次输入密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{success && <div className="success">{success}</div>}
|
||||
{error && <div className="error mb-4">{error}</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 ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user