初始化

This commit is contained in:
马宝龙
2026-07-27 23:20:12 +08:00
commit 5e07c24dd4
35 changed files with 2966 additions and 0 deletions

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=/api

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Training full-stack project" />
<title>Training Project</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1732
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "training-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

176
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,176 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
type User = {
id: number
fullName: string
email: string
role: string
status: 'ACTIVE' | 'INACTIVE'
createdAt: string
}
type UserForm = Omit<User, 'id' | 'createdAt'>
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '/api'
const emptyForm: UserForm = { fullName: '', email: '', role: 'Member', status: 'ACTIVE' }
function App() {
const [users, setUsers] = useState<User[]>([])
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [search, setSearch] = useState('')
const [form, setForm] = useState<UserForm>(emptyForm)
const [editingId, setEditingId] = useState<number | null>(null)
const [isFormOpen, setIsFormOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const loadUsers = useCallback(async (value = search) => {
setIsLoading(true)
setError('')
try {
const response = await fetch(`${apiBaseUrl}/users?keyword=${encodeURIComponent(value)}&size=100`)
if (!response.ok) throw new Error('无法加载用户列表')
const data = await response.json() as { items: User[]; total: number }
setUsers(data.items)
setTotal(data.total)
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '请求失败,请稍后重试')
} finally {
setIsLoading(false)
}
}, [search])
useEffect(() => { void loadUsers() }, [loadUsers])
function openCreate() {
setEditingId(null)
setForm(emptyForm)
setError('')
setIsFormOpen(true)
}
function openEdit(user: User) {
setEditingId(user.id)
setForm({ fullName: user.fullName, email: user.email, role: user.role, status: user.status })
setError('')
setIsFormOpen(true)
}
async function submitForm(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setIsSaving(true)
setError('')
try {
const response = await fetch(`${apiBaseUrl}/users${editingId ? `/${editingId}` : ''}`, {
method: editingId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (!response.ok) {
const body = await response.json().catch(() => null) as { message?: string } | null
throw new Error(body?.message ?? '保存失败,请检查输入')
}
setIsFormOpen(false)
await loadUsers()
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '保存失败,请稍后重试')
} finally {
setIsSaving(false)
}
}
async function removeUser(user: User) {
if (!window.confirm(`确定删除用户“${user.fullName}”吗?`)) return
setError('')
try {
const response = await fetch(`${apiBaseUrl}/users/${user.id}`, { method: 'DELETE' })
if (!response.ok) throw new Error('删除失败,请稍后重试')
await loadUsers()
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '删除失败,请稍后重试')
}
}
function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSearch(keyword.trim())
}
return (
<main className="app-shell">
<header className="topbar">
<div className="brand-lockup">
<div className="brand-mark">U</div>
<div>
<p className="brand-name">Console</p>
<p className="brand-context">Operations workspace</p>
</div>
</div>
<span className="connection-status"><span className="status-dot" /> API connected</span>
</header>
<section className="content-wrap" aria-labelledby="page-title">
<div className="page-heading">
<div>
<p className="eyebrow">DIRECTORY</p>
<h1 id="page-title">User management</h1>
<p className="page-summary">Manage access, roles, and account status from one place.</p>
</div>
<button className="primary-button" type="button" onClick={openCreate}>+ Add user</button>
</div>
<div className="toolbar">
<form className="search-form" onSubmit={submitSearch}>
<label className="sr-only" htmlFor="user-search">Search users</label>
<input id="user-search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="Search by name or email" />
<button type="submit" className="secondary-button">Search</button>
</form>
<span className="result-count">{total} {total === 1 ? 'user' : 'users'}</span>
</div>
{error && <div className="error-banner" role="alert">{error}</div>}
<section className="table-frame" aria-live="polite">
{isLoading ? <div className="empty-state">Loading users...</div> : users.length === 0 ? (
<div className="empty-state"><strong>No users found</strong><span>Try another search or add a new user.</span></div>
) : (
<div className="table-scroll">
<table>
<thead><tr><th>User</th><th>Role</th><th>Status</th><th>Created</th><th><span className="sr-only">Actions</span></th></tr></thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td><div className="user-cell"><span className="avatar">{user.fullName.charAt(0).toUpperCase()}</span><span><strong>{user.fullName}</strong><small>{user.email}</small></span></div></td>
<td><span className="role-label">{user.role}</span></td>
<td><span className={`status-label ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Active' : 'Inactive'}</span></td>
<td className="muted-cell">{new Date(user.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</td>
<td><div className="row-actions"><button type="button" onClick={() => openEdit(user)}>Edit</button><button type="button" className="danger-action" onClick={() => void removeUser(user)}>Delete</button></div></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</section>
{isFormOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setIsFormOpen(false) }}>
<section className="modal" role="dialog" aria-modal="true" aria-labelledby="form-title">
<div className="modal-heading"><div><p className="eyebrow">USER RECORD</p><h2 id="form-title">{editingId ? 'Edit user' : 'Add user'}</h2></div><button className="close-button" type="button" aria-label="Close" onClick={() => setIsFormOpen(false)}>×</button></div>
<form onSubmit={submitForm}>
<label>Full name<input required maxLength={80} value={form.fullName} onChange={(event) => setForm({ ...form, fullName: event.target.value })} placeholder="e.g. Alex Morgan" /></label>
<label>Email address<input required type="email" maxLength={160} value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} placeholder="alex@company.com" /></label>
<div className="form-grid"><label>Role<select value={form.role} onChange={(event) => setForm({ ...form, role: event.target.value })}><option>Admin</option><option>Manager</option><option>Member</option><option>Viewer</option></select></label><label>Status<select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as UserForm['status'] })}><option value="ACTIVE">Active</option><option value="INACTIVE">Inactive</option></select></label></div>
<div className="modal-actions"><button type="button" className="secondary-button" onClick={() => setIsFormOpen(false)}>Cancel</button><button disabled={isSaving} type="submit" className="primary-button">{isSaving ? 'Saving...' : editingId ? 'Save changes' : 'Create user'}</button></div>
</form>
</section>
</div>
)}
</main>
)
}
export default App

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

83
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,83 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #16222b;
background: #f5f7f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input, select { font: inherit; }
button { cursor: pointer; }
.app-shell { min-height: 100vh; }
.topbar { height: 72px; padding: 0 5vw; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e1e7ea; background: #fff; }
.brand-lockup, .connection-status, .user-cell, .row-actions { display: flex; align-items: center; }
.brand-lockup { gap: 11px; }
.brand-mark { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 7px; background: #176b73; color: white; font-weight: 800; }
.brand-name, .brand-context { margin: 0; }
.brand-name { font-weight: 750; letter-spacing: .01em; }
.brand-context { margin-top: 2px; color: #86939a; font-size: 11px; }
.connection-status { gap: 7px; color: #6c7c84; font-size: 12px; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #33947c; }
.content-wrap { width: min(1180px, 90vw); margin: 0 auto; padding: 62px 0 80px; }
.page-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 42px; }
.eyebrow { margin: 0 0 10px; color: #176b73; font-size: 11px; font-weight: 800; letter-spacing: .16em; }
h1, h2 { margin: 0; letter-spacing: -.025em; }
h1 { font-size: clamp(30px, 4vw, 43px); line-height: 1.1; }
h2 { font-size: 25px; }
.page-summary { margin: 13px 0 0; color: #728087; font-size: 15px; }
.primary-button, .secondary-button { min-height: 40px; padding: 0 16px; border: 1px solid transparent; border-radius: 5px; font-size: 13px; font-weight: 700; transition: .15s ease; }
.primary-button { background: #176b73; color: white; }
.primary-button:hover { background: #11575e; }
.primary-button:disabled { cursor: wait; opacity: .6; }
.secondary-button { border-color: #d5dfe3; background: #fff; color: #32444d; }
.secondary-button:hover { border-color: #9fb3b8; background: #f8fafb; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.search-form { display: flex; gap: 8px; width: min(490px, 100%); }
input, select { width: 100%; height: 42px; padding: 0 12px; border: 1px solid #d5dfe3; border-radius: 5px; outline: 0; background: white; color: #16222b; }
input:focus, select:focus { border-color: #4b9aa0; box-shadow: 0 0 0 3px rgb(23 107 115 / 10%); }
.result-count { color: #859299; font-size: 13px; }
.error-banner { margin: 16px 0; padding: 12px 14px; border: 1px solid #f0c7c4; border-radius: 5px; background: #fff5f4; color: #ae3f3c; font-size: 13px; }
.table-frame { overflow: hidden; min-height: 220px; border: 1px solid #e0e7e9; border-radius: 7px; background: white; box-shadow: 0 8px 28px rgb(39 59 68 / 4%); }
.table-scroll { overflow-x: auto; }
table { width: 100%; min-width: 760px; border-collapse: collapse; }
th, td { padding: 16px 20px; border-bottom: 1px solid #edf1f2; text-align: left; }
th { color: #819097; font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #fbfcfc; }
.user-cell { gap: 11px; min-width: 240px; }
.avatar { display: grid; width: 33px; height: 33px; flex: 0 0 auto; place-items: center; border-radius: 50%; background: #dcefee; color: #176b73; font-size: 13px; font-weight: 800; }
.user-cell strong, .user-cell small { display: block; }
.user-cell strong { font-size: 14px; }
.user-cell small { margin-top: 3px; color: #859299; font-size: 12px; }
.role-label { color: #4b5b63; font-size: 13px; }
.status-label { display: inline-block; padding: 5px 9px; border-radius: 99px; font-size: 11px; font-weight: 800; }
.status-label.active { background: #e3f4ed; color: #21775e; }
.status-label.inactive { background: #edf0f1; color: #6e7b80; }
.muted-cell { color: #819097; font-size: 13px; white-space: nowrap; }
.row-actions { justify-content: end; gap: 14px; }
.row-actions button { padding: 0; border: 0; background: none; color: #176b73; font-size: 13px; font-weight: 700; }
.row-actions .danger-action { color: #b5534d; }
.empty-state { min-height: 220px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #8a989e; font-size: 13px; }
.empty-state strong { color: #46565e; font-size: 15px; }
.modal-backdrop { position: fixed; inset: 0; display: grid; place-items: center; padding: 20px; background: rgb(17 32 39 / 42%); }
.modal { width: min(100%, 480px); padding: 28px; border-radius: 8px; background: white; box-shadow: 0 20px 55px rgb(17 32 39 / 20%); }
.modal-heading { display: flex; justify-content: space-between; align-items: start; margin-bottom: 26px; }
.close-button { padding: 0; border: 0; background: none; color: #87949a; font-size: 26px; line-height: 1; }
.modal form { display: grid; gap: 17px; }
.modal label { display: grid; gap: 7px; color: #53636b; font-size: 12px; font-weight: 750; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.modal-actions { display: flex; justify-content: end; gap: 9px; margin-top: 9px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 640px) {
.topbar { padding: 0 5vw; }
.connection-status { display: none; }
.content-wrap { width: 90vw; padding-top: 42px; }
.page-heading { align-items: start; flex-direction: column; margin-bottom: 30px; }
.page-heading .primary-button { width: 100%; }
.toolbar { align-items: stretch; flex-direction: column; }
.search-form { width: 100%; }
.result-count { align-self: end; }
.modal { padding: 22px; }
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"allowJs": true,
"checkJs": false,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.mjs"]
}

16
frontend/vite.config.mjs Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '127.0.0.1',
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})