初始化
This commit is contained in:
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
1732
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal 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
176
frontend/src/App.tsx
Normal 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
10
frontend/src/main.tsx
Normal 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
83
frontend/src/styles.css
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
frontend/tsconfig.app.json
Normal file
20
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
17
frontend/tsconfig.node.json
Normal file
17
frontend/tsconfig.node.json
Normal 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
16
frontend/vite.config.mjs
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user