"添加前端模板和运行代码模块"
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* API usage reference — patterns for typed calls and `extractApiData`.
|
||||
* Agents can mirror this structure when generating API layers.
|
||||
*/
|
||||
|
||||
import { api, extractApiData } from '@/lib/api';
|
||||
|
||||
// ============================================
|
||||
// 1. Shared types (align with your backend contract)
|
||||
// ============================================
|
||||
|
||||
/** Typical wrapped API response */
|
||||
interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Paginated list payload */
|
||||
interface PaginatedResult<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** List query parameters */
|
||||
interface ListParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
/** Identifier accepted by the API */
|
||||
type ID = string | number;
|
||||
|
||||
// ============================================
|
||||
// 2. Domain entities
|
||||
// ============================================
|
||||
|
||||
interface User {
|
||||
id: ID;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
role: 'admin' | 'user';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface CreateUserParams {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role?: 'admin' | 'user';
|
||||
}
|
||||
|
||||
interface UpdateUserParams {
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. API functions
|
||||
// ============================================
|
||||
|
||||
export const userApi = {
|
||||
/** Paginated user list */
|
||||
getList: async (params: ListParams): Promise<PaginatedResult<User>> => {
|
||||
const response = await api.get<ApiResponse<PaginatedResult<User>>>(
|
||||
'/api/users',
|
||||
{ params }
|
||||
);
|
||||
return extractApiData(response);
|
||||
},
|
||||
|
||||
/** Single user by id */
|
||||
getById: async (id: ID): Promise<User> => {
|
||||
const response = await api.get<ApiResponse<User>>(`/api/users/${id}`);
|
||||
return extractApiData(response);
|
||||
},
|
||||
|
||||
/** Create user */
|
||||
create: async (data: CreateUserParams): Promise<User> => {
|
||||
const response = await api.post<ApiResponse<User>>('/api/users', data);
|
||||
return extractApiData(response);
|
||||
},
|
||||
|
||||
/** Update user */
|
||||
update: async (id: ID, data: UpdateUserParams): Promise<User> => {
|
||||
const response = await api.put<ApiResponse<User>>(`/api/users/${id}`, data);
|
||||
return extractApiData(response);
|
||||
},
|
||||
|
||||
/** Delete user */
|
||||
delete: async (id: ID): Promise<void> => {
|
||||
await api.delete(`/api/users/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 4. Usage inside a component (commented)
|
||||
// ============================================
|
||||
|
||||
/*
|
||||
import { useApi } from '@/lib/services';
|
||||
import { userApi } from '@/examples/api-example';
|
||||
|
||||
function UserListPage() {
|
||||
const { data, loading, error, refetch } = useApi(
|
||||
() => userApi.getList({ page: 1, pageSize: 10 }),
|
||||
[]
|
||||
);
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{data?.list.map(user => (
|
||||
<li key={user.id}>{user.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Form reference — React Hook Form + Zod + shadcn-style Form primitives.
|
||||
* Agents can copy patterns, not this file wholesale, into production code.
|
||||
*/
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
// ============================================
|
||||
// 1. Zod schema
|
||||
// ============================================
|
||||
|
||||
const userFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, 'Name must be at least 2 characters')
|
||||
.max(50, 'Name must be at most 50 characters'),
|
||||
email: z.string().email('Enter a valid email address'),
|
||||
role: z.enum(['admin', 'user'], { required_error: 'Select a role' }),
|
||||
agreeTerms: z
|
||||
.boolean()
|
||||
.refine(val => val === true, 'You must accept the terms'),
|
||||
});
|
||||
|
||||
type UserFormValues = z.infer<typeof userFormSchema>;
|
||||
|
||||
// ============================================
|
||||
// 2. Form component
|
||||
// ============================================
|
||||
|
||||
interface UserFormProps {
|
||||
defaultValues?: Partial<UserFormValues>;
|
||||
onSubmit: (data: UserFormValues) => void | Promise<void>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function UserFormExample({
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
loading,
|
||||
}: UserFormProps) {
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
role: undefined,
|
||||
agreeTerms: false,
|
||||
...defaultValues,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: UserFormValues) => {
|
||||
try {
|
||||
await onSubmit(data);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
console.error('Form submit failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-6'>
|
||||
{/* Text input */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Your name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='email' placeholder='you@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>We will never share your email.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Select */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='role'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Choose a role' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='user'>User</SelectItem>
|
||||
<SelectItem value='admin'>Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Checkbox */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='agreeTerms'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-start space-x-3 space-y-0'>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='space-y-1 leading-none'>
|
||||
<FormLabel>I agree to the terms and privacy policy</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' disabled={loading}>
|
||||
{loading ? 'Submitting…' : 'Submit'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserFormExample;
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* List page reference — patterns for data tables, search, and pagination.
|
||||
* Copy ideas, not this file verbatim, into production code.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useApi } from '@/lib/services';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
||||
// ============================================
|
||||
// 1. Shared types (adjust to your API contract)
|
||||
// ============================================
|
||||
|
||||
/** Paginated list payload */
|
||||
interface PaginatedResult<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** Query params for list endpoints */
|
||||
interface ListParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2. Domain model
|
||||
// ============================================
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
category: string;
|
||||
stock: number;
|
||||
}
|
||||
|
||||
// Mock fetch — swap for real `api.get` + `extractApiData`
|
||||
const mockFetchProducts = async (
|
||||
params: ListParams
|
||||
): Promise<PaginatedResult<Product>> => {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
return {
|
||||
list: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Product A',
|
||||
price: 99.99,
|
||||
category: 'Electronics',
|
||||
stock: 100,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Product B',
|
||||
price: 199.99,
|
||||
category: 'Apparel',
|
||||
stock: 50,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Product C',
|
||||
price: 49.99,
|
||||
category: 'Grocery',
|
||||
stock: 200,
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 3. Example page component
|
||||
// ============================================
|
||||
|
||||
export function ProductListExample() {
|
||||
const [params, setParams] = useState<ListParams>({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
});
|
||||
|
||||
const { data, loading, error, refetch } = useApi(
|
||||
() => mockFetchProducts(params),
|
||||
[params.page, params.pageSize, params.keyword]
|
||||
);
|
||||
|
||||
const handleSearch = (keyword: string) => {
|
||||
setParams(prev => ({ ...prev, keyword, page: 1 }));
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setParams(prev => ({ ...prev, page }));
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Delete this item?')) {
|
||||
// await productApi.delete(id);
|
||||
console.log('deleted:', id);
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='p-4 text-center text-red-500'>
|
||||
Failed to load: {error.message}
|
||||
<Button onClick={refetch} className='ml-2'>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='p-6 space-y-6'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h1 className='text-2xl font-bold'>Products</h1>
|
||||
<Button>Add product</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-4'>
|
||||
<Input
|
||||
placeholder='Search products…'
|
||||
value={params.keyword}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
className='max-w-sm'
|
||||
/>
|
||||
<Button variant='outline' onClick={refetch}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className='text-center py-8 text-gray-500'>Loading…</div>
|
||||
)}
|
||||
|
||||
{!loading && data && (
|
||||
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
{data.list.map(product => (
|
||||
<Card key={product.id} className='p-4'>
|
||||
<h3 className='font-semibold text-lg'>{product.name}</h3>
|
||||
<p className='text-gray-500 text-sm'>{product.category}</p>
|
||||
<div className='flex justify-between items-center mt-4'>
|
||||
<span className='text-blue-600 font-bold'>
|
||||
¥{product.price}
|
||||
</span>
|
||||
<span className='text-gray-400 text-sm'>
|
||||
Stock: {product.stock}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex gap-2 mt-4'>
|
||||
<Button size='sm' variant='outline'>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={() => handleDelete(product.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data?.list.length === 0 && (
|
||||
<div className='text-center py-8 text-gray-500'>No data</div>
|
||||
)}
|
||||
|
||||
{data && data.total > params.pageSize && (
|
||||
<div className='flex justify-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={params.page <= 1}
|
||||
onClick={() => handlePageChange(params.page - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className='px-4 py-2'>
|
||||
Page {params.page} / {Math.ceil(data.total / params.pageSize)}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={params.page >= Math.ceil(data.total / params.pageSize)}
|
||||
onClick={() => handlePageChange(params.page + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProductListExample;
|
||||
Reference in New Issue
Block a user