"添加前端模板和运行代码模块"
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* API usage reference for Vue template.
|
||||
* Mirrors react-vite conventions while keeping Vue-friendly consumption.
|
||||
*/
|
||||
import { api, extractApiData, type ApiResponse } from '@/lib/api';
|
||||
|
||||
type ID = string | number;
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ListParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: ID;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
role: 'admin' | 'user';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateUserParams {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role?: 'admin' | 'user';
|
||||
}
|
||||
|
||||
export interface UpdateUserParams {
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
async getList(params: ListParams): Promise<PaginatedResult<User>> {
|
||||
const response = await api.get<ApiResponse<PaginatedResult<User>>>(
|
||||
'/api/users',
|
||||
{ params }
|
||||
);
|
||||
return extractApiData<PaginatedResult<User>>(response);
|
||||
},
|
||||
async getById(id: ID): Promise<User> {
|
||||
const response = await api.get<ApiResponse<User>>(`/api/users/${id}`);
|
||||
return extractApiData<User>(response);
|
||||
},
|
||||
async create(data: CreateUserParams): Promise<User> {
|
||||
const response = await api.post<ApiResponse<User>>('/api/users', data);
|
||||
return extractApiData<User>(response);
|
||||
},
|
||||
async update(id: ID, data: UpdateUserParams): Promise<User> {
|
||||
const response = await api.put<ApiResponse<User>>(`/api/users/${id}`, data);
|
||||
return extractApiData<User>(response);
|
||||
},
|
||||
async delete(id: ID): Promise<void> {
|
||||
await api.delete(`/api/users/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useForm } from 'vee-validate';
|
||||
import { toTypedSchema } from '@vee-validate/zod';
|
||||
import { z } from 'zod';
|
||||
import Button from '@/components/ui/button.vue';
|
||||
import Input from '@/components/ui/input.vue';
|
||||
import Select from '@/components/ui/select.vue';
|
||||
import Checkbox from '@/components/ui/checkbox.vue';
|
||||
import Label from '@/components/ui/label.vue';
|
||||
|
||||
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(value => value === true, 'You must accept the terms'),
|
||||
});
|
||||
|
||||
type UserFormValues = z.infer<typeof userFormSchema>;
|
||||
|
||||
const roleOptions = [
|
||||
{ label: 'User', value: 'user' },
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
];
|
||||
|
||||
const { defineField, errors, handleSubmit, resetForm, isSubmitting } =
|
||||
useForm<UserFormValues>({
|
||||
validationSchema: toTypedSchema(userFormSchema),
|
||||
initialValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
role: undefined,
|
||||
agreeTerms: false,
|
||||
},
|
||||
});
|
||||
|
||||
const [name] = defineField('name');
|
||||
const [email] = defineField('email');
|
||||
const [role] = defineField('role');
|
||||
const [agreeTerms] = defineField('agreeTerms');
|
||||
|
||||
const submitLabel = computed(() =>
|
||||
isSubmitting.value ? 'Submitting...' : 'Submit'
|
||||
);
|
||||
|
||||
const onSubmit = handleSubmit(async values => {
|
||||
console.log('Form values:', values);
|
||||
resetForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="space-y-6 p-6 rounded-lg border border-gray-200 bg-white"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Name</Label>
|
||||
<Input id="name" v-model="name" placeholder="Your name" />
|
||||
<p v-if="errors.name" class="text-sm font-medium text-red-500">
|
||||
{{ errors.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<p class="text-sm text-gray-500">We will never share your email.</p>
|
||||
<p v-if="errors.email" class="text-sm font-medium text-red-500">
|
||||
{{ errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="role">Role</Label>
|
||||
<Select
|
||||
id="role"
|
||||
v-model="role"
|
||||
:options="roleOptions"
|
||||
placeholder="Choose a role"
|
||||
/>
|
||||
<p v-if="errors.role" class="text-sm font-medium text-red-500">
|
||||
{{ errors.role }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<Checkbox id="agreeTerms" v-model="agreeTerms" class="mt-1" />
|
||||
<div class="space-y-1 leading-none">
|
||||
<Label for="agreeTerms">I agree to the terms and privacy policy</Label>
|
||||
<p v-if="errors.agreeTerms" class="text-sm font-medium text-red-500">
|
||||
{{ errors.agreeTerms }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="isSubmitting">{{ submitLabel }}</Button>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Button from '@/components/ui/button.vue';
|
||||
import Card from '@/components/ui/card.vue';
|
||||
import Input from '@/components/ui/input.vue';
|
||||
import { useApi } from '@/lib/services';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
category: string;
|
||||
stock: number;
|
||||
}
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const keyword = ref('');
|
||||
|
||||
const mockFetchProducts = async (): Promise<PaginatedResult<Product>> => {
|
||||
await new Promise(resolve => setTimeout(resolve, 350));
|
||||
|
||||
const source: Product[] = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = source.filter(item =>
|
||||
item.name.toLowerCase().includes(keyword.value.toLowerCase())
|
||||
);
|
||||
|
||||
return {
|
||||
list: filtered,
|
||||
total: filtered.length,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
};
|
||||
|
||||
const { data, loading, error, refetch } = useApi(mockFetchProducts, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
watch([page, pageSize, keyword], () => {
|
||||
void refetch();
|
||||
});
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (!data.value || data.value.total === 0) return 1;
|
||||
return Math.ceil(data.value.total / pageSize.value);
|
||||
});
|
||||
|
||||
function nextPage(): void {
|
||||
if (page.value < totalPages.value) {
|
||||
page.value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage(): void {
|
||||
if (page.value > 1) {
|
||||
page.value -= 1;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Products</h1>
|
||||
<Button>Add product</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<Input
|
||||
v-model="keyword"
|
||||
placeholder="Search products..."
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" @click="refetch">Refresh</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="p-4 text-center text-red-500">
|
||||
Failed to load: {{ error.message }}
|
||||
<Button class="ml-2" @click="refetch">Retry</Button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="text-center py-8 text-gray-500">
|
||||
Loading...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="data && data.list.length > 0"
|
||||
class="grid gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<Card v-for="product in data.list" :key="product.id" class="p-4">
|
||||
<h3 class="font-semibold text-lg">{{ product.name }}</h3>
|
||||
<p class="text-gray-500 text-sm">{{ product.category }}</p>
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<span class="text-blue-600 font-bold">${{ product.price }}</span>
|
||||
<span class="text-gray-400 text-sm">Stock: {{ product.stock }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button size="sm" variant="outline">Edit</Button>
|
||||
<Button size="sm" variant="destructive">Delete</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-8 text-gray-500">No data</div>
|
||||
|
||||
<div v-if="data && data.total > pageSize" class="flex justify-center gap-2">
|
||||
<Button variant="outline" :disabled="page <= 1" @click="prevPage"
|
||||
>Previous</Button
|
||||
>
|
||||
<span class="px-4 py-2">Page {{ page }} / {{ totalPages }}</span>
|
||||
<Button variant="outline" :disabled="page >= totalPages" @click="nextPage"
|
||||
>Next</Button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user