"添加前端模板和运行代码模块"

This commit is contained in:
Codex
2026-06-01 13:43:09 +08:00
parent 24045274ad
commit 8092c4b1f8
587 changed files with 99761 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
# EditorConfig 配置文件
# 用于统一不同编辑器和IDE的代码风格
# 顶级配置文件
root = true
# 所有文件的通用配置
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
# Markdown 文件配置
[*.md]
trim_trailing_whitespace = false
# JSON 文件配置
[*.json]
indent_size = 2
# YAML 文件配置
[*.{yml,yaml}]
indent_size = 2
# 配置文件
[*.{js,ts,jsx,tsx,vue}]
indent_size = 2
# CSS 文件配置
[*.{css,scss,sass,less}]
indent_size = 2
# HTML 文件配置
[*.html]
indent_size = 2
# 其他配置文件
[*.{ini,conf,config}]
indent_size = 2

View File

@@ -0,0 +1,41 @@
# 依赖
node_modules/
# 构建输出
dist/
build/
*.local
# 配置文件
*.config.js
*.config.ts
vite.config.ts
# 日志
*.log
# 环境变量
.env*
# 缓存
.cache/
.parcel-cache/
# 覆盖率报告
coverage/
# 临时文件
tmp/
temp/
# 操作系统文件
.DS_Store
Thumbs.db
# 编辑器文件
.vscode/
.idea/
# 其他
*.min.js
*.min.css

View File

@@ -0,0 +1,36 @@
module.exports = {
root: true,
env: {
browser: true,
es2020: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parser: '@typescript-eslint/parser',
},
plugins: ['vue', '@typescript-eslint'],
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'vue/no-v-html': 'off',
'vue/max-attributes-per-line': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/html-self-closing': 'off',
'vue/require-default-prop': 'off',
'vue/one-component-per-file': 'off',
'vue/no-reserved-component-names': 'off',
'vue/html-indent': 'off',
'vue/html-closing-bracket-newline': 'off',
},
};

View File

@@ -0,0 +1,124 @@
# 依赖
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# 构建输出
dist/
dist-ssr/
build/
*.local
# 环境变量
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# 编辑器
.vscode/
.idea/
*.swp
*.swo
*~
# 操作系统
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# 日志
logs/
*.log
# 运行时数据
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage
.grunt
# Bower dependency directory
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons
build/Release
# Dependency directories
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,15 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"quoteProps": "as-needed",
"jsxSingleQuote": true,
"proseWrap": "preserve"
}

View File

@@ -0,0 +1,84 @@
# Vue Vite Template — Agent Contract
## Goal
Use this template with deterministic rules so AI agents can deliver features quickly with minimal guessing.
## Command Contract
Use `pnpm` only.
```bash
pnpm dev # Start dev server
pnpm build # Build production assets
pnpm type-check # TypeScript check (vue-tsc)
pnpm lint # ESLint
pnpm check # Required gate: type-check + lint
```
## Task Entry (always follow)
1. Confirm target template and feature goal in one sentence.
2. Locate the matching module area (`pages`, `components`, `lib`, `router`).
3. Reuse existing patterns from `src/examples` and `src/components/ui`.
4. Implement with framework-native style (Vue Composition API + `<script setup lang="ts">`).
5. Run `pnpm check` before finalizing.
## Isomorphic Structure Mapping
Use these module locations in this Vue template:
| Concern | Vue location |
| ------------------- | ---------------------------- |
| Route pages | `src/pages/*.vue` |
| Shared components | `src/components/**` |
| API client | `src/lib/api.ts` |
| Service helpers | `src/lib/services.ts` |
| Utilities | `src/lib/utils.ts` |
| Router registry | `src/router/index.ts` |
| Examples entry page | `src/pages/ExamplesPage.vue` |
| Examples route | `/#/examples` |
## Minimal Delivery Paths
### New page (5 steps)
1. Create `src/pages/<FeatureName>.vue`.
2. Add route in `src/router/index.ts` with path + component.
3. Use existing UI primitives from `src/components/ui`.
4. If data is required, call service functions via `src/lib/services.ts`.
5. Run `pnpm check`.
### New component (4 steps)
1. Create component under `src/components/` (or `src/components/ui/` for primitives).
2. Type all props/emits explicitly.
3. Keep styling Tailwind-first and merge classes via `cn` when needed.
4. Run `pnpm check`.
### New API flow (4 steps)
1. Define/extend endpoint calls in `src/lib/api.ts` or service module.
2. Add typed service wrapper in `src/lib/services.ts`.
3. Consume via page/component using `useApi` style pattern.
4. Run `pnpm check`.
## Allowed Changes
- `src/pages/**`, `src/components/**`, `src/lib/**`, `src/router/**`
- Template docs (`AGENTS.md`, template `README` if present)
- Template-local config required for feature delivery
## Forbidden Changes
- Do not copy `src/examples/*` into production files verbatim.
- Do not change package manager (`pnpm` is mandatory).
- Do not add parallel agent rule docs (this file is the single source).
- Do not introduce framework-mixed patterns (keep Vue idioms only).
## Pre-submit Checklist
1. `pnpm check` passes in this template.
2. If template metadata/version changed, validate related metadata files before release.
3. New route/page/API follows the mapping table and naming conventions.
4. `src/examples` remains reference-only.

