20260810172235
This commit is contained in:
1 parent
c4891538a7
commit
ed5ab3885d
35 files changed
+1314
-653
No files matched your search
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useTimeoutFn } from "@vueuse/core";
|
||||
import { Check, ChevronDown, LoaderCircle, X } from "@lucide/vue";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLookupOptions } from "@/composables/useLookupOptions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
viewName: string;
|
||||
labelKey?: string;
|
||||
valueKey?: string;
|
||||
searchCondition?: string;
|
||||
orderBy?: string;
|
||||
searchColumns?: string[];
|
||||
pageSize?: number;
|
||||
searchDelay?: number;
|
||||
allowClear?: boolean;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
labelKey: "b_name",
|
||||
valueKey: "b_id",
|
||||
orderBy: "",
|
||||
searchCondition: "",
|
||||
pageSize: 20,
|
||||
searchDelay: 300,
|
||||
allowClear: true,
|
||||
placeholder: "",
|
||||
},
|
||||
);
|
||||
|
||||
const modelValue = defineModel<string>({ default: "" });
|
||||
const emit = defineEmits<{ change: [record: Record<string, any> | null] }>();
|
||||
|
||||
const { rows, loading, search, immediate, loadMore, reset } = useLookupOptions({
|
||||
viewName: props.viewName,
|
||||
labelKey: props.labelKey,
|
||||
valueKey: props.valueKey,
|
||||
searchCondition: props.searchCondition,
|
||||
orderBy: props.orderBy,
|
||||
searchColumns: props.searchColumns,
|
||||
pageSize: props.pageSize,
|
||||
searchDelay: props.searchDelay,
|
||||
});
|
||||
|
||||
const open = ref(false);
|
||||
const keyword = ref("");
|
||||
const highlighted = ref(-1);
|
||||
const hasClearValue = computed(() => Boolean(modelValue.value || keyword.value));
|
||||
|
||||
const { start: startBlurTimer, stop: stopBlurTimer } = useTimeoutFn(
|
||||
() => {
|
||||
open.value = false;
|
||||
reset();
|
||||
},
|
||||
150,
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
const options = computed(() =>
|
||||
rows.value.map((row: any) => {
|
||||
const label = String(row[props.labelKey] ?? "");
|
||||
return { value: label, label, record: row };
|
||||
}),
|
||||
);
|
||||
|
||||
function onFocus() {
|
||||
stopBlurTimer();
|
||||
open.value = true;
|
||||
// 输入框保留当前值,但拉全量列表
|
||||
keyword.value = modelValue.value;
|
||||
highlighted.value = -1;
|
||||
immediate("");
|
||||
}
|
||||
|
||||
function onInputClick() {
|
||||
if (open.value) return;
|
||||
onFocus();
|
||||
}
|
||||
|
||||
function onInput(v: string | number) {
|
||||
keyword.value = String(v);
|
||||
modelValue.value = keyword.value;
|
||||
highlighted.value = -1;
|
||||
if (!open.value) open.value = true;
|
||||
search(keyword.value);
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
startBlurTimer();
|
||||
}
|
||||
|
||||
function select(opt: { value: string; label: string; record: Record<string, any> }) {
|
||||
stopBlurTimer();
|
||||
modelValue.value = opt.label;
|
||||
keyword.value = "";
|
||||
open.value = false;
|
||||
reset();
|
||||
emit("change", opt.record);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
stopBlurTimer();
|
||||
modelValue.value = "";
|
||||
keyword.value = "";
|
||||
open.value = false;
|
||||
reset();
|
||||
emit("change", null);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !open.value) {
|
||||
e.preventDefault();
|
||||
onFocus();
|
||||
return;
|
||||
}
|
||||
if (!open.value || !options.value.length) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
highlighted.value = Math.min(highlighted.value + 1, options.value.length - 1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
highlighted.value = Math.max(highlighted.value - 1, 0);
|
||||
} else if (e.key === "Enter" && highlighted.value >= 0) {
|
||||
e.preventDefault();
|
||||
select(options.value[highlighted.value]!);
|
||||
} else if (e.key === "Escape") {
|
||||
open.value = false;
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target || loading.value) return;
|
||||
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 24) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
watch(modelValue, (v) => {
|
||||
if (!v) keyword.value = "";
|
||||
});
|
||||
|
||||
watch(options, (list) => {
|
||||
if (!open.value || !list.length) {
|
||||
highlighted.value = -1;
|
||||
return;
|
||||
}
|
||||
if (highlighted.value < 0 || highlighted.value >= list.length) {
|
||||
highlighted.value = 0;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group relative w-full">
|
||||
<Input
|
||||
:model-value="open ? keyword : modelValue"
|
||||
class="w-full pr-8"
|
||||
:placeholder="placeholder"
|
||||
@focus="onFocus"
|
||||
@click="onInputClick"
|
||||
@update:model-value="onInput"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<Button
|
||||
v-if="allowClear && hasClearValue && !loading"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="pointer-events-none absolute right-1 top-1/2 size-6 -translate-y-1/2 cursor-pointer opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"
|
||||
@pointerdown.prevent="clear"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</Button>
|
||||
<LoaderCircle
|
||||
v-if="loading"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
<ChevronDown
|
||||
v-else
|
||||
class="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
:class="allowClear && hasClearValue ? 'transition-opacity group-hover:opacity-0' : ''"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="open"
|
||||
class="absolute left-0 top-full z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||
>
|
||||
<div role="listbox" class="max-h-[200px] overflow-y-auto" @scroll.passive="handleScroll">
|
||||
<div
|
||||
v-if="!loading && !options.length"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无数据
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(opt, i) in options"
|
||||
:key="opt.value"
|
||||
role="option"
|
||||
:aria-selected="opt.value === modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm',
|
||||
i === highlighted && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
@mouseenter="highlighted = i"
|
||||
@mousedown.prevent="select(opt)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ opt.label }}</span>
|
||||
<span class="inline-flex size-4 shrink-0 items-center justify-center">
|
||||
<Check v-if="opt.value === modelValue" class="size-4 text-primary" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useTimeoutFn } from "@vueuse/core";
|
||||
import { Check, ChevronDown, LoaderCircle, X } from "@lucide/vue";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLookupOptions } from "@/composables/useLookupOptions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
viewName: string;
|
||||
label?: string;
|
||||
labelKey?: string;
|
||||
valueKey?: string;
|
||||
searchCondition?: string;
|
||||
orderBy?: string;
|
||||
searchColumns?: string[];
|
||||
pageSize?: number;
|
||||
searchDelay?: number;
|
||||
allowClear?: boolean;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
label: "",
|
||||
labelKey: "b_name",
|
||||
valueKey: "b_id",
|
||||
orderBy: "",
|
||||
searchCondition: "",
|
||||
pageSize: 20,
|
||||
searchDelay: 300,
|
||||
allowClear: false,
|
||||
placeholder: "",
|
||||
},
|
||||
);
|
||||
|
||||
const modelValue = defineModel<string>({ default: "" });
|
||||
const modelLabel = defineModel<string>("label", { default: "" });
|
||||
|
||||
const emit = defineEmits<{ change: [record: Record<string, any> | null] }>();
|
||||
|
||||
const { rows, loading, search, immediate, loadMore, reset } = useLookupOptions({
|
||||
viewName: props.viewName,
|
||||
labelKey: props.labelKey,
|
||||
valueKey: props.valueKey,
|
||||
searchCondition: props.searchCondition,
|
||||
orderBy: props.orderBy,
|
||||
searchColumns: props.searchColumns,
|
||||
pageSize: props.pageSize,
|
||||
searchDelay: props.searchDelay,
|
||||
});
|
||||
|
||||
const open = ref(false);
|
||||
const keyword = ref("");
|
||||
const highlighted = ref(-1);
|
||||
const hasClearValue = computed(() => Boolean(modelValue.value || keyword.value));
|
||||
|
||||
const { start: startBlurTimer, stop: stopBlurTimer } = useTimeoutFn(
|
||||
() => {
|
||||
open.value = false;
|
||||
reset();
|
||||
},
|
||||
150,
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
const displayLabel = computed(() => modelLabel.value || props.label || "");
|
||||
|
||||
const options = computed(() =>
|
||||
rows.value.map((row: any) => {
|
||||
const value = String(row[props.valueKey] ?? "");
|
||||
const label = String(row[props.labelKey] ?? "");
|
||||
return { value, label, record: row };
|
||||
}),
|
||||
);
|
||||
|
||||
const displays = computed(() => {
|
||||
const list = [...options.value];
|
||||
const extLabel = props.label || modelLabel.value;
|
||||
const extValue = modelValue.value;
|
||||
if (extValue && extLabel && !list.some((o) => o.value === extValue)) {
|
||||
list.unshift({
|
||||
value: extValue,
|
||||
label: extLabel,
|
||||
record: { [props.valueKey]: extValue, [props.labelKey]: extLabel },
|
||||
});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function onFocus() {
|
||||
stopBlurTimer();
|
||||
open.value = true;
|
||||
// Keep the selected label visible and use an empty query for the full list.
|
||||
keyword.value = displayLabel.value;
|
||||
highlighted.value = -1;
|
||||
immediate("");
|
||||
}
|
||||
|
||||
function onInputClick() {
|
||||
if (open.value) return;
|
||||
onFocus();
|
||||
}
|
||||
|
||||
function onInput(v: string | number) {
|
||||
keyword.value = String(v);
|
||||
highlighted.value = -1;
|
||||
if (!open.value) open.value = true;
|
||||
if (!keyword.value) {
|
||||
const hadSelection = Boolean(modelValue.value || modelLabel.value);
|
||||
modelValue.value = "";
|
||||
modelLabel.value = "";
|
||||
if (hadSelection) emit("change", null);
|
||||
immediate("");
|
||||
return;
|
||||
}
|
||||
search(keyword.value);
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
startBlurTimer();
|
||||
}
|
||||
|
||||
function select(opt: { value: string; label: string; record: Record<string, any> }) {
|
||||
stopBlurTimer();
|
||||
modelValue.value = opt.value;
|
||||
modelLabel.value = opt.label;
|
||||
keyword.value = "";
|
||||
open.value = false;
|
||||
reset();
|
||||
emit("change", opt.record);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
stopBlurTimer();
|
||||
modelValue.value = "";
|
||||
modelLabel.value = "";
|
||||
keyword.value = "";
|
||||
open.value = false;
|
||||
reset();
|
||||
emit("change", null);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const list = displays.value;
|
||||
if (e.key === "Enter" && !open.value) {
|
||||
e.preventDefault();
|
||||
onFocus();
|
||||
return;
|
||||
}
|
||||
if (!open.value || !list.length) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
highlighted.value = Math.min(highlighted.value + 1, list.length - 1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
highlighted.value = Math.max(highlighted.value - 1, 0);
|
||||
} else if (e.key === "Enter" && highlighted.value >= 0) {
|
||||
e.preventDefault();
|
||||
select(list[highlighted.value]!);
|
||||
} else if (e.key === "Escape") {
|
||||
open.value = false;
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target || loading.value) return;
|
||||
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 24) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
watch(displays, (list) => {
|
||||
if (!open.value || !list.length) {
|
||||
highlighted.value = -1;
|
||||
return;
|
||||
}
|
||||
if (highlighted.value < 0 || highlighted.value >= list.length) {
|
||||
highlighted.value = 0;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group relative w-full">
|
||||
<Input
|
||||
:model-value="open ? keyword : displayLabel"
|
||||
class="w-full pr-8"
|
||||
:placeholder="placeholder"
|
||||
@focus="onFocus"
|
||||
@click="onInputClick"
|
||||
@update:model-value="onInput"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<Button
|
||||
v-if="allowClear && hasClearValue && !loading"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="pointer-events-none absolute right-1 top-1/2 size-6 -translate-y-1/2 cursor-pointer opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"
|
||||
@pointerdown.prevent="clear"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</Button>
|
||||
<LoaderCircle
|
||||
v-if="loading"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
<ChevronDown
|
||||
v-else
|
||||
class="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
:class="allowClear && hasClearValue ? 'transition-opacity group-hover:opacity-0' : ''"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="open"
|
||||
class="absolute left-0 top-full z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md"
|
||||
>
|
||||
<div role="listbox" class="max-h-[200px] overflow-y-auto" @scroll.passive="handleScroll">
|
||||
<div
|
||||
v-if="!loading && !displays.length"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无数据
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(opt, i) in displays"
|
||||
:key="opt.value"
|
||||
role="option"
|
||||
:aria-selected="opt.value === modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm',
|
||||
i === highlighted && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
@mouseenter="highlighted = i"
|
||||
@mousedown.prevent="select(opt)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ opt.label }}</span>
|
||||
<span class="inline-flex size-4 shrink-0 items-center justify-center">
|
||||
<Check v-if="opt.value === modelValue" class="size-4 text-primary" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,126 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Building2, KeyRound, User } from "@lucide/vue"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
orgId?: string
|
||||
userId?: string
|
||||
password?: string
|
||||
submitting?: boolean
|
||||
error?: string
|
||||
hint?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:orgId', v: string): void
|
||||
(e: 'update:userId', v: string): void
|
||||
(e: 'update:password', v: string): void
|
||||
(e: 'submit'): void
|
||||
}>()
|
||||
|
||||
function onOrgIdInput(e: Event) {
|
||||
emit('update:orgId', (e.target as HTMLInputElement).value)
|
||||
}
|
||||
function onUserIdInput(e: Event) {
|
||||
emit('update:userId', (e.target as HTMLInputElement).value)
|
||||
}
|
||||
function onPasswordInput(e: Event) {
|
||||
emit('update:password', (e.target as HTMLInputElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form :class="cn('flex flex-col gap-6', props.class)" @submit.prevent="$emit('submit')">
|
||||
<FieldGroup>
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<h1 class="text-2xl font-bold">登录</h1>
|
||||
<p class="text-muted-foreground text-sm text-balance">
|
||||
输入登录信息以进入系统
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Hint banner -->
|
||||
<div
|
||||
v-if="hint"
|
||||
class="rounded-md bg-amber-50 px-4 py-2.5 text-center text-sm text-amber-700 dark:bg-amber-950 dark:text-amber-300"
|
||||
>
|
||||
{{ hint }}
|
||||
</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="rounded-md bg-red-50 px-4 py-2.5 text-center text-sm text-red-700 dark:bg-red-950 dark:text-red-300"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="orgId">机构码</FieldLabel>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-3 flex items-center text-muted-foreground">
|
||||
<Building2 class="size-4" />
|
||||
</span>
|
||||
<Input
|
||||
id="orgId"
|
||||
:value="orgId"
|
||||
placeholder="请输入机构码"
|
||||
class="pl-9"
|
||||
:disabled="submitting"
|
||||
@input="onOrgIdInput"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="userId">账号</FieldLabel>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-3 flex items-center text-muted-foreground">
|
||||
<User class="size-4" />
|
||||
</span>
|
||||
<Input
|
||||
id="userId"
|
||||
:value="userId"
|
||||
placeholder="请输入账号"
|
||||
class="pl-9"
|
||||
:disabled="submitting"
|
||||
@input="onUserIdInput"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="password">密码</FieldLabel>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-3 flex items-center text-muted-foreground">
|
||||
<KeyRound class="size-4" />
|
||||
</span>
|
||||
<Input
|
||||
id="password"
|
||||
:value="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
class="pl-9"
|
||||
:disabled="submitting"
|
||||
@input="onPasswordInput"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Button type="submit" :disabled="submitting" class="w-full cursor-pointer">
|
||||
{{ submitting ? "登录中..." : "登录" }}
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</template>
|
||||
@@ -1,89 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
|
||||
import { GalleryVerticalEnd } from "@lucide/vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-6', props.class)">
|
||||
<form>
|
||||
<FieldGroup>
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<a
|
||||
href="#"
|
||||
class="flex flex-col items-center gap-2 font-medium"
|
||||
>
|
||||
<div class="flex size-8 items-center justify-center rounded-md">
|
||||
<GalleryVerticalEnd class="size-6" />
|
||||
</div>
|
||||
<span class="sr-only">Acme Inc.</span>
|
||||
</a>
|
||||
<h1 class="text-xl font-bold">
|
||||
Welcome to Acme Inc.
|
||||
</h1>
|
||||
<FieldDescription>
|
||||
Don't have an account?
|
||||
<a href="#">
|
||||
Sign up
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel for="email">
|
||||
Email
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit">
|
||||
Login
|
||||
</Button>
|
||||
</Field>
|
||||
<FieldSeparator>Or</FieldSeparator>
|
||||
<Field class="grid gap-4 sm:grid-cols-2">
|
||||
<Button variant="outline" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Apple
|
||||
</Button>
|
||||
<Button variant="outline" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<FieldDescription class="px-6 text-center">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormContext, GenericSchema } from "vee-validate"
|
||||
import { useForm } from "vee-validate"
|
||||
|
||||
interface FormProps<T extends GenericSchema = GenericSchema> {
|
||||
form?: FormContext<T>
|
||||
validationSchema?: T
|
||||
initialValues?: Record<string, unknown>
|
||||
keepValuesOnUnmount?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<FormProps>()
|
||||
const emit = defineEmits<{
|
||||
submit: [values: Record<string, unknown>]
|
||||
}>()
|
||||
|
||||
const form = props.form ?? useForm({
|
||||
validationSchema: props.validationSchema,
|
||||
initialValues: props.initialValues,
|
||||
keepValuesOnUnmount: props.keepValuesOnUnmount,
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
emit("submit", values)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form data-slot="form" @submit="onSubmit">
|
||||
<slot />
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Field } from "vee-validate"
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Field v-slot="{ field, errors: fieldErrors, meta }" v-bind="$attrs">
|
||||
<slot v-bind="{ componentField: field, errors: fieldErrors, meta }" />
|
||||
</Field>
|
||||
</template>
|
||||
@@ -1,7 +1,8 @@
|
||||
export { default as Form } from "./Form.vue"
|
||||
export { default as FormField } from "./FormField.vue"
|
||||
export { default as FormControl } from "./FormControl.vue"
|
||||
export { default as FormDescription } from "./FormDescription.vue"
|
||||
export { default as FormItem } from "./FormItem.vue"
|
||||
export { default as FormLabel } from "./FormLabel.vue"
|
||||
export { default as FormMessage } from "./FormMessage.vue"
|
||||
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
|
||||
export { Form, Field as FormField, FieldArray as FormFieldArray } from "vee-validate"
|
||||
Reference in new issue
Block a user