"添加前端模板和运行代码模块"
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<!-- Root shell only provides route outlet for page-level views. -->
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
export interface AccordionItemData {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface AccordionProps {
|
||||
items: AccordionItemData[];
|
||||
}
|
||||
|
||||
defineProps<AccordionProps>();
|
||||
const openId = ref<string | null>(null);
|
||||
|
||||
function toggle(id: string): void {
|
||||
openId.value = openId.value === id ? null : id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full rounded-md border border-gray-200">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="border-b border-gray-200 last:border-b-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between p-4 text-left text-sm font-medium"
|
||||
@click="toggle(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
<span>{{ openId === item.id ? '-' : '+' }}</span>
|
||||
</button>
|
||||
<div v-if="openId === item.id" class="px-4 pb-4 text-sm text-gray-600">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle,
|
||||
} from 'radix-vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AlertDialogProps {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<AlertDialogProps>(), {
|
||||
open: false,
|
||||
title: 'Are you sure?',
|
||||
description: 'This action cannot be undone.',
|
||||
confirmText: 'Continue',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
confirm: [];
|
||||
cancel: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot :open="open" @update:open="emit('update:open', $event)">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/50" />
|
||||
<AlertDialogContent
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg bg-white p-6 shadow-lg'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="grid gap-1.5">
|
||||
<AlertDialogTitle class="text-lg font-semibold text-gray-900">{{
|
||||
title
|
||||
}}</AlertDialogTitle>
|
||||
<AlertDialogDescription class="text-sm text-gray-500">{{
|
||||
description
|
||||
}}</AlertDialogDescription>
|
||||
</div>
|
||||
<slot />
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<AlertDialogCancel
|
||||
class="inline-flex h-10 items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium hover:bg-gray-50"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
class="inline-flex h-10 items-center justify-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface AspectRatioProps {
|
||||
ratio?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<AspectRatioProps>(), {
|
||||
ratio: 16 / 9,
|
||||
});
|
||||
|
||||
const paddingBottom = computed(() => `${100 / props.ratio}%`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<div :style="{ paddingBottom }" />
|
||||
<div class="absolute inset-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
fallback?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
defineProps<AvatarProps>();
|
||||
const imageError = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="src && !imageError"
|
||||
:src="src"
|
||||
:alt="alt ?? 'avatar'"
|
||||
class="aspect-square h-full w-full"
|
||||
@error="imageError = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center rounded-full bg-gray-100 text-sm font-medium text-gray-600"
|
||||
>
|
||||
{{ fallback ?? 'U' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
destructive: 'bg-red-600 text-white hover:bg-red-700',
|
||||
outline:
|
||||
'border border-gray-300 bg-white hover:bg-gray-50 hover:text-gray-900',
|
||||
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
|
||||
ghost: 'hover:bg-gray-100 hover:text-gray-900',
|
||||
link: 'text-blue-600 underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface ButtonProps {
|
||||
variant?:
|
||||
| 'default'
|
||||
| 'destructive'
|
||||
| 'outline'
|
||||
| 'secondary'
|
||||
| 'ghost'
|
||||
| 'link';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
const className = computed(() =>
|
||||
cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :disabled="disabled" :class="className">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CardProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<CardProps>();
|
||||
const className = computed(() =>
|
||||
cn(
|
||||
'rounded-lg border border-gray-200 bg-white text-gray-950 shadow-sm',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="className">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CheckboxProps {
|
||||
modelValue?: boolean;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CheckboxProps>(), {
|
||||
modelValue: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const className = computed(() =>
|
||||
cn(
|
||||
'h-4 w-4 rounded border border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled"
|
||||
:class="className"
|
||||
@change="
|
||||
emit('update:modelValue', ($event.target as HTMLInputElement).checked)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
interface CollapsibleProps {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<CollapsibleProps>(), {
|
||||
open: false,
|
||||
title: 'Toggle section',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-md border border-gray-200 p-3">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium"
|
||||
@click="emit('update:open', !open)"
|
||||
>
|
||||
{{ title }}
|
||||
</button>
|
||||
<div v-if="open" class="mt-3">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from 'radix-vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DialogProps {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DialogProps>(), {
|
||||
open: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
|
||||
const contentClass = computed(() =>
|
||||
cn('w-full max-w-lg rounded-lg bg-white p-6 shadow-lg', props.class)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="open" @update:open="emit('update:open', $event)">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-50 bg-black/50" />
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 p-6',
|
||||
contentClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div v-if="title || description" class="grid gap-1.5">
|
||||
<DialogTitle
|
||||
v-if="title"
|
||||
class="text-lg font-semibold text-gray-900"
|
||||
>{{ title }}</DialogTitle
|
||||
>
|
||||
<DialogDescription v-if="description" class="text-sm text-gray-500">{{
|
||||
description
|
||||
}}</DialogDescription>
|
||||
</div>
|
||||
<slot />
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
} from 'radix-vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DropdownItem {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DropdownMenuProps {
|
||||
open?: boolean;
|
||||
items: DropdownItem[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DropdownMenuProps>(), {
|
||||
open: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
select: [value: string];
|
||||
}>();
|
||||
|
||||
const className = computed(() =>
|
||||
cn(
|
||||
'absolute z-50 mt-2 min-w-[10rem] rounded-md border border-gray-200 bg-white p-1 shadow-md',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot :open="open" @update:open="emit('update:open', $event)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="trigger" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent :class="className" :side-offset="6">
|
||||
<DropdownMenuItem
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
class="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm outline-none data-[highlighted]:bg-gray-100"
|
||||
:class="item.disabled ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
:disabled="item.disabled"
|
||||
@select="emit('select', item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
import { computed, defineComponent, h, inject, provide } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FormFieldContextValue {
|
||||
name: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const FORM_FIELD_CONTEXT_KEY = Symbol('form-field-context');
|
||||
|
||||
export const Form = defineComponent({
|
||||
name: 'Form',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', slots.default?.());
|
||||
},
|
||||
});
|
||||
|
||||
export const FormField = defineComponent({
|
||||
name: 'FormField',
|
||||
props: {
|
||||
name: { type: String, required: true },
|
||||
error: { type: String, default: '' },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
provide<FormFieldContextValue>(FORM_FIELD_CONTEXT_KEY, {
|
||||
name: props.name,
|
||||
error: props.error || undefined,
|
||||
});
|
||||
return () => h('div', slots.default?.());
|
||||
},
|
||||
});
|
||||
|
||||
export const FormItem = defineComponent({
|
||||
name: 'FormItem',
|
||||
props: { class: { type: String, default: '' } },
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h('div', { class: cn('space-y-2', props.class) }, slots.default?.());
|
||||
},
|
||||
});
|
||||
|
||||
export const FormLabel = defineComponent({
|
||||
name: 'FormLabel',
|
||||
props: {
|
||||
for: { type: String, default: '' },
|
||||
class: { type: String, default: '' },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const field = inject<FormFieldContextValue | null>(
|
||||
FORM_FIELD_CONTEXT_KEY,
|
||||
null
|
||||
);
|
||||
const className = computed(() =>
|
||||
cn(
|
||||
'text-sm font-medium leading-none',
|
||||
field?.error && 'text-red-500',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
return () =>
|
||||
h('label', { for: props.for, class: className.value }, slots.default?.());
|
||||
},
|
||||
});
|
||||
|
||||
export const FormControl = defineComponent({
|
||||
name: 'FormControl',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', slots.default?.());
|
||||
},
|
||||
});
|
||||
|
||||
export const FormDescription = defineComponent({
|
||||
name: 'FormDescription',
|
||||
props: { class: { type: String, default: '' } },
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'p',
|
||||
{ class: cn('text-sm text-gray-500', props.class) },
|
||||
slots.default?.()
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const FormMessage = defineComponent({
|
||||
name: 'FormMessage',
|
||||
props: {
|
||||
class: { type: String, default: '' },
|
||||
message: { type: String, default: '' },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const field = inject<FormFieldContextValue | null>(
|
||||
FORM_FIELD_CONTEXT_KEY,
|
||||
null
|
||||
);
|
||||
const body = computed(() => props.message || field?.error || '');
|
||||
return () =>
|
||||
body.value
|
||||
? h(
|
||||
'p',
|
||||
{ class: cn('text-sm font-medium text-red-500', props.class) },
|
||||
body.value || slots.default?.()
|
||||
)
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
export function useFormField(): FormFieldContextValue | null {
|
||||
return inject<FormFieldContextValue | null>(FORM_FIELD_CONTEXT_KEY, null);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export { default as Accordion } from './accordion.vue';
|
||||
export { default as AlertDialog } from './alert-dialog.vue';
|
||||
export { default as AspectRatio } from './aspect-ratio.vue';
|
||||
export { default as Avatar } from './avatar.vue';
|
||||
export { default as Button } from './button.vue';
|
||||
export { default as Card } from './card.vue';
|
||||
export { default as Checkbox } from './checkbox.vue';
|
||||
export { default as Collapsible } from './collapsible.vue';
|
||||
export { default as Dialog } from './dialog.vue';
|
||||
export { default as DropdownMenu } from './dropdown-menu.vue';
|
||||
export {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useFormField,
|
||||
} from './form';
|
||||
export { default as Input } from './input.vue';
|
||||
export { default as Label } from './label.vue';
|
||||
export { default as Menubar } from './menubar.vue';
|
||||
export { default as NavigationMenu } from './navigation-menu.vue';
|
||||
export { default as Popover } from './popover.vue';
|
||||
export { default as Progress } from './progress.vue';
|
||||
export { default as RadioGroup } from './radio-group.vue';
|
||||
export { default as ScrollArea } from './scroll-area.vue';
|
||||
export { default as Select } from './select.vue';
|
||||
export { default as Separator } from './separator.vue';
|
||||
export { default as Slider } from './slider.vue';
|
||||
export { default as Switch } from './switch.vue';
|
||||
export { default as Tabs } from './tabs.vue';
|
||||
export { default as Textarea } from './textarea.vue';
|
||||
export { default as Toggle } from './toggle.vue';
|
||||
export { default as ToggleGroup } from './toggle-group.vue';
|
||||
export { default as Tooltip } from './tooltip.vue';
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InputProps {
|
||||
modelValue?: string | number;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<InputProps>(), {
|
||||
type: 'text',
|
||||
modelValue: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const className = computed(() =>
|
||||
cn(
|
||||
'flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:class="className"
|
||||
@input="
|
||||
emit('update:modelValue', ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LabelProps {
|
||||
for?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<LabelProps>();
|
||||
|
||||
const className = computed(() =>
|
||||
cn('text-sm font-medium leading-none', props.class)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label :for="props.for" :class="className">
|
||||
<slot />
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
export interface MenubarItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface MenubarProps {
|
||||
items: MenubarItem[];
|
||||
}
|
||||
|
||||
defineProps<MenubarProps>();
|
||||
const emit = defineEmits<{ select: [value: string] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex h-10 items-center gap-1 rounded-md border border-gray-200 bg-white p-1"
|
||||
>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="rounded-sm px-3 py-1.5 text-sm hover:bg-gray-100"
|
||||
@click="emit('select', item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
export interface NavigationItem {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface NavigationMenuProps {
|
||||
items: NavigationItem[];
|
||||
}
|
||||
|
||||
defineProps<NavigationMenuProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="flex items-center gap-2 rounded-md border border-gray-200 bg-white p-2"
|
||||
>
|
||||
<a
|
||||
v-for="item in items"
|
||||
:key="item.href"
|
||||
:href="item.href"
|
||||
class="rounded px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger,
|
||||
} from 'radix-vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PopoverProps {
|
||||
open?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PopoverProps>(), {
|
||||
open: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
|
||||
const contentClass = computed(() =>
|
||||
cn(
|
||||
'absolute z-50 mt-2 w-72 rounded-md border border-gray-200 bg-white p-4 shadow-md',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot :open="open" @update:open="emit('update:open', $event)">
|
||||
<PopoverTrigger as-child>
|
||||
<slot name="trigger" />
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent :class="contentClass" :side-offset="6">
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProgressProps {
|
||||
value?: number;
|
||||
max?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ProgressProps>(), {
|
||||
value: 0,
|
||||
max: 100,
|
||||
});
|
||||
|
||||
const clamped = computed(() => Math.max(0, Math.min(props.value, props.max)));
|
||||
const width = computed(() => `${(clamped.value / props.max) * 100}%`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-gray-200',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="h-full bg-blue-600 transition-all" :style="{ width }" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface RadioOption {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface RadioGroupProps {
|
||||
modelValue?: string;
|
||||
options: RadioOption[];
|
||||
name: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<RadioGroupProps>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
|
||||
|
||||
const className = computed(() => cn('grid gap-2', props.class));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="className">
|
||||
<label
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
:class="
|
||||
option.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
:name="name"
|
||||
:value="option.value"
|
||||
:checked="modelValue === option.value"
|
||||
:disabled="option.disabled"
|
||||
class="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
@change="emit('update:modelValue', option.value)"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ScrollAreaProps {
|
||||
class?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaProps>(), {
|
||||
maxHeight: '16rem',
|
||||
});
|
||||
|
||||
const className = computed(() =>
|
||||
cn('overflow-auto rounded-md border border-gray-200', props.class)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="className" :style="{ maxHeight }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
SelectContent,
|
||||
SelectIcon,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport,
|
||||
} from 'radix-vue';
|
||||
import { Check, ChevronDown } from 'lucide-vue-next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
modelValue?: string;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SelectProps>(), {
|
||||
modelValue: '',
|
||||
placeholder: 'Select an option',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const className = computed(() =>
|
||||
cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emit('update:modelValue', String($event))"
|
||||
>
|
||||
<SelectTrigger :class="className" :disabled="disabled">
|
||||
<SelectValue :placeholder="placeholder" />
|
||||
<SelectIcon>
|
||||
<ChevronDown class="h-4 w-4 opacity-60" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
class="z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white shadow-md"
|
||||
>
|
||||
<SelectViewport class="p-1">
|
||||
<SelectItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<span
|
||||
class="absolute left-2 inline-flex h-3.5 w-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectItemIndicator>
|
||||
<Check class="h-4 w-4" />
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
<SelectItemText>{{ option.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SeparatorProps {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SeparatorProps>(), {
|
||||
orientation: 'horizontal',
|
||||
});
|
||||
|
||||
const className = computed(() =>
|
||||
cn(
|
||||
props.orientation === 'horizontal'
|
||||
? 'h-px w-full bg-gray-200'
|
||||
: 'h-full w-px bg-gray-200',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="className" role="separator" :aria-orientation="orientation" />
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SliderProps {
|
||||
modelValue?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SliderProps>(), {
|
||||
modelValue: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number] }>();
|
||||
const className = computed(() => cn('w-full', props.class));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
type="range"
|
||||
:class="className"
|
||||
:value="modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="
|
||||
emit(
|
||||
'update:modelValue',
|
||||
Number(($event.target as HTMLInputElement).value)
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SwitchProps {
|
||||
modelValue?: boolean;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SwitchProps>(), {
|
||||
modelValue: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const rootClass = computed(() =>
|
||||
cn(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
props.modelValue ? 'bg-blue-600' : 'bg-gray-300',
|
||||
props.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
|
||||
function toggle(): void {
|
||||
if (!props.disabled) emit('update:modelValue', !props.modelValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" :disabled="disabled" :class="rootClass" @click="toggle">
|
||||
<span
|
||||
class="inline-block h-5 w-5 transform rounded-full bg-white transition-transform"
|
||||
:class="modelValue ? 'translate-x-5' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'radix-vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TabItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
modelValue: string;
|
||||
tabs: TabItem[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<TabsProps>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
|
||||
|
||||
const rootClass = computed(() => cn('w-full', props.class));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot
|
||||
:model-value="modelValue"
|
||||
:class="rootClass"
|
||||
@update:model-value="emit('update:modelValue', String($event))"
|
||||
>
|
||||
<TabsList
|
||||
class="inline-flex h-10 items-center justify-center rounded-md bg-gray-100 p-1 text-gray-500"
|
||||
>
|
||||
<TabsTrigger
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:value="tab.value"
|
||||
class="inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all"
|
||||
:class="
|
||||
modelValue === tab.value
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'hover:text-gray-900'
|
||||
"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:value="tab.value"
|
||||
class="mt-2 focus:outline-none"
|
||||
force-mount
|
||||
>
|
||||
<slot :active-tab="modelValue" />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TextareaProps {
|
||||
modelValue?: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TextareaProps>(), {
|
||||
modelValue: '',
|
||||
rows: 4,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const className = computed(() =>
|
||||
cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:rows="rows"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:class="className"
|
||||
@input="
|
||||
emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import Toggle from './toggle.vue';
|
||||
|
||||
export interface ToggleOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ToggleGroupProps {
|
||||
modelValue?: string[];
|
||||
options: ToggleOption[];
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ToggleGroupProps>(), {
|
||||
modelValue: () => [],
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>();
|
||||
|
||||
function onToggle(value: string, active: boolean): void {
|
||||
if (props.multiple) {
|
||||
const next = active
|
||||
? [...props.modelValue, value]
|
||||
: props.modelValue.filter(item => item !== value);
|
||||
emit('update:modelValue', next);
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:modelValue', active ? [value] : []);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<Toggle
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:model-value="modelValue.includes(option.value)"
|
||||
@update:model-value="onToggle(option.value, $event)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</Toggle>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ToggleProps {
|
||||
modelValue?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ToggleProps>(), {
|
||||
modelValue: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>();
|
||||
|
||||
const className = computed(() =>
|
||||
cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors',
|
||||
props.modelValue
|
||||
? 'bg-gray-200 text-gray-900'
|
||||
: 'bg-transparent hover:bg-gray-100',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="className"
|
||||
@click="emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from 'radix-vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TooltipProps {
|
||||
text: string;
|
||||
class?: string;
|
||||
delayDuration?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<TooltipProps>();
|
||||
const className = computed(() =>
|
||||
cn(
|
||||
'z-50 overflow-hidden rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-md',
|
||||
props.class
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="delayDuration ?? 150">
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent :class="className" side="top" :side-offset="6">
|
||||
{{ text }}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
@@ -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>
|
||||
132
qiming-xagi-frontend-templates/packages/vue3-vite/src/lib/api.ts
Normal file
132
qiming-xagi-frontend-templates/packages/vue3-vite/src/lib/api.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number;
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private instance: AxiosInstance;
|
||||
|
||||
constructor(baseURL = '') {
|
||||
this.instance = axios.create({
|
||||
baseURL,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors(): void {
|
||||
this.instance.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => config,
|
||||
(error: AxiosError) => Promise.reject(error)
|
||||
);
|
||||
|
||||
this.instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response) {
|
||||
console.error('API Error:', error.response.status, error.message);
|
||||
} else if (error.request) {
|
||||
console.error('Network Error:', error.message);
|
||||
} else {
|
||||
console.error('Request Error:', error.message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async get<T = unknown>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.instance.get<T>(url, config);
|
||||
}
|
||||
|
||||
async post<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.instance.post<T>(url, data, config);
|
||||
}
|
||||
|
||||
async put<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.instance.put<T>(url, data, config);
|
||||
}
|
||||
|
||||
async delete<T = unknown>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.instance.delete<T>(url, config);
|
||||
}
|
||||
|
||||
async patch<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.instance.patch<T>(url, data, config);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.get<T>(url, config),
|
||||
post: <T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig
|
||||
) => apiClient.post<T>(url, data, config),
|
||||
put: <T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig
|
||||
) => apiClient.put<T>(url, data, config),
|
||||
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.delete<T>(url, config),
|
||||
patch: <T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig
|
||||
) => apiClient.patch<T>(url, data, config),
|
||||
};
|
||||
|
||||
/**
|
||||
* Unwrap common backend envelope:
|
||||
* - `{ code, data, message }` -> `data`
|
||||
* - plain payload -> payload itself
|
||||
*/
|
||||
export function extractApiData<T = unknown>(response: AxiosResponse): T {
|
||||
const payload = response.data;
|
||||
if (payload && typeof payload === 'object' && 'data' in payload) {
|
||||
return (payload as { data: T }).data;
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export default ApiClient;
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
fetchEventSource,
|
||||
type EventSourceMessage,
|
||||
} from '@microsoft/fetch-event-source';
|
||||
import { onMounted, ref, type Ref, watch, type WatchSource } from 'vue';
|
||||
import { api, extractApiData, type ApiResponse } from './api';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ListParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface ListResult<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
async getUserInfo(): Promise<User> {
|
||||
const response = await api.get<ApiResponse<User>>('/user/info');
|
||||
return extractApiData<User>(response);
|
||||
},
|
||||
async updateUserInfo(data: Partial<User>): Promise<User> {
|
||||
const response = await api.put<ApiResponse<User>>('/user/info', data);
|
||||
return extractApiData<User>(response);
|
||||
},
|
||||
};
|
||||
|
||||
export const exampleApi = {
|
||||
async getList(params: ListParams): Promise<ListResult<unknown>> {
|
||||
const response = await api.get<ApiResponse<ListResult<unknown>>>(
|
||||
'/example/list',
|
||||
{ params }
|
||||
);
|
||||
return extractApiData<ListResult<unknown>>(response);
|
||||
},
|
||||
async create(data: unknown): Promise<unknown> {
|
||||
const response = await api.post<ApiResponse<unknown>>(
|
||||
'/example/create',
|
||||
data
|
||||
);
|
||||
return extractApiData(response);
|
||||
},
|
||||
async update(id: number, data: unknown): Promise<unknown> {
|
||||
const response = await api.put<ApiResponse<unknown>>(
|
||||
`/example/update/${id}`,
|
||||
data
|
||||
);
|
||||
return extractApiData(response);
|
||||
},
|
||||
async delete(id: number): Promise<void> {
|
||||
await api.delete(`/example/delete/${id}`);
|
||||
},
|
||||
async getDetail(id: number): Promise<unknown> {
|
||||
const response = await api.get<ApiResponse<unknown>>(
|
||||
`/example/detail/${id}`
|
||||
);
|
||||
return extractApiData(response);
|
||||
},
|
||||
};
|
||||
|
||||
interface UseApiOptions {
|
||||
immediate?: boolean;
|
||||
watchSources?: WatchSource[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue-friendly async state helper aligned with react-vite semantics:
|
||||
* - loading / error state
|
||||
* - explicit execute()
|
||||
* - refetch alias
|
||||
*/
|
||||
export function useApi<T>(
|
||||
apiCall: () => Promise<T>,
|
||||
options: UseApiOptions = {}
|
||||
): {
|
||||
data: Ref<T | null>;
|
||||
loading: Ref<boolean>;
|
||||
error: Ref<Error | null>;
|
||||
execute: () => Promise<void>;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const data = ref<T | null>(null) as Ref<T | null>;
|
||||
const loading = ref(false);
|
||||
const error = ref<Error | null>(null);
|
||||
|
||||
const execute = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
data.value = await apiCall();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err : new Error('Unknown error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (options.immediate ?? true) {
|
||||
onMounted(execute);
|
||||
}
|
||||
|
||||
if (options.watchSources && options.watchSources.length > 0) {
|
||||
watch(options.watchSources, () => {
|
||||
void execute();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
execute,
|
||||
refetch: execute,
|
||||
};
|
||||
}
|
||||
|
||||
export type StreamResponse<T = unknown> = T;
|
||||
|
||||
export async function streamRequest<T>(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
onData: (response: StreamResponse<T>) => void,
|
||||
onError?: (error: Error) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetchEventSource(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
onmessage(msg: EventSourceMessage) {
|
||||
if (msg.event === 'FatalError') {
|
||||
throw new Error(msg.data);
|
||||
}
|
||||
try {
|
||||
onData(JSON.parse(msg.data) as StreamResponse<T>);
|
||||
} catch {
|
||||
console.warn('Failed to parse stream chunk:', msg.data);
|
||||
}
|
||||
},
|
||||
onerror(err) {
|
||||
if (onError) {
|
||||
onError(err as Error);
|
||||
}
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (onError) {
|
||||
onError(err as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Merge Tailwind classes and remove conflicting duplicates.
|
||||
* This keeps component class composition predictable across variants.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Format a date-like value using locale defaults. */
|
||||
export function formatDate(
|
||||
date: Date | string,
|
||||
options: Intl.DateTimeFormatOptions = {}
|
||||
): string {
|
||||
const dateValue = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
...options,
|
||||
}).format(dateValue);
|
||||
}
|
||||
|
||||
/** Debounce a function call by wait milliseconds. */
|
||||
export function debounce<T extends (...args: never[]) => void>(
|
||||
fn: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
/** Throttle a function call to at most once per limit milliseconds. */
|
||||
export function throttle<T extends (...args: never[]) => void>(
|
||||
fn: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle = false;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (inThrottle) {
|
||||
return;
|
||||
}
|
||||
|
||||
fn(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => {
|
||||
inThrottle = false;
|
||||
}, limit);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue';
|
||||
import './style.css';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import FormExample from '@/examples/form-example.vue';
|
||||
import ListPageExample from '@/examples/list-page-example.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-gray-50">
|
||||
<div class="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Vue UI Examples</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Smoke playground for the generated Vue UI library, API helpers, and
|
||||
Zod-based form flow.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
class="mb-10 rounded-lg border border-gray-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900">
|
||||
Form Example (Zod + vee-validate)
|
||||
</h2>
|
||||
<FormExample />
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900">
|
||||
List Page Example
|
||||
</h2>
|
||||
<ListPageExample />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
import { Cpu, MessageCircle, Sparkles } from 'lucide-vue-next';
|
||||
|
||||
/**
|
||||
* Step metadata mirrors the React template structure:
|
||||
* - number: visual sequence badge
|
||||
* - title: short action label
|
||||
* - description: kept for future extension and parity with source template data
|
||||
* - icon: Vue component from lucide-vue-next
|
||||
*/
|
||||
interface Step {
|
||||
number: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
// Keep wording aligned with packages/react-vite/src/pages/Home.tsx.
|
||||
const steps: Step[] = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Describe',
|
||||
description: 'Tell the assistant what you want in plain language',
|
||||
icon: MessageCircle,
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Build',
|
||||
description: 'Let AI scaffold UI and logic for you',
|
||||
icon: Cpu,
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Preview',
|
||||
description: 'See the result update as you iterate',
|
||||
icon: Sparkles,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex-1 max-w-4xl mx-auto w-full px-4 sm:px-6 py-6 sm:py-8 flex flex-col items-center justify-center"
|
||||
>
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-black mb-3">
|
||||
Smart app builder
|
||||
</h1>
|
||||
<p class="text-base sm:text-lg text-black mb-6">
|
||||
Describe your idea once and ship polished web UI faster - with help
|
||||
from your coding assistant.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Keep layout and spacing closely aligned with react-vite Home.tsx. -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg sm:text-xl font-bold text-black mb-4 text-center">
|
||||
Get started in three steps
|
||||
</h2>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-6 lg:gap-8"
|
||||
>
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div
|
||||
class="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-bold text-sm"
|
||||
>
|
||||
{{ step.number }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="step.icon" class="w-5 h-5 text-blue-600" />
|
||||
<h3 class="font-semibold text-black text-sm">{{ step.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="py-3 sm:py-4 px-4 border-t border-gray-200 bg-white/50 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row items-center justify-between gap-2 sm:gap-4"
|
||||
>
|
||||
<p class="text-xs sm:text-sm text-gray-600">
|
||||
Built for modern web development
|
||||
</p>
|
||||
<p class="text-xs sm:text-sm text-gray-600">
|
||||
This is a template page - replace it with your real experience.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<main
|
||||
class="min-h-screen grid place-content-center gap-2 text-center text-slate-800"
|
||||
>
|
||||
<h1 class="m-0 text-5xl font-bold">404</h1>
|
||||
<p class="m-0">Page not found.</p>
|
||||
<a href="#/" class="font-semibold text-blue-600 no-underline"
|
||||
>Back to home</a
|
||||
>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
import Home from '@/pages/Home.vue';
|
||||
import NotFound from '@/pages/NotFound.vue';
|
||||
import ExamplesPage from '@/pages/ExamplesPage.vue';
|
||||
|
||||
/**
|
||||
* Hash-based routing keeps navigation fully relative for static hosting:
|
||||
* - URLs look like /#/path and do not need server-side rewrite rules.
|
||||
* - The server always receives "/" while the client handles route matching.
|
||||
*/
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: NotFound,
|
||||
},
|
||||
{
|
||||
path: '/examples',
|
||||
name: 'examples',
|
||||
component: ExamplesPage,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,32 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Keep root-level defaults minimal and framework-agnostic. */
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
/* Reset body spacing and let page sections control layout. */
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
Reference in New Issue
Block a user