View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -0,0 +1,26 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.3/schema.json",
"files": {
"includes": ["src/**/*.{js,jsx,ts,tsx,vue}"]
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"correctness": {
"noUndeclaredDependencies": "error"
},
"suspicious": {
"noRedeclare": "error"
}
}
},
"formatter": {
"enabled": false
}
}

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/style.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue3 Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{
"name": "Vue 3 + Vite",
"description": "基于 Vue 3 + Vite 的现代化前端项目模板",
"version": "1.1.3",
"author": "XAGI Team",
"features": [
"Vue 3",
"Composition API",
"Vite",
"TypeScript",
"ESLint",
"Prettier",
"SFC"
],
"keywords": ["vue", "vue3", "vite", "typescript", "frontend", "spa"],
"category": "frontend",
"framework": "vue",
"bundler": "vite",
"language": "typescript"
}

View File

@@ -0,0 +1,65 @@
{
"name": "@xagi-templates/vue3-vite",
"version": "1.2.2",
"description": "Vue 3 + Vite + TypeScript starter template for modern web apps",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:production": "NODE_ENV=production vite build",
"preview": "vite preview",
"preview:production": "NODE_ENV=production vite preview",
"lint": "eslint src --ext .ts,.vue --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint src --ext .ts,.vue --report-unused-disable-directives --max-warnings 0 --fix",
"type-check": "vue-tsc --noEmit",
"type-check:watch": "vue-tsc --noEmit --watch",
"check": "pnpm run type-check && pnpm run lint",
"clean": "rm -rf dist",
"prebuild": "pnpm run clean",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@microsoft/fetch-event-source": "2.0.1",
"@vee-validate/zod": "^4.15.1",
"axios": "^1.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.542.0",
"radix-vue": "^1.9.17",
"tailwind-merge": "^3.3.1",
"vee-validate": "^4.15.1",
"vue": "^3.3.11",
"vue-router": "^4.6.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"@vitejs/plugin-vue": "^4.5.2",
"autoprefixer": "^10.4.21",
"eslint": "8",
"eslint-plugin-vue": "^9.30.0",
"postcss": "^8.5.6",
"prettier": "^3.0.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.18",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vue-eslint-parser": "^9.4.3",
"vue-tsc": "^3.2.7"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"vue",
"vite",
"typescript",
"template",
"starter"
],
"author": "XAGI Team",
"license": "MIT"
}

View File

