20260810172235

This commit is contained in:
oneao committed 2026-08-10 17:22:36 +08:00
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"