@@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
vue-demi: true

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,4 @@
<template>
<!-- Root shell only provides route outlet for page-level views. -->
<router-view />
</template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}`);
},
};

View File

@@ -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>

View File

@@ -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>

View 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;

View File

@@ -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);
}
}
}

View File

@@ -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);
};
}

View File

@@ -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');

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -0,0 +1,507 @@
/** @type {import('tailwindcss').Config} */
// Tailwind default palette names
const defaultColors = [
'slate',
'gray',
'zinc',
'neutral',
'stone',
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'indigo',
'violet',
'purple',
'fuchsia',
'pink',
'rose',
];
// Custom palette extension
const customColors = ['primary'];
// Shade steps
const colorShades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
// Utility prefixes that take color tokens
const colorPrefixes = [
'text',
'bg',
'border',
'ring',
'divide',
'placeholder',
'from',
'via',
'to',
'stroke',
'fill',
'accent',
'caret',
'outline',
];
// Build safelist entries for color utilities
function generateColorSafelist() {
const safelist = [];
const allColors = [...defaultColors, ...customColors];
// For each color-related prefix × palette name
colorPrefixes.forEach(prefix => {
allColors.forEach(color => {
// Shade-specific classes (e.g. text-blue-500)
colorShades.forEach(shade => {
safelist.push(`${prefix}-${color}-${shade}`);
});
// Base color token without shade
safelist.push(`${prefix}-${color}`);
});
});
// Special keywords: white, black, transparent, current
const specialColors = ['white', 'black', 'transparent', 'current'];
colorPrefixes.forEach(prefix => {
specialColors.forEach(color => {
safelist.push(`${prefix}-${color}`);
});
});
return safelist;
}
// Default Tailwind spacing scale
const spacingValues = [
0,
0.5,
1,
1.5,
2,
2.5,
3,
3.5,
4,
5,
6,
7,
8,
9,
10,
11,
12,
14,
16,
20,
24,
28,
32,
36,
40,
44,
48,
52,
56,
60,
64,
72,
80,
96,
// Extra spacing keys used in this project
18,
88,
// Keyword spacing tokens
'px',
'auto',
'full',
'screen',
];
// Padding / margin utility prefixes
const spacingPrefixes = {
padding: ['p', 'px', 'py', 'pt', 'pr', 'pb', 'pl'],
margin: ['m', 'mx', 'my', 'mt', 'mr', 'mb', 'ml'],
};
// Safelist spacing utilities
function generateSpacingSafelist() {
const safelist = [];
const allPrefixes = [...spacingPrefixes.padding, ...spacingPrefixes.margin];
// For each prefix × spacing value
allPrefixes.forEach(prefix => {
spacingValues.forEach(value => {
// Numeric spacing (p-3, m-4, fractional keys, …)
if (typeof value === 'number') {
// Numeric keys; Tailwind normalizes fractional classes
safelist.push(`${prefix}-${value}`);
} else {
// Keyword spacing (px, auto, full, screen)
safelist.push(`${prefix}-${value}`);
}
});
});
return safelist;
}
// Default border-width scale
const borderWidthValues = [0, 1, 2, 4, 8];
// Border-width utility prefixes
const borderWidthPrefixes = [
'border',
'border-t',
'border-r',
'border-b',
'border-l',
'border-x',
'border-y',
];
// Safelist border-width utilities
function generateBorderWidthSafelist() {
const safelist = [];
// For each prefix × width value
borderWidthPrefixes.forEach(prefix => {
borderWidthValues.forEach(value => {
// e.g. border-0, border-2, border-4, border-8
safelist.push(`${prefix}-${value}`);
});
});
// Plain `border` (default 1px)
safelist.push('border');
return safelist;
}
// Default border-style keywords
const borderStyleValues = [
'solid',
'dashed',
'dotted',
'double',
'hidden',
'none',
];
// Safelist border-style utilities
function generateBorderStyleSafelist() {
const safelist = [];
// border-solid, border-dashed, …
borderStyleValues.forEach(style => {
safelist.push(`border-${style}`);
});
return safelist;
}
// Default font-weight tokens
const fontWeightValues = [
'thin', // 100
'extralight', // 200
'light', // 300
'normal', // 400
'medium', // 500
'semibold', // 600
'bold', // 700
'extrabold', // 800
'black', // 900
];
// Safelist font-weight utilities
function generateFontWeightSafelist() {
const safelist = [];
// font-thin, font-light, font-bold, …
fontWeightValues.forEach(weight => {
safelist.push(`font-${weight}`);
});
return safelist;
}
// Default text-size keys
// Matches Tailwind docs: xs … 9xl
const fontSizeValues = [
'xs',
'sm',
'base',
'lg',
'xl',
'2xl',
'3xl',
'4xl',
'5xl',
'6xl',
'7xl',
'8xl',
'9xl',
];
// Safelist text-* size utilities
function generateFontSizeSafelist() {
const safelist = [];
// text-sm, text-base, text-lg, …
fontSizeValues.forEach(size => {
safelist.push(`text-${size}`);
});
return safelist;
}
// Default line-height keys
// Matches Tailwind: none … loose, numeric 310
const lineHeightValues = [
'none',
'tight',
'snug',
'normal',
'relaxed',
'loose',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
];
// Safelist leading-* utilities
function generateLineHeightSafelist() {
const safelist = [];
// leading-none, leading-tight, leading-5, …
lineHeightValues.forEach(value => {
safelist.push(`leading-${value}`);
});
return safelist;
}
// Default letter-spacing tokens
// Matches Tailwind tracking scale
const letterSpacingValues = [
'tighter',
'tight',
'normal',
'wide',
'wider',
'widest',
];
// Safelist tracking-* utilities
function generateLetterSpacingSafelist() {
const safelist = [];
// tracking-tighter, tracking-wide, …
letterSpacingValues.forEach(value => {
safelist.push(`tracking-${value}`);
});
return safelist;
}
// Default text-align keywords
// Matches Tailwind text-* alignment
const textAlignValues = ['left', 'center', 'right', 'justify', 'start', 'end'];
// Safelist text-align utilities (text-left, …)
function generateTextAlignSafelist() {
const safelist = [];
// Note: shares `text-` prefix with font sizes in safelist
textAlignValues.forEach(value => {
safelist.push(`text-${value}`);
});
return safelist;
}
// Default opacity percentage steps
// Matches Tailwind opacity scale
const opacityValues = [
0, 5, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 95, 100,
];
// Safelist opacity-* utilities
function generateOpacitySafelist() {
const safelist = [];
// opacity-0, opacity-50, opacity-100, …
opacityValues.forEach(value => {
safelist.push(`opacity-${value}`);
});
return safelist;
}
// Default border-radius keys
// Matches Tailwind rounded-* scale
const borderRadiusValues = [
'none',
'sm',
'md',
'lg',
'xl',
'2xl',
'3xl',
'full',
];
// rounded-* utility prefixes
const borderRadiusPrefixes = [
'rounded',
'rounded-t',
'rounded-r',
'rounded-b',
'rounded-l',
'rounded-tl',
'rounded-tr',
'rounded-bl',
'rounded-br',
];
// Safelist rounded utilities
function generateBorderRadiusSafelist() {
const safelist = [];
// For each rounded prefix × radius token
borderRadiusPrefixes.forEach(prefix => {
borderRadiusValues.forEach(value => {
// rounded-md, rounded-lg, rounded-tl-sm, …
safelist.push(`${prefix}-${value}`);
});
});
return safelist;
}
// Default box-shadow tokens
// Matches Tailwind shadow-* scale
const shadowValues = ['sm', 'md', 'lg', 'xl', '2xl', 'inner', 'none'];
// Safelist shadow-* utilities
function generateShadowSafelist() {
const safelist = [];
// shadow-sm, shadow-md, shadow-lg, …
shadowValues.forEach(value => {
safelist.push(`shadow-${value}`);
});
return safelist;
}
export default {
// Scan Vue and TS entry files so utility classes in SFC templates are preserved.
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
// Merge every generated utility into safelist so dynamic class names are not purged
safelist: [
...generateColorSafelist(),
...generateSpacingSafelist(),
...generateBorderWidthSafelist(),
...generateBorderStyleSafelist(),
...generateFontWeightSafelist(),
...generateFontSizeSafelist(),
...generateLineHeightSafelist(),
...generateLetterSpacingSafelist(),
...generateTextAlignSafelist(),
...generateOpacitySafelist(),
...generateBorderRadiusSafelist(),
...generateShadowSafelist(),
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['Fira Code', 'monospace'],
},
spacing: {
18: '4.5rem',
88: '22rem',
},
borderRadius: {
'4xl': '2rem',
},
boxShadow: {
soft: '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'bounce-slow': 'bounce 2s infinite',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
},
},
plugins: [
function ({ addUtilities }) {
const newUtilities = {
'.text-balance': {
'text-wrap': 'balance',
},
};
addUtilities(newUtilities);
},
],
darkMode: 'class',
};

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
// Keep the Vue SFC plugin first for standard Vue compilation.
vue(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});